@reddoorla/maintenance 0.95.1 → 0.97.0

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 (316) hide show
  1. package/README.md +41 -7
  2. package/dist/{announce-CLQYX5I4.js → announce-5ZD2SBGD.js} +39 -31
  3. package/dist/announce-5ZD2SBGD.js.map +1 -0
  4. package/dist/attachments-JFGFQRXQ.js +10 -0
  5. package/dist/{blux-4KNXUP2K.js → blux-NXQLMS4W.js} +1 -1
  6. package/dist/{bump-deps-C5G2EFBB.js → bump-deps-775VLDYB.js} +6 -6
  7. package/dist/{chunk-FUWFLAWQ.js → chunk-3RLDX3Q2.js} +5 -5
  8. package/dist/{chunk-XHHMM5HX.js → chunk-43SW3FGW.js} +63 -1
  9. package/dist/chunk-43SW3FGW.js.map +1 -0
  10. package/dist/{chunk-R53VL3RI.js → chunk-5CJVUPJU.js} +1 -1
  11. package/dist/{chunk-R53VL3RI.js.map → chunk-5CJVUPJU.js.map} +1 -1
  12. package/dist/{chunk-S7RYFUIK.js → chunk-6PGXKN6F.js} +2 -2
  13. package/dist/{chunk-HTABUCF4.js → chunk-6THEZQLA.js} +3 -3
  14. package/dist/chunk-A5QKNOXW.js +72 -0
  15. package/dist/chunk-A5QKNOXW.js.map +1 -0
  16. package/dist/chunk-ASLDCWBF.js +41 -0
  17. package/dist/chunk-ASLDCWBF.js.map +1 -0
  18. package/dist/{chunk-CWXTNPNZ.js → chunk-AYDQQBNG.js} +3 -3
  19. package/dist/chunk-AYDQQBNG.js.map +1 -0
  20. package/dist/{chunk-WVNDEYXL.js → chunk-BINFZQ2T.js} +57 -6
  21. package/dist/chunk-BINFZQ2T.js.map +1 -0
  22. package/dist/{chunk-2NQUCS7O.js → chunk-BIYQN7FH.js} +3 -1
  23. package/dist/{chunk-2NQUCS7O.js.map → chunk-BIYQN7FH.js.map} +1 -1
  24. package/dist/{chunk-YLYXW5PH.js → chunk-CXICCLNM.js} +34 -4
  25. package/dist/chunk-CXICCLNM.js.map +1 -0
  26. package/dist/{chunk-BYGPUUCT.js → chunk-DRNZVORX.js} +66 -30
  27. package/dist/chunk-DRNZVORX.js.map +1 -0
  28. package/dist/{chunk-LM2NHRCJ.js → chunk-DYTYPKFN.js} +4 -4
  29. package/dist/chunk-DYTYPKFN.js.map +1 -0
  30. package/dist/{chunk-DFKBTBO3.js → chunk-EN6CTBUN.js} +35 -6
  31. package/dist/chunk-EN6CTBUN.js.map +1 -0
  32. package/dist/{chunk-5EEMTJMT.js → chunk-FHFXJ3EM.js} +8 -6
  33. package/dist/chunk-FHFXJ3EM.js.map +1 -0
  34. package/dist/{chunk-Z2K4JPQ3.js → chunk-FP6HOW6J.js} +10 -7
  35. package/dist/chunk-FP6HOW6J.js.map +1 -0
  36. package/dist/{chunk-2LYOAB2E.js → chunk-HSMN35LU.js} +2 -2
  37. package/dist/chunk-ICSRWW5Q.js +148 -0
  38. package/dist/chunk-ICSRWW5Q.js.map +1 -0
  39. package/dist/{chunk-FSGICFXB.js → chunk-IDBPII6G.js} +17 -5
  40. package/dist/chunk-IDBPII6G.js.map +1 -0
  41. package/dist/{chunk-C3IQQKXL.js → chunk-IOTOOTII.js} +121 -1
  42. package/dist/{chunk-C3IQQKXL.js.map → chunk-IOTOOTII.js.map} +1 -1
  43. package/dist/{chunk-BCKEP5S6.js → chunk-JHJNNJLG.js} +2 -2
  44. package/dist/{chunk-TO7DJYLZ.js → chunk-L3S3QZXL.js} +178 -39
  45. package/dist/chunk-L3S3QZXL.js.map +1 -0
  46. package/dist/{chunk-WSJ7RO54.js → chunk-LAG6IP7Y.js} +23 -5
  47. package/dist/chunk-LAG6IP7Y.js.map +1 -0
  48. package/dist/{chunk-WJMPCVOR.js → chunk-LAJ3FF54.js} +12 -4
  49. package/dist/chunk-LAJ3FF54.js.map +1 -0
  50. package/dist/{chunk-2ZAZ7ETT.js → chunk-LDHRIQUW.js} +32 -18
  51. package/dist/chunk-LDHRIQUW.js.map +1 -0
  52. package/dist/{chunk-6R56OKJZ.js → chunk-LF2TJN4X.js} +2 -2
  53. package/dist/chunk-LF2TJN4X.js.map +1 -0
  54. package/dist/{chunk-ZQV7UQNE.js → chunk-LUF52EIY.js} +2 -2
  55. package/dist/{chunk-5UINJNAJ.js → chunk-LZMAG73U.js} +34 -21
  56. package/dist/chunk-LZMAG73U.js.map +1 -0
  57. package/dist/{chunk-VSGMCCGQ.js → chunk-MMSMZC7S.js} +2 -2
  58. package/dist/chunk-MVKNU4QD.js +37 -0
  59. package/dist/chunk-MVKNU4QD.js.map +1 -0
  60. package/dist/{chunk-XPJQXSC4.js → chunk-P7323YVW.js} +27 -5
  61. package/dist/chunk-P7323YVW.js.map +1 -0
  62. package/dist/{chunk-KJRVMMM3.js → chunk-PARKXDXI.js} +36 -5
  63. package/dist/chunk-PARKXDXI.js.map +1 -0
  64. package/dist/{chunk-4763KQ6O.js → chunk-PXRN2MER.js} +2 -2
  65. package/dist/{chunk-WMBLR5VA.js → chunk-PZZ6HM5T.js} +3 -3
  66. package/dist/{chunk-TR3HFH6J.js → chunk-QFSL2L2B.js} +67 -10
  67. package/dist/chunk-QFSL2L2B.js.map +1 -0
  68. package/dist/chunk-RQJFGK3A.js +51 -0
  69. package/dist/chunk-RQJFGK3A.js.map +1 -0
  70. package/dist/{chunk-3Z6X3BHU.js → chunk-TC4GANMX.js} +53 -150
  71. package/dist/chunk-TC4GANMX.js.map +1 -0
  72. package/dist/{chunk-F3JWOPTG.js → chunk-TEMQPSCM.js} +45 -105
  73. package/dist/chunk-TEMQPSCM.js.map +1 -0
  74. package/dist/chunk-TJLKWQOX.js +52 -0
  75. package/dist/chunk-TJLKWQOX.js.map +1 -0
  76. package/dist/{chunk-CJ2RMTT7.js → chunk-TV76A6GN.js} +2 -2
  77. package/dist/{chunk-KZRR4NFG.js → chunk-TVZ6QCOE.js} +3 -3
  78. package/dist/{chunk-BVEDKMNG.js → chunk-U2ZI6G7H.js} +29 -11
  79. package/dist/chunk-U2ZI6G7H.js.map +1 -0
  80. package/dist/chunk-UPE6D4AT.js +69 -0
  81. package/dist/chunk-UPE6D4AT.js.map +1 -0
  82. package/dist/{chunk-R5IMFAFR.js → chunk-VMGHQUTV.js} +7 -16
  83. package/dist/chunk-VMGHQUTV.js.map +1 -0
  84. package/dist/chunk-VQMAAQ5M.js +53 -0
  85. package/dist/chunk-VQMAAQ5M.js.map +1 -0
  86. package/dist/{chunk-AVEQUR44.js → chunk-WMF5S2MB.js} +60 -17
  87. package/dist/chunk-WMF5S2MB.js.map +1 -0
  88. package/dist/{chunk-SVAAONYB.js → chunk-WQLNUEZL.js} +12 -2
  89. package/dist/{chunk-SVAAONYB.js.map → chunk-WQLNUEZL.js.map} +1 -1
  90. package/dist/{chunk-XN37VTDB.js → chunk-X56LAKPR.js} +3 -3
  91. package/dist/chunk-X56LAKPR.js.map +1 -0
  92. package/dist/{chunk-ZCL7C5LH.js → chunk-Y6FCXRIC.js} +1661 -387
  93. package/dist/chunk-Y6FCXRIC.js.map +1 -0
  94. package/dist/{chunk-2SLED3NB.js → chunk-YDZ7XGPZ.js} +1 -1
  95. package/dist/{chunk-2SLED3NB.js.map → chunk-YDZ7XGPZ.js.map} +1 -1
  96. package/dist/{chunk-IY6256GK.js → chunk-YJBSB4LP.js} +6 -1
  97. package/dist/chunk-YJBSB4LP.js.map +1 -0
  98. package/dist/{chunk-BI2VI6EM.js → chunk-YXWBM26P.js} +2 -2
  99. package/dist/{chunk-QF6IQQFP.js → chunk-Z7FDJ35L.js} +18 -12
  100. package/dist/chunk-Z7FDJ35L.js.map +1 -0
  101. package/dist/cli/bin.js +111 -51
  102. package/dist/cli/bin.js.map +1 -1
  103. package/dist/cli/commands/audit.d.ts +33 -3
  104. package/dist/cli/commands/audit.js +28 -25
  105. package/dist/cli/commands/audit.js.map +1 -1
  106. package/dist/client-DGTO65WB.js +11 -0
  107. package/dist/configs/playwright-a11y.d.ts +11 -1
  108. package/dist/configs/playwright-a11y.js +3 -1
  109. package/dist/{convert-to-pnpm-YTFREUJK.js → convert-to-pnpm-KLWA53N5.js} +6 -6
  110. package/dist/{db-XKZYDCKM.js → db-II5WAK4D.js} +45 -20
  111. package/dist/db-II5WAK4D.js.map +1 -0
  112. package/dist/deadletter-G4M3HSVM.js +17 -0
  113. package/dist/{digest-7UBGUBRK.js → digest-CWKK2DMR.js} +56 -32
  114. package/dist/digest-CWKK2DMR.js.map +1 -0
  115. package/dist/{digest-collectors-DJFN5ZDK.js → digest-collectors-CYTN2EUX.js} +17 -12
  116. package/dist/{digest-state-3KHZF7GD.js → digest-state-ZDPKR7MW.js} +18 -2
  117. package/dist/digest-state-ZDPKR7MW.js.map +1 -0
  118. package/dist/ensure-site-2QZHFVQ3.js +70 -0
  119. package/dist/ensure-site-2QZHFVQ3.js.map +1 -0
  120. package/dist/ensure-site-JEKV2ODE.js +105 -0
  121. package/dist/ensure-site-JEKV2ODE.js.map +1 -0
  122. package/dist/{ensure-site-OAAE5YE2.js → ensure-site-OO4UPWNQ.js} +49 -44
  123. package/dist/ensure-site-OO4UPWNQ.js.map +1 -0
  124. package/dist/fleet-events-CkOZpMVC.d.ts +435 -0
  125. package/dist/{fleet-state-UEVZ3X3J.js → fleet-state-RJROYXKO.js} +19 -7
  126. package/dist/{forms-notify-target-UC6PF3AE.js → forms-notify-target-X6UEOI2M.js} +19 -15
  127. package/dist/forms-notify-target-X6UEOI2M.js.map +1 -0
  128. package/dist/{freeze-A6MUCZU4.js → freeze-GUQ43EAU.js} +2 -2
  129. package/dist/{github-signals-TX5FPYP6.js → github-signals-QIRTHHET.js} +22 -17
  130. package/dist/github-signals-QIRTHHET.js.map +1 -0
  131. package/dist/{header-image-PSPT7CUM.js → header-image-H7ZJAV26.js} +15 -18
  132. package/dist/header-image-H7ZJAV26.js.map +1 -0
  133. package/dist/{health-endpoint-KVCJYWET.js → health-endpoint-BJ6BKRHF.js} +8 -7
  134. package/dist/{health-endpoint-KVCJYWET.js.map → health-endpoint-BJ6BKRHF.js.map} +1 -1
  135. package/dist/{health-mirror-XJAKA3EY.js → health-mirror-KU3G7V2Y.js} +10 -10
  136. package/dist/{import-airtable-TAKSN4NV.js → import-airtable-UBF5GYAL.js} +3 -3
  137. package/dist/index.d.ts +158 -419
  138. package/dist/index.js +91 -46
  139. package/dist/index.js.map +1 -1
  140. package/dist/{init-FFE4FCO2.js → init-GOWDA5QS.js} +19 -18
  141. package/dist/{init-FFE4FCO2.js.map → init-GOWDA5QS.js.map} +1 -1
  142. package/dist/{launch-UD36AHUN.js → launch-7N3X2MXB.js} +229 -79
  143. package/dist/launch-7N3X2MXB.js.map +1 -0
  144. package/dist/{lighthouse-VHI77PFB.js → lighthouse-52WSPZ5D.js} +2 -2
  145. package/dist/lighthouse-airtable-M45OPVRV.js +14 -0
  146. package/dist/match-harness-GBCDSVPS.js +417 -0
  147. package/dist/match-harness-GBCDSVPS.js.map +1 -0
  148. package/dist/migrate-XZ7U5DFC.js +7 -0
  149. package/dist/{notify-IISTHNBR.js → notify-K3WVH3DM.js} +4 -4
  150. package/dist/{onboard-TM2KESGF.js → onboard-JQQIMQW7.js} +6 -6
  151. package/dist/{orchestrate-HYVQPRCE.js → orchestrate-L2A5BZFY.js} +13 -10
  152. package/dist/parity-2JGVUCZD.js +14 -0
  153. package/dist/{pipeline-YROAY6DT.js → pipeline-UUPXOS2J.js} +3 -3
  154. package/dist/{preflight-RYIP54NY.js → preflight-O7Q6FWHU.js} +35 -11
  155. package/dist/preflight-O7Q6FWHU.js.map +1 -0
  156. package/dist/{prismic-ci-6GKKZGB5.js → prismic-ci-NAPWOXXD.js} +39 -9
  157. package/dist/prismic-ci-NAPWOXXD.js.map +1 -0
  158. package/dist/{prismic-models-P6FWWVRS.js → prismic-models-LL6ORTQ2.js} +20 -17
  159. package/dist/prismic-models-LL6ORTQ2.js.map +1 -0
  160. package/dist/prospect/types.d.ts +72 -0
  161. package/dist/prospect/types.js +1 -1
  162. package/dist/{prospect-audit-PRHU53HS.js → prospect-audit-FMPGQIRQ.js} +16 -7
  163. package/dist/prospect-audit-FMPGQIRQ.js.map +1 -0
  164. package/dist/{prospect-audits-R3LWF6U7.js → prospect-audits-IZVQY3OQ.js} +4 -2
  165. package/dist/protection-audit-YXJKFU6O.js +413 -0
  166. package/dist/protection-audit-YXJKFU6O.js.map +1 -0
  167. package/dist/recipes/sync-configs.js +3 -3
  168. package/dist/{render-YGNGTX3W.js → render-MHUUHPLN.js} +36 -9
  169. package/dist/render-MHUUHPLN.js.map +1 -0
  170. package/dist/{render-from-row-IFSBHWDH.js → render-from-row-75JTED2Z.js} +6 -4
  171. package/dist/{renovate-dispatch-U5Q52SKT.js → renovate-dispatch-2IHAZDVA.js} +14 -9
  172. package/dist/{renovate-dispatch-U5Q52SKT.js.map → renovate-dispatch-2IHAZDVA.js.map} +1 -1
  173. package/dist/{replay-PCSQOGCZ.js → replay-4FVFOMHH.js} +10 -23
  174. package/dist/replay-4FVFOMHH.js.map +1 -0
  175. package/dist/{report-LQS4ECTM.js → report-IAHXN5CW.js} +103 -57
  176. package/dist/report-IAHXN5CW.js.map +1 -0
  177. package/dist/{report-mirror-HDTOJE64.js → report-mirror-4QM57LMS.js} +34 -12
  178. package/dist/report-mirror-4QM57LMS.js.map +1 -0
  179. package/dist/roster-GDNSIRAK.js +19 -0
  180. package/dist/roster-GDNSIRAK.js.map +1 -0
  181. package/dist/{schema-A4WCPIF4.js → schema-KWUQEZ5O.js} +1 -1
  182. package/dist/schema-KWUQEZ5O.js.map +1 -0
  183. package/dist/{self-updating-MWXIZLL7.js → self-updating-ZUWPGYUP.js} +8 -7
  184. package/dist/{self-updating-MWXIZLL7.js.map → self-updating-ZUWPGYUP.js.map} +1 -1
  185. package/dist/{selftest-VXTHRPRN.js → selftest-G4W3LIBQ.js} +21 -17
  186. package/dist/{selftest-VXTHRPRN.js.map → selftest-G4W3LIBQ.js.map} +1 -1
  187. package/dist/site-create-ZU3ACBTO.js +45 -0
  188. package/dist/site-create-ZU3ACBTO.js.map +1 -0
  189. package/dist/site-lookup-DTOLJ4EA.js +27 -0
  190. package/dist/site-lookup-DTOLJ4EA.js.map +1 -0
  191. package/dist/{site-mirror-WON4FTJ2.js → site-mirror-WR7ACXOV.js} +26 -12
  192. package/dist/site-mirror-WR7ACXOV.js.map +1 -0
  193. package/dist/{smoke-suite-7DGITCTZ.js → smoke-suite-WAUPI6EJ.js} +8 -7
  194. package/dist/{smoke-suite-7DGITCTZ.js.map → smoke-suite-WAUPI6EJ.js.map} +1 -1
  195. package/dist/{submissions-OVZPVBYG.js → submissions-2SHCFOKD.js} +12 -7
  196. package/dist/submissions-2SHCFOKD.js.map +1 -0
  197. package/dist/{submissions-3I3GAQAX.js → submissions-JDZP35JM.js} +49 -9
  198. package/dist/submissions-JDZP35JM.js.map +1 -0
  199. package/dist/{svelte-codemods-WSV2FGNG.js → svelte-codemods-VOLOKKFI.js} +6 -6
  200. package/dist/{sync-K3PKHVLH.js → sync-F4USL4K5.js} +6 -6
  201. package/dist/{sync-configs-RRIHNLIJ.js → sync-configs-OXSWKVZ2.js} +6 -6
  202. package/dist/turso-KQKBBQCK.js +24 -0
  203. package/dist/turso-KQKBBQCK.js.map +1 -0
  204. package/dist/{upgrade-VTD6V4HS.js → upgrade-EVAL3JTO.js} +6 -6
  205. package/dist/util/git.d.ts +49 -10
  206. package/dist/util/git.js +1 -1
  207. package/dist/{webflow-IRKOIT7S.js → webflow-624FEBFD.js} +4 -4
  208. package/dist/{websites-TB4TCAWM.js → websites-Y6FN2XFD.js} +27 -20
  209. package/dist/{write-audits-to-airtable-53EVXKMU.js → write-audits-to-airtable-TI3JMWSY.js} +7 -5
  210. package/package.json +3 -2
  211. package/dist/airtable-JGCPS7JH.js +0 -9
  212. package/dist/announce-CLQYX5I4.js.map +0 -1
  213. package/dist/attachments-NLZIJ5NU.js +0 -9
  214. package/dist/chunk-2ZAZ7ETT.js.map +0 -1
  215. package/dist/chunk-3Z6X3BHU.js.map +0 -1
  216. package/dist/chunk-5EEMTJMT.js.map +0 -1
  217. package/dist/chunk-5GUJ36TL.js +0 -53
  218. package/dist/chunk-5GUJ36TL.js.map +0 -1
  219. package/dist/chunk-5UINJNAJ.js.map +0 -1
  220. package/dist/chunk-6R56OKJZ.js.map +0 -1
  221. package/dist/chunk-AVEQUR44.js.map +0 -1
  222. package/dist/chunk-BVEDKMNG.js.map +0 -1
  223. package/dist/chunk-BYGPUUCT.js.map +0 -1
  224. package/dist/chunk-CWXTNPNZ.js.map +0 -1
  225. package/dist/chunk-DFKBTBO3.js.map +0 -1
  226. package/dist/chunk-F3JWOPTG.js.map +0 -1
  227. package/dist/chunk-FSGICFXB.js.map +0 -1
  228. package/dist/chunk-GASBX52O.js +0 -12
  229. package/dist/chunk-GASBX52O.js.map +0 -1
  230. package/dist/chunk-IY6256GK.js.map +0 -1
  231. package/dist/chunk-KJRVMMM3.js.map +0 -1
  232. package/dist/chunk-LM2NHRCJ.js.map +0 -1
  233. package/dist/chunk-QF6IQQFP.js.map +0 -1
  234. package/dist/chunk-R5IMFAFR.js.map +0 -1
  235. package/dist/chunk-TO7DJYLZ.js.map +0 -1
  236. package/dist/chunk-TR3HFH6J.js.map +0 -1
  237. package/dist/chunk-WJMPCVOR.js.map +0 -1
  238. package/dist/chunk-WSJ7RO54.js.map +0 -1
  239. package/dist/chunk-WVNDEYXL.js.map +0 -1
  240. package/dist/chunk-XHHMM5HX.js.map +0 -1
  241. package/dist/chunk-XN37VTDB.js.map +0 -1
  242. package/dist/chunk-XPJQXSC4.js.map +0 -1
  243. package/dist/chunk-YFAXOD7Z.js +0 -42
  244. package/dist/chunk-YFAXOD7Z.js.map +0 -1
  245. package/dist/chunk-YLYXW5PH.js.map +0 -1
  246. package/dist/chunk-Z2K4JPQ3.js.map +0 -1
  247. package/dist/chunk-ZCL7C5LH.js.map +0 -1
  248. package/dist/client-G2WGWPCA.js +0 -11
  249. package/dist/db-XKZYDCKM.js.map +0 -1
  250. package/dist/digest-7UBGUBRK.js.map +0 -1
  251. package/dist/digest-state-3KHZF7GD.js.map +0 -1
  252. package/dist/ensure-site-OAAE5YE2.js.map +0 -1
  253. package/dist/fleet-events-cF2Om88w.d.ts +0 -24
  254. package/dist/forms-notify-target-UC6PF3AE.js.map +0 -1
  255. package/dist/github-signals-TX5FPYP6.js.map +0 -1
  256. package/dist/header-image-PSPT7CUM.js.map +0 -1
  257. package/dist/launch-UD36AHUN.js.map +0 -1
  258. package/dist/lighthouse-airtable-YMH4UA7R.js +0 -12
  259. package/dist/match-harness-QRG6XZAR.js +0 -70
  260. package/dist/match-harness-QRG6XZAR.js.map +0 -1
  261. package/dist/migrate-Y7R4CESL.js +0 -7
  262. package/dist/parity-CAJ477PS.js +0 -14
  263. package/dist/preflight-RYIP54NY.js.map +0 -1
  264. package/dist/prismic-ci-6GKKZGB5.js.map +0 -1
  265. package/dist/prismic-models-P6FWWVRS.js.map +0 -1
  266. package/dist/prospect-audit-PRHU53HS.js.map +0 -1
  267. package/dist/protection-audit-SJO53MEQ.js +0 -168
  268. package/dist/protection-audit-SJO53MEQ.js.map +0 -1
  269. package/dist/render-YGNGTX3W.js.map +0 -1
  270. package/dist/replay-PCSQOGCZ.js.map +0 -1
  271. package/dist/report-LQS4ECTM.js.map +0 -1
  272. package/dist/report-mirror-HDTOJE64.js.map +0 -1
  273. package/dist/schema-A4WCPIF4.js.map +0 -1
  274. package/dist/site-mirror-WON4FTJ2.js.map +0 -1
  275. package/dist/submissions-3I3GAQAX.js.map +0 -1
  276. package/dist/submissions-OVZPVBYG.js.map +0 -1
  277. /package/dist/{airtable-JGCPS7JH.js.map → attachments-JFGFQRXQ.js.map} +0 -0
  278. /package/dist/{blux-4KNXUP2K.js.map → blux-NXQLMS4W.js.map} +0 -0
  279. /package/dist/{bump-deps-C5G2EFBB.js.map → bump-deps-775VLDYB.js.map} +0 -0
  280. /package/dist/{chunk-FUWFLAWQ.js.map → chunk-3RLDX3Q2.js.map} +0 -0
  281. /package/dist/{chunk-S7RYFUIK.js.map → chunk-6PGXKN6F.js.map} +0 -0
  282. /package/dist/{chunk-HTABUCF4.js.map → chunk-6THEZQLA.js.map} +0 -0
  283. /package/dist/{chunk-2LYOAB2E.js.map → chunk-HSMN35LU.js.map} +0 -0
  284. /package/dist/{chunk-BCKEP5S6.js.map → chunk-JHJNNJLG.js.map} +0 -0
  285. /package/dist/{chunk-ZQV7UQNE.js.map → chunk-LUF52EIY.js.map} +0 -0
  286. /package/dist/{chunk-VSGMCCGQ.js.map → chunk-MMSMZC7S.js.map} +0 -0
  287. /package/dist/{chunk-4763KQ6O.js.map → chunk-PXRN2MER.js.map} +0 -0
  288. /package/dist/{chunk-WMBLR5VA.js.map → chunk-PZZ6HM5T.js.map} +0 -0
  289. /package/dist/{chunk-CJ2RMTT7.js.map → chunk-TV76A6GN.js.map} +0 -0
  290. /package/dist/{chunk-KZRR4NFG.js.map → chunk-TVZ6QCOE.js.map} +0 -0
  291. /package/dist/{chunk-BI2VI6EM.js.map → chunk-YXWBM26P.js.map} +0 -0
  292. /package/dist/{attachments-NLZIJ5NU.js.map → client-DGTO65WB.js.map} +0 -0
  293. /package/dist/{convert-to-pnpm-YTFREUJK.js.map → convert-to-pnpm-KLWA53N5.js.map} +0 -0
  294. /package/dist/{client-G2WGWPCA.js.map → deadletter-G4M3HSVM.js.map} +0 -0
  295. /package/dist/{digest-collectors-DJFN5ZDK.js.map → digest-collectors-CYTN2EUX.js.map} +0 -0
  296. /package/dist/{fleet-state-UEVZ3X3J.js.map → fleet-state-RJROYXKO.js.map} +0 -0
  297. /package/dist/{freeze-A6MUCZU4.js.map → freeze-GUQ43EAU.js.map} +0 -0
  298. /package/dist/{health-mirror-XJAKA3EY.js.map → health-mirror-KU3G7V2Y.js.map} +0 -0
  299. /package/dist/{import-airtable-TAKSN4NV.js.map → import-airtable-UBF5GYAL.js.map} +0 -0
  300. /package/dist/{lighthouse-VHI77PFB.js.map → lighthouse-52WSPZ5D.js.map} +0 -0
  301. /package/dist/{lighthouse-airtable-YMH4UA7R.js.map → lighthouse-airtable-M45OPVRV.js.map} +0 -0
  302. /package/dist/{migrate-Y7R4CESL.js.map → migrate-XZ7U5DFC.js.map} +0 -0
  303. /package/dist/{notify-IISTHNBR.js.map → notify-K3WVH3DM.js.map} +0 -0
  304. /package/dist/{onboard-TM2KESGF.js.map → onboard-JQQIMQW7.js.map} +0 -0
  305. /package/dist/{orchestrate-HYVQPRCE.js.map → orchestrate-L2A5BZFY.js.map} +0 -0
  306. /package/dist/{parity-CAJ477PS.js.map → parity-2JGVUCZD.js.map} +0 -0
  307. /package/dist/{pipeline-YROAY6DT.js.map → pipeline-UUPXOS2J.js.map} +0 -0
  308. /package/dist/{prospect-audits-R3LWF6U7.js.map → prospect-audits-IZVQY3OQ.js.map} +0 -0
  309. /package/dist/{render-from-row-IFSBHWDH.js.map → render-from-row-75JTED2Z.js.map} +0 -0
  310. /package/dist/{svelte-codemods-WSV2FGNG.js.map → svelte-codemods-VOLOKKFI.js.map} +0 -0
  311. /package/dist/{sync-K3PKHVLH.js.map → sync-F4USL4K5.js.map} +0 -0
  312. /package/dist/{sync-configs-RRIHNLIJ.js.map → sync-configs-OXSWKVZ2.js.map} +0 -0
  313. /package/dist/{upgrade-VTD6V4HS.js.map → upgrade-EVAL3JTO.js.map} +0 -0
  314. /package/dist/{webflow-IRKOIT7S.js.map → webflow-624FEBFD.js.map} +0 -0
  315. /package/dist/{websites-TB4TCAWM.js.map → websites-Y6FN2XFD.js.map} +0 -0
  316. /package/dist/{write-audits-to-airtable-53EVXKMU.js.map → write-audits-to-airtable-TI3JMWSY.js.map} +0 -0
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/reports/airtable/site-status.ts","../src/reports/airtable/websites.ts"],"sourcesContent":["/**\n * The site-status vocabulary — ONE module owns it (#539 Phase 4).\n *\n * The fleet's lifecycle names were inherited from an Airtable single-select\n * written by hand years ago (\"probably not our problem\"). The operator approved\n * a canonical vocabulary, and the migration is now COMPLETE in both stores:\n * Airtable's \"Status\" single-select carries exactly the six canonical options\n * and nothing else. This module no longer knows the old names at all.\n *\n * The retired mapping, kept as the record of what was merged into what:\n *\n * old (retired 2026-08-25) new (canonical)\n * ───────────────────────── ───────────────\n * in development building\n * launch period launching\n * maintenance maintained\n * hosting hosted-only\n * probably not our problem external\n * legacy ┐\n * deprecated ┴ archived ← approved MERGE, many-to-one\n *\n * `legacy` and `deprecated` were already treated identically by every PREDICATE\n * (ARCHIVED_STATUSES held both), so collapsing them changes no *selection* — no\n * fleet op gains or loses a site. It is NOT true that collapsing them changes\n * nothing at all, and one surface proved it: the cockpit's Archived lane LABELS\n * each row with its Status cell, and canonicalizing there rendered all 12 live\n * archived rows as \"archived\". That lane now labels from `statusRaw`, which is\n * exactly why `WebsiteRow.statusRaw` exists. The rule this leaves behind:\n * canonical values decide BEHAVIOUR; anything DISPLAYING a Status cell, or\n * writing one back, uses the raw cell.\n *\n * The merge had NO clean reverse map while both vocabularies coexisted, which\n * is why `toAirtableStatus` had to pick one archived name deliberately. With the\n * old names retired there is nothing left to pick between, and no code path\n * feeds operator-supplied text through it anyway (see\n * `src/recipes/forms-notify-target.ts`, which writes `--restore` verbatim).\n *\n * THE SHAPE OF THE TRANSITION (three stages):\n *\n * stage 1 (DONE) code speaks the NEW vocabulary internally; reads canonicalize\n * at the two Airtable/Turso seams; writes still emit the OLD\n * Airtable option names. Airtable untouched. No SELECTION\n * change — every predicate picks exactly the sites it picked\n * before (pinned row-by-row in tests/reports/airtable/\n * site-status.test.ts). Displayed labels come from `statusRaw`.\n * stage 2 (DONE) the Airtable \"Status\" single-select gained the 6 new options\n * and all 44 cells were migrated (verified live: maintained=13,\n * archived=12, external=9, building=6, hosted-only=2,\n * launching=2 — zero old values, zero unrecognized), `db sync`\n * ran clean (mismatches=0), and AIRTABLE_USES_NEW_VOCABULARY\n * flipped to true. Writers now emit canonical names verbatim.\n * The alias map STAYS: the 7 old options still exist in the\n * field, so a human can still pick one in the Airtable UI.\n * stage 3 (DONE) the operator deleted the 7 old options from the Airtable\n * \"Status\" field on 2026-08-25, which is what made this safe:\n * the gate was always \"no old value CAN BE ENTERED\", not\n * merely \"none is stored\". Verified against the live base\n * before any code changed — the field carries exactly the six\n * canonical choices, byte-exact, and all 44 cells still hold a\n * canonical value with ZERO blanks (deleting a single-select\n * option CLEARS the cells using it, so \"no cell was blanked\"\n * was checked, not assumed). AIRTABLE_STATUS_ALIASES,\n * AIRTABLE_OLD_NAMES and AIRTABLE_USES_NEW_VOCABULARY are\n * gone; both functions reduce to the identity.\n *\n * WHAT CHANGED IN BEHAVIOUR AT STAGE 3, and it is the only thing that did: an\n * old name is no longer translated. `canonicalizeStatus(\"maintenance\")` used to\n * yield `maintained`; it now yields `\"maintenance\"` verbatim, which\n * `isUnrecognizedStatus` flags and the cockpit surfaces as a watch row. That is\n * intended. The option cannot be selected any more, so a stored old value would\n * mean something went wrong — a restored backup, a scripted write, an API\n * caller with a stale constant — and the fleet should say so rather than absorb\n * it into a status nobody chose.\n *\n * Canonicalization happens on READ, never at rest: `src/db/import-airtable.ts`\n * still stores the raw Airtable cell verbatim in `sites.status`, so the hourly\n * parity check keeps comparing raw-to-raw and the importer stays honest to its\n * source. Both readers — `mapRow` (Airtable) and `rowFromJoined` (Turso) — run\n * the raw value through `canonicalizeStatus`, which is what keeps the #558\n * reader-equivalence instrument green.\n */\n\n/** The canonical site lifecycle vocabulary. */\nexport type Status =\n \"building\" | \"launching\" | \"maintained\" | \"hosted-only\" | \"external\" | \"archived\";\n\n/** Every canonical status, in lifecycle order. The order is load-bearing: the\n * dashboard status editor renders its options from it, and stage 1 must leave\n * that dropdown byte-identical. */\nexport const CANONICAL_STATUSES: readonly Status[] = [\n \"building\",\n \"launching\",\n \"maintained\",\n \"hosted-only\",\n \"external\",\n \"archived\",\n] as const;\n\nconst CANONICAL_SET: ReadonlySet<string> = new Set<string>(CANONICAL_STATUSES);\n\n/**\n * Map a raw Status cell (Airtable field value, or the `sites.status` column) to\n * the canonical vocabulary. Applied at BOTH read seams so the rest of the code\n * only ever sees canonical names.\n *\n * Three deliberate non-obvious behaviours, each protecting an existing invariant:\n *\n * - ABSENT ONLY yields null. A cell that is present but empty (`\"\"`) comes back\n * as `\"\"`, not null, because `due.ts`/`preflight.ts` treat a null status as\n * eligible-by-default — nulling anything non-absent would silently ACTIVATE\n * the row for scheduled client reports.\n * - An unrecognized value is returned VERBATIM (blind-cast to Status, exactly as\n * `mapRow` did before this module existed) so `isUnrecognizedStatus` still\n * flags it and the cockpit still surfaces it as a watch row. Since stage 3\n * that INCLUDES the seven retired names: `maintenance` is now as unrecognized\n * as `maintenence`, which is the point — it can no longer be entered, so its\n * reappearance is an anomaly to surface, not a spelling to absorb.\n * - No trimming or case-folding. `\"maintained \"` stays unrecognized, which is\n * what `isUnrecognizedStatus` has always been documented to catch (\"typo /\n * renamed option / stray whitespace\"). Normalizing here would silence it.\n *\n * With the alias map gone this is the identity on every string, so `status` and\n * `statusRaw` now hold the same value for any present cell. The two fields are\n * kept distinct deliberately — the read/display split is the architecture that\n * made this migration survivable, and collapsing it would have to be undone the\n * next time Airtable's vocabulary and the code's diverge.\n */\nexport function canonicalizeStatus(raw: unknown): Status | null {\n if (typeof raw !== \"string\") return null;\n // Canonical values pass through; so does anything else, unchanged — see above.\n return raw as Status;\n}\n\n/**\n * The string to WRITE into the Airtable \"Status\" cell for a canonical status.\n * Every writer of a CODE-OWNED status routes through here.\n *\n * Since stage 3 this is the identity: Airtable's single-select carries exactly\n * the six canonical options, so the canonical name IS the option name. It is\n * kept as a named seam rather than inlined because it is the one place a future\n * divergence between the code's vocabulary and Airtable's would be expressed,\n * and because every writer already routes through it — inlining would scatter\n * that decision across `ensureSite`, `updateLaunched`, `forms-notify-target`\n * and the dashboard status save.\n *\n * The stage-1/2 hazard it used to carry is GONE: while a many-to-one map\n * existed, `toAirtableStatus(canonicalizeStatus(x))` was not the identity —\n * feeding it \"legacy\" yielded \"deprecated\", silently writing an option the\n * operator never asked for. Both functions are now the identity, so that trap\n * no longer exists. `forms-notify-target --restore` still writes operator text\n * verbatim (`restoreCell`) rather than routing through here, which remains the\n * right shape: operator free text is not a canonical status.\n */\nexport function toAirtableStatus(s: Status): string {\n return s;\n}\n\n/** True when `s` is one of the canonical statuses (not a blind-cast typo). */\nexport function isCanonicalStatus(s: string): s is Status {\n return CANONICAL_SET.has(s);\n}\n","import type { FieldSet } from \"airtable\";\nimport type { AirtableBase } from \"./client.js\";\nimport type { LighthouseScores, LighthouseScoreWriteback } from \"../types.js\";\nimport {\n CANONICAL_STATUSES,\n canonicalizeStatus,\n toAirtableStatus,\n type Status,\n} from \"./site-status.js\";\n\nexport const WEBSITES_TABLE = \"Websites\";\n\nexport type Frequency = \"None\" | \"Monthly\" | \"Quarterly\" | \"Yearly\";\n\n/** The canonical lifecycle vocabulary lives in ./site-status.ts (which also owns\n * the old-Airtable-name aliases and the write-direction mapping). Re-exported\n * here because every consumer already imports `Status` from this module. */\nexport type { Status };\n\n/**\n * Per-site notification routing. When present on a `maintained` site, the form\n * notification is addressed by the value of a submission field (`field`, read from\n * `extraFields`) — e.g. route a contact form's `interest` to a different recipient\n * per option, always CC-ing a shared address. Absent (`null`) → the site keeps the\n * default single-POC behavior. Recipients live HERE (server-side Airtable config),\n * never supplied by the submitting site, so the ingest can't be turned into an open\n * relay.\n */\nexport type NotifyRouting = {\n /** The `extraFields` key whose value selects a route, e.g. \"interest\". */\n field: string;\n /** Field-value → recipient address(es). */\n routes: Record<string, string | string[]>;\n /** Recipient(s) when the value matches no route. */\n default?: string | string[];\n /** Address(es) CC'd on every routed (maintenance) send. */\n cc?: string[];\n};\n\nexport type WebsiteRow = {\n id: string;\n name: string;\n url: string;\n /** Canonical lifecycle status (see ./site-status.ts). Canonicalized at the read\n * boundary, so an old-vocabulary Airtable cell and a new one read identically.\n * A present-but-unrecognized cell survives VERBATIM (blind-cast) rather than\n * becoming null — `isUnrecognizedStatus` flags it, and null would make\n * due.ts/preflight.ts treat the row as eligible-by-default. */\n status: Status | null;\n /** The literal Airtable Status cell behind `status` — same pattern as\n * `maintenanceFreqRaw`. The dashboard status editor round-trips THIS, so its\n * dropdown offers and preselects the values Airtable actually holds while the\n * two vocabularies coexist. Null = blank cell. */\n statusRaw: string | null;\n pointOfContact: string | null;\n maintenanceFreq: Frequency;\n testingFreq: Frequency;\n /** The literal Airtable cell values behind the coerced frequencies. `toFrequency`\n * trims whitespace (\"Monthly \" reads as Monthly), then maps any still-unrecognized\n * value (\"Quaterly\") to \"None\" with a LOUD console.warn so nothing bogus reaches a\n * client email and the drop is never invisible. Preflight validates THESE to surface\n * the same failure as a structured finding. Null = blank cell. */\n maintenanceFreqRaw: string | null;\n testingFreqRaw: string | null;\n /** Last manually-recorded maintenance day (used as fallback when no Reports row exists). */\n maintenanceDay: string | null;\n testingDay: string | null;\n ga4PropertyId: string | null;\n /** Operator-supplied query for the Google search-presence check (e.g. the business name).\n * Null = no query set → the check is skipped for this site. */\n searchQuery: string | null;\n /** Explicit Search Console property for this site (`sc-domain:...` or `https://.../`).\n * Null = auto-resolve from the SA's visible properties by host. */\n searchConsoleProperty: string | null;\n /** ISO timestamp of the last draft run where THIS site's GA/Search enrichment\n * ERRORED (vs a legitimate \"not configured\" skip). Set by drafting on a soft-fail,\n * cleared (null) on a clean enrichment, so the per-site analytics-failure signal\n * self-heals. Null when the operator-added `Analytics soft-fail at` column is absent. */\n analyticsSoftFailAt: string | null;\n /** GitHub repo identity as `owner/repo`. Null = no git wiring → self-update ops skip\n * (or, for local runs, fall back to the checkout's origin remote). */\n gitRepo: string | null;\n reportRecipientsTo: string | null;\n reportRecipientsCc: string | null;\n acceptedWatchConditions: string[];\n /** First attachment in the Header image field (Airtable's signed URL — fetch before expiry). */\n headerImage: { url: string; filename: string; type: string } | null;\n /** Lighthouse \"current state\" snapshot, kept fresh by `audit lighthouse --write-airtable`. */\n pScore: number | null;\n rScore: number | null;\n bpScore: number | null;\n seoScore: number | null;\n /** ISO timestamp set by `audit lighthouse --write-airtable` when scores were last refreshed. */\n lastLighthouseAuditAt: string | null;\n /** Last-known counts from non-lighthouse audits, written by\n * `audit --write-airtable`. `null` = never audited (or this audit\n * type was skipped on the last run). 0 = audited, clean. */\n a11yViolations: number | null;\n /** Declared-range drift vs the Reddoor baseline (what package.json asks for). */\n depsDrifted: number | null;\n depsMajorBehind: number | null;\n /** Real installed-version drift: deps behind the registry's latest, from the\n * committed lockfile (`pnpm outdated`). Null = not determined this run. */\n depsOutdated: number | null;\n /** Of {@link depsOutdated}, how many are a *major* version behind the\n * registry's latest — the \"majors available on npm\" signal, distinct from the\n * baseline-drift {@link depsMajorBehind}. Null = not determined this run. */\n depsMajorOutdated: number | null;\n securityVulnsCritical: number | null;\n securityVulnsHigh: number | null;\n securityVulnsModerate: number | null;\n securityVulnsLow: number | null;\n /** Count of consecutive nightly Renovate auto-fix dispatches for the CURRENT\n * critical/high vuln episode that have NOT yet cleared it. Owned by\n * `renovate-dispatch`: +1 per real dispatch, reset to 0 when vulns clear.\n * Null = field absent / never dispatched → reads as 0. At/above\n * AUTO_FIX_EXHAUSTED_CYCLES the vuln renders as \"auto-fix failed\". */\n securityAutoFixAttempts: number | null;\n /** ISO timestamp the security audit last ran — gates freshness of the Security Updates auto-tick\n * (clean counts only auto-tick when recent). */\n lastSecurityAuditAt: string | null;\n /** The known advisories behind the counts (severity-sorted, capped), so the dashboard can show\n * WHICH packages are vulnerable, not just the totals. null = never audited / unparseable;\n * empty array = audited clean. Written alongside the counts by the security audit. */\n securityAdvisories: SecurityAdvisory[] | null;\n /** Domain/DNS/SSL probe (the `domain` audit). `certDaysRemaining` is days until the TLS cert\n * expires (null = unresolved or no usable cert); `domainCheckedAt` is when it last ran. */\n certDaysRemaining: number | null;\n domainCheckedAt: string | null;\n /** Netlify site id — the IDENTITY the `netlify-deploy` audit needs to query the\n * Netlify API. Read-only input (operator-set in Airtable); null = not on Netlify\n * (or not wired) → that audit skips. Never derived from the URL. */\n netlifyId: string | null;\n /** Latest PRODUCTION deploy health (the `netlify-deploy` audit). `deployStatus` is\n * Netlify's deploy state lower-cased (`ready`/`error`/`building`/…), null = none / not\n * checked; `lastDeployAt` is when it deployed; `deployLogUrl` links to the deploy/log. */\n deployStatus: string | null;\n lastDeployAt: string | null;\n deployLogUrl: string | null;\n /** When the `netlify-deploy` audit last RAN (freshness stamp for `deployStatus`). The audit\n * already writes \"Deploy checked at\"; this read-back is the Plan-2 fix so `deployEvidence`\n * (Plan 4) can gate on check time — NOT on `lastDeployAt`, which is deploy time, not check time. */\n deployCheckedAt: string | null;\n /** Function-health verdict (the `function-health` audit): the deployed `/health` function\n * answered `ok:true` (pass) or `ok:false` (fail). Single-select `pass`/`fail`; null = never ran\n * / unreachable (→ Plan 4 maps to unknown/amber). Kept SEPARATE from `deployStatus` so\n * `isFailedDeployStatus` keeps meaning \"the build failed\". */\n functionHealth: \"pass\" | \"fail\" | null;\n /** CMS reachability (server-side), derived from the same `/health` body's `details.prismic ===\n * \"ok\"`. Single-select `pass`/`fail`; null = never ran. No per-site Prismic token or identity\n * column is ever built — this rides `/health`. */\n cmsReachable: \"pass\" | \"fail\" | null;\n /** Does the deployed site's Turnstile widget actually work? Single-select `pass`/`fail`;\n * null = **unverified**, which is the normal state — see below. Freshness gated by\n * `formE2eCheckedAt`, the clock of the audit that writes it. Powers the cockpit Require-Turnstile guardrail: a site with\n * `requireTurnstile` ON but no working widget silently buckets 100% of its real leads.\n *\n * One of the two `\"fail\"` arms is derived from `/health` (the other is a browser seeing\n * 110200 on the live hostname), whose `forms.turnstile` is a truthiness check\n * on `PUBLIC_TURNSTILE_SITE_KEY` that never contacts Cloudflare (see\n * audits/function-health-airtable.ts for the incident). No key IS proof the widget can't\n * work; a key is NOT proof that it can — a sitekey whose widget is full at Cloudflare's\n * 10-hostname cap sets the var and still mints no token. So a `\"pass\"` here has to be\n * earned by a real browser rendering the real widget (form-e2e), and until it is, null\n * keeps the site in the cockpit's amber \"can't verify\" watch rather than claiming health\n * nobody measured. */\n turnstileWidget: \"pass\" | \"fail\" | null;\n /** When the `function-health` audit last ran — the freshness gate for `functionHealth`,\n * `cmsReachable`, and `turnstileWidget`. Null = never ran. */\n functionHealthCheckedAt: string | null;\n /** Deployed-URL browser probe (the `browser` audit): cross-engine render OK, mobile render OK,\n * internal-links OK + broken count, and when it last ran (one timestamp gates all three). */\n crossbrowserOk: boolean | null;\n mobileOk: boolean | null;\n linksOk: boolean | null;\n brokenLinks: number | null;\n browserCheckedAt: string | null;\n /** Uptime-reachable verdict (browser audit): every sampled route returned 2xx/3xx. Single-select\n * `pass`/`fail`; null = never ran. Point-in-time. Freshness-gated by `browserCheckedAt`. */\n reachableOk: \"pass\" | \"fail\" | null;\n /** Titles & meta verdict (browser audit, chromium): every sampled route has a non-empty `<title>`\n * ≤ 70 chars + a non-empty meta description, and no duplicate titles across the sample.\n * Single-select `pass`/`fail`; null = never ran. Freshness-gated by `browserCheckedAt`. */\n titleMetaOk: \"pass\" | \"fail\" | null;\n /** Per-site copy overrides (M6a). Blank → null → the DEFAULT_COPY value. */\n copyIntro: string | null;\n copyContact: string | null;\n copyFooter: string | null;\n /** Go-live timestamp, stamped when a Launch report sends (M6b). Null = not yet launched. */\n launchedAt: string | null;\n /** Optional per-site webhook (e.g. Zapier Catch Hook). When set, the ingest\n * POSTs newsletter-formType submissions here (best-effort). Blank → null. */\n newsletterWebhook: string | null;\n /** Per-site Mailchimp (newsletter). Both must be set for the direct add;\n * blank → skipped. The API key is `key-dc` format; dc is derived from it. */\n mailchimpApiKey: string | null;\n mailchimpAudienceId: string | null;\n /** Per-site Cloudflare Turnstile gate (Airtable checkbox). When true, a submission\n * whose Turnstile token verifies as \"fail\" (forged) OR \"absent\" (secret configured\n * centrally but NO token forwarded — the direct-POST-bot signature) is escalated to\n * auto-spam regardless of content score. A present-but-expired token stays\n * \"unverifiable\" and neutral (a real browser DID render the widget — fail-open).\n * ROLLOUT PRECONDITION: only enable on a site whose DEPLOYED package forwards\n * `_meta.turnstileToken` from EVERY form (widget rendered + `cf-turnstile-response`\n * posted; check the site's `/health` → forms.turnstile:true). A site that never\n * forwards tokens would silently bucket 100% of its real leads — and the form-e2e\n * probe cannot catch that state (testMode bypasses the gate). */\n requireTurnstile: boolean;\n /** GitHub-signals sweep (slice 2a), written nightly by `github-signals --fleet`. */\n renovateFailingCis: number | null;\n defaultBranchCi: string | null; // \"passing\" | \"failing\" | \"pending\" | \"none\"\n lastCommitAt: string | null;\n githubSignalsAt: string | null;\n /** Per-site smoke-suite verdict (the `smoke` audit runs `pnpm test:smoke`).\n * Single-select pass/fail; null = never ran. `lastSmokeAt` gates freshness. */\n smokeOk: \"pass\" | \"fail\" | null;\n lastSmokeAt: string | null;\n /** Synthetic form end-to-end verdict (the `form-e2e` audit submits the real prod\n * contact form in test-mode). Single-select pass/fail; null = never ran OR (with\n * a fresh `formE2eCheckedAt`) no contact form → n/a. `formE2eCheckedAt` gates\n * freshness AND encodes the n/a-vs-never-ran distinction. */\n formE2eOk: \"pass\" | \"fail\" | null;\n formE2eCheckedAt: string | null;\n /**\n * Nightly Prismic model drift sweep (`prismic-models --fleet --write-airtable`).\n *\n * FOUR-VALUED, unlike every other verdict column in this table, and the extra\n * state is the point:\n *\n * - `pass` — the repo and its Prismic repository were compared and agree.\n * - `fail` — they were compared and DIVERGE (a model to create/update, or\n * one that exists only in Prismic). A finding for a human.\n * - `unknown` — THE CHECK ITSELF FAILED. Unreadable checkout, broken config,\n * missing/expired token, unreadable remote. Nothing at all was\n * established about this site's models.\n * - `null` — no verdict to hold: the sweep has never run for this site, or\n * it ran and found no Prismic config at all (a repo that simply\n * is not a Prismic site). `prismicModelsCheckedAt` separates\n * those two — fresh timestamp, blank verdict = \"checked, and\n * there is nothing here to check\".\n *\n * `unknown` exists because the alternatives are both wrong in the dangerous\n * direction. Writing `pass` for a site nobody could read is a green tick over\n * an outage; writing NOTHING (the obvious \"leave the last known value alone\")\n * leaves yesterday's `pass` standing for a site that has been failing every\n * night since — and nothing ages a stale `pass` out, because the digest's\n * freshness gate only ever examines failures. Both make \"I could not read this\n * site\" indistinguishable from \"this site is fine\", in the record the cockpit\n * and the digest read.\n *\n * OPERATOR PRECONDITION: `Prismic Models` is a single select whose options are\n * `pass`, `fail`, `unknown`. Until the three columns exist the sweep's writes\n * fail with UNKNOWN_FIELD_NAME and are collected as soft failures — the feature\n * ships dark rather than reddening the nightly.\n */\n prismicModels: PrismicModelsVerdict | null;\n /** When the sweep last ran for this site — written on EVERY outcome, including\n * `unknown` and blank, so the age of the row is the age of the answer. */\n prismicModelsCheckedAt: string | null;\n /** What the sweep found, verbatim: the drift report for a `fail`, the reason\n * nothing could be established for an `unknown`, the skip reason for a blank,\n * and null for a `pass` (which clears yesterday's finding). */\n prismicModelsDrift: string | null;\n /**\n * An expiry the operator sets to accept a KNOWN, EXPECTED `fail` — most often\n * \"I am modelling in Prismic on a branch that has not landed, so Prismic is\n * legitimately ahead of `main`\". While this timestamp is in the future the\n * drift item is not raised.\n *\n * It expires on purpose, and nothing renews it automatically. A permanent ack\n * would reproduce this column's own failure mode one step later: once the\n * branch lands, the same acked cell would swallow real drift and \"nobody is\n * looking at this\" would render as \"this is fine\". An expiry means the worst\n * case is a silence that ends by itself.\n *\n * Narrow by construction — it mutes only `fail`, the finding the operator\n * actually reviewed. `unknown` (the check could not run) and a verdict too old\n * to be current are never muted by it; see `collectPrismicDriftAlerts`.\n *\n * OPERATOR PRECONDITION: `Prismic Ack Until` is a dateTime column. Until it\n * exists this reads null and nothing is acked, which is the safe direction.\n */\n prismicAckUntil: string | null;\n notifyRouting: NotifyRouting | null;\n /** The RAW `Notify Routing` cell, verbatim. Same reason `statusRaw` exists: the\n * dashboard editor round-trips this JSON, and re-serializing the PARSED object\n * would drop any key the parser ignores and reformat what the operator typed —\n * a silent rewrite of a cell they only opened to look at. */\n notifyRoutingRaw: string | null;\n /** Read-back of the code-owned next-due dates (date-only, written by the\n * nightly `writeNextDueDates`). These exist so the writer can DIFF the\n * freshly-computed dates against what the row already holds and skip the\n * ~31 no-op writes a night the fleet was paying for (#539 Phase 3). Null =\n * cell blank or the operator-added `Next … at` column absent. */\n nextMaintenanceAt: string | null;\n nextTestingAt: string | null;\n};\n\nexport function siteSlug(name: string): string {\n return name\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-|-$/g, \"\");\n}\n\n/** Blank-trim-to-null: a non-string or whitespace-only value becomes null,\n * otherwise the trimmed string. */\nexport function trimToNull(raw: unknown): string | null {\n if (typeof raw !== \"string\") return null;\n const trimmed = raw.trim();\n return trimmed.length > 0 ? trimmed : null;\n}\n\n/**\n * Parse the Websites `Notify Routing` JSON into a NotifyRouting, defensively: a\n * non-string, blank, malformed-JSON, or wrong-shape value yields null (the site\n * then keeps default single-POC routing) — never throws. Mirrors the pipeline's\n * \"a bad Airtable string degrades quietly\" rule.\n */\nexport function parseNotifyRouting(raw: unknown): NotifyRouting | null {\n if (typeof raw !== \"string\") return null;\n const trimmed = raw.trim();\n if (!trimmed) return null;\n let parsed: unknown;\n try {\n parsed = JSON.parse(trimmed);\n } catch {\n return null;\n }\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n const o = parsed as Record<string, unknown>;\n if (typeof o.field !== \"string\" || !o.field.trim()) return null;\n if (!o.routes || typeof o.routes !== \"object\" || Array.isArray(o.routes)) return null;\n const routing: NotifyRouting = {\n field: o.field,\n routes: o.routes as Record<string, string | string[]>,\n };\n if (o.default !== undefined) routing.default = o.default as string | string[];\n if (Array.isArray(o.cc)) routing.cc = o.cc.filter((x): x is string => typeof x === \"string\");\n return routing;\n}\n\n/**\n * Active sites: actively-maintained or pre-launch. Single source of truth for\n * \"is this a live site\" — the operator cockpit shows these, and the fleet\n * audit/report path runs against these. A `null` status (not-yet-active) is\n * deliberately excluded.\n */\nexport const ACTIVE_STATUSES: ReadonlySet<Status> = new Set<Status>([\"maintained\", \"launching\"]);\n\nexport function isDashboardVisible(site: WebsiteRow): boolean {\n return site.status !== null && ACTIVE_STATUSES.has(site.status);\n}\n\n/**\n * Pre-launch lifecycle stages: the site is being built/prepared, NOT yet live. A\n * Launch report (recipes/launch.ts) flips Status → \"maintained\" at go-live\n * (updateLaunched), so \"maintained\" is the true live state. Pre-launch sites must\n * not be audited as production (their deploy/domain/uptime/CMS audits fail because\n * nothing is live yet) nor scheduled recurring Maintenance/Testing reports.\n *\n * NOTE `launching` is deliberately in BOTH this set and ACTIVE_STATUSES — a\n * launching site is cockpit-visible but not production-audited. That dual\n * membership predates the vocabulary rename and was re-approved as-is with it.\n */\nexport const PRE_LAUNCH_STATUSES: ReadonlySet<Status> = new Set<Status>([\"building\", \"launching\"]);\n\nexport function isPreLaunch(status: Status | null): boolean {\n return status !== null && PRE_LAUNCH_STATUSES.has(status);\n}\n\n/** Every Status value the code recognizes — the canonical vocabulary, since both\n * read seams canonicalize. Typed ReadonlySet<string> so a blind-cast typo'd cell\n * can be probed without a cast. */\nexport const KNOWN_STATUSES: ReadonlySet<string> = new Set<Status>(CANONICAL_STATUSES);\n\n/** Terminal, out-of-fleet lifecycle states: kept in Airtable for the record,\n * excluded from every fleet op (sweeps, reports, audits, cockpit tiers) exactly\n * as before, but surfaced on the cockpit as an archived lane so a row can never\n * silently vanish. Airtable's `legacy` AND `deprecated` both canonicalize to the\n * single `archived` — an approved merge; the two were always treated alike. */\nexport const ARCHIVED_STATUSES: ReadonlySet<Status> = new Set<Status>([\"archived\"]);\n\nexport function isArchivedStatus(status: Status | null): boolean {\n return status !== null && ARCHIVED_STATUSES.has(status);\n}\n\n/** True when the Status cell holds a value outside the code's union (typo /\n * renamed option / stray whitespace). mapRow deliberately does NOT null these —\n * due.ts/preflight.ts treat a null status as eligible-by-default, so nulling a\n * typo would ACTIVATE the row. The cockpit surfaces these as watch rows instead. */\nexport function isUnrecognizedStatus(status: Status | null): boolean {\n return status !== null && !KNOWN_STATUSES.has(status);\n}\n\nconst FREQUENCIES: readonly Frequency[] = [\"None\", \"Monthly\", \"Quarterly\", \"Yearly\"];\n\n/** Coerce an Airtable single-select value to a known Frequency at the read boundary.\n * Whitespace is trimmed first, so an operator's trailing-space option (\"Quarterly \")\n * still schedules instead of silently unscheduling the site. Any other non-empty,\n * unrecognized value — a renamed / typo'd option — warns LOUDLY and falls back to\n * \"None\" (its section is simply omitted) rather than flowing a bogus string downstream,\n * which the announcement would otherwise render as \"We do this undefined.\" into a\n * client email. Blank/undefined is a silent \"None\": no schedule is intentional. */\nexport function toFrequency(raw: unknown, context: string): Frequency {\n if (typeof raw !== \"string\") return \"None\";\n const trimmed = raw.trim();\n if ((FREQUENCIES as readonly string[]).includes(trimmed)) return trimmed as Frequency;\n if (trimmed !== \"\") {\n console.warn(\n `⚠ ${context}: unrecognized frequency '${raw}' — treating as None (not scheduling); fix the Airtable value`,\n );\n }\n return \"None\";\n}\n\n/** Coerce an Airtable tri-state single-select verdict cell (`pass`/`fail`/blank) to\n * `\"pass\" | \"fail\" | null`. Any value other than the literal strings \"pass\"/\"fail\" — blank,\n * an unrecognized option, a typo, wrong type — reads as null (\"never ran\"), never guessed. The\n * ONE shared reader for every verdict column of this shape: `Function health`, `CMS Reachable`,\n * `Uptime Reachable`, `Titles & Meta OK` (Plan 2), and reused (not redeclared) by Plan 3's\n * `Smoke OK` / `Form E2E OK` read-backs. */\nexport function toVerdict(raw: unknown): \"pass\" | \"fail\" | null {\n return raw === \"pass\" || raw === \"fail\" ? raw : null;\n}\n\n/** The `Prismic Models` cell — see {@link WebsiteRow.prismicModels} for why it\n * carries a third state that no other verdict column in this table has. */\nexport type PrismicModelsVerdict = \"pass\" | \"fail\" | \"unknown\";\n\n/**\n * Read the `Prismic Models` cell. Deliberately NOT {@link toVerdict}.\n *\n * `toVerdict` maps anything that is not literally `pass`/`fail` to null — which\n * for this column would read `unknown` (\"the check ran and failed\") back as null\n * (\"the check never ran\"), silently, on the way to the cockpit. That is the\n * pipeline's governing failure (\"I could not read X\" wearing the face of \"X does\n * not exist\") reintroduced by a shared helper that predates the third state.\n *\n * Everything else — blank, a typo, an option somebody added by hand, the wrong\n * type — still reads as null. A verdict is never guessed.\n */\nexport function toPrismicModelsVerdict(raw: unknown): PrismicModelsVerdict | null {\n return raw === \"pass\" || raw === \"fail\" || raw === \"unknown\" ? raw : null;\n}\n\n// NOTE: every `f[\"...\"]` key below is a load-bearing magic string that must match\n// the live Airtable \"Websites\" column name EXACTLY — including the legacy\n// misspelling `\"maintenence freq\"`, the mixed-case `\"GA4 property ID\"`, and the\n// lowercase `\"url\"` / `\"point of contact\"`. A column rename in Airtable silently\n// returns undefined here (→ null), which degrades quietly (GA skipped, recipients\n// empty) with no error. If you rename a column, change it here too.\nexport function mapRow(rec: { id: string; fields: Record<string, unknown> }): WebsiteRow {\n const f = rec.fields;\n const name = String(f[\"Name\"] ?? \"\");\n const attachments =\n (f[\"Header image\"] as Array<{ url: string; filename: string; type: string }> | undefined) ?? [];\n // LAST, not first: Airtable's uploadAttachment APPENDS, so the newest file is the\n // tail. Reading [0] served the OLDEST forever whenever a field held more than one —\n // which is how a pre-clean-plate header reached a live announcement (#574/#577).\n // The prune keeps these fields at a single entry, so this is normally the same\n // element; preferring the tail means a field that stacks again still serves the\n // CURRENT header instead of silently reverting to the first one ever uploaded.\n const header = attachments.at(-1) ?? null;\n return {\n id: rec.id,\n name,\n url: String(f[\"url\"] ?? \"\"),\n status: canonicalizeStatus(f[\"Status\"]),\n statusRaw: (f[\"Status\"] as string | undefined) ?? null,\n pointOfContact: (f[\"point of contact\"] as string | undefined) ?? null,\n maintenanceFreq: toFrequency(f[\"maintenence freq\"], `${name} maintenance`),\n testingFreq: toFrequency(f[\"testing freq\"], `${name} testing`),\n maintenanceFreqRaw: (f[\"maintenence freq\"] as string | undefined) ?? null,\n testingFreqRaw: (f[\"testing freq\"] as string | undefined) ?? null,\n maintenanceDay: (f[\"maintenance day\"] as string | undefined) ?? null,\n testingDay: (f[\"testing day\"] as string | undefined) ?? null,\n ga4PropertyId: (f[\"GA4 property ID\"] as string | undefined) ?? null,\n searchQuery: (f[\"Search query\"] as string | undefined) ?? null,\n searchConsoleProperty: (f[\"Search Console property\"] as string | undefined) ?? null,\n analyticsSoftFailAt: (f[\"Analytics soft-fail at\"] as string | undefined) ?? null,\n gitRepo: (f[\"Git repo\"] as string | undefined) ?? null,\n reportRecipientsTo: (f[\"Report recipients (To)\"] as string | undefined) ?? null,\n reportRecipientsCc: (f[\"Report recipients (CC)\"] as string | undefined) ?? null,\n // Tolerate BOTH the current Multiple-Select array shape AND a delimited long-text\n // string (comma/newline separated), so the field can migrate to a plain text column\n // with no code change here. Trim + drop empties either way. The array branch also\n // validates ELEMENT types: a collaborator/attachment-shaped field passes\n // Array.isArray with OBJECT elements, and an unchecked cast would make assignTier's\n // `.trim()` throw — one misconfigured row must not 500 the whole cockpit build.\n acceptedWatchConditions: Array.isArray(f[\"Accepted Watch Conditions\"])\n ? (f[\"Accepted Watch Conditions\"] as unknown[])\n .filter((x): x is string => typeof x === \"string\")\n .map((s) => s.trim())\n .filter(Boolean)\n : typeof f[\"Accepted Watch Conditions\"] === \"string\"\n ? (f[\"Accepted Watch Conditions\"] as string)\n .split(/[\\n,]/)\n .map((s) => s.trim())\n .filter(Boolean)\n : [],\n headerImage: header,\n pScore: (f[\"pScore\"] as number | undefined) ?? null,\n rScore: (f[\"rScore\"] as number | undefined) ?? null,\n bpScore: (f[\"bpScore\"] as number | undefined) ?? null,\n seoScore: (f[\"seoScore\"] as number | undefined) ?? null,\n lastLighthouseAuditAt: (f[\"Last lighthouse audit at\"] as string | undefined) ?? null,\n a11yViolations: (f[\"A11y Violations\"] as number | undefined) ?? null,\n depsDrifted: (f[\"Deps Drifted\"] as number | undefined) ?? null,\n depsMajorBehind: (f[\"Deps Major Behind\"] as number | undefined) ?? null,\n depsOutdated: (f[\"Deps Outdated\"] as number | undefined) ?? null,\n depsMajorOutdated: (f[\"Deps Major Outdated\"] as number | undefined) ?? null,\n securityVulnsCritical: (f[\"Security Vulns Critical\"] as number | undefined) ?? null,\n securityVulnsHigh: (f[\"Security Vulns High\"] as number | undefined) ?? null,\n securityVulnsModerate: (f[\"Security Vulns Moderate\"] as number | undefined) ?? null,\n securityVulnsLow: (f[\"Security Vulns Low\"] as number | undefined) ?? null,\n securityAutoFixAttempts: (f[\"Security Auto-Fix Attempts\"] as number | undefined) ?? null,\n lastSecurityAuditAt: (f[\"Last security audit at\"] as string | undefined) ?? null,\n securityAdvisories: parseSecurityAdvisories(f[\"Security advisories\"]),\n certDaysRemaining: (f[\"Cert days remaining\"] as number | undefined) ?? null,\n domainCheckedAt: (f[\"Domain checked at\"] as string | undefined) ?? null,\n netlifyId: trimToNull(f[\"Netlify ID\"]),\n deployStatus: (f[\"Deploy status\"] as string | undefined) ?? null,\n lastDeployAt: (f[\"Last deploy at\"] as string | undefined) ?? null,\n deployLogUrl: (f[\"Deploy log URL\"] as string | undefined) ?? null,\n deployCheckedAt: (f[\"Deploy checked at\"] as string | undefined) ?? null,\n functionHealth: toVerdict(f[\"Function health\"]),\n cmsReachable: toVerdict(f[\"CMS Reachable\"]),\n turnstileWidget: toVerdict(f[\"Turnstile widget\"]),\n functionHealthCheckedAt: (f[\"Function health checked at\"] as string | undefined) ?? null,\n crossbrowserOk:\n typeof f[\"Crossbrowser OK\"] === \"boolean\" ? (f[\"Crossbrowser OK\"] as boolean) : null,\n mobileOk: typeof f[\"Mobile OK\"] === \"boolean\" ? (f[\"Mobile OK\"] as boolean) : null,\n linksOk: typeof f[\"Links OK\"] === \"boolean\" ? (f[\"Links OK\"] as boolean) : null,\n brokenLinks: typeof f[\"Broken links\"] === \"number\" ? (f[\"Broken links\"] as number) : null,\n browserCheckedAt: (f[\"Browser checked at\"] as string | undefined) ?? null,\n reachableOk: toVerdict(f[\"Uptime Reachable\"]),\n titleMetaOk: toVerdict(f[\"Titles & Meta OK\"]),\n copyIntro: trimToNull(f[\"Copy — Intro\"]),\n copyContact: trimToNull(f[\"Copy — Contact\"]),\n copyFooter: trimToNull(f[\"Copy — Footer\"]),\n launchedAt: (f[\"Launched at\"] as string | undefined) ?? null,\n newsletterWebhook: trimToNull(f[\"Newsletter Webhook\"]),\n notifyRouting: parseNotifyRouting(f[\"Notify Routing\"]),\n notifyRoutingRaw: trimToNull(f[\"Notify Routing\"]),\n mailchimpApiKey: trimToNull(f[\"Mailchimp API Key\"]),\n mailchimpAudienceId: trimToNull(f[\"Mailchimp Audience ID\"]),\n // Boolean guard like crossbrowserOk, but defaults FALSE (not null) when absent: an\n // unset/unknown column must read as \"not required\" so the feature ships dark.\n requireTurnstile:\n typeof f[\"Require Turnstile\"] === \"boolean\" ? (f[\"Require Turnstile\"] as boolean) : false,\n renovateFailingCis: (f[\"Renovate Failing CIs\"] as number | undefined) ?? null,\n defaultBranchCi: (f[\"Default Branch CI\"] as string | undefined) ?? null,\n lastCommitAt: (f[\"Last Commit At\"] as string | undefined) ?? null,\n githubSignalsAt: (f[\"GitHub Signals At\"] as string | undefined) ?? null,\n smokeOk: toVerdict(f[\"Smoke OK\"]),\n lastSmokeAt: (f[\"Last Smoke At\"] as string | undefined) ?? null,\n formE2eOk: toVerdict(f[\"Form E2E OK\"]),\n formE2eCheckedAt: (f[\"Form E2E checked at\"] as string | undefined) ?? null,\n prismicModels: toPrismicModelsVerdict(f[\"Prismic Models\"]),\n prismicModelsCheckedAt: (f[\"Prismic Models Checked At\"] as string | undefined) ?? null,\n prismicModelsDrift: (f[\"Prismic Models Drift\"] as string | undefined) ?? null,\n prismicAckUntil: (f[\"Prismic Ack Until\"] as string | undefined) ?? null,\n nextMaintenanceAt: (f[\"Next maintenance at\"] as string | undefined) ?? null,\n nextTestingAt: (f[\"Next testing at\"] as string | undefined) ?? null,\n };\n}\n\nexport async function listWebsites(base: AirtableBase): Promise<WebsiteRow[]> {\n const out: WebsiteRow[] = [];\n await base(WEBSITES_TABLE)\n .select({ pageSize: 100 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return out;\n}\n\nexport async function getWebsiteBySlug(\n base: AirtableBase,\n slug: string,\n): Promise<WebsiteRow | null> {\n // Slugs are siteSlug() output: [a-z0-9] segments joined by single hyphens.\n // Reject anything else — it can't match a real row, and it keeps URL-supplied\n // input out of the filter formula below (formula-injection guard).\n if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) return null;\n\n // Narrow the fetch to the slug-matching row server-side instead of paging the\n // whole table per request (MEDIUM-H). The formula replicates siteSlug() on\n // {Name} — lowercase → non-alnum runs to \"-\" → strip leading/trailing \"-\" —\n // verified against the live base. maxRecords caps it (slug collisions keep the\n // prior first-match-wins behavior).\n const formula = `REGEX_REPLACE(REGEX_REPLACE(LOWER({Name}),\"[^a-z0-9]+\",\"-\"),\"^-|-$\",\"\")=${JSON.stringify(\n slug,\n )}`;\n const rows: WebsiteRow[] = [];\n await base(WEBSITES_TABLE)\n .select({ filterByFormula: formula, maxRecords: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n // Confirm the match in JS too: keeps the function correct if the formula and\n // siteSlug() ever drift, and under test fakes that don't evaluate the formula.\n return rows.find((w) => siteSlug(w.name) === slug) ?? null;\n}\n\n// ── audit-field builders ─────────────────────────────────────────────────────\n// One source of truth for the column-name → value mappings of each audit type.\n// The per-audit `updateXxxCounts` writers delegate to these (for their other\n// callers), and `updateAuditFields` merges whichever are present into ONE write —\n// so the field-name magic strings live in exactly one place.\n\nexport type A11yCounts = { violations: number };\nexport type DepsCounts = {\n drifted: number;\n majorBehind: number;\n outdated: number | null;\n majorOutdated: number | null;\n};\nexport type SecurityCounts = { critical: number; high: number; moderate: number; low: number };\nexport type Severity = \"low\" | \"moderate\" | \"high\" | \"critical\";\n/** One known vulnerability behind the security counts, as persisted/rendered. */\nexport type SecurityAdvisory = {\n module: string;\n severity: Severity;\n title: string;\n cves: string[];\n url: string | null;\n /** GitHub Dependabot dependency scope (\"runtime\" | \"development\"), when known. Lets the\n * dashboard flag build-time-only (\"development\") vulns. Omitted for advisories from the\n * lockfile `pnpm audit` fallback, which carries no per-package graph scope. */\n scope?: \"runtime\" | \"development\";\n /** GitHub Dependabot dependency-graph relationship, when known. \"transitive\" means no\n * direct-dep bump can fix it — Renovate's vuln alerts have no fix vehicle until the weekly\n * lockfile-maintenance window. Omitted when GitHub reports \"unknown\" or for the\n * `pnpm audit` fallback. Drives {@link allActionableVulnsTransitive}. */\n relationship?: \"direct\" | \"transitive\";\n};\nexport type DomainResult = { certDaysRemaining: number | null; checkedAt: string };\nexport type NetlifyDeployResult = {\n /** Netlify deploy state lower-cased (`ready`/`error`/`building`/…), null = none. */\n state: string | null;\n /** When the latest production deploy was published/created (ISO), null = unknown. */\n deployedAt: string | null;\n /** Link to the deploy / its build log, null = unknown. */\n logUrl: string | null;\n /** When the audit last ran. */\n checkedAt: string;\n};\nexport type BrowserAuditFields = {\n desktopOk: boolean;\n mobileOk: boolean;\n linksOk: boolean;\n reachableOk: boolean;\n titleMetaOk: boolean;\n brokenLinks: number;\n checkedAt: string;\n};\nexport type FunctionHealthResult = {\n /** `pass` when `/health` answered `ok:true`, else `fail`. Never null — the audit only produces a\n * result when it ran (a self-skip carries no details, so this extractor isn't reached). */\n functionHealth: \"pass\" | \"fail\";\n /** From the same body's `prismic` sub-status (R2.2): `\"ok\"` → `\"pass\"`, `\"error\"` → `\"fail\"`.\n * Anything else (`\"skipped\"` — a placeholder repo with no live Prismic — or a raw `null`, e.g.\n * the synthetic \"deployed but erroring\" body) → `null`: the CMS probe never actually ran, so it\n * must NOT red CMS reachability for a site that simply hasn't wired Prismic yet. */\n cmsReachable: \"pass\" | \"fail\" | null;\n /** From the same body's `forms.turnstile` boolean: `true` → `\"pass\"`, `false` → `\"fail\"`.\n * A missing/null `forms` block (older site package, or the synthetic \"deployed but\n * erroring\" body) → `null` — the widget state is simply unknown, not a failure. */\n turnstileWidget: \"pass\" | \"fail\" | null;\n /** When the audit ran (freshness stamp for all three verdicts). */\n checkedAt: string;\n};\n\nexport type SmokeResult = { ok: \"pass\" | \"fail\"; checkedAt: string };\n\n/** `ok` null clears the single-select cell (n/a — no contact form); a fresh\n * `checkedAt` still stamps the row so Plan 4 reads null+fresh as n/a. */\nexport type FormE2eResult = {\n /** Absent = no FORM verdict this run; the column and its stamp are left alone. */\n ok?: \"pass\" | \"fail\" | null;\n checkedAt: string;\n /** The `Turnstile widget` verdict, owned by this audit since #689 — see\n * `formE2eFields`. Absent = no opinion this run (preserve); null = looked and\n * could not tell (clear). */\n turnstileWidget?: \"pass\" | \"fail\" | null;\n};\n\nfunction scoreFields(scores: LighthouseScoreWriteback): FieldSet {\n // A null score CLEARS the cell (→ dashboard \"—\"), distinguishing a metric that\n // errored this run (e.g. NO_LCP → null performance) from a real low score. This\n // intentionally overwrites a prior value: a run that couldn't measure the metric\n // shouldn't keep showing a stale number. Writing null to clear mirrors\n // updateAnalyticsHealth; the `as FieldSet` cast is needed because airtable's\n // FieldSet type omits null.\n const fields: Record<string, number | string | null> = {\n pScore: scores.performance,\n rScore: scores.accessibility,\n bpScore: scores.bestPractices,\n seoScore: scores.seo,\n \"Last lighthouse audit at\": new Date().toISOString(),\n };\n return fields as FieldSet;\n}\n\nfunction a11yFields(counts: A11yCounts): FieldSet {\n return { \"A11y Violations\": counts.violations };\n}\n\nfunction depsFields(counts: DepsCounts): FieldSet {\n const fields: FieldSet = {\n \"Deps Drifted\": counts.drifted,\n \"Deps Major Behind\": counts.majorBehind,\n };\n // Only write the outdated count when it was determined — a null (no/stale\n // lockfile this run) must not clobber a previously-good value.\n if (counts.outdated !== null) {\n fields[\"Deps Outdated\"] = counts.outdated;\n }\n // Same null-guard for the registry-major breakdown (same source signal).\n if (counts.majorOutdated !== null) {\n fields[\"Deps Major Outdated\"] = counts.majorOutdated;\n }\n return fields;\n}\n\n/** Critical first → low last; the persist cap and the dashboard list both lean on this. */\nexport const SEVERITY_RANK: Record<Severity, number> = {\n critical: 0,\n high: 1,\n moderate: 2,\n low: 3,\n};\n/** Cap persisted advisories so the JSON field can't blow up on a badly-neglected site. */\nconst MAX_PERSISTED_ADVISORIES = 25;\n\n/** Coerce one untrusted value into a SecurityAdvisory, or null if it isn't shaped like one.\n * Shared by the read path (`parseSecurityAdvisories`) and the write path so both validate\n * identically. A missing module or unrecognized severity drops the entry entirely. */\nexport function normalizeSecurityAdvisory(raw: unknown): SecurityAdvisory | null {\n if (!raw || typeof raw !== \"object\") return null;\n const e = raw as Record<string, unknown>;\n const module = typeof e[\"module\"] === \"string\" ? (e[\"module\"] as string) : null;\n const severity = e[\"severity\"];\n if (module === null) return null;\n if (\n severity !== \"low\" &&\n severity !== \"moderate\" &&\n severity !== \"high\" &&\n severity !== \"critical\"\n )\n return null;\n const cves = Array.isArray(e[\"cves\"])\n ? (e[\"cves\"] as unknown[]).filter((c): c is string => typeof c === \"string\")\n : [];\n const scope =\n e[\"scope\"] === \"runtime\" || e[\"scope\"] === \"development\"\n ? (e[\"scope\"] as \"runtime\" | \"development\")\n : undefined;\n const relationship =\n e[\"relationship\"] === \"direct\" || e[\"relationship\"] === \"transitive\"\n ? (e[\"relationship\"] as \"direct\" | \"transitive\")\n : undefined;\n return {\n module,\n severity,\n title: typeof e[\"title\"] === \"string\" ? (e[\"title\"] as string) : \"\",\n cves,\n url: typeof e[\"url\"] === \"string\" ? (e[\"url\"] as string) : null,\n ...(scope ? { scope } : {}),\n ...(relationship ? { relationship } : {}),\n };\n}\n\n/**\n * True iff the persisted advisory detail proves EVERY actionable (critical/high) vuln is a\n * TRANSITIVE dependency — i.e. Renovate's vulnerability alerts have no direct-dep bump to\n * open, and the fix rides the weekly lockfile-maintenance window instead. Deliberately\n * conservative: `null` detail (never audited / `pnpm audit` fallback), an advisory missing\n * its `relationship` (unknown / pre-existing JSON), or a counts-vs-detail mismatch (no\n * critical/high advisory in the list) all return false — missing data must never mute the\n * auto-fix-failed escalation. Consumed by the attempts counter (don't count a known no-op\n * dispatch as a failed fix attempt) and the digest (say \"transitive-only\", not\n * \"auto-fix failed\"). The persist cap keeps advisories sorted critical-first, so\n * critical/high entries are never the ones capped away.\n */\nexport function allActionableVulnsTransitive(advisories: SecurityAdvisory[] | null): boolean {\n if (!advisories) return false;\n const actionable = advisories.filter((a) => a.severity === \"critical\" || a.severity === \"high\");\n if (actionable.length === 0) return false;\n return actionable.every((a) => a.relationship === \"transitive\");\n}\n\n/** Parse the `Security advisories` JSON cell. null when absent/blank/unparseable/not-an-array\n * (treated as \"never audited\"); a valid array (possibly empty = audited clean) otherwise. */\nexport function parseSecurityAdvisories(raw: unknown): SecurityAdvisory[] | null {\n if (typeof raw !== \"string\" || raw.trim() === \"\") return null;\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return null;\n }\n if (!Array.isArray(parsed)) return null;\n return parsed.map(normalizeSecurityAdvisory).filter((a): a is SecurityAdvisory => a !== null);\n}\n\nfunction securityAdvisoryFields(advisories: SecurityAdvisory[]): FieldSet {\n const capped = [...advisories]\n .sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])\n .slice(0, MAX_PERSISTED_ADVISORIES);\n // Always write — an empty array (\"[]\") clears a stale list when a once-vulnerable site goes clean.\n return { \"Security advisories\": JSON.stringify(capped) };\n}\n\nfunction securityFields(counts: SecurityCounts): FieldSet {\n return {\n \"Security Vulns Critical\": counts.critical,\n \"Security Vulns High\": counts.high,\n \"Security Vulns Moderate\": counts.moderate,\n \"Security Vulns Low\": counts.low,\n // Stamp freshness alongside the counts so the Security Updates auto-tick can require a recent\n // audit (a clean count from months ago must not silently keep ticking the box).\n \"Last security audit at\": new Date().toISOString(),\n };\n}\n\nfunction domainFields(result: DomainResult): FieldSet {\n const fields: FieldSet = { \"Domain checked at\": result.checkedAt };\n // Write the cert days UNCONDITIONALLY: a null (unresolved / no usable cert) must CLEAR any\n // previously-good value. Leaving a stale number in place — next to a freshly-stamped \"Domain\n // checked at\" — false-passes the Domain/DNS/SSL auto-tick (domainEvidence reads the stale\n // non-null value as a current pass) for a site that's actually down. null clears the cell in\n // Airtable; FieldSet's type doesn't model null, hence the cast through a widened record.\n (fields as Record<string, number | null>)[\"Cert days remaining\"] = result.certDaysRemaining;\n return fields;\n}\n\nfunction netlifyDeployFields(result: NetlifyDeployResult): FieldSet {\n // Write all three deploy fields UNCONDITIONALLY (null clears the cell): a null state /\n // deployedAt / logUrl this run means \"couldn't read it\" and must not leave a STALE value\n // sitting next to a fresh \"Deploy checked at\" — the dashboard would otherwise render a green\n // \"ready\" badge for a site whose latest deploy actually errored. FieldSet's type doesn't model\n // null, hence the cast through a widened record (same approach as domainFields).\n const fields: Record<string, string | null> = {\n \"Deploy status\": result.state,\n \"Last deploy at\": result.deployedAt,\n \"Deploy log URL\": result.logUrl,\n \"Deploy checked at\": result.checkedAt,\n };\n return fields as FieldSet;\n}\n\nfunction browserFields(r: BrowserAuditFields): FieldSet {\n return {\n \"Crossbrowser OK\": r.desktopOk,\n \"Mobile OK\": r.mobileOk,\n \"Links OK\": r.linksOk,\n \"Broken links\": r.brokenLinks,\n \"Browser checked at\": r.checkedAt,\n // NEW tri-state single-select verdicts (empty = never ran). The browser audit only produces a\n // BrowserAuditFields when it actually ran (hasBrowserResult guards on checkedAt), so each verdict\n // is always a concrete boolean here — serialize true→\"pass\", false→\"fail\". The existing boolean\n // columns above are deliberately NOT retrofitted (out of scope).\n \"Uptime Reachable\": r.reachableOk ? \"pass\" : \"fail\",\n \"Titles & Meta OK\": r.titleMetaOk ? \"pass\" : \"fail\",\n };\n}\n\nfunction functionHealthFields(r: FunctionHealthResult): FieldSet {\n // \"CMS Reachable\" is written UNCONDITIONALLY (null clears the cell): the audit only supplies a\n // result when it ran, but `cmsReachable` itself can legitimately be null this run (R2.2 — the CMS\n // probe never actually happened, e.g. a placeholder repo with no live Prismic). Clearing rather\n // than leaving a stale pass/fail sitting next to a freshly-stamped checked-at is what keeps a\n // placeholder site from showing a stale CMS verdict. FieldSet's type doesn't model null, hence the\n // cast through a widened record (same approach as domainFields/netlifyDeployFields).\n // \"Function health\" is never null — the audit only writes when it ran, so it's always a concrete\n // pass/fail. Written SEPARATELY from \"Deploy status\" so the Netlify build state keeps its own\n // meaning.\n const fields: Record<string, string | null> = {\n \"Function health\": r.functionHealth,\n \"CMS Reachable\": r.cmsReachable,\n // \"Turnstile widget\" is NOT written here any more. /health only knows whether\n // PUBLIC_TURNSTILE_SITE_KEY is a non-empty string — it never contacts\n // Cloudflare — so this audit cannot tell a working widget from one whose\n // hostname is not on the allowlist (#689). The column is owned by `form-e2e`,\n // which drives a real browser at the real widget and can. Writing it from\n // both would also be unreachable by the red alarm: this sweep runs at 08:00,\n // the digest reads at 09:23, form-e2e writes at 10:15 — a browser verdict\n // would be cleared by this null every morning before the alarm ever saw it.\n \"Function health checked at\": r.checkedAt,\n };\n return fields as FieldSet;\n}\n\nfunction smokeFields(r: SmokeResult): FieldSet {\n // The verdict is stored as the literal single-select option (\"pass\"/\"fail\"), so\n // no boolean→string coercion is needed. A skip never reaches here (it produces no\n // SmokeResult), so this column is only ever written with a concrete verdict.\n return { \"Smoke OK\": r.ok, \"Last Smoke At\": r.checkedAt };\n}\n\nfunction formE2eFields(r: FormE2eResult): FieldSet {\n // `ok` is already the single-select value (\"pass\"/\"fail\") or null. Writing null\n // CLEARS the cell (→ n/a, distinguished from \"never ran\" by the fresh checked-at\n // stamped alongside). FieldSet's type omits null, hence the widened-record cast\n // (same approach as domainFields / netlifyDeployFields).\n const fields: Record<string, string | null> = {};\n // The FORM verdict and its stamp move together, and only when this run produced\n // one. A run with no form verdict (the testMode-undeclared skip) writes neither,\n // which preserves both — refreshing the stamp while preserving a stale verdict\n // would present months-old evidence as fresh to auto-tick's `formsEvidence`.\n if (r.ok !== undefined) {\n fields[\"Form E2E OK\"] = r.ok;\n fields[\"Form E2E checked at\"] = r.checkedAt;\n }\n // The Turnstile verdict rides this audit because it is the only one that opens\n // a browser on the live form, where the site's REAL widget renders (the probe\n // never swaps the sitekey). Written only when this run had an opinion: the key\n // is ABSENT on a run that could not look, which preserves the prior verdict,\n // and an explicit null is the distinct \"looked, could not tell\" that clears it.\n if (r.turnstileWidget !== undefined) fields[\"Turnstile widget\"] = r.turnstileWidget;\n return fields as FieldSet;\n}\n\n/**\n * Write the four Lighthouse scores + a refreshed-at timestamp onto a Websites row.\n * Called by `audit lighthouse --write-airtable` after a successful audit run, so\n * the operator never has to paste numbers manually before drafting a report.\n */\nexport async function updateScores(\n base: AirtableBase,\n recordId: string,\n scores: LighthouseScores,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: scoreFields(scores) }]);\n}\n\n/**\n * Record (or clear) the per-site GA/Search enrichment health on the `Analytics\n * soft-fail at` column. `at` is an ISO timestamp when the site's last draft had a\n * GA/Search soft-failure, or `null` to clear it after a clean enrichment. The\n * caller (drafting) swallows errors: this column is operator-added, so until it\n * exists Airtable throws UNKNOWN_FIELD_NAME — which must not break a draft.\n */\nexport async function updateAnalyticsHealth(\n base: AirtableBase,\n recordId: string,\n at: string | null,\n): Promise<FieldSet> {\n const fields: Record<string, string | null> = { \"Analytics soft-fail at\": at };\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: fields as FieldSet }]);\n // Same contract as updateNextDueDates/updateAuditFields: the #539 Turso mirror\n // consumes the returned FieldSet, so the two writes cannot diverge.\n return fields as FieldSet;\n}\n\n/** Persist a11y violation count. */\nexport async function updateA11yCounts(\n base: AirtableBase,\n recordId: string,\n counts: A11yCounts,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: a11yFields(counts) }]);\n}\n\n/** Persist deps drift counts (declared-range drift + real outdated installs). */\nexport async function updateDepsCounts(\n base: AirtableBase,\n recordId: string,\n counts: DepsCounts,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: depsFields(counts) }]);\n}\n\n/** Persist security vulnerability counts by severity. */\nexport async function updateSecurityCounts(\n base: AirtableBase,\n recordId: string,\n counts: SecurityCounts,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: securityFields(counts) }]);\n}\n\n/** Persist a site's auto-fix attempt counter. Its own one-field writer so the\n * nightly Renovate dispatch can update it without touching the audit's counts. */\nexport async function updateAutoFixAttempts(\n base: AirtableBase,\n recordId: string,\n attempts: number,\n): Promise<FieldSet> {\n const fields: FieldSet = { \"Security Auto-Fix Attempts\": attempts };\n await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);\n // Returned for the #539 Turso mirror — see updateNextDueDates.\n return fields;\n}\n\n/**\n * Persist the code-computed next-due dates (date-only `YYYY-MM-DD`, or `null` to\n * clear) for the maintenance + testing schedules. Owned by the nightly `--due` sweep\n * so the \"next\" dates shown in Airtable come from the SAME logic as the scheduler\n * (`nextDueDate`) — no Airtable-side formula or automation. Best-effort at the call\n * site: the `Next … at` columns are operator-added, so until they exist Airtable\n * throws UNKNOWN_FIELD_NAME, which must not break the nightly draft run.\n */\nexport async function updateNextDueDates(\n base: AirtableBase,\n recordId: string,\n dates: { maintenanceAt: string | null; testingAt: string | null },\n): Promise<FieldSet> {\n const fields: Record<string, string | null> = {\n \"Next maintenance at\": dates.maintenanceAt,\n \"Next testing at\": dates.testingAt,\n };\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: fields as FieldSet }]);\n // Same contract as updateAuditFields/updateGitHubSignals: the Phase 3 Turso\n // mirror consumes the returned FieldSet, so the two writes cannot diverge.\n return fields as FieldSet;\n}\n\n/** The cell shapes the site editor can write. Airtable rejects a string written\n * to a checkbox or a multi-select, so `Require Turnstile` (boolean) and\n * `Accepted Watch Conditions` (string[]) travel as themselves rather than being\n * stringified at the boundary and coerced back later. */\nexport type AirtableCellValue = string | boolean | string[];\n\n/** Generic single-field writer for the dashboard site-details editor. The caller\n * (setSiteDetail) restricts `column` to the EDITABLE_SITE_FIELDS allowlist, so this\n * never writes an arbitrary column from request input. */\nexport async function updateSiteField(\n base: AirtableBase,\n recordId: string,\n column: string,\n value: AirtableCellValue,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: { [column]: value } }]);\n}\n\n/**\n * Persist all of a single audit run's results to one Websites row in ONE atomic\n * `update()` — instead of up to four sequential updates on the same id (which left\n * a row half-written on a mid-sequence failure and quadrupled the request volume).\n * Pass only the audit slices that produced real values; each present slice is merged\n * via the SAME field mappings the per-audit writers use. Omit a slice (or pass\n * undefined) to leave those columns untouched. Returns the merged FieldSet so the\n * caller can enumerate what was written.\n */\nexport async function updateAuditFields(\n base: AirtableBase,\n recordId: string,\n audits: {\n scores?: LighthouseScoreWriteback;\n a11y?: A11yCounts;\n deps?: DepsCounts;\n security?: SecurityCounts;\n securityAdvisories?: SecurityAdvisory[];\n domain?: DomainResult;\n browser?: BrowserAuditFields;\n netlifyDeploy?: NetlifyDeployResult;\n functionHealth?: FunctionHealthResult;\n smoke?: SmokeResult;\n formE2e?: FormE2eResult;\n },\n): Promise<FieldSet> {\n const fields: FieldSet = {};\n if (audits.scores) Object.assign(fields, scoreFields(audits.scores));\n if (audits.a11y) Object.assign(fields, a11yFields(audits.a11y));\n if (audits.deps) Object.assign(fields, depsFields(audits.deps));\n if (audits.security) Object.assign(fields, securityFields(audits.security));\n // Separate slice (not folded into `security`) so the advisory list and the counts can be\n // written independently, but in practice the security audit supplies both together.\n if (audits.securityAdvisories)\n Object.assign(fields, securityAdvisoryFields(audits.securityAdvisories));\n if (audits.domain) Object.assign(fields, domainFields(audits.domain));\n if (audits.browser) Object.assign(fields, browserFields(audits.browser));\n if (audits.netlifyDeploy) Object.assign(fields, netlifyDeployFields(audits.netlifyDeploy));\n if (audits.functionHealth) Object.assign(fields, functionHealthFields(audits.functionHealth));\n if (audits.smoke) Object.assign(fields, smokeFields(audits.smoke));\n if (audits.formE2e) Object.assign(fields, formE2eFields(audits.formE2e));\n await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);\n return fields;\n}\n\n/** Persist the GitHub-signals sweep onto a Websites row (slice 2a). A null\n * `lastCommitAt` is OMITTED so a not-determined-this-run value never clobbers a\n * previously-good timestamp (mirrors updateDepsCounts' outdated handling).\n * Returns the FieldSet it wrote — the Phase 3 Turso mirror consumes the same\n * payload, so the two writes cannot diverge (updateAuditFields' contract). */\nexport async function updateGitHubSignals(\n base: AirtableBase,\n recordId: string,\n signals: {\n renovateFailingCis: number;\n ciState: string;\n lastCommitAt: string | null;\n sweptAt: string;\n },\n): Promise<FieldSet> {\n const fields: FieldSet = {\n \"Renovate Failing CIs\": signals.renovateFailingCis,\n \"Default Branch CI\": signals.ciState,\n \"GitHub Signals At\": signals.sweptAt,\n };\n if (signals.lastCommitAt !== null) {\n fields[\"Last Commit At\"] = signals.lastCommitAt;\n }\n await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);\n return fields;\n}\n\n/** One site's Prismic model verdict, as the sweep hands it to the record.\n *\n * `verdict: null` is a REQUEST TO BLANK the cell, not \"leave it alone\" — see\n * {@link updatePrismicModels}. There is no way to express \"leave it alone\" here\n * on purpose. */\nexport type PrismicModelsWriteback = {\n verdict: PrismicModelsVerdict | null;\n checkedAt: string;\n /** The finding, the failure reason, or the skip reason — whichever this\n * verdict has. `null` clears the column. */\n detail: string | null;\n};\n\n/** Airtable's long-text cells hold ~100k characters and the sweep's report runs\n * long on a badly drifted site (an empty Prismic repository sorts every local\n * model into `toCreate`, with a line per field). Half the cell is the budget;\n * the rest is headroom for whatever renders it. */\nconst MAX_PRISMIC_DETAIL_CHARS = 50_000;\n\n/**\n * Shorten a detail that will not fit, and SAY SO — with the original length, so\n * nobody mistakes the remainder for the whole. Same reasoning as the PR comment's\n * truncation notice: a report that was shortened without saying it was shortened\n * is a report that looks complete and is not.\n *\n * The cut is by code unit, so it can land between the halves of an astral\n * character; a lone surrogate is not valid text and can make Airtable reject the\n * write, losing the entire finding to a cosmetic detail.\n */\nfunction truncatePrismicDetail(detail: string): string {\n if (detail.length <= MAX_PRISMIC_DETAIL_CHARS) return detail;\n const notice =\n `\\n…[truncated: this report is ${detail.length} characters and only the first` +\n ` ${MAX_PRISMIC_DETAIL_CHARS} are kept here. Run \\`reddoor-maint prismic-models\\` in the` +\n ` site — dry is the default — for the whole thing.]`;\n let head = detail.slice(0, MAX_PRISMIC_DETAIL_CHARS - notice.length);\n const last = head.charCodeAt(head.length - 1);\n if (last >= 0xd800 && last <= 0xdbff) head = head.slice(0, -1);\n return head + notice;\n}\n\n/**\n * Persist one site's Prismic model verdict.\n *\n * ALL THREE COLUMNS, ALWAYS, in one update — including when the verdict is\n * `unknown` or blank. That is the whole design: the alternative (\"only write a\n * verdict when we have one\") leaves the PREVIOUS verdict standing for a site\n * whose check has since started failing, and a stale `pass` is never aged out by\n * anything — the digest's freshness gate only examines failures. So the record\n * always says what the last run actually established, and `checkedAt` always\n * says when. See {@link WebsiteRow.prismicModels} for the four states.\n *\n * Best-effort AT THE CALL SITE: `Prismic Models*` are operator-added columns, so\n * until they exist Airtable throws UNKNOWN_FIELD_NAME. The nightly sweep must\n * survive that and collect it — same contract as `updateNextDueDates`.\n *\n * Takes a non-null `AirtableBase`, like every other writer here: whether to write\n * at all is the caller's decision, and this repo keeps \"do no writes\" separate\n * from \"do no IO\" one layer up.\n */\nexport async function updatePrismicModels(\n base: AirtableBase,\n recordId: string,\n models: PrismicModelsWriteback,\n): Promise<FieldSet> {\n const fields: Record<string, string | null> = {\n \"Prismic Models\": models.verdict,\n \"Prismic Models Checked At\": models.checkedAt,\n \"Prismic Models Drift\": models.detail === null ? null : truncatePrismicDetail(models.detail),\n };\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: fields as FieldSet }]);\n // Returned for the #539 Turso mirror — see updateNextDueDates.\n return fields as FieldSet;\n}\n\n/** Mark a site launched: flip Status → maintained + stamp Launched at (M6b).\n * The first code that writes Status. Called after a Launch report sends.\n * Routed through `toAirtableStatus`, so it still writes Airtable's \"maintenance\"\n * option until the stage-2 switch flips. */\nexport async function updateLaunched(\n base: AirtableBase,\n recordId: string,\n at: string,\n): Promise<FieldSet> {\n const fields: FieldSet = { Status: toAirtableStatus(\"maintained\"), \"Launched at\": at };\n await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);\n // Returned for the #539 Turso mirror. BOTH columns travel together on purpose:\n // mirroring them as two UPDATEs would open a window where Turso says a site is\n // maintained but never launched.\n return fields;\n}\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/audits/a11y-airtable.ts","../src/audits/deps-airtable.ts","../src/audits/security-airtable.ts","../src/audits/domain-airtable.ts","../src/audits/browser-airtable.ts","../src/audits/netlify-deploy-airtable.ts","../src/audits/function-health-airtable.ts","../src/audits/smoke-airtable.ts","../src/audits/form-e2e-airtable.ts","../src/audits/write-audits-to-airtable.ts"],"sourcesContent":["import type { AuditResult } from \"../types.js\";\n\ntype A11yDetails = {\n totalViolations: number;\n byImpact: Partial<Record<\"minor\" | \"moderate\" | \"serious\" | \"critical\", number>>;\n};\n\n/** True when an a11y AuditResult carries real counts worth persisting.\n * Mirrors the `hasRealScores` policy on lighthouse: write whenever real\n * data exists, regardless of status (a \"warn\" or \"fail\" with concrete\n * violation counts is exactly what the dashboard needs to track). */\nexport function hasA11yCounts(result: AuditResult): boolean {\n if (result.audit !== \"a11y\") return false;\n const details = result.details as A11yDetails | undefined;\n return typeof details?.totalViolations === \"number\";\n}\n\nexport function a11yCountsFromResult(result: AuditResult): { violations: number } {\n if (result.audit !== \"a11y\") {\n throw new Error(`Expected an 'a11y' AuditResult, got '${result.audit}'`);\n }\n const details = result.details as A11yDetails | undefined;\n return { violations: details?.totalViolations ?? 0 };\n}\n","import type { AuditResult } from \"../types.js\";\nimport type { DepsDetails, DepsDriftEntry } from \"./deps.js\";\n\n/** True when a deps AuditResult carries the structured details (an entries\n * array, per {@link DepsDetails}). */\nexport function hasDepsCounts(result: AuditResult): boolean {\n if (result.audit !== \"deps\") return false;\n const d = result.details as Partial<DepsDetails> | undefined;\n return Array.isArray(d?.entries);\n}\n\nexport function depsCountsFromResult(result: AuditResult): {\n drifted: number;\n majorBehind: number;\n outdated: number | null;\n majorOutdated: number | null;\n} {\n if (result.audit !== \"deps\") {\n throw new Error(`Expected a 'deps' AuditResult, got '${result.audit}'`);\n }\n const details = (result.details ?? {}) as Partial<DepsDetails>;\n const entries = (details.entries ?? []) as DepsDriftEntry[];\n // \"drifted\" = drift !== \"same\", which intentionally includes \"newer\" (ahead of\n // baseline) — parity with the deps audit summary text.\n const drifted = entries.filter((e) => e.drift !== \"same\").length;\n const majorBehind = entries.filter((e) => e.drift === \"major\").length;\n // The real installed-version drift, distinct from declared-range drift. Null\n // when the audit couldn't determine it (no/stale lockfile) — kept null (not 0)\n // so the dashboard shows \"—\" rather than a misleading \"clean\".\n const outdated = details.outdated?.outdated ?? null;\n // How many of those outdated installs are a *major* behind the registry's\n // latest — surfaced separately from the total so the cockpit can show\n // \"N major\" available, distinct from the baseline-drift \"major\" count. Shares\n // the outdated signal's null-when-undetermined semantics.\n const majorOutdated = details.outdated?.major ?? null;\n return { drifted, majorBehind, outdated, majorOutdated };\n}\n","import type { AuditResult } from \"../types.js\";\nimport { normalizeSecurityAdvisory, type SecurityAdvisory } from \"../reports/airtable/websites.js\";\n\ntype SecurityDetails = {\n counts: { low: number; moderate: number; high: number; critical: number };\n advisories?: unknown;\n};\n\n/** True when a security AuditResult carries a counts object. Skipped runs\n * (no pnpm + no npm) have no details and return false. */\nexport function hasSecurityCounts(result: AuditResult): boolean {\n if (result.audit !== \"security\") return false;\n const details = result.details as SecurityDetails | undefined;\n return !!details && typeof details.counts === \"object\";\n}\n\nexport function securityCountsFromResult(result: AuditResult): {\n critical: number;\n high: number;\n moderate: number;\n low: number;\n} {\n if (result.audit !== \"security\") {\n throw new Error(`Expected a 'security' AuditResult, got '${result.audit}'`);\n }\n const details = result.details as SecurityDetails | undefined;\n const c = details?.counts ?? { low: 0, moderate: 0, high: 0, critical: 0 };\n return { critical: c.critical, high: c.high, moderate: c.moderate, low: c.low };\n}\n\n/** The known advisories behind the counts, normalized for persistence. Empty when the\n * run was clean or carried no advisory detail (npm/pnpm sometimes omit it). Malformed\n * entries are dropped via the shared `normalizeSecurityAdvisory`. */\nexport function advisoriesFromResult(result: AuditResult): SecurityAdvisory[] {\n if (result.audit !== \"security\") {\n throw new Error(`Expected a 'security' AuditResult, got '${result.audit}'`);\n }\n const details = result.details as SecurityDetails | undefined;\n const raw = details?.advisories;\n if (!Array.isArray(raw)) return [];\n return raw.map(normalizeSecurityAdvisory).filter((a): a is SecurityAdvisory => a !== null);\n}\n","import type { AuditResult } from \"../types.js\";\nimport type { DomainResult } from \"../reports/airtable/websites.js\";\n\ntype DomainDetails = { resolved: boolean; certDaysRemaining: number | null; checkedAt: string };\n\n/** True when an AuditResult is a `domain` audit carrying a usable details payload (i.e. it\n * actually ran — a \"skip\" for a site with no deployed URL has no details). */\nexport function hasDomainResult(result: AuditResult): boolean {\n if (result.audit !== \"domain\") return false;\n const d = result.details as DomainDetails | undefined;\n return !!d && typeof d.checkedAt === \"string\";\n}\n\n/** Extract the Airtable-writable domain result (cert days + checked-at) from a `domain` audit. */\nexport function domainResultFromAudit(result: AuditResult): DomainResult {\n if (result.audit !== \"domain\") {\n throw new Error(`Expected a 'domain' AuditResult, got '${result.audit}'`);\n }\n const d = result.details as DomainDetails | undefined;\n return {\n certDaysRemaining: typeof d?.certDaysRemaining === \"number\" ? d.certDaysRemaining : null,\n checkedAt: typeof d?.checkedAt === \"string\" ? d.checkedAt : new Date().toISOString(),\n };\n}\n","import type { AuditResult } from \"../types.js\";\nimport type { BrowserAuditFields } from \"../reports/airtable/websites.js\";\n\ntype BrowserDetails = {\n desktopOk: boolean;\n mobileOk: boolean;\n linksOk: boolean;\n reachableOk: boolean;\n titleMetaOk: boolean;\n brokenLinks: number;\n checkedAt: string;\n};\n\n/** True when an AuditResult is a `browser` audit that actually ran (has a details payload — a\n * \"skip\" for a site with no deployed URL has none). */\nexport function hasBrowserResult(result: AuditResult): boolean {\n if (result.audit !== \"browser\") return false;\n const d = result.details as BrowserDetails | undefined;\n return !!d && typeof d.checkedAt === \"string\";\n}\n\n/** Extract the Airtable-writable browser verdicts from a `browser` audit. */\nexport function browserFieldsFromAudit(result: AuditResult): BrowserAuditFields {\n if (result.audit !== \"browser\") {\n throw new Error(`Expected a 'browser' AuditResult, got '${result.audit}'`);\n }\n const d = result.details as BrowserDetails | undefined;\n return {\n desktopOk: d?.desktopOk === true,\n mobileOk: d?.mobileOk === true,\n linksOk: d?.linksOk === true,\n reachableOk: d?.reachableOk === true,\n titleMetaOk: d?.titleMetaOk === true,\n brokenLinks: typeof d?.brokenLinks === \"number\" ? d.brokenLinks : 0,\n checkedAt: typeof d?.checkedAt === \"string\" ? d.checkedAt : new Date().toISOString(),\n };\n}\n","import type { AuditResult } from \"../types.js\";\nimport type { NetlifyDeployResult } from \"../reports/airtable/websites.js\";\n\ntype NetlifyDeployDetails = {\n state: string | null;\n deployedAt: string | null;\n logUrl: string | null;\n checkedAt: string;\n};\n\n/** True when an AuditResult is a `netlify-deploy` audit carrying a usable details\n * payload (i.e. it actually ran — a \"skip\" for a site with no Netlify id / no\n * token has no details). */\nexport function hasNetlifyDeployResult(result: AuditResult): boolean {\n if (result.audit !== \"netlify-deploy\") return false;\n const d = result.details as NetlifyDeployDetails | undefined;\n return !!d && typeof d.checkedAt === \"string\";\n}\n\n/** Extract the Airtable-writable deploy result (state + deployedAt + logUrl +\n * checkedAt) from a `netlify-deploy` audit. */\nexport function netlifyDeployResultFromAudit(result: AuditResult): NetlifyDeployResult {\n if (result.audit !== \"netlify-deploy\") {\n throw new Error(`Expected a 'netlify-deploy' AuditResult, got '${result.audit}'`);\n }\n const d = result.details as NetlifyDeployDetails | undefined;\n return {\n state: typeof d?.state === \"string\" ? d.state : null,\n deployedAt: typeof d?.deployedAt === \"string\" ? d.deployedAt : null,\n logUrl: typeof d?.logUrl === \"string\" ? d.logUrl : null,\n checkedAt: typeof d?.checkedAt === \"string\" ? d.checkedAt : new Date().toISOString(),\n };\n}\n","import type { AuditResult } from \"../types.js\";\nimport type { FunctionHealthResult } from \"../reports/airtable/websites.js\";\n\ntype FunctionHealthDetails = {\n ok: boolean;\n prismic: \"ok\" | \"error\" | \"skipped\" | null;\n forms: unknown;\n checkedAt: string;\n};\n\n/** True when an AuditResult is a `function-health` audit carrying a usable details payload (i.e. it\n * actually ran — a self-skip for an unreachable / non-JSON `/health`, or a site with no deployed\n * URL, has no details, so the writer preserves the prior verdict). */\nexport function hasFunctionHealthResult(result: AuditResult): boolean {\n if (result.audit !== \"function-health\") return false;\n const d = result.details as FunctionHealthDetails | undefined;\n return !!d && typeof d.checkedAt === \"string\";\n}\n\n/** Extract the Airtable-writable function-health verdicts. `functionHealth` from `ok`; `cmsReachable`\n * from the same body's `prismic` sub-status (R2.2): `\"ok\"` → `\"pass\"`, `\"error\"` → `\"fail\"`, and\n * anything else (`\"skipped\"` — a placeholder repo, or a raw `null` from a \"deployed but erroring\"\n * body / an unrecognized value) → `null` — the CMS probe never actually ran, so it must not red CMS\n * reachability for a site that simply hasn't wired Prismic yet. Only `functionHealth` keys off\n * `ok`. */\nexport function functionHealthResultFromAudit(result: AuditResult): FunctionHealthResult {\n if (result.audit !== \"function-health\") {\n throw new Error(`Expected a 'function-health' AuditResult, got '${result.audit}'`);\n }\n const d = result.details as FunctionHealthDetails | undefined;\n const cmsReachable: \"pass\" | \"fail\" | null =\n d?.prismic === \"ok\" ? \"pass\" : d?.prismic === \"error\" ? \"fail\" : null;\n // `forms` is untyped on the details payload (unknown) — validate defensively. Anything\n // that isn't a boolean `turnstile` inside an object (null forms from an older site package\n // or a \"deployed but erroring\" body, a malformed shape) → null: unknown this run, never a\n // fail. Powers the Require-Turnstile guardrail.\n const formsRaw = d?.forms;\n const turnstileFlag =\n formsRaw && typeof formsRaw === \"object\"\n ? (formsRaw as Record<string, unknown>)[\"turnstile\"]\n : undefined;\n // Asymmetric on purpose, and this is the whole point of the column being trustworthy.\n // `forms.turnstile` is `!!PUBLIC_TURNSTILE_SITE_KEY?.trim()` in the site's own /health\n // (recipes/health-endpoint/template.ts) — a truthiness check on a string that never\n // contacts Cloudflare. It answers \"is a sitekey configured\", not \"does the widget work\",\n // and the two came apart on 2026-09-04: a site deployed with the sitekey of a widget\n // already FULL at Cloudflare's 10-hostname cap reported `turnstile: true` while the live\n // widget threw 110200 and minted no token. Under `Require Turnstile` that buckets 100% of\n // real leads (ingest.ts, turnstile-required-absent) — and the false \"pass\" satisfied BOTH\n // halves of the guardrail meant to catch it: the red item needs \"fail\"\n // (alerts/digest-collectors.ts) and the amber watch needs !== \"pass\" (fleet-cockpit.ts).\n //\n // false → \"fail\" — no key IS proof the widget cannot work. Sound, and unchanged.\n // true → null — a key is NOT proof that it does. Unknown, not confirmed.\n //\n // null is not a downgrade to silence: fleet-cockpit's `!== \"pass\"` already turns it into\n // the accept-able amber \"can't verify\" watch, and websites.ts clears the cell rather than\n // leaving a stale verdict. Only a real browser can earn the \"pass\" — form-e2e already\n // launches Chromium on each site's live /contact, which is where that belongs.\n const turnstileWidget: \"pass\" | \"fail\" | null = turnstileFlag === false ? \"fail\" : null;\n return {\n functionHealth: d?.ok === true ? \"pass\" : \"fail\",\n cmsReachable,\n turnstileWidget,\n checkedAt: typeof d?.checkedAt === \"string\" ? d.checkedAt : new Date().toISOString(),\n };\n}\n","import type { AuditResult } from \"../types.js\";\nimport type { SmokeResult } from \"../reports/airtable/websites.js\";\n\ntype SmokeDetails = { ok: \"pass\" | \"fail\"; checkedAt: string };\n\n/** True when an AuditResult is a `smoke` audit that actually ran (has details with\n * a checkedAt — a \"skip\" for a missing `pnpm`/`test:smoke` script has none, so the\n * writer preserves the prior verdict). */\nexport function hasSmokeResult(result: AuditResult): boolean {\n if (result.audit !== \"smoke\") return false;\n const d = result.details as SmokeDetails | undefined;\n return !!d && typeof d.checkedAt === \"string\";\n}\n\n/** Extract the Airtable-writable smoke verdict (pass/fail + checked-at). */\nexport function smokeResultFromAudit(result: AuditResult): SmokeResult {\n if (result.audit !== \"smoke\") {\n throw new Error(`Expected a 'smoke' AuditResult, got '${result.audit}'`);\n }\n const d = result.details as SmokeDetails | undefined;\n return {\n ok: d?.ok === \"fail\" ? \"fail\" : \"pass\",\n checkedAt: typeof d?.checkedAt === \"string\" ? d.checkedAt : new Date().toISOString(),\n };\n}\n","import type { AuditResult } from \"../types.js\";\nimport type { FormE2eResult } from \"../reports/airtable/websites.js\";\n\ntype FormE2eDetails = {\n ok?: \"pass\" | \"fail\" | null;\n formPresent?: boolean;\n checkedAt: string;\n turnstileWidget?: \"pass\" | \"fail\" | null;\n};\n\n/** True when an AuditResult is a `form-e2e` audit that actually RAN (has details\n * with a checkedAt). Both a real verdict AND the no-form n/a case carry a checkedAt,\n * so both persist; only a no-deployed-URL skip (no details) is preserved-prior. */\nexport function hasFormE2eResult(result: AuditResult): boolean {\n if (result.audit !== \"form-e2e\") return false;\n const d = result.details as FormE2eDetails | undefined;\n return !!d && typeof d.checkedAt === \"string\";\n}\n\n/** Extract the Airtable-writable form-e2e verdict (pass/fail/null + checked-at). */\nexport function formE2eResultFromAudit(result: AuditResult): FormE2eResult {\n if (result.audit !== \"form-e2e\") {\n throw new Error(`Expected a 'form-e2e' AuditResult, got '${result.audit}'`);\n }\n const d = result.details as FormE2eDetails | undefined;\n // `ok` absent means \"no form verdict this run\" and must stay absent — coercing\n // it to null would CLEAR `Form E2E OK` on a skip that exists to preserve it.\n const ok =\n d?.ok === undefined ? undefined : d.ok === \"pass\" ? \"pass\" : d.ok === \"fail\" ? \"fail\" : null;\n // `turnstileWidget` is OPTIONAL and its absence is meaningful: a run with no\n // opinion (the no-contact-form n/a) must leave the existing verdict alone\n // rather than clear it, so the key is omitted rather than set to null. Only an\n // explicit null — a run that looked and could not tell — clears the cell.\n const t = d?.turnstileWidget;\n const turnstileWidget = t === \"pass\" || t === \"fail\" || t === null ? t : undefined;\n return {\n ...(ok !== undefined ? { ok } : {}),\n checkedAt: typeof d?.checkedAt === \"string\" ? d.checkedAt : new Date().toISOString(),\n ...(turnstileWidget !== undefined ? { turnstileWidget } : {}),\n };\n}\n","import type { FieldSet } from \"airtable\";\nimport type { AuditResult } from \"../types.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport type { HealthMirror } from \"./health-mirror.js\";\nimport { TURSO_IS_AUTHORITATIVE } from \"../db/freeze.js\";\nimport { type WebsiteRow, siteSlug, updateAuditFields } from \"../reports/airtable/websites.js\";\nimport type {\n A11yCounts,\n DepsCounts,\n SecurityCounts,\n SecurityAdvisory,\n DomainResult,\n BrowserAuditFields,\n NetlifyDeployResult,\n FunctionHealthResult,\n SmokeResult,\n FormE2eResult,\n} from \"../reports/airtable/websites.js\";\nimport type { LighthouseScoreWriteback } from \"../reports/types.js\";\nimport { hasRealScores, lighthouseScoresFromResult } from \"./lighthouse-airtable.js\";\nimport { hasA11yCounts, a11yCountsFromResult } from \"./a11y-airtable.js\";\nimport { hasDepsCounts, depsCountsFromResult } from \"./deps-airtable.js\";\nimport {\n hasSecurityCounts,\n securityCountsFromResult,\n advisoriesFromResult,\n} from \"./security-airtable.js\";\nimport { hasDomainResult, domainResultFromAudit } from \"./domain-airtable.js\";\nimport { hasBrowserResult, browserFieldsFromAudit } from \"./browser-airtable.js\";\nimport { hasNetlifyDeployResult, netlifyDeployResultFromAudit } from \"./netlify-deploy-airtable.js\";\nimport {\n hasFunctionHealthResult,\n functionHealthResultFromAudit,\n} from \"./function-health-airtable.js\";\nimport { hasSmokeResult, smokeResultFromAudit } from \"./smoke-airtable.js\";\nimport { hasFormE2eResult, formE2eResultFromAudit } from \"./form-e2e-airtable.js\";\nimport { detectAuditEvents } from \"./fleet-event-detectors.js\";\nimport type { FleetEvent } from \"../db/fleet-events.js\";\n\ntype WriteSummary = {\n siteName: string;\n writes: Array<{\n audit:\n | \"lighthouse\"\n | \"a11y\"\n | \"deps\"\n | \"security\"\n | \"github-signals\"\n | \"domain\"\n | \"browser\"\n | \"netlify-deploy\"\n | \"function-health\"\n | \"smoke\"\n | \"form-e2e\"\n // Not an audit: the nightly Prismic model sweep reuses this result shape so\n // its write-back reports through the same `FLEET_WRITE_SUMMARY` line every\n // other fleet writer emits, rather than inventing a second contract for CI\n // to grep.\n | \"prismic-models\";\n counts: object;\n }>;\n /** Fleet-activity events detected from this site's prior row vs the fresh audits.\n * Optional: only the fleet path records them; the single-site path ignores them. */\n events?: FleetEvent[];\n /** The Websites rec id + the merged FieldSet actually written — what the\n * Phase 3 Turso mirror consumes, so mirror and Airtable write share ONE\n * payload. Populated by writeAuditsToAirtable; other WriteSummary\n * producers (prismic-models, github-signals) don't carry them. */\n siteId?: string;\n fields?: FieldSet;\n};\n\n/** Orchestrates the per-audit Airtable writes for `audit --write-airtable`.\n * Extracted from the CLI command so it can be unit-tested with a fake base\n * and so adding new audit types is a one-line addition here rather than\n * growing the CLI handler.\n *\n * Throws (with .exitCode set) on the failure modes the CLI surfaces today:\n * - 2: --only ran without lighthouse, or no Websites row matched the slug\n * - 1: lighthouse ran but produced no real scores (infrastructure failure).\n * The a11y/deps/security writes still complete FIRST — a Lighthouse\n * miss flags the site without discarding its other audit data.\n *\n * Precedence note: the Websites-row lookup (exitCode 2) is checked BEFORE the\n * no-scores gate (exitCode 1), so the rare no-row + no-scores combo surfaces\n * as exitCode 2. */\nexport async function writeAuditsToAirtable(args: {\n base: AirtableBase;\n websites: WebsiteRow[];\n slug: string;\n results: AuditResult[];\n}): Promise<WriteSummary> {\n const { base, websites, slug, results } = args;\n\n // Lighthouse is OPTIONAL. The checkout-free `--only lighthouse,domain,browser` nightly includes\n // it, but a standalone `--only security` (checkout-ful) sweep legitimately has none. We write\n // whatever audits produced values; only the lighthouse-MISS flag below is lighthouse-specific\n // (so a real Lighthouse infra failure still reds the run without discarding other audit data).\n const lhResult = results.find((r) => r.audit === \"lighthouse\");\n const target = websites.find((w) => siteSlug(w.name) === slug);\n if (!target) {\n throw Object.assign(new Error(`No Websites row matched slug \"${slug}\"`), { exitCode: 2 });\n }\n\n const writes: WriteSummary[\"writes\"] = [];\n const audits: {\n scores?: LighthouseScoreWriteback;\n a11y?: A11yCounts;\n deps?: DepsCounts;\n security?: SecurityCounts;\n securityAdvisories?: SecurityAdvisory[];\n domain?: DomainResult;\n browser?: BrowserAuditFields;\n netlifyDeploy?: NetlifyDeployResult;\n functionHealth?: FunctionHealthResult;\n smoke?: SmokeResult;\n formE2e?: FormE2eResult;\n } = {};\n\n // Collect every audit that produced real values into ONE merged input, then do a\n // SINGLE atomic Airtable update (was: up to four sequential updates on the same\n // row — a mid-sequence failure left it half-written yet reported fully failed, at\n // 4× the request volume). Lighthouse is the most timeout-prone audit: a Lighthouse\n // miss must NOT discard the site's valid a11y/deps/security results (morning-brief\n // 2026-06-10 MEDIUM-E). So include Lighthouse scores only when real, include the\n // other audits whenever present, write all of them in one update, then — if\n // Lighthouse missed — throw AFTER that atomic write so the site is still flagged\n // (exitCode 1 / collected in FleetWriteResult.failed) without losing its other data.\n const lhHasScores = lhResult ? hasRealScores(lhResult) : false;\n if (lhResult && lhHasScores) {\n const scores = lighthouseScoresFromResult(lhResult);\n audits.scores = scores;\n writes.push({ audit: \"lighthouse\", counts: scores });\n }\n\n const a11y = results.find((r) => r.audit === \"a11y\");\n if (a11y && hasA11yCounts(a11y)) {\n const counts = a11yCountsFromResult(a11y);\n audits.a11y = counts;\n writes.push({ audit: \"a11y\", counts });\n }\n\n const deps = results.find((r) => r.audit === \"deps\");\n if (deps && hasDepsCounts(deps)) {\n const counts = depsCountsFromResult(deps);\n audits.deps = counts;\n writes.push({ audit: \"deps\", counts });\n }\n\n const sec = results.find((r) => r.audit === \"security\");\n if (sec && hasSecurityCounts(sec)) {\n const counts = securityCountsFromResult(sec);\n audits.security = counts;\n // Persist the advisory list alongside the counts so the dashboard can show which packages\n // are vulnerable. An empty array (clean run) clears any stale list.\n audits.securityAdvisories = advisoriesFromResult(sec);\n writes.push({ audit: \"security\", counts });\n }\n\n const dom = results.find((r) => r.audit === \"domain\");\n if (dom && hasDomainResult(dom)) {\n const result = domainResultFromAudit(dom);\n audits.domain = result;\n writes.push({ audit: \"domain\", counts: result });\n }\n\n const browser = results.find((r) => r.audit === \"browser\");\n if (browser && hasBrowserResult(browser)) {\n const fields = browserFieldsFromAudit(browser);\n audits.browser = fields;\n writes.push({ audit: \"browser\", counts: fields });\n }\n\n const netlifyDeploy = results.find((r) => r.audit === \"netlify-deploy\");\n if (netlifyDeploy && hasNetlifyDeployResult(netlifyDeploy)) {\n const result = netlifyDeployResultFromAudit(netlifyDeploy);\n audits.netlifyDeploy = result;\n writes.push({ audit: \"netlify-deploy\", counts: result });\n }\n\n const functionHealth = results.find((r) => r.audit === \"function-health\");\n if (functionHealth && hasFunctionHealthResult(functionHealth)) {\n const result = functionHealthResultFromAudit(functionHealth);\n audits.functionHealth = result;\n writes.push({ audit: \"function-health\", counts: result });\n }\n\n const smoke = results.find((r) => r.audit === \"smoke\");\n if (smoke && hasSmokeResult(smoke)) {\n const result = smokeResultFromAudit(smoke);\n audits.smoke = result;\n writes.push({ audit: \"smoke\", counts: result });\n }\n\n const formE2e = results.find((r) => r.audit === \"form-e2e\");\n if (formE2e && hasFormE2eResult(formE2e)) {\n const result = formE2eResultFromAudit(formE2e);\n audits.formE2e = result;\n writes.push({ audit: \"form-e2e\", counts: result });\n }\n\n // One atomic write of everything that ran. Skip the call only if there is nothing\n // to write at all (no real scores AND no other audit produced values) — an empty\n // update is a wasted request.\n let fields: FieldSet = {};\n if (Object.keys(audits).length > 0) {\n fields = await updateAuditFields(base, target.id, audits);\n }\n\n // Detect fleet-activity transitions from the prior row (`target`, loaded before this\n // write) vs the fresh audits. Computed here where both are in hand; recorded by the\n // caller (fleet path) — single-site callers simply ignore `events`.\n const events = detectAuditEvents(\n target,\n {\n ...(audits.security !== undefined ? { security: audits.security } : {}),\n ...(audits.domain !== undefined ? { domain: audits.domain } : {}),\n },\n new Date().toISOString(),\n );\n\n // Lighthouse-miss flag: only when lighthouse WAS requested (in results) but produced no scores —\n // an infra failure worth reding the run, AFTER persisting the other audits. A sweep that never\n // ran lighthouse (e.g. `--only security`) skips this entirely.\n if (lhResult && !lhHasScores) {\n // Enumerate what WAS persisted so the failure (surfaced to the single-site\n // CLI operator via console.error) reads as a partial write, not a total one.\n const persisted = writes.map((w) => w.audit);\n throw Object.assign(\n new Error(\n `Lighthouse audit produced no scores; ${\n persisted.length ? `wrote ${persisted.join(\"/\")} but refused Lighthouse` : \"wrote nothing\"\n }. Summary: ${lhResult.summary}`,\n ),\n { exitCode: 1 },\n );\n }\n\n return { siteName: target.name, writes, events, siteId: target.id, fields };\n}\n\nexport type FleetWriteResult = {\n written: WriteSummary[];\n failed: Array<{ slug: string; error: string }>;\n /** Turso write-through counts (#539 Phase 3 dual-write). Present ONLY when a\n * mirror was wired — absent means mirroring was not attempted (no libSQL\n * creds), which must stay distinguishable from `mirrored=0` (a wired mirror\n * that landed nothing = the fleet-wide outage alarm). */\n mirrored?: number;\n mirrorFailed?: number;\n /** Mirror UPDATEs that matched no site_health row (site created in Airtable\n * after the last hourly import). Not a failure — the row converges on the\n * next sync — but not mirrored either: counted apart so `mirrored=` stays\n * an honest Phase 5 cutover signal instead of overcounting by the no-row\n * sites. */\n mirrorMissed?: number;\n};\n\n/** Render the fleet write-back outcome for the CLI/CI. Beyond the human-readable\n * lines, it emits a single deterministic, machine-parseable line —\n * `FLEET_WRITE_SUMMARY wrote=N failed=M total=T` — that the nightly workflow\n * greps to decide pass/fail. Keying CI on this line (not the prose, and not a\n * \"wrote ≥ 1\" heuristic) lets the gate tolerate a single known flake while\n * still reding on a total or mass write-back failure. */\n/** Did this sweep fail, for exit-code purposes (#612)?\n *\n * Pre-freeze, only an Airtable write failure counts. Mirror failures and missed\n * rows are real but survivable: the hourly import converges them, and going red\n * over a transient the system already handles would train the alarm to be\n * ignored.\n *\n * Post-freeze there is no import to converge anything, so all three become\n * fatal — including an ABSENT mirror, which is the worst of the three and the\n * easiest to misread as success: no counters at all means no libSQL creds,\n * which means the sweep wrote nothing to the only store there is.\n *\n * Note what this deliberately does NOT change: `writeFleetAuditsToAirtable`\n * still catches per-site mirror failures rather than throwing. One bad site\n * must not abort a 44-site sweep. This gates the RUN, not the loop. */\nexport function fleetWriteFailed(\n result: FleetWriteResult,\n strict: boolean = TURSO_IS_AUTHORITATIVE,\n): boolean {\n if (result.failed.length > 0) return true;\n if (!strict) return false;\n if (result.mirrored === undefined) return true; // no mirror was wired at all\n return (result.mirrorFailed ?? 0) > 0 || (result.mirrorMissed ?? 0) > 0;\n}\n\nexport function formatFleetWriteSummary(result: FleetWriteResult): string {\n const wrote = result.written.length;\n const failed = result.failed.length;\n const total = wrote + failed;\n let out = `→ wrote ${wrote} site(s) to Airtable`;\n if (failed > 0) {\n out += `\\n⚠ ${failed} site(s) not written: ${result.failed\n .map((f) => `${f.slug} (${f.error})`)\n .join(\"; \")}`;\n }\n out += `\\nFLEET_WRITE_SUMMARY wrote=${wrote} failed=${failed} total=${total}`;\n // Mirror counts APPEND so the fleet workflows' `grep -oE \"FLEET_WRITE_SUMMARY\n // wrote=... total=[0-9]+\"` extraction still matches its prefix untouched.\n if (result.mirrored !== undefined) {\n out += ` mirrored=${result.mirrored} mirror_failed=${result.mirrorFailed ?? 0}`;\n out += ` mirror_missed=${result.mirrorMissed ?? 0}`;\n }\n return out;\n}\n\n/** Write each site's pooled audit results back to its own Websites row,\n * best-effort. Results are grouped by `result.site` (the slug the fleet\n * inventory stamped as Site.name). A per-site failure (no scores, no matching\n * row) is collected — not thrown — so one bad site never aborts the batch. */\nexport async function writeFleetAuditsToAirtable(args: {\n base: AirtableBase;\n websites: WebsiteRow[];\n results: AuditResult[];\n /** Optional Turso write-through (#539 Phase 3 dual-write). Each site's\n * just-written FieldSet mirrors into site_health right after its Airtable\n * write; a mirror failure is counted, never thrown — Airtable stays\n * authoritative and the hourly sync converges what the mirror missed.\n * (The lighthouse-miss partial write lands in `failed` and is deliberately\n * NOT mirrored — same convergence path.) */\n mirror?: HealthMirror;\n}): Promise<FleetWriteResult> {\n const { base, websites, results, mirror } = args;\n\n const bySlug = new Map<string, AuditResult[]>();\n for (const r of results) {\n const arr = bySlug.get(r.site) ?? [];\n arr.push(r);\n bySlug.set(r.site, arr);\n }\n\n const written: WriteSummary[] = [];\n const failed: FleetWriteResult[\"failed\"] = [];\n let mirrored = 0;\n let mirrorFailed = 0;\n let mirrorMissed = 0;\n // Serial on purpose: even at one (now atomic) update call per site, Airtable's\n // ~5 req/sec limit means a Promise.all fan-out across the fleet would burst and\n // trip 429s (silently filed as failures). Below a few dozen sites, serial trades\n // wall-clock for safety. (morning-brief 2026-06-09 MEDIUM-3.) Add a bounded pool\n // when the fleet grows.\n for (const [slug, siteResults] of bySlug) {\n try {\n const summary = await writeAuditsToAirtable({ base, websites, slug, results: siteResults });\n written.push(summary);\n if (mirror && summary.siteId && summary.fields && Object.keys(summary.fields).length > 0) {\n try {\n if (await mirror(summary.siteId, summary.fields)) mirrored++;\n else mirrorMissed++;\n } catch (e) {\n mirrorFailed++;\n console.error(`[health-mirror] ${slug}: ${(e as Error).message}`);\n }\n }\n } catch (e) {\n failed.push({ slug, error: (e as Error).message });\n }\n }\n return { written, failed, ...(mirror ? { mirrored, mirrorFailed, mirrorMissed } : {}) };\n}\n"],"mappings":"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","names":["fields"]}
@@ -1,53 +0,0 @@
1
- import {
2
- ACTIVE_STATUSES,
3
- isPreLaunch,
4
- listWebsites,
5
- siteSlug
6
- } from "./chunk-3Z6X3BHU.js";
7
- import {
8
- isHttpUrl
9
- } from "./chunk-RARREDQE.js";
10
-
11
- // src/inventory/airtable.ts
12
- function fromAirtableBase(base, opts = {}) {
13
- return async () => {
14
- const workdir = opts.workdir ?? process.env.REDDOOR_FLEET_WORKDIR;
15
- if (!workdir) {
16
- throw new Error(
17
- "fromAirtableBase requires `workdir` option or REDDOOR_FLEET_WORKDIR env (sites need a local path)"
18
- );
19
- }
20
- const websites = await listWebsites(base);
21
- return websites.filter(
22
- (w) => w.status !== null && ACTIVE_STATUSES.has(w.status) && !isPreLaunch(w.status) && w.url.length > 0
23
- ).flatMap((w) => {
24
- const slug = siteSlug(w.name);
25
- if (slug.length === 0) {
26
- console.warn(
27
- `[inventory] skipping "${w.name}" (row ${w.id}): Name has no slug-able characters (empty slug)`
28
- );
29
- return [];
30
- }
31
- const site = {
32
- path: `${workdir}/${slug}`,
33
- name: slug,
34
- meta: { airtableRowId: w.id, displayName: w.name }
35
- };
36
- if (isHttpUrl(w.url)) {
37
- site.deployedUrl = w.url;
38
- } else {
39
- console.warn(
40
- `[inventory] skipping deployed audit for "${w.name}": url is not http(s): ${JSON.stringify(w.url)}`
41
- );
42
- }
43
- if (w.gitRepo) site.gitRepo = w.gitRepo;
44
- if (w.netlifyId) site.netlifyId = w.netlifyId;
45
- return [site];
46
- });
47
- };
48
- }
49
-
50
- export {
51
- fromAirtableBase
52
- };
53
- //# sourceMappingURL=chunk-5GUJ36TL.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/inventory/airtable.ts"],"sourcesContent":["import type { Site, InventoryProvider } from \"../types.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport {\n listWebsites,\n siteSlug,\n ACTIVE_STATUSES,\n isPreLaunch,\n} from \"../reports/airtable/websites.js\";\nimport { isHttpUrl } from \"../util/url.js\";\n\nexport type AirtableInventoryOptions = {\n /**\n * Local workdir to compute each site's path as `{workdir}/{slug}`.\n * Defaults to REDDOOR_FLEET_WORKDIR env var if not provided.\n * Airtable doesn't store local checkout paths, so this is required.\n */\n workdir?: string;\n};\n\n/**\n * Read sites from the Airtable Websites table as an InventoryProvider.\n * Each row becomes one Site; `path` is computed as `{workdir}/{slug}`.\n * Only LIVE `maintenance` sites that have a `url` are included — pre-launch\n * stages (\"launching\" / \"building\", via isPreLaunch) are excluded so a\n * not-yet-live site is never audited as production (its deploy/domain/uptime/CMS\n * audits would fail against nothing and red its Airtable row) nor swept by other\n * `--fleet airtable` ops; it re-enters the fleet when a Launch report flips its\n * Status to \"maintained\". The production URL is exposed as `Site.deployedUrl` so\n * the lighthouse audit can run against it with no checkout. `repoUrl` is\n * intentionally NOT set from `url` — a clone source must come from `gitRepo`\n * (`owner/repo`), never the production URL.\n */\nexport function fromAirtableBase(\n base: AirtableBase,\n opts: AirtableInventoryOptions = {},\n): InventoryProvider {\n return async (): Promise<Site[]> => {\n const workdir = opts.workdir ?? process.env.REDDOOR_FLEET_WORKDIR;\n if (!workdir) {\n throw new Error(\n \"fromAirtableBase requires `workdir` option or REDDOOR_FLEET_WORKDIR env (sites need a local path)\",\n );\n }\n const websites = await listWebsites(base);\n return websites\n .filter(\n (w) =>\n w.status !== null &&\n ACTIVE_STATUSES.has(w.status) &&\n !isPreLaunch(w.status) &&\n w.url.length > 0,\n )\n .flatMap((w) => {\n const slug = siteSlug(w.name);\n // An empty slug (a Name with no slug-able characters) can't form a stable\n // path and — fatally — can't be matched back to its Websites row on\n // write-back: every empty-slug site would collapse under the \"\" key and\n // mis-write or fail. Skip it loudly rather than silently mis-map it.\n if (slug.length === 0) {\n console.warn(\n `[inventory] skipping \"${w.name}\" (row ${w.id}): Name has no slug-able characters (empty slug)`,\n );\n return [];\n }\n const site: Site = {\n path: `${workdir}/${slug}`,\n name: slug,\n meta: { airtableRowId: w.id, displayName: w.name },\n };\n // Scheme-allowlist the Airtable `url` before exposing it as the\n // deployed-audit target (it's handed straight to Chrome/lhci). A\n // `file://`/`gopher://`/internal-host value would be a local-file read\n // or SSRF — skip the deployed audit for that site rather than trust it.\n if (isHttpUrl(w.url)) {\n site.deployedUrl = w.url;\n } else {\n console.warn(\n `[inventory] skipping deployed audit for \"${w.name}\": url is not http(s): ${JSON.stringify(w.url)}`,\n );\n }\n if (w.gitRepo) site.gitRepo = w.gitRepo;\n // Expose the Netlify site id (operator-set identity column) so the\n // netlify-deploy audit can query the API with no checkout. Absent → that\n // audit skips for this site. Not derived from the URL.\n if (w.netlifyId) site.netlifyId = w.netlifyId;\n return [site];\n });\n };\n}\n"],"mappings":";;;;;;;;;;;AAgCO,SAAS,iBACd,MACA,OAAiC,CAAC,GACf;AACnB,SAAO,YAA6B;AAClC,UAAM,UAAU,KAAK,WAAW,QAAQ,IAAI;AAC5C,QAAI,CAAC,SAAS;AACZ,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AAAA,IACF;AACA,UAAM,WAAW,MAAM,aAAa,IAAI;AACxC,WAAO,SACJ;AAAA,MACC,CAAC,MACC,EAAE,WAAW,QACb,gBAAgB,IAAI,EAAE,MAAM,KAC5B,CAAC,YAAY,EAAE,MAAM,KACrB,EAAE,IAAI,SAAS;AAAA,IACnB,EACC,QAAQ,CAAC,MAAM;AACd,YAAM,OAAO,SAAS,EAAE,IAAI;AAK5B,UAAI,KAAK,WAAW,GAAG;AACrB,gBAAQ;AAAA,UACN,yBAAyB,EAAE,IAAI,UAAU,EAAE,EAAE;AAAA,QAC/C;AACA,eAAO,CAAC;AAAA,MACV;AACA,YAAM,OAAa;AAAA,QACjB,MAAM,GAAG,OAAO,IAAI,IAAI;AAAA,QACxB,MAAM;AAAA,QACN,MAAM,EAAE,eAAe,EAAE,IAAI,aAAa,EAAE,KAAK;AAAA,MACnD;AAKA,UAAI,UAAU,EAAE,GAAG,GAAG;AACpB,aAAK,cAAc,EAAE;AAAA,MACvB,OAAO;AACL,gBAAQ;AAAA,UACN,4CAA4C,EAAE,IAAI,0BAA0B,KAAK,UAAU,EAAE,GAAG,CAAC;AAAA,QACnG;AAAA,MACF;AACA,UAAI,EAAE,QAAS,MAAK,UAAU,EAAE;AAIhC,UAAI,EAAE,UAAW,MAAK,YAAY,EAAE;AACpC,aAAO,CAAC,IAAI;AAAA,IACd,CAAC;AAAA,EACL;AACF;","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/reports/header-image/assets/index.ts","../src/reports/header-image/index.ts","../src/reports/header-image/capture.ts","../src/reports/header-image/compose.ts","../src/reports/header-image/geometry.ts"],"sourcesContent":["import { readFile } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst PLATE = \"plate-clean.png\";\n\n/** Per-report-type headline overlays, stamped over the CLEAN plate at send time\n * (see orchestrate.ts). Types with no entry go out on the clean plate.\n *\n * Source: Figma \"Web-Maintenance-Email\" (mQ3hy2d9JnOG9ljCzbZS8j) —\n * Maintenance is node 158:15, Testing is 153:721 (\"maintenance & testing\",\n * which is what a Testing report actually covers). Export each at 4x; Figma\n * crops a text export to its ink box, which is why these are 1328x660 /\n * 1715x664 rather than the 1840x720 text box.\n *\n * ⚠️ A Figma MCP `download_assets` export arrives FLATTENED — never\n * transparent — and it flattens onto WHATEVER SITS BEHIND THE NODE, which is\n * not one fixed colour: Maintenance/Testing live inside a white Header frame\n * and come back on white, while Announcement/Launch sit on the bare canvas and\n * come back on Figma's canvas grey (30,30,30). Stamping either unprocessed\n * paints a solid slab over the paper texture — that, not a bad design file, is\n * what made the 2026-08-20 Testing asset unusable.\n *\n * Recover the alpha instead of re-exporting by hand: for ink C over backdrop B,\n * a = (px - B) / (C - B). DETECT B (the export's most common colour) rather\n * than assuming white, and pick the channel with the largest |C - B| — green\n * separates the brand red from white by 221 levels but from canvas grey by only\n * 4, which would quantise the alpha to noise. Verified against the known-good\n * Maintenance asset at mean abs alpha diff 0.022, 0.0037% of pixels off by\n * more than 8.\n *\n * Editing this copy needs Figma desktop, not the Plugin API: `characters`\n * requires loadFontAsync and the MCP font environment carries no Helvetica at\n * all, so the licensed Helvetica Neue LT Std can never load there. */\nconst HEADLINE_FILES = {\n Maintenance: \"headline-maintenance.png\",\n Testing: \"headline-testing.png\",\n Announcement: \"headline-announcement.png\",\n Launch: \"headline-launch.png\",\n} as const;\n\nexport type HeadlineKind = keyof typeof HEADLINE_FILES;\n\n/** The headline kind for a report type, or null for types that ship clean. */\nexport function headlineKindFor(reportType: string): HeadlineKind | null {\n return Object.hasOwn(HEADLINE_FILES, reportType) ? (reportType as HeadlineKind) : null;\n}\n\n// Walk up from this module's URL looking for the assets dir in either the dev\n// layout (src/reports/header-image/assets/) or the published layout\n// (dist/reports/header-image/assets/). REQUIRED because tsup inlines this module\n// into dist/cli/bin.js — so import.meta.url-based sibling resolution looks in\n// dist/cli/ and fails with ENOENT. This exact bug shipped in 0.10.0-0.10.1 for\n// the maintenance-email assets; tests passed in dev because vitest evaluates the\n// source file, where import.meta.url is already correct. Only `pnpm test:dist`\n// catches a regression here.\nlet cachedAssetsDir: string | null = null;\nfunction resolveAssetsDir(): string {\n if (cachedAssetsDir) return cachedAssetsDir;\n let dir = dirname(fileURLToPath(import.meta.url));\n for (;;) {\n for (const layout of [\"src\", \"dist\"]) {\n const candidate = join(dir, layout, \"reports\", \"header-image\", \"assets\", PLATE);\n if (existsSync(candidate)) {\n cachedAssetsDir = dirname(candidate);\n return cachedAssetsDir;\n }\n }\n const parent = dirname(dir);\n if (parent === dir) {\n throw new Error(\n `loadPlate: could not locate the header-image assets dir by walking up from ${fileURLToPath(import.meta.url)}. Checked both src/ and dist/ layouts.`,\n );\n }\n dir = parent;\n }\n}\n\n/** Read the bundled 2400x3200 CLEAN plate (no headline — the report type's\n * headline is stamped at send time, so one stored image serves every type). */\nexport async function loadPlate(): Promise<Uint8Array> {\n return new Uint8Array(await readFile(join(resolveAssetsDir(), PLATE)));\n}\n\n/** Read a headline overlay (transparent PNG, ink-box cropped). */\nexport async function loadHeadline(kind: HeadlineKind): Promise<Uint8Array> {\n return new Uint8Array(await readFile(join(resolveAssetsDir(), HEADLINE_FILES[kind])));\n}\n","import sharp from \"sharp\";\nimport { captureHomepage, type Shooter } from \"./capture.js\";\nimport { composeHeaderImage, stampHeadline, headlineInkCount } from \"./compose.js\";\nimport { loadPlate, loadHeadline, headlineKindFor } from \"./assets/index.js\";\nimport { CANVAS } from \"./geometry.js\";\n\n/** Headline-red pixels tolerated in the band before a stored header is treated\n * as already carrying a headline. A clean-plate header measures 0 and a baked\n * one ~62,000, so this sits far from both — it exists only to absorb stray\n * JPEG artefacts, not to tune a boundary. */\nconst HEADLINE_INK_TOLERANCE = 500;\n\nexport type GenerateInput = {\n /** The site's production URL. */\n url: string;\n /** Airtable site slug; drives the attachment filename. */\n slug?: string;\n /** Injected browser IO (tests). */\n shooter?: Shooter;\n /** Per-site settle override for animation-heavy or consent-gated homepages. */\n settleMs?: number;\n};\n\nexport type GeneratedHeaderImage = {\n bytes: Uint8Array;\n /** The domain as printed on the plate. */\n domain: string;\n filename: string;\n contentType: \"image/jpeg\";\n};\n\n/** Strip scheme, leading www and any path — the plate prints a bare domain. */\nexport function domainFromUrl(url: string): string {\n return new URL(url).hostname.replace(/^www\\./, \"\");\n}\n\n/**\n * A capture is \"blank\" when it is almost entirely near-white — a consent gate,\n * a failed render (blank canvas), or a shot fired before first paint. The\n * operator reviews the rendered email before sending, so this is a backstop\n * rather than the only gate; its job is to stop an obviously broken shot from\n * replacing a good stored header.\n *\n * Uniformity alone is NOT the signal: a homepage with a large solid-colour\n * hero (any brand colour) is legitimately uniform but not blank. Only\n * near-white uniformity is flagged, since every real failure mode here\n * (consent overlay, unrendered canvas, pre-paint flash) lands on a white\n * background.\n */\nasync function assertNotBlank(shot: Uint8Array): Promise<void> {\n const { data, info } = await sharp(Buffer.from(shot))\n .resize(64, 40, { fit: \"fill\" })\n .removeAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true });\n const total = info.width * info.height;\n const at = (i: number): number => data[i] ?? 0;\n\n let sumR = 0;\n let sumG = 0;\n let sumB = 0;\n for (let i = 0; i < data.length; i += info.channels) {\n sumR += at(i);\n sumG += at(i + 1);\n sumB += at(i + 2);\n }\n const meanR = sumR / total;\n const meanG = sumG / total;\n const meanB = sumB / total;\n\n let near = 0;\n for (let i = 0; i < data.length; i += info.channels) {\n if (\n Math.abs(at(i) - meanR) < 8 &&\n Math.abs(at(i + 1) - meanG) < 8 &&\n Math.abs(at(i + 2) - meanB) < 8\n ) {\n near++;\n }\n }\n\n const uniform = near / total > 0.95;\n const nearWhite = meanR > 240 && meanG > 240 && meanB > 240;\n if (uniform && nearWhite) {\n throw new Error(\n \"header-image: capture looks blank (near-white, >95% one colour) — keeping the existing header\",\n );\n }\n}\n\n/**\n * Stamp the report type's headline onto a stored (clean) header at send time.\n * Types with no headline registered pass through untouched, as does any header\n * that must not be stamped:\n *\n * - wrong dimensions (a legacy hand-made header), where fixed coordinates\n * would misplace the text;\n * - a header that ALREADY CARRIES A HEADLINE, i.e. one built on the old baked\n * plate. Stamping those prints the new headline directly over the baked one\n * and the two overprint into unreadable pulp — which shipped in a real\n * announcement on 2026-08-24. An earlier version of this comment claimed\n * such headers were safe because they'd \"keep their baked headline\"; that\n * was wrong. They are canvas-sized, so nothing stopped them being stamped.\n *\n * The band is empty on a clean-plate header and holds ~62k red px on a baked\n * one, so the check is unambiguous rather than a tuned threshold. A skipped\n * header still goes out looking correct (its baked headline is a real headline)\n * and self-heals on the site's next draft, which regenerates it clean;\n * `header-image --all --force` does the whole fleet at once.\n */\nexport async function applyReportTypeHeadline(\n header: Uint8Array,\n reportType: string,\n): Promise<Uint8Array> {\n const kind = headlineKindFor(reportType);\n if (!kind) return header;\n // Best-effort by design: the headline is cosmetic, so anything unexpected —\n // undecodable bytes included — warns and sends the header as stored rather\n // than failing the send. Mandatory processing (and its hard failures) lives\n // in prepareHeaderImage downstream.\n try {\n const meta = await sharp(Buffer.from(header)).metadata();\n if (meta.width !== CANVAS.width || meta.height !== CANVAS.height) {\n console.warn(\n `⚑ header headline skipped: stored header is ${meta.width}x${meta.height}, not ${CANVAS.width}x${CANVAS.height} — sending it as stored`,\n );\n return header;\n }\n const existing = await headlineInkCount(header);\n if (existing > HEADLINE_INK_TOLERANCE) {\n console.warn(\n `⚑ header headline skipped: the stored header already carries a headline (${existing} ink px in the band) — it predates the clean-plate switch, so stamping would overprint. Sending it as stored; run \\`header-image ${\"<site>\"} --write-airtable\\` to regenerate it clean.`,\n );\n return header;\n }\n return await stampHeadline(header, await loadHeadline(kind));\n } catch (err) {\n console.warn(\n `⚑ header headline skipped (${err instanceof Error ? err.message : String(err)}) — sending the header as stored`,\n );\n return header;\n }\n}\n\n/** Capture a site's homepage and composite its header image. */\nexport async function generateHeaderImage(input: GenerateInput): Promise<GeneratedHeaderImage> {\n const screenshot = await captureHomepage(input.url, {\n ...(input.shooter !== undefined ? { shooter: input.shooter } : {}),\n ...(input.settleMs !== undefined ? { settleMs: input.settleMs } : {}),\n });\n await assertNotBlank(screenshot);\n\n const domain = domainFromUrl(input.url);\n const bytes = await composeHeaderImage({\n plate: await loadPlate(),\n screenshot,\n domain,\n });\n const base = input.slug ?? domain.split(\".\")[0];\n return { bytes, domain, filename: `${base}Header.jpg`, contentType: \"image/jpeg\" };\n}\n","/** Viewport matching the plate's MacBook screen aspect (16:10), so the crop in\n * compose.ts is a no-op for a well-behaved homepage. */\nconst VIEWPORT = { width: 1600, height: 1000 } as const;\n/** 2x so the 1349px-wide screen rect is fed real pixels, not upscaled ones. */\nconst DEVICE_SCALE_FACTOR = 2;\n/** Entrance animations and webfonts settle before the shutter. Measured against\n * live fleet sites; overridable per site because consent gates and long\n * animations vary. */\nconst DEFAULT_SETTLE_MS = 2500;\n/** Budget for the `load` milestone — the navigation we actually gate on.\n *\n * Do NOT put `waitUntil: \"networkidle\"` back on the `goto`. Measured across all\n * 14 live fleet sites (30s timeout): `load` succeeded on 14/14 in 346–1205ms,\n * while `networkidle` NEVER fired on 4 of them (ERP Industrials, Vineyard,\n * Revogen, 1836dig) — chat widgets, analytics polling and websockets hold the\n * connection open forever, so `goto` threw and those sites could never get a\n * header image at all. */\nconst NAV_TIMEOUT_MS = 60_000;\n/** Best-effort extra wait for network idle, applied *after* `load` and with its\n * rejection swallowed. The 10 sites that do settle still get the benefit; the 4\n * that never will just proceed. 3s is sized from the measurement above: the\n * slowest site that does reach idle took 4078ms from `goto` start, and by the\n * time this runs the `load` milestone (~0.3–1.2s) has already passed, so 3s\n * covers the real remaining settle time without stalling the never-idle sites. */\nconst IDLE_BUDGET_MS = 3_000;\n\nexport type ShootOptions = {\n url: string;\n width: number;\n height: number;\n deviceScaleFactor: number;\n settleMs: number;\n};\n\n/** Injected browser IO. The real impl drives Playwright; tests pass a fake. */\nexport type Shooter = {\n shoot: (opts: ShootOptions) => Promise<Uint8Array>;\n};\n\nexport type CaptureOptions = {\n shooter?: Shooter;\n settleMs?: number;\n};\n\n/**\n * Screenshot a site's homepage for the header image.\n *\n * Viewport-only, never `fullPage`: the plate shows a laptop screen, not a\n * scroll. Failures propagate — a caller must be able to keep the site's existing\n * header rather than overwrite it with a broken shot.\n */\nexport async function captureHomepage(\n url: string,\n options: CaptureOptions = {},\n): Promise<Uint8Array> {\n const shooter = options.shooter ?? (await defaultShooter());\n return shooter.shoot({\n url,\n width: VIEWPORT.width,\n height: VIEWPORT.height,\n deviceScaleFactor: DEVICE_SCALE_FACTOR,\n settleMs: options.settleMs ?? DEFAULT_SETTLE_MS,\n });\n}\n\n/** Real Playwright shooter. Lazily imported so unit tests never load it and the\n * static import graph stays central-dep-free for `test:dist`. */\nexport async function defaultShooter(): Promise<Shooter> {\n const { chromium } = await import(\"@playwright/test\");\n return {\n async shoot(opts) {\n const browser = await chromium.launch();\n try {\n const page = await browser.newPage({\n viewport: { width: opts.width, height: opts.height },\n deviceScaleFactor: opts.deviceScaleFactor,\n });\n await page.goto(opts.url, { waitUntil: \"load\", timeout: NAV_TIMEOUT_MS });\n // Best-effort only — a site that never idles must still be captured.\n await page.waitForLoadState(\"networkidle\", { timeout: IDLE_BUDGET_MS }).catch(() => {});\n await page.evaluate(\"document.fonts && document.fonts.ready\");\n await page.waitForTimeout(opts.settleMs);\n const buf = await page.screenshot({ type: \"png\" });\n return new Uint8Array(buf);\n } finally {\n await browser.close();\n }\n },\n };\n}\n","import sharp from \"sharp\";\nimport type { OverlayOptions } from \"sharp\";\nimport {\n CANVAS,\n SCREEN,\n DOMAIN,\n PAPER,\n HEADLINE,\n HEADLINE_BAND,\n HEADLINE_INK,\n} from \"./geometry.js\";\n\nexport type ComposeInput = {\n /** The bundled 2400x3200 plate. */\n plate: Uint8Array;\n /** A homepage screenshot, any size. Cropped to fill the screen rect. */\n screenshot: Uint8Array;\n /** The site's public domain, drawn bottom-left (e.g. \"gallerysonder.com\"). */\n domain: string;\n};\n\n/** JPEG quality. The hand-made headers are ~2.4MB; 88 lands near 0.7MB with no\n * visible difference at the 600px the email actually renders. */\nconst JPEG_QUALITY = 88;\n\n/** Escape the five XML metacharacters. The domain is interpolated into an SVG,\n * and a bare `&` yields invalid XML that sharp rejects outright. */\nfunction escapeXml(s: string): string {\n return s\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&apos;\");\n}\n\n/**\n * The domain line as a transparent SVG overlay the size of the full canvas, so\n * it can be composited at 0,0 and positioned by its own coordinates.\n *\n * ⚠️ FONT AVAILABILITY IS ENVIRONMENT-DEPENDENT. sharp renders SVG through\n * librsvg + fontconfig, so the family below resolves against INSTALLED fonts.\n * On macOS it resolves to real Helvetica Neue, which reproduces the hand-made\n * reference exactly (verified: ink box dx=0 dy=0 dw=0 dh=0). A stock\n * ubuntu-latest runner has none of Helvetica Neue / Helvetica / Arial, so it\n * falls through to `sans-serif` — typically DejaVu Sans, which is wider and\n * would render a visibly different domain line.\n *\n * This matters because report drafting (which regenerates the header) runs on\n * ubuntu-latest in `.github/workflows/daily-reports.yml`. That workflow now\n * installs `fonts-urw-base35`, which provides Nimbus Sans — metrically\n * equivalent to Helvetica and third in the stack below — so a CI-generated\n * header's domain line matches a locally-generated one. Any OTHER environment\n * that runs this code still needs a Helvetica-metric font installed.\n */\nfunction domainSvg(domain: string): Buffer {\n return Buffer.from(\n `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${CANVAS.width}\" height=\"${CANVAS.height}\">\n <text x=\"${DOMAIN.x}\" y=\"${DOMAIN.baseline}\"\n font-family=\"Helvetica Neue, Helvetica, Nimbus Sans, Arial, sans-serif\"\n font-size=\"${DOMAIN.size}\" font-weight=\"${DOMAIN.weight}\"\n fill=\"${DOMAIN.color}\">${escapeXml(domain)}</text>\n </svg>`,\n \"utf-8\",\n );\n}\n\n/**\n * Composite one site's header image: paste its homepage screenshot into the\n * plate's laptop screen and draw its domain underneath.\n *\n * PURE — no browser, no network, no filesystem. Fidelity lives here, so it must\n * be verifiable in a unit test.\n *\n * The screenshot is cropped to FILL the 16:10 screen from the top: a taller\n * homepage loses its fold rather than being squashed, which would be obvious\n * and wrong. `flatten` guards against a transparent source punching a hole in\n * the plate.\n */\nexport async function composeHeaderImage(input: ComposeInput): Promise<Uint8Array> {\n const screen = await sharp(Buffer.from(input.screenshot))\n .resize(SCREEN.w, SCREEN.h, { fit: \"cover\", position: \"top\" })\n .flatten({ background: PAPER })\n .toBuffer();\n\n const layers: OverlayOptions[] = [{ input: screen, left: SCREEN.x, top: SCREEN.y }];\n if (input.domain.trim().length > 0) {\n layers.push({ input: domainSvg(input.domain), left: 0, top: 0 });\n }\n\n const out = await sharp(Buffer.from(input.plate))\n .composite(layers)\n .jpeg({ quality: JPEG_QUALITY })\n .toBuffer();\n return new Uint8Array(out);\n}\n\n/**\n * Count headline-red pixels already present in the headline band.\n *\n * A header composed on the clean plate has EXACTLY ZERO (measured), because the\n * band sits between the logo and the laptop and nothing else paints there. A\n * header built on the old baked plate has ~62,000 — its headline is part of the\n * image. That gap is what lets {@link stampHeadline}'s caller refuse to print a\n * second headline over the first. PURE.\n */\nexport async function headlineInkCount(header: Uint8Array): Promise<number> {\n const { data, info } = await sharp(Buffer.from(header))\n .extract({\n left: HEADLINE_BAND.x,\n top: HEADLINE_BAND.y,\n width: HEADLINE_BAND.w,\n height: HEADLINE_BAND.h,\n })\n .removeAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true });\n let n = 0;\n for (let i = 0; i < data.length; i += info.channels) {\n const r = data[i] ?? 0;\n const g = data[i + 1] ?? 0;\n const b = data[i + 2] ?? 0;\n // Generous band around the brand red rather than an exact match: the stored\n // header is a JPEG, so its ink has been through lossy re-encoding.\n if (r > HEADLINE_INK.r - 60 && g < HEADLINE_INK.g + 60 && b < HEADLINE_INK.b + 60) n++;\n }\n return n;\n}\n\n/**\n * Stamp a report-type headline overlay onto a finished (clean-plate) header at\n * the measured HEADLINE origin. PURE and strict: the header must be exactly\n * CANVAS-sized — a legacy hand-made header at other dimensions would misplace\n * the text, so the caller (applyReportTypeHeadline) size-guards and skips\n * instead of calling in. One extra JPEG generation at quality 88 is invisible\n * at the email's 600px display size.\n */\nexport async function stampHeadline(header: Uint8Array, headline: Uint8Array): Promise<Uint8Array> {\n const meta = await sharp(Buffer.from(header)).metadata();\n if (meta.width !== CANVAS.width || meta.height !== CANVAS.height) {\n throw new Error(\n `stampHeadline: header is ${meta.width}x${meta.height}, expected ${CANVAS.width}x${CANVAS.height}`,\n );\n }\n const out = await sharp(Buffer.from(header))\n .composite([{ input: Buffer.from(headline), left: HEADLINE.x, top: HEADLINE.y }])\n .jpeg({ quality: JPEG_QUALITY })\n .toBuffer();\n return new Uint8Array(out);\n}\n","// Measured off the real plate, not guessed.\n//\n// SCREEN is the hole INSIDE the laptop bezel. It is derived from the bezel —\n// a perfectly flat black frame (luminance 0.0) — because that is\n// content-independent. Deriving it from content instead (diffing two headers for\n// pixels that vary) yields x=282 y=1856 w=1394 h=871, which is WRONG: that rect\n// spans the laptop's outer panel, covering the bezel on three sides while\n// falling ~29px short at the bottom, so the plate's baked-in screenshot shows\n// as a strip under every site's content.\n//\n// Cross-checked: the photo-to-flat-black transition at the bottom of the plate\n// lands at y=2756, exactly SCREEN.y + SCREEN.h - 1.\n//\n// Re-measure only if the Figma template changes — and if you do, find the bezel.\n\n/** The plate's pixel dimensions — Figma frame 600x800 exported at 4x. */\nexport const CANVAS = { width: 2400, height: 3200 } as const;\n\n/** The MacBook Pro screen the site screenshot is pasted into — the area inside\n * the bezel, so the bezel stays visible around the content exactly as it does\n * in the hand-made headers. Aspect 1.5983 (16:10 to within 0.1%). */\nexport const SCREEN = { x: 302, y: 1913, w: 1349, h: 844 } as const;\n\n/** Where a report-type headline overlay lands on the clean plate.\n *\n * Measured, not guessed: red ink present in the baked maintenance plate but\n * absent from plate-clean spans x=277 y=998 w=1326 h=658 — and\n * headline-maintenance.png is that ink box cropped to its own edges\n * (1328x660, ±1px of anti-aliasing at the threshold), so compositing the\n * asset at this origin reproduces the baked plate's headline exactly. */\nexport const HEADLINE = { x: 277, y: 998 } as const;\n\n/** The band a headline occupies, sized to the WIDEST registered overlay\n * (Testing, 1715x664). Used to detect a header that already carries a baked-in\n * headline, so it can't be stamped a second time. Deliberately clear of the\n * logo above (ends y≈506) and the laptop screen below (starts y=1913), so the\n * only ink that can land here is a headline. */\nexport const HEADLINE_BAND = { x: HEADLINE.x, y: HEADLINE.y, w: 1715, h: 664 } as const;\n\n/** The headline ink colour (brand red), and how far a pixel may drift from it\n * and still count — JPEG re-encoding moves it by a level or two. */\nexport const HEADLINE_INK = { r: 209, g: 34, b: 62 } as const;\n\n/** The per-site domain line, bottom left.\n *\n * Solved against the hand-made reference rather than guessed: rendering\n * \"gallerysonder.com\" with these values reproduces Sonder's original ink box\n * (x=283 y=2963 w=645 h=74) EXACTLY — dx=0 dy=0 dw=0 dh=0. A sweep over sizes\n * 62-88 at weights 300/400 has a single exact solution at size 80 / weight 400.\n *\n * `baseline` is the text baseline in canvas pixels; the ink box sits above it\n * by the cap height and below by the descender. */\nexport const DOMAIN = {\n x: 280,\n baseline: 3020,\n size: 80,\n weight: 400,\n color: \"#747474\",\n} as const;\n\n/** Flat paper tone, used as the compositing background so a transparent or\n * short source can never punch a hole in the plate. */\nexport const PAPER = \"#fcfcfc\" as const;\n"],"mappings":"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","names":["sharp","sharp"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/db/import-airtable.ts"],"sourcesContent":["import type { Db } from \"./client.js\";\nimport type { SitesTable, SiteHealthTable, SiteScheduleTable, ReportsTable } from \"./schema.js\";\nimport { siteSlug } from \"../reports/airtable/websites.js\";\nimport { MAINTENANCE_CHECKLIST, TESTING_CHECKLIST } from \"../reports/checklist.js\";\n\n/** One raw Airtable record: id + the fields object exactly as the API returns it\n * (empty cells are ABSENT, not null — the API omits them). */\nexport type RawRecord = { id: string; fields: Record<string, unknown> };\n\n/**\n * Phase 1.3 of #539: map raw Airtable records into the 0007 fleet-state tables,\n * and upsert them idempotently.\n *\n * The mapping here is the SINGLE source of truth for Airtable-column →\n * Turso-column: the parity harness (`db parity`) imports these same functions,\n * so \"what the importer writes\" and \"what parity expects\" cannot drift apart.\n *\n * Values are stored RAW (the cell as Airtable returns it, stringified where the\n * column is TEXT). Coercion — `toFrequency`, `toVerdict`, recipient splitting —\n * stays in the READ layer, exactly as `mapRow` does it today, so the store\n * never bakes in a lossy interpretation. The two exceptions are shapes, not\n * meanings: `Accepted Watch Conditions` normalizes to a JSON array (mapRow\n * accepts array or delimited string; the new store keeps one shape), and the\n * report checklist re-keys from Airtable column names to the stable keys in\n * src/reports/checklist.ts.\n */\n\n/** Websites columns that must NEVER reach Turso, and why. Everything else that\n * is populated but unmapped lands in `sites.legacy` (JSON, keyed by original\n * column name) so no data is silently dropped. */\nexport const EXCLUDED_WEBSITE_FIELDS: ReadonlySet<string> = new Set([\n // Dropped under D4's rule (empty everywhere + unreferenced), verified 2026-08-23.\n \"site host username\",\n \"site host password\",\n \"launch day\",\n \"contract link\",\n // Plaintext credentials — operator ruling 2026-08-23: these live on ONLY in\n // the frozen Airtable base. A new store does not inherit plaintext creds.\n \"DNS username\",\n \"DNS password\",\n \"cms username\",\n \"cms password\",\n // Link columns: Turso joins by rec id (D1); the links carry no extra data.\n \"Reports\",\n \"Submissions\",\n \"Spam Screenouts\",\n // Regenerated into sites.header_image as a BLOB by the header-image CLI (D5);\n // the Airtable attachment (an expiring signed URL) is never migrated.\n \"Header image\",\n]);\n\nconst s = (v: unknown): string | null => {\n if (v === undefined || v === null) return null;\n if (typeof v === \"string\") return v.trim() === \"\" ? null : v;\n return String(v);\n};\nconst n = (v: unknown): number | null => (typeof v === \"number\" ? v : null);\nconst b01 = (v: unknown): number => (v === true ? 1 : 0);\nconst b01n = (v: unknown): number | null => (typeof v === \"boolean\" ? (v ? 1 : 0) : null);\nconst json = (v: unknown): string | null => (v === undefined ? null : JSON.stringify(v));\n\n/** Direct field→column map for `sites` (operator-owned config). Exported for\n * the site-details write-through (fleet-state.mirrorSiteField), so the editor\n * and the importer share ONE Airtable-column → sites-column truth. */\nexport const SITE_FIELDS: Record<string, keyof SitesTable> = {\n Name: \"name\",\n url: \"url\",\n Status: \"status\",\n \"point of contact\": \"point_of_contact\",\n \"maintenence freq\": \"maintenance_freq\", // Airtable's misspelling dies at this boundary\n \"testing freq\": \"testing_freq\",\n \"maintenance day\": \"maintenance_day\",\n \"testing day\": \"testing_day\",\n \"GA4 property ID\": \"ga4_property_id\",\n \"Search query\": \"search_query\",\n \"Search Console property\": \"search_console_property\",\n \"Git repo\": \"git_repo\",\n \"Netlify ID\": \"netlify_id\",\n \"Report recipients (To)\": \"report_recipients_to\",\n \"Report recipients (CC)\": \"report_recipients_cc\",\n \"Copy — Intro\": \"copy_intro\",\n \"Copy — Contact\": \"copy_contact\",\n \"Copy — Footer\": \"copy_footer\",\n \"Newsletter Webhook\": \"newsletter_webhook\",\n \"Mailchimp API Key\": \"mailchimp_api_key\",\n \"Mailchimp Audience ID\": \"mailchimp_audience_id\",\n \"Notify Routing\": \"notify_routing\",\n \"Prismic Ack Until\": \"prismic_ack_until\",\n \"Launched at\": \"launched_at\",\n // Non-text columns. They live here so `mirrorSiteField` can resolve them like\n // any other editor field; the coercion that makes them non-text is in\n // `siteValueFor`, which BOTH this importer and that mirror go through.\n \"Require Turnstile\": \"require_turnstile\",\n \"Accepted Watch Conditions\": \"accepted_watch_conditions\",\n};\n\n/** Normalize an `Accepted Watch Conditions` cell (Airtable array, or a delimited\n * string) to the trimmed list both stores agree on. */\nfunction normalizeAwc(raw: unknown): string[] {\n if (Array.isArray(raw)) {\n return raw\n .filter((x): x is string => typeof x === \"string\")\n .map((x) => x.trim())\n .filter(Boolean);\n }\n if (typeof raw === \"string\") {\n return raw\n .split(/[\\n,]/)\n .map((x) => x.trim())\n .filter(Boolean);\n }\n return [];\n}\n\n/**\n * Resolve one Airtable Websites cell to the value its `sites` column stores.\n *\n * The ONE shared path between `mapWebsiteRecord` (full-row import) and\n * `fleet-state.mirrorSiteField` (the editor's write-through) — same contract and\n * the same reason as `healthColumnFor`: a coercion that exists twice will\n * eventually disagree, and here a disagreement is not subtle. Parity compares\n * raw-to-raw, so a mirror storing `\"true\"` where the importer stores `1` reds\n * every hourly run until the next import quietly papers over it.\n */\nexport function siteValueFor(column: keyof SitesTable, raw: unknown): string | number | null {\n if (column === \"require_turnstile\") return b01(raw);\n if (column === \"accepted_watch_conditions\") {\n const awc = normalizeAwc(raw);\n return awc.length > 0 ? JSON.stringify(awc) : null;\n }\n return s(raw);\n}\n\n/** Direct field→column map for `site_health` (nightly-cron-owned). Exported for\n * the Phase 3 writer mirrors (fleet-state.mirrorHealthFields) and their\n * lockstep tests, so the nightly writers and the importer share ONE\n * Airtable-column → site_health-column truth. */\nexport const HEALTH_FIELDS: Record<string, keyof SiteHealthTable> = {\n pScore: \"p_score\",\n rScore: \"r_score\",\n bpScore: \"bp_score\",\n seoScore: \"seo_score\",\n \"Last lighthouse audit at\": \"lighthouse_at\",\n \"A11y Violations\": \"a11y_violations\",\n \"Deps Drifted\": \"deps_drifted\",\n \"Deps Major Behind\": \"deps_major_behind\",\n \"Deps Outdated\": \"deps_outdated\",\n \"Deps Major Outdated\": \"deps_major_outdated\",\n \"Security Vulns Critical\": \"vulns_critical\",\n \"Security Vulns High\": \"vulns_high\",\n \"Security Vulns Moderate\": \"vulns_moderate\",\n \"Security Vulns Low\": \"vulns_low\",\n \"Last security audit at\": \"security_audit_at\",\n \"Security advisories\": \"security_advisories\",\n \"Security Auto-Fix Attempts\": \"auto_fix_attempts\",\n \"Analytics soft-fail at\": \"analytics_soft_fail_at\",\n \"Cert days remaining\": \"cert_days_remaining\",\n \"Domain checked at\": \"domain_checked_at\",\n \"Deploy status\": \"deploy_status\",\n \"Last deploy at\": \"last_deploy_at\",\n \"Deploy log URL\": \"deploy_log_url\",\n \"Deploy checked at\": \"deploy_checked_at\",\n \"Function health\": \"function_health\",\n \"CMS Reachable\": \"cms_reachable\",\n \"Turnstile widget\": \"turnstile_widget\",\n \"Function health checked at\": \"function_health_checked_at\",\n \"Broken links\": \"broken_links\",\n \"Browser checked at\": \"browser_checked_at\",\n \"Uptime Reachable\": \"uptime_reachable\",\n \"Titles & Meta OK\": \"titles_meta_ok\",\n \"Smoke OK\": \"smoke_ok\",\n \"Last Smoke At\": \"last_smoke_at\",\n \"Form E2E OK\": \"form_e2e_ok\",\n \"Form E2E checked at\": \"form_e2e_checked_at\",\n \"Renovate Failing CIs\": \"renovate_failing_cis\",\n \"Default Branch CI\": \"default_branch_ci\",\n \"Last Commit At\": \"last_commit_at\",\n \"GitHub Signals At\": \"github_signals_at\",\n \"Prismic Models\": \"prismic_models\",\n \"Prismic Models Checked At\": \"prismic_models_checked_at\",\n \"Prismic Models Drift\": \"prismic_models_drift\",\n};\n\n/** Numeric health columns (everything else in HEALTH_FIELDS stores as text). */\nconst HEALTH_NUMERIC: ReadonlySet<keyof SiteHealthTable> = new Set([\n \"p_score\",\n \"r_score\",\n \"bp_score\",\n \"seo_score\",\n \"a11y_violations\",\n \"deps_drifted\",\n \"deps_major_behind\",\n \"deps_outdated\",\n \"deps_major_outdated\",\n \"vulns_critical\",\n \"vulns_high\",\n \"vulns_moderate\",\n \"vulns_low\",\n \"auto_fix_attempts\",\n \"cert_days_remaining\",\n \"broken_links\",\n \"renovate_failing_cis\",\n]);\n\n/** Boolean-checkbox health columns (Airtable true/absent → 1/0/null). */\nexport const HEALTH_BOOLEAN: Record<string, keyof SiteHealthTable> = {\n \"Crossbrowser OK\": \"crossbrowser_ok\",\n \"Mobile OK\": \"mobile_ok\",\n \"Links OK\": \"links_ok\",\n};\n\n/** Fields consumed by the schedule table. */\nexport const SCHEDULE_FIELDS: Record<string, keyof SiteScheduleTable> = {\n \"Next maintenance at\": \"next_maintenance_at\",\n \"Next testing at\": \"next_testing_at\",\n};\n\n/** Resolve one Airtable health field to its site_health column AND the exact\n * coercion the importer applies to it. The ONE shared path between\n * mapWebsiteRecord (full-row import) and fleet-state.mirrorHealthFields\n * (partial write-through): a coercion that exists twice will eventually\n * disagree, and the hourly import silently papers over the loser. Returns\n * null for a field no health column claims. */\nexport function healthColumnFor(\n field: string,\n): { col: keyof SiteHealthTable; coerce: (v: unknown) => string | number | null } | null {\n const direct = HEALTH_FIELDS[field];\n if (direct) return { col: direct, coerce: HEALTH_NUMERIC.has(direct) ? n : s };\n const bool = HEALTH_BOOLEAN[field];\n if (bool) return { col: bool, coerce: b01n };\n return null;\n}\n\n/** The schedule twin of {@link healthColumnFor} (both columns store as text). */\nexport function scheduleColumnFor(\n field: string,\n): { col: keyof SiteScheduleTable; coerce: (v: unknown) => string | null } | null {\n const col = SCHEDULE_FIELDS[field];\n return col ? { col, coerce: s } : null;\n}\n\nexport type MappedWebsite = {\n site: Omit<\n SitesTable,\n \"header_image\" | \"header_image_filename\" | \"header_image_type\" | \"header_image_generated_at\"\n >;\n health: SiteHealthTable;\n schedule: SiteScheduleTable;\n};\n\n/** Map one raw Websites record into its three-table shape. Pure. */\nexport function mapWebsiteRecord(rec: RawRecord, computedAt: string): MappedWebsite {\n const f = rec.fields;\n const name = s(f[\"Name\"]);\n if (!name) throw new Error(`Websites ${rec.id}: blank Name — cannot derive a slug`);\n const slug = siteSlug(name);\n if (!slug) throw new Error(`Websites ${rec.id}: Name \"${name}\" yields an empty slug`);\n\n const site: MappedWebsite[\"site\"] = {\n id: rec.id,\n slug,\n name,\n url: null,\n status: null,\n point_of_contact: null,\n maintenance_freq: null,\n testing_freq: null,\n maintenance_day: null,\n testing_day: null,\n ga4_property_id: null,\n search_query: null,\n search_console_property: null,\n git_repo: null,\n netlify_id: null,\n report_recipients_to: null,\n report_recipients_cc: null,\n copy_intro: null,\n copy_contact: null,\n copy_footer: null,\n newsletter_webhook: null,\n mailchimp_api_key: null,\n mailchimp_audience_id: null,\n notify_routing: null,\n require_turnstile: 0,\n accepted_watch_conditions: null,\n prismic_ack_until: null,\n launched_at: null,\n legacy: null,\n };\n // Every mapped column goes through siteValueFor, so the non-text ones\n // (require_turnstile, accepted_watch_conditions) get the SAME coercion the\n // editor's write-through mirror applies. The literals above are placeholders\n // this loop overwrites; they exist only to satisfy the row type.\n for (const [field, col] of Object.entries(SITE_FIELDS)) {\n if (col === \"name\") continue; // handled above (validated)\n (site as unknown as Record<string, unknown>)[col] = siteValueFor(col, f[field]);\n }\n\n const health = { site_id: rec.id } as SiteHealthTable;\n // Through healthColumnFor — the SAME resolution+coercion the Phase 3 writer\n // mirrors use, so import and write-through cannot drift apart.\n for (const field of [...Object.keys(HEALTH_FIELDS), ...Object.keys(HEALTH_BOOLEAN)]) {\n const m = healthColumnFor(field);\n if (m) (health as unknown as Record<string, unknown>)[m.col] = m.coerce(f[field]);\n }\n\n const schedule: SiteScheduleTable = {\n site_id: rec.id,\n next_maintenance_at: s(f[\"Next maintenance at\"]),\n next_testing_at: s(f[\"Next testing at\"]),\n computed_at: computedAt,\n };\n\n // Everything populated that no table claims and no exclusion bans → legacy.\n const claimed = new Set<string>([\n \"Name\",\n ...Object.keys(SITE_FIELDS),\n ...Object.keys(HEALTH_FIELDS),\n ...Object.keys(HEALTH_BOOLEAN),\n ...Object.keys(SCHEDULE_FIELDS),\n ]);\n const legacy: Record<string, unknown> = {};\n for (const [field, value] of Object.entries(f)) {\n if (claimed.has(field) || EXCLUDED_WEBSITE_FIELDS.has(field)) continue;\n legacy[field] = value;\n }\n site.legacy = Object.keys(legacy).length > 0 ? JSON.stringify(legacy) : null;\n\n return { site, health, schedule };\n}\n\n/** Stable-key checklist mapping (Airtable column name → key). */\nconst CHECKLIST_BY_FIELD: ReadonlyArray<{ field: string; key: string }> = [\n ...MAINTENANCE_CHECKLIST,\n ...TESTING_CHECKLIST,\n].map((i) => ({ field: i.field, key: i.key }));\n\n/** Map one raw Reports record. `renderedHtml` is fetched by the caller (the\n * attachment URL is an expiring signed URL — see the design's Attachments\n * section); pass null when the record has no attachment or the fetch failed. */\nexport function mapReportRecord(rec: RawRecord, renderedHtml: string | null): ReportsTable {\n const f = rec.fields;\n const linkSites = (f[\"Site\"] as string[] | undefined) ?? [];\n const checklist: Record<string, boolean> = {};\n for (const { field, key } of CHECKLIST_BY_FIELD) checklist[key] = Boolean(f[field]);\n\n return {\n id: rec.id,\n site_id: linkSites[0] ?? null,\n report_id: s(f[\"Report ID\"]),\n report_type: s(f[\"Report type\"]),\n period: s(f[\"Period\"]),\n period_start: s(f[\"Period start\"]),\n period_end: s(f[\"Period end\"]),\n completed_on: s(f[\"Completed on\"]),\n lighthouse_performance: n(f[\"Lighthouse — Performance\"]),\n lighthouse_accessibility: n(f[\"Lighthouse — Accessibility\"]),\n lighthouse_best_practices: n(f[\"Lighthouse — Best Practices\"]),\n lighthouse_seo: n(f[\"Lighthouse — SEO\"]),\n ga_users_current: n(f[\"GA users (period)\"]),\n ga_users_previous: n(f[\"GA users (prev period)\"]),\n search_found_page1: b01n(f[\"Search found page 1\"]),\n search_position: n(f[\"Search position\"]),\n last_tested_date: s(f[\"Last tested date\"]),\n commentary: s(f[\"Commentary\"]),\n subject_override: s(f[\"Subject override\"]),\n draft_ready: b01(f[\"Draft ready\"]),\n approved_to_send: b01(f[\"Approved to send\"]),\n approved_at: s(f[\"Approved At\"]),\n approved_by: s(f[\"Approved By\"]),\n send_override: b01(f[\"Send override\"]),\n override_reason: s(f[\"Override reason\"]),\n override_by: s(f[\"Override by\"]),\n override_at: s(f[\"Override at\"]),\n sent_at: s(f[\"Sent at\"]),\n delivery_status: s(f[\"Delivery status\"]) ?? \"pending\",\n resend_message_id: s(f[\"Resend message ID\"]),\n checklist: JSON.stringify(checklist),\n // The Airtable cell is a long-text field, so the API hands us a STRING of\n // JSON — store it verbatim. json() here would double-encode it, and\n // parseAutoEvidence on the read side would then parse to a string and\n // yield null (evidence silently lost). The non-string branch only exists\n // for defensive completeness.\n checklist_auto_evidence:\n typeof f[\"Checklist auto-evidence\"] === \"string\"\n ? s(f[\"Checklist auto-evidence\"])\n : json(f[\"Checklist auto-evidence\"] ?? undefined),\n rendered_html: renderedHtml,\n };\n}\n\n/** First Rendered HTML attachment's signed URL, or null. Exposed so the CLI can\n * fetch it and tests can assert which URL would be fetched. */\nexport function renderedHtmlUrl(rec: RawRecord): string | null {\n const atts = rec.fields[\"Rendered HTML\"] as Array<{ url?: string }> | undefined;\n const url = atts?.[0]?.url;\n return typeof url === \"string\" && url.length > 0 ? url : null;\n}\n\n/** What the import removed because Airtable no longer has the record — and what\n * it declined to remove. `refusals` is non-empty only when the incoming read\n * looked untrustworthy; in that case NOTHING was deleted for that table. */\nexport type ReapSummary = {\n sites: string[];\n reports: string[];\n refusals: string[];\n};\n\n/** Reaping is the only destructive thing the importer does, and it acts on the\n * absence of evidence — so it is only ever as trustworthy as the read that\n * motivated it. A short read (a truncated page, a partial outage) is\n * indistinguishable from mass deletion by looking at the rows alone, so the\n * allowance is deliberately shaped like an operator tidying up rather than\n * like a bad page: at most 10% of what is stored, with a floor of 5 so a small\n * table stays workable. Over that, refuse the whole table and let parity red\n * the run — a wedged-red sync is recoverable, an emptied Turso is not. */\nconst REAP_FLOOR = 5;\nconst REAP_FRACTION = 0.1;\n\nexport function reapAllowance(stored: number): number {\n return Math.max(REAP_FLOOR, Math.ceil(stored * REAP_FRACTION));\n}\n\n/** Human lines plus the FLEET_REAP machine line, emitted on EVERY pass (nothing\n * reaped included) — an absent line must read as \"the reap never ran\", never as\n * \"it ran clean\", the same contract FLEET_PARITY and FLEET_SYNC hold. Shared by\n * `db sync` and the one-shot `db import-airtable`: both can delete, so a second\n * hand-rolled copy is how one of them ends up doing it quietly. */\nexport function formatReapSummary(reaped: ReapSummary): string[] {\n const lines: string[] = [];\n for (const id of reaped.sites) {\n lines.push(`✂ reaped sites ${id} (+ its health/schedule rows) — no longer in Airtable`);\n }\n for (const id of reaped.reports) {\n lines.push(`✂ reaped reports ${id} — no longer in Airtable`);\n }\n for (const reason of reaped.refusals) lines.push(`⚠ ${reason}`);\n lines.push(\n `FLEET_REAP sites=${reaped.sites.length} reports=${reaped.reports.length} ` +\n `refused=${reaped.refusals.length}`,\n );\n return lines;\n}\n\n/** Null when the reap may proceed, else the human reason it was refused. */\nexport function reapRefusal(\n table: string,\n incoming: number,\n stored: number,\n toReap: number,\n): string | null {\n if (incoming === 0 && stored > 0) {\n return `${table}: REFUSED to reap — Airtable returned 0 rows while ${stored} are stored; that is a failed read, not an empty fleet. Nothing deleted.`;\n }\n const allowed = reapAllowance(stored);\n if (toReap > allowed) {\n return `${table}: REFUSED to reap — ${toReap} rows are missing from Airtable, over the ${allowed}-row allowance for ${stored} stored. Nothing deleted; re-run, and if the deletions are real, remove them in smaller batches.`;\n }\n return null;\n}\n\nexport type ImportSummary = {\n sites: number;\n reports: number;\n /** Rows removed (or refused) because their Airtable record is gone. */\n reaped: ReapSummary;\n /** Reports whose Rendered HTML attachment could not be fetched (URL expired /\n * network) — imported with rendered_html null, named so the run is honest. */\n renderedHtmlMisses: string[];\n /** Attachment fetches attempted and succeeded this run. */\n renderedHtmlFetched: number;\n /** Fetches skipped because the stored row already carries rendered_html\n * (only in reportHtml: \"when-missing\" mode — the hourly sync's mode). */\n renderedHtmlSkipped: number;\n};\n\nexport type ImportOptions = {\n /** \"always\" (default): fetch every report's Rendered HTML attachment — the\n * one-shot full import's mode. \"when-missing\": fetch only for reports whose\n * stored row has no rendered_html yet — the hourly sync's mode, so 24 runs a\n * day don't re-download every attachment every hour. Safe for Phase 2:\n * nothing reads reports.rendered_html from Turso until the Phase 4 report\n * review lands; revisit the staleness story there (a re-rendered report's\n * stored HTML goes stale under \"when-missing\"). Parity is unaffected —\n * rendered_html is a parity SKIP_COLUMN. */\n reportHtml?: \"always\" | \"when-missing\";\n};\n\nexport type ImportIo = {\n listWebsiteRecords: () => Promise<RawRecord[]>;\n listReportRecords: () => Promise<RawRecord[]>;\n /** Fetch an attachment body; null on failure (the miss is reported, not fatal). */\n fetchAttachment: (url: string) => Promise<string | null>;\n now: () => Date;\n};\n\n/**\n * Import both tables, idempotently: upsert by rec id, so a re-run converges\n * instead of duplicating. On conflict the sites upsert deliberately does NOT\n * touch the header_image* columns — Airtable stopped being their source (D5),\n * so a re-import must never wipe a regenerated image. rendered_html is only\n * overwritten when the fetch succeeded, for the same reason: a re-run with an\n * expired signed URL must not erase a body captured while it was valid.\n */\nexport async function importFleetState(\n db: Db,\n io: ImportIo,\n opts: ImportOptions = {},\n): Promise<ImportSummary> {\n const computedAt = io.now().toISOString();\n const websites = await io.listWebsiteRecords();\n\n const mapped = websites.map((r) => mapWebsiteRecord(r, computedAt));\n const slugOwner = new Map<string, string>();\n for (const m of mapped) {\n const prior = slugOwner.get(m.site.slug);\n if (prior)\n throw new Error(`slug collision: ${prior} and ${m.site.id} both map to '${m.site.slug}'`);\n slugOwner.set(m.site.slug, m.site.id);\n }\n\n for (const { site, health, schedule } of mapped) {\n await db\n .insertInto(\"sites\")\n .values(site)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(site))\n .execute();\n await db\n .insertInto(\"site_health\")\n .values(health)\n .onConflict((oc) => oc.column(\"site_id\").doUpdateSet(health))\n .execute();\n await db\n .insertInto(\"site_schedule\")\n .values(schedule)\n .onConflict((oc) => oc.column(\"site_id\").doUpdateSet(schedule))\n .execute();\n }\n\n const reports = await io.listReportRecords();\n // \"when-missing\": one pre-read of which report rows already hold a body, so\n // the loop can skip their attachment fetches. A skipped fetch keeps the\n // stored body (the upsert falls into the rowSansHtml branch) and is counted,\n // not treated as a miss.\n const alreadyStored =\n opts.reportHtml === \"when-missing\"\n ? new Set(\n (\n await db\n .selectFrom(\"reports\")\n .select(\"id\")\n .where(\"rendered_html\", \"is not\", null)\n .execute()\n ).map((r) => r.id),\n )\n : null;\n const misses: string[] = [];\n let fetched = 0;\n let skipped = 0;\n for (const rec of reports) {\n const url = renderedHtmlUrl(rec);\n const skip = url !== null && alreadyStored !== null && alreadyStored.has(rec.id);\n if (skip) skipped++;\n const html = url && !skip ? await io.fetchAttachment(url) : null;\n if (url && !skip && html === null) misses.push(rec.id);\n if (html !== null) fetched++;\n const row = mapReportRecord(rec, html);\n const { rendered_html: _rh, ...rowSansHtml } = row;\n void _rh;\n await db\n .insertInto(\"reports\")\n .values(row)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(html !== null ? row : rowSansHtml))\n .execute();\n }\n\n const reaped: ReapSummary = { sites: [], reports: [], refusals: [] };\n\n // Sites first. No foreign keys are declared, so the health and schedule rows\n // must be removed explicitly — parity only reverse-checks `sites`, so an\n // orphan left here would linger forever, unnoticed and still readable.\n const liveSiteIds = new Set(mapped.map((m) => m.site.id));\n const storedSites = (await db.selectFrom(\"sites\").select(\"id\").execute()).map((r) =>\n String(r.id),\n );\n const staleSites = storedSites.filter((id) => !liveSiteIds.has(id)).sort();\n const siteRefusal = reapRefusal(\"sites\", websites.length, storedSites.length, staleSites.length);\n if (siteRefusal) reaped.refusals.push(siteRefusal);\n else if (staleSites.length > 0) {\n await db.deleteFrom(\"site_health\").where(\"site_id\", \"in\", staleSites).execute();\n await db.deleteFrom(\"site_schedule\").where(\"site_id\", \"in\", staleSites).execute();\n await db.deleteFrom(\"sites\").where(\"id\", \"in\", staleSites).execute();\n reaped.sites = staleSites;\n }\n\n const liveReportIds = new Set(reports.map((r) => r.id));\n const storedReports = (await db.selectFrom(\"reports\").select(\"id\").execute()).map((r) =>\n String(r.id),\n );\n const staleReports = storedReports.filter((id) => !liveReportIds.has(id)).sort();\n const reportRefusal = reapRefusal(\n \"reports\",\n reports.length,\n storedReports.length,\n staleReports.length,\n );\n if (reportRefusal) reaped.refusals.push(reportRefusal);\n else if (staleReports.length > 0) {\n await db.deleteFrom(\"reports\").where(\"id\", \"in\", staleReports).execute();\n reaped.reports = staleReports;\n }\n\n return {\n sites: mapped.length,\n reports: reports.length,\n renderedHtmlMisses: misses,\n renderedHtmlFetched: fetched,\n renderedHtmlSkipped: skipped,\n reaped,\n };\n}\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/util/git.ts"],"sourcesContent":["import { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport type { Site } from \"../types.js\";\n\nconst exec = promisify(execFile);\n\nasync function git(cwd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {\n return exec(\"git\", args, { cwd, env: process.env });\n}\n\nexport function branchName(recipe: string, when: Date = new Date()): string {\n // ISO with millisecond precision: 2026-05-20T10:30:00.123Z → 20260520T103000123Z.\n // Millis (vs. second-precision) shrinks the collision window for parallel runs.\n const compact = when.toISOString().replace(/[-:.]/g, \"\");\n return `maint/${recipe}-${compact}`;\n}\n\nexport async function currentBranch(cwd: string): Promise<string> {\n const { stdout } = await git(cwd, [\"rev-parse\", \"--abbrev-ref\", \"HEAD\"]);\n return stdout.trim();\n}\n\nexport async function isWorkingTreeClean(cwd: string): Promise<boolean> {\n const { stdout } = await git(cwd, [\"status\", \"--porcelain\"]);\n return stdout.trim().length === 0;\n}\n\nexport async function createBranch(cwd: string, name: string): Promise<void> {\n await git(cwd, [\"checkout\", \"-b\", name]);\n}\n\n/** Check out an existing branch. Throws (via git) if the branch is missing or\n * the checkout is blocked (e.g. uncommitted changes that would be overwritten). */\nexport async function checkoutBranch(cwd: string, name: string): Promise<void> {\n await git(cwd, [\"checkout\", name]);\n}\n\n/**\n * Force-check-out an existing branch, DISCARDING any uncommitted changes on the\n * current branch. Used by the recipe failure path to return the operator to\n * their original branch even when a recipe left the work-in-progress branch\n * dirty. Only ever called with the operator's ORIGINAL branch as `name`. Does\n * not run `git clean`, so untracked operator files are left untouched.\n */\nexport async function forceCheckoutBranch(cwd: string, name: string): Promise<void> {\n await git(cwd, [\"checkout\", \"-f\", name]);\n}\n\n/**\n * Delete a local branch with `-D` (force). Used by the recipe failure path to\n * remove the branch the recipe itself created so a re-run starts clean. Callers\n * MUST only ever pass the recipe-created branch here, never the operator's\n * original branch.\n */\nexport async function deleteBranch(cwd: string, name: string): Promise<void> {\n await git(cwd, [\"branch\", \"-D\", name]);\n}\n\nexport async function stageAll(cwd: string): Promise<void> {\n await git(cwd, [\"add\", \"-A\"]);\n}\n\nexport async function listTrackedFiles(cwd: string): Promise<string[]> {\n const { stdout } = await git(cwd, [\"ls-files\"]);\n return stdout\n .split(\"\\n\")\n .map((l) => l.trim())\n .filter((l) => l.length > 0);\n}\n\nexport async function removeFromIndex(cwd: string, paths: string[]): Promise<void> {\n if (paths.length === 0) return;\n await git(cwd, [\"rm\", \"-r\", \"--cached\", \"--\", ...paths]);\n}\n\n/**\n * The subset of `paths` that is NOT in HEAD's tree — what a fresh clone would\n * NOT get. POSITIVE evidence: a path counts as installed only when it is FOUND\n * in `git ls-tree`'s output. `git add -A` honours .gitignore and exits 0 either\n * way, so \"staging did not error\" proves nothing about what landed.\n *\n * `-z`, because `ls-tree` C-quotes any path outside core.quotePath's safe set\n * (`\"caf\\303\\251.txt\"`), which would read as missing. NOT `--full-tree`:\n * without it `ls-tree` reports paths relative to `cwd`, the same base the\n * caller's relative paths use.\n *\n * A git failure (no HEAD yet, not a repo) yields an EMPTY tree, so every path\n * comes back missing. An error may only ever DENY.\n */\nexport async function pathsMissingFromHead(\n cwd: string,\n paths: readonly string[],\n): Promise<string[]> {\n if (paths.length === 0) return [];\n let inTree: Set<string>;\n try {\n const { stdout } = await git(cwd, [\"ls-tree\", \"-r\", \"-z\", \"--name-only\", \"HEAD\"]);\n inTree = new Set(stdout.split(\"\\0\").filter((p) => p.length > 0));\n } catch {\n inTree = new Set();\n }\n return paths.filter((p) => !inTree.has(p));\n}\n\n/**\n * Why git is excluding `paths`: `<source>:<line>:<pattern>\\t<path>` rows from\n * `git check-ignore -v --no-index`. A path with no row is absent for some other\n * reason (a nested repository, say) and the caller must say so rather than\n * guess. Never throws: check-ignore exits 1 when NOTHING matches and 128 on\n * error, and both must read as \"no rule found\" — the caller is already\n * reporting a failure and must not lose it to a second one.\n */\nexport async function ignoreRulesFor(cwd: string, paths: readonly string[]): Promise<string[]> {\n if (paths.length === 0) return [];\n let out: string;\n try {\n ({ stdout: out } = await git(cwd, [\"check-ignore\", \"-v\", \"--no-index\", \"--\", ...paths]));\n } catch (err) {\n out = (err as { stdout?: string }).stdout ?? \"\";\n }\n return out.split(\"\\n\").filter((l) => l.length > 0);\n}\n\n/**\n * Stages all current changes and commits with `message`. Returns the commit SHA,\n * or `null` if there was nothing to commit.\n */\nexport async function commit(cwd: string, message: string): Promise<string | null> {\n await stageAll(cwd);\n const { stdout: status } = await git(cwd, [\"status\", \"--porcelain\"]);\n if (status.trim().length === 0) return null;\n await git(cwd, [\"commit\", \"-m\", message]);\n const { stdout: sha } = await git(cwd, [\"rev-parse\", \"HEAD\"]);\n return sha.trim();\n}\n\n/**\n * Strict GitHub repo identity: exactly two `[A-Za-z0-9._-]` segments separated\n * by a single slash. Rejects a scheme, host, extra path segment, traversal\n * (`..`), whitespace, or an argv flag — anything that could retarget a `gh`\n * write at an unintended (attacker/typo-controlled) repo.\n */\nexport const OWNER_REPO_RE = /^[A-Za-z0-9._-]+\\/[A-Za-z0-9._-]+$/;\n\n/** True when `repo` is a clean `owner/repo` (see {@link OWNER_REPO_RE}). The\n * explicit `..` reject covers traversal segments the char-class would otherwise\n * admit (`.` is a legal repo char, so `../evil` matches the shape regex). */\nexport function isOwnerRepo(repo: string): boolean {\n if (repo.includes(\"..\")) return false;\n return OWNER_REPO_RE.test(repo);\n}\n\n/**\n * True when two repo references name the same `owner/repo`. Each side may be a\n * full remote URL (https or scp-style), or already an `owner/repo`. Used to\n * verify an existing checkout's `origin` matches the site's expected repo\n * before reusing it. Returns false if either side is unparseable.\n */\nexport function sameOwnerRepo(a: string, b: string): boolean {\n const na = isOwnerRepo(a.trim()) ? a.trim() : parseOwnerRepo(a);\n const nb = isOwnerRepo(b.trim()) ? b.trim() : parseOwnerRepo(b);\n if (na === null || nb === null) return false;\n return na.toLowerCase() === nb.toLowerCase();\n}\n\n/** Derive `owner/repo` from a git remote URL (https or scp-style). Null if unparseable. */\nexport function parseOwnerRepo(remoteUrl: string): string | null {\n const trimmed = remoteUrl\n .trim()\n .replace(/\\.git$/, \"\")\n .replace(/\\/$/, \"\");\n // scp-style: git@github.com:owner/repo\n const scp = trimmed.match(/^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:(.+)$/);\n const path = scp ? scp[1]! : trimmed.replace(/^https?:\\/\\/[^/]+\\//, \"\");\n const segments = path.split(\"/\").filter(Boolean);\n if (segments.length < 2) return null;\n return `${segments[segments.length - 2]}/${segments[segments.length - 1]}`;\n}\n\n/** `origin` remote URL for a checkout, trimmed. Throws (via git) if there's no origin. */\nexport async function getRemoteUrl(cwd: string): Promise<string> {\n const { stdout } = await git(cwd, [\"remote\", \"get-url\", \"origin\"]);\n return stdout.trim();\n}\n\n/**\n * Resolve the `owner/repo` a recipe will act on. An explicit `site.gitRepo`\n * (Airtable, or a JSON inventory) wins; otherwise derive it from the checkout's\n * `origin`.\n *\n * Returns `null` when there is nothing to act on (no `gitRepo`, no origin) — a\n * benign \"nothing wired\" state. THROWS when a value IS present but does not\n * match the strict `owner/repo` shape: callers write repo secrets, branch\n * protection and pull requests at this identity, so a typo'd or\n * attacker-controlled value must be rejected here, before the first `gh` call.\n *\n * Extracted from `self-updating`'s private `resolveRepo` because `prismic-ci`\n * read `site.gitRepo` directly and therefore refused every POSITIONAL run with\n * \"no Git repo on this site\" — `localPath()` (src/inventory/local.ts:11) builds\n * `{ path, name }` and nothing else. A site being bootstrapped is exactly the\n * case that has no Airtable row yet (`--fleet airtable` filters pre-launch\n * statuses), so the positional path is the ONLY one `/new-site` can use.\n */\nexport async function resolveOwnerRepo(site: Site): Promise<string | null> {\n if (site.gitRepo) {\n if (!isOwnerRepo(site.gitRepo)) {\n throw new Error(\n `refusing to act on malformed repo identity: expected \"owner/repo\", got ${JSON.stringify(site.gitRepo)}`,\n );\n }\n return site.gitRepo;\n }\n let fromOrigin: string | null;\n try {\n fromOrigin = parseOwnerRepo(await getRemoteUrl(site.path));\n } catch {\n return null;\n }\n if (fromOrigin === null) return null;\n if (!isOwnerRepo(fromOrigin)) {\n throw new Error(\n `refusing to act on malformed repo identity from origin: ${JSON.stringify(fromOrigin)}`,\n );\n }\n return fromOrigin;\n}\n\n/** Push a branch to origin, setting upstream. Throws on non-zero (execFile rejects). */\nexport async function push(cwd: string, branch: string): Promise<void> {\n await git(cwd, [\"push\", \"-u\", \"origin\", branch]);\n}\n"],"mappings":"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","names":[]}