claudecode-omc 5.6.6 → 5.6.8

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 (179) hide show
  1. package/.local/skills/THIRD_PARTY_LICENSES/AvdLee-SwiftUI-Agent-Skill.LICENSE +21 -0
  2. package/.local/skills/THIRD_PARTY_LICENSES/Dimillian-Skills.LICENSE +21 -0
  3. package/.local/skills/THIRD_PARTY_LICENSES/README.md +36 -0
  4. package/.local/skills/THIRD_PARTY_LICENSES/twostraws-swiftui-agent-skill.LICENSE +21 -0
  5. package/.local/skills/h5-to-swiftui/SKILL.md +201 -0
  6. package/.local/skills/h5-to-swiftui/assets/calibration/README.md +176 -0
  7. package/.local/skills/h5-to-swiftui/assets/calibration/h5-twin/index.html +52 -0
  8. package/.local/skills/h5-to-swiftui/assets/calibration/h5-twin/style.css +133 -0
  9. package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin/Package.swift +26 -0
  10. package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin/Sources/CalibrationScreen/CalibrationScreen.swift +142 -0
  11. package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin-divergent/Package.swift +32 -0
  12. package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin-divergent/Sources/CalibrationScreenDivergent/CalibrationScreenDivergent.swift +122 -0
  13. package/.local/skills/h5-to-swiftui/assets/calibration/tokens.json +42 -0
  14. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/index.html +14 -0
  15. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/package.json +20 -0
  16. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/public/api/articles/001.json +96 -0
  17. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/public/api/articles/index.json +89 -0
  18. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/App.jsx +22 -0
  19. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/App.module.css +11 -0
  20. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/ArticleCard.jsx +53 -0
  21. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/ArticleCard.module.css +139 -0
  22. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/NavBar.jsx +37 -0
  23. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/NavBar.module.css +72 -0
  24. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TagCloud.jsx +30 -0
  25. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TagCloud.module.css +50 -0
  26. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TrendChart.jsx +159 -0
  27. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TrendChart.module.css +21 -0
  28. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/main.jsx +12 -0
  29. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/ArticleScreen.jsx +182 -0
  30. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/ArticleScreen.module.css +294 -0
  31. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/FeedScreen.jsx +147 -0
  32. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/FeedScreen.module.css +161 -0
  33. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/styles/global.css +50 -0
  34. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/styles/tokens.css +103 -0
  35. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/vite.config.js +6 -0
  36. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/data/tasks.js +67 -0
  37. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/index.html +26 -0
  38. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/router.js +73 -0
  39. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/screens/detail.js +164 -0
  40. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/screens/home.js +53 -0
  41. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/screens/list.js +87 -0
  42. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/styles/app.css +342 -0
  43. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/styles/tokens.css +68 -0
  44. package/.local/skills/h5-to-swiftui/references/css-to-swiftui-map.md +205 -0
  45. package/.local/skills/h5-to-swiftui/references/design-token-extraction.md +209 -0
  46. package/.local/skills/h5-to-swiftui/references/high-risk-triage.md +209 -0
  47. package/.local/skills/h5-to-swiftui/references/render-equivalence-calibration.md +193 -0
  48. package/.local/skills/h5-to-swiftui/references/stack-detection.md +160 -0
  49. package/.local/skills/h5-to-swiftui/references/visual-diff-loop-protocol.md +365 -0
  50. package/.local/skills/h5-to-swiftui/scripts/_calib-consts.mjs +150 -0
  51. package/.local/skills/h5-to-swiftui/scripts/_imglib.mjs +547 -0
  52. package/.local/skills/h5-to-swiftui/scripts/_provenance.mjs +123 -0
  53. package/.local/skills/h5-to-swiftui/scripts/calibrate-render.mjs +625 -0
  54. package/.local/skills/h5-to-swiftui/scripts/capture-reference.mjs +386 -0
  55. package/.local/skills/h5-to-swiftui/scripts/detect-stack.mjs +305 -0
  56. package/.local/skills/h5-to-swiftui/scripts/evaluate-convergence.mjs +1093 -0
  57. package/.local/skills/h5-to-swiftui/scripts/extract-tokens.mjs +600 -0
  58. package/.local/skills/h5-to-swiftui/scripts/mark-overlay.mjs +379 -0
  59. package/.local/skills/h5-to-swiftui/scripts/pixel-diff.mjs +530 -0
  60. package/.local/skills/h5-to-swiftui/scripts/sim-screenshot.sh +544 -0
  61. package/.local/skills/ios-debugger-agent/SKILL.md +51 -0
  62. package/.local/skills/ios-debugger-agent/agents/openai.yaml +4 -0
  63. package/.local/skills/swift-concurrency-expert/SKILL.md +105 -0
  64. package/.local/skills/swift-concurrency-expert/agents/openai.yaml +4 -0
  65. package/.local/skills/swift-concurrency-expert/references/approachable-concurrency.md +63 -0
  66. package/.local/skills/swift-concurrency-expert/references/swift-6-2-concurrency.md +272 -0
  67. package/.local/skills/swift-concurrency-expert/references/swiftui-concurrency-tour-wwdc.md +33 -0
  68. package/.local/skills/swiftui-expert-skill/SKILL.md +162 -0
  69. package/.local/skills/swiftui-expert-skill/references/accessibility-patterns.md +215 -0
  70. package/.local/skills/swiftui-expert-skill/references/animation-advanced.md +403 -0
  71. package/.local/skills/swiftui-expert-skill/references/animation-basics.md +284 -0
  72. package/.local/skills/swiftui-expert-skill/references/animation-transitions.md +326 -0
  73. package/.local/skills/swiftui-expert-skill/references/charts-accessibility.md +135 -0
  74. package/.local/skills/swiftui-expert-skill/references/charts.md +602 -0
  75. package/.local/skills/swiftui-expert-skill/references/focus-patterns.md +299 -0
  76. package/.local/skills/swiftui-expert-skill/references/image-optimization.md +203 -0
  77. package/.local/skills/swiftui-expert-skill/references/latest-apis.md +488 -0
  78. package/.local/skills/swiftui-expert-skill/references/layout-best-practices.md +266 -0
  79. package/.local/skills/swiftui-expert-skill/references/liquid-glass.md +423 -0
  80. package/.local/skills/swiftui-expert-skill/references/list-patterns.md +446 -0
  81. package/.local/skills/swiftui-expert-skill/references/macos-scenes.md +318 -0
  82. package/.local/skills/swiftui-expert-skill/references/macos-views.md +357 -0
  83. package/.local/skills/swiftui-expert-skill/references/macos-window-styling.md +303 -0
  84. package/.local/skills/swiftui-expert-skill/references/performance-patterns.md +403 -0
  85. package/.local/skills/swiftui-expert-skill/references/scroll-patterns.md +293 -0
  86. package/.local/skills/swiftui-expert-skill/references/sheet-navigation-patterns.md +363 -0
  87. package/.local/skills/swiftui-expert-skill/references/state-management.md +388 -0
  88. package/.local/skills/swiftui-expert-skill/references/text-patterns.md +32 -0
  89. package/.local/skills/swiftui-expert-skill/references/trace-analysis.md +295 -0
  90. package/.local/skills/swiftui-expert-skill/references/trace-recording.md +134 -0
  91. package/.local/skills/swiftui-expert-skill/references/view-structure.md +780 -0
  92. package/.local/skills/swiftui-expert-skill/scripts/__pycache__/analyze_trace.cpython-313.pyc +0 -0
  93. package/.local/skills/swiftui-expert-skill/scripts/__pycache__/record_trace.cpython-313.pyc +0 -0
  94. package/.local/skills/swiftui-expert-skill/scripts/analyze_trace.py +301 -0
  95. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__init__.py +1 -0
  96. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/__init__.cpython-313.pyc +0 -0
  97. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/causes.cpython-313.pyc +0 -0
  98. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/correlate.cpython-313.pyc +0 -0
  99. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/events.cpython-313.pyc +0 -0
  100. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/hangs.cpython-313.pyc +0 -0
  101. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/hitches.cpython-313.pyc +0 -0
  102. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/summary.cpython-313.pyc +0 -0
  103. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/swiftui.cpython-313.pyc +0 -0
  104. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/time_profiler.cpython-313.pyc +0 -0
  105. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/xctrace.cpython-313.pyc +0 -0
  106. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/xml_utils.cpython-313.pyc +0 -0
  107. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/causes.py +187 -0
  108. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/correlate.py +179 -0
  109. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/events.py +291 -0
  110. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/hangs.py +108 -0
  111. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/hitches.py +145 -0
  112. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/summary.py +243 -0
  113. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/swiftui.py +195 -0
  114. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/time_profiler.py +135 -0
  115. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/xctrace.py +117 -0
  116. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/xml_utils.py +224 -0
  117. package/.local/skills/swiftui-expert-skill/scripts/record_trace.py +252 -0
  118. package/.local/skills/swiftui-liquid-glass/SKILL.md +90 -0
  119. package/.local/skills/swiftui-liquid-glass/agents/openai.yaml +4 -0
  120. package/.local/skills/swiftui-liquid-glass/references/liquid-glass.md +280 -0
  121. package/.local/skills/swiftui-performance-audit/SKILL.md +106 -0
  122. package/.local/skills/swiftui-performance-audit/agents/openai.yaml +4 -0
  123. package/.local/skills/swiftui-performance-audit/references/code-smells.md +150 -0
  124. package/.local/skills/swiftui-performance-audit/references/demystify-swiftui-performance-wwdc23.md +46 -0
  125. package/.local/skills/swiftui-performance-audit/references/optimizing-swiftui-performance-instruments.md +29 -0
  126. package/.local/skills/swiftui-performance-audit/references/profiling-intake.md +44 -0
  127. package/.local/skills/swiftui-performance-audit/references/report-template.md +47 -0
  128. package/.local/skills/swiftui-performance-audit/references/understanding-hangs-in-your-app.md +33 -0
  129. package/.local/skills/swiftui-performance-audit/references/understanding-improving-swiftui-performance.md +52 -0
  130. package/.local/skills/swiftui-pro/SKILL.md +108 -0
  131. package/.local/skills/swiftui-pro/agents/openai.yaml +10 -0
  132. package/.local/skills/swiftui-pro/assets/swiftui-pro-icon.png +0 -0
  133. package/.local/skills/swiftui-pro/assets/swiftui-pro-icon.svg +29 -0
  134. package/.local/skills/swiftui-pro/references/accessibility.md +13 -0
  135. package/.local/skills/swiftui-pro/references/api.md +39 -0
  136. package/.local/skills/swiftui-pro/references/data.md +43 -0
  137. package/.local/skills/swiftui-pro/references/design.md +32 -0
  138. package/.local/skills/swiftui-pro/references/hygiene.md +9 -0
  139. package/.local/skills/swiftui-pro/references/navigation.md +14 -0
  140. package/.local/skills/swiftui-pro/references/performance.md +46 -0
  141. package/.local/skills/swiftui-pro/references/swift.md +56 -0
  142. package/.local/skills/swiftui-pro/references/views.md +36 -0
  143. package/.local/skills/swiftui-ui-patterns/SKILL.md +95 -0
  144. package/.local/skills/swiftui-ui-patterns/agents/openai.yaml +4 -0
  145. package/.local/skills/swiftui-ui-patterns/references/app-wiring.md +201 -0
  146. package/.local/skills/swiftui-ui-patterns/references/async-state.md +96 -0
  147. package/.local/skills/swiftui-ui-patterns/references/components-index.md +50 -0
  148. package/.local/skills/swiftui-ui-patterns/references/controls.md +57 -0
  149. package/.local/skills/swiftui-ui-patterns/references/deeplinks.md +66 -0
  150. package/.local/skills/swiftui-ui-patterns/references/focus.md +90 -0
  151. package/.local/skills/swiftui-ui-patterns/references/form.md +97 -0
  152. package/.local/skills/swiftui-ui-patterns/references/grids.md +71 -0
  153. package/.local/skills/swiftui-ui-patterns/references/haptics.md +71 -0
  154. package/.local/skills/swiftui-ui-patterns/references/input-toolbar.md +51 -0
  155. package/.local/skills/swiftui-ui-patterns/references/lightweight-clients.md +93 -0
  156. package/.local/skills/swiftui-ui-patterns/references/list.md +86 -0
  157. package/.local/skills/swiftui-ui-patterns/references/loading-placeholders.md +38 -0
  158. package/.local/skills/swiftui-ui-patterns/references/macos-settings.md +71 -0
  159. package/.local/skills/swiftui-ui-patterns/references/matched-transitions.md +59 -0
  160. package/.local/skills/swiftui-ui-patterns/references/media.md +73 -0
  161. package/.local/skills/swiftui-ui-patterns/references/menu-bar.md +101 -0
  162. package/.local/skills/swiftui-ui-patterns/references/navigationstack.md +159 -0
  163. package/.local/skills/swiftui-ui-patterns/references/overlay.md +45 -0
  164. package/.local/skills/swiftui-ui-patterns/references/performance.md +62 -0
  165. package/.local/skills/swiftui-ui-patterns/references/previews.md +48 -0
  166. package/.local/skills/swiftui-ui-patterns/references/scroll-reveal.md +133 -0
  167. package/.local/skills/swiftui-ui-patterns/references/scrollview.md +87 -0
  168. package/.local/skills/swiftui-ui-patterns/references/searchable.md +71 -0
  169. package/.local/skills/swiftui-ui-patterns/references/sheets.md +155 -0
  170. package/.local/skills/swiftui-ui-patterns/references/split-views.md +72 -0
  171. package/.local/skills/swiftui-ui-patterns/references/tabview.md +114 -0
  172. package/.local/skills/swiftui-ui-patterns/references/theming.md +71 -0
  173. package/.local/skills/swiftui-ui-patterns/references/title-menus.md +93 -0
  174. package/.local/skills/swiftui-ui-patterns/references/top-bar.md +49 -0
  175. package/.local/skills/swiftui-view-refactor/SKILL.md +202 -0
  176. package/.local/skills/swiftui-view-refactor/agents/openai.yaml +4 -0
  177. package/.local/skills/swiftui-view-refactor/references/mv-patterns.md +161 -0
  178. package/bundled/manifest.json +1 -1
  179. package/package.json +1 -1
