@ambitresearch/genie 1.3.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.
Files changed (301) hide show
  1. package/README.md +15 -0
  2. package/dist/auth/bearer.d.ts +49 -0
  3. package/dist/auth/bearer.d.ts.map +1 -0
  4. package/dist/auth/bearer.js +159 -0
  5. package/dist/auth/bearer.js.map +1 -0
  6. package/dist/auth/oauth/authorize.d.ts +19 -0
  7. package/dist/auth/oauth/authorize.d.ts.map +1 -0
  8. package/dist/auth/oauth/authorize.js +111 -0
  9. package/dist/auth/oauth/authorize.js.map +1 -0
  10. package/dist/auth/oauth/config.d.ts +10 -0
  11. package/dist/auth/oauth/config.d.ts.map +1 -0
  12. package/dist/auth/oauth/config.js +16 -0
  13. package/dist/auth/oauth/config.js.map +1 -0
  14. package/dist/auth/oauth/dcr.d.ts +15 -0
  15. package/dist/auth/oauth/dcr.d.ts.map +1 -0
  16. package/dist/auth/oauth/dcr.js +38 -0
  17. package/dist/auth/oauth/dcr.js.map +1 -0
  18. package/dist/auth/oauth/jwt.d.ts +13 -0
  19. package/dist/auth/oauth/jwt.d.ts.map +1 -0
  20. package/dist/auth/oauth/jwt.js +46 -0
  21. package/dist/auth/oauth/jwt.js.map +1 -0
  22. package/dist/auth/oauth/metadata.d.ts +9 -0
  23. package/dist/auth/oauth/metadata.d.ts.map +1 -0
  24. package/dist/auth/oauth/metadata.js +19 -0
  25. package/dist/auth/oauth/metadata.js.map +1 -0
  26. package/dist/auth/oauth/router.d.ts +32 -0
  27. package/dist/auth/oauth/router.d.ts.map +1 -0
  28. package/dist/auth/oauth/router.js +159 -0
  29. package/dist/auth/oauth/router.js.map +1 -0
  30. package/dist/auth/oauth/store.d.ts +55 -0
  31. package/dist/auth/oauth/store.d.ts.map +1 -0
  32. package/dist/auth/oauth/store.js +63 -0
  33. package/dist/auth/oauth/store.js.map +1 -0
  34. package/dist/auth/oauth/token.d.ts +21 -0
  35. package/dist/auth/oauth/token.d.ts.map +1 -0
  36. package/dist/auth/oauth/token.js +77 -0
  37. package/dist/auth/oauth/token.js.map +1 -0
  38. package/dist/auth/oidc/group-policy.d.ts +43 -0
  39. package/dist/auth/oidc/group-policy.d.ts.map +1 -0
  40. package/dist/auth/oidc/group-policy.js +54 -0
  41. package/dist/auth/oidc/group-policy.js.map +1 -0
  42. package/dist/auth/oidc/verifier.d.ts +42 -0
  43. package/dist/auth/oidc/verifier.d.ts.map +1 -0
  44. package/dist/auth/oidc/verifier.js +145 -0
  45. package/dist/auth/oidc/verifier.js.map +1 -0
  46. package/dist/auth/token-cli.d.ts +9 -0
  47. package/dist/auth/token-cli.d.ts.map +1 -0
  48. package/dist/auth/token-cli.js +98 -0
  49. package/dist/auth/token-cli.js.map +1 -0
  50. package/dist/cli.d.ts +3 -0
  51. package/dist/cli.d.ts.map +1 -0
  52. package/dist/cli.js +149 -0
  53. package/dist/cli.js.map +1 -0
  54. package/dist/config/redact.d.ts +20 -0
  55. package/dist/config/redact.d.ts.map +1 -0
  56. package/dist/config/redact.js +77 -0
  57. package/dist/config/redact.js.map +1 -0
  58. package/dist/config/secrets.d.ts +100 -0
  59. package/dist/config/secrets.d.ts.map +1 -0
  60. package/dist/config/secrets.js +130 -0
  61. package/dist/config/secrets.js.map +1 -0
  62. package/dist/framework/html.d.ts +103 -0
  63. package/dist/framework/html.d.ts.map +1 -0
  64. package/dist/framework/html.js +271 -0
  65. package/dist/framework/html.js.map +1 -0
  66. package/dist/framework/interface.d.ts +130 -0
  67. package/dist/framework/interface.d.ts.map +1 -0
  68. package/dist/framework/interface.js +112 -0
  69. package/dist/framework/interface.js.map +1 -0
  70. package/dist/framework/react.d.ts +133 -0
  71. package/dist/framework/react.d.ts.map +1 -0
  72. package/dist/framework/react.js +243 -0
  73. package/dist/framework/react.js.map +1 -0
  74. package/dist/framework/vue.d.ts +99 -0
  75. package/dist/framework/vue.d.ts.map +1 -0
  76. package/dist/framework/vue.js +375 -0
  77. package/dist/framework/vue.js.map +1 -0
  78. package/dist/index.d.ts +3 -0
  79. package/dist/index.d.ts.map +1 -0
  80. package/dist/index.js +3 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/llm/client.d.ts +81 -0
  83. package/dist/llm/client.d.ts.map +1 -0
  84. package/dist/llm/client.js +141 -0
  85. package/dist/llm/client.js.map +1 -0
  86. package/dist/llm/component-response.d.ts +111 -0
  87. package/dist/llm/component-response.d.ts.map +1 -0
  88. package/dist/llm/component-response.js +167 -0
  89. package/dist/llm/component-response.js.map +1 -0
  90. package/dist/llm/generated-files.d.ts +10 -0
  91. package/dist/llm/generated-files.d.ts.map +1 -0
  92. package/dist/llm/generated-files.js +26 -0
  93. package/dist/llm/generated-files.js.map +1 -0
  94. package/dist/llm/prompts/CHANGELOG.md +70 -0
  95. package/dist/llm/prompts/generate-component.system.md +99 -0
  96. package/dist/llm/prompts/refine-component.system.md +112 -0
  97. package/dist/llm/prompts.d.ts +66 -0
  98. package/dist/llm/prompts.d.ts.map +1 -0
  99. package/dist/llm/prompts.js +78 -0
  100. package/dist/llm/prompts.js.map +1 -0
  101. package/dist/llm/retry.d.ts +183 -0
  102. package/dist/llm/retry.d.ts.map +1 -0
  103. package/dist/llm/retry.js +475 -0
  104. package/dist/llm/retry.js.map +1 -0
  105. package/dist/llm/schema.d.ts +129 -0
  106. package/dist/llm/schema.d.ts.map +1 -0
  107. package/dist/llm/schema.js +147 -0
  108. package/dist/llm/schema.js.map +1 -0
  109. package/dist/llm/validate.d.ts +107 -0
  110. package/dist/llm/validate.d.ts.map +1 -0
  111. package/dist/llm/validate.js +174 -0
  112. package/dist/llm/validate.js.map +1 -0
  113. package/dist/manifest/compiler.d.ts +112 -0
  114. package/dist/manifest/compiler.d.ts.map +1 -0
  115. package/dist/manifest/compiler.js +306 -0
  116. package/dist/manifest/compiler.js.map +1 -0
  117. package/dist/manifest/ensure.d.ts +46 -0
  118. package/dist/manifest/ensure.d.ts.map +1 -0
  119. package/dist/manifest/ensure.js +49 -0
  120. package/dist/manifest/ensure.js.map +1 -0
  121. package/dist/manifest/index.d.ts +13 -0
  122. package/dist/manifest/index.d.ts.map +1 -0
  123. package/dist/manifest/index.js +13 -0
  124. package/dist/manifest/index.js.map +1 -0
  125. package/dist/middleware/plan-guard.d.ts +167 -0
  126. package/dist/middleware/plan-guard.d.ts.map +1 -0
  127. package/dist/middleware/plan-guard.js +190 -0
  128. package/dist/middleware/plan-guard.js.map +1 -0
  129. package/dist/plans/index.d.ts +71 -0
  130. package/dist/plans/index.d.ts.map +1 -0
  131. package/dist/plans/index.js +252 -0
  132. package/dist/plans/index.js.map +1 -0
  133. package/dist/schemas/component.schema.json +114 -0
  134. package/dist/server.d.ts +96 -0
  135. package/dist/server.d.ts.map +1 -0
  136. package/dist/server.js +258 -0
  137. package/dist/server.js.map +1 -0
  138. package/dist/store/empty-manifest.d.ts +58 -0
  139. package/dist/store/empty-manifest.d.ts.map +1 -0
  140. package/dist/store/empty-manifest.js +59 -0
  141. package/dist/store/empty-manifest.js.map +1 -0
  142. package/dist/store/git-host.d.ts +120 -0
  143. package/dist/store/git-host.d.ts.map +1 -0
  144. package/dist/store/git-host.js +796 -0
  145. package/dist/store/git-host.js.map +1 -0
  146. package/dist/store/index.d.ts +5 -0
  147. package/dist/store/index.d.ts.map +1 -0
  148. package/dist/store/index.js +4 -0
  149. package/dist/store/index.js.map +1 -0
  150. package/dist/store/interface.d.ts +322 -0
  151. package/dist/store/interface.d.ts.map +1 -0
  152. package/dist/store/interface.js +100 -0
  153. package/dist/store/interface.js.map +1 -0
  154. package/dist/store/kit-files.d.ts +88 -0
  155. package/dist/store/kit-files.d.ts.map +1 -0
  156. package/dist/store/kit-files.js +199 -0
  157. package/dist/store/kit-files.js.map +1 -0
  158. package/dist/store/local.d.ts +70 -0
  159. package/dist/store/local.d.ts.map +1 -0
  160. package/dist/store/local.js +651 -0
  161. package/dist/store/local.js.map +1 -0
  162. package/dist/store/manifest.d.ts +44 -0
  163. package/dist/store/manifest.d.ts.map +1 -0
  164. package/dist/store/manifest.js +116 -0
  165. package/dist/store/manifest.js.map +1 -0
  166. package/dist/store/viewer-assets.d.ts +20 -0
  167. package/dist/store/viewer-assets.d.ts.map +1 -0
  168. package/dist/store/viewer-assets.js +118 -0
  169. package/dist/store/viewer-assets.js.map +1 -0
  170. package/dist/sync/anchor.d.ts +125 -0
  171. package/dist/sync/anchor.d.ts.map +1 -0
  172. package/dist/sync/anchor.js +210 -0
  173. package/dist/sync/anchor.js.map +1 -0
  174. package/dist/sync/index.d.ts +15 -0
  175. package/dist/sync/index.d.ts.map +1 -0
  176. package/dist/sync/index.js +15 -0
  177. package/dist/sync/index.js.map +1 -0
  178. package/dist/sync/orchestrator.d.ts +160 -0
  179. package/dist/sync/orchestrator.d.ts.map +1 -0
  180. package/dist/sync/orchestrator.js +288 -0
  181. package/dist/sync/orchestrator.js.map +1 -0
  182. package/dist/tools/bind_kit.d.ts +29 -0
  183. package/dist/tools/bind_kit.d.ts.map +1 -0
  184. package/dist/tools/bind_kit.js +70 -0
  185. package/dist/tools/bind_kit.js.map +1 -0
  186. package/dist/tools/conjure.d.ts +141 -0
  187. package/dist/tools/conjure.d.ts.map +1 -0
  188. package/dist/tools/conjure.js +655 -0
  189. package/dist/tools/conjure.js.map +1 -0
  190. package/dist/tools/conjure_screen.d.ts +174 -0
  191. package/dist/tools/conjure_screen.d.ts.map +1 -0
  192. package/dist/tools/conjure_screen.js +458 -0
  193. package/dist/tools/conjure_screen.js.map +1 -0
  194. package/dist/tools/create_kit.d.ts +32 -0
  195. package/dist/tools/create_kit.d.ts.map +1 -0
  196. package/dist/tools/create_kit.js +131 -0
  197. package/dist/tools/create_kit.js.map +1 -0
  198. package/dist/tools/create_project.d.ts +267 -0
  199. package/dist/tools/create_project.d.ts.map +1 -0
  200. package/dist/tools/create_project.js +546 -0
  201. package/dist/tools/create_project.js.map +1 -0
  202. package/dist/tools/delete_files.d.ts +93 -0
  203. package/dist/tools/delete_files.d.ts.map +1 -0
  204. package/dist/tools/delete_files.js +276 -0
  205. package/dist/tools/delete_files.js.map +1 -0
  206. package/dist/tools/delete_project.d.ts +43 -0
  207. package/dist/tools/delete_project.d.ts.map +1 -0
  208. package/dist/tools/delete_project.js +118 -0
  209. package/dist/tools/delete_project.js.map +1 -0
  210. package/dist/tools/get_kit.d.ts +28 -0
  211. package/dist/tools/get_kit.d.ts.map +1 -0
  212. package/dist/tools/get_kit.js +95 -0
  213. package/dist/tools/get_kit.js.map +1 -0
  214. package/dist/tools/get_project.d.ts +37 -0
  215. package/dist/tools/get_project.d.ts.map +1 -0
  216. package/dist/tools/get_project.js +71 -0
  217. package/dist/tools/get_project.js.map +1 -0
  218. package/dist/tools/list_components.d.ts +42 -0
  219. package/dist/tools/list_components.d.ts.map +1 -0
  220. package/dist/tools/list_components.js +125 -0
  221. package/dist/tools/list_components.js.map +1 -0
  222. package/dist/tools/list_files.d.ts +32 -0
  223. package/dist/tools/list_files.d.ts.map +1 -0
  224. package/dist/tools/list_files.js +109 -0
  225. package/dist/tools/list_files.js.map +1 -0
  226. package/dist/tools/list_kits.d.ts +14 -0
  227. package/dist/tools/list_kits.d.ts.map +1 -0
  228. package/dist/tools/list_kits.js +41 -0
  229. package/dist/tools/list_kits.js.map +1 -0
  230. package/dist/tools/list_projects.d.ts +23 -0
  231. package/dist/tools/list_projects.d.ts.map +1 -0
  232. package/dist/tools/list_projects.js +66 -0
  233. package/dist/tools/list_projects.js.map +1 -0
  234. package/dist/tools/plan.d.ts +16 -0
  235. package/dist/tools/plan.d.ts.map +1 -0
  236. package/dist/tools/plan.js +146 -0
  237. package/dist/tools/plan.js.map +1 -0
  238. package/dist/tools/preview.d.ts +289 -0
  239. package/dist/tools/preview.d.ts.map +1 -0
  240. package/dist/tools/preview.js +672 -0
  241. package/dist/tools/preview.js.map +1 -0
  242. package/dist/tools/read_file.d.ts +40 -0
  243. package/dist/tools/read_file.d.ts.map +1 -0
  244. package/dist/tools/read_file.js +114 -0
  245. package/dist/tools/read_file.js.map +1 -0
  246. package/dist/tools/refine.d.ts +220 -0
  247. package/dist/tools/refine.d.ts.map +1 -0
  248. package/dist/tools/refine.js +501 -0
  249. package/dist/tools/refine.js.map +1 -0
  250. package/dist/tools/validate.d.ts +74 -0
  251. package/dist/tools/validate.d.ts.map +1 -0
  252. package/dist/tools/validate.js +224 -0
  253. package/dist/tools/validate.js.map +1 -0
  254. package/dist/tools/write_files.d.ts +158 -0
  255. package/dist/tools/write_files.d.ts.map +1 -0
  256. package/dist/tools/write_files.js +541 -0
  257. package/dist/tools/write_files.js.map +1 -0
  258. package/dist/transport.d.ts +77 -0
  259. package/dist/transport.d.ts.map +1 -0
  260. package/dist/transport.js +448 -0
  261. package/dist/transport.js.map +1 -0
  262. package/dist/ui/card-asset-broker.d.ts +42 -0
  263. package/dist/ui/card-asset-broker.d.ts.map +1 -0
  264. package/dist/ui/card-asset-broker.js +479 -0
  265. package/dist/ui/card-asset-broker.js.map +1 -0
  266. package/dist/ui/grid-resource.d.ts +252 -0
  267. package/dist/ui/grid-resource.d.ts.map +1 -0
  268. package/dist/ui/grid-resource.js +680 -0
  269. package/dist/ui/grid-resource.js.map +1 -0
  270. package/dist/ui/viewer-static/index.html +95 -0
  271. package/dist/ui/viewer-static/viewer.css +451 -0
  272. package/dist/ui/viewer-static/viewer.js +1405 -0
  273. package/dist/validate/full-scan.d.ts +87 -0
  274. package/dist/validate/full-scan.d.ts.map +1 -0
  275. package/dist/validate/full-scan.js +299 -0
  276. package/dist/validate/full-scan.js.map +1 -0
  277. package/dist/validate/index.d.ts +15 -0
  278. package/dist/validate/index.d.ts.map +1 -0
  279. package/dist/validate/index.js +18 -0
  280. package/dist/validate/index.js.map +1 -0
  281. package/dist/validate/marker.d.ts +89 -0
  282. package/dist/validate/marker.d.ts.map +1 -0
  283. package/dist/validate/marker.js +87 -0
  284. package/dist/validate/marker.js.map +1 -0
  285. package/dist/validate/phash.d.ts +135 -0
  286. package/dist/validate/phash.d.ts.map +1 -0
  287. package/dist/validate/phash.js +307 -0
  288. package/dist/validate/phash.js.map +1 -0
  289. package/dist/validate/render.d.ts +38 -0
  290. package/dist/validate/render.d.ts.map +1 -0
  291. package/dist/validate/render.js +155 -0
  292. package/dist/validate/render.js.map +1 -0
  293. package/dist/watch/index.d.ts +12 -0
  294. package/dist/watch/index.d.ts.map +1 -0
  295. package/dist/watch/index.js +12 -0
  296. package/dist/watch/index.js.map +1 -0
  297. package/dist/watch/watcher.d.ts +65 -0
  298. package/dist/watch/watcher.d.ts.map +1 -0
  299. package/dist/watch/watcher.js +238 -0
  300. package/dist/watch/watcher.js.map +1 -0
  301. package/package.json +70 -0
