raptor-aios 0.13.2 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.14.1] - 2026-06-21
9
+
10
+ ### Fixed
11
+
12
+ - **Correções do code-review xhigh do épico de fidelidade visual.** `gate.design.fidelity`
13
+ resiliente a race (`readFileSync`/`statSync` não crasham mais) e com contenção de
14
+ evidência à prova de symlink (`realpathSync`); duplicata de screenshot comparada por
15
+ path resolvido. Parser do scorecard mais estrito: divergência com enum inválido e
16
+ região sem `id` são **descartadas** (em vez de retornar objeto com tipo inválido).
17
+ `token-diff` normaliza hex de 4 dígitos (`#rgba`). Scripts `hex-scan`/`screenshot.sh`
18
+ validam argumentos (sem crash de `shift`; resolução do core com mensagem clara). Doc:
19
+ diagrama Mermaid corrigido (linter havia transformado arestas em tabela).
20
+
21
+ ## [0.14.0] - 2026-06-21
22
+
23
+ ### Added
24
+
25
+ - **Loop de fidelidade visual (Figma → React Native) — nota por micro-região.**
26
+ Transforma "a tela tem os tokens" em "a tela replica o Figma". Cada tela ganha
27
+ uma seção `## Anatomy` em `design/screens/<slug>.md` (regiões→elementos amarrados
28
+ a token) — o **contrato** de fidelidade. Um agente `design-fidelity` (read-only,
29
+ adversarial) pontua cada micro-região 60–100 (nota = a dimensão mais fraca; mínimo
30
+ default 95) e escreve `design/visual-review/<slug>.fidelity.json` (schema
31
+ `raptor.fidelity/v1`). Peças:
32
+ - `gate.design.fidelity` (estático, `required`): lê o scorecard e trava o
33
+ `raptor verify` se alguma região < mínimo, hex hardcoded, screenshot de evidência
34
+ ausente/duplicada, ou divergência `design-decision` sem aprovação — nunca roda o
35
+ app (como os gates M7 leem `impl-log.md`). Piso de threshold: o scorecard pode
36
+ endurecer, nunca afrouxar.
37
+ - `raptor verify fidelity [feature]`: lente read-only que pontua os scorecards.
38
+ - `raptor design fidelity <feature>`: driver do loop — reporta a fidelidade e emite
39
+ o prompt do scorer; o loop é dirigido pelo agente iterando até convergir.
40
+ - Backbone determinístico (anti-gaming, medido pelo Raptor): `hex-scan` (hex +
41
+ `rgb()/rgba()/hsl()` + cores nomeadas em prop de cor), `token-diff` (drift
42
+ Figma×impl) e `scripts/app/screenshot.sh` (captura de evidência iOS/Android).
43
+ - Evento de auditoria `design.fidelity.scored` registra cada ciclo.
44
+ - Doc: [docs/visual-fidelity-loop.md](docs/visual-fidelity-loop.md).
45
+
8
46
  ## [0.13.2] - 2026-06-19
9
47
 
10
48
  ### Fixed
