@reddoorla/maintenance 0.96.0 → 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 (237) hide show
  1. package/README.md +5 -6
  2. package/dist/{announce-45HVP4J4.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-RUFMNOUJ.js → blux-NXQLMS4W.js} +5 -5
  6. package/dist/{bump-deps-EL3TKMIL.js → bump-deps-775VLDYB.js} +6 -6
  7. package/dist/{chunk-IMYL67JV.js → chunk-3RLDX3Q2.js} +5 -5
  8. package/dist/{chunk-CA6ADGOC.js → chunk-6PGXKN6F.js} +2 -2
  9. package/dist/{chunk-Y5BVR4HW.js → chunk-6THEZQLA.js} +3 -3
  10. package/dist/chunk-ASLDCWBF.js +41 -0
  11. package/dist/chunk-ASLDCWBF.js.map +1 -0
  12. package/dist/{chunk-DVJNYPFR.js → chunk-AYDQQBNG.js} +2 -2
  13. package/dist/{chunk-GEUC5VGM.js → chunk-BINFZQ2T.js} +49 -4
  14. package/dist/chunk-BINFZQ2T.js.map +1 -0
  15. package/dist/{chunk-72FJZ6LO.js → chunk-DRNZVORX.js} +26 -22
  16. package/dist/chunk-DRNZVORX.js.map +1 -0
  17. package/dist/{chunk-SHGIDTYF.js → chunk-DYTYPKFN.js} +2 -2
  18. package/dist/{chunk-NVLREB5R.js → chunk-EN6CTBUN.js} +7 -7
  19. package/dist/{chunk-ZH5LOQET.js → chunk-FHFXJ3EM.js} +7 -5
  20. package/dist/chunk-FHFXJ3EM.js.map +1 -0
  21. package/dist/{chunk-H445EH33.js → chunk-FP6HOW6J.js} +10 -7
  22. package/dist/chunk-FP6HOW6J.js.map +1 -0
  23. package/dist/{chunk-RRE74VWI.js → chunk-HSMN35LU.js} +2 -2
  24. package/dist/chunk-ICSRWW5Q.js +148 -0
  25. package/dist/chunk-ICSRWW5Q.js.map +1 -0
  26. package/dist/{chunk-XG2A6GIG.js → chunk-IDBPII6G.js} +4 -4
  27. package/dist/{chunk-XG2A6GIG.js.map → chunk-IDBPII6G.js.map} +1 -1
  28. package/dist/{chunk-T76MA2FH.js → chunk-IOTOOTII.js} +12 -1
  29. package/dist/{chunk-T76MA2FH.js.map → chunk-IOTOOTII.js.map} +1 -1
  30. package/dist/{chunk-ECDJDHKT.js → chunk-JHJNNJLG.js} +2 -2
  31. package/dist/{chunk-XY7YV2YQ.js → chunk-L3S3QZXL.js} +2 -2
  32. package/dist/{chunk-XED2DNAI.js → chunk-LAG6IP7Y.js} +7 -7
  33. package/dist/{chunk-P4A6D6HO.js → chunk-LDHRIQUW.js} +32 -18
  34. package/dist/chunk-LDHRIQUW.js.map +1 -0
  35. package/dist/{chunk-UIITN37J.js → chunk-LF2TJN4X.js} +2 -2
  36. package/dist/chunk-LF2TJN4X.js.map +1 -0
  37. package/dist/{chunk-SQ4FSNYL.js → chunk-LUF52EIY.js} +2 -2
  38. package/dist/{chunk-MZZDMBDT.js → chunk-LZMAG73U.js} +19 -19
  39. package/dist/chunk-LZMAG73U.js.map +1 -0
  40. package/dist/{chunk-6INOBNRO.js → chunk-MMSMZC7S.js} +2 -2
  41. package/dist/{chunk-CGQZFQAY.js → chunk-MVKNU4QD.js} +2 -3
  42. package/dist/chunk-MVKNU4QD.js.map +1 -0
  43. package/dist/{chunk-62YWOFVN.js → chunk-PARKXDXI.js} +3 -3
  44. package/dist/{chunk-62YWOFVN.js.map → chunk-PARKXDXI.js.map} +1 -1
  45. package/dist/{chunk-44SDGPGQ.js → chunk-PZZ6HM5T.js} +3 -3
  46. package/dist/chunk-RQJFGK3A.js +51 -0
  47. package/dist/chunk-RQJFGK3A.js.map +1 -0
  48. package/dist/{chunk-KN52PM63.js → chunk-TC4GANMX.js} +47 -149
  49. package/dist/chunk-TC4GANMX.js.map +1 -0
  50. package/dist/{chunk-F3JWOPTG.js → chunk-TEMQPSCM.js} +45 -105
  51. package/dist/chunk-TEMQPSCM.js.map +1 -0
  52. package/dist/{chunk-4UTJ4G6P.js → chunk-TJLKWQOX.js} +2 -2
  53. package/dist/{chunk-VEIIUCYL.js → chunk-TV76A6GN.js} +2 -2
  54. package/dist/{chunk-D3CTOCUR.js → chunk-TVZ6QCOE.js} +3 -3
  55. package/dist/{chunk-3EA43S3R.js → chunk-U2ZI6G7H.js} +12 -12
  56. package/dist/chunk-UPE6D4AT.js +69 -0
  57. package/dist/chunk-UPE6D4AT.js.map +1 -0
  58. package/dist/{chunk-OTEVKRAH.js → chunk-VMGHQUTV.js} +7 -16
  59. package/dist/chunk-VMGHQUTV.js.map +1 -0
  60. package/dist/chunk-VQMAAQ5M.js +53 -0
  61. package/dist/chunk-VQMAAQ5M.js.map +1 -0
  62. package/dist/{chunk-EASYCD6F.js → chunk-WMF5S2MB.js} +1 -1
  63. package/dist/{chunk-EASYCD6F.js.map → chunk-WMF5S2MB.js.map} +1 -1
  64. package/dist/{chunk-37B4LVB3.js → chunk-X56LAKPR.js} +3 -3
  65. package/dist/chunk-X56LAKPR.js.map +1 -0
  66. package/dist/{chunk-IY6256GK.js → chunk-YJBSB4LP.js} +6 -1
  67. package/dist/chunk-YJBSB4LP.js.map +1 -0
  68. package/dist/{chunk-EQQ4H6L3.js → chunk-YXWBM26P.js} +2 -2
  69. package/dist/cli/bin.js +92 -50
  70. package/dist/cli/bin.js.map +1 -1
  71. package/dist/cli/commands/audit.d.ts +33 -3
  72. package/dist/cli/commands/audit.js +25 -22
  73. package/dist/cli/commands/audit.js.map +1 -1
  74. package/dist/client-DGTO65WB.js +11 -0
  75. package/dist/{convert-to-pnpm-4DON7IFS.js → convert-to-pnpm-KLWA53N5.js} +6 -6
  76. package/dist/{db-5K4CDEGJ.js → db-II5WAK4D.js} +13 -13
  77. package/dist/{digest-5AJGWS33.js → digest-CWKK2DMR.js} +28 -27
  78. package/dist/digest-CWKK2DMR.js.map +1 -0
  79. package/dist/{digest-collectors-MC5NWEUA.js → digest-collectors-CYTN2EUX.js} +15 -12
  80. package/dist/ensure-site-2QZHFVQ3.js +70 -0
  81. package/dist/ensure-site-2QZHFVQ3.js.map +1 -0
  82. package/dist/ensure-site-JEKV2ODE.js +105 -0
  83. package/dist/ensure-site-JEKV2ODE.js.map +1 -0
  84. package/dist/{ensure-site-XESZHZEB.js → ensure-site-OO4UPWNQ.js} +19 -42
  85. package/dist/ensure-site-OO4UPWNQ.js.map +1 -0
  86. package/dist/fleet-events-CkOZpMVC.d.ts +435 -0
  87. package/dist/{fleet-state-GJWGKTLD.js → fleet-state-RJROYXKO.js} +17 -7
  88. package/dist/{forms-notify-target-4NPLX2UG.js → forms-notify-target-X6UEOI2M.js} +17 -13
  89. package/dist/forms-notify-target-X6UEOI2M.js.map +1 -0
  90. package/dist/{github-signals-DMU74SAX.js → github-signals-QIRTHHET.js} +20 -15
  91. package/dist/github-signals-QIRTHHET.js.map +1 -0
  92. package/dist/{header-image-BRTOLHJF.js → header-image-H7ZJAV26.js} +13 -17
  93. package/dist/header-image-H7ZJAV26.js.map +1 -0
  94. package/dist/{health-endpoint-UCLMSSCN.js → health-endpoint-BJ6BKRHF.js} +7 -7
  95. package/dist/{health-mirror-ONRT4DQ5.js → health-mirror-KU3G7V2Y.js} +10 -10
  96. package/dist/{import-airtable-IIK2QUUF.js → import-airtable-UBF5GYAL.js} +3 -3
  97. package/dist/index.d.ts +102 -494
  98. package/dist/index.js +60 -49
  99. package/dist/index.js.map +1 -1
  100. package/dist/{init-3LMZTCVD.js → init-GOWDA5QS.js} +15 -15
  101. package/dist/{launch-XTDMC6LD.js → launch-7N3X2MXB.js} +36 -34
  102. package/dist/launch-7N3X2MXB.js.map +1 -0
  103. package/dist/lighthouse-airtable-M45OPVRV.js +14 -0
  104. package/dist/{chunk-IHHAYXKY.js → match-harness-GBCDSVPS.js} +70 -4
  105. package/dist/match-harness-GBCDSVPS.js.map +1 -0
  106. package/dist/migrate-XZ7U5DFC.js +7 -0
  107. package/dist/notify-K3WVH3DM.js +1 -1
  108. package/dist/{onboard-CZYBWVWH.js → onboard-JQQIMQW7.js} +6 -6
  109. package/dist/{orchestrate-TGDFGY52.js → orchestrate-L2A5BZFY.js} +13 -10
  110. package/dist/parity-2JGVUCZD.js +14 -0
  111. package/dist/{preflight-POZZI3GN.js → preflight-O7Q6FWHU.js} +35 -11
  112. package/dist/preflight-O7Q6FWHU.js.map +1 -0
  113. package/dist/{prismic-ci-AHY5ULJR.js → prismic-ci-NAPWOXXD.js} +11 -11
  114. package/dist/prismic-ci-NAPWOXXD.js.map +1 -0
  115. package/dist/{prismic-models-URYGODNY.js → prismic-models-LL6ORTQ2.js} +19 -16
  116. package/dist/prismic-models-LL6ORTQ2.js.map +1 -0
  117. package/dist/{prospect-audit-AJEE3Z4L.js → prospect-audit-FMPGQIRQ.js} +2 -2
  118. package/dist/{protection-audit-3J3UEOVT.js → protection-audit-YXJKFU6O.js} +3 -3
  119. package/dist/{protection-audit-3J3UEOVT.js.map → protection-audit-YXJKFU6O.js.map} +1 -1
  120. package/dist/recipes/sync-configs.js +3 -3
  121. package/dist/{render-from-row-65DC6ZP3.js → render-from-row-75JTED2Z.js} +6 -4
  122. package/dist/{renovate-dispatch-ONJMBUHJ.js → renovate-dispatch-2IHAZDVA.js} +13 -8
  123. package/dist/{renovate-dispatch-ONJMBUHJ.js.map → renovate-dispatch-2IHAZDVA.js.map} +1 -1
  124. package/dist/{report-RC5LGPU3.js → report-IAHXN5CW.js} +102 -58
  125. package/dist/report-IAHXN5CW.js.map +1 -0
  126. package/dist/{report-mirror-KKZZ6RMZ.js → report-mirror-4QM57LMS.js} +28 -10
  127. package/dist/report-mirror-4QM57LMS.js.map +1 -0
  128. package/dist/roster-GDNSIRAK.js +19 -0
  129. package/dist/roster-GDNSIRAK.js.map +1 -0
  130. package/dist/{self-updating-RKKIYNV4.js → self-updating-ZUWPGYUP.js} +7 -7
  131. package/dist/{selftest-AFLR7YV7.js → selftest-G4W3LIBQ.js} +21 -17
  132. package/dist/{selftest-AFLR7YV7.js.map → selftest-G4W3LIBQ.js.map} +1 -1
  133. package/dist/site-create-ZU3ACBTO.js +45 -0
  134. package/dist/site-create-ZU3ACBTO.js.map +1 -0
  135. package/dist/{site-mirror-H2FYMN55.js → site-mirror-WR7ACXOV.js} +10 -10
  136. package/dist/site-mirror-WR7ACXOV.js.map +1 -0
  137. package/dist/{smoke-suite-BWHR3XX5.js → smoke-suite-WAUPI6EJ.js} +7 -7
  138. package/dist/{submissions-II5J35QM.js → submissions-2SHCFOKD.js} +11 -6
  139. package/dist/submissions-2SHCFOKD.js.map +1 -0
  140. package/dist/{svelte-codemods-QWGIRNN6.js → svelte-codemods-VOLOKKFI.js} +6 -6
  141. package/dist/{sync-NKPDCMYW.js → sync-F4USL4K5.js} +6 -6
  142. package/dist/{sync-configs-HGLQV55H.js → sync-configs-OXSWKVZ2.js} +6 -6
  143. package/dist/turso-KQKBBQCK.js +24 -0
  144. package/dist/turso-KQKBBQCK.js.map +1 -0
  145. package/dist/{upgrade-WZ44IARG.js → upgrade-EVAL3JTO.js} +6 -6
  146. package/dist/util/git.d.ts +1 -1
  147. package/dist/util/git.js +1 -1
  148. package/dist/{websites-6DHIHJ2D.js → websites-Y6FN2XFD.js} +26 -21
  149. package/dist/{write-audits-to-airtable-BIK6LNVC.js → write-audits-to-airtable-TI3JMWSY.js} +6 -4
  150. package/package.json +1 -1
  151. package/dist/airtable-SJV4ZSMS.js +0 -9
  152. package/dist/announce-45HVP4J4.js.map +0 -1
  153. package/dist/attachments-NLZIJ5NU.js +0 -9
  154. package/dist/chunk-37B4LVB3.js.map +0 -1
  155. package/dist/chunk-72FJZ6LO.js.map +0 -1
  156. package/dist/chunk-74QRWE6G.js +0 -53
  157. package/dist/chunk-74QRWE6G.js.map +0 -1
  158. package/dist/chunk-CGQZFQAY.js.map +0 -1
  159. package/dist/chunk-F3JWOPTG.js.map +0 -1
  160. package/dist/chunk-GEUC5VGM.js.map +0 -1
  161. package/dist/chunk-H445EH33.js.map +0 -1
  162. package/dist/chunk-IHHAYXKY.js.map +0 -1
  163. package/dist/chunk-IY6256GK.js.map +0 -1
  164. package/dist/chunk-KN52PM63.js.map +0 -1
  165. package/dist/chunk-MZZDMBDT.js.map +0 -1
  166. package/dist/chunk-OTEVKRAH.js.map +0 -1
  167. package/dist/chunk-P4A6D6HO.js.map +0 -1
  168. package/dist/chunk-UIITN37J.js.map +0 -1
  169. package/dist/chunk-YFAXOD7Z.js +0 -42
  170. package/dist/chunk-YFAXOD7Z.js.map +0 -1
  171. package/dist/chunk-ZH5LOQET.js.map +0 -1
  172. package/dist/client-L7NAUG6J.js +0 -11
  173. package/dist/digest-5AJGWS33.js.map +0 -1
  174. package/dist/ensure-site-XESZHZEB.js.map +0 -1
  175. package/dist/fleet-events-cF2Om88w.d.ts +0 -24
  176. package/dist/forms-notify-target-4NPLX2UG.js.map +0 -1
  177. package/dist/github-signals-DMU74SAX.js.map +0 -1
  178. package/dist/header-image-BRTOLHJF.js.map +0 -1
  179. package/dist/launch-XTDMC6LD.js.map +0 -1
  180. package/dist/lighthouse-airtable-3NOFB42B.js +0 -12
  181. package/dist/match-harness-OW45CB5H.js +0 -77
  182. package/dist/match-harness-OW45CB5H.js.map +0 -1
  183. package/dist/migrate-NE3DUMTM.js +0 -7
  184. package/dist/parity-AEKYSLN6.js +0 -14
  185. package/dist/preflight-POZZI3GN.js.map +0 -1
  186. package/dist/prismic-ci-AHY5ULJR.js.map +0 -1
  187. package/dist/prismic-models-URYGODNY.js.map +0 -1
  188. package/dist/report-RC5LGPU3.js.map +0 -1
  189. package/dist/report-mirror-KKZZ6RMZ.js.map +0 -1
  190. package/dist/site-mirror-H2FYMN55.js.map +0 -1
  191. package/dist/submissions-II5J35QM.js.map +0 -1
  192. package/dist/write-audits-to-airtable-BIK6LNVC.js.map +0 -1
  193. /package/dist/{airtable-SJV4ZSMS.js.map → attachments-JFGFQRXQ.js.map} +0 -0
  194. /package/dist/{blux-RUFMNOUJ.js.map → blux-NXQLMS4W.js.map} +0 -0
  195. /package/dist/{bump-deps-EL3TKMIL.js.map → bump-deps-775VLDYB.js.map} +0 -0
  196. /package/dist/{chunk-IMYL67JV.js.map → chunk-3RLDX3Q2.js.map} +0 -0
  197. /package/dist/{chunk-CA6ADGOC.js.map → chunk-6PGXKN6F.js.map} +0 -0
  198. /package/dist/{chunk-Y5BVR4HW.js.map → chunk-6THEZQLA.js.map} +0 -0
  199. /package/dist/{chunk-DVJNYPFR.js.map → chunk-AYDQQBNG.js.map} +0 -0
  200. /package/dist/{chunk-SHGIDTYF.js.map → chunk-DYTYPKFN.js.map} +0 -0
  201. /package/dist/{chunk-NVLREB5R.js.map → chunk-EN6CTBUN.js.map} +0 -0
  202. /package/dist/{chunk-RRE74VWI.js.map → chunk-HSMN35LU.js.map} +0 -0
  203. /package/dist/{chunk-ECDJDHKT.js.map → chunk-JHJNNJLG.js.map} +0 -0
  204. /package/dist/{chunk-XY7YV2YQ.js.map → chunk-L3S3QZXL.js.map} +0 -0
  205. /package/dist/{chunk-XED2DNAI.js.map → chunk-LAG6IP7Y.js.map} +0 -0
  206. /package/dist/{chunk-SQ4FSNYL.js.map → chunk-LUF52EIY.js.map} +0 -0
  207. /package/dist/{chunk-6INOBNRO.js.map → chunk-MMSMZC7S.js.map} +0 -0
  208. /package/dist/{chunk-44SDGPGQ.js.map → chunk-PZZ6HM5T.js.map} +0 -0
  209. /package/dist/{chunk-4UTJ4G6P.js.map → chunk-TJLKWQOX.js.map} +0 -0
  210. /package/dist/{chunk-VEIIUCYL.js.map → chunk-TV76A6GN.js.map} +0 -0
  211. /package/dist/{chunk-D3CTOCUR.js.map → chunk-TVZ6QCOE.js.map} +0 -0
  212. /package/dist/{chunk-3EA43S3R.js.map → chunk-U2ZI6G7H.js.map} +0 -0
  213. /package/dist/{chunk-EQQ4H6L3.js.map → chunk-YXWBM26P.js.map} +0 -0
  214. /package/dist/{attachments-NLZIJ5NU.js.map → client-DGTO65WB.js.map} +0 -0
  215. /package/dist/{convert-to-pnpm-4DON7IFS.js.map → convert-to-pnpm-KLWA53N5.js.map} +0 -0
  216. /package/dist/{db-5K4CDEGJ.js.map → db-II5WAK4D.js.map} +0 -0
  217. /package/dist/{client-L7NAUG6J.js.map → digest-collectors-CYTN2EUX.js.map} +0 -0
  218. /package/dist/{digest-collectors-MC5NWEUA.js.map → fleet-state-RJROYXKO.js.map} +0 -0
  219. /package/dist/{health-endpoint-UCLMSSCN.js.map → health-endpoint-BJ6BKRHF.js.map} +0 -0
  220. /package/dist/{health-mirror-ONRT4DQ5.js.map → health-mirror-KU3G7V2Y.js.map} +0 -0
  221. /package/dist/{fleet-state-GJWGKTLD.js.map → import-airtable-UBF5GYAL.js.map} +0 -0
  222. /package/dist/{init-3LMZTCVD.js.map → init-GOWDA5QS.js.map} +0 -0
  223. /package/dist/{import-airtable-IIK2QUUF.js.map → lighthouse-airtable-M45OPVRV.js.map} +0 -0
  224. /package/dist/{lighthouse-airtable-3NOFB42B.js.map → migrate-XZ7U5DFC.js.map} +0 -0
  225. /package/dist/{onboard-CZYBWVWH.js.map → onboard-JQQIMQW7.js.map} +0 -0
  226. /package/dist/{migrate-NE3DUMTM.js.map → orchestrate-L2A5BZFY.js.map} +0 -0
  227. /package/dist/{orchestrate-TGDFGY52.js.map → parity-2JGVUCZD.js.map} +0 -0
  228. /package/dist/{prospect-audit-AJEE3Z4L.js.map → prospect-audit-FMPGQIRQ.js.map} +0 -0
  229. /package/dist/{parity-AEKYSLN6.js.map → render-from-row-75JTED2Z.js.map} +0 -0
  230. /package/dist/{self-updating-RKKIYNV4.js.map → self-updating-ZUWPGYUP.js.map} +0 -0
  231. /package/dist/{smoke-suite-BWHR3XX5.js.map → smoke-suite-WAUPI6EJ.js.map} +0 -0
  232. /package/dist/{svelte-codemods-QWGIRNN6.js.map → svelte-codemods-VOLOKKFI.js.map} +0 -0
  233. /package/dist/{sync-NKPDCMYW.js.map → sync-F4USL4K5.js.map} +0 -0
  234. /package/dist/{sync-configs-HGLQV55H.js.map → sync-configs-OXSWKVZ2.js.map} +0 -0
  235. /package/dist/{upgrade-WZ44IARG.js.map → upgrade-EVAL3JTO.js.map} +0 -0
  236. /package/dist/{render-from-row-65DC6ZP3.js.map → websites-Y6FN2XFD.js.map} +0 -0
  237. /package/dist/{websites-6DHIHJ2D.js.map → write-audits-to-airtable-TI3JMWSY.js.map} +0 -0
