@reddoorla/maintenance 0.82.0 → 0.83.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 (129) hide show
  1. package/dist/airtable-5ZPU3MT4.js +9 -0
  2. package/dist/{announce-7QHQGJPV.js → announce-4H64L2Z6.js} +4 -4
  3. package/dist/{blux-EATHOHKK.js → blux-ZGDSHI3U.js} +5 -5
  4. package/dist/{bump-deps-ZREAMK7M.js → bump-deps-2OL6PGTM.js} +5 -5
  5. package/dist/{chunk-OIDDJLS7.js → chunk-2WN4SNBQ.js} +2 -2
  6. package/dist/{chunk-HVOOCK6L.js → chunk-4DHQCN5H.js} +2 -1
  7. package/dist/{chunk-5E4L7WKE.js → chunk-5P2HVNLZ.js} +3 -3
  8. package/dist/{chunk-ZXRRKKMR.js → chunk-6V7565O2.js} +8 -8
  9. package/dist/{chunk-YRM6EED7.js → chunk-CLWNAMUX.js} +28 -2
  10. package/dist/chunk-CLWNAMUX.js.map +1 -0
  11. package/dist/{chunk-VXXF4Y25.js → chunk-FSRJAF2Q.js} +60 -3
  12. package/dist/chunk-FSRJAF2Q.js.map +1 -0
  13. package/dist/{chunk-B5ZUQZFL.js → chunk-ICXV25N7.js} +2 -2
  14. package/dist/{chunk-UH5YPYS4.js → chunk-JVWJJ36K.js} +5 -4
  15. package/dist/chunk-JVWJJ36K.js.map +1 -0
  16. package/dist/{chunk-4PW6ISX7.js → chunk-KV7DIRRO.js} +3 -3
  17. package/dist/{chunk-ODC4PXZL.js → chunk-MQUOUVQP.js} +2 -2
  18. package/dist/{chunk-LMCO7CYQ.js → chunk-OQFOMKBP.js} +2 -2
  19. package/dist/{chunk-FFI7P74K.js → chunk-PBCGXXG6.js} +2 -2
  20. package/dist/{chunk-T4FQ6TZT.js → chunk-QRDB4NLB.js} +6 -6
  21. package/dist/{chunk-K5JZ5KLO.js → chunk-R4AB6GCT.js} +3 -3
  22. package/dist/chunk-R53VL3RI.js +32 -0
  23. package/dist/chunk-R53VL3RI.js.map +1 -0
  24. package/dist/{chunk-FX7RAMGC.js → chunk-R6FPFMOS.js} +3 -3
  25. package/dist/{chunk-MGJ72BA4.js → chunk-RJB3QUSP.js} +2 -2
  26. package/dist/{chunk-ATJ6ZA2V.js → chunk-SDUJIANJ.js} +2 -2
  27. package/dist/{chunk-7ADQL5Y3.js → chunk-THEFTZ7F.js} +2 -2
  28. package/dist/{chunk-NT4WIP7I.js → chunk-UYCAF77E.js} +2 -2
  29. package/dist/chunk-VNB5XBQ4.js +699 -0
  30. package/dist/chunk-VNB5XBQ4.js.map +1 -0
  31. package/dist/{chunk-YAMRVXOD.js → chunk-VRWUL4TR.js} +2 -2
  32. package/dist/{chunk-2BHSGLWR.js → chunk-XDCRVYFL.js} +3 -3
  33. package/dist/chunk-XDCRVYFL.js.map +1 -0
  34. package/dist/{chunk-4NAOR44V.js → chunk-ZMIPG6TR.js} +2 -2
  35. package/dist/cli/bin.js +51 -23
  36. package/dist/cli/bin.js.map +1 -1
  37. package/dist/cli/commands/audit.d.ts +1 -1
  38. package/dist/cli/commands/audit.js +10 -10
  39. package/dist/{convert-to-pnpm-UT2R6EOW.js → convert-to-pnpm-KCXOYOHW.js} +5 -5
  40. package/dist/{digest-CLFUWW62.js → digest-WGEPAWMP.js} +12 -7
  41. package/dist/digest-WGEPAWMP.js.map +1 -0
  42. package/dist/{ensure-site-LLPOXLWP.js → ensure-site-HYAIVZY2.js} +2 -2
  43. package/dist/{forms-notify-target-PN6P3WPW.js → forms-notify-target-HSL4ZMT5.js} +2 -2
  44. package/dist/{github-signals-HTH7474O.js → github-signals-I7XACMCE.js} +6 -6
  45. package/dist/{header-image-WMHZFU7B.js → header-image-ADYPTGVY.js} +2 -2
  46. package/dist/{health-endpoint-4E53ZMLE.js → health-endpoint-JNCUILHO.js} +6 -6
  47. package/dist/index.d.ts +65 -3
  48. package/dist/index.js +32 -31
  49. package/dist/index.js.map +1 -1
  50. package/dist/{init-3ZJ7Z5NG.js → init-FLGLJCMJ.js} +13 -13
  51. package/dist/{launch-FRCDOB56.js → launch-AVKH2JVF.js} +10 -10
  52. package/dist/{lighthouse-airtable-I46QCEIS.js → lighthouse-airtable-CHANWVO6.js} +3 -3
  53. package/dist/{onboard-45NPDMMW.js → onboard-HF4FTJDE.js} +5 -5
  54. package/dist/{orchestrate-34JYNIXT.js → orchestrate-5FMNYJEA.js} +3 -3
  55. package/dist/{preflight-WVD32COX.js → preflight-HTWIX3MN.js} +4 -4
  56. package/dist/prismic-ci-BH225Q4L.js +363 -0
  57. package/dist/prismic-ci-BH225Q4L.js.map +1 -0
  58. package/dist/prismic-models-HXCAM4ZL.js +1020 -0
  59. package/dist/prismic-models-HXCAM4ZL.js.map +1 -0
  60. package/dist/{protection-audit-2UR45SOC.js → protection-audit-64PLLBLI.js} +3 -3
  61. package/dist/recipes/sync-configs.d.ts +1 -1
  62. package/dist/{renovate-dispatch-LOOCNZTW.js → renovate-dispatch-HBZG2UEU.js} +4 -4
  63. package/dist/{report-CLF2753O.js → report-LV7NY5W3.js} +20 -13
  64. package/dist/report-LV7NY5W3.js.map +1 -0
  65. package/dist/{self-updating-5TJ2NB26.js → self-updating-4Q65OVXY.js} +6 -6
  66. package/dist/{selftest-BTCZLCTL.js → selftest-K5XZKVTE.js} +7 -7
  67. package/dist/{smoke-suite-GHKXMKIR.js → smoke-suite-J3LL3JZJ.js} +6 -6
  68. package/dist/{submissions-KUXLKCSQ.js → submissions-GMBUP4LV.js} +2 -2
  69. package/dist/{svelte-codemods-YTCTLK7G.js → svelte-codemods-JTMX4BSX.js} +4 -4
  70. package/dist/{sync-configs-KSSQZU5L.js → sync-configs-5DXSL64Q.js} +4 -4
  71. package/dist/{types-DnEL3NRn.d.ts → types-OIQZ5hWN.d.ts} +1 -1
  72. package/dist/{upgrade-UDAPY3SN.js → upgrade-HCR25P25.js} +5 -5
  73. package/dist/{websites-JXG3WWAU.js → websites-VITQ3WXN.js} +6 -2
  74. package/dist/{write-audits-to-airtable-7D3LV4KP.js → write-audits-to-airtable-3SOXLKJK.js} +4 -4
  75. package/package.json +1 -1
  76. package/dist/airtable-ZDHLKK5K.js +0 -9
  77. package/dist/chunk-2BHSGLWR.js.map +0 -1
  78. package/dist/chunk-KRDLHAKK.js +0 -17
  79. package/dist/chunk-KRDLHAKK.js.map +0 -1
  80. package/dist/chunk-UH5YPYS4.js.map +0 -1
  81. package/dist/chunk-VXXF4Y25.js.map +0 -1
  82. package/dist/chunk-YRM6EED7.js.map +0 -1
  83. package/dist/digest-CLFUWW62.js.map +0 -1
  84. package/dist/report-CLF2753O.js.map +0 -1
  85. /package/dist/{airtable-ZDHLKK5K.js.map → airtable-5ZPU3MT4.js.map} +0 -0
  86. /package/dist/{announce-7QHQGJPV.js.map → announce-4H64L2Z6.js.map} +0 -0
  87. /package/dist/{blux-EATHOHKK.js.map → blux-ZGDSHI3U.js.map} +0 -0
  88. /package/dist/{bump-deps-ZREAMK7M.js.map → bump-deps-2OL6PGTM.js.map} +0 -0
  89. /package/dist/{chunk-OIDDJLS7.js.map → chunk-2WN4SNBQ.js.map} +0 -0
  90. /package/dist/{chunk-HVOOCK6L.js.map → chunk-4DHQCN5H.js.map} +0 -0
  91. /package/dist/{chunk-5E4L7WKE.js.map → chunk-5P2HVNLZ.js.map} +0 -0
  92. /package/dist/{chunk-ZXRRKKMR.js.map → chunk-6V7565O2.js.map} +0 -0
  93. /package/dist/{chunk-B5ZUQZFL.js.map → chunk-ICXV25N7.js.map} +0 -0
  94. /package/dist/{chunk-4PW6ISX7.js.map → chunk-KV7DIRRO.js.map} +0 -0
  95. /package/dist/{chunk-ODC4PXZL.js.map → chunk-MQUOUVQP.js.map} +0 -0
  96. /package/dist/{chunk-LMCO7CYQ.js.map → chunk-OQFOMKBP.js.map} +0 -0
  97. /package/dist/{chunk-FFI7P74K.js.map → chunk-PBCGXXG6.js.map} +0 -0
  98. /package/dist/{chunk-T4FQ6TZT.js.map → chunk-QRDB4NLB.js.map} +0 -0
  99. /package/dist/{chunk-K5JZ5KLO.js.map → chunk-R4AB6GCT.js.map} +0 -0
  100. /package/dist/{chunk-FX7RAMGC.js.map → chunk-R6FPFMOS.js.map} +0 -0
  101. /package/dist/{chunk-MGJ72BA4.js.map → chunk-RJB3QUSP.js.map} +0 -0
  102. /package/dist/{chunk-ATJ6ZA2V.js.map → chunk-SDUJIANJ.js.map} +0 -0
  103. /package/dist/{chunk-7ADQL5Y3.js.map → chunk-THEFTZ7F.js.map} +0 -0
  104. /package/dist/{chunk-NT4WIP7I.js.map → chunk-UYCAF77E.js.map} +0 -0
  105. /package/dist/{chunk-YAMRVXOD.js.map → chunk-VRWUL4TR.js.map} +0 -0
  106. /package/dist/{chunk-4NAOR44V.js.map → chunk-ZMIPG6TR.js.map} +0 -0
  107. /package/dist/{convert-to-pnpm-UT2R6EOW.js.map → convert-to-pnpm-KCXOYOHW.js.map} +0 -0
  108. /package/dist/{ensure-site-LLPOXLWP.js.map → ensure-site-HYAIVZY2.js.map} +0 -0
  109. /package/dist/{forms-notify-target-PN6P3WPW.js.map → forms-notify-target-HSL4ZMT5.js.map} +0 -0
  110. /package/dist/{github-signals-HTH7474O.js.map → github-signals-I7XACMCE.js.map} +0 -0
  111. /package/dist/{header-image-WMHZFU7B.js.map → header-image-ADYPTGVY.js.map} +0 -0
  112. /package/dist/{health-endpoint-4E53ZMLE.js.map → health-endpoint-JNCUILHO.js.map} +0 -0
  113. /package/dist/{init-3ZJ7Z5NG.js.map → init-FLGLJCMJ.js.map} +0 -0
  114. /package/dist/{launch-FRCDOB56.js.map → launch-AVKH2JVF.js.map} +0 -0
  115. /package/dist/{lighthouse-airtable-I46QCEIS.js.map → lighthouse-airtable-CHANWVO6.js.map} +0 -0
  116. /package/dist/{onboard-45NPDMMW.js.map → onboard-HF4FTJDE.js.map} +0 -0
  117. /package/dist/{orchestrate-34JYNIXT.js.map → orchestrate-5FMNYJEA.js.map} +0 -0
  118. /package/dist/{preflight-WVD32COX.js.map → preflight-HTWIX3MN.js.map} +0 -0
  119. /package/dist/{protection-audit-2UR45SOC.js.map → protection-audit-64PLLBLI.js.map} +0 -0
  120. /package/dist/{renovate-dispatch-LOOCNZTW.js.map → renovate-dispatch-HBZG2UEU.js.map} +0 -0
  121. /package/dist/{self-updating-5TJ2NB26.js.map → self-updating-4Q65OVXY.js.map} +0 -0
  122. /package/dist/{selftest-BTCZLCTL.js.map → selftest-K5XZKVTE.js.map} +0 -0
  123. /package/dist/{smoke-suite-GHKXMKIR.js.map → smoke-suite-J3LL3JZJ.js.map} +0 -0
  124. /package/dist/{submissions-KUXLKCSQ.js.map → submissions-GMBUP4LV.js.map} +0 -0
  125. /package/dist/{svelte-codemods-YTCTLK7G.js.map → svelte-codemods-JTMX4BSX.js.map} +0 -0
  126. /package/dist/{sync-configs-KSSQZU5L.js.map → sync-configs-5DXSL64Q.js.map} +0 -0
  127. /package/dist/{upgrade-UDAPY3SN.js.map → upgrade-HCR25P25.js.map} +0 -0
  128. /package/dist/{websites-JXG3WWAU.js.map → websites-VITQ3WXN.js.map} +0 -0
  129. /package/dist/{write-audits-to-airtable-7D3LV4KP.js.map → write-audits-to-airtable-3SOXLKJK.js.map} +0 -0