package/README.md CHANGED
@@ -7,8 +7,8 @@
7
7
  [![npm](https://img.shields.io/npm/v/raptor-aios?color=ef4444&label=raptor-aios&logo=npm)](https://www.npmjs.com/package/raptor-aios)
8
8
  [![node](https://img.shields.io/badge/node-%E2%89%A5%2020-3c873a?logo=node.js&logoColor=white)](https://nodejs.org)
9
9
  [![platform](https://img.shields.io/badge/target-React%20Native-61dafb?logo=react&logoColor=black)](https://reactnative.dev)
10
- [![tests](https://img.shields.io/badge/tests-1374%20passing-22c55e)](#️-desenvolvimento-local)
11
- [![version](https://img.shields.io/badge/version-0.13.0-ef4444)](CHANGELOG.md)
10
+ [![tests](https://img.shields.io/badge/tests-1481%20passing-22c55e)](#️-desenvolvimento-local)
11
+ [![version](https://img.shields.io/badge/version-0.14.0-ef4444)](CHANGELOG.md)
12
12
  [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
13
13
 
14
14
  </div>
@@ -40,6 +40,7 @@ raptor new login-biometrico -d "Permitir login com Face ID / digital"
40
40
  - [📱 Fluxo correto com React Native](#-fluxo-correto-com-react-native)
41
41
  - [🛡️ Gates: o que bloqueia o quê](#️-gates-o-que-bloqueia-o-quê)
42
42
  - [🔬 A ponta de verificação (`verify`)](#-a-ponta-de-verificação-verify)
43
+ - [🎯 Fidelidade visual (Figma → React Native)](#-fidelidade-visual-figma--react-native)
43
44
  - [🧰 Capacidades do Raptor](#-capacidades-do-raptor)
44
45
  - [📖 Referência de comandos CLI](#-referência-de-comandos-cli)
45
46
  - [🗂️ Estrutura criada no projeto](#️-estrutura-criada-no-projeto)
@@ -61,6 +62,7 @@ O Raptor leva o SDD a sério para o mundo mobile, adicionando o que falta para u
61
62
  | 📜 **Trilha de auditoria** | Cada passo emite eventos em `audit.jsonl`, validáveis contra um JSON Schema canônico. |
62
63
  | 🔗 **Rastreabilidade código↔spec (M7)** | Cada artefato de código aponta para os critérios de aceitação que satisfaz, com evidência pareada (*code + test*). |
63
64
  | 🔬 **Verificação real** | `verify a11y/perf/stores/os-matrix` medem o app de verdade e comparam com o que a spec/plan declararam. |
65
+ | 🎯 **Fidelidade visual (Figma→RN)** | Decompõe a tela em **anatomia**, dá **nota por micro-região** (mín. 95) e **trava o merge** via `gate.design.fidelity` até a implementação replicar o Figma. Guia: [docs/visual-fidelity-loop.md](docs/visual-fidelity-loop.md). |
64
66
  | 🤖 **Agnóstico de agente** | Materializa slash commands para Claude Code, Cursor, Copilot, Codex, Gemini e Antigravity. |
65
67
  | ✨ **Specs sem pontas soltas** | A spec nasce preenchida (defaults informados), com teto de 3 dúvidas de alto impacto, e um `/raptor-clarify` investigativo que caça lacunas antes do planejamento. |
66
68
  | 🌿 **Branch & commit sob controle** | Cada feature ganha sua branch (`feat/001-…`) automaticamente, e hooks *warn-only* alertam sobre arquivos sensíveis ou fora do contexto — sem travar seu fluxo git. |
@@ -454,12 +456,48 @@ Declarar não é cumprir. O Raptor **mede** e compara com o declarado:
454
456
  | `raptor verify stores <feat>` | M1 | Compara `spec.stores` com `AndroidManifest.xml`/`Info.plist`; `--fix` adiciona faltantes |
455
457
  | `raptor verify os-matrix <feat>` | M4 | Compara `plan.os_matrix` com Gradle/Podfile; `--fix` alinha |
456
458
  | `raptor verify architecture <feat>` | M8 (exp.) | Checagem estática de arquitetura limpa sobre o grafo de imports |
459
+ | `raptor verify fidelity <feat>` | M3 | Pontua os scorecards de fidelidade visual por micro-região (60–100, mín. 95) vs Figma |
457
460
  | `raptor verify <feat>` | — | Roda **todos** os gates da feature de uma vez |
458
461
 
459
462
  > 🧪 **Exemplo real:** `verify stores` detectou que `ACCESS_FINE_LOCATION` e `POST_NOTIFICATIONS` estavam na spec mas **ausentes** do `AndroidManifest.xml` — e `--fix` corrigiu. `verify perf` mediu TTFI mediano de **957ms** contra orçamento de 2000ms.
460
463
 
461
464
  ---
462
465
 
466
+ ## 🎯 Fidelidade visual (Figma → React Native)
467
+
468
+ > **Novo na v0.14.0.** Transforma _"a tela tem os tokens"_ em _"a tela **replica** o Figma"_.
469
+
470
+ <p align="center"><img src="docs/assets/visual-fidelity-loop.svg" alt="Visual Fidelity Loop — anatomia, loop de fidelidade e gate" width="760"></p>
471
+
472
+ Capturar o design não garante fidelidade. O Raptor decompõe cada tela em **anatomia**
473
+ (regiões → elementos amarrados a token), pontua cada **micro-região de 60 a 100** (mínimo
474
+ **95**) e **trava o `verify`** via `gate.design.fidelity` até a implementação replicar o
475
+ protótipo — com um **backbone determinístico** (hex / `rgb()` / token + screenshots de
476
+ evidência) que o Raptor mede sozinho, então o agente **não consegue inflar a nota**.
477
+
478
+ ```bash
479
+ raptor design fidelity 001-home # driver do loop: reporta a fidelidade + emite o prompt do scorer
480
+ raptor verify fidelity 001-home # lente read-only: nota por micro-região (60–100, mín. 95)
481
+ raptor verify 001-home # gate.design.fidelity (estático) trava se alguma região < mínimo
482
+ ```
483
+
484
+ ```
485
+ === fidelity: 001-home ===
486
+ ✅ home-unavailable [unavailable] header: 97/95
487
+ ✅ home-unavailable [unavailable] earnings-card: 99/95
488
+ ✅ home-unavailable [unavailable] status-component: 98/95
489
+ Summary: 5 region(s) scored — 5 pass, 0 fail.
490
+ ```
491
+
492
+ O agente `design-fidelity` (read-only, _"na dúvida, reprova"_) é acionado **depois do
493
+ implement**: extrai os valores reais do Figma, captura o app rodando, pontua cada
494
+ micro-região e escreve `design/visual-review/<slug>.fidelity.json`. O gate **estático** lê
495
+ esse scorecard (como os gates M7 leem `impl-log.md`) e nunca roda o app.
496
+
497
+ > 📖 **Guia completo** — use cases, workflow, exemplos e diagramas: [docs/visual-fidelity-loop.md](docs/visual-fidelity-loop.md).
498
+
499
+ ---
500
+
463
501
  ## 🧰 Capacidades do Raptor
464
502
 
465
503
  | Capacidade | Para que serve | Comandos |
@@ -472,7 +510,7 @@ Declarar não é cumprir. O Raptor **mede** e compara com o declarado:
472
510
  | 🪝 **Hooks** | Scripts disparados em **34 pontos** do ciclo (`before_*`/`after_*` de 17 comandos). | `raptor hook list · run` |
473
511
  | 📦 **Extensões** | Pacotes self-contained (templates/gates/hooks/workflows). | `raptor extension add · list · info · remove` |
474
512
  | 🎟️ **Jira** | Puxa issues do Jira (somente leitura) para semear specs. | `raptor jira connect · status · pull · disconnect` |
475
- | 🎨 **Figma / Design** | Captura **determinística** de tokens/telas/assets/fontes do Figma (provider `figma-rest` + PAT) e semeia `design/`. | `raptor design connect · status · pull · sync · disconnect` |
513
+ | 🎨 **Figma / Design** | Captura **determinística** de tokens/telas/assets/fontes do Figma (provider `figma-rest` + PAT) e semeia `design/`; **loop de fidelidade visual** com nota por micro-região. | `raptor design connect · status · pull · sync · fidelity · disconnect` |
476
514
  | 📜 **Auditoria & trace** | Consulta a trilha de eventos e a rastreabilidade. | `raptor audit query · show` · `raptor trace` |
477
515
 
478
516
  ### 🎟️ Integração com Jira
@@ -497,6 +535,8 @@ raptor design sync home # re-captura idempotente (nunca sobrescreve
497
535
 
498
536
  O provider **`figma-rest`** usa a REST API da Figma com um PAT (`${FIGMA_TOKEN}` no `raptor.yml`), é determinístico e roda em CI. A captura gera `design/tokens.json` (colors/typography/spacing/radii), `design/screens/<slug>.md` (frames de topo) e `design/assets-manifest.json` (assets baixados com SHA-256, guarda anti-SSRF). Quando há captura real, os prompts fazem o **flip REDISTRIBUIR**: o agente passa a *refinar e redistribuir* os artefatos em disco em vez de "olhar o Figma" — cobrado pelo `gate.design.ready`.
499
537
 
538
+ > 🎯 **Fidelidade visual:** depois do `implement`, um loop garante que a tela **replica** o Figma (anatomia + nota por micro-região + `gate.design.fidelity`) — veja [🎯 Fidelidade visual (Figma → React Native)](#-fidelidade-visual-figma--react-native).
539
+
500
540
  > 📖 Detalhes, providers e limitações: [docs/design-figma-sync.md](docs/design-figma-sync.md) e [docs/design-gate.md](docs/design-gate.md).
501
541
 
502
542
  ---
@@ -37,12 +37,68 @@ const PHASE_MAP = {
37
37
  " artefatos semeados (`design/tokens.json` populado, `design/screens/*.md`), REFINE e",
38
38
  " redistribua esses artefatos em vez de re-buscar; senão use o Figma MCP para ENXERGAR o",
39
39
  " design. Derive telas/fluxos/critérios das telas reais e refine (ou crie) `design/screens/<nome>.md`.",
40
+ "9. Para cada tela, AUTORE a anatomia: uma seção `## Anatomy` em `design/screens/<nome>.md` decompondo",
41
+ " a tela em regiões→elementos, cada valor amarrado a token (`{color.*}`/`{space.*}`/`{radius.*}`/`{type.*}`).",
42
+ " Gramática: `- region: <id> — <notas>` e itens indentados `- <elemento> — <spec>`. É o CONTRATO de",
43
+ " fidelidade pontuado pós-implement por `raptor design fidelity` + `gate.design.fidelity`.",
40
44
  "",
41
45
  "### Feature a especificar:",
42
46
  "",
43
47
  "{{DESCRIPTION}}",
44
48
  ].join("\n"),
45
49
  },
50
+ "design-fidelity": {
51
+ command: "design-fidelity",
52
+ inputs: [
53
+ { filename: "spec.md", role: "spec" },
54
+ { filename: "constitution.md", role: "constitution" },
55
+ ],
56
+ expectedOutput: "specs/{feature}/design/visual-review/*.fidelity.json",
57
+ outputSchema: "raptor.fidelity/v1",
58
+ scope: {
59
+ read: ["specs/{feature}/**", "src/**", "assets/**", ".raptor/constitution.md"],
60
+ write: ["specs/{feature}/design/visual-review/**"],
61
+ forbidden: [
62
+ "src/**",
63
+ ".raptor/audit.jsonl",
64
+ "specs/{feature}/spec.md",
65
+ "specs/{feature}/plan.md",
66
+ "specs/{feature}/tasks.md",
67
+ ],
68
+ },
69
+ bodyTemplate: [
70
+ "## Instrução",
71
+ "",
72
+ "Você é o agente de FIDELIDADE VISUAL do Raptor — read-only no código. Seu papel é",
73
+ "PONTUAR a fidelidade da tela implementada contra o Figma, com rigor adversarial:",
74
+ "na dúvida, REPROVE. Você produz EVIDÊNCIA; não edita `src/` enquanto pontua",
75
+ "(a correção é etapa separada do loop).",
76
+ "",
77
+ "Para CADA tela que declara `## Anatomy` (em `design/screens/<tela>.md`) e cada estado",
78
+ "relevante (disponível, loading, erro, vazio…):",
79
+ "1. Leia a ANATOMIA da tela (seção `## Anatomy`): regiões→elementos→tokens.",
80
+ "2. Extraia do Figma os valores REAIS do nó com `get_design_context` / `get_variable_defs` /",
81
+ " `get_screenshot`. REGRA DE OURO: o nó vence a percepção — nunca chute px/cor/raio/peso.",
82
+ "3. Capture o app RODANDO (evidência, não invente): rode",
83
+ " `scripts/app/screenshot.sh design/visual-review/_shots/<tela>.<estado>.app.png` e salve o",
84
+ " screenshot do Figma como `…<estado>.figma.png`.",
85
+ "4. Compare Figma×app POR MICRO-REGIÃO e pontue 60–100 em cada dimensão",
86
+ " (layout, spacing, color, typography, dimensions, icon, states). A nota da região = a",
87
+ " dimensão MAIS FRACA (o elo). Mínimo de aprovação = 95 (ou o threshold do preset).",
88
+ "5. Backbone determinístico (não falsifique): rode `node scripts/review/hex-scan.mjs src --json`",
89
+ " e preencha `deterministic.hex_hardcoded` com o resultado REAL; rode",
90
+ " `node scripts/review/diff-tokens.mjs <figma-tokens.json> design/tokens.json --json` p/ o drift.",
91
+ "6. Registre cada divergência `{ dimension, figma, rn, fix, severity, status }`. `status`:",
92
+ " `resolved` (corrigida), `open` (ainda falha) ou `design-decision` (desvio semântico —",
93
+ " troca de ícone/cor/copy — que EXIGE aprovação humana antes de passar o gate).",
94
+ "7. Escreva `design/visual-review/<tela>.fidelity.json` (schema `raptor.fidelity/v1`):",
95
+ " `states[].regions[]` com `score`+`dimensions`+`divergences`, `screenshot_figma`/`screenshot_app`,",
96
+ " `deterministic`, e `learnings[]` (aprendizados reutilizáveis). TODA região da anatomia deve",
97
+ " ser pontuada.",
98
+ "8. NÃO edite `src/` aqui. `gate.design.fidelity` trava o `raptor verify` até toda região ≥ mínimo,",
99
+ " sem hex hardcoded e com screenshots de evidência presentes em disco.",
100
+ ].join("\n"),
101
+ },
46
102
  plan: {
47
103
  command: "plan",
48
104
  inputs: [
@@ -65,6 +65,7 @@ export const auditEventSchema = {
65
65
  "jira.issues.created",
66
66
  "design.imported",
67
67
  "design.synced",
68
+ "design.fidelity.scored",
68
69
  "branch.created",
69
70
  "branch.switched",
70
71
  "commit.guarded",
@@ -0,0 +1,85 @@
1
+ import { slugify } from "./slug.js";
2
+ const LIST_ITEM = /^(\s*)[-*]\s+(.*\S)\s*$/;
3
+ const REGION_PREFIX = /^(?:region|regi[aã]o)\s*:\s*(.+)$/i;
4
+ const DELIM = /\s+[—–-]\s+|\s*:\s+/;
5
+ function splitHead(text) {
6
+ const m = DELIM.exec(text);
7
+ if (!m)
8
+ return { head: text.trim() };
9
+ if (m.index <= 0) {
10
+ return { head: "", tail: text.slice(m.index + m[0].length).trim() || undefined };
11
+ }
12
+ return {
13
+ head: text.slice(0, m.index).trim(),
14
+ tail: text.slice(m.index + m[0].length).trim() || undefined,
15
+ };
16
+ }
17
+ function sliceAnatomy(content) {
18
+ const lines = content.split(/\r?\n/);
19
+ let start = -1;
20
+ for (let i = 0; i < lines.length; i++) {
21
+ if (/^##\s+anatomy\b/i.test(lines[i].trim())) {
22
+ start = i + 1;
23
+ break;
24
+ }
25
+ }
26
+ if (start === -1)
27
+ return null;
28
+ const out = [];
29
+ for (let i = start; i < lines.length; i++) {
30
+ if (/^#{1,6}\s+\S/.test(lines[i]))
31
+ break;
32
+ out.push(lines[i]);
33
+ }
34
+ return out;
35
+ }
36
+ export function parseAnatomy(content) {
37
+ const body = sliceAnatomy(content);
38
+ if (body === null) {
39
+ return { regions: [], problems: ["no `## Anatomy` section found"] };
40
+ }
41
+ const regions = [];
42
+ const problems = [];
43
+ const seenIds = new Set();
44
+ let current = null;
45
+ let inFence = false;
46
+ for (const line of body) {
47
+ if (/^\s*(?:```|~~~)/.test(line)) {
48
+ inFence = !inFence;
49
+ continue;
50
+ }
51
+ if (inFence)
52
+ continue;
53
+ const item = LIST_ITEM.exec(line);
54
+ if (!item)
55
+ continue;
56
+ const text = item[2].trim();
57
+ const region = REGION_PREFIX.exec(text);
58
+ if (region) {
59
+ const { head, tail } = splitHead(region[1].trim());
60
+ const id = slugify(head);
61
+ if (!id) {
62
+ problems.push(`region with empty label: "${text}"`);
63
+ current = null;
64
+ continue;
65
+ }
66
+ if (seenIds.has(id)) {
67
+ problems.push(`duplicate region "${id}"`);
68
+ }
69
+ seenIds.add(id);
70
+ current = { id, label: head, ...(tail ? { notes: tail } : {}), elements: [] };
71
+ regions.push(current);
72
+ continue;
73
+ }
74
+ if (!current) {
75
+ problems.push(`element "${text}" appears before any region`);
76
+ continue;
77
+ }
78
+ const { head, tail } = splitHead(text);
79
+ current.elements.push({ name: head, ...(tail ? { spec: tail } : {}) });
80
+ }
81
+ if (regions.length === 0 && problems.length === 0) {
82
+ problems.push("`## Anatomy` section has no regions");
83
+ }
84
+ return { regions, problems };
85
+ }
@@ -0,0 +1,265 @@
1
+ export const FIDELITY_SCORECARD_SCHEMA = "raptor.fidelity/v1";
2
+ export const FIDELITY_DIMENSIONS = [
3
+ "layout",
4
+ "spacing",
5
+ "color",
6
+ "typography",
7
+ "dimensions",
8
+ "icon",
9
+ "states",
10
+ ];
11
+ export const DEFAULT_FIDELITY_THRESHOLD = 95;
12
+ export const DIVERGENCE_SEVERITIES = ["minor", "major", "blocker"];
13
+ export const DIVERGENCE_STATUSES = ["resolved", "open", "design-decision"];
14
+ function isRecord(v) {
15
+ return typeof v === "object" && v !== null && !Array.isArray(v);
16
+ }
17
+ function isScore(v) {
18
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 100;
19
+ }
20
+ function validateDivergence(d, label, problems) {
21
+ if (!isRecord(d)) {
22
+ problems.push(`${label}: divergence must be an object`);
23
+ return null;
24
+ }
25
+ let valid = true;
26
+ const dim = d["dimension"];
27
+ if (typeof dim !== "string" || !FIDELITY_DIMENSIONS.includes(dim)) {
28
+ problems.push(`${label}: divergence "dimension" must be one of ${FIDELITY_DIMENSIONS.join("|")}`);
29
+ valid = false;
30
+ }
31
+ const sev = d["severity"];
32
+ if (typeof sev !== "string" || !DIVERGENCE_SEVERITIES.includes(sev)) {
33
+ problems.push(`${label}: divergence "severity" must be one of ${DIVERGENCE_SEVERITIES.join("|")}`);
34
+ valid = false;
35
+ }
36
+ const status = d["status"];
37
+ if (typeof status !== "string" || !DIVERGENCE_STATUSES.includes(status)) {
38
+ problems.push(`${label}: divergence "status" must be one of ${DIVERGENCE_STATUSES.join("|")}`);
39
+ valid = false;
40
+ }
41
+ if (!valid)
42
+ return null;
43
+ return {
44
+ dimension: dim,
45
+ figma: typeof d["figma"] === "string" ? d["figma"] : "",
46
+ rn: typeof d["rn"] === "string" ? d["rn"] : "",
47
+ ...(typeof d["fix"] === "string" ? { fix: d["fix"] } : {}),
48
+ severity: sev,
49
+ status: status,
50
+ };
51
+ }
52
+ function validateRegion(r, label, problems) {
53
+ if (!isRecord(r)) {
54
+ problems.push(`${label} must be an object`);
55
+ return null;
56
+ }
57
+ const id = r["id"];
58
+ if (typeof id !== "string" || !id.trim()) {
59
+ problems.push(`${label}: missing "id"`);
60
+ return null;
61
+ }
62
+ if (!isScore(r["score"])) {
63
+ problems.push(`${label}: "score" must be a number 0–100`);
64
+ }
65
+ let dimensions;
66
+ if (r["dimensions"] !== undefined) {
67
+ if (!isRecord(r["dimensions"])) {
68
+ problems.push(`${label}: "dimensions" must be an object`);
69
+ }
70
+ else {
71
+ dimensions = {};
72
+ for (const [k, v] of Object.entries(r["dimensions"])) {
73
+ if (!FIDELITY_DIMENSIONS.includes(k)) {
74
+ problems.push(`${label}: unknown dimension "${k}"`);
75
+ continue;
76
+ }
77
+ if (!isScore(v)) {
78
+ problems.push(`${label}: dimension "${k}" must be a number 0–100`);
79
+ continue;
80
+ }
81
+ dimensions[k] = v;
82
+ }
83
+ }
84
+ }
85
+ let divergences;
86
+ if (r["divergences"] !== undefined) {
87
+ if (!Array.isArray(r["divergences"])) {
88
+ problems.push(`${label}: "divergences" must be an array`);
89
+ }
90
+ else {
91
+ divergences = [];
92
+ r["divergences"].forEach((d, i) => {
93
+ const parsed = validateDivergence(d, `${label}.divergences[${i}]`, problems);
94
+ if (parsed)
95
+ divergences.push(parsed);
96
+ });
97
+ }
98
+ }
99
+ return {
100
+ id,
101
+ ...(typeof r["label"] === "string" ? { label: r["label"] } : {}),
102
+ score: isScore(r["score"]) ? r["score"] : 0,
103
+ ...(dimensions ? { dimensions } : {}),
104
+ ...(divergences ? { divergences } : {}),
105
+ };
106
+ }
107
+ export function parseFidelityScorecard(raw) {
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(raw);
111
+ }
112
+ catch (err) {
113
+ return {
114
+ scorecard: null,
115
+ problems: [`fidelity scorecard is not valid JSON: ${err instanceof Error ? err.message : String(err)}`],
116
+ };
117
+ }
118
+ if (!isRecord(parsed)) {
119
+ return { scorecard: null, problems: ["fidelity scorecard must be a JSON object"] };
120
+ }
121
+ const problems = [];
122
+ if (parsed["schema"] !== FIDELITY_SCORECARD_SCHEMA) {
123
+ problems.push(`fidelity scorecard schema must be "${FIDELITY_SCORECARD_SCHEMA}" (got ${JSON.stringify(parsed["schema"])})`);
124
+ return { scorecard: null, problems };
125
+ }
126
+ if (typeof parsed["screen"] !== "string" || !parsed["screen"].trim()) {
127
+ problems.push('fidelity scorecard: missing "screen"');
128
+ }
129
+ if (parsed["threshold"] !== undefined && !isScore(parsed["threshold"])) {
130
+ problems.push('fidelity scorecard: "threshold" must be a number 0–100');
131
+ }
132
+ const states = [];
133
+ const rawStates = parsed["states"];
134
+ if (!Array.isArray(rawStates)) {
135
+ problems.push('fidelity scorecard: "states" must be an array');
136
+ }
137
+ else {
138
+ rawStates.forEach((st, si) => {
139
+ if (!isRecord(st)) {
140
+ problems.push(`state[${si}] must be an object`);
141
+ return;
142
+ }
143
+ const stateName = typeof st["state"] === "string" && st["state"].trim() ? st["state"] : `state[${si}]`;
144
+ if (typeof st["state"] !== "string" || !st["state"].trim()) {
145
+ problems.push(`state[${si}]: missing "state"`);
146
+ }
147
+ const regions = [];
148
+ if (!Array.isArray(st["regions"])) {
149
+ problems.push(`state "${stateName}": "regions" must be an array`);
150
+ }
151
+ else {
152
+ st["regions"].forEach((r, ri) => {
153
+ const region = validateRegion(r, `state "${stateName}" region[${ri}]`, problems);
154
+ if (region)
155
+ regions.push(region);
156
+ });
157
+ }
158
+ states.push({
159
+ state: typeof st["state"] === "string" ? st["state"] : stateName,
160
+ ...(typeof st["screenshot_figma"] === "string" ? { screenshot_figma: st["screenshot_figma"] } : {}),
161
+ ...(typeof st["screenshot_app"] === "string" ? { screenshot_app: st["screenshot_app"] } : {}),
162
+ regions,
163
+ });
164
+ });
165
+ }
166
+ let deterministic;
167
+ if (parsed["deterministic"] !== undefined) {
168
+ if (!isRecord(parsed["deterministic"])) {
169
+ problems.push('fidelity scorecard: "deterministic" must be an object');
170
+ }
171
+ else {
172
+ const det = parsed["deterministic"];
173
+ deterministic = {
174
+ ...(Array.isArray(det["hex_hardcoded"]) ? { hex_hardcoded: det["hex_hardcoded"].map(String) } : {}),
175
+ ...(typeof det["screenshots_present"] === "boolean" ? { screenshots_present: det["screenshots_present"] } : {}),
176
+ ...(typeof det["token_diff"] === "string" ? { token_diff: det["token_diff"] } : {}),
177
+ };
178
+ }
179
+ }
180
+ const scorecard = {
181
+ schema: FIDELITY_SCORECARD_SCHEMA,
182
+ screen: typeof parsed["screen"] === "string" ? parsed["screen"] : "",
183
+ ...(typeof parsed["node_id"] === "string" ? { node_id: parsed["node_id"] } : {}),
184
+ ...(isScore(parsed["threshold"]) ? { threshold: parsed["threshold"] } : {}),
185
+ states,
186
+ ...(deterministic ? { deterministic } : {}),
187
+ ...(isScore(parsed["min_score"]) ? { min_score: parsed["min_score"] } : {}),
188
+ ...(typeof parsed["passed"] === "boolean" ? { passed: parsed["passed"] } : {}),
189
+ ...(Array.isArray(parsed["learnings"]) ? { learnings: parsed["learnings"].map(String) } : {}),
190
+ };
191
+ return { scorecard, problems };
192
+ }
193
+ export function regionScore(region) {
194
+ const dims = region.dimensions
195
+ ? Object.values(region.dimensions).filter((n) => typeof n === "number" && Number.isFinite(n))
196
+ : [];
197
+ if (dims.length > 0)
198
+ return Math.min(...dims);
199
+ return typeof region.score === "number" ? region.score : 0;
200
+ }
201
+ export function evaluateScorecard(card, opts) {
202
+ const threshold = opts?.threshold ?? card.threshold ?? DEFAULT_FIDELITY_THRESHOLD;
203
+ const regions = [];
204
+ const pendingDesignDecisions = [];
205
+ for (const st of card.states ?? []) {
206
+ for (const r of st.regions ?? []) {
207
+ const reasons = [];
208
+ const score = regionScore(r);
209
+ if (score < threshold)
210
+ reasons.push(`score ${score} < threshold ${threshold}`);
211
+ const dimVals = r.dimensions
212
+ ? Object.values(r.dimensions).filter((n) => typeof n === "number" && Number.isFinite(n))
213
+ : [];
214
+ if (r.dimensions !== undefined) {
215
+ if (dimVals.length === 0) {
216
+ reasons.push("declared dimensions breakdown is empty");
217
+ }
218
+ else if (typeof r.score === "number") {
219
+ const weakest = Math.min(...dimVals);
220
+ if (r.score !== weakest) {
221
+ reasons.push(`declared score ${r.score} must equal the weakest dimension ${weakest}`);
222
+ }
223
+ }
224
+ }
225
+ for (const d of r.divergences ?? []) {
226
+ if (d.status === "open")
227
+ reasons.push(`open divergence in ${d.dimension}`);
228
+ if (d.status === "design-decision") {
229
+ pendingDesignDecisions.push({ state: st.state, region: r.id, dimension: d.dimension });
230
+ }
231
+ }
232
+ regions.push({
233
+ state: st.state,
234
+ region: r.id,
235
+ score,
236
+ threshold,
237
+ pass: reasons.length === 0,
238
+ reasons,
239
+ });
240
+ }
241
+ }
242
+ const hexHardcoded = card.deterministic?.hex_hardcoded ?? [];
243
+ const screenshotsPresent = card.deterministic?.screenshots_present ?? false;
244
+ const failing = regions.filter((r) => !r.pass);
245
+ const reasons = [];
246
+ if (regions.length === 0)
247
+ reasons.push("scorecard scored no regions");
248
+ for (const st of card.states ?? []) {
249
+ if ((st.regions?.length ?? 0) === 0)
250
+ reasons.push(`state "${st.state}" has no regions`);
251
+ }
252
+ if (hexHardcoded.length > 0)
253
+ reasons.push(`${hexHardcoded.length} hardcoded hex colour(s)`);
254
+ const ok = reasons.length === 0 && failing.length === 0;
255
+ return {
256
+ threshold,
257
+ regions,
258
+ failing,
259
+ hexHardcoded,
260
+ screenshotsPresent,
261
+ pendingDesignDecisions,
262
+ reasons,
263
+ ok,
264
+ };
265
+ }
@@ -0,0 +1,62 @@
1
+ const TOKEN_DIR = /(?:^|\/)(?:design-system|design_system|tokens?|theme|palette|colou?rs?)\//i;
2
+ const HEX = /(?<![\w#&])#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
3
+ const COLOR_FUNC = /(?<!\w)(?:rgba?|hsla?)\s*\([^)]*\)/gi;
4
+ const NAMED_COLOR_ASSIGN = /\b\w*(?:[Cc]olor|[Ff]ill|[Ss]troke|[Tt]int|[Ss]hadow)\b\s*[:=]\s*["']([A-Za-z]+)["']/g;
5
+ const CSS_NAMED_COLORS = new Set([
6
+ "aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige", "bisque", "black", "blanchedalmond",
7
+ "blue", "blueviolet", "brown", "burlywood", "cadetblue", "chartreuse", "chocolate", "coral", "cornflowerblue",
8
+ "cornsilk", "crimson", "cyan", "darkblue", "darkcyan", "darkgoldenrod", "darkgray", "darkgreen", "darkgrey",
9
+ "darkkhaki", "darkmagenta", "darkolivegreen", "darkorange", "darkorchid", "darkred", "darksalmon",
10
+ "darkseagreen", "darkslateblue", "darkslategray", "darkslategrey", "darkturquoise", "darkviolet", "deeppink",
11
+ "deepskyblue", "dimgray", "dimgrey", "dodgerblue", "firebrick", "floralwhite", "forestgreen", "fuchsia",
12
+ "gainsboro", "ghostwhite", "gold", "goldenrod", "gray", "green", "greenyellow", "grey", "honeydew", "hotpink",
13
+ "indianred", "indigo", "ivory", "khaki", "lavender", "lavenderblush", "lawngreen", "lemonchiffon", "lightblue",
14
+ "lightcoral", "lightcyan", "lightgoldenrodyellow", "lightgray", "lightgreen", "lightgrey", "lightpink",
15
+ "lightsalmon", "lightseagreen", "lightskyblue", "lightslategray", "lightslategrey", "lightsteelblue",
16
+ "lightyellow", "lime", "limegreen", "linen", "magenta", "maroon", "mediumaquamarine", "mediumblue",
17
+ "mediumorchid", "mediumpurple", "mediumseagreen", "mediumslateblue", "mediumspringgreen", "mediumturquoise",
18
+ "mediumvioletred", "midnightblue", "mintcream", "mistyrose", "moccasin", "navajowhite", "navy", "oldlace",
19
+ "olive", "olivedrab", "orange", "orangered", "orchid", "palegoldenrod", "palegreen", "paleturquoise",
20
+ "palevioletred", "papayawhip", "peachpuff", "peru", "pink", "plum", "powderblue", "purple", "rebeccapurple",
21
+ "red", "rosybrown", "royalblue", "saddlebrown", "salmon", "sandybrown", "seagreen", "seashell", "sienna",
22
+ "silver", "skyblue", "slateblue", "slategray", "slategrey", "snow", "springgreen", "steelblue", "tan", "teal",
23
+ "thistle", "tomato", "turquoise", "violet", "wheat", "white", "whitesmoke", "yellow", "yellowgreen",
24
+ ]);
25
+ function isCommentLine(trimmed) {
26
+ return (trimmed.startsWith("//") ||
27
+ trimmed.startsWith("*") ||
28
+ trimmed.startsWith("/*") ||
29
+ trimmed.startsWith("{/*"));
30
+ }
31
+ export function scanHardcodedHex(files, opts) {
32
+ const findings = [];
33
+ for (const { path, content } of files) {
34
+ if (TOKEN_DIR.test(path))
35
+ continue;
36
+ if (opts?.allowPaths?.some((p) => path.includes(p)))
37
+ continue;
38
+ const lines = content.split(/\r?\n/);
39
+ lines.forEach((line, i) => {
40
+ const trimmed = line.trim();
41
+ if (isCommentLine(trimmed))
42
+ return;
43
+ const push = (hex) => findings.push({ file: path, line: i + 1, hex, snippet: trimmed.slice(0, 120) });
44
+ for (const re of [HEX, COLOR_FUNC]) {
45
+ re.lastIndex = 0;
46
+ let m;
47
+ while ((m = re.exec(line)))
48
+ push(m[0]);
49
+ }
50
+ NAMED_COLOR_ASSIGN.lastIndex = 0;
51
+ let m;
52
+ while ((m = NAMED_COLOR_ASSIGN.exec(line))) {
53
+ if (CSS_NAMED_COLORS.has(m[1].toLowerCase()))
54
+ push(m[1]);
55
+ }
56
+ });
57
+ }
58
+ return findings;
59
+ }
60
+ export function hexFindingsToScorecard(findings) {
61
+ return findings.map((f) => `${f.hex} at ${f.file}:${f.line}`);
62
+ }
@@ -9,6 +9,10 @@ export * from "./slug.js";
9
9
  export * from "./asset-paths.js";
10
10
  export * from "./asset-normalize.js";
11
11
  export * from "./screens.js";
12
+ export * from "./anatomy.js";
13
+ export * from "./fidelity.js";
14
+ export * from "./hex-scan.js";
15
+ export * from "./token-diff.js";
12
16
  export * from "./downloader.js";
13
17
  export * from "./font-resolver.js";
14
18
  export * from "./figma-dialects.js";