@milpa/comal 0.2.2 → 0.2.4

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.
Files changed (115) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-device-v1.json +423 -0
  4. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  5. package/package.json +4 -1
  6. package/skills/mv3/SKILL.md +44 -0
  7. package/skills/mv3/skill.json +4 -0
  8. package/src/agent.mjs +299 -32
  9. package/src/auto-guard.mjs +40 -3
  10. package/src/autocompact.mjs +233 -0
  11. package/src/bash-timeouts.mjs +29 -0
  12. package/src/child-env.mjs +23 -0
  13. package/src/cli.mjs +261 -62
  14. package/src/code-run-artifact.mjs +30 -0
  15. package/src/code-run-journal.mjs +135 -0
  16. package/src/code-run-session-proof.mjs +50 -0
  17. package/src/code-run-session.mjs +97 -0
  18. package/src/code-run-worker.mjs +20 -4
  19. package/src/code-run.mjs +49 -14
  20. package/src/codex-provider.mjs +4 -4
  21. package/src/compaction-notice.mjs +22 -0
  22. package/src/compaction.mjs +162 -23
  23. package/src/connection-settings.mjs +36 -4
  24. package/src/context-pressure.mjs +5 -4
  25. package/src/context.mjs +27 -22
  26. package/src/execution-metrics.mjs +36 -5
  27. package/src/file-recovery-proof.mjs +9 -1
  28. package/src/file-recovery.mjs +36 -3
  29. package/src/gemini-provider.mjs +5 -3
  30. package/src/git-integration-operations.mjs +2 -1
  31. package/src/git-plumbing.mjs +2 -1
  32. package/src/git-source.mjs +2 -1
  33. package/src/git-workspaces.mjs +2 -1
  34. package/src/house.mjs +13 -1
  35. package/src/i18n/cli-es.mjs +12 -1
  36. package/src/i18n/es.mjs +38 -1
  37. package/src/judges.mjs +2 -1
  38. package/src/mcp.mjs +2 -1
  39. package/src/model-controls.mjs +16 -2
  40. package/src/model-deadline.mjs +2 -2
  41. package/src/openai-provider.mjs +8 -4
  42. package/src/operations.mjs +17 -2
  43. package/src/package-identity.mjs +68 -0
  44. package/src/peer-probe.mjs +16 -0
  45. package/src/permissions.mjs +97 -4
  46. package/src/plugin-config.mjs +18 -1
  47. package/src/plugin-controls.mjs +31 -8
  48. package/src/plugin-trust.mjs +15 -6
  49. package/src/plugins/chrome-lab/bridge.mjs +19 -6
  50. package/src/plugins/chrome-lab/client.mjs +242 -12
  51. package/src/plugins/chrome-lab/index.mjs +106 -24
  52. package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
  53. package/src/plugins/chrome-lab/native-reply.mjs +31 -0
  54. package/src/plugins/open-relay/actions.mjs +60 -9
  55. package/src/plugins/open-relay/client.mjs +3 -3
  56. package/src/plugins/open-relay/decision.mjs +3 -2
  57. package/src/plugins/open-relay/index.mjs +17 -7
  58. package/src/plugins/open-relay/lookup.mjs +41 -4
  59. package/src/plugins/open-relay/notices.mjs +44 -3
  60. package/src/plugins/open-relay/panel.mjs +4 -1
  61. package/src/plugins/open-relay/watch.mjs +27 -8
  62. package/src/process-lock.mjs +20 -12
  63. package/src/provider-auth.mjs +6 -3
  64. package/src/provider-profiles.mjs +13 -3
  65. package/src/provider.mjs +175 -30
  66. package/src/qr-scan.mjs +240 -0
  67. package/src/read-only-check.mjs +6 -1
  68. package/src/relay/broker.mjs +173 -0
  69. package/src/relay/cards.mjs +702 -0
  70. package/src/relay/flow.mjs +886 -0
  71. package/src/relay/follow.mjs +119 -0
  72. package/src/relay/git.mjs +23 -0
  73. package/src/relay/hub-client.mjs +163 -0
  74. package/src/relay/state.mjs +55 -0
  75. package/src/remote-control.mjs +283 -15
  76. package/src/remote-device-card.mjs +111 -0
  77. package/src/remote-device.mjs +92 -0
  78. package/src/remote-permissions.mjs +159 -0
  79. package/src/remote-relay.mjs +468 -0
  80. package/src/remote-stats.mjs +205 -0
  81. package/src/session-bus.mjs +13 -3
  82. package/src/session-provider-settings.mjs +2 -2
  83. package/src/skill-runtime.mjs +8 -6
  84. package/src/source-ownership.mjs +3 -1
  85. package/src/tool-manifest.mjs +71 -0
  86. package/src/tool-packs.mjs +22 -6
  87. package/src/tool-schema.mjs +14 -2
  88. package/src/tools.mjs +96 -27
  89. package/src/tui/controller.mjs +4 -1
  90. package/src/tui/discovery.mjs +17 -7
  91. package/src/tui/inbound-blocks.mjs +5 -1
  92. package/src/tui/screen.mjs +53 -14
  93. package/src/tui/swarm-panel.mjs +19 -8
  94. package/src/turn-timing.mjs +82 -0
  95. package/src/usage-accounting.mjs +12 -1
  96. package/src/vision.mjs +6 -1
  97. package/src/visual/chrome.mjs +379 -0
  98. package/src/visual/fingerprint.mjs +30 -0
  99. package/src/visual/fixtures.mjs +201 -0
  100. package/src/visual/gate.mjs +92 -0
  101. package/src/visual/judge.mjs +114 -0
  102. package/src/visual/ledger.mjs +160 -0
  103. package/src/visual/measure.mjs +145 -0
  104. package/src/visual/names.mjs +7 -0
  105. package/src/visual/operations.mjs +211 -0
  106. package/src/visual/pixel-diff.mjs +96 -0
  107. package/src/visual/png.mjs +103 -0
  108. package/src/visual/registry.mjs +265 -0
  109. package/src/visual/report.mjs +64 -0
  110. package/src/visual/verify.mjs +292 -0
  111. package/src/work-assertions.mjs +15 -4
  112. package/src/work-plan.mjs +166 -30
  113. package/src/work-state.mjs +4 -4
  114. package/src/workflows/chrome-task-adoption.mjs +7 -3
  115. package/src/workspace-serve.mjs +59 -0