@@ -0,0 +1,135 @@
1
+ /** Decoded RGBA pixel buffer — what a PNG decoder (`pngjs`) hands back, and
2
+ * what {@link computePHash} (via `blockhash-core`'s `bmvbhash`) consumes. */
3
+ export interface RGBAImage {
4
+ /** Raw pixel bytes, 4 per pixel (R,G,B,A), row-major. */
5
+ data: Uint8Array;
6
+ width: number;
7
+ height: number;
8
+ }
9
+ /**
10
+ * Perceptual hashes within this many bits (out of 256, i.e. ~1.5%) are
11
+ * treated as "the same rendered card" for AC5's `variantsIdentical` check.
12
+ * See the module doc above for the empirical calibration behind this number.
13
+ */
14
+ export declare const DEFAULT_TOLERANCE_BITS = 4;
15
+ /**
16
+ * Perceptual-hash an already-decoded RGBA image via the blockhash algorithm
17
+ * (AC5). Returns a hex string; two structurally-identical images always hash
18
+ * identically (verified deterministic), and the hash space is the fixed
19
+ * `HASH_BITS²` bits `bmvbhash` always emits for a given `bits` argument, so
20
+ * every hash this module produces is directly comparable via {@link
21
+ * hammingDistance}.
22
+ */
23
+ export declare function computePHash(image: RGBAImage): string;
24
+ /**
25
+ * A card's mean "ink" color (DRO-717) — the second signal the color veto in
26
+ * {@link findDuplicateClusters} compares alongside the blockhash. Mean R/G/B
27
+ * over the saturated, non-background pixels of a render (see {@link
28
+ * computeColorSignature} for the exclusion rule). `undefined` when a render has
29
+ * no ink pixels at all (a blank/near-empty card), in which case the veto
30
+ * abstains and the pairing falls back to blockhash alone.
31
+ */
32
+ export interface ColorSignature {
33
+ r: number;
34
+ g: number;
35
+ b: number;
36
+ }
37
+ /**
38
+ * L1 (Manhattan) distance below which two {@link ColorSignature}s are treated
39
+ * as "the same fill color" for the hue-veto in {@link findDuplicateClusters}
40
+ * (DRO-717). Calibrated on real DRO-717 renders (see the module doc's
41
+ * "Hue-aware color veto" section): label variants of a single fill drift ≤ ~4.5,
42
+ * every genuinely-distinct hue pair measured ≥ ~33 — a threshold of 20 sits
43
+ * centered in that gap, ~4× above label-variant noise and comfortably below the
44
+ * tightest real hue difference.
45
+ */
46
+ export declare const DEFAULT_COLOR_TOLERANCE_L1 = 20;
47
+ /**
48
+ * Compute a render's mean "ink" color (DRO-717) — the hue signal the color veto
49
+ * in {@link findDuplicateClusters} pairs with the blockhash. Averages R/G/B over
50
+ * only the saturated, non-background pixels (see {@link INK_ALPHA_MIN} et al.
51
+ * for the exclusion rule and the module doc for why background + white text are
52
+ * dropped rather than averaged in). Returns `undefined` when NO pixel qualifies
53
+ * as ink (a fully blank or near-white card) — the caller then compares such a
54
+ * card on blockhash alone, since it has no hue to veto on.
55
+ *
56
+ * Pure arithmetic over the same decoded RGBA buffer {@link computePHash}
57
+ * consumes — no I/O, one linear pass, so it adds negligibly to AC7's per-render
58
+ * budget.
59
+ */
60
+ export declare function computeColorSignature(image: RGBAImage): ColorSignature | undefined;
61
+ /**
62
+ * L1 (Manhattan) distance between two {@link ColorSignature}s — the metric the
63
+ * color veto thresholds against {@link DEFAULT_COLOR_TOLERANCE_L1}. Manhattan,
64
+ * not Euclidean, because the calibration gap is wide (≤4.5 vs ≥33) so the
65
+ * cheaper sum-of-abs-differences is more than discriminating enough, and it
66
+ * keeps the whole comparison integer-friendly.
67
+ */
68
+ export declare function colorDistanceL1(a: ColorSignature, b: ColorSignature): number;
69
+ /**
70
+ * Count differing BITS (not hex characters) between two equal-length hex hash
71
+ * strings (AC5). Both {@link computePHash}'s output and this function operate
72
+ * over the same fixed-width hash space, so a caller never mixes hashes from a
73
+ * different `HASH_BITS` setting — but the length check below still guards the
74
+ * case defensively rather than silently truncating.
75
+ *
76
+ * Hex, not raw bit-strings, because that's the wire/storage-friendly form
77
+ * `computePHash` returns; comparing correctly still requires decoding to bits
78
+ * per nibble (a naive per-CHARACTER diff would undercount — e.g. `0x0` vs
79
+ * `0xf` differ in ALL 4 bits of that nibble but are only "1 character" apart).
80
+ */
81
+ export declare function hammingDistance(hexA: string, hexB: string): number;
82
+ /** One kit file's path plus its precomputed perceptual hash — the input
83
+ * {@link findDuplicateClusters} clusters. `color` is the DRO-717 hue-veto
84
+ * signal ({@link computeColorSignature}); OPTIONAL and backward-compatible — an
85
+ * entry without it is compared on blockhash alone, exactly as before the veto
86
+ * existed. Production (`full-scan.ts`) always supplies it; arithmetic-only tests
87
+ * may omit it. */
88
+ export interface HashedEntry {
89
+ path: string;
90
+ hash: string;
91
+ color?: ColorSignature;
92
+ }
93
+ /**
94
+ * Group `entries` into near-duplicate clusters (AC5: pairs — generalized here
95
+ * to clusters, since 3+ near-identical renders is exactly the failure mode
96
+ * the AC's own example describes) and return the FLAT, deduplicated,
97
+ * sorted list of every path that landed in a cluster of size >= 2. A path
98
+ * with no near-duplicate (a singleton) is omitted entirely — `variantsIdentical`
99
+ * is "these paths render the same as some other path", not a roster of every
100
+ * file scanned.
101
+ *
102
+ * Clustering is transitive (union-find): if A is within tolerance of B, and B
103
+ * is within tolerance of C, all three land in ONE cluster even if A and C
104
+ * alone exceed tolerance — the natural reading of "these components all look
105
+ * the same" when three-plus near-duplicates form a chain rather than every
106
+ * pair being mutually within tolerance.
107
+ *
108
+ * ── Two-signal near-duplicate test (DRO-717) ─────────────────────────────────
109
+ * Two entries are unioned only when they are near-duplicates on BOTH signals:
110
+ * 1. blockhash Hamming distance <= `toleranceBits` (layout/luminance), AND
111
+ * 2. `colorDistanceL1` <= `colorToleranceL1` (hue) — the DRO-717 veto that
112
+ * stops the hue-blind blockhash from clustering a primary/danger/success
113
+ * button set (identical layout, distinct fills) as identical.
114
+ * The color check is a pure VETO: it can only PREVENT a union the blockhash
115
+ * would have made, never create one. It abstains (does not veto) when EITHER
116
+ * entry lacks a color signature — so a caller that supplies no colors (e.g. an
117
+ * arithmetic-only test) gets the exact pre-DRO-717 blockhash-only behavior.
118
+ *
119
+ * Why the veto doesn't cause false NEGATIVES (dropping a real duplicate): for a
120
+ * SAME-fill pair, the ink-color distance co-varies with the button's coverage,
121
+ * which co-varies with the blockhash. Two same-fill cards only drift apart in
122
+ * ink color once their rendered widths differ enough — and by then their
123
+ * blockhash has ALREADY diverged past `toleranceBits`, so the union was never on
124
+ * the table for the veto to remove (measured on real dark- and light-background
125
+ * renders: a same-fill pair reaching colorL1 ≈ 39 was already ~35 blockhash bits
126
+ * apart). The veto therefore only ever bites the case it targets — blockhash
127
+ * says "close" while hue says "different", i.e. the hue-blind false positive.
128
+ *
129
+ * O(n²) pairwise comparisons — acceptable for AC7's ~50-component budget (at
130
+ * most ~1225 comparisons, each a cheap XOR+popcount over a 256-bit BigInt plus
131
+ * a three-term color subtraction); would need a smarter index (e.g. an LSH
132
+ * bucket) at kit sizes multiple orders of magnitude larger than genie's target.
133
+ */
134
+ export declare function findDuplicateClusters(entries: HashedEntry[], toleranceBits?: number, colorToleranceL1?: number): string[];
135
+ //# sourceMappingURL=phash.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"phash.d.ts","sourceRoot":"","sources":["../../src/validate/phash.ts"],"names":[],"mappings":"AAmFA;6EAC6E;AAC7E,MAAM,WAAW,SAAS;IACxB,yDAAyD;IACzD,IAAI,EAAE,UAAU,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAUD;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,IAAI,CAAC;AAExC;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAErD;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC7B,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,0BAA0B,KAAK,CAAC;AAmB7C;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,GAAG,cAAc,GAAG,SAAS,CAsBlF;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,cAAc,EAAE,CAAC,EAAE,cAAc,GAAG,MAAM,CAE5E;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAelE;AAED;;;;;kBAKkB;AAClB,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,cAAc,CAAC;CACxB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,WAAW,EAAE,EACtB,aAAa,GAAE,MAA+B,EAC9C,gBAAgB,GAAE,MAAmC,GACpD,MAAM,EAAE,CAuDV"}
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Perceptual-hash helpers for the M3-04 (DRO-260) `validate` full-scan facet's
3
+ * `variantsIdentical` check (AC5) — "the model generated 3 buttons that all
4
+ * look the same".
5
+ *
6
+ * Pure arithmetic, no I/O: {@link computePHash} takes decoded RGBA pixels (the
7
+ * caller renders + decodes the PNG — see `render.ts`), and {@link
8
+ * findDuplicateClusters} takes already-computed hashes. Kept pure/offline so
9
+ * this module — and its test suite — never touches Playwright, a browser, or
10
+ * the filesystem, mirroring `validate/marker.ts`'s own "pure and offline" design
11
+ * note.
12
+ *
13
+ * ── Algorithm choice: blockhash, not a DCT pHash ──────────────────────────────
14
+ * `bmvbhash` (the `blockhash-core` package, the reference implementation of
15
+ * blockhash.io's algorithm) divides the image into an NxN grid of blocks,
16
+ * takes each block's average brightness, and bits the result against the
17
+ * per-quadrant median. It is simpler and faster than a DCT-based pHash and
18
+ * needs no native build step (no `sharp`/libvips) — genie's server stays a
19
+ * pure-JS install, matching the rest of `dependencies` in package.json. The
20
+ * accuracy tradeoff is fine for this AC: the goal is "flag near-identical
21
+ * card renders", not general-purpose reverse image search.
22
+ *
23
+ * ── Tolerance calibration ─────────────────────────────────────────────────────
24
+ * `DEFAULT_TOLERANCE_BITS` was calibrated empirically (real Chromium renders +
25
+ * `bmvbhash(…, 16)`, i.e. a 256-bit hash), most recently re-measured post-#154
26
+ * WITH fonts loaded (DRO-717 — an early fontless run misleadingly showed every
27
+ * label variant at distance 0, because a browser with no font renders every
28
+ * button at the same zero-glyph-advance width; the numbers below are the real,
29
+ * font-loaded measurements):
30
+ * - Byte-identical HTML (the model re-emitting the exact same card twice)
31
+ * hashes to a Hamming distance of **0**.
32
+ * - A legitimately distinct restyle of the same component — an outline vs.
33
+ * filled button, a secondary size/radius variant — produces **~11–27 bits**.
34
+ * - Two cards differing ONLY in their label TEXT ("Save" vs "Delete account")
35
+ * also produce **~8–34 bits** — because a wider label lays the whole button
36
+ * out at a different width, which genuinely changes the block-grid
37
+ * structure. (This corrects an earlier version of this comment, which
38
+ * claimed label-only differences hash to distance 0 / cluster together —
39
+ * true only in the degenerate fontless render; false under real text.)
40
+ * Those last two ranges OVERLAP, so no single `DEFAULT_TOLERANCE_BITS` can both
41
+ * cluster every label variant AND avoid clustering every genuine restyle. We
42
+ * therefore keep the tolerance TIGHT (**4 bits out of 256**, just above pure
43
+ * render noise — antialiasing jitter, sub-pixel rounding — and far below the
44
+ * smallest genuinely-different pair), and treat `variantsIdentical` as its
45
+ * literal name: renders that are STRUCTURALLY identical (a true duplicate card
46
+ * — same markup, or whitespace-only differences), not merely two buttons of the
47
+ * same shape wearing different words. This preserves the doc's own long-standing
48
+ * bias — a false negative just means a human's eyes catch it in the rendered
49
+ * grid, while a false positive makes `validate` cry wolf on a legitimate
50
+ * "Save"/"Cancel" pair.
51
+ *
52
+ * ── Hue-aware color veto (DRO-717, fixes finding 1) ───────────────────────────
53
+ * blockhash is a LUMINANCE-structure hash: `bmvbhash` reduces each block to a
54
+ * single brightness value (the `R+G+B` sum), with no hue term at all. Three
55
+ * buttons of identical layout but visually distinct fills — clay `#c87c5e`,
56
+ * blue `#345197`, red `#c5372f` — therefore hash to Hamming distance **0**
57
+ * (confirmed on real renders), which would falsely cluster a primary/danger/
58
+ * success button SET as `variantsIdentical`. This is structural, not a
59
+ * tolerance-tuning problem: no `DEFAULT_TOLERANCE_BITS` recovers hue the hash
60
+ * never encoded.
61
+ *
62
+ * The fix is an ADDITIVE second signal, not a different hash: alongside each
63
+ * card's blockhash we carry a cheap {@link computeColorSignature} — the mean
64
+ * RGB of its "ink" pixels (saturated, non-background — see the function doc for
65
+ * why page background and white glyph fill are excluded). Two cards are treated
66
+ * as near-duplicates only when they are BOTH blockhash-close AND color-close
67
+ * (L1 within {@link DEFAULT_COLOR_TOLERANCE_L1}). The color signature was
68
+ * calibrated on the same DRO-717 real renders: label variants of one fill drift
69
+ * at most **~4.5** (blank space and white text excluded, so a growing button
70
+ * doesn't move it), while every genuinely-different hue pair measured — the
71
+ * issue trio included — sits at **≥33** (the tightest real pair, teal/green,
72
+ * was 33.5; the issue trio spanned 115–269). A threshold of **20** sits
73
+ * centered in that gap. The veto is strictly SUBTRACTIVE — it can only DROP a
74
+ * pairing the blockhash alone would have made, never add one — so an entry
75
+ * carrying no color signature falls back to the exact pre-DRO-717 behavior.
76
+ *
77
+ * `blockhash-core` ships its own `index.d.ts` (a small, pure-JS package, no
78
+ * native build step — genie's server stays a pure-JS install, matching the
79
+ * rest of `dependencies` in package.json), so the named import below needs no
80
+ * ambient shim.
81
+ */
82
+ import { bmvbhash } from "blockhash-core";
83
+ /**
84
+ * Grid resolution `bmvbhash` divides the image into (an N×N block grid,
85
+ * yielding an N²-bit hash). 16 → a 256-bit hash — fine-grained enough to
86
+ * distinguish real layout differences while staying cheap to compute for a
87
+ * batch of dozens of components (AC7's < 5 s / 50-component budget).
88
+ */
89
+ const HASH_BITS = 16;
90
+ /**
91
+ * Perceptual hashes within this many bits (out of 256, i.e. ~1.5%) are
92
+ * treated as "the same rendered card" for AC5's `variantsIdentical` check.
93
+ * See the module doc above for the empirical calibration behind this number.
94
+ */
95
+ export const DEFAULT_TOLERANCE_BITS = 4;
96
+ /**
97
+ * Perceptual-hash an already-decoded RGBA image via the blockhash algorithm
98
+ * (AC5). Returns a hex string; two structurally-identical images always hash
99
+ * identically (verified deterministic), and the hash space is the fixed
100
+ * `HASH_BITS²` bits `bmvbhash` always emits for a given `bits` argument, so
101
+ * every hash this module produces is directly comparable via {@link
102
+ * hammingDistance}.
103
+ */
104
+ export function computePHash(image) {
105
+ return bmvbhash({ data: image.data, width: image.width, height: image.height }, HASH_BITS);
106
+ }
107
+ /**
108
+ * L1 (Manhattan) distance below which two {@link ColorSignature}s are treated
109
+ * as "the same fill color" for the hue-veto in {@link findDuplicateClusters}
110
+ * (DRO-717). Calibrated on real DRO-717 renders (see the module doc's
111
+ * "Hue-aware color veto" section): label variants of a single fill drift ≤ ~4.5,
112
+ * every genuinely-distinct hue pair measured ≥ ~33 — a threshold of 20 sits
113
+ * centered in that gap, ~4× above label-variant noise and comfortably below the
114
+ * tightest real hue difference.
115
+ */
116
+ export const DEFAULT_COLOR_TOLERANCE_L1 = 20;
117
+ /**
118
+ * Thresholds (DRO-717) for {@link computeColorSignature}'s "is this pixel ink?"
119
+ * test. A pixel is EXCLUDED from the mean when it is either barely-opaque
120
+ * (`alpha < ALPHA_MIN` — antialiased edges, transparent margin) or
121
+ * bright-and-low-chroma (`min(R,G,B) > BRIGHT_MIN` AND `max−min < CHROMA_MAX`).
122
+ * The second clause drops both the light page background a preview renders
123
+ * against AND the white glyph fill of the label text — the two things that,
124
+ * left in, would (a) dominate a small button's mean and (b) shift it as the
125
+ * label grows, which is exactly the width-sensitivity that disqualifies a
126
+ * whole-canvas mean as a hue signal. What survives is the saturated button
127
+ * fill, whose mean is stable across label-length changes (measured drift ≤ ~4.5)
128
+ * yet cleanly separates distinct hues (≥ ~33).
129
+ */
130
+ const INK_ALPHA_MIN = 128;
131
+ const INK_BRIGHT_MIN = 200;
132
+ const INK_CHROMA_MAX = 24;
133
+ /**
134
+ * Compute a render's mean "ink" color (DRO-717) — the hue signal the color veto
135
+ * in {@link findDuplicateClusters} pairs with the blockhash. Averages R/G/B over
136
+ * only the saturated, non-background pixels (see {@link INK_ALPHA_MIN} et al.
137
+ * for the exclusion rule and the module doc for why background + white text are
138
+ * dropped rather than averaged in). Returns `undefined` when NO pixel qualifies
139
+ * as ink (a fully blank or near-white card) — the caller then compares such a
140
+ * card on blockhash alone, since it has no hue to veto on.
141
+ *
142
+ * Pure arithmetic over the same decoded RGBA buffer {@link computePHash}
143
+ * consumes — no I/O, one linear pass, so it adds negligibly to AC7's per-render
144
+ * budget.
145
+ */
146
+ export function computeColorSignature(image) {
147
+ const d = image.data;
148
+ let r = 0;
149
+ let g = 0;
150
+ let b = 0;
151
+ let n = 0;
152
+ for (let i = 0; i + 3 < d.length; i += 4) {
153
+ const R = d[i];
154
+ const G = d[i + 1];
155
+ const B = d[i + 2];
156
+ const A = d[i + 3];
157
+ if (A < INK_ALPHA_MIN)
158
+ continue;
159
+ const mn = Math.min(R, G, B);
160
+ const mx = Math.max(R, G, B);
161
+ if (mn > INK_BRIGHT_MIN && mx - mn < INK_CHROMA_MAX)
162
+ continue; // background / white text
163
+ r += R;
164
+ g += G;
165
+ b += B;
166
+ n += 1;
167
+ }
168
+ if (n === 0)
169
+ return undefined;
170
+ return { r: r / n, g: g / n, b: b / n };
171
+ }
172
+ /**
173
+ * L1 (Manhattan) distance between two {@link ColorSignature}s — the metric the
174
+ * color veto thresholds against {@link DEFAULT_COLOR_TOLERANCE_L1}. Manhattan,
175
+ * not Euclidean, because the calibration gap is wide (≤4.5 vs ≥33) so the
176
+ * cheaper sum-of-abs-differences is more than discriminating enough, and it
177
+ * keeps the whole comparison integer-friendly.
178
+ */
179
+ export function colorDistanceL1(a, b) {
180
+ return Math.abs(a.r - b.r) + Math.abs(a.g - b.g) + Math.abs(a.b - b.b);
181
+ }
182
+ /**
183
+ * Count differing BITS (not hex characters) between two equal-length hex hash
184
+ * strings (AC5). Both {@link computePHash}'s output and this function operate
185
+ * over the same fixed-width hash space, so a caller never mixes hashes from a
186
+ * different `HASH_BITS` setting — but the length check below still guards the
187
+ * case defensively rather than silently truncating.
188
+ *
189
+ * Hex, not raw bit-strings, because that's the wire/storage-friendly form
190
+ * `computePHash` returns; comparing correctly still requires decoding to bits
191
+ * per nibble (a naive per-CHARACTER diff would undercount — e.g. `0x0` vs
192
+ * `0xf` differ in ALL 4 bits of that nibble but are only "1 character" apart).
193
+ */
194
+ export function hammingDistance(hexA, hexB) {
195
+ if (hexA.length !== hexB.length) {
196
+ throw new Error(`hammingDistance: hash length mismatch (${hexA.length} vs ${hexB.length}) — hashes must come from the same hash configuration.`);
197
+ }
198
+ const a = BigInt(`0x${hexA}`);
199
+ const b = BigInt(`0x${hexB}`);
200
+ let xor = a ^ b;
201
+ let count = 0;
202
+ while (xor > 0n) {
203
+ count += Number(xor & 1n);
204
+ xor >>= 1n;
205
+ }
206
+ return count;
207
+ }
208
+ /**
209
+ * Group `entries` into near-duplicate clusters (AC5: pairs — generalized here
210
+ * to clusters, since 3+ near-identical renders is exactly the failure mode
211
+ * the AC's own example describes) and return the FLAT, deduplicated,
212
+ * sorted list of every path that landed in a cluster of size >= 2. A path
213
+ * with no near-duplicate (a singleton) is omitted entirely — `variantsIdentical`
214
+ * is "these paths render the same as some other path", not a roster of every
215
+ * file scanned.
216
+ *
217
+ * Clustering is transitive (union-find): if A is within tolerance of B, and B
218
+ * is within tolerance of C, all three land in ONE cluster even if A and C
219
+ * alone exceed tolerance — the natural reading of "these components all look
220
+ * the same" when three-plus near-duplicates form a chain rather than every
221
+ * pair being mutually within tolerance.
222
+ *
223
+ * ── Two-signal near-duplicate test (DRO-717) ─────────────────────────────────
224
+ * Two entries are unioned only when they are near-duplicates on BOTH signals:
225
+ * 1. blockhash Hamming distance <= `toleranceBits` (layout/luminance), AND
226
+ * 2. `colorDistanceL1` <= `colorToleranceL1` (hue) — the DRO-717 veto that
227
+ * stops the hue-blind blockhash from clustering a primary/danger/success
228
+ * button set (identical layout, distinct fills) as identical.
229
+ * The color check is a pure VETO: it can only PREVENT a union the blockhash
230
+ * would have made, never create one. It abstains (does not veto) when EITHER
231
+ * entry lacks a color signature — so a caller that supplies no colors (e.g. an
232
+ * arithmetic-only test) gets the exact pre-DRO-717 blockhash-only behavior.
233
+ *
234
+ * Why the veto doesn't cause false NEGATIVES (dropping a real duplicate): for a
235
+ * SAME-fill pair, the ink-color distance co-varies with the button's coverage,
236
+ * which co-varies with the blockhash. Two same-fill cards only drift apart in
237
+ * ink color once their rendered widths differ enough — and by then their
238
+ * blockhash has ALREADY diverged past `toleranceBits`, so the union was never on
239
+ * the table for the veto to remove (measured on real dark- and light-background
240
+ * renders: a same-fill pair reaching colorL1 ≈ 39 was already ~35 blockhash bits
241
+ * apart). The veto therefore only ever bites the case it targets — blockhash
242
+ * says "close" while hue says "different", i.e. the hue-blind false positive.
243
+ *
244
+ * O(n²) pairwise comparisons — acceptable for AC7's ~50-component budget (at
245
+ * most ~1225 comparisons, each a cheap XOR+popcount over a 256-bit BigInt plus
246
+ * a three-term color subtraction); would need a smarter index (e.g. an LSH
247
+ * bucket) at kit sizes multiple orders of magnitude larger than genie's target.
248
+ */
249
+ export function findDuplicateClusters(entries, toleranceBits = DEFAULT_TOLERANCE_BITS, colorToleranceL1 = DEFAULT_COLOR_TOLERANCE_L1) {
250
+ const n = entries.length;
251
+ if (n < 2)
252
+ return [];
253
+ /**
254
+ * The DRO-717 hue veto: `true` when the two entries' fills are close enough
255
+ * to be "the same color". Abstains (returns `true`) when either entry has no
256
+ * color signature, so a color-less caller keeps blockhash-only behavior.
257
+ */
258
+ function colorAgrees(a, b) {
259
+ if (!a.color || !b.color)
260
+ return true;
261
+ return colorDistanceL1(a.color, b.color) <= colorToleranceL1;
262
+ }
263
+ // Union-find over entry INDICES (not paths) so duplicate paths in the input
264
+ // can never collide as map keys.
265
+ const parent = Array.from({ length: n }, (_, i) => i);
266
+ function find(i) {
267
+ while (parent[i] !== i) {
268
+ parent[i] = parent[parent[i]]; // path-halving
269
+ i = parent[i];
270
+ }
271
+ return i;
272
+ }
273
+ function union(i, j) {
274
+ const ri = find(i);
275
+ const rj = find(j);
276
+ if (ri !== rj)
277
+ parent[ri] = rj;
278
+ }
279
+ for (let i = 0; i < n; i++) {
280
+ for (let j = i + 1; j < n; j++) {
281
+ const a = entries[i];
282
+ const b = entries[j];
283
+ if (hammingDistance(a.hash, b.hash) <= toleranceBits && colorAgrees(a, b)) {
284
+ union(i, j);
285
+ }
286
+ }
287
+ }
288
+ // Group entry indices by cluster root; keep only clusters with >= 2 members.
289
+ const clusters = new Map();
290
+ for (let i = 0; i < n; i++) {
291
+ const root = find(i);
292
+ const members = clusters.get(root);
293
+ if (members)
294
+ members.push(i);
295
+ else
296
+ clusters.set(root, [i]);
297
+ }
298
+ const result = [];
299
+ for (const members of clusters.values()) {
300
+ if (members.length < 2)
301
+ continue;
302
+ for (const idx of members)
303
+ result.push(entries[idx].path);
304
+ }
305
+ return result.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
306
+ }
307
+ //# sourceMappingURL=phash.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"phash.js","sourceRoot":"","sources":["../../src/validate/phash.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,38 @@
1
+ /** One render's measurements: content height (AC4) + a decoded screenshot for
2
+ * perceptual hashing (AC5). */
3
+ export interface RenderedCard {
4
+ /** Union bounding-box height, in CSS px, of `document.body`'s direct
5
+ * children — the rendered content's real vertical extent. */
6
+ contentHeight: number;
7
+ /** Decoded RGBA screenshot, ready for `validate/phash.ts`'s `computePHash`. */
8
+ image: {
9
+ data: Uint8Array;
10
+ width: number;
11
+ height: number;
12
+ };
13
+ }
14
+ /**
15
+ * The render seam `full-scan.ts` depends on (DI, same pattern as `refine.ts`'s
16
+ * `RegionCropper`) — tests inject a stub so no browser is ever launched in the
17
+ * unit suite; production gets {@link createDefaultRenderer}'s real Playwright
18
+ * instance.
19
+ */
20
+ export interface Renderer {
21
+ /** Render `html` at `viewport` and return its measurements. */
22
+ render(html: string, viewport: {
23
+ width: number;
24
+ height: number;
25
+ }): Promise<RenderedCard>;
26
+ /** Shut down the underlying browser. Idempotent-safe to call once per scan. */
27
+ close(): Promise<void>;
28
+ }
29
+ /**
30
+ * Create the default (production) {@link Renderer}: one headless Chromium
31
+ * instance (via lazy Playwright), reused across every `render()` call until
32
+ * `close()`. Returns `null` when Playwright cannot be loaded — the caller
33
+ * (`full-scan.ts`) degrades AC4/AC5 gracefully rather than failing the whole
34
+ * `validate` call over an environment gap (same posture as `refine.ts`'s AC7
35
+ * region crop).
36
+ */
37
+ export declare function createDefaultRenderer(): Promise<Renderer | null>;
38
+ //# sourceMappingURL=render.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/validate/render.ts"],"names":[],"mappings":"AAkDA;+BAC+B;AAC/B,MAAM,WAAW,YAAY;IAC3B;iEAC6D;IAC7D,aAAa,EAAE,MAAM,CAAC;IACtB,+EAA+E;IAC/E,KAAK,EAAE;QAAE,IAAI,EAAE,UAAU,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CAC5D;AAED;;;;;GAKG;AACH,MAAM,WAAW,QAAQ;IACvB,+DAA+D;IAC/D,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IACzF,+EAA+E;IAC/E,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AA8ED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAgDtE"}
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Lazy-Playwright renderer for the M3-04 (DRO-260) `validate` full-scan facet.
3
+ *
4
+ * Renders a component preview's raw HTML in headless Chromium and reports two
5
+ * things per render: the rendered content's bounding-box height (AC4's "thin"
6
+ * check) and a decoded RGBA screenshot buffer (AC5's perceptual-hash input,
7
+ * via `validate/phash.ts`). One {@link Renderer} instance owns ONE browser
8
+ * process reused across every file in a scan — launching a fresh browser per
9
+ * file would blow AC7's < 5 s / 50-component budget on launch overhead alone.
10
+ *
11
+ * ── Playwright is an OPTIONAL peer dependency, same precedent as `refine.ts` ──
12
+ * `refine.ts`'s AC7 region-crop already established this pattern for genie:
13
+ * Playwright is not in `dependencies` (a real browser download is too heavy to
14
+ * force on every install), so it is resolved via a runtime, non-literal
15
+ * `import()` that degrades to `null` (never throws) when the package isn't
16
+ * installed. `createDefaultRenderer` here follows the exact same contract —
17
+ * `packages/server/package.json` DOES declare it this time (M3-04's own issue
18
+ * asks for it: "Reuse Playwright instance from M2-04's region cropper"), but
19
+ * the caller (`full-scan.ts`) still must not assume it is always resolvable
20
+ * (a stripped install, an offline `npm ci --omit=optional`-style environment,
21
+ * or a sandboxed CI runner missing the OS-level shared libraries Chromium
22
+ * itself needs — independent of whether the npm package is present).
23
+ *
24
+ * ── Degradation is logged, not silent (PR #152 review) ────────────────────────
25
+ * A `null` renderer makes the full-scan facet skip `thin`/`variantsIdentical`
26
+ * entirely, so its `{ thin: [], variantsIdentical: [] }` would otherwise be
27
+ * indistinguishable from a genuinely clean kit — neither an operator nor the
28
+ * model consuming `validate` could tell the checks were skipped for an
29
+ * environment reason rather than a real "nothing wrong" result. Both
30
+ * degradation paths below (import-missing, launch-failure) emit a structured
31
+ * stderr line via the same `logStderr` (`llm/component-response.ts`) `refine.ts`
32
+ * uses for its own region-cropper degradation — stderr, never stdout, since
33
+ * stdout is the stdio transport's JSON-RPC framing.
34
+ *
35
+ * ── Bare-fragment previews render in quirks mode — measure children, not body ──
36
+ * A genie `<Name>.html` preview is a bare fragment: `<!-- @genie … -->`
37
+ * followed directly by markup, with NO `<!DOCTYPE html>`/`<html>`/`<body>`
38
+ * wrapper (confirmed against `validate/marker.test.ts`'s own fixtures and
39
+ * `refine.test.ts`'s component fixtures). Loaded via `page.setContent`, that
40
+ * makes the page quirks-mode, where `document.body` stretches to fill the
41
+ * viewport regardless of content (`document.body.scrollHeight` reports the
42
+ * VIEWPORT height, not the content's real extent — verified empirically).
43
+ * `measureContentHeight` instead unions the bounding boxes of `body`'s direct
44
+ * children (skipping any zero-area element, e.g. a `display:none` node) —
45
+ * this reports the actual rendered content extent independent of quirks-mode
46
+ * body stretching, and needs no synthetic doctype/wrapper injection that
47
+ * could itself perturb the very layout AC4 is trying to measure.
48
+ */
49
+ import { logStderr } from "../llm/component-response.js";
50
+ /** Load Playwright at runtime, or `null` if it is not installed / its OS-level
51
+ * dependencies are missing. The non-literal specifier keeps `tsc` from trying
52
+ * to resolve types for it at build time (mirrors `refine.ts`'s
53
+ * `importPlaywright`, kept as a separate copy here rather than a shared
54
+ * export — the two call sites have no other coupling, and duplicating one
55
+ * seven-line try/catch is cheaper than introducing a cross-tool dependency
56
+ * for it). */
57
+ async function importPlaywright() {
58
+ const specifier = "playwright";
59
+ try {
60
+ return (await import(specifier));
61
+ }
62
+ catch (error) {
63
+ // Logged (not silent, PR #152 review) — see the module doc's "Degradation
64
+ // is logged, not silent" section for why an operator/model needs this
65
+ // signal. Same event-naming convention as `refine.ts`'s own
66
+ // `refine.region.unavailable` for the identical Playwright-absent case.
67
+ logStderr({
68
+ event: "validate.render.unavailable",
69
+ reason: "playwright-not-installed",
70
+ error: String(error),
71
+ });
72
+ return null;
73
+ }
74
+ }
75
+ /**
76
+ * The script `measureContentHeight` evaluates in-page (AC4). Returns the union
77
+ * bounding-box height of `document.body`'s direct children, skipping any
78
+ * zero-area element (`display:none`, a collapsed empty node, …) — see the
79
+ * module doc for why this measures real content instead of quirks-mode body
80
+ * stretch. A `<Name>.html` with literally no rendered content (a marker line
81
+ * with nothing after it) correctly yields `0`.
82
+ */
83
+ const MEASURE_CONTENT_HEIGHT_SCRIPT = `(() => {
84
+ const children = Array.from(document.body.children);
85
+ if (children.length === 0) return 0;
86
+ let top = Infinity;
87
+ let bottom = -Infinity;
88
+ for (const el of children) {
89
+ const rect = el.getBoundingClientRect();
90
+ if (rect.width === 0 && rect.height === 0) continue;
91
+ top = Math.min(top, rect.top);
92
+ bottom = Math.max(bottom, rect.bottom);
93
+ }
94
+ if (!isFinite(top)) return 0;
95
+ return bottom - top;
96
+ })()`;
97
+ /**
98
+ * Create the default (production) {@link Renderer}: one headless Chromium
99
+ * instance (via lazy Playwright), reused across every `render()` call until
100
+ * `close()`. Returns `null` when Playwright cannot be loaded — the caller
101
+ * (`full-scan.ts`) degrades AC4/AC5 gracefully rather than failing the whole
102
+ * `validate` call over an environment gap (same posture as `refine.ts`'s AC7
103
+ * region crop).
104
+ */
105
+ export async function createDefaultRenderer() {
106
+ const pw = await importPlaywright();
107
+ if (!pw)
108
+ return null;
109
+ let browser;
110
+ try {
111
+ // `--no-sandbox` so this runs in restricted CI/container environments —
112
+ // the same flag `refine.ts`'s `defaultRegionCropper` launches with.
113
+ browser = await pw.chromium.launch({ args: ["--no-sandbox"] });
114
+ }
115
+ catch (error) {
116
+ // A Chromium binary that fails to LAUNCH (missing OS-level shared
117
+ // libraries, no downloaded browser, …) is the same "can't render" signal
118
+ // as Playwright not being installed at all — degrade the same way rather
119
+ // than letting a launch failure crash the whole `validate` call. Logged
120
+ // (not silent, PR #152 review) — same reasoning as `importPlaywright`'s
121
+ // catch above.
122
+ logStderr({
123
+ event: "validate.render.unavailable",
124
+ reason: "chromium-launch-failed",
125
+ error: String(error),
126
+ });
127
+ return null;
128
+ }
129
+ return {
130
+ async render(html, viewport) {
131
+ const page = await browser.newPage({ viewport });
132
+ try {
133
+ // `load`, not `networkidle`: an embedded-CSP preview
134
+ // (`default-src 'none'`) has no network to go idle on — see
135
+ // `refine.ts`'s identical reasoning for its own `setContent` call.
136
+ await page.setContent(html, { waitUntil: "load" });
137
+ const contentHeight = (await page.evaluate(MEASURE_CONTENT_HEIGHT_SCRIPT));
138
+ const buffer = await page.screenshot({ type: "png" });
139
+ const { PNG } = await import("pngjs");
140
+ const png = PNG.sync.read(buffer);
141
+ return {
142
+ contentHeight,
143
+ image: { data: new Uint8Array(png.data), width: png.width, height: png.height },
144
+ };
145
+ }
146
+ finally {
147
+ await page.close();
148
+ }
149
+ },
150
+ async close() {
151
+ await browser.close();
152
+ },
153
+ };
154
+ }
155
+ //# sourceMappingURL=render.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.js","sourceRoot":"","sources":["../../src/validate/render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,OAAO,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAqDzD;;;;;;cAMc;AACd,KAAK,UAAU,gBAAgB;IAC7B,MAAM,SAAS,GAAG,YAAY,CAAC;IAC/B,IAAI,CAAC;QACH,OAAO,CAAC,MAAM,MAAM,CAAC,SAAS,CAAC,CAAqB,CAAC;IACvD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,0EAA0E;QAC1E,sEAAsE;QACtE,4DAA4D;QAC5D,wEAAwE;QACxE,SAAS,CAAC;YACR,KAAK,EAAE,6BAA6B;YACpC,MAAM,EAAE,0BAA0B;YAClC,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC;SACrB,CAAC,CAAC;QACH,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,6BAA6B,GAAG;;;;;;;;;;;;;KAajC,CAAC;AAEN;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB;IACzC,MAAM,EAAE,GAAG,MAAM,gBAAgB,EAAE,CAAC;IACpC,IAAI,CAAC,EAAE;QAAE,OAAO,IAAI,CAAC;IAErB,IAAI,OAAkB,CAAC;IACvB,IAAI,CAAC;QACH,wEAAwE;QACxE,oEAAoE;QACpE,OAAO,GAAG,MAAM,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,CAAC,cAAc,CAAC,EAAE,CAAC,CAAC;IACjE,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,kEAAkE;QAClE,yEAAyE;QACzE,yEAAyE;QACzE,wEAAwE;QACxE,wEAAwE;QACxE,eAAe;QACf,SAAS,CAAC;YACR,KAAK,EAAE,6BAA6B;YACpC,MAAM,EAAE,wBAAwB;YAChC,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC;SACrB,CAAC,CAAC;QACH,OAAO,IAAI,CAAC;IACd,CAAC;IAED,OAAO;QACL,KAAK,CAAC,MAAM,CAAC,IAAI,EAAE,QAAQ;YACzB,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC;YACjD,IAAI,CAAC;gBACH,qDAAqD;gBACrD,4DAA4D;gBAC5D,mEAAmE;gBACnE,MAAM,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC,CAAC;gBACnD,MAAM,aAAa,GAAG,CAAC,MAAM,IAAI,CAAC,QAAQ,CAAC,6BAA6B,CAAC,CAAW,CAAC;gBACrF,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;gBACtD,MAAM,EAAE,GAAG,EAAE,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,CAAC;gBACtC,MAAM,GAAG,GAAG,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;gBAClC,OAAO;oBACL,aAAa;oBACb,KAAK,EAAE,EAAE,IAAI,EAAE,IAAI,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE;iBAChF,CAAC;YACJ,CAAC;oBAAS,CAAC;gBACT,MAAM,IAAI,CAAC,KAAK,EAAE,CAAC;YACrB,CAAC;QACH,CAAC;QACD,KAAK,CAAC,KAAK;YACT,MAAM,OAAO,CAAC,KAAK,EAAE,CAAC;QACxB,CAAC;KACF,CAAC;AACJ,CAAC"}