@@ -0,0 +1 @@
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-back`.\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"]}
@@ -9,6 +9,9 @@ import {
9
9
  // src/cli/fleet/resolve-sites.ts
10
10
  import { pathToFileURL } from "url";
11
11
  import { resolve, extname } from "path";
12
+ var FLEET_KEYWORD = "turso";
13
+ var DEPRECATED_FLEET_KEYWORD = "airtable";
14
+ var DEPRECATED_FLEET_KEYWORD_WARNING = "[fleet] --fleet airtable is deprecated: the fleet roster is read from Turso, not Airtable (#646 step 4). Use --fleet turso.";
12
15
  function sanitizeDynamicSites(sites) {
13
16
  return sites.map((s) => {
14
17
  if (s.deployedUrl !== void 0 && !isHttpUrl(s.deployedUrl)) {
@@ -28,12 +31,12 @@ async function resolveSites(input) {
28
31
  exitCode: 2
29
32
  });
30
33
  }
31
- if (input.fleet === "airtable") {
32
- const { openBase, readAirtableConfig } = await import("./client-EPVO3FEH.js");
33
- const { fromAirtableBase } = await import("./airtable-SJV4ZSMS.js");
34
- const base = openBase(readAirtableConfig());
35
- const provider = fromAirtableBase(base, input.workdir ? { workdir: input.workdir } : {});
36
- return provider();
34
+ if (input.fleet === FLEET_KEYWORD || input.fleet === DEPRECATED_FLEET_KEYWORD) {
35
+ if (input.fleet === DEPRECATED_FLEET_KEYWORD) console.warn(DEPRECATED_FLEET_KEYWORD_WARNING);
36
+ const { openDb, readDbConfig } = await import("./client-DGTO65WB.js");
37
+ const { fromTursoDb } = await import("./turso-KQKBBQCK.js");
38
+ const open = input.openDb ?? (() => openDb(readDbConfig()));
39
+ return fromTursoDb(open, input.workdir ? { workdir: input.workdir } : {})();
37
40
  }
38
41
  if (input.fleet) {
39
42
  const fleetPath = resolve(input.cwd, input.fleet);
@@ -61,4 +64,4 @@ async function resolveSites(input) {
61
64
  export {
62
65
  resolveSites
63
66
  };
64
- //# sourceMappingURL=chunk-H445EH33.js.map
67
+ //# sourceMappingURL=chunk-FP6HOW6J.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli/fleet/resolve-sites.ts"],"sourcesContent":["import { pathToFileURL } from \"node:url\";\nimport { resolve, extname } from \"node:path\";\nimport type { InventoryProvider, Site } from \"../../types.js\";\nimport { localPath } from \"../../inventory/local.js\";\nimport { fromJsonFile } from \"../../inventory/json.js\";\nimport { isHttpUrl } from \"../../util/url.js\";\nimport type { Db } from \"../../db/client.js\";\n\n/** The `--fleet` keyword that reads the fleet roster from the database (#646 step 4). */\nexport const FLEET_KEYWORD = \"turso\";\n\n/**\n * The keyword every sweep used until #646 step 4. It is kept as an ALIAS that now\n * reads Turso, not as a path that still reads Airtable, because:\n *\n * - Airtable can no longer answer the question. A site made by the Turso-native\n * `ensure-site` (step 3) has a `site_<ULID>` id and no Airtable record, so an\n * Airtable roster is silently short — the invisibility step 4 exists to close.\n * - The keyword is typed by hand from the docs, the runbooks, AUTONOMY.md and\n * shell history. Refusing it would break those invocations; keeping it on\n * Airtable would keep them wrong and keep Airtable load-bearing for reads.\n *\n * It is not SILENT: every use prints DEPRECATED_FLEET_KEYWORD_WARNING to stderr,\n * naming the store actually read, so a misleading name never reads a different\n * store without saying so. Remove it with the Airtable layer (steps 6–8).\n */\nexport const DEPRECATED_FLEET_KEYWORD = \"airtable\";\n\nexport const DEPRECATED_FLEET_KEYWORD_WARNING =\n \"[fleet] --fleet airtable is deprecated: the fleet roster is read from Turso, not Airtable \" +\n \"(#646 step 4). Use --fleet turso.\";\n\n/** A dynamic .js/.mjs/.cjs inventory returns arbitrary Site objects; apply the\n * same `deployedUrl` scheme-allowlist the JSON + Airtable providers enforce, so\n * a module returning `file:///…`/`gopher://…` can't reach Chrome/lhci. Drop the\n * offending value (and warn) rather than throw — matching the JSON provider. */\nexport function sanitizeDynamicSites(sites: Site[]): Site[] {\n return sites.map((s) => {\n if (s.deployedUrl !== undefined && !isHttpUrl(s.deployedUrl)) {\n console.warn(\n `[inventory] dynamic inventory: ignoring deployedUrl that is not http(s) for ` +\n `${s.name ?? s.path}: ${JSON.stringify(s.deployedUrl)}`,\n );\n const copy = { ...s };\n delete copy.deployedUrl;\n return copy;\n }\n return s;\n });\n}\n\nexport type ResolveSitesInput = {\n site?: string;\n fleet?: string;\n /** Optional workdir for the `--fleet turso` keyword path (computes site.path as {workdir}/{slug}). */\n workdir?: string;\n /** Test seam for the `--fleet turso` keyword: the database the roster is read\n * from. Defaults to `TURSO_DATABASE_URL`/`TURSO_AUTH_TOKEN`. */\n openDb?: () => Promise<Db>;\n cwd: string;\n};\n\nexport async function resolveSites(input: ResolveSitesInput): Promise<Site[]> {\n if (input.site && input.fleet) {\n throw Object.assign(new Error(\"cannot combine a positional [site] with --fleet\"), {\n exitCode: 2,\n });\n }\n\n if (input.fleet === FLEET_KEYWORD || input.fleet === DEPRECATED_FLEET_KEYWORD) {\n if (input.fleet === DEPRECATED_FLEET_KEYWORD) console.warn(DEPRECATED_FLEET_KEYWORD_WARNING);\n const { openDb, readDbConfig } = await import(\"../../db/client.js\");\n const { fromTursoDb } = await import(\"../../inventory/turso.js\");\n const open = input.openDb ?? (() => openDb(readDbConfig()));\n return fromTursoDb(open, input.workdir ? { workdir: input.workdir } : {})();\n }\n\n if (input.fleet) {\n const fleetPath = resolve(input.cwd, input.fleet);\n const ext = extname(fleetPath).toLowerCase();\n if (ext === \".json\") {\n // fromJsonFile already scheme-allowlists deployedUrl internally.\n return fromJsonFile(fleetPath)();\n }\n if (ext === \".js\" || ext === \".mjs\" || ext === \".cjs\") {\n const mod = (await import(pathToFileURL(fleetPath).href)) as {\n default?: InventoryProvider;\n };\n if (!mod.default || typeof mod.default !== \"function\") {\n throw Object.assign(new Error(`--fleet ${input.fleet}: default export is not a function`), {\n exitCode: 2,\n });\n }\n // A dynamic module's deployedUrl is unvalidated — sanitize before it can\n // reach a deployed audit (the JSON/Airtable providers already do this).\n return sanitizeDynamicSites(await mod.default());\n }\n throw Object.assign(\n new Error(`--fleet ${input.fleet}: unsupported extension ${ext || \"(none)\"}`),\n { exitCode: 2 },\n );\n }\n\n return localPath(resolve(input.cwd, input.site ?? input.cwd))();\n}\n"],"mappings":";;;;;;;;;AAAA,SAAS,qBAAqB;AAC9B,SAAS,SAAS,eAAe;AAQ1B,IAAM,gBAAgB;AAiBtB,IAAM,2BAA2B;AAEjC,IAAM,mCACX;AAOK,SAAS,qBAAqB,OAAuB;AAC1D,SAAO,MAAM,IAAI,CAAC,MAAM;AACtB,QAAI,EAAE,gBAAgB,UAAa,CAAC,UAAU,EAAE,WAAW,GAAG;AAC5D,cAAQ;AAAA,QACN,+EACK,EAAE,QAAQ,EAAE,IAAI,KAAK,KAAK,UAAU,EAAE,WAAW,CAAC;AAAA,MACzD;AACA,YAAM,OAAO,EAAE,GAAG,EAAE;AACpB,aAAO,KAAK;AACZ,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT,CAAC;AACH;AAaA,eAAsB,aAAa,OAA2C;AAC5E,MAAI,MAAM,QAAQ,MAAM,OAAO;AAC7B,UAAM,OAAO,OAAO,IAAI,MAAM,iDAAiD,GAAG;AAAA,MAChF,UAAU;AAAA,IACZ,CAAC;AAAA,EACH;AAEA,MAAI,MAAM,UAAU,iBAAiB,MAAM,UAAU,0BAA0B;AAC7E,QAAI,MAAM,UAAU,yBAA0B,SAAQ,KAAK,gCAAgC;AAC3F,UAAM,EAAE,QAAQ,aAAa,IAAI,MAAM,OAAO,sBAAoB;AAClE,UAAM,EAAE,YAAY,IAAI,MAAM,OAAO,qBAA0B;AAC/D,UAAM,OAAO,MAAM,WAAW,MAAM,OAAO,aAAa,CAAC;AACzD,WAAO,YAAY,MAAM,MAAM,UAAU,EAAE,SAAS,MAAM,QAAQ,IAAI,CAAC,CAAC,EAAE;AAAA,EAC5E;AAEA,MAAI,MAAM,OAAO;AACf,UAAM,YAAY,QAAQ,MAAM,KAAK,MAAM,KAAK;AAChD,UAAM,MAAM,QAAQ,SAAS,EAAE,YAAY;AAC3C,QAAI,QAAQ,SAAS;AAEnB,aAAO,aAAa,SAAS,EAAE;AAAA,IACjC;AACA,QAAI,QAAQ,SAAS,QAAQ,UAAU,QAAQ,QAAQ;AACrD,YAAM,MAAO,MAAM,OAAO,cAAc,SAAS,EAAE;AAGnD,UAAI,CAAC,IAAI,WAAW,OAAO,IAAI,YAAY,YAAY;AACrD,cAAM,OAAO,OAAO,IAAI,MAAM,WAAW,MAAM,KAAK,oCAAoC,GAAG;AAAA,UACzF,UAAU;AAAA,QACZ,CAAC;AAAA,MACH;AAGA,aAAO,qBAAqB,MAAM,IAAI,QAAQ,CAAC;AAAA,IACjD;AACA,UAAM,OAAO;AAAA,MACX,IAAI,MAAM,WAAW,MAAM,KAAK,2BAA2B,OAAO,QAAQ,EAAE;AAAA,MAC5E,EAAE,UAAU,EAAE;AAAA,IAChB;AAAA,EACF;AAEA,SAAO,UAAU,QAAQ,MAAM,KAAK,MAAM,QAAQ,MAAM,GAAG,CAAC,EAAE;AAChE;","names":[]}
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-J7SZQUCW.js";
4
4
  import {
5
5
  withRecipe
6
- } from "./chunk-SQ4FSNYL.js";
6
+ } from "./chunk-LUF52EIY.js";
7
7
 
