From f38d840b46c0ab5f296626d41e1ff6dae2e4da68 Mon Sep 17 00:00:00 2001 From: Andrea Gorletta Date: Thu, 25 Jun 2026 11:49:30 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20figma-analyze=20workflow=20(Figma?= =?UTF-8?q?=20design=20=E2=86=94=20FE=20code=20visual=20coverage)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twin of spec-analyze for the frontend. Renders Figma frames to PNG (fetch_figma.py, stdlib, token from env/.env), optionally screenshots the running app via Playwright (shoot_app.mjs, recipe-driven), and runs a multimodal fan-out (figma-analyze.js) comparing design vs the LOCAL FE code pinned at HEAD: cartographer + crawler in Context, per-unit gating on missing design shots, analyzer/verifier/rework (multimodal), reverse-diff, IT report. Reuses the spec-analyze orchestration spine. Preflight/usage in figma-analyze.md. Validated end-to-end on WI10 (PIN-10144): 3 screen-units, full artifact tree (repo-map, comments, findings, reviews, reverse-diff, report). Co-Authored-By: Claude Opus 4.8 --- .gitignore | 11 + package-lock.json | 63 ++++++ package.json | 19 ++ workflows/fetch_figma.py | 211 +++++++++++++++++ workflows/figma-analyze.js | 451 +++++++++++++++++++++++++++++++++++++ workflows/figma-analyze.md | 61 +++++ workflows/shoot_app.mjs | 129 +++++++++++ 7 files changed, 945 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 workflows/fetch_figma.py create mode 100644 workflows/figma-analyze.js create mode 100644 workflows/figma-analyze.md create mode 100644 workflows/shoot_app.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df06baf --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Secrets — never commit +.env +**/.env + +# Analysis output dirs (artifacts, may contain .env) +.spec-analyze/ +.spec-analyze-goals/ +.spec-analyze-fe/ + +# Node / Playwright (FE analysis) +node_modules/ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ebc937a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,63 @@ +{ + "name": "spec-code-analyzer", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spec-code-analyzer", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "playwright": "^1.61.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3237e3d --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "spec-code-analyzer", + "version": "1.0.0", + "description": "Confronta una **specifica (SRS)** con il **codice as-is** di un repository e produce un report di copertura strutturato, sezione per sezione.", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git@github.com-gorlemz:buildo/spec-code-analyzer.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "playwright": "^1.61.0" + } +} diff --git a/workflows/fetch_figma.py b/workflows/fetch_figma.py new file mode 100644 index 0000000..62d0664 --- /dev/null +++ b/workflows/fetch_figma.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""fetch_figma.py — render deterministico di frame Figma per il workflow figma-analyze. + +Specchio di fetch_atlassian.py: sola stdlib di Python 3 (nessuna dipendenza), token letto +solo da ambiente o `/.env`, **mai stampato**, exit-code espliciti. + +Contratto (CLI): + fetch_figma.py --file-key (--units | --nodes ) \ + --out [--scale 1] + +`--units` è l'input CANONICO: un JSON array di unit `{ "idx": "01", "figmaNode": "3977:52475", ... }` +(l'`idx` confermato in preflight è l'unica fonte di verità che lega design/.png a unit/finding/report). +`--nodes` è solo un HELPER DI DEBUG: idx = posizione 1-based. + +Output (sotto ): + design/.png un PNG per ogni frame renderizzabile + design-index.md tabella: idx | node | file | name | status + +Esiti (RF-6, adattati al multi-nodo): + exit 0 -> almeno un frame renderizzato (i fallimenti per-nodo sono FLAGGATI in + design-index.md, non fatali) + exit 2 -> file o TUTTI i nodi non risolvibili (niente renderizzato) + exit 3 -> credenziali assenti o permessi negati (401/403) +""" + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +FIGMA_API = "https://api.figma.com/v1" +ENV_KEY = "FIGMA_TOKEN" +HTTP_TIMEOUT = 60 + + +def log(msg): + """Diagnostica su stderr (stdout resta pulito; il token non compare MAI).""" + print(msg, file=sys.stderr) + + +def fail(code, msg): + log(f"ERROR: {msg}") + sys.exit(code) + + +# --- credenziali: env ha precedenza, fallback su /.env (come fetch_atlassian) --- +def load_token(out_dir): + tok = os.environ.get(ENV_KEY, "") + if tok: + return tok + # Search /.env then the PARENT dir's .env (the preflight usually puts .env at + # outputDir level, while --out points at outputDir/). + candidates = [ + os.path.join(out_dir, ".env"), + os.path.join(os.path.dirname(os.path.normpath(out_dir)), ".env"), + ] + for env_path in candidates: + if not os.path.isfile(env_path): + continue + with open(env_path, encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + if key.strip() == ENV_KEY: + return val.strip().strip('"').strip("'") + return "" + + +def normalize_node_id(raw): + """Accetta sia la forma URL '3977-52475' sia quella API '3977:52475'.""" + return str(raw).strip().replace("-", ":") + + +def api_get(path, token): + """GET su api.figma.com. Solleva HTTPError; il chiamante mappa gli status su exit-code.""" + req = urllib.request.Request(FIGMA_API + path, headers={"X-Figma-Token": token}) + with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def http_to_exit(err): + """401/403 -> 3 (creds), 404 -> 2 (non risolvibile), altro -> 2.""" + if isinstance(err, urllib.error.HTTPError) and err.code in (401, 403): + return 3 + return 2 + + +def download(url, dest): + with urllib.request.urlopen(url, timeout=HTTP_TIMEOUT) as resp: + data = resp.read() + with open(dest, "wb") as fh: + fh.write(data) + + +def build_units(args): + """Ritorna una lista di dict {idx, node} dall'input --units (canonico) o --nodes (debug).""" + if args.units: + with open(args.units, encoding="utf-8") as fh: + raw = json.load(fh) + if not isinstance(raw, list) or not raw: + fail(2, f"--units file is not a non-empty JSON array: {args.units}") + units = [] + for i, u in enumerate(raw): + idx = str(u.get("idx") or f"{i + 1:02d}") + node = u.get("figmaNode") or u.get("node") + if not node: + fail(2, f"unit {idx} has no 'figmaNode'") + units.append({"idx": idx, "node": normalize_node_id(node)}) + return units + # --nodes: debug, idx = posizione 1-based + ids = [n for n in (args.nodes or "").split(",") if n.strip()] + if not ids: + fail(2, "neither --units nor --nodes provided a renderable node") + return [{"idx": f"{i + 1:02d}", "node": normalize_node_id(n)} for i, n in enumerate(ids)] + + +def main(): + ap = argparse.ArgumentParser(description="Render Figma frames to PNG (deterministic).") + ap.add_argument("--file-key", required=True) + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("--units", help="path to units.json (canonical)") + g.add_argument("--nodes", help="comma-separated node ids (debug only)") + ap.add_argument("--out", required=True, help="output dir (design/ is written under it)") + ap.add_argument("--scale", default="1") + args = ap.parse_args() + + token = load_token(args.out) + if not token: + fail(3, f"{ENV_KEY} not set (env or {args.out}/.env)") + + units = build_units(args) + ids = [u["node"] for u in units] + key = args.file_key + + # Nomi dei nodi (best-effort) + esistenza file: una sola chiamata /nodes. + names = {} + file_name = key + try: + q = urllib.parse.urlencode({"ids": ",".join(ids), "depth": "0"}) + nodes_resp = api_get(f"/files/{key}/nodes?{q}", token) + file_name = nodes_resp.get("name") or key + for nid, wrap in (nodes_resp.get("nodes") or {}).items(): + doc = (wrap or {}).get("document") or {} + if doc.get("name"): + names[nid] = doc["name"] + except urllib.error.HTTPError as e: + fail(http_to_exit(e), f"figma /nodes failed (HTTP {e.code}) — file or token issue") + except (urllib.error.URLError, ValueError) as e: + fail(2, f"figma /nodes failed: {e}") + + # Render: una sola chiamata /images per tutti gli id. + try: + q = urllib.parse.urlencode({"ids": ",".join(ids), "format": "png", "scale": str(args.scale)}) + img_resp = api_get(f"/images/{key}?{q}", token) + except urllib.error.HTTPError as e: + fail(http_to_exit(e), f"figma /images failed (HTTP {e.code})") + except (urllib.error.URLError, ValueError) as e: + fail(2, f"figma /images failed: {e}") + + images = img_resp.get("images") or {} + if img_resp.get("err") and not images: + fail(2, f"figma /images returned error and no images: {img_resp.get('err')}") + + design_dir = os.path.join(args.out, "design") + os.makedirs(design_dir, exist_ok=True) + + rows = [] + ok_count = 0 + for u in units: + idx, node = u["idx"], u["node"] + fname = f"design/{idx}.png" + dest = os.path.join(design_dir, f"{idx}.png") + url = images.get(node) + name = names.get(node, "") + if not url: + rows.append((idx, node, "—", name, "MISSING (no render url)")) + log(f" - {idx} {node}: no render url (flagged, not fatal)") + continue + try: + download(url, dest) + ok_count += 1 + rows.append((idx, node, fname, name, "ok")) + log(f" - {idx} {node}: ok -> {fname}") + except (urllib.error.URLError, OSError) as e: + rows.append((idx, node, "—", name, f"DOWNLOAD FAILED ({e})")) + log(f" - {idx} {node}: download failed (flagged): {e}") + + # design-index.md + index_path = os.path.join(args.out, "design-index.md") + with open(index_path, "w", encoding="utf-8") as fh: + fh.write(f"# Design index — {file_name} (`{key}`)\n\n") + fh.write(f"scale: {args.scale} · renderizzati: {ok_count}/{len(units)}\n\n") + fh.write("| idx | node | file | name | status |\n") + fh.write("|-----|------|------|------|--------|\n") + for idx, node, fname, name, status in rows: + safe_name = (name or "").replace("|", "\\|") + fh.write(f"| {idx} | `{node}` | {fname} | {safe_name} | {status} |\n") + + log(f"design-index.md written: {ok_count}/{len(units)} rendered -> {index_path}") + if ok_count == 0: + fail(2, "no frame rendered (all nodes unresolvable)") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/workflows/figma-analyze.js b/workflows/figma-analyze.js new file mode 100644 index 0000000..0483336 --- /dev/null +++ b/workflows/figma-analyze.js @@ -0,0 +1,451 @@ +export const meta = { + name: 'figma-analyze', + description: 'Figma-design-vs-frontend-code visual coverage as a dynamic workflow. Twin of spec-analyze: Context in parallel (figma-shooter + optional app-shooter render the screenshots, cartographer maps the LOCAL FE repo, crawler discovers PRs by epic/PIN), fan-out of N multimodal analyzers (one per confirmed screen-unit) comparing the Figma design PNG + optional rendered app PNG + the local code, adversarial verification + bounded rework in a pipeline, reverse diff (code->design), report. Reuses the spec-analyze orchestration spine; reads the LOCAL working tree pinned at HEAD (not gh ?ref).', + whenToUse: 'After the interactive preflight: FIGMA_TOKEN in /.env (scope file_content:read); for rendered capture, a fresh REACT_APP_MOCK_TOKEN in the FE .env.development.local + dev server up; and the WI segmented into <=10 screen-units (each with idx/titolo/prose/figmaNode and, for rendered capture, a route + recipe). Inputs are passed via args.', + phases: [ + { title: 'Context', detail: 'figma-shooter + app-shooter (capture) + cartographer + crawler, parallel', model: 'haiku' }, + { title: 'Analysis', detail: 'fan-out: one multimodal analyzer per screen-unit (design + app + code)', model: 'sonnet' }, + { title: 'Verification', detail: 'multimodal verifier per finding + bounded rework (<=1 round)', model: 'sonnet' }, + { title: 'Reverse diff', detail: 'reverse-scout: FE behaviors/screens absent from the design', model: 'sonnet' }, + { title: 'Report', detail: 'final synthesis report.md (IT) with per-screen visual coverage', model: 'sonnet' }, + ], +} + +// --------------------------------------------------------------------------- +// args (prepared by the driver/main-loop, NOT by this script): +// { +// fePath: "/abs/path/to/pdnd-interop-frontend", // REQUIRED. local FE working tree = as-is truth. +// repo: "pagopa/pdnd-interop-frontend", // for crawler PR discovery via gh (remote) +// branch: "", // informational; the SHA is pinned at run start +// epicKey: "PIN-8621", // crawler discovery key (epic) +// workItemKeys: ["PIN-10144"], // crawler discovery keys (the WI's Jira issues) +// prNumbers: [1925], // OPTIONAL explicit PRs (skip gh search when known) +// figmaFileKey: "CpRV3kPvFEWLXGtJUgWeZW", +// outputDir: "./.spec-analyze-fe", +// slug: "wi10-adeguamento-fe", +// appBaseUrl: "http://localhost:3000/ui", // mode B only +// renderedCapture: true, // optional; skip-with-flag if app unreachable +// tokenCap: 500000, +// units: [ { idx:"01", titolo:"...", prose:"...", figmaNode:"3977:52475", +// route?:"/it/...", waitFor?:"text=...", viewport?:"1440x900", +// steps?:[{click:{role,name}}|{click:"text"}], settle?:2500, dataNote?:"..." } ] // <=10 +// } +// AS-IS truth = the LOCAL checked-out branch (NOT gh ?ref=). The workflow reads fePath via Read/Grep/Glob +// and pins HEAD (cartographer records the SHA + dirty flag). Secrets (FIGMA_TOKEN / REACT_APP_MOCK_TOKEN) +// live ONLY in env or a gitignored .env and are read by the capture scripts — never by the model. +// The report.md is written in ITALIAN on purpose. +// --------------------------------------------------------------------------- + +const A = (typeof args === 'string') + ? (() => { try { return JSON.parse(args) } catch { return {} } })() + : (args || {}) + +const fePath = A.fePath +const repo = A.repo +const branch = A.branch || 'unknown' +const epicKey = A.epicKey || null +const workItemKeys = Array.isArray(A.workItemKeys) ? A.workItemKeys : [] +const prNumbers = Array.isArray(A.prNumbers) ? A.prNumbers : [] +const figmaFileKey = A.figmaFileKey +const outputDir = A.outputDir || './.spec-analyze-fe' +const slug = A.slug +const appBaseUrl = A.appBaseUrl || 'http://localhost:3000/ui' +const renderedCapture = A.renderedCapture !== false // default true; the shooter still skips-with-flag if app is down +const units = Array.isArray(A.units) ? A.units : [] +const base = `${outputDir}/${slug}` + +// --- arg validation (mirrors spec-analyze.js:39-79) ------------------------ +if (!fePath || !figmaFileKey || !slug || units.length === 0) { + throw new Error('Missing args: fePath, figmaFileKey, slug and a non-empty units[] are required. Run the interactive preflight first (creds + segmentation).') +} +const badUnits = units + .map((u, i) => ({ u, i })) + .filter(({ u }) => !u || !u.idx || !(u.titolo ?? u.title) || !(u.figmaNode ?? u.node)) +if (badUnits.length > 0) { + throw new Error(`Malformed args.units: every unit needs a non-empty 'idx', 'titolo' and 'figmaNode'. Offending: ${badUnits.map(({ u, i }) => u?.idx ?? `#${i}`).join(', ')}.`) +} +// idx keys design/.png, app-shots/.png, findings/, reviews/ and report rows — collisions corrupt the mapping. +const dupIdx = units.map((u) => u.idx).filter((id, i, all) => all.indexOf(id) !== i) +if (dupIdx.length > 0) { + throw new Error(`Duplicate args.units idx: ${[...new Set(dupIdx)].join(', ')}. Each unit needs a unique idx.`) +} +if (units.length > 10) { + log(`WARNING: ${units.length} units exceed the cap of 10 analyzers — the driver should have merged screens. Proceeding WITHOUT silent truncation.`) +} + +// --- token-cost ceiling (best-effort, skip-with-flag; see spec-analyze.js) -- +const TOKEN_CAP = Number(A.tokenCap) > 0 ? Number(A.tokenCap) : 500_000 +const REPORT_RESERVE = 40_000 +const TAIL_RESERVE = REPORT_RESERVE +const startSpent = budget?.spent?.() ?? 0 +const spentHere = () => (budget?.spent?.() ?? startSpent) - startSpent +const analysisBudgetExhausted = () => spentHere() >= (TOKEN_CAP - TAIL_RESERVE) +const budgetSkipped = [] +let budgetHit = false +const flagBudget = (what) => { + budgetHit = true + log(`TOKEN CAP: ~${spentHere()}/${TOKEN_CAP} in-workflow tokens — skipping ${what} (flagged, NOT silently truncated).`) +} + +// --- per-role model strategy (analysis roles MUST be multimodal) ----------- +const MODELS = { + figmaShooter: 'haiku', // runs fetch_figma.py (deterministic render); cheap + appShooter: 'haiku', // runs shoot_app.mjs (Playwright); cheap + cartographer: 'haiku', // maps the local FE repo + pins HEAD + crawler: 'haiku', // PR discovery via gh + analyzer: 'sonnet', // MULTIMODAL: reads the design/app PNGs + code + verifier: 'sonnet', // MULTIMODAL adversarial review + rework: 'sonnet', // MULTIMODAL single-round fix + reverseScout: 'sonnet', + report: 'sonnet', +} +const modelMix = Object.entries(MODELS).map(([k, v]) => `${k}=${v}`).join(', ') + +// --------------------------------------------------------------------------- +// Structured-output schemas +// --------------------------------------------------------------------------- +const FIGMA_SHOOTER_SCHEMA = { + type: 'object', additionalProperties: true, required: ['renderedIdx'], + properties: { + renderedIdx: { type: 'array', items: { type: 'string' }, description: 'idx values that got a design/.png' }, + missingIdx: { type: 'array', items: { type: 'string' }, description: 'idx values with NO design PNG (flagged in design-index.md)' }, + indexPath: { type: 'string' }, + note: { type: 'string' }, + }, +} +const APP_SHOOTER_SCHEMA = { + type: 'object', additionalProperties: true, required: ['capturedIdx'], + properties: { + capturedIdx: { type: 'array', items: { type: 'string' }, description: 'idx values that got an app-shots/.png' }, + failedIdx: { type: 'array', items: { type: 'string' }, description: 'idx values not captured (with a classified reason in app-shots-index.md)' }, + skipped: { type: 'boolean', description: 'true if the whole rendered capture was skipped (app down / disabled)' }, + note: { type: 'string' }, + }, +} +const FE_ANALYSIS_SCHEMA = { + type: 'object', additionalProperties: true, required: ['idx', 'titolo', 'stato', 'findingPath'], + properties: { + idx: { type: 'string' }, + titolo: { type: 'string' }, + stato: { type: 'string', enum: ['fully_covered', 'partially_covered', 'not_covered', 'uncertain'] }, + dimensions: { + type: 'array', + items: { + type: 'object', additionalProperties: true, required: ['name', 'gap'], + properties: { + name: { type: 'string', enum: ['layout', 'tokens', 'typography', 'components', 'states', 'copy', 'spacing'] }, + gap: { type: 'string', enum: ['missing', 'partial', 'different_approach', 'n/a'] }, + note: { type: 'string' }, + }, + }, + }, + figmaNode: { type: 'string' }, + route: { type: 'string' }, + designShot: { type: 'string', description: 'path of the design/.png compared' }, + appShot: { type: 'string', description: 'path of app-shots/.png compared, or "" if none' }, + codeRefs: { type: 'string', description: 'code references path:line (relative to the pinned HEAD)' }, + renderedShot: { type: 'boolean', description: 'true if a rendered app PNG was available and used' }, + dataInsufficient: { type: 'boolean', description: 'true if a state/element could not be visually verified due to thin DEV data' }, + dataInsufficientReason: { type: 'string' }, + findingPath: { type: 'string' }, + note: { type: 'string' }, + }, +} +const VERIFIER_SCHEMA = { + type: 'object', additionalProperties: true, required: ['idx', 'verdetto', 'reviewPath'], + properties: { + idx: { type: 'string' }, + verdetto: { type: 'string', enum: ['confirmed', 'revise'] }, + reviewPath: { type: 'string' }, + contestazioni: { type: 'string', description: 'if revise: pointed, actionable objections; else empty' }, + }, +} +const REPORT_SCHEMA = { + type: 'object', additionalProperties: true, required: ['reportPath'], + properties: { + reportPath: { type: 'string' }, + summary: { type: 'string' }, + }, +} + +// --------------------------------------------------------------------------- +// Shared preamble — ADAPTED from spec-analyze COMMON for the LOCAL-repo model. +// --------------------------------------------------------------------------- +const COMMON = ` +SHARED CONTEXT +- Frontend repo (LOCAL working tree = as-is truth): ${fePath} · branch (informational): ${branch} +- analysis output-dir: ${base} +- repo-map (FE orientation): ${base}/repo-map/ +- Figma design screenshots: ${base}/design/.png (index: ${base}/design-index.md) +- rendered app screenshots (if captured): ${base}/app-shots/.png (index: ${base}/app-shots-index.md) + +CROSS-CUTTING INVARIANTS (follow them to the letter) +- AS-IS truth = the LOCAL checked-out tree at ${fePath}, pinned at HEAD by the cartographer. Read code with Read/Grep/Glob on local paths. Do NOT use gh to read FE code (gh is ONLY for PR/comment discovery). codeRefs are path:line relative to that HEAD. +- MULTIMODAL comparison: see a design/app screenshot by calling the Read tool on its PNG path (the runtime renders the image). The comparison is VISUAL, not pixel-perfect. +- COVERAGE STATUS enum: fully_covered | partially_covered | not_covered | uncertain. +- GAP enum (per dimension): missing | partial | different_approach | n/a. +- VISUAL DIMENSIONS: layout, tokens (colours/MUI theme), typography, components, states (hover/disabled/error/empty/loading), copy (i18n strings), spacing. +- DATA-INSUFFICIENCY: if a state/element cannot be visually confirmed because the rendered app lacked the data, set dataInsufficient=true and say so — never guess. +- SECRETS never in artifacts: FIGMA_TOKEN / REACT_APP_MOCK_TOKEN live only in env or a gitignored .env, read by the capture scripts — never echo them. +- TOOLS / least privilege: NEVER use Edit, Task/Agent, WebFetch or WebSearch. Use ONLY the tools listed in your role's "TOOLS:" line; write ONLY to the paths your role owns. + +i18n / stack TIPS (pdnd-interop-frontend) +- Copy lives in ${fePath}/src/static/locales/{it,en}/.json (i18next). Design tokens = MUI theme. Routes = src/router/routes.tsx. +`.trim() + +// --------------------------------------------------------------------------- +// Role prompts +// --------------------------------------------------------------------------- +// Minimal per-unit payload for the shooters (idx + node, and the app recipe). +const unitsForFigma = JSON.stringify(units.map((u) => ({ idx: u.idx, figmaNode: u.figmaNode ?? u.node }))) +const unitsForApp = JSON.stringify( + units.map((u) => ({ idx: u.idx, route: u.route, waitFor: u.waitFor, viewport: u.viewport, steps: u.steps, settle: u.settle })) +) + +const figmaShooterPrompt = `${COMMON} + +ROLE: FIGMA-SHOOTER — render the Figma design frames to PNG, run ONCE. TOOLS: Bash, Read, Write only. +You produce the design screenshots EVERY comparison is based on; they are persisted run intermediates. + +PROCEDURE +1. Write this units array to ${base}/units.json (create the dir if needed): +${unitsForFigma} +2. Run: python3 workflows/fetch_figma.py --file-key ${figmaFileKey} --units ${base}/units.json --out ${base} --scale 1 + (the script reads FIGMA_TOKEN from env or ${outputDir}/.env or ${base}/.env — never print it; exit 0 = at least one rendered, 2 = nothing/file bad, 3 = creds.) +3. Read ${base}/design-index.md and report which idx got a design/.png and which are MISSING. + +OUTPUT: design/.png + design-index.md under ${base}. A missing PNG is flagged, not fatal. +Return the structured object (renderedIdx[], missingIdx[], indexPath, note).` + +const appShooterPrompt = `${COMMON} + +ROLE: APP-SHOOTER — capture screenshots of the RENDERED FE, run ONCE. TOOLS: Bash, Read, Write only. +This leg is best-effort: the app must be up + authenticated (REACT_APP_MOCK_TOKEN). Read-only navigation. + +PROCEDURE +1. Write ${base}/units-app.json containing EXACTLY this JSON, byte-for-byte. Do NOT modify, reorder, add or remove any field. In particular: do NOT add a 'route' to a unit that has none, do NOT invent purposeTemplateIds, do NOT copy another unit's recipe. A unit without a 'route' is intentional — shoot_app.mjs will skip it (that screen is analyzed on design+code only). +${unitsForApp} +2. Run: node workflows/shoot_app.mjs --units ${base}/units-app.json --base ${appBaseUrl} --out ${base} + (units with no 'route' are skipped; failures are classified in app-shots-index.md — EMPTY render usually means an expired/invalid token.) +3. Read ${base}/app-shots-index.md and report which idx got an app-shots/.png and which failed (with reasons). + +OUTPUT: app-shots/.png + app-shots-index.md under ${base}. Any failure is flagged, never fatal. +If the app is unreachable / all empty, set skipped=true and explain. Return (capturedIdx[], failedIdx[], skipped, note).` + +const cartographerPrompt = `${COMMON} + +ROLE: CARTOGRAPHER — orientation map of the LOCAL FE repo + pin the as-is reference, run ONCE. TOOLS: Bash, Read, Write only. + +PROCEDURE +1. Pin as-is: run \`git -C ${fePath} rev-parse HEAD\` and \`git -C ${fePath} status --porcelain\`. If ${fePath} is not a git repo or the command fails, STOP with an error (downstream analysis cannot trust an unpinned tree). +2. Build orientation WITHOUT reading file contents: list the tree (git ls-files / find), group paths into coherent AREAS (routes & pages from src/router/routes.tsx and src/pages, shared components, MUI theme/design tokens, i18n locales, api layer). Aim 6-15 compact nodes. +3. Write ${base}/repo-map/index.md FIRST — at the TOP record the pinned HEAD sha + whether the tree is DIRTY (and that codeRefs are relative to that sha) — then a | area | node | purpose | table. Write one ${base}/repo-map/.md per area (purpose, key paths, no content). + +OUTPUT: write ONLY inside ${base}/repo-map/. Return a short summary (HEAD sha, dirty?, number of areas, index path).` + +const crawlerPrompt = `${COMMON} + +ROLE: CRAWLER — PR & comment discovery for the WI, run ONCE in PARALLEL (no dependency on repo-map). TOOLS: Bash, Read, Write only. +gh is used ONLY here, and ONLY against the REMOTE repo ${repo} for PRs/comments (never to read FE code). + +PROCEDURE +1. Discover candidate PRs from: ${prNumbers.length ? `the explicit PR numbers [${prNumbers.join(', ')}] FIRST` : 'gh search'}; the epic ${epicKey ? `\`${epicKey}\`` : '(none)'}; and the work-item keys ${workItemKeys.length ? workItemKeys.map((k) => `\`${k}\``).join(', ') : '(none)'} — e.g. \`gh search prs --repo ${repo} ""\`, \`gh pr view --json number,title,state,files,author,body\`. +2. For each relevant PR, collect the touched FE files (src/...) and the useful review/issue comments (drop bots/LGTM/CI). A PIN key that resolves to a BE PR (e.g. a different repo / no FE files) is recorded as CONTEXT, not FE coverage. +3. Map PRs -> touched FE paths so analyzers can find the changed screens. + +OUTPUT: ${base}/comments.md — at the HEAD a | PR | Title | State | Keys | Touched FE paths | index, then per-PR comments, then discarded PRs with a one-line reason. No secrets. +Return a short summary (PRs found, enriched, discarded, BE-only keys noted).` + +const analyzerPrompt = (u) => `${COMMON} + +ROLE: ANALYZER (multimodal) — visual coverage for ONE screen-unit, in fan-out. TOOLS: Bash, Read, Grep, Glob, Write only. + +ASSIGNED UNIT +- idx: ${u.idx} +- titolo: ${u.titolo ?? u.title} +- requirement prose: +${u.prose ?? u.prosa ?? '(prose not provided)'} +- figma node: ${u.figmaNode ?? u.node}${u.route ? `\n- app route: ${u.route}` : ''} + +PROCEDURE +1. READ THE DESIGN: Read ${base}/design/${u.idx}.png (the runtime renders the image). This is the source of truth for the intended UI. +2. READ THE RENDERED APP if present: Read ${base}/app-shots/${u.idx}.png — if it is absent or app-shots-index.md flags it (EMPTY/auth/skip), proceed on design + code only and set renderedShot=false. +3. LOCATE THE CODE: from ${base}/repo-map/index.md + the PR->paths index in ${base}/comments.md, find the components/pages/i18n that implement this screen; Read them on the LOCAL tree at ${fePath}. +4. COMPARE per visual dimension (layout, tokens, typography, components, states, copy, spacing): is each present/faithful in the code (and in the rendered app, if available)? Note where the app/code is MORE explicit than the design, or diverges. +5. If a state/element can't be visually confirmed because the rendered app lacked data, set dataInsufficient=true with a reason. + +OUTPUT: ${base}/findings/${u.idx}-.md with: stato (enum), per-dimension table (dimension | gap enum | note), figma node, route, designShot/appShot paths, codeRefs (path:line), data-insufficiency note. Italian prose. +Return the structured object (idx, titolo, stato, dimensions, figmaNode, route, designShot, appShot, codeRefs, renderedShot, dataInsufficient, dataInsufficientReason, findingPath, note).` + +const verifierPrompt = (idx, titolo, findingPath) => `${COMMON} + +ROLE: VERIFIER (multimodal) — adversarial review of ONE finding. FALSIFY, don't confirm; critic != author. TOOLS: Bash, Read, Grep, Glob, Write only. + +INPUT +- finding: ${findingPath} (unit ${idx} - ${titolo}) +- Re-Read ${base}/design/${idx}.png and ${base}/app-shots/${idx}.png (if present); re-locate the code on ${fePath}. + +PROCEDURE +1. Verify EVERY cited codeRef actually exists on the pinned tree and supports the claim (watch off-by-one). +2. Re-examine the screenshots: is the declared stato honest? Are claimed matches/gaps real per dimension? Is data-insufficiency used correctly (not as an excuse for a real gap)? +3. Check the enums are used to the letter. + +OUTPUT: ${base}/reviews/${idx}-.md with verdict (confirmed | revise); if revise, a pointed ACTIONABLE objection list. Write ONLY inside reviews/. +Return (idx, verdetto, reviewPath, contestazioni).` + +const reworkPrompt = (idx, titolo, findingPath, objections) => `${COMMON} + +ROLE: ANALYZER - REWORK (multimodal), a single round. TOOLS: Bash, Read, Grep, Glob, Write only. +The verifier issued 'revise' on ${findingPath} (unit ${idx} - ${titolo}). Address the objections and REWRITE ${findingPath} as the DEFINITIVE version (single round; note any residual objection). + +VERIFIER OBJECTIONS: +${objections} + +Keep the finding contract (stato enum, per-dimension gaps, codeRefs verified on the pinned tree, designShot/appShot, data-insufficiency). Re-Read the PNGs as needed. +Return the updated structured object (idx, titolo, stato, dimensions, ..., findingPath, note).` + +const reverseScoutPrompt = `${COMMON} + +ROLE: REVERSE-SCOUT — reverse diff code->design, run AFTER verification. Mirror of the analyzer: start from the CODE/app and find what is NOT in the captured design. TOOLS: Bash, Read, Grep, Glob, Write only. + +PROCEDURE +1. Enumerate ${base}/findings/*.md (screens already covered) and ${base}/design-index.md (the captured frames). +2. From ${base}/repo-map/ + ${base}/comments.md (PR-touched paths), find FE screens/components/states (routes, variants, error/empty states, copy) that have NO corresponding captured design frame. +3. Optionally Read any captured app-shots to spot rendered states absent from the design. + +OUTPUT: ${base}/reverse-diff.md (ITALIAN) — undocumented-in-design behaviors, with code references (path:line) and a note on why the design doesn't cover them. Keep identifiers/snippets verbatim. +Return a short summary (number of entries).` + +const reportPrompt = (results, verdicts, budgetInfo) => `${COMMON} + +ROLE: ORCHESTRATOR - REPORT. Write ${base}/report.md in ITALIAN (markdown). Read ${base}/findings/*.md, ${base}/reviews/*.md, ${base}/reverse-diff.md, ${base}/design-index.md, ${base}/app-shots-index.md, ${base}/comments.md. TOOLS: Read, Write, Glob, Grep, Bash only. + +MANDATORY STRUCTURE (headings + prose in Italian) +1. OVERVIEW: per-screen table — | idx | schermata | stato | rendered? | note | (use the summarized results, verify against findings/). +2. DESIGN -> CODE: per screen, the per-dimension gaps (layout/tokens/typography/components/states/copy/spacing), with code references (path:line) and the design/app screenshot paths (embed thumbnails where useful). +3. CODE -> DESIGN: synthesis of reverse-diff.md. +4. VERIFICATION: which screens the verifier CONFIRMED vs REVISED / still contested (residual objections). +5. EVIDENCE & LIMITS: + - As-is: the pinned HEAD sha + whether the tree was dirty (from repo-map/index.md). + - Rendered capture: which screens had an app-shot vs design-only; list EMPTY/auth/skip flags from app-shots-index.md (e.g. an expired REACT_APP_MOCK_TOKEN), and every DATA-INSUFFICIENCY note — these are NOT coverage gaps, state them as such. + - TOKEN CAP: ${budgetInfo} — report verbatim; if anything was skipped for budget, list it (no silent truncation). + - PER-ROLE MODEL MIX: ${modelMix}. + +ANALYSIS RESULTS (validate against the files): +${JSON.stringify(results, null, 2)} + +VERIFIER VERDICTS: +${JSON.stringify(verdicts, null, 2)} + +WRITE: write the FULL report to ${base}/report.md — if it already exists, Read it first (the Write tool refuses to overwrite an un-Read file), then Write and Read it back to confirm your content landed. The report BODY goes in the FILE, never only in the return value. +As-is truth; enums to the letter; no secrets. Return the structured object (reportPath, summary) where 'summary' is a SINGLE LINE outcome (e.g. "3 schermate: 1 fully, 2 partially") — NOT the report body.` + +// --------------------------------------------------------------------------- +// Orchestration +// --------------------------------------------------------------------------- +log(`figma-analyze · FE ${fePath} · figma ${figmaFileKey} · ${units.length} screen-units · rendered=${renderedCapture} · out ${base}`) + +// Context — capture (figma + app) ∥ cartographer ∥ crawler. Barrier: analysis needs design + repo-map. +phase('Context') +const [figmaRes, appRes, mapRes, crawlRes] = await parallel([ + () => agent(figmaShooterPrompt, { label: 'figma-shooter', phase: 'Context', schema: FIGMA_SHOOTER_SCHEMA, model: MODELS.figmaShooter }), + () => (renderedCapture + ? agent(appShooterPrompt, { label: 'app-shooter', phase: 'Context', schema: APP_SHOOTER_SCHEMA, model: MODELS.appShooter }) + : Promise.resolve({ capturedIdx: [], failedIdx: [], skipped: true, note: 'renderedCapture disabled' })), + () => agent(cartographerPrompt, { label: 'cartographer', phase: 'Context', model: MODELS.cartographer }), + () => agent(crawlerPrompt, { label: 'crawler', phase: 'Context', model: MODELS.crawler }), +]) + +if (!figmaRes || !mapRes) { + const failed = [!figmaRes && 'figma-shooter (design/)', !mapRes && 'cartographer (repo-map/)'].filter(Boolean).join(' and ') + throw new Error(`Context phase aborted: ${failed} failed — design screenshots and the repo map are mandatory inputs for the analysis.`) +} +if (!crawlRes) log('WARNING: crawler failed — analyzers proceed without the PR->paths index (code located via repo-map only).') + +// Per-unit gating: a unit with NO design/.png is NOT analyzable (design is its primary input). +const rendered = new Set((figmaRes.renderedIdx || []).map(String)) +const captured = new Set(((appRes && appRes.capturedIdx) || []).map(String)) +const analyzable = units.filter((u) => rendered.has(String(u.idx))) +const skippedNoDesign = units.filter((u) => !rendered.has(String(u.idx))) +for (const u of skippedNoDesign) { + budgetSkipped.push(`analyzer:${u.idx} (no design shot)`) + log(`SKIP unit ${u.idx} "${u.titolo ?? u.title}" — no design/${u.idx}.png (recorded not_covered).`) +} +if (analyzable.length === 0) { + throw new Error('Analysis aborted: no unit has a design screenshot (figma-shooter rendered nothing). Check the figma node ids / FIGMA_TOKEN scope.') +} +log(`analyzable: ${analyzable.length}/${units.length} units · app-shots: ${captured.size}${appRes && appRes.skipped ? ' (rendered capture skipped)' : ''}`) + +// Analysis — fan-out analyzer -> verifier -> bounded rework, in a pipeline (no barrier between units). +const results = await pipeline( + analyzable, + (u) => { + if (analysisBudgetExhausted()) { budgetSkipped.push(`analyzer:${u.idx}`); flagBudget(`analyzer:${u.idx}`); return null } + return agent(analyzerPrompt(u), { label: `analyzer:${u.idx}`, phase: 'Analysis', schema: FE_ANALYSIS_SCHEMA, model: MODELS.analyzer }) + }, + async (finding, u) => { + if (!finding) return null + const idx = u.idx + const titolo = finding.titolo || u.titolo || u.title || '' + if (analysisBudgetExhausted()) { + budgetSkipped.push(`verifier:${idx}`); flagBudget(`verifier:${idx}`) + return { ...finding, idx, verifierOutcome: 'skipped-budget', reviewPath: null } + } + const verdict = await agent(verifierPrompt(idx, titolo, finding.findingPath), { label: `verifier:${idx}`, phase: 'Verification', schema: VERIFIER_SCHEMA, model: MODELS.verifier }) + if (verdict && verdict.verdetto === 'revise') { + if (analysisBudgetExhausted()) { + budgetSkipped.push(`rework:${idx}`); flagBudget(`rework:${idx}`) + return { ...finding, idx, verifierOutcome: 'revise->skipped-budget', reviewPath: verdict.reviewPath } + } + const definitive = await agent( + reworkPrompt(idx, titolo, finding.findingPath, verdict.contestazioni || '(objections in the review file)'), + { label: `rework:${idx}`, phase: 'Verification', schema: FE_ANALYSIS_SCHEMA, model: MODELS.rework }, + ) + return { ...(definitive || finding), idx, verifierOutcome: definitive ? 'revise->rewritten' : 'revise->rework-failed', reviewPath: verdict.reviewPath } + } + return { ...finding, idx, verifierOutcome: verdict ? verdict.verdetto : 'verifier-failed', reviewPath: verdict?.reviewPath } + }, +) + +const okResults = results.filter(Boolean) +// Record the design-less units as explicit not_covered rows so the report is complete. +for (const u of skippedNoDesign) { + okResults.push({ idx: u.idx, titolo: u.titolo ?? u.title, stato: 'not_covered', verifierOutcome: 'skipped-no-design', note: 'no design screenshot captured', findingPath: null }) +} +if (okResults.length === 0) { + throw new Error(`Analysis aborted: all ${analyzable.length} analyzers failed — nothing to synthesize.`) +} +const verdicts = okResults.map((e) => ({ idx: e.idx, verifierOutcome: e.verifierOutcome, reviewPath: e.reviewPath })) +log(`analysis + verification done: ${okResults.length} unit rows`) + +// Reverse diff (after verification). +phase('Reverse diff') +if (analysisBudgetExhausted()) { + budgetSkipped.push('reverse-scout'); flagBudget('reverse-scout (reverse diff)') +} else { + await agent(reverseScoutPrompt, { label: 'reverse-scout', phase: 'Reverse diff', model: MODELS.reverseScout }) +} + +// Report (always attempted within REPORT_RESERVE). +phase('Report') +const budgetInfo = `TOKEN CAP for this run: ${TOKEN_CAP} output tokens (arg-parametrizable, default 500k; best-effort, enforced at agent-spawn checkpoints). In-workflow spend (budget.spent() delta) at report time: ~${spentHere()}. Cap hit: ${budgetHit ? 'YES' : 'no'}.${budgetSkipped.length ? ` Skipped (NOT silently truncated): ${budgetSkipped.join(', ')}.` : ''} PER-ROLE MODEL MIX: ${modelMix}.` +const report = await agent(reportPrompt(okResults, verdicts, budgetInfo), { label: 'report', phase: 'Report', schema: REPORT_SCHEMA, model: MODELS.report }) +if (!report) log(`WARNING: report agent failed — ${base}/report.md may be missing; findings/reviews/reverse-diff are still on disk.`) + +return { + fePath, + figmaFileKey, + slug, + reportPath: report?.reportPath, + summary: report?.summary, + unitsTotal: units.length, + analyzed: results.filter(Boolean).length, + skippedNoDesign: skippedNoDesign.map((u) => u.idx), + appShotsCaptured: [...captured], + tokenCap: TOKEN_CAP, + tokenSpentApprox: spentHere(), + budgetHit, + budgetSkipped, + results: okResults, + verdicts, +} diff --git a/workflows/figma-analyze.md b/workflows/figma-analyze.md new file mode 100644 index 0000000..1f6bd38 --- /dev/null +++ b/workflows/figma-analyze.md @@ -0,0 +1,61 @@ +# figma-analyze — driver & preflight + +Twin di `spec-analyze`, ma **Figma design ↔ codice frontend** (confronto visivo, multimodale). Legge il **repo FE locale** al `HEAD` pinnato; renderizza i frame Figma via Images API; opzionalmente screenshotta l'app reale via Playwright. Il workflow (`workflows/figma-analyze.js`) è deterministico: **non** fa fetch né conferme — presuppone questo preflight. + +## Componenti +- **`workflows/figma-analyze.js`** — il workflow (Context ∥ → fan-out analyzer multimodale → verifier+rework → reverse-diff → report). +- **`workflows/fetch_figma.py`** — render deterministico dei frame → `design/.png` (stdlib; token da env/`.env`; mai stampato). +- **`workflows/shoot_app.mjs`** — cattura Playwright dell'app renderizzata → `app-shots/.png` (best-effort). + +## Preflight (interattivo) +**1. Credenziali** +```bash +# Figma (scope file_content:read) — in /.env (gitignored) o in env +echo 'FIGMA_TOKEN=figd_...' >> ./.spec-analyze-fe/.env + +# Solo per la cattura RENDERIZZATA (model B): token DEV nel FE, + dev server su +printf 'REACT_APP_MOCK_TOKEN=\n' >> ~/LocalWork/PagoPa/pdnd-interop-frontend/.env.development.local +( cd ~/LocalWork/PagoPa/pdnd-interop-frontend && npm run dev ) # Vite :3000, base /ui +``` +Il `REACT_APP_MOCK_TOKEN` è un JWT di sessione DEV a breve scadenza: se l'app rende **vuoto**, è scaduto (`shoot_app.mjs` lo segnala come `EMPTY (no content — auth/token expired?)`). + +**2. Segmenta il Work Item in ≤10 screen-unit** e conferma. Ogni unit: +```jsonc +{ + "idx": "01", "titolo": "...", "prose": "", + "figmaNode": "3977:52475", // nodo del frame (forma API o URL) + "route": "/it/fruizione/template-finalita", // mode B: route STABILE da cui partire + "waitFor": "text=I miei template", // opz: selettore/`networkidle` + "steps": [{"click":"Visualizza"}, // opz: naviga fino alla schermata + {"click":{"role":"tab","name":"E-service e template e-service suggeriti"}}], + "settle": 2500, "dataNote": "..." +} +``` +Nota recipe: il deep-link diretto a una detail route rende **vuoto** — parti da una route stabile (es. la lista) e arriva alla schermata con gli `steps`. + +## Lancio del workflow (dal main-loop di Claude Code) +Passa gli `args`: +```jsonc +{ + "fePath": "/Users//LocalWork/PagoPa/pdnd-interop-frontend", // REQUIRED, repo locale = as-is + "repo": "pagopa/pdnd-interop-frontend", // per la discovery PR via gh + "branch": "feature/PIN-10158_dead-code-cleanup", + "epicKey": "PIN-8621", "workItemKeys": ["PIN-10144"], "prNumbers": [1925], + "figmaFileKey": "CpRV3kPvFEWLXGtJUgWeZW", + "outputDir": "./.spec-analyze-fe", "slug": "wi10-adeguamento-fe", + "appBaseUrl": "http://localhost:3000/ui", + "renderedCapture": true, // false = solo model A (design+codice), nessun token + "tokenCap": 500000, + "units": [ /* ≤10, come sopra */ ] +} +``` + +## Output (`//`) +`design/.png` + `design-index.md` · `app-shots/.png` + `app-shots-index.md` · `repo-map/` · `comments.md` · `findings/-*.md` · `reviews/-*.md` · `reverse-diff.md` · `report.md` (IT). + +## Modelli A vs B +- **A (sempre)**: design-immagine vs codice letto — nessun token/app necessari (`renderedCapture: false`). +- **B (arricchimento)**: aggiunge lo screenshot dell'app renderizzata; richiede token + dev server. Se l'app non è raggiungibile, l'app-shooter salta-con-flag e l'analisi prosegue su A. + +## Sicurezza +`FIGMA_TOKEN`/`REACT_APP_MOCK_TOKEN` vivono solo in env o `.env` gitignored, letti dagli script — mai dal modello, mai negli artefatti. `.gitignore` copre `.env`, `.spec-analyze*/`, `node_modules/`. diff --git a/workflows/shoot_app.mjs b/workflows/shoot_app.mjs new file mode 100644 index 0000000..2d7e606 --- /dev/null +++ b/workflows/shoot_app.mjs @@ -0,0 +1,129 @@ +// shoot_app.mjs — cattura screenshot del FE renderizzato per il workflow figma-analyze. +// +// Best-effort / environment-dependent (NON deterministico come il render Figma): richiede +// l'app FE up + autenticata (REACT_APP_MOCK_TOKEN, gestito dal preflight). Naviga in SOLA +// LETTURA secondo la recipe per-unit e scrive app-shots/.png; i fallimenti sono +// classificati e l'unit resta senza appShot (l'analisi prosegue su design + codice). +// +// Uso: +// node shoot_app.mjs --units --base --out [--viewport 1440x900] +// +// Recipe per-unit (campi letti da ogni unit di units.json; le unit senza `route` sono saltate): +// { idx, route, waitFor?: ""|"networkidle", viewport?: "WxH", steps?: [{click:{role,name}}|{click:""}], settle?: } +// +// Output: /app-shots/.png + /app-shots-index.md (idx | route | file | status) +// Exit 0 sempre (i fallimenti per-unit sono flaggati, mai fatali per il batch). + +import { chromium } from 'playwright' +import { mkdirSync, readFileSync, writeFileSync } from 'fs' +import path from 'path' + +function arg(name, def) { + const i = process.argv.indexOf(name) + return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : def +} + +const unitsPath = arg('--units') +const base = (arg('--base') || process.env.APP_BASE_URL || 'http://localhost:3000/ui').replace(/\/$/, '') +const out = arg('--out') +const [defW, defH] = arg('--viewport', '1440x900').split('x').map(Number) + +if (!unitsPath || !out) { + console.error('usage: node shoot_app.mjs --units --base --out [--viewport WxH]') + process.exit(2) +} + +const allUnits = JSON.parse(readFileSync(unitsPath, 'utf8')) +const units = allUnits.filter((u) => u.route) // no route -> not capturable (e.g. wizard draft-only) +const dir = path.join(out, 'app-shots') +mkdirSync(dir, { recursive: true }) + +const browser = await chromium.launch() +const rows = [] + +for (const u of units) { + const idx = String(u.idx) + const route = u.route.startsWith('/') ? u.route : '/' + u.route + const url = base + route + const [vw, vh] = (u.viewport || '').split('x').map(Number) + const ctx = await browser.newContext({ viewport: { width: vw || defW, height: vh || defH } }) + const page = await ctx.newPage() + let status = 'ok' + let file = `app-shots/${idx}.png` + + try { + await page.goto(url, { waitUntil: 'networkidle', timeout: 45000 }) + } catch (e) { + const m = String(e) + status = /ERR_CONNECTION|ECONNREFUSED|ERR_NAME_NOT_RESOLVED|ERR_ADDRESS/.test(m) + ? 'UNREACHABLE (app down?)' + : 'NAV-TIMEOUT' + file = '—' + rows.push([idx, route, file, status]) + console.error(` - ${idx} ${route}: ${status}`) + await ctx.close() + continue + } + + // login redirect => auth not valid (token expired/missing) + const finalUrl = page.url() + if (/selfcare|\/login|\/auth(\b|\/)/i.test(finalUrl) && !finalUrl.startsWith(base)) { + status = 'LOGIN-REDIRECT (auth invalid)' + file = '—' + rows.push([idx, route, file, status]) + console.error(` - ${idx} ${route}: ${status} (${finalUrl})`) + await ctx.close() + continue + } + + // Expired/missing auth on this app renders a blank shell (spinner) with NO login redirect, + // so guard on empty content before attempting steps (token expiry is the common cause). + await page.waitForTimeout(1200) + const bodyLen = (await page.locator('body').innerText().catch(() => '')).trim().length + if (bodyLen < 40) { + status = 'EMPTY (no content — auth/token expired?)' + file = '—' + rows.push([idx, route, file, status]) + console.error(` - ${idx} ${route}: ${status} (bodyTextLen=${bodyLen})`) + await ctx.close() + continue + } + + try { + if (u.waitFor && u.waitFor !== 'networkidle') { + await page.waitForSelector(u.waitFor, { timeout: 15000 }) + } + for (const s of u.steps || []) { + const loc = + s.click && typeof s.click === 'object' + ? page.getByRole(s.click.role || 'button', { name: new RegExp(s.click.name, 'i') }) + : page.getByText(new RegExp(String(s.click), 'i')) + await loc.first().click({ timeout: 15000 }) + } + await page.waitForTimeout(Number(u.settle ?? 2500)) + await page.screenshot({ path: path.join(out, 'app-shots', `${idx}.png`), fullPage: true }) + } catch (e) { + const m = String(e) + status = /waitForSelector|getByRole|getByText|click/i.test(m) + ? `STEP-FAILED (${(u.waitFor || 'step')})` + : `FAILED: ${m.slice(0, 80)}` + file = '—' + } + + rows.push([idx, route, file, status]) + console.error(` - ${idx} ${route}: ${status}`) + await ctx.close() +} + +await browser.close() + +const indexPath = path.join(out, 'app-shots-index.md') +writeFileSync( + indexPath, + `# App-shots index — ${base}\n\nviewport: ${defW}x${defH} · catturati: ${rows.filter((r) => r[3] === 'ok').length}/${units.length}\n\n` + + `| idx | route | file | status |\n|-----|-------|------|--------|\n` + + rows.map(([i, r, f, s]) => `| ${i} | ${r} | ${f} | ${s} |`).join('\n') + + '\n' +) +console.error(`app-shots-index.md written -> ${indexPath}`) +process.exit(0) From 1e005e249d51151c29d87bd3718e259bef695b29 Mon Sep 17 00:00:00 2001 From: Andrea Gorletta Date: Thu, 25 Jun 2026 14:07:31 +0200 Subject: [PATCH 2/3] feat(figma-analyze): screen-unit coherence + discovery-based preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardens the Figma↔FE visual-coverage workflow after a WI10 run where all three rendered app-shots captured the wrong screen yet were reported as "ok". - analyzer: add an explicit same-screen GATE. If the app-shot depicts a different screen than the design (e.g. a creation wizard step vs a read-only detail tab that merely shares a title), set appShotMismatch=true, discard the app-shot and compare design↔code only. New schema fields appShotMismatch/appShotMismatchReason; report no longer juxtaposes mismatched PNGs and the orchestrator warns when no usable app-shot was produced. - shoot_app.mjs: `ok` now means "target state asserted". waitFor (a unique selector) is checked AFTER the steps (clicks already auto-wait); absent → UNVERIFIED, failed → WRONG-SCREEN? — no more false "ok". - fetch_figma.py: discovery modes so the agent finds the screens itself, no hand node-ids — `--list-pages` (pages are named per epic/PIN) and `--discover-page` (renders every frame on a page, writes discovered.json). - figma-analyze.md: preflight rewritten to discovery + screenshot, then the user's only interactive input is the relative app URL (+ optional nav steps) per discovered screen. - .gitignore: ignore .DS_Store / __pycache__. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 6 ++ workflows/fetch_figma.py | 121 +++++++++++++++++++++++++++++-------- workflows/figma-analyze.js | 28 ++++++--- workflows/figma-analyze.md | 41 ++++++++++--- workflows/shoot_app.mjs | 24 ++++++-- 5 files changed, 174 insertions(+), 46 deletions(-) diff --git a/.gitignore b/.gitignore index df06baf..bc96618 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,9 @@ # Node / Playwright (FE analysis) node_modules/ + +# OS / Python junk +.DS_Store +**/.DS_Store +__pycache__/ +**/__pycache__/ diff --git a/workflows/fetch_figma.py b/workflows/fetch_figma.py index 62d0664..6e7e8ae 100644 --- a/workflows/fetch_figma.py +++ b/workflows/fetch_figma.py @@ -4,17 +4,25 @@ Specchio di fetch_atlassian.py: sola stdlib di Python 3 (nessuna dipendenza), token letto solo da ambiente o `/.env`, **mai stampato**, exit-code espliciti. -Contratto (CLI): - fetch_figma.py --file-key (--units | --nodes ) \ - --out [--scale 1] - -`--units` è l'input CANONICO: un JSON array di unit `{ "idx": "01", "figmaNode": "3977:52475", ... }` +Contratto (CLI) — una delle quattro modalità: + fetch_figma.py --file-key --list-pages --out + fetch_figma.py --file-key --discover-page --out [--scale 1] + fetch_figma.py --file-key --units --out [--scale 1] + fetch_figma.py --file-key --nodes --out [--scale 1] + +`--list-pages` stampa su stdout le pagine del file (`\t`) e esce: serve al preflight per trovare +la pagina di un'epica/PIN per nome, senza che l'utente tocchi i node-id. +`--discover-page ` ENUMERA e renderizza OGNI frame-schermata della pagina (FRAME diretti + FRAME dentro +SECTION di primo livello), in ordine di lettura (alto→basso, sx→dx); idx assegnato 01,02,…; scrive anche +`discovered.json` ([{idx, figmaNode, name}]) da cui il preflight costruisce le unit aggiungendo route/steps. +`--units` è l'input CANONICO per la run: un JSON array `{ "idx": "01", "figmaNode": "3977:52475", ... }` (l'`idx` confermato in preflight è l'unica fonte di verità che lega design/.png a unit/finding/report). `--nodes` è solo un HELPER DI DEBUG: idx = posizione 1-based. Output (sotto ): design/.png un PNG per ogni frame renderizzabile design-index.md tabella: idx | node | file | name | status + discovered.json (solo --discover-page) [{idx, figmaNode, name}] per il pairing del preflight Esiti (RF-6, adattati al multi-nodo): exit 0 -> almeno un frame renderizzato (i fallimenti per-nodo sono FLAGGATI in @@ -97,6 +105,41 @@ def download(url, dest): fh.write(data) +def get_pages(key, token): + """Ritorna [(id, name)] delle pagine (CANVAS) del file — una sola chiamata shallow.""" + d = api_get(f"/files/{key}?depth=1", token) + doc = d.get("document") or {} + return [(c["id"], c.get("name", "")) for c in doc.get("children", []) if c.get("type") == "CANVAS"] + + +def discover_frames(key, page_id, token): + """Enumera le frame-schermata di una pagina: FRAME diretti + FRAME dentro SECTION di primo livello, + ordinati in lettura (alto→basso, sx→dx). Ritorna (file_name, [{idx, node, name}]).""" + q = urllib.parse.urlencode({"ids": page_id, "depth": "2"}) + d = api_get(f"/files/{key}/nodes?{q}", token) + file_name = d.get("name") or key + page = ((d.get("nodes") or {}).get(page_id) or {}).get("document") or {} + frames = [] + for child in page.get("children", []): + t = child.get("type") + if t == "FRAME": + frames.append(child) + elif t == "SECTION": + frames.extend(s for s in child.get("children", []) if s.get("type") == "FRAME") + + def pos(f): + bb = f.get("absoluteBoundingBox") or {} + # bucket y (tolleranza 50px) così frame quasi-allineati restano ordinati sx→dx + return (round((bb.get("y") or 0) / 50.0), bb.get("x") or 0) + + frames.sort(key=pos) + out = [ + {"idx": f"{i + 1:02d}", "node": normalize_node_id(f["id"]), "name": f.get("name", "")} + for i, f in enumerate(frames) + ] + return file_name, out + + def build_units(args): """Ritorna una lista di dict {idx, node} dall'input --units (canonico) o --nodes (debug).""" if args.units: @@ -123,35 +166,65 @@ def main(): ap = argparse.ArgumentParser(description="Render Figma frames to PNG (deterministic).") ap.add_argument("--file-key", required=True) g = ap.add_mutually_exclusive_group(required=True) - g.add_argument("--units", help="path to units.json (canonical)") + g.add_argument("--units", help="path to units.json (canonical run input)") g.add_argument("--nodes", help="comma-separated node ids (debug only)") - ap.add_argument("--out", required=True, help="output dir (design/ is written under it)") + g.add_argument("--discover-page", help="page node id: enumerate + render every SCREEN frame on it (no hand node-ids)") + g.add_argument("--list-pages", action="store_true", help="print the file's pages (idname) and exit") + ap.add_argument("--out", required=True, help="output dir (design/ is written under it; for --list-pages used only to find .env)") ap.add_argument("--scale", default="1") args = ap.parse_args() + key = args.file_key token = load_token(args.out) if not token: fail(3, f"{ENV_KEY} not set (env or {args.out}/.env)") - units = build_units(args) - ids = [u["node"] for u in units] - key = args.file_key - - # Nomi dei nodi (best-effort) + esistenza file: una sola chiamata /nodes. + # --list-pages: stampa le pagine ed esce (il preflight le filtra per epica/PIN per nome). + if args.list_pages: + try: + for pid, name in get_pages(key, token): + print(f"{pid}\t{name}") + except urllib.error.HTTPError as e: + fail(http_to_exit(e), f"figma /files failed (HTTP {e.code}) — file or token issue") + except (urllib.error.URLError, ValueError) as e: + fail(2, f"figma /files failed: {e}") + sys.exit(0) + + # Risolvi le unit da renderizzare (+ nomi già noti) secondo la modalità. names = {} file_name = key - try: - q = urllib.parse.urlencode({"ids": ",".join(ids), "depth": "0"}) - nodes_resp = api_get(f"/files/{key}/nodes?{q}", token) - file_name = nodes_resp.get("name") or key - for nid, wrap in (nodes_resp.get("nodes") or {}).items(): - doc = (wrap or {}).get("document") or {} - if doc.get("name"): - names[nid] = doc["name"] - except urllib.error.HTTPError as e: - fail(http_to_exit(e), f"figma /nodes failed (HTTP {e.code}) — file or token issue") - except (urllib.error.URLError, ValueError) as e: - fail(2, f"figma /nodes failed: {e}") + if args.discover_page: + try: + file_name, disc = discover_frames(key, normalize_node_id(args.discover_page), token) + except urllib.error.HTTPError as e: + fail(http_to_exit(e), f"figma /nodes failed (HTTP {e.code}) — page or token issue") + except (urllib.error.URLError, ValueError) as e: + fail(2, f"figma /nodes failed: {e}") + if not disc: + fail(2, f"no SCREEN frames found on page {args.discover_page}") + units = [{"idx": u["idx"], "node": u["node"]} for u in disc] + names = {u["node"]: u["name"] for u in disc} + os.makedirs(args.out, exist_ok=True) + with open(os.path.join(args.out, "discovered.json"), "w", encoding="utf-8") as fh: + json.dump(disc, fh, ensure_ascii=False, indent=2) + log(f"discovered {len(disc)} frames on page {args.discover_page} -> {args.out}/discovered.json") + else: + units = build_units(args) + # Nomi dei nodi (best-effort) + esistenza file: una sola chiamata /nodes. + try: + q = urllib.parse.urlencode({"ids": ",".join(u["node"] for u in units), "depth": "0"}) + nodes_resp = api_get(f"/files/{key}/nodes?{q}", token) + file_name = nodes_resp.get("name") or key + for nid, wrap in (nodes_resp.get("nodes") or {}).items(): + doc = (wrap or {}).get("document") or {} + if doc.get("name"): + names[nid] = doc["name"] + except urllib.error.HTTPError as e: + fail(http_to_exit(e), f"figma /nodes failed (HTTP {e.code}) — file or token issue") + except (urllib.error.URLError, ValueError) as e: + fail(2, f"figma /nodes failed: {e}") + + ids = [u["node"] for u in units] # Render: una sola chiamata /images per tutti gli id. try: diff --git a/workflows/figma-analyze.js b/workflows/figma-analyze.js index 0483336..54d4890 100644 --- a/workflows/figma-analyze.js +++ b/workflows/figma-analyze.js @@ -144,8 +144,10 @@ const FE_ANALYSIS_SCHEMA = { designShot: { type: 'string', description: 'path of the design/.png compared' }, appShot: { type: 'string', description: 'path of app-shots/.png compared, or "" if none' }, codeRefs: { type: 'string', description: 'code references path:line (relative to the pinned HEAD)' }, - renderedShot: { type: 'boolean', description: 'true if a rendered app PNG was available and used' }, - dataInsufficient: { type: 'boolean', description: 'true if a state/element could not be visually verified due to thin DEV data' }, + renderedShot: { type: 'boolean', description: 'true if a rendered app PNG was available AND depicts the same screen as the design (i.e. it was actually used in the comparison)' }, + appShotMismatch: { type: 'boolean', description: 'true if an app-shot exists but depicts a DIFFERENT screen than the design (e.g. a creation wizard step vs a read-only detail tab) — the unit recipe is mis-specified; the app-shot was discarded' }, + appShotMismatchReason: { type: 'string', description: 'if appShotMismatch: what the app-shot actually shows vs what the design shows' }, + dataInsufficient: { type: 'boolean', description: 'true if a state/element could not be visually verified due to thin DEV data (NOT the same as appShotMismatch — that is a wrong-screen capture)' }, dataInsufficientReason: { type: 'string' }, findingPath: { type: 'string' }, note: { type: 'string' }, @@ -269,13 +271,14 @@ ${u.prose ?? u.prosa ?? '(prose not provided)'} PROCEDURE 1. READ THE DESIGN: Read ${base}/design/${u.idx}.png (the runtime renders the image). This is the source of truth for the intended UI. -2. READ THE RENDERED APP if present: Read ${base}/app-shots/${u.idx}.png — if it is absent or app-shots-index.md flags it (EMPTY/auth/skip), proceed on design + code only and set renderedShot=false. +2. READ THE RENDERED APP if present: Read ${base}/app-shots/${u.idx}.png — if it is absent or app-shots-index.md flags it (EMPTY/auth/skip/UNVERIFIED/WRONG-SCREEN), proceed on design + code only and set renderedShot=false. +2b. SAME-SCREEN GATE (do this BEFORE comparing): if an app-shot is present, confirm it depicts the SAME screen as the design — same UI surface, not merely a shared title. A creation/edit WIZARD step (stepper + form inputs, often empty) is NOT the same screen as a read-only DETAIL tab (populated table), even when the section title matches. A shared header/route prefix is NOT enough. If they differ, set appShotMismatch=true with appShotMismatchReason (what the app-shot actually shows vs the design), set renderedShot=false, DISCARD the app-shot, and compare design↔code only. A wrong-screen capture is NOT dataInsufficient — keep the two flags distinct. 3. LOCATE THE CODE: from ${base}/repo-map/index.md + the PR->paths index in ${base}/comments.md, find the components/pages/i18n that implement this screen; Read them on the LOCAL tree at ${fePath}. -4. COMPARE per visual dimension (layout, tokens, typography, components, states, copy, spacing): is each present/faithful in the code (and in the rendered app, if available)? Note where the app/code is MORE explicit than the design, or diverges. +4. COMPARE per visual dimension (layout, tokens, typography, components, states, copy, spacing): is each present/faithful in the code (and in the rendered app ONLY if it passed the same-screen gate)? Note where the app/code is MORE explicit than the design, or diverges. 5. If a state/element can't be visually confirmed because the rendered app lacked data, set dataInsufficient=true with a reason. -OUTPUT: ${base}/findings/${u.idx}-.md with: stato (enum), per-dimension table (dimension | gap enum | note), figma node, route, designShot/appShot paths, codeRefs (path:line), data-insufficiency note. Italian prose. -Return the structured object (idx, titolo, stato, dimensions, figmaNode, route, designShot, appShot, codeRefs, renderedShot, dataInsufficient, dataInsufficientReason, findingPath, note).` +OUTPUT: ${base}/findings/${u.idx}-.md with: stato (enum), per-dimension table (dimension | gap enum | note), figma node, route, designShot/appShot paths, codeRefs (path:line), data-insufficiency note, and — if applicable — an APP-SHOT MISMATCH note (the captured app-shot is a different screen; recipe to fix). Italian prose. +Return the structured object (idx, titolo, stato, dimensions, figmaNode, route, designShot, appShot, codeRefs, renderedShot, appShotMismatch, appShotMismatchReason, dataInsufficient, dataInsufficientReason, findingPath, note).` const verifierPrompt = (idx, titolo, findingPath) => `${COMMON} @@ -322,12 +325,12 @@ ROLE: ORCHESTRATOR - REPORT. Write ${base}/report.md in ITALIAN (markdown). Read MANDATORY STRUCTURE (headings + prose in Italian) 1. OVERVIEW: per-screen table — | idx | schermata | stato | rendered? | note | (use the summarized results, verify against findings/). -2. DESIGN -> CODE: per screen, the per-dimension gaps (layout/tokens/typography/components/states/copy/spacing), with code references (path:line) and the design/app screenshot paths (embed thumbnails where useful). +2. DESIGN -> CODE: per screen, the per-dimension gaps (layout/tokens/typography/components/states/copy/spacing), with code references (path:line) and the design screenshot path. Embed the app-shot ALONGSIDE the design ONLY when the finding has renderedShot=true (it passed the same-screen gate). If the finding has appShotMismatch=true, do NOT show the app-shot next to the design as if it were the same screen — show the design only and add one line: the captured app-shot depicts a DIFFERENT screen (state the mismatch reason), so the unit recipe is mis-specified and the comparison is design-vs-code only. 3. CODE -> DESIGN: synthesis of reverse-diff.md. 4. VERIFICATION: which screens the verifier CONFIRMED vs REVISED / still contested (residual objections). 5. EVIDENCE & LIMITS: - As-is: the pinned HEAD sha + whether the tree was dirty (from repo-map/index.md). - - Rendered capture: which screens had an app-shot vs design-only; list EMPTY/auth/skip flags from app-shots-index.md (e.g. an expired REACT_APP_MOCK_TOKEN), and every DATA-INSUFFICIENCY note — these are NOT coverage gaps, state them as such. + - Rendered capture: which screens had a USABLE app-shot (renderedShot=true) vs design-only; list EMPTY/auth/skip/UNVERIFIED/WRONG-SCREEN flags from app-shots-index.md (e.g. an expired REACT_APP_MOCK_TOKEN), every APP-SHOT MISMATCH (captured a different screen — recipe mis-specified), and every DATA-INSUFFICIENCY note — these are NOT coverage gaps, state them as such. If NO unit had a usable app-shot, say so plainly: the rendered-capture leg added no visual verification this run — the recipes/units need revising. - TOKEN CAP: ${budgetInfo} — report verbatim; if anything was skipped for budget, list it (no silent truncation). - PER-ROLE MODEL MIX: ${modelMix}. @@ -418,6 +421,15 @@ if (okResults.length === 0) { const verdicts = okResults.map((e) => ({ idx: e.idx, verifierOutcome: e.verifierOutcome, reviewPath: e.reviewPath })) log(`analysis + verification done: ${okResults.length} unit rows`) +// Rendered-capture health: a unit-recipe whose app-shot is a different screen (appShotMismatch) or thin-data +// adds no visual verification. If renderedCapture was on yet NO unit got a usable app-shot, say it out loud. +const usableAppShots = okResults.filter((e) => e.renderedShot === true).length +const mismatchedIdx = okResults.filter((e) => e.appShotMismatch === true).map((e) => e.idx) +if (mismatchedIdx.length) log(`APP-SHOT MISMATCH (wrong screen, recipe mis-specified): ${mismatchedIdx.join(', ')} — these app-shots were discarded, comparison is design-vs-code.`) +if (renderedCapture && !(appRes && appRes.skipped) && usableAppShots === 0) { + log('WARNING: rendered-capture leg produced NO usable app-shot (all mismatch/empty/data-insufficient) — design-vs-code only this run; revise the unit recipes (route+steps+waitFor must reach the SAME screen as the figma node).') +} + // Reverse diff (after verification). phase('Reverse diff') if (analysisBudgetExhausted()) { diff --git a/workflows/figma-analyze.md b/workflows/figma-analyze.md index 1f6bd38..66b99e2 100644 --- a/workflows/figma-analyze.md +++ b/workflows/figma-analyze.md @@ -19,19 +19,41 @@ printf 'REACT_APP_MOCK_TOKEN=\n' >> ~/LocalWork/PagoPa/pdnd-interop- ``` Il `REACT_APP_MOCK_TOKEN` è un JWT di sessione DEV a breve scadenza: se l'app rende **vuoto**, è scaduto (`shoot_app.mjs` lo segnala come `EMPTY (no content — auth/token expired?)`). -**2. Segmenta il Work Item in ≤10 screen-unit** e conferma. Ogni unit: +**2. Gathering screenshot da Figma (l'agente — l'utente NON tocca node-id, componenti, colori o spacing)** +Le pagine del file Figma sono nominate per epica/PIN. L'agente trova la pagina e renderizza tutti i suoi frame: +```bash +# a) elenca le pagine (idnome) e individua quella che contiene l'epica/PIN (es. "PIN-8621") +python3 workflows/fetch_figma.py --file-key --list-pages --out ./.spec-analyze-fe/ +# b) renderizza OGNI frame-schermata di quella pagina → design/.png (idx 01,02,… in ordine di lettura) +python3 workflows/fetch_figma.py --file-key --discover-page --out ./.spec-analyze-fe/ +``` +Output: `design/.png`, `design-index.md` e `discovered.json` (`[{idx, figmaNode, name}]`). Se più pagine combaciano col nome (o nessuna), fai **confermare la pagina** all'utente prima del passo b. + +**3. Pairing design → app (l'UNICO input interattivo dell'utente)** +Per ogni schermata scoperta — mostra `design/.png` + il `name` del frame — chiedi all'utente l'**URL relativo dell'app** che porta a quella schermata (per ricostruire il flusso utente→app). Alcune schermate non sono raggiungibili dal solo URL (step di wizard, tab interne, accordion): in quei casi raccogli anche una **piccola navigazione** (`steps` + `waitFor`). + +Costruisci le unit — `idx`, `titolo` e `figmaNode` vengono da `discovered.json`; `route`/`steps`/`waitFor` dall'utente: ```jsonc { - "idx": "01", "titolo": "...", "prose": "", - "figmaNode": "3977:52475", // nodo del frame (forma API o URL) - "route": "/it/fruizione/template-finalita", // mode B: route STABILE da cui partire - "waitFor": "text=I miei template", // opz: selettore/`networkidle` - "steps": [{"click":"Visualizza"}, // opz: naviga fino alla schermata + "idx": "03", // da discovered.json + "titolo": "Dettaglio template — risorse", // = name del frame (da discovered.json) + "figmaNode": "3977:52306", // da discovered.json — NON digitato a mano + "route": "/it/fruizione/template-finalita", // ← URL relativo inserito dall'utente + "steps": [{"click":"Visualizza"}, // ← SOLO se la schermata richiede navigazione {"click":{"role":"tab","name":"E-service e template e-service suggeriti"}}], - "settle": 2500, "dataNote": "..." + "waitFor": "table" // ← asserzione univoca dello schermo target (post-steps) } ``` -Nota recipe: il deep-link diretto a una detail route rende **vuoto** — parti da una route stabile (es. la lista) e arriva alla schermata con gli `steps`. + +> **Coerenza unit (la garantisce il pairing)** — il `figmaNode` viene dallo screenshot scoperto e la `route`/`steps` sono inserite dall'utente PER QUELLO screenshot: per costruzione descrivono la stessa schermata. Se la `route` non riproduce quella schermata, l'analyzer alza `appShotMismatch`, scarta l'app-shot e confronta solo design↔codice. + +> **`waitFor` = asserzione post-steps** — selettore CSS/text **univoco** dello schermo finale (non un titolo/route condiviso). Verificato DOPO gli `steps` (i click attendono già da soli). Senza `waitFor` → status `UNVERIFIED`; se non trovato → `WRONG-SCREEN?`. + +Note per ricavare route/steps: +- **Deep-link a detail/wizard rende vuoto**: parti da una route stabile (la lista) e arriva con gli `steps`. +- **Tab**: `{"click":{"role":"tab","name":"..."}}` + `waitFor` del contenuto della tab. +- **Accordion**: un `{"click":"..."}` per espanderlo prima dello screenshot. +- **Schermata non riproducibile nell'app** (es. wizard step che richiede un draft attivo): lascia la unit **design-only** (ometti `route`) — meglio nessun app-shot che uno sbagliato. ## Lancio del workflow (dal main-loop di Claude Code) Passa gli `args`: @@ -46,9 +68,10 @@ Passa gli `args`: "appBaseUrl": "http://localhost:3000/ui", "renderedCapture": true, // false = solo model A (design+codice), nessun token "tokenCap": 500000, - "units": [ /* ≤10, come sopra */ ] + "units": [ /* ≤10, costruite ai passi 2-3: discovery + pairing */ ] } ``` +Le `units` sono il risultato del preflight (discovery + pairing). Includi solo le schermate rilevanti per il WI (scarta i frame Figma fuori scope); se le scoperte superano 10, selezionane ≤10 — il workflow non tronca in silenzio ma avvisa. ## Output (`//`) `design/.png` + `design-index.md` · `app-shots/.png` + `app-shots-index.md` · `repo-map/` · `comments.md` · `findings/-*.md` · `reviews/-*.md` · `reverse-diff.md` · `report.md` (IT). diff --git a/workflows/shoot_app.mjs b/workflows/shoot_app.mjs index 2d7e606..b31c2ca 100644 --- a/workflows/shoot_app.mjs +++ b/workflows/shoot_app.mjs @@ -11,7 +11,14 @@ // Recipe per-unit (campi letti da ogni unit di units.json; le unit senza `route` sono saltate): // { idx, route, waitFor?: ""|"networkidle", viewport?: "WxH", steps?: [{click:{role,name}}|{click:""}], settle?: } // +// `waitFor` (selettore CSS/text, non 'networkidle') = ASSERZIONE dello stato target. I click attendono già il +// proprio locator, quindi l'asserzione viene verificata DOPO gli steps: deve puntare a un elemento UNIVOCO della +// schermata finale (non a un titolo/route condiviso con schermate diverse). Senza `waitFor` lo status è UNVERIFIED: +// lo screenshot è salvato ma non possiamo garantire di aver raggiunto lo schermo giusto. +// // Output: /app-shots/.png + /app-shots-index.md (idx | route | file | status) +// Status: ok (target asserito) · UNVERIFIED (nessun waitFor) · WRONG-SCREEN? (waitFor/steps non trovati) · +// UNREACHABLE / NAV-TIMEOUT / LOGIN-REDIRECT / EMPTY (vedi sotto). // Exit 0 sempre (i fallimenti per-unit sono flaggati, mai fatali per il batch). import { chromium } from 'playwright' @@ -89,10 +96,10 @@ for (const u of units) { continue } + // `waitFor` (a CSS/text selector, not 'networkidle') asserts the TARGET state. Clicks auto-wait on their + // locator, so the assertion is most meaningful AFTER the steps: it confirms we reached the intended screen. + const targetSelector = u.waitFor && u.waitFor !== 'networkidle' ? u.waitFor : null try { - if (u.waitFor && u.waitFor !== 'networkidle') { - await page.waitForSelector(u.waitFor, { timeout: 15000 }) - } for (const s of u.steps || []) { const loc = s.click && typeof s.click === 'object' @@ -100,12 +107,19 @@ for (const u of units) { : page.getByText(new RegExp(String(s.click), 'i')) await loc.first().click({ timeout: 15000 }) } + if (targetSelector) { + await page.waitForSelector(targetSelector, { timeout: 15000 }) + } await page.waitForTimeout(Number(u.settle ?? 2500)) await page.screenshot({ path: path.join(out, 'app-shots', `${idx}.png`), fullPage: true }) + // The screenshot is saved; 'ok' means the TARGET STATE was asserted. Without a target selector we cannot + // tell whether the right screen was reached (a shared title/route is not enough), so flag it UNVERIFIED. + status = targetSelector ? 'ok' : 'UNVERIFIED (no waitFor — target screen not asserted)' } catch (e) { const m = String(e) - status = /waitForSelector|getByRole|getByText|click/i.test(m) - ? `STEP-FAILED (${(u.waitFor || 'step')})` + // A failed target assertion after steps usually means we landed on the WRONG screen, not just a flaky step. + status = /waitForSelector|getByRole|getByText|click|Timeout/i.test(m) + ? `WRONG-SCREEN? (${targetSelector || 'step'} not found)` : `FAILED: ${m.slice(0, 80)}` file = '—' } From 6c1f293dfec6e352bef9ed278615925f9a297947 Mon Sep 17 00:00:00 2001 From: Andrea Gorletta Date: Thu, 25 Jun 2026 16:11:32 +0200 Subject: [PATCH 3/3] feat(figma-analyze): manualDesign fallback for when the Figma API is unavailable When FIGMA_TOKEN is missing or the API is rate-limited (the "low" tier can lock for ~40h), the design screenshots can be PROVIDED BY THE USER instead of rendered. - figma-analyze.js: new manualDesign arg. The figma-shooter then skips the Figma API and just indexes the pre-placed design/.png; figmaFileKey and per-unit figmaNode become optional; analyzer handles a missing figma node. The API-mode shooter also falls back to pre-placed PNGs on a 429/creds error. - figma-analyze.md: preflight documents the fallback (user supplies the images, agent places them as design/.png, pairing proceeds unchanged). - Fix: the manual figma-shooter prompt now passes the explicit idx=titolo mapping so design-index.md uses the real screen names instead of guessing. - .gitignore: ignore screens/ (local manual-design input). Validated end-to-end on a manual run (4 wizard-step-2 screenshots): no API call, figmaNode absent, no app-shot, analyzers found real copy/component divergences. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 +++ workflows/figma-analyze.js | 35 ++++++++++++++++++++++++++++------- workflows/figma-analyze.md | 3 +++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index bc96618..322cf96 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ .spec-analyze-goals/ .spec-analyze-fe/ +# Local manual-design screenshots (workflow input, not source) +screens/ + # Node / Playwright (FE analysis) node_modules/ diff --git a/workflows/figma-analyze.js b/workflows/figma-analyze.js index 54d4890..4f4eeba 100644 --- a/workflows/figma-analyze.js +++ b/workflows/figma-analyze.js @@ -25,6 +25,7 @@ export const meta = { // slug: "wi10-adeguamento-fe", // appBaseUrl: "http://localhost:3000/ui", // mode B only // renderedCapture: true, // optional; skip-with-flag if app unreachable +// manualDesign: false, // optional; true = user-provided design/.png (Figma API fallback), figmaNode optional // tokenCap: 500000, // units: [ { idx:"01", titolo:"...", prose:"...", figmaNode:"3977:52475", // route?:"/it/...", waitFor?:"text=...", viewport?:"1440x900", @@ -51,18 +52,22 @@ const outputDir = A.outputDir || './.spec-analyze-fe' const slug = A.slug const appBaseUrl = A.appBaseUrl || 'http://localhost:3000/ui' const renderedCapture = A.renderedCapture !== false // default true; the shooter still skips-with-flag if app is down +// manualDesign: the design screenshots are PROVIDED BY THE USER (already at /design/.png) instead of +// rendered via the Figma API — the fallback when FIGMA_TOKEN is missing or the API is rate-limited (429, the +// "low" tier can lock for ~40h). In this mode the figma-shooter does NOT call the API and figmaNode is optional. +const manualDesign = A.manualDesign === true const units = Array.isArray(A.units) ? A.units : [] const base = `${outputDir}/${slug}` // --- arg validation (mirrors spec-analyze.js:39-79) ------------------------ -if (!fePath || !figmaFileKey || !slug || units.length === 0) { - throw new Error('Missing args: fePath, figmaFileKey, slug and a non-empty units[] are required. Run the interactive preflight first (creds + segmentation).') +if (!fePath || !slug || units.length === 0 || (!manualDesign && !figmaFileKey)) { + throw new Error('Missing args: fePath, slug and a non-empty units[] are required (plus figmaFileKey unless manualDesign=true). Run the interactive preflight first (creds + discovery/pairing).') } const badUnits = units .map((u, i) => ({ u, i })) - .filter(({ u }) => !u || !u.idx || !(u.titolo ?? u.title) || !(u.figmaNode ?? u.node)) + .filter(({ u }) => !u || !u.idx || !(u.titolo ?? u.title) || (!manualDesign && !(u.figmaNode ?? u.node))) if (badUnits.length > 0) { - throw new Error(`Malformed args.units: every unit needs a non-empty 'idx', 'titolo' and 'figmaNode'. Offending: ${badUnits.map(({ u, i }) => u?.idx ?? `#${i}`).join(', ')}.`) + throw new Error(`Malformed args.units: every unit needs a non-empty 'idx', 'titolo'${manualDesign ? '' : " and 'figmaNode'"}. Offending: ${badUnits.map(({ u, i }) => u?.idx ?? `#${i}`).join(', ')}.`) } // idx keys design/.png, app-shots/.png, findings/, reviews/ and report rows — collisions corrupt the mapping. const dupIdx = units.map((u) => u.idx).filter((id, i, all) => all.indexOf(id) !== i) @@ -204,7 +209,22 @@ const unitsForApp = JSON.stringify( units.map((u) => ({ idx: u.idx, route: u.route, waitFor: u.waitFor, viewport: u.viewport, steps: u.steps, settle: u.settle })) ) -const figmaShooterPrompt = `${COMMON} +const expectedIdx = units.map((u) => u.idx).join(', ') +const idxTitles = units.map((u) => `${u.idx} = ${u.titolo ?? u.title}`).join('\n') +const figmaShooterPrompt = manualDesign + ? `${COMMON} + +ROLE: FIGMA-SHOOTER (MANUAL design source) — the design screenshots were PROVIDED BY THE USER and already live at ${base}/design/.png. Do NOT call the Figma API, do NOT run fetch_figma.py. TOOLS: Bash, Read, Write only. + +PROCEDURE +1. List ${base}/design/ and check a .png exists for each expected idx: ${expectedIdx}. +2. Write ${base}/design-index.md: a table | idx | file | name | status | — status 'ok (user-provided)' if design/.png exists, 'MISSING' otherwise. Use EXACTLY these names (the unit titoli — do NOT invent or guess names): +${idxTitles} +3. Report which idx have a design/.png and which are MISSING. + +OUTPUT: design-index.md under ${base} (the PNGs already exist). A missing PNG is flagged, not fatal. +Return the structured object (renderedIdx[], missingIdx[], indexPath, note='manual design screenshots').` + : `${COMMON} ROLE: FIGMA-SHOOTER — render the Figma design frames to PNG, run ONCE. TOOLS: Bash, Read, Write only. You produce the design screenshots EVERY comparison is based on; they are persisted run intermediates. @@ -214,7 +234,8 @@ PROCEDURE ${unitsForFigma} 2. Run: python3 workflows/fetch_figma.py --file-key ${figmaFileKey} --units ${base}/units.json --out ${base} --scale 1 (the script reads FIGMA_TOKEN from env or ${outputDir}/.env or ${base}/.env — never print it; exit 0 = at least one rendered, 2 = nothing/file bad, 3 = creds.) -3. Read ${base}/design-index.md and report which idx got a design/.png and which are MISSING. +3. FALLBACK: if the script exits 3 (no creds) or you hit HTTP 429 (rate-limited) BUT design/.png are ALREADY present at ${base}/design/ (user-provided), use those instead of failing — they are valid design shots. Note 'fallback: pre-placed screenshots' and still write design-index.md. +4. Read/write ${base}/design-index.md and report which idx got a design/.png and which are MISSING. OUTPUT: design/.png + design-index.md under ${base}. A missing PNG is flagged, not fatal. Return the structured object (renderedIdx[], missingIdx[], indexPath, note).` @@ -267,7 +288,7 @@ ASSIGNED UNIT - titolo: ${u.titolo ?? u.title} - requirement prose: ${u.prose ?? u.prosa ?? '(prose not provided)'} -- figma node: ${u.figmaNode ?? u.node}${u.route ? `\n- app route: ${u.route}` : ''} +- figma node: ${u.figmaNode ?? u.node ?? '(design fornito come screenshot manuale — nessun node Figma)'}${u.route ? `\n- app route: ${u.route}` : ''} PROCEDURE 1. READ THE DESIGN: Read ${base}/design/${u.idx}.png (the runtime renders the image). This is the source of truth for the intended UI. diff --git a/workflows/figma-analyze.md b/workflows/figma-analyze.md index 66b99e2..1d71fa8 100644 --- a/workflows/figma-analyze.md +++ b/workflows/figma-analyze.md @@ -29,6 +29,8 @@ python3 workflows/fetch_figma.py --file-key --discover-page .png`, `design-index.md` e `discovered.json` (`[{idx, figmaNode, name}]`). Se più pagine combaciano col nome (o nessuna), fai **confermare la pagina** all'utente prima del passo b. +> **Fallback — API Figma non disponibile → screenshot forniti dall'utente.** Se manca `FIGMA_TOKEN`, l'API risponde **429** (il rate-limit tier "low" può bloccare ~40h: controlla l'header `Retry-After`) o non vuoi usare l'API: chiedi all'utente di **fornire le immagini** delle schermate (export/screenshot da Figma, una per schermata). Salvale come `${base}/design/.png` (idx `01,02,…` nell'ordine che l'utente conferma) e scrivi tu `design-index.md` (idx | file | nome). Poi procedi col pairing identico e lancia con **`manualDesign: true`** — la figma-shooter NON chiama l'API, usa le PNG già presenti (e `figmaNode` nelle unit diventa opzionale). + **3. Pairing design → app (l'UNICO input interattivo dell'utente)** Per ogni schermata scoperta — mostra `design/.png` + il `name` del frame — chiedi all'utente l'**URL relativo dell'app** che porta a quella schermata (per ricostruire il flusso utente→app). Alcune schermate non sono raggiungibili dal solo URL (step di wizard, tab interne, accordion): in quei casi raccogli anche una **piccola navigazione** (`steps` + `waitFor`). @@ -67,6 +69,7 @@ Passa gli `args`: "outputDir": "./.spec-analyze-fe", "slug": "wi10-adeguamento-fe", "appBaseUrl": "http://localhost:3000/ui", "renderedCapture": true, // false = solo model A (design+codice), nessun token + "manualDesign": false, // true = design/.png forniti dall'utente (fallback API bloccata); figmaNode opzionale "tokenCap": 500000, "units": [ /* ≤10, costruite ai passi 2-3: discovery + pairing */ ] }