@@ -0,0 +1,547 @@
1
+ /**
2
+ * _imglib.mjs — Shared image primitives for h5-to-swiftui scripts
3
+ *
4
+ * Provides:
5
+ * loadPng(path) → { width, height, data: Uint8Array (RGBA) }
6
+ * savePng(path, img) → void (writes RGBA raster as PNG)
7
+ * pHash(img) → BigInt (DCT 8x8 64-bit perceptual hash)
8
+ * hammingDistance(a, b) → number (0–64)
9
+ * ssimWindow(imgA, imgB, roi?, windowSize?) → number (0–1)
10
+ * ciede2000Region(imgA, imgB, roi?) → { p50, p95, mean }
11
+ * resampleLanczos3(img, targetW, targetH) → img
12
+ * p3ToSrgb(img) → img (in-place sRGB conversion, relative colorimetric)
13
+ * cropRect(img, x, y, w, h) → img
14
+ * drawRect(img, x, y, w, h, r, g, b, a) → void (in-place)
15
+ * drawFilledRect(img, x, y, w, h, r, g, b, alpha) → void (in-place, alpha blend)
16
+ * bboxIoU(a, b) → number (Intersection-over-Union for {x,y,w,h} rects)
17
+ *
18
+ * PNG backend: pngjs if installed; otherwise exits 2 with an actionable hint.
19
+ *
20
+ * All pixel data is RGBA (4 bytes per pixel, row-major).
21
+ * Coordinate system: (0,0) = top-left.
22
+ */
23
+
24
+ // ── PNG backend ───────────────────────────────────────────────────────────────
25
+
26
+ let PNG;
27
+
28
+ /**
29
+ * Load the PNG codec. Tries pngjs first; if unavailable prints a hint and
30
+ * exits 2 so callers can distinguish "image dep missing" from other errors.
31
+ */
32
+ export async function requirePng() {
33
+ if (PNG) return PNG;
34
+ try {
35
+ const mod = await import('pngjs');
36
+ PNG = mod.PNG;
37
+ return PNG;
38
+ } catch {
39
+ console.error(
40
+ '\nError: image dependency "pngjs" is not installed.\n\n' +
41
+ 'pixel-diff.mjs, calibrate-render.mjs, and mark-overlay.mjs require it.\n\n' +
42
+ 'Install it in the SKILL directory (or your project root) so that Node\n' +
43
+ "ESM `import('pngjs')` can resolve it via normal node_modules lookup\n" +
44
+ '(NODE_PATH is ignored by ESM):\n\n' +
45
+ ' cd <h5-to-swiftui skill dir> # the dir containing scripts/\n' +
46
+ ' npm install pngjs\n\n' +
47
+ 'Then re-run the script.'
48
+ );
49
+ process.exit(2);
50
+ }
51
+ }
52
+
53
+ // ── I/O ───────────────────────────────────────────────────────────────────────
54
+
55
+ import { readFileSync, writeFileSync } from 'node:fs';
56
+
57
+ /**
58
+ * Load a PNG file into { width, height, data: Uint8Array (RGBA) }.
59
+ * Calls requirePng() internally — call it once at startup if you want the
60
+ * dependency error to surface early.
61
+ */
62
+ export async function loadPng(filePath) {
63
+ const Png = await requirePng();
64
+ const buf = readFileSync(filePath);
65
+ return new Promise((resolve, reject) => {
66
+ const png = new Png();
67
+ png.parse(buf, (err, img) => {
68
+ if (err) return reject(err);
69
+ resolve({ width: img.width, height: img.height, data: new Uint8Array(img.data.buffer) });
70
+ });
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Write RGBA raster to a PNG file.
76
+ */
77
+ export async function savePng(filePath, img) {
78
+ const Png = await requirePng();
79
+ const png = new Png({ width: img.width, height: img.height });
80
+ png.data = Buffer.from(img.data.buffer);
81
+ const buf = Png.sync.write(png);
82
+ writeFileSync(filePath, buf);
83
+ }
84
+
85
+ // ── Perceptual hash (DCT 8x8) ─────────────────────────────────────────────────
86
+
87
+ /**
88
+ * Compute a 64-bit perceptual hash using the standard DCT-hash approach:
89
+ * resize to 32×32 grayscale → 32×32 DCT → top-left 8×8 AC → median → bitmask.
90
+ * Returns a BigInt (64 bits).
91
+ */
92
+ export function pHash(img) {
93
+ const SIZE = 32;
94
+ const small = resampleLanczos3(img, SIZE, SIZE);
95
+ // Grayscale (luma BT.601)
96
+ const gray = new Float64Array(SIZE * SIZE);
97
+ for (let i = 0; i < SIZE * SIZE; i++) {
98
+ const o = i * 4;
99
+ gray[i] = 0.299 * small.data[o] + 0.587 * small.data[o + 1] + 0.114 * small.data[o + 2];
100
+ }
101
+ // 2-D DCT-II (separable)
102
+ const dct = dct2d(gray, SIZE, SIZE);
103
+ // Take top-left 8×8 (skip DC at [0,0])
104
+ const ac = [];
105
+ for (let row = 0; row < 8; row++) {
106
+ for (let col = 0; col < 8; col++) {
107
+ if (row === 0 && col === 0) continue;
108
+ ac.push(dct[row * SIZE + col]);
109
+ }
110
+ }
111
+ const median = quantile(ac, 0.5);
112
+ // Build 64-bit hash (include DC slot as 0 for consistent length)
113
+ let hash = 0n;
114
+ let bit = 63n;
115
+ for (let row = 0; row < 8; row++) {
116
+ for (let col = 0; col < 8; col++) {
117
+ const val = row === 0 && col === 0 ? 0 : dct[row * SIZE + col];
118
+ if (val > median) hash |= (1n << bit);
119
+ bit--;
120
+ }
121
+ }
122
+ return hash;
123
+ }
124
+
125
+ /** Hamming distance between two BigInt hashes (0–64). */
126
+ export function hammingDistance(a, b) {
127
+ let xor = a ^ b;
128
+ let count = 0;
129
+ while (xor) { xor &= xor - 1n; count++; }
130
+ return count;
131
+ }
132
+
133
+ // ── SSIM ──────────────────────────────────────────────────────────────────────
134
+
135
+ /**
136
+ * Compute SSIM over an optional ROI {x, y, w, h} (defaults to full image).
137
+ * Uses an 8-pixel sliding window as per the calibration reference.
138
+ * Returns a value in [0, 1].
139
+ */
140
+ export function ssimWindow(imgA, imgB, roi, windowSize = 8) {
141
+ const { x = 0, y = 0, w = imgA.width, h = imgA.height } = roi ?? {};
142
+
143
+ const C1 = (0.01 * 255) ** 2;
144
+ const C2 = (0.03 * 255) ** 2;
145
+ let ssimSum = 0;
146
+ let count = 0;
147
+
148
+ const stepX = Math.max(1, Math.floor(windowSize / 2));
149
+ const stepY = Math.max(1, Math.floor(windowSize / 2));
150
+
151
+ for (let wy = y; wy + windowSize <= y + h; wy += stepY) {
152
+ for (let wx = x; wx + windowSize <= x + w; wx += stepX) {
153
+ let sumA = 0, sumB = 0, sumA2 = 0, sumB2 = 0, sumAB = 0, n = 0;
154
+ for (let py = wy; py < wy + windowSize; py++) {
155
+ for (let px = wx; px < wx + windowSize; px++) {
156
+ const ia = (py * imgA.width + px) * 4;
157
+ const ib = (py * imgB.width + px) * 4;
158
+ const la = imgLuma(imgA.data, ia);
159
+ const lb = imgLuma(imgB.data, ib);
160
+ sumA += la; sumB += lb;
161
+ sumA2 += la * la; sumB2 += lb * lb; sumAB += la * lb;
162
+ n++;
163
+ }
164
+ }
165
+ const muA = sumA / n, muB = sumB / n;
166
+ const sigA2 = sumA2 / n - muA * muA;
167
+ const sigB2 = sumB2 / n - muB * muB;
168
+ const sigAB = sumAB / n - muA * muB;
169
+ const num = (2 * muA * muB + C1) * (2 * sigAB + C2);
170
+ const den = (muA * muA + muB * muB + C1) * (sigA2 + sigB2 + C2);
171
+ ssimSum += den > 0 ? num / den : 1;
172
+ count++;
173
+ }
174
+ }
175
+ return count > 0 ? ssimSum / count : 1;
176
+ }
177
+
178
+ function imgLuma(data, offset) {
179
+ return 0.299 * data[offset] + 0.587 * data[offset + 1] + 0.114 * data[offset + 2];
180
+ }
181
+
182
+ // ── CIEDE2000 ─────────────────────────────────────────────────────────────────
183
+
184
+ /**
185
+ * Compute CIEDE2000 ΔE statistics over an optional ROI.
186
+ * Returns { p50, p95, mean }.
187
+ */
188
+ export function ciede2000Region(imgA, imgB, roi) {
189
+ const { x = 0, y = 0, w = imgA.width, h = imgA.height } = roi ?? {};
190
+ const deltas = [];
191
+
192
+ for (let py = y; py < y + h; py++) {
193
+ for (let px = x; px < x + w; px++) {
194
+ const ia = (py * imgA.width + px) * 4;
195
+ const ib = (py * imgB.width + px) * 4;
196
+ const labA = srgbToLab(imgA.data[ia], imgA.data[ia + 1], imgA.data[ia + 2]);
197
+ const labB = srgbToLab(imgB.data[ib], imgB.data[ib + 1], imgB.data[ib + 2]);
198
+ deltas.push(ciede2000(labA, labB));
199
+ }
200
+ }
201
+
202
+ deltas.sort((a, b) => a - b);
203
+ const n = deltas.length;
204
+ return {
205
+ p50: n > 0 ? quantile(deltas, 0.5) : 0,
206
+ p95: n > 0 ? quantile(deltas, 0.95) : 0,
207
+ mean: n > 0 ? deltas.reduce((s, v) => s + v, 0) / n : 0,
208
+ };
209
+ }
210
+
211
+ // sRGB → XYZ D65 → Lab
212
+ function srgbToLab(r, g, b) {
213
+ const lin = (v) => {
214
+ const n = v / 255;
215
+ return n <= 0.04045 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4;
216
+ };
217
+ const lr = lin(r), lg = lin(g), lb = lin(b);
218
+ // sRGB D65 matrix
219
+ const X = 0.4124564 * lr + 0.3575761 * lg + 0.1804375 * lb;
220
+ const Y = 0.2126729 * lr + 0.7151522 * lg + 0.0721750 * lb;
221
+ const Z = 0.0193339 * lr + 0.1191920 * lg + 0.9503041 * lb;
222
+ // D65 white point
223
+ const xn = 0.95047, yn = 1.00000, zn = 1.08883;
224
+ const f = (t) => t > 0.008856 ? t ** (1 / 3) : 7.787 * t + 16 / 116;
225
+ const L = 116 * f(Y / yn) - 16;
226
+ const a = 500 * (f(X / xn) - f(Y / yn));
227
+ const bv = 200 * (f(Y / yn) - f(Z / zn));
228
+ return { L, a, b: bv };
229
+ }
230
+
231
+ // CIEDE2000 formula
232
+ function ciede2000(lab1, lab2) {
233
+ const { L: L1, a: a1, b: b1 } = lab1;
234
+ const { L: L2, a: a2, b: b2 } = lab2;
235
+ const avgL = (L1 + L2) / 2;
236
+ const C1 = Math.sqrt(a1 * a1 + b1 * b1);
237
+ const C2 = Math.sqrt(a2 * a2 + b2 * b2);
238
+ const avgC = (C1 + C2) / 2;
239
+ const avgC7 = avgC ** 7;
240
+ const g = 0.5 * (1 - Math.sqrt(avgC7 / (avgC7 + 25 ** 7)));
241
+ const a1p = a1 * (1 + g);
242
+ const a2p = a2 * (1 + g);
243
+ const C1p = Math.sqrt(a1p * a1p + b1 * b1);
244
+ const C2p = Math.sqrt(a2p * a2p + b2 * b2);
245
+ const h1p = Math.atan2(b1, a1p) * (180 / Math.PI) + (Math.atan2(b1, a1p) < 0 ? 360 : 0);
246
+ const h2p = Math.atan2(b2, a2p) * (180 / Math.PI) + (Math.atan2(b2, a2p) < 0 ? 360 : 0);
247
+ const dLp = L2 - L1;
248
+ const dCp = C2p - C1p;
249
+ let dhp = h2p - h1p;
250
+ if (C1p * C2p === 0) dhp = 0;
251
+ else if (Math.abs(dhp) > 180) dhp += dhp < 0 ? 360 : -360;
252
+ const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp * Math.PI / 180) / 2);
253
+ const avgLp = (L1 + L2) / 2;
254
+ const avgCp = (C1p + C2p) / 2;
255
+ let avghp = (h1p + h2p) / 2;
256
+ if (C1p * C2p !== 0 && Math.abs(h1p - h2p) > 180) {
257
+ avghp += avghp < 180 ? 180 : -180;
258
+ }
259
+ const T =
260
+ 1 -
261
+ 0.17 * Math.cos((avghp - 30) * Math.PI / 180) +
262
+ 0.24 * Math.cos(2 * avghp * Math.PI / 180) +
263
+ 0.32 * Math.cos((3 * avghp + 6) * Math.PI / 180) -
264
+ 0.20 * Math.cos((4 * avghp - 63) * Math.PI / 180);
265
+ const SL = 1 + 0.015 * (avgLp - 50) ** 2 / Math.sqrt(20 + (avgLp - 50) ** 2);
266
+ const SC = 1 + 0.045 * avgCp;
267
+ const SH = 1 + 0.015 * avgCp * T;
268
+ const avgCp7 = avgCp ** 7;
269
+ const RC = 2 * Math.sqrt(avgCp7 / (avgCp7 + 25 ** 7));
270
+ const dTheta = 30 * Math.exp(-(((avghp - 275) / 25) ** 2));
271
+ const RT = -Math.sin(2 * dTheta * Math.PI / 180) * RC;
272
+ return Math.sqrt(
273
+ (dLp / SL) ** 2 +
274
+ (dCp / SC) ** 2 +
275
+ (dHp / SH) ** 2 +
276
+ RT * (dCp / SC) * (dHp / SH)
277
+ );
278
+ }
279
+
280
+ // ── P3 → sRGB ─────────────────────────────────────────────────────────────────
281
+
282
+ /**
283
+ * Convert Display-P3 pixel values to sRGB in-place (relative colorimetric).
284
+ * Input/output: RGBA bytes (Uint8Array or Buffer).
285
+ * Returns the same img object.
286
+ */
287
+ export function p3ToSrgb(img) {
288
+ const data = img.data;
289
+ for (let i = 0; i < data.length; i += 4) {
290
+ const [r, g, b] = p3PixelToSrgb(data[i], data[i + 1], data[i + 2]);
291
+ data[i] = r; data[i + 1] = g; data[i + 2] = b;
292
+ }
293
+ return img;
294
+ }
295
+
296
+ function p3PixelToSrgb(r8, g8, b8) {
297
+ // Linearize P3 (same gamma as sRGB)
298
+ const lin = (v) => {
299
+ const n = v / 255;
300
+ return n <= 0.04045 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4;
301
+ };
302
+ const lr = lin(r8), lg = lin(g8), lb = lin(b8);
303
+ // P3 D65 → XYZ
304
+ const X = 0.4865709 * lr + 0.2656677 * lg + 0.1982173 * lb;
305
+ const Y = 0.2289946 * lr + 0.6917385 * lg + 0.0792669 * lb;
306
+ const Z = 0.0000000 * lr + 0.0451134 * lg + 1.0439444 * lb;
307
+ // XYZ → sRGB D65
308
+ const lR = 3.2404542 * X - 1.5371385 * Y - 0.4985314 * Z;
309
+ const lG = -0.9692660 * X + 1.8760108 * Y + 0.0415560 * Z;
310
+ const lB = 0.0556434 * X - 0.2040259 * Y + 1.0572252 * Z;
311
+ // Gamma-encode
312
+ const enc = (v) => {
313
+ const c = Math.max(0, Math.min(1, v));
314
+ return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055) * 255);
315
+ };
316
+ return [enc(lR), enc(lG), enc(lB)];
317
+ }
318
+
319
+ // ── Lanczos3 resampling ───────────────────────────────────────────────────────
320
+
321
+ /**
322
+ * Resample img to targetW × targetH using a Lanczos-3 filter.
323
+ * Returns a new image object.
324
+ */
325
+ export function resampleLanczos3(img, targetW, targetH) {
326
+ const out = {
327
+ width: targetW,
328
+ height: targetH,
329
+ data: new Uint8Array(targetW * targetH * 4),
330
+ };
331
+
332
+ const lanczos = (x) => {
333
+ if (x === 0) return 1;
334
+ if (Math.abs(x) >= 3) return 0;
335
+ const px = Math.PI * x;
336
+ return 3 * Math.sin(px) * Math.sin(px / 3) / (px * px);
337
+ };
338
+
339
+ const scaleX = img.width / targetW;
340
+ const scaleY = img.height / targetH;
341
+
342
+ for (let dy = 0; dy < targetH; dy++) {
343
+ for (let dx = 0; dx < targetW; dx++) {
344
+ const srcX = (dx + 0.5) * scaleX - 0.5;
345
+ const srcY = (dy + 0.5) * scaleY - 0.5;
346
+ const x0 = Math.floor(srcX) - 2;
347
+ const y0 = Math.floor(srcY) - 2;
348
+ let r = 0, g = 0, b = 0, a = 0, wSum = 0;
349
+ for (let ky = 0; ky < 6; ky++) {
350
+ const py = Math.max(0, Math.min(img.height - 1, y0 + ky));
351
+ const wy = lanczos(srcY - (y0 + ky));
352
+ for (let kx = 0; kx < 6; kx++) {
353
+ const px = Math.max(0, Math.min(img.width - 1, x0 + kx));
354
+ const wx = lanczos(srcX - (x0 + kx));
355
+ const w = wx * wy;
356
+ const off = (py * img.width + px) * 4;
357
+ r += w * img.data[off];
358
+ g += w * img.data[off + 1];
359
+ b += w * img.data[off + 2];
360
+ a += w * img.data[off + 3];
361
+ wSum += w;
362
+ }
363
+ }
364
+ const doff = (dy * targetW + dx) * 4;
365
+ out.data[doff] = Math.max(0, Math.min(255, Math.round(r / wSum)));
366
+ out.data[doff + 1] = Math.max(0, Math.min(255, Math.round(g / wSum)));
367
+ out.data[doff + 2] = Math.max(0, Math.min(255, Math.round(b / wSum)));
368
+ out.data[doff + 3] = Math.max(0, Math.min(255, Math.round(a / wSum)));
369
+ }
370
+ }
371
+ return out;
372
+ }
373
+
374
+ // ── Crop ─────────────────────────────────────────────────────────────────────
375
+
376
+ /** Extract a rectangular sub-image. Returns new image object. */
377
+ export function cropRect(img, x, y, w, h) {
378
+ const out = { width: w, height: h, data: new Uint8Array(w * h * 4) };
379
+ for (let row = 0; row < h; row++) {
380
+ const srcOff = ((y + row) * img.width + x) * 4;
381
+ const dstOff = row * w * 4;
382
+ out.data.set(img.data.subarray(srcOff, srcOff + w * 4), dstOff);
383
+ }
384
+ return out;
385
+ }
386
+
387
+ // ── Drawing primitives ────────────────────────────────────────────────────────
388
+
389
+ /** Draw a solid-colour border rectangle (outline only, 1px). In-place. */
390
+ export function drawRect(img, x, y, w, h, r, g, b, alpha = 255) {
391
+ const setPixel = (px, py) => {
392
+ if (px < 0 || px >= img.width || py < 0 || py >= img.height) return;
393
+ const o = (py * img.width + px) * 4;
394
+ img.data[o] = r; img.data[o + 1] = g; img.data[o + 2] = b; img.data[o + 3] = alpha;
395
+ };
396
+ for (let i = x; i < x + w; i++) { setPixel(i, y); setPixel(i, y + h - 1); }
397
+ for (let j = y; j < y + h; j++) { setPixel(x, j); setPixel(x + w - 1, j); }
398
+ }
399
+
400
+ /** Draw a translucent filled rectangle with alpha blending. In-place. */
401
+ export function drawFilledRect(img, x, y, w, h, r, g, b, alpha) {
402
+ const a01 = alpha / 255;
403
+ const ia = 1 - a01;
404
+ for (let py = Math.max(0, y); py < Math.min(img.height, y + h); py++) {
405
+ for (let px = Math.max(0, x); px < Math.min(img.width, x + w); px++) {
406
+ const o = (py * img.width + px) * 4;
407
+ img.data[o] = Math.round(r * a01 + img.data[o] * ia);
408
+ img.data[o + 1] = Math.round(g * a01 + img.data[o + 1] * ia);
409
+ img.data[o + 2] = Math.round(b * a01 + img.data[o + 2] * ia);
410
+ img.data[o + 3] = Math.min(255, img.data[o + 3] + alpha);
411
+ }
412
+ }
413
+ }
414
+
415
+ // ── IoU ───────────────────────────────────────────────────────────────────────
416
+
417
+ /** Intersection-over-Union for two {x, y, w, h} bounding boxes. */
418
+ export function bboxIoU(a, b) {
419
+ const ix = Math.max(a.x, b.x);
420
+ const iy = Math.max(a.y, b.y);
421
+ const ix2 = Math.min(a.x + a.w, b.x + b.w);
422
+ const iy2 = Math.min(a.y + a.h, b.y + b.h);
423
+ const inter = Math.max(0, ix2 - ix) * Math.max(0, iy2 - iy);
424
+ const union = a.w * a.h + b.w * b.h - inter;
425
+ return union > 0 ? inter / union : 0;
426
+ }
427
+
428
+ // ── Internal math ─────────────────────────────────────────────────────────────
429
+
430
+ /** Quantile of a sorted array (linear interpolation). */
431
+ export function quantile(sorted, q) {
432
+ if (sorted.length === 0) return 0;
433
+ if (sorted.length === 1) return sorted[0];
434
+ const pos = q * (sorted.length - 1);
435
+ const lo = Math.floor(pos);
436
+ const hi = Math.ceil(pos);
437
+ const frac = pos - lo;
438
+ return sorted[lo] * (1 - frac) + sorted[hi] * frac;
439
+ }
440
+
441
+ /** Separable 2-D DCT-II on a flat Float64Array of size rows×cols. */
442
+ function dct2d(signal, cols, rows) {
443
+ const out = new Float64Array(rows * cols);
444
+ // DCT-II row-wise
445
+ const tmp = new Float64Array(rows * cols);
446
+ for (let row = 0; row < rows; row++) {
447
+ dct1d(signal, tmp, row * cols, cols);
448
+ }
449
+ // DCT-II column-wise
450
+ const col1d = new Float64Array(rows);
451
+ const col1dOut = new Float64Array(rows);
452
+ for (let col = 0; col < cols; col++) {
453
+ for (let row = 0; row < rows; row++) col1d[row] = tmp[row * cols + col];
454
+ dct1d(col1d, col1dOut, 0, rows);
455
+ for (let row = 0; row < rows; row++) out[row * cols + col] = col1dOut[row];
456
+ }
457
+ return out;
458
+ }
459
+
460
+ function dct1d(input, output, offset, n) {
461
+ for (let k = 0; k < n; k++) {
462
+ let sum = 0;
463
+ for (let i = 0; i < n; i++) {
464
+ sum += input[offset + i] * Math.cos((Math.PI / n) * (i + 0.5) * k);
465
+ }
466
+ output[offset + k] = sum;
467
+ }
468
+ }
469
+
470
+ // ── AA-tolerant diff mask ─────────────────────────────────────────────────────
471
+
472
+ /**
473
+ * Build an AA-tolerant diff mask (pixelmatch YIQ / anti-aliasing detection).
474
+ * Returns { mask: img, diffPixelCount, totalPixels }.
475
+ *
476
+ * mask pixels: { r:255, g:0, b:0 } = differing; { r:255,g:255,b:0 } = AA-skip; rest = transparent.
477
+ */
478
+ export function buildDiffMask(imgA, imgB, threshold = 0.1) {
479
+ const { width, height } = imgA;
480
+ const mask = { width, height, data: new Uint8Array(width * height * 4) };
481
+ let diffCount = 0;
482
+
483
+ const yiq = (r, g, b) => 0.299 * r + 0.587 * g + 0.114 * b;
484
+
485
+ for (let py = 0; py < height; py++) {
486
+ for (let px = 0; px < width; px++) {
487
+ const ia = (py * width + px) * 4;
488
+ const ib = (py * imgB.width + px) * 4;
489
+ const dr = imgA.data[ia] - imgB.data[ib];
490
+ const dg = imgA.data[ia + 1] - imgB.data[ib + 1];
491
+ const db = imgA.data[ia + 2] - imgB.data[ib + 2];
492
+ const yDelta = Math.abs(yiq(dr, dg, db)) / 255;
493
+
494
+ const mo = (py * width + px) * 4;
495
+ if (yDelta <= threshold) {
496
+ // same — leave transparent
497
+ mask.data[mo + 3] = 0;
498
+ continue;
499
+ }
500
+
501
+ // Check anti-aliasing: if pixel is part of an AA edge in either image, skip
502
+ if (isAntialiased(imgA, px, py, width, height) ||
503
+ isAntialiased(imgB, px, py, imgB.width, imgB.height)) {
504
+ // AA skip — yellow
505
+ mask.data[mo] = 255;
506
+ mask.data[mo + 1] = 255;
507
+ mask.data[mo + 2] = 0;
508
+ mask.data[mo + 3] = 200;
509
+ continue;
510
+ }
511
+
512
+ // Real diff — red
513
+ mask.data[mo] = 255;
514
+ mask.data[mo + 1] = 0;
515
+ mask.data[mo + 2] = 0;
516
+ mask.data[mo + 3] = 255;
517
+ diffCount++;
518
+ }
519
+ }
520
+ return { mask, diffPixelCount: diffCount, totalPixels: width * height };
521
+ }
522
+
523
+ function isAntialiased(img, px, py, w, h) {
524
+ // Heuristic: check 3×3 neighbourhood for rapid luma transitions
525
+ const getLuma = (x, y) => {
526
+ if (x < 0 || x >= w || y < 0 || y >= h) return null;
527
+ const o = (y * w + x) * 4;
528
+ return 0.299 * img.data[o] + 0.587 * img.data[o + 1] + 0.114 * img.data[o + 2];
529
+ };
530
+ const center = getLuma(px, py);
531
+ let minDelta = Infinity, maxDelta = -Infinity;
532
+ let transitions = 0;
533
+ let prev = null;
534
+ for (let dy = -1; dy <= 1; dy++) {
535
+ for (let dx = -1; dx <= 1; dx++) {
536
+ if (dx === 0 && dy === 0) continue;
537
+ const v = getLuma(px + dx, py + dy);
538
+ if (v === null) continue;
539
+ const d = Math.abs(v - center);
540
+ if (d < minDelta) minDelta = d;
541
+ if (d > maxDelta) maxDelta = d;
542
+ if (prev !== null && Math.abs(v - prev) > 10) transitions++;
543
+ prev = v;
544
+ }
545
+ }
546
+ return maxDelta > 10 && minDelta < 10 && transitions >= 2;
547
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * _provenance.mjs — Shared provenance binding for h5-to-swiftui calibration.
3
+ *
4
+ * Single source of truth for the deterministic source-tree hash used to bind
5
+ * the IDENTITY of the SHIPPED bundled calibration twin SOURCE FILES (NOT the
6
+ * measured `floor` value) into a `calibration.json`. Both
7
+ * `calibrate-render.mjs` (producer) and `evaluate-convergence.mjs` (consumer)
8
+ * import THIS function so the hash is computed identically on both sides.
9
+ *
10
+ * Scope of what this binds (precise — no over-claim): it binds the *identity*
11
+ * of the bundled twin source files. It does NOT bind the measured `floor`
12
+ * value: an attacker who keeps the real, public, unmodified bundled-twin
13
+ * source hashes but writes a loose `floor` is NOT caught by this hash. The
14
+ * grader narrows that path with FIX A (the floor must satisfy
15
+ * calibrate-render's own sanity envelope, see `_calib-consts.mjs`); a floor
16
+ * *within* that envelope yet looser than the true measured floor is a
17
+ * disclosed irreducible residual (the grader cannot re-render to re-measure
18
+ * it). See the Trust-model header in `evaluate-convergence.mjs`.
19
+ *
20
+ * Why a source-tree hash (not the rendered PNG hash):
21
+ * The rendered PNGs are produced at runtime by sim-screenshot.sh /
22
+ * capture-reference.mjs and will NOT be byte-identical across machines
23
+ * (renderer/OS/font differences). The SOURCE the calibration must derive
24
+ * from is fixed and shippable, so we content-hash the source tree instead.
25
+ *
26
+ * Determinism rules (must hold across OS / cwd / clone):
27
+ * - file list is sorted by POSIX relative path (forward slashes)
28
+ * - each file contributes: `<relpath>\0<sha256-of-bytes>\n`
29
+ * - directories that do not exist contribute a sentinel (so a missing tree
30
+ * is detected, never silently treated as "matches")
31
+ * - the `.build/`, `.swiftpm/`, `xcuserdata/` SwiftPM artifact dirs and
32
+ * dotfiles are excluded (gitignored build output, not source) — so what
33
+ * is pinned is the SOURCE FILES (excluding build output/dotfiles), not
34
+ * the whole on-disk tree; see the EXCLUSION RATIONALE on SKIP_DIR below
35
+ *
36
+ * No npm dependencies — node:crypto + node:fs + node:path only.
37
+ */
38
+
39
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
40
+ import { join, relative, sep } from 'node:path';
41
+ import { createHash } from 'node:crypto';
42
+
43
+ // EXCLUSION RATIONALE (FIX D — MAJOR-1, precise):
44
+ // Excluded-path content — dotfiles/dotdirs (the `name.startsWith('.')` skip
45
+ // in walk()), `.git`, `.build`, `.swiftpm`, `xcuserdata`, `node_modules`
46
+ // (this SKIP_DIR set) — is INTENTIONALLY NOT bound by the source-tree hash.
47
+ // This is SAFE FOR THE BUNDLED CALIBRATION ASSETS SPECIFICALLY because those
48
+ // trees (`assets/calibration/{h5-twin,swiftui-twin,swiftui-twin-divergent}`)
49
+ // are HTML/CSS/Swift SOURCE containing no dotfiles, and `.build/` /
50
+ // `.swiftpm/` / `xcuserdata/` are gitignored SwiftPM/Xcode BUILD OUTPUT (not
51
+ // source) that is regenerated per machine and would make the hash unstable.
52
+ // Consequence (stated, not hidden): only the bundled twin SOURCE FILES are
53
+ // hash-pinned — build output and dotfiles are not. For non-source-only trees
54
+ // this exclusion would be a gap; for the calibration assets it is correct by
55
+ // construction (see assets/calibration/.gitignore + README.md).
56
+ const SKIP_DIR = new Set(['.build', '.swiftpm', 'xcuserdata', '.git', 'node_modules']);
57
+
58
+ /** Recursively collect files under `dir` as POSIX relpaths (sorted). */
59
+ function walk(dir, base, acc) {
60
+ let entries;
61
+ try {
62
+ entries = readdirSync(dir, { withFileTypes: true });
63
+ } catch {
64
+ return;
65
+ }
66
+ for (const e of entries) {
67
+ const name = e.name;
68
+ if (name.startsWith('.')) continue; // dotfiles/dotdirs excluded
69
+ if (e.isDirectory()) {
70
+ if (SKIP_DIR.has(name)) continue;
71
+ walk(join(dir, name), base, acc);
72
+ } else if (e.isFile()) {
73
+ const rel = relative(base, join(dir, name)).split(sep).join('/');
74
+ acc.push(rel);
75
+ }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Deterministic SHA-256 content hash of a source tree.
81
+ * Returns a 64-hex string. A non-existent directory yields a STABLE sentinel
82
+ * hash of the literal string `__MISSING__:<basename>` so absence is bound
83
+ * (never confused with a real tree).
84
+ */
85
+ export function sourceTreeHash(dir) {
86
+ const h = createHash('sha256');
87
+ if (!existsSync(dir)) {
88
+ const baseName = dir.split(sep).filter(Boolean).pop() ?? dir;
89
+ h.update(`__MISSING__:${baseName}`);
90
+ return h.digest('hex');
91
+ }
92
+ let st;
93
+ try {
94
+ st = statSync(dir);
95
+ } catch {
96
+ h.update('__UNSTATABLE__');
97
+ return h.digest('hex');
98
+ }
99
+ if (!st.isDirectory()) {
100
+ // A file where a directory was expected — bind that fact deterministically.
101
+ h.update('__NOT_A_DIR__:');
102
+ h.update(createHash('sha256').update(readFileSync(dir)).digest('hex'));
103
+ return h.digest('hex');
104
+ }
105
+ const files = [];
106
+ walk(dir, dir, files);
107
+ files.sort();
108
+ for (const rel of files) {
109
+ const bytesHash = createHash('sha256')
110
+ .update(readFileSync(join(dir, rel)))
111
+ .digest('hex');
112
+ h.update(rel);
113
+ h.update('\0');
114
+ h.update(bytesHash);
115
+ h.update('\n');
116
+ }
117
+ return h.digest('hex');
118
+ }
119
+
120
+ /** SHA-256 of a single file's raw bytes (64-hex). */
121
+ export function sha256File(p) {
122
+ return createHash('sha256').update(readFileSync(p)).digest('hex');
123
+ }