8
8
  // src/recipes/svelte-codemods.ts
9
9
  import { writeFile } from "fs/promises";
@@ -32,4 +32,4 @@ async function svelteCodemods(site) {
32
32
  export {
33
33
  svelteCodemods
34
34
  };
35
- //# sourceMappingURL=chunk-RRE74VWI.js.map
35
+ //# sourceMappingURL=chunk-HSMN35LU.js.map
@@ -0,0 +1,148 @@
1
+ // src/fleet/site-status.ts
2
+ var CANONICAL_STATUSES = [
3
+ "building",
4
+ "launching",
5
+ "maintained",
6
+ "hosted-only",
7
+ "external",
8
+ "archived"
9
+ ];
10
+ var CANONICAL_SET = new Set(CANONICAL_STATUSES);
11
+ function canonicalizeStatus(raw) {
12
+ if (typeof raw !== "string") return null;
13
+ return raw;
14
+ }
15
+
16
+ // src/fleet/site-row.ts
17
+ function siteSlug(name) {
18
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
19
+ }
20
+ function trimToNull(raw) {
21
+ if (typeof raw !== "string") return null;
22
+ const trimmed = raw.trim();
23
+ return trimmed.length > 0 ? trimmed : null;
24
+ }
25
+ function parseNotifyRouting(raw) {
26
+ if (typeof raw !== "string") return null;
27
+ const trimmed = raw.trim();
28
+ if (!trimmed) return null;
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(trimmed);
32
+ } catch {
33
+ return null;
34
+ }
35
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
36
+ const o = parsed;
37
+ if (typeof o.field !== "string" || !o.field.trim()) return null;
38
+ if (!o.routes || typeof o.routes !== "object" || Array.isArray(o.routes)) return null;
39
+ const routing = {
40
+ field: o.field,
41
+ routes: o.routes
42
+ };
43
+ if (o.default !== void 0) routing.default = o.default;
44
+ if (Array.isArray(o.cc)) routing.cc = o.cc.filter((x) => typeof x === "string");
45
+ return routing;
46
+ }
47
+ var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["maintained", "launching"]);
48
+ function isDashboardVisible(site) {
49
+ return site.status !== null && ACTIVE_STATUSES.has(site.status);
50
+ }
51
+ var PRE_LAUNCH_STATUSES = /* @__PURE__ */ new Set(["building", "launching"]);
52
+ function isPreLaunch(status) {
53
+ return status !== null && PRE_LAUNCH_STATUSES.has(status);
54
+ }
55
+ var KNOWN_STATUSES = new Set(CANONICAL_STATUSES);
56
+ var ARCHIVED_STATUSES = /* @__PURE__ */ new Set(["archived"]);
57
+ function isArchivedStatus(status) {
58
+ return status !== null && ARCHIVED_STATUSES.has(status);
59
+ }
60
+ function isUnrecognizedStatus(status) {
61
+ return status !== null && !KNOWN_STATUSES.has(status);
62
+ }
63
+ var FREQUENCIES = ["None", "Monthly", "Quarterly", "Yearly"];
64
+ function toFrequency(raw, context) {
65
+ if (typeof raw !== "string") return "None";
66
+ const trimmed = raw.trim();
67
+ if (FREQUENCIES.includes(trimmed)) return trimmed;
68
+ if (trimmed !== "") {
69
+ console.warn(
70
+ `\u26A0 ${context}: unrecognized frequency '${raw}' \u2014 treating as None (not scheduling); fix the Airtable value`
71
+ );
72
+ }
73
+ return "None";
74
+ }
75
+ function toVerdict(raw) {
76
+ return raw === "pass" || raw === "fail" ? raw : null;
77
+ }
78
+ function toPrismicModelsVerdict(raw) {
79
+ return raw === "pass" || raw === "fail" || raw === "unknown" ? raw : null;
80
+ }
81
+ var SEVERITY_RANK = {
82
+ critical: 0,
83
+ high: 1,
84
+ moderate: 2,
85
+ low: 3
86
+ };
87
+ function normalizeSecurityAdvisory(raw) {
88
+ if (!raw || typeof raw !== "object") return null;
89
+ const e = raw;
90
+ const module = typeof e["module"] === "string" ? e["module"] : null;
91
+ const severity = e["severity"];
92
+ if (module === null) return null;
93
+ if (severity !== "low" && severity !== "moderate" && severity !== "high" && severity !== "critical")
94
+ return null;
95
+ const cves = Array.isArray(e["cves"]) ? e["cves"].filter((c) => typeof c === "string") : [];
96
+ const scope = e["scope"] === "runtime" || e["scope"] === "development" ? e["scope"] : void 0;
97
+ const relationship = e["relationship"] === "direct" || e["relationship"] === "transitive" ? e["relationship"] : void 0;
98
+ return {
99
+ module,
100
+ severity,
101
+ title: typeof e["title"] === "string" ? e["title"] : "",
102
+ cves,
103
+ url: typeof e["url"] === "string" ? e["url"] : null,
104
+ ...scope ? { scope } : {},
105
+ ...relationship ? { relationship } : {}
106
+ };
107
+ }
108
+ function allActionableVulnsTransitive(advisories) {
109
+ if (!advisories) return false;
110
+ const actionable = advisories.filter((a) => a.severity === "critical" || a.severity === "high");
111
+ if (actionable.length === 0) return false;
112
+ return actionable.every((a) => a.relationship === "transitive");
113
+ }
114
+ function parseSecurityAdvisories(raw) {
115
+ if (typeof raw !== "string" || raw.trim() === "") return null;
116
+ let parsed;
117
+ try {
118
+ parsed = JSON.parse(raw);
119
+ } catch {
120
+ return null;
121
+ }
122
+ if (!Array.isArray(parsed)) return null;
123
+ return parsed.map(normalizeSecurityAdvisory).filter((a) => a !== null);
124
+ }
125
+
126
+ export {
127
+ CANONICAL_STATUSES,
128
+ canonicalizeStatus,
129
+ siteSlug,
130
+ trimToNull,
131
+ parseNotifyRouting,
132
+ ACTIVE_STATUSES,
133
+ isDashboardVisible,
134
+ PRE_LAUNCH_STATUSES,
135
+ isPreLaunch,
136
+ KNOWN_STATUSES,
137
+ ARCHIVED_STATUSES,
138
+ isArchivedStatus,
139
+ isUnrecognizedStatus,
140
+ toFrequency,
141
+ toVerdict,
142
+ toPrismicModelsVerdict,
143
+ SEVERITY_RANK,
144
+ normalizeSecurityAdvisory,
145
+ allActionableVulnsTransitive,
146
+ parseSecurityAdvisories
147
+ };
148
+ //# sourceMappingURL=chunk-ICSRWW5Q.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/fleet/site-status.ts","../src/fleet/site-row.ts"],"sourcesContent":["/**\n * The site-status vocabulary — ONE module owns it (#539 Phase 4).\n *\n * It lived at `src/reports/airtable/site-status.ts` until #539 Phase 6 step 1\n * (#646) moved it here: `canonicalizeStatus` runs on every Turso lead read, so\n * it cannot sit in the directory Phase 6 deletes. The Airtable write seam\n * `toAirtableStatus`, discussed below, stayed behind in that file, which also\n * re-exports everything here.\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/** 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","/**\n * The fleet's site-row model and its pure, vendor-neutral coercers.\n *\n * `WebsiteRow` is what BOTH readers return — `mapRow` over an Airtable record\n * (`src/reports/airtable/websites.ts`) and `rowFromJoined` over Turso\n * (`src/db/fleet-state.ts`) — and the coercers below are the ONE truth each of\n * them applies to a raw cell/column. They lived in the Airtable module until\n * #539 Phase 6 step 1 (#646) moved them here: they run on every Turso lead read,\n * so they could not stay in the directory Phase 6 deletes. Nothing here touches\n * a store; the doc comments still name Airtable columns because that is where\n * each value was first defined, and the Turso columns carry the same raw values.\n *\n * `src/reports/airtable/websites.ts` re-exports every name, so existing imports\n * resolve unchanged.\n */\nimport { CANONICAL_STATUSES, type Status } from \"./site-status.js\";\n\nexport type Frequency = \"None\" | \"Monthly\" | \"Quarterly\" | \"Yearly\";\n\n/** The canonical lifecycle vocabulary lives in ./site-status.ts (the Airtable\n * write-direction mapping, `toAirtableStatus`, stays in the Airtable module).\n * Re-exported here because every consumer already imports `Status` alongside\n * `WebsiteRow`. */\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-back`. */\n pScore: number | null;\n rScore: number | null;\n bpScore: number | null;\n seoScore: number | null;\n /** ISO timestamp set by `audit lighthouse --write-back` when scores were last refreshed. */\n lastLighthouseAuditAt: string | null;\n /** Last-known counts from non-lighthouse audits, written by\n * `audit --write-back`. `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-back`).\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// ── security advisories (the persisted vulnerability detail) ─────────────────\n\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};\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\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"],"mappings":"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","names":[]}
@@ -8,7 +8,7 @@ import {
8
8
  RESTORED_NOTE,
9
9
  refusedByGit,
10
10
  undoRefusedWrites
11
- } from "./chunk-4UTJ4G6P.js";
11
+ } from "./chunk-TJLKWQOX.js";
12
12
  import {
13
13
  RENOVATE_ACTION_CONFIG,
14
14
  renovateActionGaps,
@@ -17,7 +17,7 @@ import {
17
17
  } from "./chunk-SLFG7Y76.js";
18
18
  import {
19
19
  readGitHubConfig
20
- } from "./chunk-CGQZFQAY.js";
20
+ } from "./chunk-MVKNU4QD.js";
21
21
  import {
22
22
  makeGitHub
23
23
  } from "./chunk-43SW3FGW.js";
