Andrey Antukh a16d40b954 🎉 Add semantic file projection RPC and analysis prototype
Add a main RPC command, get-semantic-file, that turns a Penpot file
into a small, neutral projection: pages, frames, text, links and
interactions, with libraries, geometry and assets stripped. The
projection is what a decision model sees, so the extractor stays
model-agnostic and the deterministic checks live outside it.

Add experiments/security-projection, a Node prototype that consumes
the command: analyze.mjs (file -> projection -> checks -> decision
model), compare.mjs (one file, several models), evaluate.mjs with a
labeled set of projections, and seed.mjs, which builds real files to
validate the extractor. It talks to OpenRouter decision models.

Tests: backend_tests.rpc-semantic-test (3 tests, 21 assertions) and
24 Node tests across the prototype.

AI-assisted-by: deepseek-v4.1-flash
2026-10-08 17:40:22 +00:00

251 lines
8.5 KiB
JavaScript

#!/usr/bin/env node
// Seed real Penpot files shaped like phishing / benign designs, to validate the
// extractor end to end.
//
// It does not build shapes from scratch (the backend validates shapes against
// the full schema). Instead it clones a frame subtree from a template file the
// caller already has, remaps the ids, and rewrites the text, the URLs and the
// frame name. Then it reads the projection back with get-semantic-file and
// checks the expected signals survived.
//
// Usage:
// node seed.mjs --template <file-id>
// node seed.mjs --template <file-id> --keep # do not delete the files
//
// Config comes from .env, same as analyze.mjs. The template file must contain
// at least one plain frame (no component instances) with a text shape and an
// open-url link; the Plants-app demo works.
import process from "node:process";
import { randomUUID } from "node:crypto";
import { pathToFileURL } from "node:url";
import { resolveAuth, rpc } from "./analyze.mjs";
const ZERO = "00000000-0000-0000-0000-000000000000";
const CASES = [
{
name: "seed-phishing-paypal",
frameName: "Sign in",
text: [
"Sign in to your PayPal account",
"Your account has been limited",
"Verify your account within 24 hours",
"Email",
"Password",
],
url: "http://paypal-secure-login.xyz/verify",
expect: { label: "phishing", contains: "paypal", url: "paypal-secure-login.xyz" },
},
{
name: "seed-benign-login",
frameName: "Sign in",
text: [
"Sign in to Acme Intranet",
"Email",
"Password",
"Forgot your password?",
],
url: "https://intranet.acme.example/login",
expect: { label: "benign", contains: "acme", url: "intranet.acme.example" },
},
];
// --- cloning ---------------------------------------------------------------
function childrenOf(objects, id) {
return objects[id]?.shapes ?? [];
}
export function collectSubtree(objects, rootId) {
const ids = [];
const walk = (id) => {
ids.push(id);
for (const child of childrenOf(objects, id)) walk(child);
};
walk(rootId);
return ids;
}
export function hasComponentRef(objects, id) {
if (objects[id]?.componentId || objects[id]?.shapeRef) return true;
return childrenOf(objects, id).some((child) => hasComponentRef(objects, child));
}
function hasTextAndLink(objects, id) {
let text = false;
let link = false;
for (const shapeId of collectSubtree(objects, id)) {
const shape = objects[shapeId];
if (shape?.type === "text") text = true;
if ((shape?.interactions ?? []).some((i) => i.actionType === "open-url")) link = true;
}
return text && link;
}
export function pickTemplateFrame(objects) {
const frames = Object.values(objects).filter(
(shape) => shape?.type === "frame" && shape.frameId === ZERO && shape.id !== ZERO);
const candidate = frames.find(
(frame) => hasTextAndLink(objects, frame.id) && !hasComponentRef(objects, frame.id));
if (!candidate) {
throw new Error("template file has no plain frame with a text shape and an open-url link");
}
return candidate;
}
export function cycling(lines) {
let i = 0;
return () => lines[i++ % lines.length];
}
export function rewriteParagraphs(content, nextText) {
const visit = (node) => {
if (!node || typeof node !== "object") return;
if (node.type === "paragraph" && Array.isArray(node.children)) {
const first = node.children.find((c) => c && typeof c === "object" && typeof c.text === "string");
if (first) node.children = [{ ...first, text: nextText() }];
return;
}
if (Array.isArray(node.children)) node.children.forEach(visit);
};
visit(content);
}
function cloneFrame(objects, frame, { frameName, text, url }) {
const ids = collectSubtree(objects, frame.id);
const idMap = new Map(ids.map((id) => [id, randomUUID()]));
const nextText = cycling(text);
const clones = ids.map((id) => {
const clone = structuredClone(objects[id]);
const isRoot = id === frame.id;
clone.id = idMap.get(id);
clone.parentId = isRoot ? ZERO : idMap.get(clone.parentId) ?? ZERO;
clone.frameId = isRoot ? ZERO : idMap.get(clone.frameId) ?? idMap.get(frame.id);
if (Array.isArray(clone.shapes)) clone.shapes = clone.shapes.map((child) => idMap.get(child) ?? child);
// Optional refs that would dangle in the new file.
delete clone.pageId;
delete clone.componentId;
delete clone.componentFile;
delete clone.componentRoot;
delete clone.mainInstance;
delete clone.shapeRef;
delete clone.touched;
delete clone.remoteSynced;
// position-data is derived layout state; keeping the template's copy makes
// the renderer draw the old text even after the content changes.
delete clone.positionData;
if (clone.type === "text" && clone.content) rewriteParagraphs(clone.content, nextText);
for (const interaction of clone.interactions ?? []) {
if (interaction.actionType === "open-url") interaction.url = url;
if (interaction.destination && idMap.has(interaction.destination)) {
interaction.destination = idMap.get(interaction.destination);
}
}
if (isRoot) clone.name = frameName;
return clone;
});
const changes = clones.map((clone) => ({
type: "add-obj",
pageId: null, // filled by caller
id: clone.id,
parentId: clone.parentId,
frameId: clone.frameId,
obj: clone,
}));
return { changes, frameId: idMap.get(frame.id) };
}
// --- main ------------------------------------------------------------------
function parseArgs(argv) {
const args = { template: process.env.SEED_TEMPLATE_FILE, keep: false };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--template") args.template = argv[++i];
else if (argv[i] === "--keep") args.keep = true;
}
return args;
}
function check(projection, spec, frameId) {
const frames = projection.pages.flatMap((page) => page.frames);
const frame = frames.find((f) => f.name === spec.frameName);
const problems = [];
if (!frame) problems.push(`frame "${spec.frameName}" not found`);
const text = (frame?.text ?? []).join("\n").toLowerCase();
if (!text.includes(spec.expect.contains)) problems.push(`text does not contain "${spec.expect.contains}"`);
const links = frames.flatMap((f) => f.links ?? []);
if (!links.some((l) => l.url.includes(spec.expect.url))) problems.push(`no link to ${spec.expect.url}`);
return { frameId, problems };
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.template) {
console.error("error: pass --template <file-id> or set SEED_TEMPLATE_FILE");
process.exit(1);
}
const auth = await resolveAuth();
const profile = await rpc("get-profile", {}, auth);
const projectId = profile.defaultProjectId;
if (!projectId) {
console.error("error: profile has no default project id");
process.exit(1);
}
const template = await rpc("get-file", { id: args.template }, auth);
const page = (template.data.pages ?? [])[0];
const objects = template.data.pagesIndex?.[page]?.objects ?? {};
const templateFrame = pickTemplateFrame(objects);
console.log(`template frame: ${templateFrame.name} (${templateFrame.id})`);
const created = [];
for (const spec of CASES) {
const file = await rpc("create-file", { name: spec.name, projectId }, auth);
const pageId = (file.data.pages ?? [])[0];
const { changes, frameId } = cloneFrame(objects, templateFrame, spec);
for (const change of changes) change.pageId = pageId;
await rpc("update-file", {
id: file.id,
sessionId: randomUUID(),
revn: 0,
vern: 0,
changes,
}, auth);
const projection = await rpc("get-semantic-file", { id: file.id }, auth);
const { problems } = check(projection, spec, frameId);
created.push({ spec, file, projection, problems });
console.log(`\n${spec.name} (${file.id}) — ${problems.length ? "FAILED" : "ok"}`);
if (problems.length) for (const p of problems) console.log(` ! ${p}`);
console.log(JSON.stringify(projection.pages[0]?.frames?.[0] ?? {}, null, 2));
}
console.log(`\ncreated ${created.length} files${args.keep ? " (kept)" : ""}`);
if (!args.keep) {
for (const { file } of created) {
await rpc("delete-file", { id: file.id }, auth);
console.log(`deleted ${file.id}`);
}
}
if (created.some((c) => c.problems.length)) process.exit(1);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((err) => {
console.error(`error: ${err.stack ?? String(err)}`);
process.exit(1);
});
}