@@ -0,0 +1,265 @@
1
+ // Workspace registry for visual acceptance. Everything under visual/ is a
2
+ // proposal the agent may edit: component specs, reference images and criteria.
3
+ // Only what the human approved in the ledger (ledger.mjs) is enforced.
4
+ import { createHash } from 'node:crypto';
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { normalizeSteps } from './chrome.mjs';
8
+ import { normalizeCriterion } from './measure.mjs';
9
+ import { normalizeTolerance } from './pixel-diff.mjs';
10
+
11
+ export const VISUAL_DIR = 'visual';
12
+ const ID = /^[a-z0-9][a-z0-9-]{0,62}$/;
13
+ const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
14
+ const fail = message => Object.assign(new Error(`Componente visual inválido: ${message}`), { code: 'VISUAL_COMPONENT_INVALID' });
15
+ const only = (object, keys, label) => { for (const key of Object.keys(object)) if (!keys.includes(key)) throw fail(`${label}: campo desconocido ${key}`); };
16
+ const selector = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 1000;
17
+ const inside = (root, file) => file === root || file.startsWith(root + path.sep);
18
+
19
+ const PROTECTED = new Set(['.comal', '.harness', '.git']);
20
+ /**
21
+ * Real path of target, which must stay inside the workspace after resolving
22
+ * symlinks and must not enter Comal's protected state (.comal, .harness) or .git.
23
+ */
24
+ export async function confinedPath(cwd, target, label) {
25
+ const root = await fs.realpath(cwd);
26
+ let real;
27
+ try { real = await fs.realpath(path.resolve(cwd, target)); } catch (error) { throw fail(`${label}: no existe ${target}`); }
28
+ const relative = path.relative(root, real);
29
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) throw fail(`${label}: ${target} sale del workspace`);
30
+ if (relative.split(path.sep).some(part => PROTECTED.has(part))) throw fail(`${label}: ${target} está en estado protegido (.comal, .harness o .git)`);
31
+ return real;
32
+ }
33
+
34
+ export const pairKey = (fixture, state) => `${fixture}--${state}`;
35
+ export function referencePaths(cwd, component, key) {
36
+ const base = path.join(cwd, VISUAL_DIR, 'references', component, key);
37
+ return { png: `${base}.png`, meta: `${base}.json` };
38
+ }
39
+ export const componentFile = (cwd, id) => path.join(cwd, VISUAL_DIR, 'components', `${id}.json`);
40
+ export const criteriaFile = (cwd, id) => path.join(cwd, VISUAL_DIR, 'criteria', `${id}.json`);
41
+
42
+ export async function listComponents(cwd) {
43
+ const dir = path.join(cwd, VISUAL_DIR, 'components');
44
+ const names = await fs.readdir(dir).catch(() => []);
45
+ return names.filter(name => name.endsWith('.json')).map(name => name.slice(0, -5)).filter(id => ID.test(id)).sort();
46
+ }
47
+
48
+ async function readSpecBytes(cwd, id) {
49
+ if (!ID.test(id || '')) throw fail(`id de componente inválido: ${id}`);
50
+ try { return await fs.readFile(componentFile(cwd, id)); }
51
+ catch (error) { throw fail(`no se pudo leer ${path.relative(cwd, componentFile(cwd, id))}: ${error.message}`); }
52
+ }
53
+
54
+ export async function loadComponent(cwd, id) { return parseComponent(cwd, id, await readSpecBytes(cwd, id)); }
55
+
56
+ /** Validate spec bytes; the same bytes are what setupDigest covers. */
57
+ export async function parseComponent(cwd, id, bytes) {
58
+ if (!ID.test(id || '')) throw fail(`id de componente inválido: ${id}`);
59
+ let raw;
60
+ try { raw = JSON.parse(Buffer.from(bytes).toString('utf8')); }
61
+ catch (error) { throw fail(`${path.relative(cwd, componentFile(cwd, id))} no es JSON: ${error.message}`); }
62
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw fail('debe ser un objeto');
63
+ only(raw, ['id', 'adapter', 'extension', 'viewport', 'root', 'anchors', 'elements', 'regions', 'fixtures', 'states', 'setup', 'description'], 'componente');
64
+ if (raw.id !== id) throw fail(`id ${raw.id} no coincide con el archivo ${id}`);
65
+ if (raw.adapter !== 'content-script') throw fail('adapter debe ser content-script');
66
+ const root = path.resolve(cwd), extensionDir = path.resolve(cwd, String(raw.extension || ''));
67
+ if (typeof raw.extension !== 'string' || !inside(root, extensionDir) || extensionDir === root) throw fail('extension debe ser un directorio dentro del workspace');
68
+ const extensionReal = await confinedPath(cwd, extensionDir, 'extension');
69
+ const stat = await fs.stat(path.join(extensionReal, 'manifest.json')).catch(() => null);
70
+ if (!stat?.isFile()) throw fail(`extension ${raw.extension} no tiene manifest.json`);
71
+ const v = raw.viewport || {};
72
+ only(v, ['width', 'height', 'deviceScaleFactor'], 'viewport');
73
+ const dim = (value, min, max) => Number.isSafeInteger(value) && value >= min && value <= max;
74
+ if (!dim(v.width, 200, 4000) || !dim(v.height, 200, 4000)) throw fail('viewport.width/height enteros entre 200 y 4000');
75
+ const scale = v.deviceScaleFactor ?? 1;
76
+ if (![1, 2, 3].includes(scale)) throw fail('viewport.deviceScaleFactor 1, 2 o 3');
77
+ if (!selector(raw.root)) throw fail('root: selector del componente montado');
78
+ for (const key of ['anchors', 'elements', 'regions']) if (raw[key] !== undefined && (!Array.isArray(raw[key]) || raw[key].length > 20 || !raw[key].every(selector))) throw fail(`${key}: lista de selectores (máx 20)`);
79
+ if (!Array.isArray(raw.fixtures) || !raw.fixtures.length || raw.fixtures.length > 30) throw fail('fixtures: entre 1 y 30');
80
+ const fixtures = await Promise.all(raw.fixtures.map(async f => {
81
+ if (!f || typeof f !== 'object') throw fail('fixture inválido');
82
+ only(f, ['id', 'kind', 'dir', 'url', 'mandatory'], `fixture ${f.id}`);
83
+ if (!ID.test(f.id || '')) throw fail(`fixture id inválido ${f.id}`);
84
+ if (!['local', 'frozen', 'live'].includes(f.kind)) throw fail(`fixture ${f.id}: kind local, frozen o live`);
85
+ if (f.kind === 'live') {
86
+ if (f.dir !== undefined) throw fail(`fixture live ${f.id} no lleva dir`);
87
+ let url; try { url = new URL(f.url); } catch { throw fail(`fixture live ${f.id}: url inválida`); }
88
+ if (url.protocol !== 'https:') throw fail(`fixture live ${f.id}: url https`);
89
+ if (f.mandatory === true) throw fail(`fixture live ${f.id} no puede ser obligatorio: un sitio vivo cambia sin aviso`);
90
+ return Object.freeze({ id: f.id, kind: 'live', url: url.href, mandatory: false });
91
+ }
92
+ if (f.url !== undefined) throw fail(`fixture ${f.id}: url sólo para live`);
93
+ const dir = path.resolve(cwd, f.dir ?? path.join(VISUAL_DIR, 'fixtures', f.id));
94
+ if (!inside(root, dir)) throw fail(`fixture ${f.id}: dir dentro del workspace`);
95
+ if (f.mandatory !== undefined && typeof f.mandatory !== 'boolean') throw fail(`fixture ${f.id}: mandatory booleano`);
96
+ return Object.freeze({ id: f.id, kind: f.kind, dir: await confinedPath(cwd, dir, `fixture ${f.id}`), mandatory: f.mandatory ?? true });
97
+ }));
98
+ const fixtureIds = new Set(fixtures.map(f => f.id));
99
+ if (fixtureIds.size !== fixtures.length) throw fail('fixtures con id repetido');
100
+ if (!Array.isArray(raw.states) || !raw.states.length || raw.states.length > 30) throw fail('states: entre 1 y 30');
101
+ const states = raw.states.map(s => {
102
+ if (!s || typeof s !== 'object') throw fail('estado inválido');
103
+ only(s, ['id', 'fixtures', 'steps', 'description'], `estado ${s.id}`);
104
+ if (!ID.test(s.id || '')) throw fail(`estado id inválido ${s.id}`);
105
+ if (s.fixtures !== undefined && (!Array.isArray(s.fixtures) || !s.fixtures.length)) throw fail(`estado ${s.id}: fixtures lista no vacía`);
106
+ for (const fixture of s.fixtures || []) if (!fixtureIds.has(fixture)) throw fail(`estado ${s.id}: fixture ${fixture} no declarado`);
107
+ return Object.freeze({ id: s.id, fixtures: s.fixtures ? [...s.fixtures] : [...fixtureIds], steps: normalizeSteps(s.steps ?? []), description: s.description ?? null });
108
+ });
109
+ if (new Set(states.map(s => s.id)).size !== states.length) throw fail('states con id repetido');
110
+ return Object.freeze({ id, adapter: 'content-script', extension: raw.extension, extension_dir: extensionReal,
111
+ viewport: Object.freeze({ width: v.width, height: v.height, deviceScaleFactor: scale }), root: raw.root,
112
+ anchors: Object.freeze([...(raw.anchors || [])]), elements: Object.freeze([...(raw.elements || [])]), regions: Object.freeze([...(raw.regions || [])]),
113
+ fixtures: Object.freeze(fixtures), states: Object.freeze(states), setup: normalizeSteps(raw.setup ?? []), description: raw.description ?? null });
114
+ }
115
+
116
+ // Exact references tolerate at most this much; looser values would accept any capture.
117
+ export const TOLERANCE_LIMITS = Object.freeze({ threshold: 0.2, maxDiffPixels: 100, maxDiffRatio: 0.0001 });
118
+ export function normalizeReferenceMeta(raw) {
119
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw fail('sidecar de referencia debe ser un objeto');
120
+ only(raw, ['mode', 'tolerance', 'fingerprint', 'source', 'captured_at', 'note'], 'referencia');
121
+ if (!['exact', 'mockup'].includes(raw.mode)) throw fail('referencia: mode exact o mockup');
122
+ if (raw.mode === 'mockup' && raw.tolerance !== undefined) throw fail('referencia mockup no lleva tolerance');
123
+ const tolerance = raw.mode === 'exact' ? normalizeTolerance(raw.tolerance) : null;
124
+ if (tolerance) for (const [key, limit] of Object.entries(TOLERANCE_LIMITS)) if (tolerance[key] > limit) throw fail(`referencia: tolerance.${key} excede ${limit}`);
125
+ return { mode: raw.mode, tolerance, fingerprint: raw.fingerprint ?? null,
126
+ source: raw.source ?? null, captured_at: raw.captured_at ?? null, note: raw.note ?? null };
127
+ }
128
+
129
+ export async function readReference(cwd, component, key) {
130
+ const paths = referencePaths(cwd, component, key);
131
+ const [png, metaBytes] = await Promise.all([fs.readFile(paths.png).catch(() => null), fs.readFile(paths.meta).catch(() => null)]);
132
+ if (!png || !metaBytes) return null;
133
+ let meta;
134
+ try { meta = normalizeReferenceMeta(JSON.parse(metaBytes.toString('utf8'))); }
135
+ catch (error) { return { file: path.relative(cwd, paths.png), error: error.message, digest: null }; }
136
+ const png_sha256 = sha256(png), meta_sha256 = sha256(metaBytes);
137
+ return { file: path.relative(cwd, paths.png), png, png_sha256, meta, meta_sha256, mode: meta.mode, digest: sha256(`${png_sha256}:${meta_sha256}`) };
138
+ }
139
+
140
+ export async function readProposedCriteria(cwd, component) {
141
+ const raw = await fs.readFile(criteriaFile(cwd, component), 'utf8').catch(() => null);
142
+ if (raw === null) return { criteria: [], errors: [] };
143
+ let list;
144
+ try { list = JSON.parse(raw); } catch (error) { return { criteria: [], errors: [`criteria/${component}.json no es JSON: ${error.message}`] }; }
145
+ if (!Array.isArray(list)) return { criteria: [], errors: [`criteria/${component}.json debe ser una lista`] };
146
+ const criteria = [], errors = [], ids = new Set();
147
+ for (const item of list) {
148
+ try { const c = normalizeCriterion(item); if (ids.has(c.id)) throw new Error(`id repetido ${c.id}`); ids.add(c.id); criteria.push(c); }
149
+ catch (error) { errors.push(error.message); }
150
+ }
151
+ return { criteria, errors };
152
+ }
153
+
154
+ /** Pairs, references and proposals of a component; pass the spec to use a snapshot of it. */
155
+ export async function componentInventory(cwd, id, given = null) {
156
+ const spec = given ?? await loadComponent(cwd, id);
157
+ const pairs = [];
158
+ for (const fixture of spec.fixtures) for (const state of spec.states) {
159
+ if (!state.fixtures.includes(fixture.id)) continue;
160
+ const key = pairKey(fixture.id, state.id);
161
+ const reference = await readReference(cwd, id, key);
162
+ pairs.push({ fixture: fixture.id, state: state.id, key, kind: fixture.kind, mandatory: fixture.mandatory, reference: reference && { ...reference, png: undefined } });
163
+ }
164
+ const proposals = await readProposedCriteria(cwd, id), known = new Set(spec.states.map(s => s.id));
165
+ // A proposal naming a state the spec lacks is an error, not a criterion that silently never applies.
166
+ const unknown = c => (c.states || []).filter(s => !known.has(s));
167
+ const proposalErrors = [...proposals.errors, ...proposals.criteria.filter(c => unknown(c).length).map(c => `criterio ${c.id}: estados que el spec no tiene: ${unknown(c).join(', ')}`)];
168
+ return { spec, pairs, proposedCriteria: proposals.criteria.filter(c => !unknown(c).length), proposalErrors };
169
+ }
170
+
171
+ // Every file Chrome could load from the unpacked extension (node_modules
172
+ // included). Symlinks are followed only while they stay inside the extension.
173
+ async function walk(dir, root = dir, out = [], seen = new Set()) {
174
+ const real = await fs.realpath(dir);
175
+ if (seen.has(real)) return out;
176
+ seen.add(real);
177
+ for (const entry of (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, 'en'))) {
178
+ if (entry.name === '.git') continue;
179
+ const full = path.join(dir, entry.name);
180
+ let stat = entry;
181
+ if (entry.isSymbolicLink()) {
182
+ const target = await fs.realpath(full).catch(() => null);
183
+ if (!target || !(target === root || target.startsWith(root + path.sep))) throw fail(`la extensión tiene un enlace que sale de ella: ${path.relative(root, full)}`);
184
+ stat = await fs.stat(full);
185
+ }
186
+ if (stat.isDirectory()) await walk(full, root, out, seen);
187
+ else if (stat.isFile()) out.push(full);
188
+ if (out.length > 20000) throw fail('la extensión tiene más de 20000 archivos');
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /** Files of a tree (walk order) with the sha256 of their bytes. */
194
+ async function hashTree(dir) {
195
+ const out = [];
196
+ for (const file of await walk(dir)) out.push({ rel: path.relative(dir, file), file, sha: sha256(await fs.readFile(file)) });
197
+ return out;
198
+ }
199
+ /** Copy a tree into dst and hash exactly the bytes copied. */
200
+ async function copyTree(src, dst) {
201
+ const out = [];
202
+ for (const file of await walk(src)) {
203
+ const rel = path.relative(src, file), bytes = await fs.readFile(file), target = path.join(dst, rel);
204
+ await fs.mkdir(path.dirname(target), { recursive: true });
205
+ await fs.writeFile(target, bytes);
206
+ out.push({ rel, file, sha: sha256(bytes) });
207
+ }
208
+ return out;
209
+ }
210
+ const treeDigest = entries => { const hash = createHash('sha256'); for (const e of entries) hash.update(e.rel).update('\0').update(e.sha).update('\n'); return hash.digest('hex'); };
211
+
212
+ /**
213
+ * Digest of the component configuration the human approves: the spec bytes and
214
+ * the full content of every local or frozen fixture. Editing states, steps,
215
+ * root, mandatory flags or fixture pages changes it and blocks acceptance.
216
+ */
217
+ function setupDigestFrom(specBytes, fixtures) {
218
+ const hash = createHash('sha256');
219
+ hash.update('spec\0').update(sha256(specBytes)).update('\n');
220
+ for (const fixture of fixtures) {
221
+ if (fixture.kind === 'live') { hash.update(`live\0${fixture.id}\0${fixture.url}\n`); continue; }
222
+ hash.update(`fixture\0${fixture.id}\0${fixture.kind}\n`);
223
+ for (const e of fixture.entries) hash.update(e.rel).update('\0').update(e.sha).update('\n');
224
+ }
225
+ return hash.digest('hex');
226
+ }
227
+ const manifestFrom = (specBytes, fixtures) => ({ spec_sha256: sha256(specBytes),
228
+ fixtures: Object.fromEntries(fixtures.map(f => [f.id, f.kind === 'live' ? { live: f.url } : Object.fromEntries(f.entries.map(e => [e.rel, e.sha]))])) });
229
+
230
+ async function setupState(cwd, spec) {
231
+ const specBytes = await fs.readFile(componentFile(cwd, spec.id));
232
+ const fixtures = [];
233
+ for (const fixture of spec.fixtures) fixtures.push(fixture.kind === 'live' ? fixture : { ...fixture, entries: await hashTree(fixture.dir) });
234
+ return { digest: setupDigestFrom(specBytes, fixtures), manifest: manifestFrom(specBytes, fixtures) };
235
+ }
236
+ export async function setupDigest(cwd, spec) { return (await setupState(cwd, spec)).digest; }
237
+ /** Per-file hashes of the setup, stored with its approval so status can show what changed. */
238
+ export async function setupManifest(cwd, spec) { return (await setupState(cwd, spec)).manifest; }
239
+
240
+ /** Files of the unpacked extension with their sha256, relative to cwd (for receipt observations). */
241
+ export async function extensionFiles(cwd, spec) {
242
+ return (await hashTree(spec.extension_dir)).map(e => ({ file_path: path.relative(cwd, e.file), sha256: e.sha }));
243
+ }
244
+
245
+ export async function buildDigest(spec) { return treeDigest(await hashTree(spec.extension_dir)); }
246
+
247
+ /**
248
+ * Private copy of everything a run uses: spec bytes, extension and local/frozen
249
+ * fixtures. Digests are computed over the copied bytes and the run serves and
250
+ * loads only the copy, so editing the workspace during a run cannot change what
251
+ * was verified versus what was approved.
252
+ */
253
+ export async function snapshotComponent(cwd, id, dir) {
254
+ const specBytes = await readSpecBytes(cwd, id), source = await parseComponent(cwd, id, specBytes);
255
+ const extension = path.join(dir, 'extension'), extensionEntries = await copyTree(source.extension_dir, extension);
256
+ const fixtures = [];
257
+ for (const fixture of source.fixtures) {
258
+ if (fixture.kind === 'live') { fixtures.push(fixture); continue; }
259
+ const target = path.join(dir, 'fixtures', fixture.id);
260
+ fixtures.push({ ...fixture, dir: target, entries: await copyTree(fixture.dir, target) });
261
+ }
262
+ const spec = Object.freeze({ ...source, extension_dir: extension, fixtures: Object.freeze(fixtures.map(({ entries, ...f }) => Object.freeze(f))) });
263
+ return { spec, source, build_digest: treeDigest(extensionEntries), setup_digest: setupDigestFrom(specBytes, fixtures), setup_manifest: manifestFrom(specBytes, fixtures),
264
+ observed_files: extensionEntries.map(e => ({ file_path: path.relative(cwd, e.file), sha256: e.sha })) };
265
+ }
@@ -0,0 +1,64 @@
1
+ // Delivery report: side-by-side reference / capture / diff for every pair and
2
+ // a per-aspect verdict table. Also builds the composite image sent to models.
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { cropImage, decodePng, encodePng } from './png.mjs';
6
+
7
+ const esc = value => String(value ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
8
+
9
+ /** Region around a box, expanded by margin and clamped to the image. */
10
+ export function regionAround(box, margin, width, height) {
11
+ if (!box) return { x: 0, y: 0, width, height };
12
+ const x = Math.max(0, Math.floor(box.x - margin)), y = Math.max(0, Math.floor(box.y - margin));
13
+ return { x, y, width: Math.min(width, Math.ceil(box.x + box.width + margin)) - x, height: Math.min(height, Math.ceil(box.y + box.height + margin)) - y };
14
+ }
15
+
16
+ function union(a, b) {
17
+ if (!a) return b; if (!b) return a;
18
+ const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
19
+ return { x, y, width: Math.max(a.x + a.width, b.x + b.width) - x, height: Math.max(a.y + a.height, b.y + b.height) - y };
20
+ }
21
+
22
+ /** Horizontal composite of equally framed panels, cropped around focus when sizes allow. */
23
+ export function composite(panels, focus) {
24
+ const images = panels.filter(Boolean).map(bytes => decodePng(bytes));
25
+ if (!images.length) return null;
26
+ const same = images.every(img => img.width === images[0].width && img.height === images[0].height);
27
+ const framed = same && focus ? images.map(img => { const r = regionAround(focus, 40, img.width, img.height); return r.width > 0 && r.height > 0 ? cropImage(img, r) : img; }) : images;
28
+ const gap = 8, height = Math.max(...framed.map(img => img.height)), width = framed.reduce((sum, img) => sum + img.width, 0) + gap * (framed.length - 1);
29
+ const data = new Uint8Array(width * height * 4).fill(255);
30
+ let left = 0;
31
+ for (const img of framed) {
32
+ for (let y = 0; y < img.height; y++) data.set(img.data.subarray(y * img.width * 4, (y + 1) * img.width * 4), (y * width + left) * 4);
33
+ left += img.width + gap;
34
+ }
35
+ return encodePng({ width, height, data });
36
+ }
37
+ export const focusOf = (componentBox, diffBounds) => union(componentBox, diffBounds);
38
+
39
+ const VERDICT_COLOR = { pass: '#1a7f37', fail: '#cf222e', blocked: '#9a6700', not_comparable: '#9a6700', inconclusive: '#6e7781' };
40
+ const badge = verdict => `<span class="v" style="background:${VERDICT_COLOR[verdict] || '#6e7781'}">${esc(verdict)}</span>`;
41
+
42
+ export async function writeReport(dir, result) {
43
+ await fs.mkdir(dir, { recursive: true });
44
+ const pairs = result.pairs.map(pair => {
45
+ const img = (label, file) => file ? `<figure><figcaption>${label}</figcaption><img src="${esc(path.basename(file))}" alt="${label}"></figure>` : '';
46
+ const aspects = pair.aspects.map(a => `<tr><td>${esc(a.id)}</td><td>${badge(a.verdict)}</td><td>${esc(a.reason || a.evidence || '')}</td></tr>`).join('');
47
+ return `<section><h2>${esc(pair.fixture)} · ${esc(pair.state)} ${badge(pair.verdict)} <small>${esc(pair.mode || 'sin referencia')}${pair.mandatory ? ' · obligatorio' : ' · informativo'}</small></h2>
48
+ ${pair.reason ? `<p class="reason">${esc(pair.reason)}</p>` : ''}
49
+ <div class="row">${img('Referencia aprobada', pair.images.reference)}${img('Captura de aceptación', pair.images.capture)}${img('Diferencias', pair.images.diff)}</div>
50
+ ${pair.pixel ? `<p>Píxeles distintos: ${pair.pixel.mismatched} (${(pair.pixel.ratio * 100).toFixed(3)}%), antialiasing tolerado: ${pair.pixel.antialiased}</p>` : ''}
51
+ <table><thead><tr><th>Aspecto</th><th>Veredicto</th><th>Evidencia</th></tr></thead><tbody>${aspects || '<tr><td colspan="3">Sin aspectos evaluados.</td></tr>'}</tbody></table></section>`;
52
+ }).join('\n');
53
+ const pending = result.pending_criteria?.length ? `<p>Criterios propuestos sin confirmar (no cuentan): ${result.pending_criteria.map(c => esc(c.id)).join(', ')}</p>` : '';
54
+ const html = `<!doctype html><html lang="es"><meta charset="utf-8"><title>Aceptación visual · ${esc(result.component)}</title>
55
+ <style>body{font:14px system-ui,sans-serif;margin:24px;color:#1f2328}h1{font-size:20px}h2{font-size:16px;margin-top:32px}.v{color:#fff;border-radius:4px;padding:1px 6px;font-size:12px}
56
+ .row{display:flex;gap:12px;flex-wrap:wrap}figure{margin:0}figcaption{font-size:12px;color:#57606a}img{max-width:420px;border:1px solid #d0d7de}table{border-collapse:collapse;margin-top:8px}
57
+ td,th{border:1px solid #d0d7de;padding:4px 8px;text-align:left;vertical-align:top}.reason{color:#9a6700}small{color:#57606a;font-weight:normal}</style>
58
+ <h1>Aceptación visual · ${esc(result.component)} ${badge(result.status === 'passed' ? 'pass' : result.status === 'failed' ? 'fail' : 'blocked')}</h1>
59
+ <p>Estado: <b>${esc(result.status)}</b>${result.reason ? ` — ${esc(result.reason)}` : ''}<br>Build: <code>${esc(result.build_digest)}</code> · Chrome: ${esc(result.fingerprint?.chrome)} · Corrida: ${esc(result.run_id)}</p>
60
+ ${pending}${pairs}</html>`;
61
+ const file = path.join(dir, 'report.html');
62
+ await fs.writeFile(file, html);
63
+ return file;
64
+ }
@@ -0,0 +1,292 @@
1
+ // Visual verification engine. The house (never the agent) loads the current
2
+ // build of the extension in a fresh Chrome profile per pair, reaches each
3
+ // (fixture, state), captures and measures it, and compares it against the
4
+ // human-approved reference: pixels in exact mode, the independent judge in
5
+ // mockup mode. Only approved references and confirmed criteria count.
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+ import fs from 'node:fs/promises';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { launchChrome, defaultChromeBinary } from './chrome.mjs';
11
+ import { startFixtureServer, readFixture } from './fixtures.mjs';
12
+ import { environmentFingerprint, fingerprintDigest, fingerprintDifferences } from './fingerprint.mjs';
13
+ import { componentInventory, readReference, referencePaths, pairKey, confinedPath, snapshotComponent } from './registry.mjs';
14
+ import { openLedger } from './ledger.mjs';
15
+ import { normalizeCriterion, evaluateMeasurable } from './measure.mjs';
16
+ import { comparePixels } from './pixel-diff.mjs';
17
+ import { decodePng, encodePng } from './png.mjs';
18
+ import { composite, focusOf, writeReport } from './report.mjs';
19
+
20
+ export const SITE_DRIFT_RATIO = 0.05;
21
+ const sha256 = value => createHash('sha256').update(value).digest('hex');
22
+ const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object'
23
+ ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
24
+ export const criteriaDigest = criteria => sha256(JSON.stringify(canonical([...criteria].sort((a, b) => a.id.localeCompare(b.id, 'en')))));
25
+ export const referencesDigest = references => sha256(JSON.stringify(Object.keys(references).sort().map(key => [key, references[key].digest])));
26
+ /** Approvals of the pairs the (approved) spec declares; orphaned approvals never count. */
27
+ export const approvedReferences = (references, pairs) => Object.fromEntries(Object.entries(references).filter(([key]) => pairs.some(pair => pair.key === key)));
28
+ const scaleBox = (box, scale) => box && { x: box.x * scale, y: box.y * scale, width: box.width * scale, height: box.height * scale };
29
+
30
+ function factsRequest(spec, criteria) {
31
+ const measurable = criteria.filter(c => c.kind === 'measurable');
32
+ const elements = new Set(spec.elements), anchors = new Set([...spec.anchors, ...spec.regions]), properties = new Set(['color', 'background-color']);
33
+ for (const c of measurable) {
34
+ if (c.element !== 'root') elements.add(c.element);
35
+ if (c.check.type === 'relation') for (const anchor of [].concat(c.check.anchor)) anchors.add(anchor);
36
+ if (c.check.type === 'css') properties.add(c.check.property);
37
+ }
38
+ return { root: spec.root, elements: [...elements], anchors: [...anchors], properties: [...properties] };
39
+ }
40
+
41
+ // Mismatched pixels outside the component region, for live-site drift.
42
+ function outsideRatio(reference, capture, box) {
43
+ if (reference.width !== capture.width || reference.height !== capture.height) return 1;
44
+ const result = comparePixels(reference, capture, { threshold: 0.1 });
45
+ if (!result.diff) return 1;
46
+ const x0 = box ? box.x - 4 : -1, y0 = box ? box.y - 4 : -1, x1 = box ? box.x + box.width + 4 : -1, y1 = box ? box.y + box.height + 4 : -1;
47
+ let outside = 0, total = 0;
48
+ for (let y = 0; y < capture.height; y++) for (let x = 0; x < capture.width; x++) {
49
+ if (x >= x0 && x <= x1 && y >= y0 && y <= y1) continue;
50
+ total++;
51
+ const i = (y * capture.width + x) * 4;
52
+ if (result.diff.data[i] === 255 && result.diff.data[i + 1] === 0) outside++;
53
+ }
54
+ return total ? outside / total : 0;
55
+ }
56
+
57
+ // Steps for the reference run without the extension: interactions with the
58
+ // component itself (its host selector) and extension-context steps are skipped.
59
+ function baselineSteps(spec, state) {
60
+ const host = spec.root.split('>>>')[0].trim();
61
+ return [...spec.setup, ...state.steps].filter(step => step.action !== 'extension' && !(step.selector && step.selector.split('>>>')[0].trim() === host));
62
+ }
63
+ const containsBox = (outer, inner) => inner.x >= outer.x - 2 && inner.y >= outer.y - 2 && inner.x + inner.width <= outer.x + outer.width + 2 && inner.y + inner.height <= outer.y + outer.height + 2;
64
+ const sameBox = (a, b) => ['x', 'y', 'width', 'height'].every(key => Math.abs(a[key] - b[key]) < 0.5);
65
+
66
+ /**
67
+ * Containment: elements that exist only with the extension and visibly paint
68
+ * must lie inside the component, its anchors or the approved regions. This is
69
+ * what stops an extension from painting the approved reference as a bitmap.
70
+ */
71
+ async function checkContainment({ page, baselinePage, baselineElements, facts, spec }) {
72
+ const allowed = [facts.elements.root?.box, ...spec.anchors.concat(spec.regions).map(selector => facts.anchors[selector]?.box)].filter(Boolean);
73
+ const baseBySignature = new Map();
74
+ for (const entry of baselineElements) { if (!baseBySignature.has(entry.signature)) baseBySignature.set(entry.signature, []); baseBySignature.get(entry.signature).push(entry); }
75
+ const extBySignature = new Map();
76
+ for (const entry of await page.elements()) { if (!extBySignature.has(entry.signature)) extBySignature.set(entry.signature, []); extBySignature.get(entry.signature).push(entry); }
77
+ const candidates = [];
78
+ for (const [signature, list] of extBySignature) {
79
+ const base = baseBySignature.get(signature) || [];
80
+ if (list.length <= base.length) continue;
81
+ const baseBoxes = (await Promise.all(base.map(entry => baselinePage.rectOf(entry)))).filter(Boolean);
82
+ for (const entry of list) {
83
+ const box = await page.rectOf(entry);
84
+ if (!box || baseBoxes.some(b => sameBox(b, box))) continue;
85
+ candidates.push(entry);
86
+ if (candidates.length > 3000) return { verdict: 'inconclusive', reason: 'La extensión agregó más de 3000 elementos; no se pudo comprobar la contención.' };
87
+ }
88
+ }
89
+ const view = spec.viewport, violations = [];
90
+ for (const entry of candidates) {
91
+ const painted = await page.paintOf(entry);
92
+ if (!painted || painted.x >= view.width || painted.y >= view.height || painted.x + painted.width <= 0 || painted.y + painted.height <= 0) continue;
93
+ if (!allowed.some(box => containsBox(box, painted))) violations.push({ tag: painted.tag, paints: painted.reasons, box: { x: painted.x, y: painted.y, width: painted.width, height: painted.height }, signature: entry.signature.slice(-160) });
94
+ }
95
+ if (violations.length) return { verdict: 'fail', violations: violations.slice(0, 5),
96
+ reason: `La extensión pinta fuera de su componente en ${violations.length} elemento(s), p. ej. <${violations[0].tag}> (${violations[0].paints.join(', ')}) en x=${violations[0].box.x}, y=${violations[0].box.y}, ${violations[0].box.width}x${violations[0].box.height}. Si es legítimo, el humano puede declarar regions en el spec.` };
97
+ return { verdict: 'pass', reason: `${candidates.length} elemento(s) propio(s) de la extensión, todos dentro del componente o de sus regiones.` };
98
+ }
99
+
100
+ /** Load the build and reach one pair; returns capture bytes, facts, containment and any step error. */
101
+ async function capturePair({ spec, fixture, state, server, chromeBinary, criteria, signal }) {
102
+ const offline = fixture.kind !== 'live', url = offline ? server.urlFor(fixture.id) : fixture.url;
103
+ const launch = () => launchChrome({ binary: chromeBinary, viewport: spec.viewport, hostRules: offline ? server.hostRules({ offline: true }) : [], ignoreCertificateErrors: offline, signal });
104
+ // Reference run without the extension, kept open until containment is measured.
105
+ let baseline = null, baselinePage = null, baselineElements = null, baselineError = null;
106
+ if (offline) {
107
+ baseline = await launch();
108
+ try { baselinePage = await baseline.openPage(); await baselinePage.navigate(url); await baselinePage.runSteps(baselineSteps(spec, state)); baselineElements = await baselinePage.elements(); }
109
+ catch (error) { if (signal?.aborted) { await baseline.close(); throw error; } baselineError = error.message; }
110
+ }
111
+ const session = await launch().catch(async error => { await baseline?.close(); throw error; });
112
+ try {
113
+ await session.loadExtension(spec.extension_dir);
114
+ const page = await session.openPage();
115
+ let stepError = null;
116
+ try {
117
+ await page.navigate(url);
118
+ await page.runSteps([...spec.setup, ...state.steps]);
119
+ } catch (error) { if (signal?.aborted) throw error; stepError = error.message; }
120
+ const capture = await page.screenshot();
121
+ let facts = { elements: {}, anchors: {} }, factsError = null;
122
+ try { facts = await page.facts(factsRequest(spec, criteria)); } catch (error) { if (signal?.aborted) throw error; factsError = error.message; }
123
+ let containment = null;
124
+ if (offline && !stepError && !factsError && facts.elements.root)
125
+ containment = baselineError ? { verdict: 'inconclusive', reason: `No se pudo reproducir el fixture sin la extensión: ${baselineError}` }
126
+ : await checkContainment({ page, baselinePage, baselineElements, facts, spec });
127
+ return { capture, facts, stepError, factsError, containment, fingerprint: environmentFingerprint({ version: session.version, viewport: spec.viewport, headless: session.headless }) };
128
+ } finally { await session.close(); await baseline?.close(); }
129
+ }
130
+
131
+ function overall(pairs) {
132
+ const mandatory = pairs.filter(p => p.mandatory);
133
+ if (!mandatory.length) return { status: 'blocked', reason: 'No hay pares obligatorios (fixtures locales o congelados).' };
134
+ if (mandatory.some(p => p.verdict === 'fail')) return { status: 'failed', reason: `${mandatory.filter(p => p.verdict === 'fail').length} par(es) obligatorio(s) no coinciden.` };
135
+ if (mandatory.every(p => p.verdict === 'pass')) return { status: 'passed', reason: null };
136
+ return { status: 'blocked', reason: `${mandatory.filter(p => p.verdict !== 'pass').length} par(es) obligatorio(s) sin veredicto concluyente.` };
137
+ }
138
+
139
+ /**
140
+ * mode 'acceptance': requires approved references and uses confirmed criteria.
141
+ * mode 'inspection': captures and measures with proposed criteria; nothing is approved.
142
+ */
143
+ export async function verifyComponent(options) {
144
+ const snapshotDir = await fs.mkdtemp(path.join(os.tmpdir(), 'comal-visual-run-'));
145
+ try { return await verifySnapshot({ ...options, snapshot: await snapshotComponent(options.cwd, options.component, snapshotDir) }); }
146
+ finally { await fs.rm(snapshotDir, { recursive: true, force: true }); }
147
+ }
148
+
149
+ /** Run against an existing snapshot (snapshotComponent); verifyComponent takes and removes one. */
150
+ export async function verifyFromSnapshot(options) { return verifySnapshot(options); }
151
+
152
+ async function verifySnapshot({ cwd, stateDir, keyDir, component, judge, mode = 'acceptance', pairs: only, signal, chromeBinary = defaultChromeBinary(), runId = randomUUID(), evidenceRoot, snapshot }) {
153
+ const inventory = await componentInventory(cwd, component, snapshot.spec), spec = inventory.spec;
154
+ const ledger = await openLedger({ stateDir, keyDir }), effective = await ledger.effective(component);
155
+ const confirmed = effective.criteria.map(c => normalizeCriterion(c));
156
+ const criteria = mode === 'acceptance' ? confirmed : inventory.proposedCriteria;
157
+ const pending = inventory.proposedCriteria.filter(c => !confirmed.some(k => k.id === c.id && JSON.stringify(canonical(k)) === JSON.stringify(canonical(c))));
158
+ const setup_digest = snapshot.setup_digest;
159
+ const setupApproved = effective.setup?.digest === setup_digest;
160
+ // Acceptance runs only on the configuration the human approved (spec + fixtures).
161
+ const selected = mode === 'acceptance' && (!setupApproved || effective.invalid_entries) ? [] : inventory.pairs.filter(p => !only || only.includes(p.key));
162
+ const references = approvedReferences(effective.references, inventory.pairs);
163
+ const dir = path.join(evidenceRoot ?? path.join(cwd, 'evidence'), 'visual', component, runId);
164
+ await fs.mkdir(dir, { recursive: true });
165
+ const { build_digest, observed_files } = snapshot;
166
+ const localFixtures = spec.fixtures.filter(f => f.kind !== 'live' && selected.some(p => p.fixture === f.id));
167
+ const server = localFixtures.length ? await startFixtureServer({ fixtures: localFixtures.map(f => f.dir), stateDir }) : null;
168
+ const results = [], captures = new Map();
169
+ let fingerprint = null;
170
+ try {
171
+ for (const pair of selected) {
172
+ signal?.throwIfAborted();
173
+ const fixture = spec.fixtures.find(f => f.id === pair.fixture), state = spec.states.find(s => s.id === pair.state);
174
+ const shot = await capturePair({ spec, fixture, state, server, chromeBinary, criteria, signal });
175
+ fingerprint ??= shot.fingerprint;
176
+ captures.set(pair.key, shot);
177
+ const base = path.join(dir, pair.key), images = { capture: `${base}.capture.png` };
178
+ await fs.writeFile(images.capture, shot.capture);
179
+ const box = shot.facts.elements.root?.box ?? null;
180
+ // A criterion applies to the states it names (all when it names none); one naming a state the spec lacks can never be checked, so it blocks.
181
+ const ghostStates = c => (c.states || []).filter(s => !spec.states.some(x => x.id === s));
182
+ const applies = c => !ghostStates(c).length && (!c.states || c.states.includes(pair.state));
183
+ const unscoped = criteria.filter(c => ghostStates(c).length).map(c => ({ id: c.id, kind: c.kind, statement: c.statement, verdict: 'inconclusive', measured: null,
184
+ evidence: `El criterio nombra estados que el spec no tiene: ${ghostStates(c).join(', ')}.` }));
185
+ const measurements = [...criteria.filter(c => c.kind === 'measurable' && applies(c)).map(c => evaluateMeasurable(c, shot.facts)), ...unscoped,
186
+ ...(shot.containment ? [{ id: 'containment', kind: 'containment', statement: 'La extensión sólo pinta dentro de su componente', verdict: shot.containment.verdict, measured: shot.containment.violations ?? null, evidence: shot.containment.reason }] : [])];
187
+ const entry = { fixture: pair.fixture, state: pair.state, key: pair.key, kind: pair.kind, mandatory: pair.mandatory, mode: null, verdict: null, reason: null,
188
+ aspects: [], measurements, pixel: null, component_box: box, images };
189
+ const finish = (verdict, reason) => { entry.verdict = verdict; entry.reason = reason; results.push(entry); };
190
+ if (shot.stepError) { entry.aspects.push(...measurements); finish('fail', `No se llegó al estado: ${shot.stepError}`); continue; }
191
+ if (shot.factsError) { finish('fail', `No se pudieron medir el componente o sus elementos: ${shot.factsError}`); continue; }
192
+ if (!box) { finish('fail', `El componente ${spec.root} no está montado en este estado.`); continue; }
193
+ const reference = await readReference(cwd, component, pair.key);
194
+ if (reference?.error) { entry.aspects.push(...measurements); finish('blocked', `Referencia inválida: ${reference.error}`); continue; }
195
+ const approval = references[pair.key];
196
+ if (mode === 'acceptance') {
197
+ if (!reference) { entry.aspects.push(...measurements); finish('blocked', 'Falta la referencia de este par.'); continue; }
198
+ if (!approval || approval.digest !== reference.digest) { entry.aspects.push(...measurements); finish('blocked', approval ? 'La referencia cambió después de aprobarse; vuelve a aprobarla.' : 'La referencia no está aprobada.'); continue; }
199
+ }
200
+ if (!reference) { entry.aspects.push(...measurements); finish(measurements.some(m => m.verdict === 'fail') ? 'fail' : 'inconclusive', 'Sin referencia: sólo mediciones.'); continue; }
201
+ entry.mode = reference.mode;
202
+ images.reference = `${base}.reference.png`;
203
+ await fs.writeFile(images.reference, reference.png);
204
+ const refImage = decodePng(reference.png), capImage = decodePng(shot.capture);
205
+ if (reference.mode === 'exact') {
206
+ const differences = reference.meta.fingerprint ? fingerprintDifferences(reference.meta.fingerprint, shot.fingerprint) : ['fingerprint'];
207
+ if (differences.length) { entry.aspects.push(...measurements); finish('not_comparable', `El entorno de captura cambió (${differences.join(', ')}); recaptura y vuelve a aprobar la referencia.`); continue; }
208
+ const pixel = comparePixels(refImage, capImage, reference.meta.tolerance);
209
+ entry.pixel = pixel.reason ? { reason: pixel.reason } : { mismatched: pixel.mismatched, antialiased: pixel.antialiased, ratio: pixel.ratio, bounds: pixel.bounds };
210
+ if (pixel.diff) { images.diff = `${base}.diff.png`; await fs.writeFile(images.diff, encodePng(pixel.diff)); }
211
+ entry.aspects.push({ id: 'pixels', verdict: pixel.passed ? 'pass' : 'fail',
212
+ reason: pixel.reason === 'dimension_mismatch' ? `Tamaño distinto: referencia ${refImage.width}x${refImage.height}, captura ${capImage.width}x${capImage.height}.`
213
+ : pixel.passed ? `Coincide (${pixel.mismatched} píxeles distintos, ${pixel.antialiased} de antialiasing tolerados).`
214
+ : `${pixel.mismatched} píxeles distintos en x=${pixel.bounds.x}, y=${pixel.bounds.y}, ${pixel.bounds.width}x${pixel.bounds.height}.` }, ...measurements);
215
+ } else {
216
+ if (pair.kind === 'live') {
217
+ const drift = outsideRatio(refImage, capImage, scaleBox(box, spec.viewport.deviceScaleFactor));
218
+ if (drift > SITE_DRIFT_RATIO) { entry.aspects.push(...measurements); finish('inconclusive', `El sitio vivo cambió: ${(drift * 100).toFixed(1)}% de píxeles distintos fuera del componente (site_drift).`); continue; }
219
+ }
220
+ if (typeof judge !== 'function') {
221
+ entry.aspects.push(...measurements);
222
+ // Inspection never calls the reviewer; only an acceptance run without one is blocked.
223
+ if (mode === 'inspection') finish(measurements.some(m => m.verdict === 'fail') ? 'fail' : 'inconclusive', 'Inspección: el revisor independiente juzga los pares mockup sólo en VisualVerify; aquí sólo hay mediciones.');
224
+ else finish('blocked', 'No hay revisor visual independiente configurado.');
225
+ continue;
226
+ }
227
+ const verdict = await judge({ reference: reference.png, capture: shot.capture, criteria: criteria.filter(c => c.kind === 'judged' && applies(c)), measurements,
228
+ context: { component, fixture: pair.fixture, state: pair.state, origin: fixture.kind === 'live' ? fixture.url : server.urlFor(fixture.id), ...(box ? { component_box: box } : {}) }, signal });
229
+ entry.judge = { model: verdict.model ?? null, independent_model: verdict.independent_model ?? null, summary: verdict.summary ?? null };
230
+ entry.aspects.push(...verdict.aspects, ...measurements);
231
+ if (verdict.verdict === 'blocked') { finish('blocked', verdict.reason); continue; }
232
+ }
233
+ const verdicts = entry.aspects.map(a => a.verdict);
234
+ finish(verdicts.length && verdicts.every(v => v === 'pass') ? 'pass' : verdicts.includes('fail') ? 'fail' : 'inconclusive', null);
235
+ }
236
+ } finally { await server?.close(); }
237
+ for (const entry of results) {
238
+ const shot = captures.get(entry.key);
239
+ const panels = [entry.images.reference ? await fs.readFile(entry.images.reference) : null, shot.capture, entry.images.diff ? await fs.readFile(entry.images.diff) : null];
240
+ const image = composite(panels, focusOf(scaleBox(entry.component_box, spec.viewport.deviceScaleFactor), entry.pixel?.bounds));
241
+ if (image) { entry.images.composite = path.join(dir, `${entry.key}.composite.png`); await fs.writeFile(entry.images.composite, image); }
242
+ }
243
+ const verdict = mode !== 'acceptance' ? null : effective.invalid_entries
244
+ ? { status: 'blocked', reason: `El registro de aprobaciones tiene ${effective.invalid_entries} entrada(s) inválida(s).` }
245
+ : !setupApproved ? { status: 'blocked', reason: effective.setup ? 'La configuración del componente (spec o fixtures) cambió después de aprobarse; revísala y apruébala con /visual approve-setup.' : 'La configuración del componente (spec y fixtures) no está aprobada: /visual approve-setup.' }
246
+ : overall(results);
247
+ const result = { kind: mode, status: verdict ? verdict.status : 'inspected', reason: verdict ? verdict.reason : null,
248
+ component, run_id: runId, build_digest, setup_digest, setup_approved: setupApproved, references_digest: referencesDigest(references), criteria_digest: criteriaDigest(confirmed),
249
+ fingerprint, fingerprint_digest: fingerprint ? fingerprintDigest(fingerprint) : null,
250
+ criteria: criteria.map(c => c.id), pending_criteria: pending.map(c => ({ id: c.id, statement: c.statement })), proposal_errors: inventory.proposalErrors,
251
+ ledger_invalid_entries: effective.invalid_entries, pairs: results, observed_files, evidence_dir: path.relative(cwd, dir) };
252
+ result.report = path.relative(cwd, await writeReport(dir, result));
253
+ for (const entry of results) for (const key of Object.keys(entry.images)) entry.images[key] = path.relative(cwd, entry.images[key]);
254
+ result.captures = captures;
255
+ return result;
256
+ }
257
+
258
+ export const MAQUETTE_PATH = '/__comal_maquette__.html';
259
+
260
+ /**
261
+ * Render a static maquette (the fixture page with the component drawn by hand)
262
+ * at the fixture's origin, without loading the extension, in the component
263
+ * viewport. Its capture can become an exact reference once the human approves it.
264
+ */
265
+ export async function captureMaquette({ cwd, stateDir, component, fixture: fixtureId, file, chromeBinary = defaultChromeBinary(), signal }) {
266
+ const inventory = await componentInventory(cwd, component), spec = inventory.spec;
267
+ const fixture = spec.fixtures.find(f => f.id === fixtureId);
268
+ if (!fixture || fixture.kind === 'live') throw Object.assign(new Error(`La maqueta necesita un fixture local o congelado del componente; ${fixtureId} no lo es.`), { code: 'VISUAL_MAQUETTE_INVALID' });
269
+ const resolved = await confinedPath(cwd, file, 'maqueta');
270
+ const origin = (await readFixture(fixture.dir)).origin;
271
+ const server = await startFixtureServer({ fixtures: [fixture.dir], stateDir, overlays: [{ origin, path: MAQUETTE_PATH, file: resolved }] });
272
+ try {
273
+ const session = await launchChrome({ binary: chromeBinary, viewport: spec.viewport, hostRules: server.hostRules({ offline: true }), ignoreCertificateErrors: true, signal });
274
+ try {
275
+ const page = await session.openPage();
276
+ await page.navigate(origin + MAQUETTE_PATH);
277
+ return { capture: await page.screenshot(), origin, fingerprint: environmentFingerprint({ version: session.version, viewport: spec.viewport, headless: session.headless }) };
278
+ } finally { await session.close(); }
279
+ } finally { await server.close(); }
280
+ }
281
+
282
+ /** Write a proposed reference for a pair from a capture; it does not count until the human approves it. */
283
+ export async function writeReferenceDraft({ cwd, component, fixture, state, mode, png, fingerprint, source, tolerance }) {
284
+ if (!['exact', 'mockup'].includes(mode)) throw Object.assign(new Error('mode debe ser exact o mockup'), { code: 'VISUAL_REFERENCE_INVALID' });
285
+ const paths = referencePaths(cwd, component, pairKey(fixture, state));
286
+ await fs.mkdir(path.dirname(paths.png), { recursive: true });
287
+ const meta = { mode, ...(mode === 'exact' ? { fingerprint, ...(tolerance ? { tolerance } : {}) } : {}), source, captured_at: new Date().toISOString() };
288
+ await fs.writeFile(paths.png, png);
289
+ await fs.writeFile(paths.meta, JSON.stringify(meta, null, 2) + '\n');
290
+ const reference = await readReference(cwd, component, pairKey(fixture, state));
291
+ return { png: path.relative(cwd, paths.png), meta: path.relative(cwd, paths.meta), sha256: sha256(png), digest: reference.digest };
292
+ }