@@ -33,7 +33,7 @@ import {
33
33
  isWorkingTreeClean,
34
34
  push,
35
35
  resolveOwnerRepo
36
- } from "./chunk-EASYCD6F.js";
36
+ } from "./chunk-WMF5S2MB.js";
37
37
 
38
38
  // src/recipes/self-updating/index.ts
39
39
  import { mkdir, readFile, writeFile } from "fs/promises";
@@ -177,4 +177,4 @@ async function selfUpdating(site, deps = {}) {
177
177
  export {
178
178
  selfUpdating
179
179
  };
180
- //# sourceMappingURL=chunk-XG2A6GIG.js.map
180
+ //# sourceMappingURL=chunk-IDBPII6G.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/recipes/self-updating/index.ts"],"sourcesContent":["import { mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport type { RecipeResult, Site } from \"../../types.js\";\nimport { refusedByGit, undoRefusedWrites, RESTORED_NOTE } from \"../_head-guard.js\";\nimport { templatesByName } from \"../sync-configs/templates.js\";\nimport {\n renovateActionGaps,\n withRenovatePinsFrom,\n RENOVATE_ACTION_CONFIG,\n} from \"../sync-configs/renovate-action.js\";\nimport {\n resolveOwnerRepo,\n push as gitPush,\n branchName,\n checkoutBranch,\n createBranch,\n currentBranch,\n commit as gitCommit,\n isWorkingTreeClean,\n} from \"../../util/git.js\";\nimport { siteLabel } from \"../../util/site.js\";\nimport { readGitHubConfig } from \"../../github/config.js\";\nimport { makeGitHub, type GitHub } from \"../../github/gh.js\";\nimport {\n FLEET_RULESET_NAME,\n desiredRuleset,\n healRuleset,\n rulesetGaps,\n} from \"../../github/rulesets.js\";\n\n// Deliberately NOT ci.yml: that file is per-site parameterized (netlify-site,\n// node-version, permissions), so an exact-match heal against a static template\n// would strip those values in a green auto-mergeable PR — the armed clobber\n// the 2026-08-02 architecture review found. The starter clone owns ci.yml's\n// shape. Its pinned reusable-workflow ref is sometimes bumped by Renovate\n// (v1.2.0 -> v1.3.0 across 17 repos, Jul-Aug 2026) but was not for v1.4.0 or\n// v1.4.1 — the latter was swept by hand on 2026-09-01 — so propagation is a\n// manual step to verify, never a guarantee.\nconst SELF_UPDATING_CONFIGS = [\"renovate-action\", \"renovate-config\"] as const;\n\n// Reusable-workflow jobs report their check as \"<caller-job> / <reusable-job>\".\n// The thin `ci` caller (job `ci`) calls reddoorla/.github's reusable workflow (job `ci`),\n// so the required context is \"ci / ci\", NOT \"ci\". (Provisional — verified empirically on the\n// starter during M7.1 rollout; correct here if the live checks API reports a different string.)\nconst REQUIRED_CHECK = \"ci / ci\";\n\nexport type SelfUpdatingDeps = {\n github?: GitHub;\n pushBranch?: (cwd: string, branch: string) => Promise<void>;\n};\n\nfunction resultOf(\n site: Site,\n status: RecipeResult[\"status\"],\n notes: string,\n commits: string[] = [],\n): RecipeResult {\n return { recipe: \"self-updating\", site: siteLabel(site), status, commits, notes };\n}\n\n/** True when a file's current content matches the canonical template for drift\n * purposes. Normalizes line endings + trailing whitespace so a stray trailing\n * newline (or CRLF) can't read as drift and open a needless PR every nightly run;\n * any real content change still differs. */\nfunction sameConfigContents(current: string, canonical: string): boolean {\n const norm = (s: string) => s.replace(/\\r\\n/g, \"\\n\").replace(/\\s+$/, \"\");\n return norm(current) === norm(canonical);\n}\n\nexport async function selfUpdating(site: Site, deps: SelfUpdatingDeps = {}): Promise<RecipeResult> {\n const templates = templatesByName([...SELF_UPDATING_CONFIGS]);\n\n let repo: string | null;\n try {\n repo = await resolveOwnerRepo(site);\n } catch (err) {\n // A malformed repo identity must abort before any `gh` write — surface it\n // as a recipe failure rather than letting the token reach an unintended repo.\n return resultOf(site, \"failed\", err instanceof Error ? err.message : String(err));\n }\n if (!repo) {\n return resultOf(\n site,\n \"failed\",\n \"could not determine a GitHub repo for this site — set Airtable 'Git repo', or give \" +\n \"the checkout an origin remote whose URL is a GitHub owner/repo\",\n );\n }\n\n const cfg = readGitHubConfig();\n if (!deps.github && !cfg) return resultOf(site, \"failed\", \"GITHUB_TOKEN not set\");\n const github = deps.github ?? makeGitHub({ token: cfg!.token });\n\n const base = await github.defaultBranch(repo).catch(() => \"main\");\n const actions: string[] = [];\n const commits: string[] = [];\n // Hoisted so the `finally` can restore the operator's branch even when a\n // failure (push/PR error) aborts AFTER we created + checked out the maint\n // branch. `maintBranch` stays null until createBranch succeeds, so the\n // restore is a no-op on every path that never left the operator's branch.\n let original: string | null = null;\n let maintBranch: string | null = null;\n\n try {\n // A. Renovate config on the default branch — compare CONTENT, not just\n // existence, so a present-but-STALE config (a pre-App workflow, a drifted\n // Renovate schedule window) is corrected rather than left silently\n // forever. The prior existence-only gate reported \"already self-updating\" for any\n // repo that merely HAD the files, however out of date — the very drift this\n // recipe exists to repair (e.g. the Renovate schedule-window regression).\n //\n // renovate.yml specifically is compliance-checked rather than byte-matched\n // (see renovateActionGaps / withRenovatePinsFrom in\n // ../sync-configs/renovate-action.js): Renovate legitimately bumps its own\n // digest pins forward, and a site's prettier may legitimately quote the\n // cron / RENOVATE_* scalars differently — neither is real drift (issue\n // #651; a byte-match recipe opened reddoor-starter-blux#1, a PR that\n // DOWNGRADED Renovate's own pin).\n const drifted: string[] = [];\n // Captured so the write loop below can carry a drifted renovate.yml's own\n // pins forward instead of re-fetching (and so a compliant-but-non-byte-\n // identical file's contents are available for that carry-forward too,\n // though it won't need healing).\n const currentContents = new Map<string, string | null>();\n for (const t of templates) {\n const current = await github.fileContentsOnBranch(repo, base, t.path);\n currentContents.set(t.path, current);\n if (current === null) {\n drifted.push(t.path);\n continue;\n }\n if (sameConfigContents(current, t.contents)) continue;\n if (t.config === RENOVATE_ACTION_CONFIG && renovateActionGaps(current).length === 0) {\n continue;\n }\n drifted.push(t.path);\n }\n if (drifted.length > 0) {\n const existingPR = await github.findOpenSelfUpdatingPR(repo);\n if (existingPR) {\n actions.push(`self-updating PR already open: ${existingPR}`);\n } else {\n if (!(await isWorkingTreeClean(site.path))) {\n return resultOf(site, \"failed\", \"working tree not clean — commit or stash first\");\n }\n // Capture the operator's branch BEFORE creating the maint branch so the\n // `finally` can return them to it (#2). Best-effort capture: if it fails\n // we just skip the restore rather than guess.\n try {\n original = await currentBranch(site.path);\n } catch {\n original = null;\n }\n maintBranch = branchName(\"self-updating\");\n await createBranch(site.path, maintBranch);\n // Write only the paths that actually drifted — a repo whose\n // renovate.json is stale but whose renovate.yml already passes\n // `renovateActionGaps` must not have that compliant renovate.yml\n // silently rewritten (e.g. un-quoting its scalars) in the same PR.\n // On a fresh repo every template path is missing, so `drifted`\n // already contains all of them and bootstrap is unaffected.\n // What each written path held BEFORE this run, so one git then refuses\n // can be put back exactly as it was (null = the file did not exist).\n const before = new Map<string, string | null>();\n for (const t of templates) {\n if (!drifted.includes(t.path)) continue;\n const dest = join(site.path, t.path);\n before.set(t.path, await readFile(dest, \"utf-8\").catch(() => null));\n await mkdir(dirname(dest), { recursive: true });\n // For a genuinely non-compliant renovate.yml, carry the site's own\n // (still-digest-pinned) action refs forward onto the template before\n // writing it — writing the template verbatim would put its OLDER\n // pins over the site's newer ones, re-introducing the downgrade\n // this compliance check exists to prevent (issue #651).\n const contents =\n t.config === RENOVATE_ACTION_CONFIG\n ? withRenovatePinsFrom(t.contents, currentContents.get(t.path) ?? null)\n : t.contents;\n await writeFile(dest, contents, \"utf-8\");\n }\n const sha = await gitCommit(site.path, \"ci: enable self-updating (Renovate auto-merge)\");\n if (sha) commits.push(sha);\n\n // Did git TAKE the configs? The PARTIAL drop is the one that bites: with\n // one of the two paths ignored the other still commits, so `commit()`\n // returns a SHA and — before this check — the branch was pushed and a PR\n // opened for a repo that would never become self-updating, after which\n // every later run reported \"self-updating PR already open\" forever.\n // Checked BEFORE the push, so nothing reaches GitHub on a refusal (#741).\n const refusal = await refusedByGit(site.path, drifted, \"the Renovate configuration\");\n if (refusal) {\n await undoRefusedWrites(site.path, before, refusal.missing);\n return resultOf(site, \"failed\", refusal.notes + RESTORED_NOTE, commits);\n }\n\n await (deps.pushBranch ?? gitPush)(site.path, maintBranch);\n const pr = await github.openPullRequest(repo, {\n head: maintBranch,\n base,\n title: \"Enable self-updating (Renovate)\",\n body: \"Adds scheduled Renovate and Renovate-owned merges for patch/minor updates. (ci.yml is deliberately not managed here — its shape comes from the starter and its reusable-workflow pin from Renovate.)\",\n });\n actions.push(`opened PR ${pr.url}`);\n // (Branch restore happens in the `finally` below — on success AND on a\n // failure that aborts after createBranch.)\n }\n }\n\n // B. Repo settings — check-then-ensure, each independent (self-healing).\n //\n // Platform auto-merge is DISABLED fleet-wide, and this block self-heals toward\n // OFF (it used to drive repos ON — do not \"fix\" it back).\n //\n // Why: GitHub's platform auto-merge is a per-PR flag that anyone with write\n // access can arm; the PR then merges itself later, unattended, once checks go\n // green. The org Renovate preset forbids auto-merging majors and was working\n // correctly — Renovate never armed them — but the preset has no authority over\n // a flag someone else set. On 2026-07-26 a bulk `gh pr merge --auto` sweep\n // armed it across 8 fleet repos, and the next morning two `actions/checkout`\n // MAJOR bumps merged with zero reviews (reddoor-starter#77,\n // reddoor-website#111) as soon as Renovate's own rebase cleared their\n // conflicts. Realized damage, not a hypothetical.\n //\n // The fix is to keep merges INSIDE Renovate's own run, where its `packageRules`\n // actually govern the decision. Renovate can only merge while it is running, so\n // the workflow cron (see sync-configs templates) is the merge cadence.\n //\n // Leaving the self-heal pointed at OFF makes this a drift ALARM: if someone\n // re-enables platform auto-merge, the next `self-updating` / `launch` run turns\n // it back off and records the action. `enableRepoAutoMerge` is retained in\n // `src/github/gh.ts` as the documented rollback path.\n if (await github.autoMergeEnabled(repo)) {\n await github.disableRepoAutoMerge(repo);\n actions.push(\"disabled auto-merge (platform auto-merge is not permitted fleet-wide)\");\n }\n const existingContexts = await github.branchProtectionContexts(repo, base);\n if (!existingContexts.includes(REQUIRED_CHECK)) {\n // protectBranch issues a full PUT that REPLACES required-status-check contexts.\n // Send the UNION of the branch's existing required contexts + our REQUIRED_CHECK\n // so we ADD the CI gate without silently dropping any other checks the repo\n // already requires. (When the branch has no protection yet, existingContexts is\n // [] and this is just [REQUIRED_CHECK] — the original behavior.) Dedupe defends\n // against REQUIRED_CHECK already appearing among the existing contexts.\n const contexts = [...new Set([...existingContexts, REQUIRED_CHECK])];\n await github.protectBranch(repo, base, contexts);\n actions.push(`required \"${REQUIRED_CHECK}\" check on ${base}`);\n }\n // Ruleset ensure — the layer that binds ADMINS. Classic protection above\n // keeps `enforce_admins: false`, which is exactly the hole the 2026-07-26\n // bulk `gh pr merge --auto` sweep walked through (two unreviewed MAJORS\n // merged). The 2026-08-01 audit then found the six NEWEST repos with no\n // ruleset at all — every hand-applied sweep regrows its gap at the next\n // onboard, so the recipe owns the invariant now. Spec + invariants:\n // docs/superpowers/specs/2026-08-02-ruleset-self-healing-design.md.\n //\n // Private repos are skipped BEFORE any attempt: rulesets there need a paid\n // plan, so trying would either fail every run or spam a phantom action\n // that flips this recipe to \"applied\" forever. Classic protection remains\n // their floor.\n if ((await github.repoVisibility(repo)) === \"public\") {\n // Evidence gate: only require \"ci / ci\" once that check-run has actually\n // been OBSERVED on the default branch. With an empty bypass list, a\n // required context that never fires would make the repo permanently\n // unmergeable by EVERYONE — e.g. a workflow_call-only ci.yml emits no\n // check runs. Unobserved ⇒ refs rules only; the check upgrades on a\n // later pass once real evidence exists.\n //\n // Residual risk, accepted: this proves the check fires on default-branch\n // PUSHES, not on pull_request events — a hand-edited push-only ci.yml\n // would pass the gate and then block every PR. ci.yml is deliberately\n // NOT healed by block A (per-site parameterized; see\n // SELF_UPDATING_CONFIGS), so this recipe cannot correct that drift —\n // the exposure is one bricked-PR repo, immediately visible on its next\n // PR. Un-brick path: edit the ruleset via API (ruleset ADMIN is not\n // ref-gated), then fix the ci.yml triggers by push.\n const check = (await github.checkContextObserved(repo, base, REQUIRED_CHECK))\n ? REQUIRED_CHECK\n : null;\n const existingRuleset = (await github.listRepoRulesets(repo)).find(\n (r) => r.name === FLEET_RULESET_NAME,\n );\n if (!existingRuleset) {\n await github.createRuleset(repo, desiredRuleset(check));\n actions.push(\n check === null\n ? `created ruleset \"${FLEET_RULESET_NAME}\" (refs rules only — \"${REQUIRED_CHECK}\" not yet observed on ${base})`\n : `created ruleset \"${FLEET_RULESET_NAME}\"`,\n );\n } else {\n const full = await github.getRuleset(repo, existingRuleset.id);\n const gaps = rulesetGaps(full, check);\n if (gaps.length > 0) {\n // PUT the healed payload (merge TOWARD the floor, never away — see\n // healRuleset). Recording the specific gaps makes this the drift\n // ALARM, same as the auto-merge-off action above.\n await github.updateRuleset(repo, existingRuleset.id, healRuleset(full, check));\n actions.push(`healed ruleset drift: ${gaps.join(\"; \")}`);\n }\n }\n }\n // (No per-repo RENOVATE_TOKEN planting anymore: Renovate authenticates as\n // the reddoor-renovate GitHub App, whose RENOVATE_APP_ID / _PRIVATE_KEY are\n // org-level with all-repos visibility — a new repo needs NOTHING set. The\n // old step was already redundant for new sites (the PAT was an org secret\n // too); existing per-repo copies are inert and left to rot harmlessly.\n // docs/runbooks/renovate-app-identity.md covers the migration + rollback.)\n } catch (err) {\n const done = actions.length ? ` (completed: ${actions.join(\"; \")})` : \"\";\n const message = err instanceof Error ? err.message : String(err);\n return resultOf(site, \"failed\", `${message}${done}`, commits);\n } finally {\n // Restore the operator's branch whenever we created + checked out a maint\n // branch — on SUCCESS and, critically, on FAILURE. Without this, a push/PR\n // error after createBranch strands the checkout on the maint branch with an\n // unpushed commit, and the retry then fails at createBranch (\"branch already\n // exists\"). Best-effort: a restore failure must not mask the real outcome.\n if (maintBranch !== null && original !== null && original !== maintBranch) {\n try {\n await checkoutBranch(site.path, original);\n } catch (err) {\n console.warn(\n `warning: could not restore branch ${original} after self-updating: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n }\n }\n }\n\n return actions.length\n ? resultOf(site, \"applied\", actions.join(\"; \"), commits)\n : resultOf(site, \"noop\", \"already self-updating\", commits);\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/recipes/self-updating/index.ts"],"sourcesContent":["import { mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport type { RecipeResult, Site } from \"../../types.js\";\nimport { refusedByGit, undoRefusedWrites, RESTORED_NOTE } from \"../_head-guard.js\";\nimport { templatesByName } from \"../sync-configs/templates.js\";\nimport {\n renovateActionGaps,\n withRenovatePinsFrom,\n RENOVATE_ACTION_CONFIG,\n} from \"../sync-configs/renovate-action.js\";\nimport {\n resolveOwnerRepo,\n push as gitPush,\n branchName,\n checkoutBranch,\n createBranch,\n currentBranch,\n commit as gitCommit,\n isWorkingTreeClean,\n} from \"../../util/git.js\";\nimport { siteLabel } from \"../../util/site.js\";\nimport { readGitHubConfig } from \"../../github/config.js\";\nimport { makeGitHub, type GitHub } from \"../../github/gh.js\";\nimport {\n FLEET_RULESET_NAME,\n desiredRuleset,\n healRuleset,\n rulesetGaps,\n} from \"../../github/rulesets.js\";\n\n// Deliberately NOT ci.yml: that file is per-site parameterized (netlify-site,\n// node-version, permissions), so an exact-match heal against a static template\n// would strip those values in a green auto-mergeable PR — the armed clobber\n// the 2026-08-02 architecture review found. The starter clone owns ci.yml's\n// shape. Its pinned reusable-workflow ref is sometimes bumped by Renovate\n// (v1.2.0 -> v1.3.0 across 17 repos, Jul-Aug 2026) but was not for v1.4.0 or\n// v1.4.1 — the latter was swept by hand on 2026-09-01 — so propagation is a\n// manual step to verify, never a guarantee.\nconst SELF_UPDATING_CONFIGS = [\"renovate-action\", \"renovate-config\"] as const;\n\n// Reusable-workflow jobs report their check as \"<caller-job> / <reusable-job>\".\n// The thin `ci` caller (job `ci`) calls reddoorla/.github's reusable workflow (job `ci`),\n// so the required context is \"ci / ci\", NOT \"ci\". (Provisional — verified empirically on the\n// starter during M7.1 rollout; correct here if the live checks API reports a different string.)\nconst REQUIRED_CHECK = \"ci / ci\";\n\nexport type SelfUpdatingDeps = {\n github?: GitHub;\n pushBranch?: (cwd: string, branch: string) => Promise<void>;\n};\n\nfunction resultOf(\n site: Site,\n status: RecipeResult[\"status\"],\n notes: string,\n commits: string[] = [],\n): RecipeResult {\n return { recipe: \"self-updating\", site: siteLabel(site), status, commits, notes };\n}\n\n/** True when a file's current content matches the canonical template for drift\n * purposes. Normalizes line endings + trailing whitespace so a stray trailing\n * newline (or CRLF) can't read as drift and open a needless PR every nightly run;\n * any real content change still differs. */\nfunction sameConfigContents(current: string, canonical: string): boolean {\n const norm = (s: string) => s.replace(/\\r\\n/g, \"\\n\").replace(/\\s+$/, \"\");\n return norm(current) === norm(canonical);\n}\n\nexport async function selfUpdating(site: Site, deps: SelfUpdatingDeps = {}): Promise<RecipeResult> {\n const templates = templatesByName([...SELF_UPDATING_CONFIGS]);\n\n let repo: string | null;\n try {\n repo = await resolveOwnerRepo(site);\n } catch (err) {\n // A malformed repo identity must abort before any `gh` write — surface it\n // as a recipe failure rather than letting the token reach an unintended repo.\n return resultOf(site, \"failed\", err instanceof Error ? err.message : String(err));\n }\n if (!repo) {\n return resultOf(\n site,\n \"failed\",\n \"could not determine a GitHub repo for this site — set Airtable 'Git repo', or give \" +\n \"the checkout an origin remote whose URL is a GitHub owner/repo\",\n );\n }\n\n const cfg = readGitHubConfig();\n if (!deps.github && !cfg) return resultOf(site, \"failed\", \"GITHUB_TOKEN not set\");\n const github = deps.github ?? makeGitHub({ token: cfg!.token });\n\n const base = await github.defaultBranch(repo).catch(() => \"main\");\n const actions: string[] = [];\n const commits: string[] = [];\n // Hoisted so the `finally` can restore the operator's branch even when a\n // failure (push/PR error) aborts AFTER we created + checked out the maint\n // branch. `maintBranch` stays null until createBranch succeeds, so the\n // restore is a no-op on every path that never left the operator's branch.\n let original: string | null = null;\n let maintBranch: string | null = null;\n\n try {\n // A. Renovate config on the default branch — compare CONTENT, not just\n // existence, so a present-but-STALE config (a pre-App workflow, a drifted\n // Renovate schedule window) is corrected rather than left silently\n // forever. The prior existence-only gate reported \"already self-updating\" for any\n // repo that merely HAD the files, however out of date — the very drift this\n // recipe exists to repair (e.g. the Renovate schedule-window regression).\n //\n // renovate.yml specifically is compliance-checked rather than byte-matched\n // (see renovateActionGaps / withRenovatePinsFrom in\n // ../sync-configs/renovate-action.js): Renovate legitimately bumps its own\n // digest pins forward, and a site's prettier may legitimately quote the\n // cron / RENOVATE_* scalars differently — neither is real drift (issue\n // #651; a byte-match recipe opened reddoor-starter-blux#1, a PR that\n // DOWNGRADED Renovate's own pin).\n const drifted: string[] = [];\n // Captured so the write loop below can carry a drifted renovate.yml's own\n // pins forward instead of re-fetching (and so a compliant-but-non-byte-\n // identical file's contents are available for that carry-forward too,\n // though it won't need healing).\n const currentContents = new Map<string, string | null>();\n for (const t of templates) {\n const current = await github.fileContentsOnBranch(repo, base, t.path);\n currentContents.set(t.path, current);\n if (current === null) {\n drifted.push(t.path);\n continue;\n }\n if (sameConfigContents(current, t.contents)) continue;\n if (t.config === RENOVATE_ACTION_CONFIG && renovateActionGaps(current).length === 0) {\n continue;\n }\n drifted.push(t.path);\n }\n if (drifted.length > 0) {\n const existingPR = await github.findOpenSelfUpdatingPR(repo);\n if (existingPR) {\n actions.push(`self-updating PR already open: ${existingPR}`);\n } else {\n if (!(await isWorkingTreeClean(site.path))) {\n return resultOf(site, \"failed\", \"working tree not clean — commit or stash first\");\n }\n // Capture the operator's branch BEFORE creating the maint branch so the\n // `finally` can return them to it (#2). Best-effort capture: if it fails\n // we just skip the restore rather than guess.\n try {\n original = await currentBranch(site.path);\n } catch {\n original = null;\n }\n maintBranch = branchName(\"self-updating\");\n await createBranch(site.path, maintBranch);\n // Write only the paths that actually drifted — a repo whose\n // renovate.json is stale but whose renovate.yml already passes\n // `renovateActionGaps` must not have that compliant renovate.yml\n // silently rewritten (e.g. un-quoting its scalars) in the same PR.\n // On a fresh repo every template path is missing, so `drifted`\n // already contains all of them and bootstrap is unaffected.\n // What each written path held BEFORE this run, so one git then refuses\n // can be put back exactly as it was (null = the file did not exist).\n const before = new Map<string, string | null>();\n for (const t of templates) {\n if (!drifted.includes(t.path)) continue;\n const dest = join(site.path, t.path);\n before.set(t.path, await readFile(dest, \"utf-8\").catch(() => null));\n await mkdir(dirname(dest), { recursive: true });\n // For a genuinely non-compliant renovate.yml, carry the site's own\n // (still-digest-pinned) action refs forward onto the template before\n // writing it — writing the template verbatim would put its OLDER\n // pins over the site's newer ones, re-introducing the downgrade\n // this compliance check exists to prevent (issue #651).\n const contents =\n t.config === RENOVATE_ACTION_CONFIG\n ? withRenovatePinsFrom(t.contents, currentContents.get(t.path) ?? null)\n : t.contents;\n await writeFile(dest, contents, \"utf-8\");\n }\n const sha = await gitCommit(site.path, \"ci: enable self-updating (Renovate auto-merge)\");\n if (sha) commits.push(sha);\n\n // Did git TAKE the configs? The PARTIAL drop is the one that bites: with\n // one of the two paths ignored the other still commits, so `commit()`\n // returns a SHA and — before this check — the branch was pushed and a PR\n // opened for a repo that would never become self-updating, after which\n // every later run reported \"self-updating PR already open\" forever.\n // Checked BEFORE the push, so nothing reaches GitHub on a refusal (#741).\n const refusal = await refusedByGit(site.path, drifted, \"the Renovate configuration\");\n if (refusal) {\n await undoRefusedWrites(site.path, before, refusal.missing);\n return resultOf(site, \"failed\", refusal.notes + RESTORED_NOTE, commits);\n }\n\n await (deps.pushBranch ?? gitPush)(site.path, maintBranch);\n const pr = await github.openPullRequest(repo, {\n head: maintBranch,\n base,\n title: \"Enable self-updating (Renovate)\",\n body: \"Adds scheduled Renovate and Renovate-owned merges for patch/minor updates. (ci.yml is deliberately not managed here — its shape comes from the starter and its reusable-workflow pin from Renovate.)\",\n });\n actions.push(`opened PR ${pr.url}`);\n // (Branch restore happens in the `finally` below — on success AND on a\n // failure that aborts after createBranch.)\n }\n }\n\n // B. Repo settings — check-then-ensure, each independent (self-healing).\n //\n // Platform auto-merge is DISABLED fleet-wide, and this block self-heals toward\n // OFF (it used to drive repos ON — do not \"fix\" it back).\n //\n // Why: GitHub's platform auto-merge is a per-PR flag that anyone with write\n // access can arm; the PR then merges itself later, unattended, once checks go\n // green. The org Renovate preset forbids auto-merging majors and was working\n // correctly — Renovate never armed them — but the preset has no authority over\n // a flag someone else set. On 2026-07-26 a bulk `gh pr merge --auto` sweep\n // armed it across 8 fleet repos, and the next morning two `actions/checkout`\n // MAJOR bumps merged with zero reviews (reddoor-starter#77,\n // reddoor-website#111) as soon as Renovate's own rebase cleared their\n // conflicts. Realized damage, not a hypothetical.\n //\n // The fix is to keep merges INSIDE Renovate's own run, where its `packageRules`\n // actually govern the decision. Renovate can only merge while it is running, so\n // the workflow cron (see sync-configs templates) is the merge cadence.\n //\n // Leaving the self-heal pointed at OFF makes this a drift ALARM: if someone\n // re-enables platform auto-merge, the next `self-updating` / `launch` run turns\n // it back off and records the action. `enableRepoAutoMerge` is retained in\n // `src/github/gh.ts` as the documented rollback path.\n if (await github.autoMergeEnabled(repo)) {\n await github.disableRepoAutoMerge(repo);\n actions.push(\"disabled auto-merge (platform auto-merge is not permitted fleet-wide)\");\n }\n const existingContexts = await github.branchProtectionContexts(repo, base);\n if (!existingContexts.includes(REQUIRED_CHECK)) {\n // protectBranch issues a full PUT that REPLACES required-status-check contexts.\n // Send the UNION of the branch's existing required contexts + our REQUIRED_CHECK\n // so we ADD the CI gate without silently dropping any other checks the repo\n // already requires. (When the branch has no protection yet, existingContexts is\n // [] and this is just [REQUIRED_CHECK] — the original behavior.) Dedupe defends\n // against REQUIRED_CHECK already appearing among the existing contexts.\n const contexts = [...new Set([...existingContexts, REQUIRED_CHECK])];\n await github.protectBranch(repo, base, contexts);\n actions.push(`required \"${REQUIRED_CHECK}\" check on ${base}`);\n }\n // Ruleset ensure — the layer that binds ADMINS. Classic protection above\n // keeps `enforce_admins: false`, which is exactly the hole the 2026-07-26\n // bulk `gh pr merge --auto` sweep walked through (two unreviewed MAJORS\n // merged). The 2026-08-01 audit then found the six NEWEST repos with no\n // ruleset at all — every hand-applied sweep regrows its gap at the next\n // onboard, so the recipe owns the invariant now. Spec + invariants:\n // docs/superpowers/specs/2026-08-02-ruleset-self-healing-design.md.\n //\n // Private repos are skipped BEFORE any attempt: rulesets there need a paid\n // plan, so trying would either fail every run or spam a phantom action\n // that flips this recipe to \"applied\" forever. Classic protection remains\n // their floor.\n if ((await github.repoVisibility(repo)) === \"public\") {\n // Evidence gate: only require \"ci / ci\" once that check-run has actually\n // been OBSERVED on the default branch. With an empty bypass list, a\n // required context that never fires would make the repo permanently\n // unmergeable by EVERYONE — e.g. a workflow_call-only ci.yml emits no\n // check runs. Unobserved ⇒ refs rules only; the check upgrades on a\n // later pass once real evidence exists.\n //\n // Residual risk, accepted: this proves the check fires on default-branch\n // PUSHES, not on pull_request events — a hand-edited push-only ci.yml\n // would pass the gate and then block every PR. ci.yml is deliberately\n // NOT healed by block A (per-site parameterized; see\n // SELF_UPDATING_CONFIGS), so this recipe cannot correct that drift —\n // the exposure is one bricked-PR repo, immediately visible on its next\n // PR. Un-brick path: edit the ruleset via API (ruleset ADMIN is not\n // ref-gated), then fix the ci.yml triggers by push.\n const check = (await github.checkContextObserved(repo, base, REQUIRED_CHECK))\n ? REQUIRED_CHECK\n : null;\n const existingRuleset = (await github.listRepoRulesets(repo)).find(\n (r) => r.name === FLEET_RULESET_NAME,\n );\n if (!existingRuleset) {\n await github.createRuleset(repo, desiredRuleset(check));\n actions.push(\n check === null\n ? `created ruleset \"${FLEET_RULESET_NAME}\" (refs rules only — \"${REQUIRED_CHECK}\" not yet observed on ${base})`\n : `created ruleset \"${FLEET_RULESET_NAME}\"`,\n );\n } else {\n const full = await github.getRuleset(repo, existingRuleset.id);\n const gaps = rulesetGaps(full, check);\n if (gaps.length > 0) {\n // PUT the healed payload (merge TOWARD the floor, never away — see\n // healRuleset). Recording the specific gaps makes this the drift\n // ALARM, same as the auto-merge-off action above.\n await github.updateRuleset(repo, existingRuleset.id, healRuleset(full, check));\n actions.push(`healed ruleset drift: ${gaps.join(\"; \")}`);\n }\n }\n }\n // (No per-repo Renovate PAT secret planting anymore: Renovate authenticates as\n // the reddoor-renovate GitHub App, whose RENOVATE_APP_ID / _PRIVATE_KEY are\n // org-level with all-repos visibility — a new repo needs NOTHING set. The\n // old step was already redundant for new sites (the PAT was an org secret\n // too); existing per-repo copies are inert and left to rot harmlessly.\n // docs/runbooks/renovate-app-identity.md covers the migration + rollback.)\n } catch (err) {\n const done = actions.length ? ` (completed: ${actions.join(\"; \")})` : \"\";\n const message = err instanceof Error ? err.message : String(err);\n return resultOf(site, \"failed\", `${message}${done}`, commits);\n } finally {\n // Restore the operator's branch whenever we created + checked out a maint\n // branch — on SUCCESS and, critically, on FAILURE. Without this, a push/PR\n // error after createBranch strands the checkout on the maint branch with an\n // unpushed commit, and the retry then fails at createBranch (\"branch already\n // exists\"). Best-effort: a restore failure must not mask the real outcome.\n if (maintBranch !== null && original !== null && original !== maintBranch) {\n try {\n await checkoutBranch(site.path, original);\n } catch (err) {\n console.warn(\n `warning: could not restore branch ${original} after self-updating: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n }\n }\n }\n\n return actions.length\n ? resultOf(site, \"applied\", actions.join(\"; \"), commits)\n : resultOf(site, \"noop\", \"already self-updating\", commits);\n}\n"],"mappings":"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","names":[]}
@@ -517,6 +517,17 @@ var MIGRATIONS = [
517
517
  {
518
518
  id: "0026_prospect_audits_chosen_questions",
519
519
  sql: `ALTER TABLE prospect_audits ADD COLUMN chosen_questions TEXT;`
520
+ },
521
+ {
522
+ // #539 Phase 6 step 2 (#646): the resend-webhook's REPORT lookup moved from
523
+ // Airtable (`{Resend message ID} = "…"`) to Turso, as
524
+ // fleet-state's findReportByMessageId. Every Resend delivery/bounce event
525
+ // that is not a lead notification runs it, so the EXPLAIN-query-plan gate
526
+ // (tests/db/query-plans.test.ts) needs it served by an index rather than a
527
+ // scan of the HTML-bearing reports table — the same reason 0008 indexed
528
+ // submissions.resend_message_id for the bounce lookup. Single statement.
529
+ id: "0027_reports_resend_message_index",
530
+ sql: `CREATE INDEX IF NOT EXISTS idx_reports_resend_message ON reports (resend_message_id);`
520
531
  }
521
532
  ];
522
533
 
@@ -551,4 +562,4 @@ async function runMigrations(client) {
551
562
  export {
552
563
  runMigrations
553
564
  };
554
- //# sourceMappingURL=chunk-T76MA2FH.js.map
565
+ //# sourceMappingURL=chunk-IOTOOTII.js.map