@@ -0,0 +1,9 @@
1
+ import {
2
+ fromAirtableBase
3
+ } from "./chunk-2WN4SNBQ.js";
4
+ import "./chunk-U5SZ4FOP.js";
5
+ import "./chunk-CLWNAMUX.js";
6
+ export {
7
+ fromAirtableBase
8
+ };
9
+ //# sourceMappingURL=airtable-5ZPU3MT4.js.map
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  scoresFromRow
3
- } from "./chunk-4PW6ISX7.js";
3
+ } from "./chunk-KV7DIRRO.js";
4
4
  import {
5
5
  fetchGaUsers,
6
6
  fetchSearch,
7
7
  readGaConfig
8
- } from "./chunk-UH5YPYS4.js";
8
+ } from "./chunk-JVWJJ36K.js";
9
9
  import {
10
10
  queueDraft
11
11
  } from "./chunk-62VF32IC.js";
@@ -30,7 +30,7 @@ import {
30
30
  listWebsites,
31
31
  siteSlug,
32
32
  updateAnalyticsHealth
33
- } from "./chunk-YRM6EED7.js";
33
+ } from "./chunk-CLWNAMUX.js";
34
34
  import {
35
35
  openBase,
36
36
  readAirtableConfig
@@ -187,4 +187,4 @@ export {
187
187
  formatAnnounceResult,
188
188
  runAnnounceCommand
189
189
  };
190
- //# sourceMappingURL=announce-7QHQGJPV.js.map
190
+ //# sourceMappingURL=announce-4H64L2Z6.js.map
@@ -1,3 +1,7 @@
1
+ import {
2
+ assetRef,
3
+ richText
4
+ } from "./chunk-UWJZXL2D.js";
1
5
  import {
2
6
  FROZEN_PAGE_TYPE,
3
7
  extractMapConfig,
@@ -8,10 +12,6 @@ import {
8
12
  CDN_HOSTS,
9
13
  collectAssetUrls
10
14
  } from "./chunk-SL24DCGC.js";
11
- import {
12
- assetRef,
13
- richText
14
- } from "./chunk-UWJZXL2D.js";
15
15
 
16
16
  // src/cli/commands/blux.ts
17
17
  import { readFile, writeFile, mkdir } from "fs/promises";
@@ -4767,4 +4767,4 @@ ${lines.join("\n")}`,
4767
4767
  export {
4768
4768
  runBluxCommand
4769
4769
  };
4770
- //# sourceMappingURL=blux-EATHOHKK.js.map
4770
+ //# sourceMappingURL=blux-ZGDSHI3U.js.map
@@ -5,16 +5,16 @@ import {
5
5
  appendSkipNotice,
6
6
  fleetWorkdir,
7
7
  prepareFleetSites
8
- } from "./chunk-4NAOR44V.js";
8
+ } from "./chunk-ZMIPG6TR.js";
9
9
  import {
10
10
  resolveSites
11
- } from "./chunk-ODC4PXZL.js";
11
+ } from "./chunk-MQUOUVQP.js";
12
12
  import {
13
13
  bumpDeps
14
- } from "./chunk-ATJ6ZA2V.js";
14
+ } from "./chunk-SDUJIANJ.js";
15
15
  import "./chunk-ZTPPCHDB.js";
16
16
  import "./chunk-FDIFTIXC.js";
17
- import "./chunk-HVOOCK6L.js";
17
+ import "./chunk-4DHQCN5H.js";
18
18
  import "./chunk-XXTZBPUY.js";
19
19
  import "./chunk-XTK5VIQB.js";
20
20
  import "./chunk-U5SZ4FOP.js";
@@ -57,4 +57,4 @@ async function runBumpDepsCommand(site, opts) {
57
57
  export {
58
58
  runBumpDepsCommand
59
59
  };
60
- //# sourceMappingURL=bump-deps-ZREAMK7M.js.map
60
+ //# sourceMappingURL=bump-deps-2OL6PGTM.js.map
@@ -6,7 +6,7 @@ import {
6
6
  isPreLaunch,
7
7
  listWebsites,
8
8
  siteSlug
9
- } from "./chunk-YRM6EED7.js";
9
+ } from "./chunk-CLWNAMUX.js";
10
10
 
11
11
  // src/inventory/airtable.ts
12
12
  function fromAirtableBase(base, opts = {}) {
@@ -50,4 +50,4 @@ function fromAirtableBase(base, opts = {}) {
50
50
  export {
51
51
  fromAirtableBase
52
52
  };
53
- //# sourceMappingURL=chunk-OIDDJLS7.js.map
53
+ //# sourceMappingURL=chunk-2WN4SNBQ.js.map
@@ -79,6 +79,7 @@ function makeSpawn(internals = {}) {
79
79
  var defaultSpawn = makeSpawn();
80
80
 
81
81
  export {
82
+ makeSpawn,
82
83
  defaultSpawn
83
84
  };
84
- //# sourceMappingURL=chunk-HVOOCK6L.js.map
85
+ //# sourceMappingURL=chunk-4DHQCN5H.js.map
@@ -5,7 +5,7 @@ import {
5
5
  import {
6
6
  isProbablyEmail,
7
7
  parseAddresses
8
- } from "./chunk-FFI7P74K.js";
8
+ } from "./chunk-PBCGXXG6.js";
9
9
  import {
10
10
  gatingHealth,
11
11
  isSendOverridden,
@@ -14,7 +14,7 @@ import {
14
14
  import {
15
15
  listWebsites,
16
16
  siteSlug
17
- } from "./chunk-YRM6EED7.js";
17
+ } from "./chunk-CLWNAMUX.js";
18
18
  import {
19
19
  openBase,
20
20
  readAirtableConfig
@@ -339,4 +339,4 @@ export {
339
339
  preflight,
340
340
  approveBlockers
341
341
  };
342
- //# sourceMappingURL=chunk-5E4L7WKE.js.map
342
+ //# sourceMappingURL=chunk-5P2HVNLZ.js.map
@@ -1,18 +1,18 @@
1
+ import {
2
+ onboard
3
+ } from "./chunk-UYCAF77E.js";
1
4
  import {
2
5
  convertToPnpm
3
- } from "./chunk-MGJ72BA4.js";
6
+ } from "./chunk-RJB3QUSP.js";
4
7
  import {
5
8
  svelteCodemods
6
9
  } from "./chunk-LS6QPANO.js";
7
10
  import {
8
11
  healthEndpoint
9
- } from "./chunk-FX7RAMGC.js";
12
+ } from "./chunk-R6FPFMOS.js";
10
13
  import {
11
14
  smokeSuite
12
- } from "./chunk-K5JZ5KLO.js";
13
- import {
14
- onboard
15
- } from "./chunk-NT4WIP7I.js";
15
+ } from "./chunk-R4AB6GCT.js";
16
16
  import {
17
17
  syncConfigs
18
18
  } from "./chunk-Q276MZ7E.js";
@@ -21,7 +21,7 @@ import {
21
21
  } from "./chunk-ZTPPCHDB.js";
22
22
  import {
23
23
  runAudits
24
- } from "./chunk-T4FQ6TZT.js";
24
+ } from "./chunk-QRDB4NLB.js";
25
25
  import {
26
26
  siteLabel
27
27
  } from "./chunk-XXTZBPUY.js";
@@ -143,4 +143,4 @@ export {
143
143
  DEFAULT_INIT_STEPS,
144
144
  init
145
145
  };
146
- //# sourceMappingURL=chunk-ZXRRKKMR.js.map
146
+ //# sourceMappingURL=chunk-6V7565O2.js.map
@@ -75,6 +75,9 @@ function toFrequency(raw, context) {
75
75
  function toVerdict(raw) {
76
76
  return raw === "pass" || raw === "fail" ? raw : null;
77
77
  }
78
+ function toPrismicModelsVerdict(raw) {
79
+ return raw === "pass" || raw === "fail" || raw === "unknown" ? raw : null;
80
+ }
78
81
  function mapRow(rec) {
79
82
  const f = rec.fields;
80
83
  const name = String(f["Name"] ?? "");
@@ -160,7 +163,10 @@ function mapRow(rec) {
160
163
  smokeOk: toVerdict(f["Smoke OK"]),
161
164
  lastSmokeAt: f["Last Smoke At"] ?? null,
162
165
  formE2eOk: toVerdict(f["Form E2E OK"]),
163
- formE2eCheckedAt: f["Form E2E checked at"] ?? null
166
+ formE2eCheckedAt: f["Form E2E checked at"] ?? null,
167
+ prismicModels: toPrismicModelsVerdict(f["Prismic Models"]),
168
+ prismicModelsCheckedAt: f["Prismic Models Checked At"] ?? null,
169
+ prismicModelsDrift: f["Prismic Models Drift"] ?? null
164
170
  };
165
171
  }
166
172
  async function listWebsites(base) {
@@ -378,6 +384,24 @@ async function updateGitHubSignals(base, recordId, signals) {
378
384
  }
379
385
  await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
380
386
  }
387
+ var MAX_PRISMIC_DETAIL_CHARS = 5e4;
388
+ function truncatePrismicDetail(detail) {
389
+ if (detail.length <= MAX_PRISMIC_DETAIL_CHARS) return detail;
390
+ const notice = `
391
+ \u2026[truncated: this report is ${detail.length} characters and only the first ${MAX_PRISMIC_DETAIL_CHARS} are kept here. Run \`reddoor-maint prismic-models\` in the site \u2014 dry is the default \u2014 for the whole thing.]`;
392
+ let head = detail.slice(0, MAX_PRISMIC_DETAIL_CHARS - notice.length);
393
+ const last = head.charCodeAt(head.length - 1);
394
+ if (last >= 55296 && last <= 56319) head = head.slice(0, -1);
395
+ return head + notice;
396
+ }
397
+ async function updatePrismicModels(base, recordId, models) {
398
+ const fields = {
399
+ "Prismic Models": models.verdict,
400
+ "Prismic Models Checked At": models.checkedAt,
401
+ "Prismic Models Drift": models.detail === null ? null : truncatePrismicDetail(models.detail)
402
+ };
403
+ await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
404
+ }
381
405
  async function updateLaunched(base, recordId, at) {
382
406
  const fields = { Status: "maintenance", "Launched at": at };
383
407
  await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
@@ -396,6 +420,7 @@ export {
396
420
  isArchivedStatus,
397
421
  isUnrecognizedStatus,
398
422
  toVerdict,
423
+ toPrismicModelsVerdict,
399
424
  mapRow,
400
425
  listWebsites,
401
426
  getWebsiteBySlug,
@@ -413,6 +438,7 @@ export {
413
438
  updateSiteField,
414
439
  updateAuditFields,
415
440
  updateGitHubSignals,
441
+ updatePrismicModels,
416
442
  updateLaunched
417
443
  };
418
- //# sourceMappingURL=chunk-YRM6EED7.js.map
444
+ //# sourceMappingURL=chunk-CLWNAMUX.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/airtable/websites.ts"],"sourcesContent":["import type { FieldSet } from \"airtable\";\nimport type { AirtableBase } from \"./client.js\";\nimport type { LighthouseScores, LighthouseScoreWriteback } from \"../types.js\";\n\nexport const WEBSITES_TABLE = \"Websites\";\n\nexport type Frequency = \"None\" | \"Monthly\" | \"Quarterly\" | \"Yearly\";\n\nexport type Status =\n | \"in development\"\n | \"launch period\"\n | \"maintenance\"\n | \"hosting\"\n | \"probably not our problem\"\n | \"deprecated\"\n | \"legacy\";\n\n/**\n * Per-site notification routing. When present on a `maintenance` 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 status: Status | null;\n pointOfContact: string | null;\n maintenanceFreq: Frequency;\n testingFreq: Frequency;\n /** The literal Airtable cell values behind the coerced frequencies. `toFrequency`\n * trims whitespace (\"Monthly \" reads as Monthly), then maps any still-unrecognized\n * value (\"Quaterly\") to \"None\" with a LOUD console.warn so nothing bogus reaches a\n * client email and the drop is never invisible. Preflight validates THESE to surface\n * the same failure as a structured finding. Null = blank cell. */\n maintenanceFreqRaw: string | null;\n testingFreqRaw: string | null;\n /** Last manually-recorded maintenance day (used as fallback when no Reports row exists). */\n maintenanceDay: string | null;\n testingDay: string | null;\n ga4PropertyId: string | null;\n /** Operator-supplied query for the Google search-presence check (e.g. the business name).\n * Null = no query set → the check is skipped for this site. */\n searchQuery: string | null;\n /** Explicit Search Console property for this site (`sc-domain:...` or `https://.../`).\n * Null = auto-resolve from the SA's visible properties by host. */\n searchConsoleProperty: string | null;\n /** ISO timestamp of the last draft run where THIS site's GA/Search enrichment\n * ERRORED (vs a legitimate \"not configured\" skip). Set by drafting on a soft-fail,\n * cleared (null) on a clean enrichment, so the per-site analytics-failure signal\n * self-heals. Null when the operator-added `Analytics soft-fail at` column is absent. */\n analyticsSoftFailAt: string | null;\n /** GitHub repo identity as `owner/repo`. Null = no git wiring → self-update ops skip\n * (or, for local runs, fall back to the checkout's origin remote). */\n gitRepo: string | null;\n reportRecipientsTo: string | null;\n reportRecipientsCc: string | null;\n acceptedWatchConditions: string[];\n /** First attachment in the Header image field (Airtable's signed URL — fetch before expiry). */\n headerImage: { url: string; filename: string; type: string } | null;\n /** Lighthouse \"current state\" snapshot, kept fresh by `audit lighthouse --write-airtable`. */\n pScore: number | null;\n rScore: number | null;\n bpScore: number | null;\n seoScore: number | null;\n /** ISO timestamp set by `audit lighthouse --write-airtable` when scores were last refreshed. */\n lastLighthouseAuditAt: string | null;\n /** Last-known counts from non-lighthouse audits, written by\n * `audit --write-airtable`. `null` = never audited (or this audit\n * type was skipped on the last run). 0 = audited, clean. */\n a11yViolations: number | null;\n /** Declared-range drift vs the Reddoor baseline (what package.json asks for). */\n depsDrifted: number | null;\n depsMajorBehind: number | null;\n /** Real installed-version drift: deps behind the registry's latest, from the\n * committed lockfile (`pnpm outdated`). Null = not determined this run. */\n depsOutdated: number | null;\n /** Of {@link depsOutdated}, how many are a *major* version behind the\n * registry's latest — the \"majors available on npm\" signal, distinct from the\n * baseline-drift {@link depsMajorBehind}. Null = not determined this run. */\n depsMajorOutdated: number | null;\n securityVulnsCritical: number | null;\n securityVulnsHigh: number | null;\n securityVulnsModerate: number | null;\n securityVulnsLow: number | null;\n /** Count of consecutive nightly Renovate auto-fix dispatches for the CURRENT\n * critical/high vuln episode that have NOT yet cleared it. Owned by\n * `renovate-dispatch`: +1 per real dispatch, reset to 0 when vulns clear.\n * Null = field absent / never dispatched → reads as 0. At/above\n * AUTO_FIX_EXHAUSTED_CYCLES the vuln renders as \"auto-fix failed\". */\n securityAutoFixAttempts: number | null;\n /** ISO timestamp the security audit last ran — gates freshness of the Security Updates auto-tick\n * (clean counts only auto-tick when recent). */\n lastSecurityAuditAt: string | null;\n /** The known advisories behind the counts (severity-sorted, capped), so the dashboard can show\n * WHICH packages are vulnerable, not just the totals. null = never audited / unparseable;\n * empty array = audited clean. Written alongside the counts by the security audit. */\n securityAdvisories: SecurityAdvisory[] | null;\n /** Domain/DNS/SSL probe (the `domain` audit). `certDaysRemaining` is days until the TLS cert\n * expires (null = unresolved or no usable cert); `domainCheckedAt` is when it last ran. */\n certDaysRemaining: number | null;\n domainCheckedAt: string | null;\n /** Netlify site id — the IDENTITY the `netlify-deploy` audit needs to query the\n * Netlify API. Read-only input (operator-set in Airtable); null = not on Netlify\n * (or not wired) → that audit skips. Never derived from the URL. */\n netlifyId: string | null;\n /** Latest PRODUCTION deploy health (the `netlify-deploy` audit). `deployStatus` is\n * Netlify's deploy state lower-cased (`ready`/`error`/`building`/…), null = none / not\n * checked; `lastDeployAt` is when it deployed; `deployLogUrl` links to the deploy/log. */\n deployStatus: string | null;\n lastDeployAt: string | null;\n deployLogUrl: string | null;\n /** When the `netlify-deploy` audit last RAN (freshness stamp for `deployStatus`). The audit\n * already writes \"Deploy checked at\"; this read-back is the Plan-2 fix so `deployEvidence`\n * (Plan 4) can gate on check time — NOT on `lastDeployAt`, which is deploy time, not check time. */\n deployCheckedAt: string | null;\n /** Function-health verdict (the `function-health` audit): the deployed `/health` function\n * answered `ok:true` (pass) or `ok:false` (fail). Single-select `pass`/`fail`; null = never ran\n * / unreachable (→ Plan 4 maps to unknown/amber). Kept SEPARATE from `deployStatus` so\n * `isFailedDeployStatus` keeps meaning \"the build failed\". */\n functionHealth: \"pass\" | \"fail\" | null;\n /** CMS reachability (server-side), derived from the same `/health` body's `details.prismic ===\n * \"ok\"`. Single-select `pass`/`fail`; null = never ran. No per-site Prismic token or identity\n * column is ever built — this rides `/health`. */\n cmsReachable: \"pass\" | \"fail\" | null;\n /** From the same `/health` body's `forms.turnstile` boolean: does the deployed site report\n * the Turnstile widget configured (`PUBLIC_TURNSTILE_SITE_KEY` set)? Single-select\n * `pass`/`fail`; null = never ran / older site package whose `/health` has no `forms`\n * block. Freshness gated by `functionHealthCheckedAt`. Powers the cockpit Require-Turnstile\n * guardrail: a site with `requireTurnstile` ON but no widget silently buckets 100% of its\n * real leads (and form-e2e can't see it — testMode bypasses the gate). */\n turnstileWidget: \"pass\" | \"fail\" | null;\n /** When the `function-health` audit last ran — the freshness gate for `functionHealth`,\n * `cmsReachable`, and `turnstileWidget`. Null = never ran. */\n functionHealthCheckedAt: string | null;\n /** Deployed-URL browser probe (the `browser` audit): cross-engine render OK, mobile render OK,\n * internal-links OK + broken count, and when it last ran (one timestamp gates all three). */\n crossbrowserOk: boolean | null;\n mobileOk: boolean | null;\n linksOk: boolean | null;\n brokenLinks: number | null;\n browserCheckedAt: string | null;\n /** Uptime-reachable verdict (browser audit): every sampled route returned 2xx/3xx. Single-select\n * `pass`/`fail`; null = never ran. Point-in-time. Freshness-gated by `browserCheckedAt`. */\n reachableOk: \"pass\" | \"fail\" | null;\n /** Titles & meta verdict (browser audit, chromium): every sampled route has a non-empty `<title>`\n * ≤ 70 chars + a non-empty meta description, and no duplicate titles across the sample.\n * Single-select `pass`/`fail`; null = never ran. Freshness-gated by `browserCheckedAt`. */\n titleMetaOk: \"pass\" | \"fail\" | null;\n /** Per-site copy overrides (M6a). Blank → null → the DEFAULT_COPY value. */\n copyIntro: string | null;\n copyContact: string | null;\n copyFooter: string | null;\n /** Go-live timestamp, stamped when a Launch report sends (M6b). Null = not yet launched. */\n launchedAt: string | null;\n /** Optional per-site webhook (e.g. Zapier Catch Hook). When set, the ingest\n * POSTs newsletter-formType submissions here (best-effort). Blank → null. */\n newsletterWebhook: string | null;\n /** Per-site Mailchimp (newsletter). Both must be set for the direct add;\n * blank → skipped. The API key is `key-dc` format; dc is derived from it. */\n mailchimpApiKey: string | null;\n mailchimpAudienceId: string | null;\n /** Per-site Cloudflare Turnstile gate (Airtable checkbox). When true, a submission\n * whose Turnstile token verifies as \"fail\" (forged) OR \"absent\" (secret configured\n * centrally but NO token forwarded — the direct-POST-bot signature) is escalated to\n * auto-spam regardless of content score. A present-but-expired token stays\n * \"unverifiable\" and neutral (a real browser DID render the widget — fail-open).\n * ROLLOUT PRECONDITION: only enable on a site whose DEPLOYED package forwards\n * `_meta.turnstileToken` from EVERY form (widget rendered + `cf-turnstile-response`\n * posted; check the site's `/health` → forms.turnstile:true). A site that never\n * forwards tokens would silently bucket 100% of its real leads — and the form-e2e\n * probe cannot catch that state (testMode bypasses the gate). */\n requireTurnstile: boolean;\n /** GitHub-signals sweep (slice 2a), written nightly by `github-signals --fleet`. */\n renovateFailingCis: number | null;\n defaultBranchCi: string | null; // \"passing\" | \"failing\" | \"pending\" | \"none\"\n lastCommitAt: string | null;\n githubSignalsAt: string | null;\n /** Per-site smoke-suite verdict (the `smoke` audit runs `pnpm test:smoke`).\n * Single-select pass/fail; null = never ran. `lastSmokeAt` gates freshness. */\n smokeOk: \"pass\" | \"fail\" | null;\n lastSmokeAt: string | null;\n /** Synthetic form end-to-end verdict (the `form-e2e` audit submits the real prod\n * contact form in test-mode). Single-select pass/fail; null = never ran OR (with\n * a fresh `formE2eCheckedAt`) no contact form → n/a. `formE2eCheckedAt` gates\n * freshness AND encodes the n/a-vs-never-ran distinction. */\n formE2eOk: \"pass\" | \"fail\" | null;\n formE2eCheckedAt: string | null;\n /**\n * Nightly Prismic model drift sweep (`prismic-models --fleet --write-airtable`).\n *\n * FOUR-VALUED, unlike every other verdict column in this table, and the extra\n * state is the point:\n *\n * - `pass` — the repo and its Prismic repository were compared and agree.\n * - `fail` — they were compared and DIVERGE (a model to create/update, or\n * one that exists only in Prismic). A finding for a human.\n * - `unknown` — THE CHECK ITSELF FAILED. Unreadable checkout, broken config,\n * missing/expired token, unreadable remote. Nothing at all was\n * established about this site's models.\n * - `null` — no verdict to hold: the sweep has never run for this site, or\n * it ran and found no Prismic config at all (a repo that simply\n * is not a Prismic site). `prismicModelsCheckedAt` separates\n * those two — fresh timestamp, blank verdict = \"checked, and\n * there is nothing here to check\".\n *\n * `unknown` exists because the alternatives are both wrong in the dangerous\n * direction. Writing `pass` for a site nobody could read is a green tick over\n * an outage; writing NOTHING (the obvious \"leave the last known value alone\")\n * leaves yesterday's `pass` standing for a site that has been failing every\n * night since — and nothing ages a stale `pass` out, because the digest's\n * freshness gate only ever examines failures. Both make \"I could not read this\n * site\" indistinguishable from \"this site is fine\", in the record the cockpit\n * and the digest read.\n *\n * OPERATOR PRECONDITION: `Prismic Models` is a single select whose options are\n * `pass`, `fail`, `unknown`. Until the three columns exist the sweep's writes\n * fail with UNKNOWN_FIELD_NAME and are collected as soft failures — the feature\n * ships dark rather than reddening the nightly.\n */\n prismicModels: PrismicModelsVerdict | null;\n /** When the sweep last ran for this site — written on EVERY outcome, including\n * `unknown` and blank, so the age of the row is the age of the answer. */\n prismicModelsCheckedAt: string | null;\n /** What the sweep found, verbatim: the drift report for a `fail`, the reason\n * nothing could be established for an `unknown`, the skip reason for a blank,\n * and null for a `pass` (which clears yesterday's finding). */\n prismicModelsDrift: string | null;\n notifyRouting: NotifyRouting | 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. */\nfunction 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>([\n \"maintenance\",\n \"launch period\",\n]);\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 → \"maintenance\" at go-live\n * (updateLaunched), so \"maintenance\" 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 */\nexport const PRE_LAUNCH_STATUSES: ReadonlySet<Status> = new Set<Status>([\n \"in development\",\n \"launch period\",\n]);\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. Typed ReadonlySet<string> so a\n * blind-cast typo'd cell can be probed without a cast. */\nexport const KNOWN_STATUSES: ReadonlySet<string> = new Set<Status>([\n \"in development\",\n \"launch period\",\n \"maintenance\",\n \"hosting\",\n \"probably not our problem\",\n \"deprecated\",\n \"legacy\",\n]);\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. */\nexport const ARCHIVED_STATUSES: ReadonlySet<Status> = new Set<Status>([\"legacy\", \"deprecated\"]);\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. */\nfunction toFrequency(raw: unknown, context: string): Frequency {\n if (typeof raw !== \"string\") return \"None\";\n const trimmed = raw.trim();\n if ((FREQUENCIES as readonly string[]).includes(trimmed)) return trimmed as Frequency;\n if (trimmed !== \"\") {\n console.warn(\n `⚠ ${context}: unrecognized frequency '${raw}' — treating as None (not scheduling); fix the Airtable value`,\n );\n }\n return \"None\";\n}\n\n/** Coerce an Airtable tri-state single-select verdict cell (`pass`/`fail`/blank) to\n * `\"pass\" | \"fail\" | null`. Any value other than the literal strings \"pass\"/\"fail\" — blank,\n * an unrecognized option, a typo, wrong type — reads as null (\"never ran\"), never guessed. The\n * ONE shared reader for every verdict column of this shape: `Function health`, `CMS Reachable`,\n * `Uptime Reachable`, `Titles & Meta OK` (Plan 2), and reused (not redeclared) by Plan 3's\n * `Smoke OK` / `Form E2E OK` read-backs. */\nexport function toVerdict(raw: unknown): \"pass\" | \"fail\" | null {\n return raw === \"pass\" || raw === \"fail\" ? raw : null;\n}\n\n/** The `Prismic Models` cell — see {@link WebsiteRow.prismicModels} for why it\n * carries a third state that no other verdict column in this table has. */\nexport type PrismicModelsVerdict = \"pass\" | \"fail\" | \"unknown\";\n\n/**\n * Read the `Prismic Models` cell. Deliberately NOT {@link toVerdict}.\n *\n * `toVerdict` maps anything that is not literally `pass`/`fail` to null — which\n * for this column would read `unknown` (\"the check ran and failed\") back as null\n * (\"the check never ran\"), silently, on the way to the cockpit. That is the\n * pipeline's governing failure (\"I could not read X\" wearing the face of \"X does\n * not exist\") reintroduced by a shared helper that predates the third state.\n *\n * Everything else — blank, a typo, an option somebody added by hand, the wrong\n * type — still reads as null. A verdict is never guessed.\n */\nexport function toPrismicModelsVerdict(raw: unknown): PrismicModelsVerdict | null {\n return raw === \"pass\" || raw === \"fail\" || raw === \"unknown\" ? raw : null;\n}\n\n// NOTE: every `f[\"...\"]` key below is a load-bearing magic string that must match\n// the live Airtable \"Websites\" column name EXACTLY — including the legacy\n// misspelling `\"maintenence freq\"`, the mixed-case `\"GA4 property ID\"`, and the\n// lowercase `\"url\"` / `\"point of contact\"`. A column rename in Airtable silently\n// returns undefined here (→ null), which degrades quietly (GA skipped, recipients\n// empty) with no error. If you rename a column, change it here too.\nexport function mapRow(rec: { id: string; fields: Record<string, unknown> }): WebsiteRow {\n const f = rec.fields;\n const name = String(f[\"Name\"] ?? \"\");\n const attachments =\n (f[\"Header image\"] as Array<{ url: string; filename: string; type: string }> | undefined) ?? [];\n const header = attachments[0] ?? null;\n return {\n id: rec.id,\n name,\n url: String(f[\"url\"] ?? \"\"),\n status: (f[\"Status\"] as Status | undefined) ?? null,\n pointOfContact: (f[\"point of contact\"] as string | undefined) ?? null,\n maintenanceFreq: toFrequency(f[\"maintenence freq\"], `${name} maintenance`),\n testingFreq: toFrequency(f[\"testing freq\"], `${name} testing`),\n maintenanceFreqRaw: (f[\"maintenence freq\"] as string | undefined) ?? null,\n testingFreqRaw: (f[\"testing freq\"] as string | undefined) ?? null,\n maintenanceDay: (f[\"maintenance day\"] as string | undefined) ?? null,\n testingDay: (f[\"testing day\"] as string | undefined) ?? null,\n ga4PropertyId: (f[\"GA4 property ID\"] as string | undefined) ?? null,\n searchQuery: (f[\"Search query\"] as string | undefined) ?? null,\n searchConsoleProperty: (f[\"Search Console property\"] as string | undefined) ?? null,\n analyticsSoftFailAt: (f[\"Analytics soft-fail at\"] as string | undefined) ?? null,\n gitRepo: (f[\"Git repo\"] as string | undefined) ?? null,\n reportRecipientsTo: (f[\"Report recipients (To)\"] as string | undefined) ?? null,\n reportRecipientsCc: (f[\"Report recipients (CC)\"] as string | undefined) ?? null,\n // Tolerate BOTH the current Multiple-Select array shape AND a delimited long-text\n // string (comma/newline separated), so the field can migrate to a plain text column\n // with no code change here. Trim + drop empties either way. The array branch also\n // validates ELEMENT types: a collaborator/attachment-shaped field passes\n // Array.isArray with OBJECT elements, and an unchecked cast would make assignTier's\n // `.trim()` throw — one misconfigured row must not 500 the whole cockpit build.\n acceptedWatchConditions: Array.isArray(f[\"Accepted Watch Conditions\"])\n ? (f[\"Accepted Watch Conditions\"] as unknown[])\n .filter((x): x is string => typeof x === \"string\")\n .map((s) => s.trim())\n .filter(Boolean)\n : typeof f[\"Accepted Watch Conditions\"] === \"string\"\n ? (f[\"Accepted Watch Conditions\"] as string)\n .split(/[\\n,]/)\n .map((s) => s.trim())\n .filter(Boolean)\n : [],\n headerImage: header,\n pScore: (f[\"pScore\"] as number | undefined) ?? null,\n rScore: (f[\"rScore\"] as number | undefined) ?? null,\n bpScore: (f[\"bpScore\"] as number | undefined) ?? null,\n seoScore: (f[\"seoScore\"] as number | undefined) ?? null,\n lastLighthouseAuditAt: (f[\"Last lighthouse audit at\"] as string | undefined) ?? null,\n a11yViolations: (f[\"A11y Violations\"] as number | undefined) ?? null,\n depsDrifted: (f[\"Deps Drifted\"] as number | undefined) ?? null,\n depsMajorBehind: (f[\"Deps Major Behind\"] as number | undefined) ?? null,\n depsOutdated: (f[\"Deps Outdated\"] as number | undefined) ?? null,\n depsMajorOutdated: (f[\"Deps Major Outdated\"] as number | undefined) ?? null,\n securityVulnsCritical: (f[\"Security Vulns Critical\"] as number | undefined) ?? null,\n securityVulnsHigh: (f[\"Security Vulns High\"] as number | undefined) ?? null,\n securityVulnsModerate: (f[\"Security Vulns Moderate\"] as number | undefined) ?? null,\n securityVulnsLow: (f[\"Security Vulns Low\"] as number | undefined) ?? null,\n securityAutoFixAttempts: (f[\"Security Auto-Fix Attempts\"] as number | undefined) ?? null,\n lastSecurityAuditAt: (f[\"Last security audit at\"] as string | undefined) ?? null,\n securityAdvisories: parseSecurityAdvisories(f[\"Security advisories\"]),\n certDaysRemaining: (f[\"Cert days remaining\"] as number | undefined) ?? null,\n domainCheckedAt: (f[\"Domain checked at\"] as string | undefined) ?? null,\n netlifyId: trimToNull(f[\"Netlify ID\"]),\n deployStatus: (f[\"Deploy status\"] as string | undefined) ?? null,\n lastDeployAt: (f[\"Last deploy at\"] as string | undefined) ?? null,\n deployLogUrl: (f[\"Deploy log URL\"] as string | undefined) ?? null,\n deployCheckedAt: (f[\"Deploy checked at\"] as string | undefined) ?? null,\n functionHealth: toVerdict(f[\"Function health\"]),\n cmsReachable: toVerdict(f[\"CMS Reachable\"]),\n turnstileWidget: toVerdict(f[\"Turnstile widget\"]),\n functionHealthCheckedAt: (f[\"Function health checked at\"] as string | undefined) ?? null,\n crossbrowserOk:\n typeof f[\"Crossbrowser OK\"] === \"boolean\" ? (f[\"Crossbrowser OK\"] as boolean) : null,\n mobileOk: typeof f[\"Mobile OK\"] === \"boolean\" ? (f[\"Mobile OK\"] as boolean) : null,\n linksOk: typeof f[\"Links OK\"] === \"boolean\" ? (f[\"Links OK\"] as boolean) : null,\n brokenLinks: typeof f[\"Broken links\"] === \"number\" ? (f[\"Broken links\"] as number) : null,\n browserCheckedAt: (f[\"Browser checked at\"] as string | undefined) ?? null,\n reachableOk: toVerdict(f[\"Uptime Reachable\"]),\n titleMetaOk: toVerdict(f[\"Titles & Meta OK\"]),\n copyIntro: trimToNull(f[\"Copy — Intro\"]),\n copyContact: trimToNull(f[\"Copy — Contact\"]),\n copyFooter: trimToNull(f[\"Copy — Footer\"]),\n launchedAt: (f[\"Launched at\"] as string | undefined) ?? null,\n newsletterWebhook: trimToNull(f[\"Newsletter Webhook\"]),\n notifyRouting: parseNotifyRouting(f[\"Notify Routing\"]),\n mailchimpApiKey: trimToNull(f[\"Mailchimp API Key\"]),\n mailchimpAudienceId: trimToNull(f[\"Mailchimp Audience ID\"]),\n // Boolean guard like crossbrowserOk, but defaults FALSE (not null) when absent: an\n // unset/unknown column must read as \"not required\" so the feature ships dark.\n requireTurnstile:\n typeof f[\"Require Turnstile\"] === \"boolean\" ? (f[\"Require Turnstile\"] as boolean) : false,\n renovateFailingCis: (f[\"Renovate Failing CIs\"] as number | undefined) ?? null,\n defaultBranchCi: (f[\"Default Branch CI\"] as string | undefined) ?? null,\n lastCommitAt: (f[\"Last Commit At\"] as string | undefined) ?? null,\n githubSignalsAt: (f[\"GitHub Signals At\"] as string | undefined) ?? null,\n smokeOk: toVerdict(f[\"Smoke OK\"]),\n lastSmokeAt: (f[\"Last Smoke At\"] as string | undefined) ?? null,\n formE2eOk: toVerdict(f[\"Form E2E OK\"]),\n formE2eCheckedAt: (f[\"Form E2E checked at\"] as string | undefined) ?? null,\n prismicModels: toPrismicModelsVerdict(f[\"Prismic Models\"]),\n prismicModelsCheckedAt: (f[\"Prismic Models Checked At\"] as string | undefined) ?? null,\n prismicModelsDrift: (f[\"Prismic Models Drift\"] as string | undefined) ?? null,\n };\n}\n\nexport async function listWebsites(base: AirtableBase): Promise<WebsiteRow[]> {\n const out: WebsiteRow[] = [];\n await base(WEBSITES_TABLE)\n .select({ pageSize: 100 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return out;\n}\n\nexport async function getWebsiteBySlug(\n base: AirtableBase,\n slug: string,\n): Promise<WebsiteRow | null> {\n // Slugs are siteSlug() output: [a-z0-9] segments joined by single hyphens.\n // Reject anything else — it can't match a real row, and it keeps URL-supplied\n // input out of the filter formula below (formula-injection guard).\n if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) return null;\n\n // Narrow the fetch to the slug-matching row server-side instead of paging the\n // whole table per request (MEDIUM-H). The formula replicates siteSlug() on\n // {Name} — lowercase → non-alnum runs to \"-\" → strip leading/trailing \"-\" —\n // verified against the live base. maxRecords caps it (slug collisions keep the\n // prior first-match-wins behavior).\n const formula = `REGEX_REPLACE(REGEX_REPLACE(LOWER({Name}),\"[^a-z0-9]+\",\"-\"),\"^-|-$\",\"\")=${JSON.stringify(\n slug,\n )}`;\n const rows: WebsiteRow[] = [];\n await base(WEBSITES_TABLE)\n .select({ filterByFormula: formula, maxRecords: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n // Confirm the match in JS too: keeps the function correct if the formula and\n // siteSlug() ever drift, and under test fakes that don't evaluate the formula.\n return rows.find((w) => siteSlug(w.name) === slug) ?? null;\n}\n\n// ── audit-field builders ─────────────────────────────────────────────────────\n// One source of truth for the column-name → value mappings of each audit type.\n// The per-audit `updateXxxCounts` writers delegate to these (for their other\n// callers), and `updateAuditFields` merges whichever are present into ONE write —\n// so the field-name magic strings live in exactly one place.\n\nexport type A11yCounts = { violations: number };\nexport type DepsCounts = {\n drifted: number;\n majorBehind: number;\n outdated: number | null;\n majorOutdated: number | null;\n};\nexport type SecurityCounts = { critical: number; high: number; moderate: number; low: number };\nexport type Severity = \"low\" | \"moderate\" | \"high\" | \"critical\";\n/** One known vulnerability behind the security counts, as persisted/rendered. */\nexport type SecurityAdvisory = {\n module: string;\n severity: Severity;\n title: string;\n cves: string[];\n url: string | null;\n /** GitHub Dependabot dependency scope (\"runtime\" | \"development\"), when known. Lets the\n * dashboard flag build-time-only (\"development\") vulns. Omitted for advisories from the\n * lockfile `pnpm audit` fallback, which carries no per-package graph scope. */\n scope?: \"runtime\" | \"development\";\n /** GitHub Dependabot dependency-graph relationship, when known. \"transitive\" means no\n * direct-dep bump can fix it — Renovate's vuln alerts have no fix vehicle until the weekly\n * lockfile-maintenance window. Omitted when GitHub reports \"unknown\" or for the\n * `pnpm audit` fallback. Drives {@link allActionableVulnsTransitive}. */\n relationship?: \"direct\" | \"transitive\";\n};\nexport type DomainResult = { certDaysRemaining: number | null; checkedAt: string };\nexport type NetlifyDeployResult = {\n /** Netlify deploy state lower-cased (`ready`/`error`/`building`/…), null = none. */\n state: string | null;\n /** When the latest production deploy was published/created (ISO), null = unknown. */\n deployedAt: string | null;\n /** Link to the deploy / its build log, null = unknown. */\n logUrl: string | null;\n /** When the audit last ran. */\n checkedAt: string;\n};\nexport type BrowserAuditFields = {\n desktopOk: boolean;\n mobileOk: boolean;\n linksOk: boolean;\n reachableOk: boolean;\n titleMetaOk: boolean;\n brokenLinks: number;\n checkedAt: string;\n};\nexport type FunctionHealthResult = {\n /** `pass` when `/health` answered `ok:true`, else `fail`. Never null — the audit only produces a\n * result when it ran (a self-skip carries no details, so this extractor isn't reached). */\n functionHealth: \"pass\" | \"fail\";\n /** From the same body's `prismic` sub-status (R2.2): `\"ok\"` → `\"pass\"`, `\"error\"` → `\"fail\"`.\n * Anything else (`\"skipped\"` — a placeholder repo with no live Prismic — or a raw `null`, e.g.\n * the synthetic \"deployed but erroring\" body) → `null`: the CMS probe never actually ran, so it\n * must NOT red CMS reachability for a site that simply hasn't wired Prismic yet. */\n cmsReachable: \"pass\" | \"fail\" | null;\n /** From the same body's `forms.turnstile` boolean: `true` → `\"pass\"`, `false` → `\"fail\"`.\n * A missing/null `forms` block (older site package, or the synthetic \"deployed but\n * erroring\" body) → `null` — the widget state is simply unknown, not a failure. */\n turnstileWidget: \"pass\" | \"fail\" | null;\n /** When the audit ran (freshness stamp for all three verdicts). */\n checkedAt: string;\n};\n\nexport type SmokeResult = { ok: \"pass\" | \"fail\"; checkedAt: string };\n\n/** `ok` null clears the single-select cell (n/a — no contact form); a fresh\n * `checkedAt` still stamps the row so Plan 4 reads null+fresh as n/a. */\nexport type FormE2eResult = { ok: \"pass\" | \"fail\" | null; checkedAt: string };\n\nfunction scoreFields(scores: LighthouseScoreWriteback): FieldSet {\n // A null score CLEARS the cell (→ dashboard \"—\"), distinguishing a metric that\n // errored this run (e.g. NO_LCP → null performance) from a real low score. This\n // intentionally overwrites a prior value: a run that couldn't measure the metric\n // shouldn't keep showing a stale number. Writing null to clear mirrors\n // updateAnalyticsHealth; the `as FieldSet` cast is needed because airtable's\n // FieldSet type omits null.\n const fields: Record<string, number | string | null> = {\n pScore: scores.performance,\n rScore: scores.accessibility,\n bpScore: scores.bestPractices,\n seoScore: scores.seo,\n \"Last lighthouse audit at\": new Date().toISOString(),\n };\n return fields as FieldSet;\n}\n\nfunction a11yFields(counts: A11yCounts): FieldSet {\n return { \"A11y Violations\": counts.violations };\n}\n\nfunction depsFields(counts: DepsCounts): FieldSet {\n const fields: FieldSet = {\n \"Deps Drifted\": counts.drifted,\n \"Deps Major Behind\": counts.majorBehind,\n };\n // Only write the outdated count when it was determined — a null (no/stale\n // lockfile this run) must not clobber a previously-good value.\n if (counts.outdated !== null) {\n fields[\"Deps Outdated\"] = counts.outdated;\n }\n // Same null-guard for the registry-major breakdown (same source signal).\n if (counts.majorOutdated !== null) {\n fields[\"Deps Major Outdated\"] = counts.majorOutdated;\n }\n return fields;\n}\n\n/** Critical first → low last; the persist cap and the dashboard list both lean on this. */\nexport const SEVERITY_RANK: Record<Severity, number> = {\n critical: 0,\n high: 1,\n moderate: 2,\n low: 3,\n};\n/** Cap persisted advisories so the JSON field can't blow up on a badly-neglected site. */\nconst MAX_PERSISTED_ADVISORIES = 25;\n\n/** Coerce one untrusted value into a SecurityAdvisory, or null if it isn't shaped like one.\n * Shared by the read path (`parseSecurityAdvisories`) and the write path so both validate\n * identically. A missing module or unrecognized severity drops the entry entirely. */\nexport function normalizeSecurityAdvisory(raw: unknown): SecurityAdvisory | null {\n if (!raw || typeof raw !== \"object\") return null;\n const e = raw as Record<string, unknown>;\n const module = typeof e[\"module\"] === \"string\" ? (e[\"module\"] as string) : null;\n const severity = e[\"severity\"];\n if (module === null) return null;\n if (\n severity !== \"low\" &&\n severity !== \"moderate\" &&\n severity !== \"high\" &&\n severity !== \"critical\"\n )\n return null;\n const cves = Array.isArray(e[\"cves\"])\n ? (e[\"cves\"] as unknown[]).filter((c): c is string => typeof c === \"string\")\n : [];\n const scope =\n e[\"scope\"] === \"runtime\" || e[\"scope\"] === \"development\"\n ? (e[\"scope\"] as \"runtime\" | \"development\")\n : undefined;\n const relationship =\n e[\"relationship\"] === \"direct\" || e[\"relationship\"] === \"transitive\"\n ? (e[\"relationship\"] as \"direct\" | \"transitive\")\n : undefined;\n return {\n module,\n severity,\n title: typeof e[\"title\"] === \"string\" ? (e[\"title\"] as string) : \"\",\n cves,\n url: typeof e[\"url\"] === \"string\" ? (e[\"url\"] as string) : null,\n ...(scope ? { scope } : {}),\n ...(relationship ? { relationship } : {}),\n };\n}\n\n/**\n * True iff the persisted advisory detail proves EVERY actionable (critical/high) vuln is a\n * TRANSITIVE dependency — i.e. Renovate's vulnerability alerts have no direct-dep bump to\n * open, and the fix rides the weekly lockfile-maintenance window instead. Deliberately\n * conservative: `null` detail (never audited / `pnpm audit` fallback), an advisory missing\n * its `relationship` (unknown / pre-existing JSON), or a counts-vs-detail mismatch (no\n * critical/high advisory in the list) all return false — missing data must never mute the\n * auto-fix-failed escalation. Consumed by the attempts counter (don't count a known no-op\n * dispatch as a failed fix attempt) and the digest (say \"transitive-only\", not\n * \"auto-fix failed\"). The persist cap keeps advisories sorted critical-first, so\n * critical/high entries are never the ones capped away.\n */\nexport function allActionableVulnsTransitive(advisories: SecurityAdvisory[] | null): boolean {\n if (!advisories) return false;\n const actionable = advisories.filter((a) => a.severity === \"critical\" || a.severity === \"high\");\n if (actionable.length === 0) return false;\n return actionable.every((a) => a.relationship === \"transitive\");\n}\n\n/** Parse the `Security advisories` JSON cell. null when absent/blank/unparseable/not-an-array\n * (treated as \"never audited\"); a valid array (possibly empty = audited clean) otherwise. */\nexport function parseSecurityAdvisories(raw: unknown): SecurityAdvisory[] | null {\n if (typeof raw !== \"string\" || raw.trim() === \"\") return null;\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return null;\n }\n if (!Array.isArray(parsed)) return null;\n return parsed.map(normalizeSecurityAdvisory).filter((a): a is SecurityAdvisory => a !== null);\n}\n\nfunction securityAdvisoryFields(advisories: SecurityAdvisory[]): FieldSet {\n const capped = [...advisories]\n .sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])\n .slice(0, MAX_PERSISTED_ADVISORIES);\n // Always write — an empty array (\"[]\") clears a stale list when a once-vulnerable site goes clean.\n return { \"Security advisories\": JSON.stringify(capped) };\n}\n\nfunction securityFields(counts: SecurityCounts): FieldSet {\n return {\n \"Security Vulns Critical\": counts.critical,\n \"Security Vulns High\": counts.high,\n \"Security Vulns Moderate\": counts.moderate,\n \"Security Vulns Low\": counts.low,\n // Stamp freshness alongside the counts so the Security Updates auto-tick can require a recent\n // audit (a clean count from months ago must not silently keep ticking the box).\n \"Last security audit at\": new Date().toISOString(),\n };\n}\n\nfunction domainFields(result: DomainResult): FieldSet {\n const fields: FieldSet = { \"Domain checked at\": result.checkedAt };\n // Write the cert days UNCONDITIONALLY: a null (unresolved / no usable cert) must CLEAR any\n // previously-good value. Leaving a stale number in place — next to a freshly-stamped \"Domain\n // checked at\" — false-passes the Domain/DNS/SSL auto-tick (domainEvidence reads the stale\n // non-null value as a current pass) for a site that's actually down. null clears the cell in\n // Airtable; FieldSet's type doesn't model null, hence the cast through a widened record.\n (fields as Record<string, number | null>)[\"Cert days remaining\"] = result.certDaysRemaining;\n return fields;\n}\n\nfunction netlifyDeployFields(result: NetlifyDeployResult): FieldSet {\n // Write all three deploy fields UNCONDITIONALLY (null clears the cell): a null state /\n // deployedAt / logUrl this run means \"couldn't read it\" and must not leave a STALE value\n // sitting next to a fresh \"Deploy checked at\" — the dashboard would otherwise render a green\n // \"ready\" badge for a site whose latest deploy actually errored. FieldSet's type doesn't model\n // null, hence the cast through a widened record (same approach as domainFields).\n const fields: Record<string, string | null> = {\n \"Deploy status\": result.state,\n \"Last deploy at\": result.deployedAt,\n \"Deploy log URL\": result.logUrl,\n \"Deploy checked at\": result.checkedAt,\n };\n return fields as FieldSet;\n}\n\nfunction browserFields(r: BrowserAuditFields): FieldSet {\n return {\n \"Crossbrowser OK\": r.desktopOk,\n \"Mobile OK\": r.mobileOk,\n \"Links OK\": r.linksOk,\n \"Broken links\": r.brokenLinks,\n \"Browser checked at\": r.checkedAt,\n // NEW tri-state single-select verdicts (empty = never ran). The browser audit only produces a\n // BrowserAuditFields when it actually ran (hasBrowserResult guards on checkedAt), so each verdict\n // is always a concrete boolean here — serialize true→\"pass\", false→\"fail\". The existing boolean\n // columns above are deliberately NOT retrofitted (out of scope).\n \"Uptime Reachable\": r.reachableOk ? \"pass\" : \"fail\",\n \"Titles & Meta OK\": r.titleMetaOk ? \"pass\" : \"fail\",\n };\n}\n\nfunction functionHealthFields(r: FunctionHealthResult): FieldSet {\n // \"CMS Reachable\" is written UNCONDITIONALLY (null clears the cell): the audit only supplies a\n // result when it ran, but `cmsReachable` itself can legitimately be null this run (R2.2 — the CMS\n // probe never actually happened, e.g. a placeholder repo with no live Prismic). Clearing rather\n // than leaving a stale pass/fail sitting next to a freshly-stamped checked-at is what keeps a\n // placeholder site from showing a stale CMS verdict. FieldSet's type doesn't model null, hence the\n // cast through a widened record (same approach as domainFields/netlifyDeployFields).\n // \"Function health\" is never null — the audit only writes when it ran, so it's always a concrete\n // pass/fail. Written SEPARATELY from \"Deploy status\" so the Netlify build state keeps its own\n // meaning.\n const fields: Record<string, string | null> = {\n \"Function health\": r.functionHealth,\n \"CMS Reachable\": r.cmsReachable,\n // Same null-clears semantics as \"CMS Reachable\": a body without a forms block means the\n // widget state is unknown THIS run — clearing beats a stale verdict next to a fresh stamp.\n \"Turnstile widget\": r.turnstileWidget,\n \"Function health checked at\": r.checkedAt,\n };\n return fields as FieldSet;\n}\n\nfunction smokeFields(r: SmokeResult): FieldSet {\n // The verdict is stored as the literal single-select option (\"pass\"/\"fail\"), so\n // no boolean→string coercion is needed. A skip never reaches here (it produces no\n // SmokeResult), so this column is only ever written with a concrete verdict.\n return { \"Smoke OK\": r.ok, \"Last Smoke At\": r.checkedAt };\n}\n\nfunction formE2eFields(r: FormE2eResult): FieldSet {\n // `ok` is already the single-select value (\"pass\"/\"fail\") or null. Writing null\n // CLEARS the cell (→ n/a, distinguished from \"never ran\" by the fresh checked-at\n // stamped alongside). FieldSet's type omits null, hence the widened-record cast\n // (same approach as domainFields / netlifyDeployFields).\n const fields: Record<string, string | null> = {\n \"Form E2E OK\": r.ok,\n \"Form E2E checked at\": r.checkedAt,\n };\n return fields as FieldSet;\n}\n\n/**\n * Write the four Lighthouse scores + a refreshed-at timestamp onto a Websites row.\n * Called by `audit lighthouse --write-airtable` after a successful audit run, so\n * the operator never has to paste numbers manually before drafting a report.\n */\nexport async function updateScores(\n base: AirtableBase,\n recordId: string,\n scores: LighthouseScores,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: scoreFields(scores) }]);\n}\n\n/**\n * Record (or clear) the per-site GA/Search enrichment health on the `Analytics\n * soft-fail at` column. `at` is an ISO timestamp when the site's last draft had a\n * GA/Search soft-failure, or `null` to clear it after a clean enrichment. The\n * caller (drafting) swallows errors: this column is operator-added, so until it\n * exists Airtable throws UNKNOWN_FIELD_NAME — which must not break a draft.\n */\nexport async function updateAnalyticsHealth(\n base: AirtableBase,\n recordId: string,\n at: string | null,\n): Promise<void> {\n const fields: Record<string, string | null> = { \"Analytics soft-fail at\": at };\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: fields as FieldSet }]);\n}\n\n/** Persist a11y violation count. */\nexport async function updateA11yCounts(\n base: AirtableBase,\n recordId: string,\n counts: A11yCounts,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: a11yFields(counts) }]);\n}\n\n/** Persist deps drift counts (declared-range drift + real outdated installs). */\nexport async function updateDepsCounts(\n base: AirtableBase,\n recordId: string,\n counts: DepsCounts,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: depsFields(counts) }]);\n}\n\n/** Persist security vulnerability counts by severity. */\nexport async function updateSecurityCounts(\n base: AirtableBase,\n recordId: string,\n counts: SecurityCounts,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: securityFields(counts) }]);\n}\n\n/** Persist a site's auto-fix attempt counter. Its own one-field writer so the\n * nightly Renovate dispatch can update it without touching the audit's counts. */\nexport async function updateAutoFixAttempts(\n base: AirtableBase,\n recordId: string,\n attempts: number,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([\n { id: recordId, fields: { \"Security Auto-Fix Attempts\": attempts } },\n ]);\n}\n\n/**\n * Persist the code-computed next-due dates (date-only `YYYY-MM-DD`, or `null` to\n * clear) for the maintenance + testing schedules. Owned by the nightly `--due` sweep\n * so the \"next\" dates shown in Airtable come from the SAME logic as the scheduler\n * (`nextDueDate`) — no Airtable-side formula or automation. Best-effort at the call\n * site: the `Next … at` columns are operator-added, so until they exist Airtable\n * throws UNKNOWN_FIELD_NAME, which must not break the nightly draft run.\n */\nexport async function updateNextDueDates(\n base: AirtableBase,\n recordId: string,\n dates: { maintenanceAt: string | null; testingAt: string | null },\n): Promise<void> {\n const fields: Record<string, string | null> = {\n \"Next maintenance at\": dates.maintenanceAt,\n \"Next testing at\": dates.testingAt,\n };\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: fields as FieldSet }]);\n}\n\n/** Generic single-field writer for the dashboard site-details editor. The caller\n * (setSiteDetail) restricts `column` to the EDITABLE_SITE_FIELDS allowlist, so this\n * never writes an arbitrary column from request input. */\nexport async function updateSiteField(\n base: AirtableBase,\n recordId: string,\n column: string,\n value: string,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: { [column]: value } }]);\n}\n\n/**\n * Persist all of a single audit run's results to one Websites row in ONE atomic\n * `update()` — instead of up to four sequential updates on the same id (which left\n * a row half-written on a mid-sequence failure and quadrupled the request volume).\n * Pass only the audit slices that produced real values; each present slice is merged\n * via the SAME field mappings the per-audit writers use. Omit a slice (or pass\n * undefined) to leave those columns untouched. Returns the merged FieldSet so the\n * caller can enumerate what was written.\n */\nexport async function updateAuditFields(\n base: AirtableBase,\n recordId: string,\n audits: {\n scores?: LighthouseScoreWriteback;\n a11y?: A11yCounts;\n deps?: DepsCounts;\n security?: SecurityCounts;\n securityAdvisories?: SecurityAdvisory[];\n domain?: DomainResult;\n browser?: BrowserAuditFields;\n netlifyDeploy?: NetlifyDeployResult;\n functionHealth?: FunctionHealthResult;\n smoke?: SmokeResult;\n formE2e?: FormE2eResult;\n },\n): Promise<FieldSet> {\n const fields: FieldSet = {};\n if (audits.scores) Object.assign(fields, scoreFields(audits.scores));\n if (audits.a11y) Object.assign(fields, a11yFields(audits.a11y));\n if (audits.deps) Object.assign(fields, depsFields(audits.deps));\n if (audits.security) Object.assign(fields, securityFields(audits.security));\n // Separate slice (not folded into `security`) so the advisory list and the counts can be\n // written independently, but in practice the security audit supplies both together.\n if (audits.securityAdvisories)\n Object.assign(fields, securityAdvisoryFields(audits.securityAdvisories));\n if (audits.domain) Object.assign(fields, domainFields(audits.domain));\n if (audits.browser) Object.assign(fields, browserFields(audits.browser));\n if (audits.netlifyDeploy) Object.assign(fields, netlifyDeployFields(audits.netlifyDeploy));\n if (audits.functionHealth) Object.assign(fields, functionHealthFields(audits.functionHealth));\n if (audits.smoke) Object.assign(fields, smokeFields(audits.smoke));\n if (audits.formE2e) Object.assign(fields, formE2eFields(audits.formE2e));\n await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);\n return fields;\n}\n\n/** Persist the GitHub-signals sweep onto a Websites row (slice 2a). A null\n * `lastCommitAt` is OMITTED so a not-determined-this-run value never clobbers a\n * previously-good timestamp (mirrors updateDepsCounts' outdated handling). */\nexport async function updateGitHubSignals(\n base: AirtableBase,\n recordId: string,\n signals: {\n renovateFailingCis: number;\n ciState: string;\n lastCommitAt: string | null;\n sweptAt: string;\n },\n): Promise<void> {\n const fields: FieldSet = {\n \"Renovate Failing CIs\": signals.renovateFailingCis,\n \"Default Branch CI\": signals.ciState,\n \"GitHub Signals At\": signals.sweptAt,\n };\n if (signals.lastCommitAt !== null) {\n fields[\"Last Commit At\"] = signals.lastCommitAt;\n }\n await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);\n}\n\n/** One site's Prismic model verdict, as the sweep hands it to the record.\n *\n * `verdict: null` is a REQUEST TO BLANK the cell, not \"leave it alone\" — see\n * {@link updatePrismicModels}. There is no way to express \"leave it alone\" here\n * on purpose. */\nexport type PrismicModelsWriteback = {\n verdict: PrismicModelsVerdict | null;\n checkedAt: string;\n /** The finding, the failure reason, or the skip reason — whichever this\n * verdict has. `null` clears the column. */\n detail: string | null;\n};\n\n/** Airtable's long-text cells hold ~100k characters and the sweep's report runs\n * long on a badly drifted site (an empty Prismic repository sorts every local\n * model into `toCreate`, with a line per field). Half the cell is the budget;\n * the rest is headroom for whatever renders it. */\nconst MAX_PRISMIC_DETAIL_CHARS = 50_000;\n\n/**\n * Shorten a detail that will not fit, and SAY SO — with the original length, so\n * nobody mistakes the remainder for the whole. Same reasoning as the PR comment's\n * truncation notice: a report that was shortened without saying it was shortened\n * is a report that looks complete and is not.\n *\n * The cut is by code unit, so it can land between the halves of an astral\n * character; a lone surrogate is not valid text and can make Airtable reject the\n * write, losing the entire finding to a cosmetic detail.\n */\nfunction truncatePrismicDetail(detail: string): string {\n if (detail.length <= MAX_PRISMIC_DETAIL_CHARS) return detail;\n const notice =\n `\\n…[truncated: this report is ${detail.length} characters and only the first` +\n ` ${MAX_PRISMIC_DETAIL_CHARS} are kept here. Run \\`reddoor-maint prismic-models\\` in the` +\n ` site — dry is the default — for the whole thing.]`;\n let head = detail.slice(0, MAX_PRISMIC_DETAIL_CHARS - notice.length);\n const last = head.charCodeAt(head.length - 1);\n if (last >= 0xd800 && last <= 0xdbff) head = head.slice(0, -1);\n return head + notice;\n}\n\n/**\n * Persist one site's Prismic model verdict.\n *\n * ALL THREE COLUMNS, ALWAYS, in one update — including when the verdict is\n * `unknown` or blank. That is the whole design: the alternative (\"only write a\n * verdict when we have one\") leaves the PREVIOUS verdict standing for a site\n * whose check has since started failing, and a stale `pass` is never aged out by\n * anything — the digest's freshness gate only examines failures. So the record\n * always says what the last run actually established, and `checkedAt` always\n * says when. See {@link WebsiteRow.prismicModels} for the four states.\n *\n * Best-effort AT THE CALL SITE: `Prismic Models*` are operator-added columns, so\n * until they exist Airtable throws UNKNOWN_FIELD_NAME. The nightly sweep must\n * survive that and collect it — same contract as `updateNextDueDates`.\n *\n * Takes a non-null `AirtableBase`, like every other writer here: whether to write\n * at all is the caller's decision, and this repo keeps \"do no writes\" separate\n * from \"do no IO\" one layer up.\n */\nexport async function updatePrismicModels(\n base: AirtableBase,\n recordId: string,\n models: PrismicModelsWriteback,\n): Promise<void> {\n const fields: Record<string, string | null> = {\n \"Prismic Models\": models.verdict,\n \"Prismic Models Checked At\": models.checkedAt,\n \"Prismic Models Drift\": models.detail === null ? null : truncatePrismicDetail(models.detail),\n };\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: fields as FieldSet }]);\n}\n\n/** Mark a site launched: flip Status → maintenance + stamp Launched at (M6b).\n * The first code that writes Status. Called after a Launch report sends. */\nexport async function updateLaunched(\n base: AirtableBase,\n recordId: string,\n at: string,\n): Promise<void> {\n const fields: FieldSet = { Status: \"maintenance\", \"Launched at\": at };\n await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);\n}\n"],"mappings":"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","names":[]}
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  approveBlockers
3
- } from "./chunk-5E4L7WKE.js";
3
+ } from "./chunk-5P2HVNLZ.js";
4
4
  import {
5
+ ACTIVE_STATUSES,
5
6
  allActionableVulnsTransitive,
7
+ isPreLaunch,
6
8
  siteSlug
7
- } from "./chunk-YRM6EED7.js";
9
+ } from "./chunk-CLWNAMUX.js";
8
10
 
9
11
  // src/alerts/digest-collectors.ts
10
12
  function dashboardUrl(baseUrl, siteName) {
@@ -220,6 +222,60 @@ function collectAnalyticsFailures(sites, baseUrl, now = /* @__PURE__ */ new Date
220
222
  }
221
223
  return items;
222
224
  }
225
+ var PRISMIC_DRIFT_STALE_DAYS = 3;
226
+ var PRISMIC_STALE_PASS_DAYS = 7;
227
+ function prismicSweepCovers(s) {
228
+ return s.status !== null && ACTIVE_STATUSES.has(s.status) && !isPreLaunch(s.status) && s.url.length > 0 && siteSlug(s.name).length > 0;
229
+ }
230
+ function collectPrismicDriftAlerts(sites, baseUrl, now = /* @__PURE__ */ new Date()) {
231
+ const items = [];
232
+ for (const s of sites) {
233
+ const verdict = s.prismicModels;
234
+ if (verdict === null) continue;
235
+ const at = s.prismicModelsCheckedAt;
236
+ const ageMs = at === null ? Number.POSITIVE_INFINITY : now.getTime() - Date.parse(at);
237
+ const ageKnown = Number.isFinite(ageMs);
238
+ const windowDays = verdict === "pass" ? PRISMIC_STALE_PASS_DAYS : PRISMIC_DRIFT_STALE_DAYS;
239
+ const unverified = ageKnown ? ageMs > windowDays * MS_PER_DAY : verdict === "pass";
240
+ if (unverified) {
241
+ if (!prismicSweepCovers(s)) continue;
242
+ items.push({
243
+ key: `prismic-stale:${s.id}`,
244
+ kind: "prismic-drift",
245
+ siteName: s.name,
246
+ title: `Prismic model check has not run recently \u2014 the last verdict ("${verdict}") is unverified`,
247
+ url: dashboardUrl(baseUrl, s.name),
248
+ severity: "warning",
249
+ metric: 1
250
+ });
251
+ continue;
252
+ }
253
+ if (verdict === "pass") continue;
254
+ const first = s.prismicModelsDrift?.split("\n").find((l) => l.trim() !== "")?.trim();
255
+ if (verdict === "unknown") {
256
+ items.push({
257
+ key: `prismic-unknown:${s.id}`,
258
+ kind: "prismic-drift",
259
+ siteName: s.name,
260
+ title: first ? `Prismic model check could not run \u2014 ${first}` : "Prismic model check could not run",
261
+ url: dashboardUrl(baseUrl, s.name),
262
+ severity: "warning",
263
+ metric: 1
264
+ });
265
+ continue;
266
+ }
267
+ items.push({
268
+ key: `prismic-drift:${s.id}`,
269
+ kind: "prismic-drift",
270
+ siteName: s.name,
271
+ title: first ? `Prismic models diverge from the repo \u2014 ${first}` : "Prismic models diverge from the repo",
272
+ url: dashboardUrl(baseUrl, s.name),
273
+ severity: "warning",
274
+ metric: 1
275
+ });
276
+ }
277
+ return items;
278
+ }
223
279
 
224
280
  // src/alerts/digest-state.ts
225
281
  function diffAttention(items, prior, today) {
@@ -295,8 +351,9 @@ export {
295
351
  NOTIFY_BOUNCE_WINDOW_DAYS,
296
352
  collectNotifyBounceAlerts,
297
353
  collectAnalyticsFailures,
354
+ collectPrismicDriftAlerts,
298
355
  diffAttention,
299
356
  readDigestState,
300
357
  writeDigestState
301
358
  };
302
- //# sourceMappingURL=chunk-VXXF4Y25.js.map
359
+ //# sourceMappingURL=chunk-FSRJAF2Q.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/alerts/digest-collectors.ts","../src/alerts/digest-state.ts"],"sourcesContent":["// src/alerts/digest-collectors.ts\nimport type { AttentionItem } from \"./attention.js\";\nimport {\n allActionableVulnsTransitive,\n siteSlug,\n ACTIVE_STATUSES,\n isPreLaunch,\n type WebsiteRow,\n} from \"../reports/airtable/websites.js\";\nimport type { ReportRow } from \"../reports/airtable/reports.js\";\nimport { approveBlockers } from \"../reports/preflight.js\";\n\n/** Build the same `/s/<slug>` dashboard link the M3 ready-section uses, trailing-slash-safe.\n * An empty Name slugs to \"\" and `/s/` is a dead link — fall back to the fleet homepage,\n * exactly like the ready-section does. */\nfunction dashboardUrl(baseUrl: string, siteName: string): string {\n const root = baseUrl.replace(/\\/$/, \"\");\n const slug = siteSlug(siteName);\n return slug ? `${root}/s/${slug}` : root;\n}\n\n/**\n * A GitHub-signals sweep older than this (or never run) is no longer trustworthy:\n * a repo whose nightly probe THREW stops being re-swept, so its persisted\n * `Default Branch CI` / `Renovate Failing CIs` freeze at their last value forever\n * — a phantom 🔴 that can never clear. 3 days ≈ 3× the daily sweep interval, so a\n * single missed/flaky run doesn't drop a real signal. The CI/Renovate collectors\n * (only) skip a site whose `githubSignalsAt` is staler than this. Vuln/Lighthouse/\n * delivery signals come from other sweeps and are unaffected.\n */\nconst GITHUB_SIGNALS_STALE_DAYS = 3;\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/** True when a site's GitHub-signals sweep is too old (or never ran) to trust the\n * persisted CI/Renovate fields. A null `githubSignalsAt` (never swept) is stale —\n * there's no sweep to vouch for the values. A future timestamp (now - swept < 0)\n * is fresh. `now` is injected so the gate is testable. */\nfunction gitHubSignalsStale(swept: string | null, now: Date): boolean {\n if (swept === null) return true;\n const ageMs = now.getTime() - Date.parse(swept);\n if (!Number.isFinite(ageMs)) return true; // unparseable timestamp → don't trust it\n return ageMs > GITHUB_SIGNALS_STALE_DAYS * MS_PER_DAY;\n}\n\n/** Renovate auto-fix dispatches for one vuln episode before it's \"exhausted\" (manual fix needed). */\nconst AUTO_FIX_EXHAUSTED_CYCLES = 3;\n\n/**\n * One attention item per site carrying current critical+high vulns (medium/low omitted\n * per the locked threshold). PURE: takes already-fetched Websites rows. `metric` is the\n * critical+high count (so a rising count diffs as WORSE); `severity` is `critical` when\n * any critical exists, else `warning`. Null counts (never audited) read as 0 → skipped.\n * Once `securityAutoFixAttempts` reaches AUTO_FIX_EXHAUSTED_CYCLES the item is flagged\n * `autoFixExhausted` (forced-critical, escalated title) — Renovate tried and couldn't fix it.\n * EXCEPT when the persisted advisories prove every critical/high vuln is TRANSITIVE: then\n * nightly dispatches were green no-ops (no direct dep to bump — the fix rides the weekly\n * lockfile window), \"auto-fix failed\" would be a lie, and a stale pre-fix counter must not\n * escalate (Sonder said \"auto-fix failed (5×)\" on exactly this, 2026-08-10). Those sites get\n * an honest transitive-only title, keep count-based severity, and stay out of the digest\n * email (amber cockpit Watch still shows them). Unknown relationship data never mutes.\n *\n * Emission is UNCONDITIONAL — every surface applies its own policy on top:\n * - digest email: includes a vuln only when `autoFixExhausted` (the operator hears\n * about a vuln only after Renovate tried and failed; before that the fleet is\n * still self-patching) — runDigest filters\n * - cockpit: non-exhausted vuln → amber Watch; exhausted → hard break (assignTier),\n * and only an exhausted vuln pierces the pre-launch mute (piercesPreLaunchMute)\n */\nexport function collectVulnAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const critical = s.securityVulnsCritical ?? 0;\n const high = s.securityVulnsHigh ?? 0;\n const metric = critical + high;\n if (metric <= 0) continue;\n const attempts = s.securityAutoFixAttempts ?? 0;\n const transitiveOnly = allActionableVulnsTransitive(s.securityAdvisories);\n const exhausted = !transitiveOnly && attempts >= AUTO_FIX_EXHAUSTED_CYCLES;\n const noun = metric === 1 ? \"vuln\" : \"vulns\";\n items.push({\n key: `vuln:${s.id}`,\n kind: \"vuln\",\n siteName: s.name,\n title: exhausted\n ? `${metric} critical/high ${noun} — auto-fix failed (${attempts}×)`\n : transitiveOnly\n ? `${metric} critical/high ${noun} — transitive-only, fix rides the weekly lockfile window`\n : `${metric} critical/high ${noun}`,\n url: dashboardUrl(baseUrl, s.name),\n severity: exhausted || critical > 0 ? \"critical\" : \"warning\",\n metric,\n ...(exhausted ? { autoFixExhausted: true } : {}),\n });\n }\n return items;\n}\n\n/** Absolute floor below which a Lighthouse category is \"Needs attention\" (Tucker's call). */\nconst LIGHTHOUSE_FLOOR = 75;\n\n/** The four Lighthouse categories, each mapped to its WebsiteRow score field, URL slug,\n * and the human label rendered in the digest title. Order is the operator's reading order. */\nconst LIGHTHOUSE_CATEGORIES: ReadonlyArray<{\n field: \"pScore\" | \"rScore\" | \"bpScore\" | \"seoScore\";\n slug: string;\n label: string;\n}> = [\n { field: \"pScore\", slug: \"performance\", label: \"Performance\" },\n { field: \"rScore\", slug: \"accessibility\", label: \"Accessibility\" },\n { field: \"bpScore\", slug: \"best-practices\", label: \"Best Practices\" },\n { field: \"seoScore\", slug: \"seo\", label: \"SEO\" },\n];\n\n/**\n * One attention item per Lighthouse category below the absolute floor (75) for each site.\n * PURE: takes already-fetched Websites rows. Categories are Performance/Accessibility/\n * Best-Practices/SEO. A null score (never audited) or a score >= 75 is skipped. The\n * `metric` is the DEFICIT (`100 - score`): a lower score → higher metric, so a category\n * that drops further diffs as WORSE and one that first crosses below 75 diffs as NEW —\n * which is how `diffAttention`'s \"WORSE on increase\" rule reads an inverted score. `key`\n * is `lighthouse:<siteId>:<categorySlug>`, so the four categories stay distinct per site.\n */\nexport function collectLighthouseAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n for (const cat of LIGHTHOUSE_CATEGORIES) {\n const score = s[cat.field];\n if (score === null || score >= LIGHTHOUSE_FLOOR) continue;\n items.push({\n key: `lighthouse:${s.id}:${cat.slug}`,\n kind: \"lighthouse\",\n siteName: s.name,\n title: `Lighthouse ${cat.label} ${score} (below ${LIGHTHOUSE_FLOOR})`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 100 - score,\n });\n }\n }\n return items;\n}\n\n/**\n * One attention item per report whose `deliveryStatus` is a failure (`bounced` or\n * `complained` — `delivered`/`pending` are ignored). PURE: takes already-fetched\n * Reports rows + a record-id→site map. A complaint ranks above a bounce (locked\n * threshold), so `severity` is `critical` for complained / `warning` for bounced.\n * `metric` is 1 (a binary event). Orphans (siteId not in the map) are skipped, as\n * the M3 ready-section does, so the digest never renders a broken link. The diff\n * key is the report RECORD id, so two failures on one site stay distinct.\n */\n/**\n * One attention item per unsent draft whose send is ALREADY known to fail\n * (approveBlockers: recipients / header image / report scores). An APPROVED one\n * is critical — the next 09:23 UTC run will go red on it; a pending one is a\n * warning — approving it just schedules that failure. Keyed `preflight:<reportId>`.\n * PURE; same predicate the approve gate and the dashboard chip use.\n */\nexport function collectPreflightBlocked(\n reports: ReportRow[],\n sitesById: Map<string, WebsiteRow>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const r of reports) {\n if (!r.draftReady || r.sentAt !== null) continue;\n // The approved state rides the KEY so a pending→approved escalation with\n // unchanged blockers re-news as a fresh critical instead of diffing\n // \"standing\" against its old warning self.\n const state = r.approvedToSend ? \"approved\" : \"pending\";\n const site = sitesById.get(r.siteId);\n if (!site) {\n // A dangling/empty Site link is itself a send blocker (sendApprovedReports\n // fails with \"Site row not found\") — surface it; the empty-slug\n // dashboardUrl fallback yields the fleet root, not a broken /s/ link.\n items.push({\n key: `preflight:${r.id}:${state}`,\n kind: \"preflight\",\n siteName: \"(unlinked site)\",\n title: r.approvedToSend\n ? `Approved ${r.reportType} will fail at send — site-not-found`\n : `${r.reportType} draft can't be approved — site-not-found`,\n url: dashboardUrl(baseUrl, \"\"),\n severity: r.approvedToSend ? \"critical\" : \"warning\",\n metric: 1,\n });\n continue;\n }\n const fails = approveBlockers(site, r).filter((f) => f.level === \"fail\");\n if (fails.length === 0) continue;\n const first = fails[0]!;\n const more = fails.length > 1 ? ` (+${fails.length - 1} more)` : \"\";\n items.push({\n key: `preflight:${r.id}:${state}`,\n kind: \"preflight\",\n siteName: site.name,\n title: r.approvedToSend\n ? `Approved ${r.reportType} will fail at send — ${first.check}${more}`\n : `${r.reportType} draft can't be approved — ${first.check}${more}`,\n url: dashboardUrl(baseUrl, site.name),\n severity: r.approvedToSend ? \"critical\" : \"warning\",\n metric: fails.length,\n });\n }\n return items;\n}\n\nexport function collectDeliveryFailures(\n reports: ReportRow[],\n sitesById: Map<string, WebsiteRow>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const r of reports) {\n if (r.deliveryStatus !== \"bounced\" && r.deliveryStatus !== \"complained\") continue;\n const site = sitesById.get(r.siteId);\n if (!site) continue; // orphan → skip rather than render a broken link\n const complained = r.deliveryStatus === \"complained\";\n items.push({\n key: `delivery:${r.id}`,\n kind: \"delivery\",\n siteName: site.name,\n title: complained ? \"Spam complaint on a sent report\" : \"A sent report bounced\",\n url: dashboardUrl(baseUrl, site.name),\n severity: complained ? \"critical\" : \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site carrying failing Renovate PRs, read from the\n * slice-2a-persisted `renovateFailingCis` field (the nightly github-signals sweep\n * populates it). PURE. Keyed `renovate:<siteId>` so the digest and the cockpit\n * share one diff key. `metric` is the count (a rising count diffs WORSE); severity\n * `warning`. Null/0 → skipped. A site whose `githubSignalsAt` is >3 days stale (or\n * null) is ALSO skipped — a repo that stopped being swept must not show a phantom\n * count forever (`now` injected, defaults to wall-clock).\n */\nexport function collectRenovateAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n const n = s.renovateFailingCis ?? 0;\n if (n <= 0) continue;\n items.push({\n key: `renovate:${s.id}`,\n kind: \"renovate\",\n siteName: s.name,\n title: `${n} Renovate ${n === 1 ? \"PR\" : \"PRs\"} failing CI`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: n,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site whose persisted default-branch CI rollup is\n * `failing` (slice 2a). PURE. `metric` 1 (binary); severity `warning`. Any other\n * state (passing/pending/none) or null is skipped. A site whose `githubSignalsAt`\n * is >3 days stale (or null) is ALSO skipped — a repo that stopped being swept must\n * not show a phantom 🔴 forever (`now` injected, defaults to wall-clock).\n */\nexport function collectCiAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n if (s.defaultBranchCi !== \"failing\") continue;\n items.push({\n key: `ci:${s.id}`,\n kind: \"ci\",\n siteName: s.name,\n title: \"Default-branch CI failing\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * A soft-fail older than this is no longer a trustworthy CURRENT signal. Drafting\n * self-clears `analyticsSoftFailAt` on the next clean enrichment, so a non-null\n * value normally means \"errored on the most recent draft and hasn't recovered\" —\n * but a site that stopped being drafted (freq→None, deprecated) would otherwise\n * show a phantom forever. 45 days covers a monthly cadence + margin; older drops.\n */\nconst ANALYTICS_SOFT_FAIL_STALE_DAYS = 45;\n\n/**\n * One attention item per site whose last draft's GA/Search enrichment ERRORED, read\n * from the `analyticsSoftFailAt` timestamp (drafting sets it on a soft-fail, clears\n * it on a clean enrichment). PURE. Keyed `analytics:<siteId>` so the digest and the\n * cockpit share one diff key; `metric` 1 (binary), severity `warning`. A null\n * timestamp (clean, or the operator-added `Analytics soft-fail at` column absent) is\n * skipped, as is one staler than {@link ANALYTICS_SOFT_FAIL_STALE_DAYS}. On a\n * FLEET-WIDE subject outage many sites surface this at once — that breadth IS the\n * signal here; the report cron additionally emails a single concise fleet-wide alert\n * (see `assessAnalyticsAlert`). `now` injected, defaults to wall-clock.\n */\n/** A function-health sweep older than this can't confirm the CURRENT widget state.\n * The sweep is nightly, so 3 days (mirrors GITHUB_SIGNALS_STALE_DAYS) tolerates a\n * weekend of runner flakes without letting a months-old verdict drive an alarm. */\nconst TURNSTILE_WIDGET_STALE_DAYS = 3;\n\n/**\n * One CRITICAL attention item per site with `Require Turnstile` ON whose deployed\n * `/health` reports the Turnstile widget NOT configured (`turnstileWidget === \"fail\"`,\n * fresh per {@link TURNSTILE_WIDGET_STALE_DAYS}). That combination silently buckets\n * 100% of the site's real leads (token-less submissions escalate to spam_auto with\n * notify skipped) and the form-e2e probe cannot see it — testMode bypasses the gate —\n * so the operator's inbox just goes quiet. PURE. Keyed `turnstile:<siteId>`. Because\n * this is an AttentionItem it rides assignTier's items short-circuit, which sits ABOVE\n * the accepted-watch mute loop — an accept key can never silence it. A null verdict or\n * a stale sweep is NOT alarmed here; assignTier raises those as an acceptable\n * \"can't verify\" watch instead. `now` injected, defaults to wall-clock.\n */\nexport function collectTurnstileGuardrailAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (!s.requireTurnstile || s.turnstileWidget !== \"fail\") continue;\n const at = s.functionHealthCheckedAt;\n if (at !== null) {\n const ageMs = now.getTime() - Date.parse(at);\n // Parseable and beyond the window → stale, downgraded to assignTier's watch.\n // Unparseable (NaN) keeps the item — never silently drop a real failure.\n if (Number.isFinite(ageMs) && ageMs > TURNSTILE_WIDGET_STALE_DAYS * MS_PER_DAY) continue;\n }\n items.push({\n key: `turnstile:${s.id}`,\n kind: \"turnstile\",\n siteName: s.name,\n title:\n \"Require Turnstile is ON but /health reports no widget — real leads are being auto-bucketed\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"critical\",\n metric: 1,\n });\n }\n return items;\n}\n\n/** Bounced lead notifications inside the window before a site alarms. One bounce can\n * be a transient greylist/full-mailbox blip; a repeat says the point-of-contact\n * address itself is bad (the Espada mode: apm@ bounced 4 of 8 with nothing alarming). */\nexport const NOTIFY_BOUNCE_THRESHOLD = 2;\n\n/** Window (days) the bounce count covers. Two weeks: long enough that a low-volume\n * site's second bounce still lands in the same window as its first, short enough\n * that a long-fixed address doesn't alarm forever (bounced rows never self-clear —\n * the alarm ages out as bounced submissions leave the window). */\nexport const NOTIFY_BOUNCE_WINDOW_DAYS = 14;\n\n/**\n * One CRITICAL attention item per site with >= {@link NOTIFY_BOUNCE_THRESHOLD} bounced\n * lead notifications in the last {@link NOTIFY_BOUNCE_WINDOW_DAYS} days — leads are\n * being captured but silently never reach the client, which is worse than no form at\n * all (notifyStatus \"sent\" only means Resend accepted the email; the resend-webhook\n * flips it to \"bounced\" when Resend later reports the failure, 2026-07-16). PURE:\n * takes the pre-fetched per-site counts (`countNotifyBouncedBySite`, keyed by the\n * Websites record id). Keyed `notify-bounce:<siteId>` so the digest and the cockpit\n * share one diff key; `metric` is the bounce count (a rising count diffs WORSE).\n * A site absent from the map (or below threshold) is skipped, as is a count for a\n * site id not in `sites` (orphan → never render a broken link).\n */\nexport function collectNotifyBounceAlerts(\n sites: WebsiteRow[],\n bouncedBySite: ReadonlyMap<string, number>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const n = bouncedBySite.get(s.id) ?? 0;\n if (n < NOTIFY_BOUNCE_THRESHOLD) continue;\n items.push({\n key: `notify-bounce:${s.id}`,\n kind: \"notify-bounce\",\n siteName: s.name,\n title: `${n} lead notifications bounced (${NOTIFY_BOUNCE_WINDOW_DAYS}d) — check the point-of-contact address`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"critical\",\n metric: n,\n });\n }\n return items;\n}\n\nexport function collectAnalyticsFailures(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const at = s.analyticsSoftFailAt;\n if (at === null) continue;\n const ageMs = now.getTime() - Date.parse(at);\n // Unparseable timestamp (NaN) → keep the item (don't silently drop a real failure\n // on a parse glitch); only a parseable, beyond-window age is skipped as stale.\n if (Number.isFinite(ageMs) && ageMs > ANALYTICS_SOFT_FAIL_STALE_DAYS * MS_PER_DAY) continue;\n items.push({\n key: `analytics:${s.id}`,\n kind: \"analytics\",\n siteName: s.name,\n title: \"GA/Search enrichment failing (analytics blank)\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/** How current a `fail` / `unknown` must be to still CLAIM the fleet's present\n * state. The sweep is nightly, so 3 days (mirrors GITHUB_SIGNALS_STALE_DAYS)\n * tolerates a weekend of runner flakes without letting a months-old finding drive\n * a \"models diverge right now\" alarm. Past it the finding does not vanish — it\n * converts to the staleness item below, which claims nothing about the models. */\nconst PRISMIC_DRIFT_STALE_DAYS = 3;\n\n/** How long a `pass` may go unrefreshed before it is treated as unverified.\n *\n * Deliberately LONGER than the currency window, and the asymmetry is the point.\n * An old `fail` stops being reported as CURRENT drift at 3 days because it may\n * already be fixed. An old `pass` is a green claim nobody has re-established, and\n * escalating that at 3 days would light up the WHOLE fleet after a long weekend of\n * runner flakes — the noise that gets a real alarm muted. A week is the interval\n * over which a silently-dead nightly actually matters. */\nconst PRISMIC_STALE_PASS_DAYS = 7;\n\n/**\n * True when the nightly `prismic-models --fleet airtable` sweep is EXPECTED to\n * cover this site. Mirrors the Airtable inventory's own filter (src/inventory/\n * airtable.ts): live `maintenance` sites (active, not pre-launch) that carry a\n * `url` AND a Name that yields a slug. Deliberately duplicated rather than\n * imported — the inventory builds `Site` objects and needs a workdir; this is a\n * pure predicate over a row — but the two must stay in step: widen the inventory\n * and widen this. That is no longer left to memory: every shape of row is put\n * through BOTH implementations by tests/alerts/prismic-sweep-scope.test.ts, which\n * goes red the moment they diverge.\n *\n * THE SLUG CLAUSE IS NOT COSMETIC, and it was the first drift this pair produced.\n * The inventory drops an empty-slug row (`siteSlug(name) === \"\"`) with a warning,\n * because such a row can neither form a checkout path nor be matched back to its\n * Websites row on write-back — and `writeSweepToAirtable` joins by that same slug,\n * so even a verdict computed for it could never land. Without this clause a live\n * site named in a script with no `[a-z0-9]` (or with an empty Name) was covered\n * here and swept nowhere: the permanent, un-ackable morning email this predicate\n * exists to prevent, produced by the predicate itself.\n *\n * Only the STALENESS escalation consults it. A `fail`/`unknown` is a verdict some\n * run actually established and is reported wherever it came from; the staleness\n * item is an alarm invented FROM AN ABSENCE, and an absence is only wrong where a\n * sweep was owed. A deprecated site that left the inventory would otherwise carry\n * a frozen `pass` into the digest every morning forever — un-ackable (attention\n * items sit above the accepted-watch mute) and unfixable except by hand-clearing\n * an Airtable cell.\n */\nfunction prismicSweepCovers(s: WebsiteRow): boolean {\n return (\n s.status !== null &&\n ACTIVE_STATUSES.has(s.status) &&\n !isPreLaunch(s.status) &&\n s.url.length > 0 &&\n siteSlug(s.name).length > 0\n );\n}\n\n/**\n * One item per site whose nightly Prismic model verdict needs a human, from the\n * `prismic-models --fleet --write-airtable` sweep. PURE (`now` injected).\n *\n * The verdict is THREE-valued plus blank, and each state gets its own key so the\n * digest's snapshot diff (keyed on `key`) can never let one condition stand in for\n * another — a site sliding from drift to \"couldn't check\" re-news as NEW rather\n * than diffing \"standing\" against the drift it replaced (both carry `metric` 1):\n *\n * - `fail`, fresh → `prismic-drift:<siteId>` — repo and Prismic diverge.\n * - `unknown`, fresh → `prismic-unknown:<siteId>` — the check RAN AND COULD NOT\n * ANSWER (unreadable checkout, dead write token, unreachable Prismic). Its own\n * wording, because reporting a dead token as \"models diverge\" sends the\n * operator to fix a model when the job is to fix a secret.\n * - any verdict nobody has re-established → `prismic-stale:<siteId>` — a `pass`\n * older than {@link PRISMIC_STALE_PASS_DAYS} (or undateable), or a `fail` /\n * `unknown` older than {@link PRISMIC_DRIFT_STALE_DAYS}. A stale verdict is\n * never silence: dropping it would make \"nobody could evaluate this\" read\n * exactly like \"this is fine\", which is the failure this whole column exists\n * to close. Gated by {@link prismicSweepCovers}.\n * - blank (never swept, or not a Prismic site) → nothing, at any age. There is\n * no claim to un-verify.\n *\n * A `pass` inside its window is the ONLY silent verdict. At most one item per site\n * — the Airtable cell holds one state at a time.\n *\n * `warning`, not `critical`: on the cockpit ANY item already tiers the site 🔴, so\n * severity buys only (a) piercing the pre-launch mute and (b) sorting first in the\n * needs-you feed. `critical` is reserved for the guardrails that are actively\n * losing leads or breaking a live page (Turnstile, notify-bounce, delivery); a\n * model that drifted — or a check that could not run — is neither, and piercing\n * the pre-launch mute would alarm on exactly the sites the sweep excludes by\n * design.\n */\nexport function collectPrismicDriftAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const verdict = s.prismicModels;\n if (verdict === null) continue;\n\n const at = s.prismicModelsCheckedAt;\n // +Infinity when there is no timestamp, NaN when it won't parse — both mean\n // \"the age of this answer is unknowable\", which the two verdict families read\n // in OPPOSITE directions on purpose (see `unverified`).\n const ageMs = at === null ? Number.POSITIVE_INFINITY : now.getTime() - Date.parse(at);\n const ageKnown = Number.isFinite(ageMs);\n const windowDays = verdict === \"pass\" ? PRISMIC_STALE_PASS_DAYS : PRISMIC_DRIFT_STALE_DAYS;\n // An undateable `pass` is unverified — a green claim nobody can date is not a\n // green claim. An undateable `fail`/`unknown` is NOT downgraded: never silently\n // drop a real finding over a parse glitch.\n const unverified = ageKnown ? ageMs > windowDays * MS_PER_DAY : verdict === \"pass\";\n\n if (unverified) {\n if (!prismicSweepCovers(s)) continue;\n items.push({\n key: `prismic-stale:${s.id}`,\n kind: \"prismic-drift\",\n siteName: s.name,\n title: `Prismic model check has not run recently — the last verdict (\"${verdict}\") is unverified`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n continue;\n }\n\n if (verdict === \"pass\") continue;\n\n const first = s.prismicModelsDrift\n ?.split(\"\\n\")\n .find((l) => l.trim() !== \"\")\n ?.trim();\n if (verdict === \"unknown\") {\n items.push({\n key: `prismic-unknown:${s.id}`,\n kind: \"prismic-drift\",\n siteName: s.name,\n title: first\n ? `Prismic model check could not run — ${first}`\n : \"Prismic model check could not run\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n continue;\n }\n\n items.push({\n key: `prismic-drift:${s.id}`,\n kind: \"prismic-drift\",\n siteName: s.name,\n title: first\n ? `Prismic models diverge from the repo — ${first}`\n : \"Prismic models diverge from the repo\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n","// src/alerts/digest-state.ts\nimport type { FieldSet } from \"airtable\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport type { AttentionItem } from \"./attention.js\";\n\n/**\n * The persisted prior-run snapshot: stable item `key` → its last metric + the\n * date it was FIRST flagged. Lives as JSON in the single \"Digest State\" Airtable\n * row (the IO that loads/stores it — readDigestState/writeDigestState — is added\n * in component 2). `next` from diffAttention is what gets written back.\n * `exhausted` is carried only for vuln keys whose Renovate auto-fix has been\n * exhausted (absent = not exhausted; older snapshots without the field read the\n * same way).\n */\nexport type DigestSnapshot = Record<\n string,\n { metric: number; firstFlaggedAt: string; exhausted?: boolean }\n>;\n\n/**\n * PURE diff — the testable core of the hybrid \"snapshot now, mark what's new\".\n * For each current item vs the prior snapshot:\n * - key absent from prior → NEW (firstFlaggedAt = today)\n * - present and metric > prior.metric → WORSE (keep the original firstFlaggedAt)\n * - present and autoFixExhausted flipped on → WORSE (the count didn't move, but\n * \"Renovate gave up\" is an escalation — without this the digest's first mention\n * of a vuln, which is gated on exhaustion, would arrive unbadged)\n * - otherwise (equal or dropped) → STANDING (keep the original firstFlaggedAt)\n * `next` contains EXACTLY the current items' keys: resolved keys drop out, so a\n * fixed-then-recurring problem re-news correctly. Neither input is mutated.\n */\nexport function diffAttention(\n items: AttentionItem[],\n prior: DigestSnapshot,\n today: string,\n): { tagged: AttentionItem[]; next: DigestSnapshot } {\n const tagged: AttentionItem[] = [];\n const next: DigestSnapshot = {};\n for (const it of items) {\n const was = prior[it.key];\n const exhaustedFlip = it.autoFixExhausted === true && was?.exhausted !== true;\n let status: AttentionItem[\"status\"];\n let firstFlaggedAt: string;\n if (!was) {\n status = \"new\";\n firstFlaggedAt = today;\n } else if (it.metric > was.metric || exhaustedFlip) {\n status = \"worse\";\n firstFlaggedAt = was.firstFlaggedAt;\n } else {\n status = \"standing\";\n firstFlaggedAt = was.firstFlaggedAt;\n }\n tagged.push({ ...it, status });\n next[it.key] = {\n metric: it.metric,\n firstFlaggedAt,\n ...(it.autoFixExhausted === true ? { exhausted: true } : {}),\n };\n }\n return { tagged, next };\n}\n\n/** The single-row Airtable table that persists the prior digest snapshot. */\nexport const DIGEST_STATE_TABLE = \"Digest State\";\n\n/**\n * Read the persisted prior snapshot from the \"Digest State\" singleton.\n *\n * Reads the FIRST row of an unfiltered select (the table holds exactly one row;\n * the test fake does not evaluate filterByFormula, so we never rely on one). A\n * read miss (no row) OR a parse error (malformed Snapshot JSON) collapses to `{}`\n * — every key then reads as NEW once, which is safe degradation (never crashes\n * the digest).\n */\nexport async function readDigestState(base: AirtableBase): Promise<DigestSnapshot> {\n const rows: { id: string; fields: Record<string, unknown> }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id, fields: rec.fields });\n fetchNextPage();\n });\n const first = rows[0];\n if (!first) return {};\n const raw = first.fields[\"Snapshot\"];\n if (typeof raw !== \"string\") return {};\n try {\n return JSON.parse(raw) as DigestSnapshot;\n } catch {\n return {};\n }\n}\n\n/**\n * Persist the next snapshot to the \"Digest State\" singleton: get-or-create the\n * one row. If a row exists, UPDATE it (keyed by its record id); otherwise CREATE\n * one. `Snapshot` = JSON.stringify(snap); `Updated At` = the injected ISO\n * timestamp (or now). A caller that catches+logs a write failure keeps the\n * already-sent digest unaffected (next run re-news at worst).\n */\nexport async function writeDigestState(\n base: AirtableBase,\n snap: DigestSnapshot,\n updatedAt: string = new Date().toISOString(),\n): Promise<void> {\n const rows: { id: string }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id });\n fetchNextPage();\n });\n const fields: FieldSet = {\n Snapshot: JSON.stringify(snap),\n \"Updated At\": updatedAt,\n };\n const existing = rows[0];\n if (existing) {\n await base(DIGEST_STATE_TABLE).update([{ id: existing.id, fields }]);\n } else {\n await base(DIGEST_STATE_TABLE).create([{ fields }]);\n }\n}\n"],"mappings":"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","names":[]}
@@ -11,7 +11,7 @@ import {
11
11
  } from "./chunk-OTGB6YQO.js";
12
12
  import {
13
13
  defaultSpawn
14
- } from "./chunk-HVOOCK6L.js";
14
+ } from "./chunk-4DHQCN5H.js";
15
15
 
16
16
  // src/recipes/svelte-5/index.ts
17
17
  import { join as join5 } from "path";
@@ -243,4 +243,4 @@ async function upgradeSvelte4to5(site, opts = {}) {
243
243
  export {
244
244
  upgradeSvelte4to5
245
245
  };
246
- //# sourceMappingURL=chunk-B5ZUQZFL.js.map
246
+ //# sourceMappingURL=chunk-ICXV25N7.js.map