@riddledc/riddle-proof 0.7.88 → 0.7.90

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.
package/README.md CHANGED
@@ -103,7 +103,9 @@ targeted copy or UI changes, the visual gate can use a lower targeted threshold
103
103
  only when route/text semantics match the requested change. When before/after
104
104
  image artifacts can be compared directly, the runtime also records the changed
105
105
  region bounding box and refuses the targeted threshold if that exact region is
106
- too broad for a localized UI change. Missing or unmeasured visual deltas
106
+ too broad for a localized UI change. External visual-delta payloads can provide
107
+ that region with common field names such as `changed_region`, `bounds`, `bbox`,
108
+ `boundingBox`, `xMin`/`xMax`, or `x1`/`x2`. Missing or unmeasured visual deltas
107
109
  continue through evidence recovery instead of being treated as a passing proof.
108
110
 
109
111
  ## CI / Profile Mode
@@ -448,6 +450,29 @@ scroll width, client width, measured horizontal overflow, and top visible
448
450
  overflow offenders. This keeps embedded-player audits in profile mode instead
449
451
  of requiring bespoke iframe inspection scripts.
450
452
 
453
+ Use `link_status` for public link or asset audits where selected `href` / `src`
454
+ URLs must still resolve. This is useful for Good Catch pages, documentation
455
+ indexes, saved-game galleries, and other public proof surfaces where stale
456
+ artifact links should fail the profile:
457
+
458
+ ```json
459
+ {
460
+ "type": "link_status",
461
+ "selector": "a[href*='/artifacts/'], img[src*='/artifacts/']",
462
+ "expected_count": 88,
463
+ "same_origin_only": true,
464
+ "require_nonzero_bytes": true,
465
+ "max_links": 150
466
+ }
467
+ ```
468
+
469
+ The check defaults to `a[href]`, deduplicates URLs, probes up to 100 selected
470
+ URLs, and treats HTTP `2xx` / `3xx` responses as healthy. Use
471
+ `allowed_statuses` or `expected_status` when a narrower status contract is
472
+ intentional, `min_count` for lower-bound audits, and `max_links` when the
473
+ selected set is intentionally larger than 100. `artifact_link_status` is an
474
+ alias with the same behavior for profiles that want artifact-specific wording.
475
+
451
476
  Use the `route_inventory` check for source-page route coverage audits where a
452
477
  navigation surface must expose a known set of routes and each route must load
453
478
  both directly and through real link clicks:
@@ -27,6 +27,8 @@ var RIDDLE_PROOF_PROFILE_CHECK_TYPES = [
27
27
  "frame_no_horizontal_overflow",
28
28
  "text_visible",
29
29
  "text_absent",
30
+ "link_status",
31
+ "artifact_link_status",
30
32
  "route_inventory",
31
33
  "no_horizontal_overflow",
32
34
  "no_mobile_horizontal_overflow",
@@ -691,6 +693,29 @@ function normalizeStringList(value, label) {
691
693
  if (!values.length) throw new Error(`${label} must contain non-empty strings.`);
692
694
  return values;
693
695
  }
696
+ function normalizeHttpStatus(value, label) {
697
+ if (value === void 0) return void 0;
698
+ const status = numberValue(value);
699
+ if (status === void 0 || !Number.isInteger(status) || status < 100 || status > 599) {
700
+ throw new Error(`${label} must be an HTTP status code.`);
701
+ }
702
+ return status;
703
+ }
704
+ function normalizeHttpStatuses(value, label) {
705
+ if (value === void 0) return void 0;
706
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
707
+ if (!value.length) throw new Error(`${label} must not be empty.`);
708
+ const statuses = value.map((item, index) => normalizeHttpStatus(item, `${label}[${index}]`));
709
+ return Array.from(new Set(statuses));
710
+ }
711
+ function normalizePositiveInteger(value, label, max) {
712
+ if (value === void 0) return void 0;
713
+ const number = numberValue(value);
714
+ if (number === void 0 || !Number.isInteger(number) || number < 1 || max !== void 0 && number > max) {
715
+ throw new Error(`${label} must be an integer from 1 to ${max ?? "unbounded"}.`);
716
+ }
717
+ return number;
718
+ }
694
719
  function validateRegexPatterns(patterns, label) {
695
720
  for (const pattern of patterns || []) {
696
721
  try {
@@ -734,7 +759,8 @@ function normalizeCheck(input, index) {
734
759
  if (type === "url_search_param_equals" && expectedValue === void 0) {
735
760
  throw new Error(`checks[${index}] url_search_param_equals requires expected_value.`);
736
761
  }
737
- if (type === "selector_count_at_least" && numberValue(input.min_count) === void 0) {
762
+ const minCount = numberValue(input.min_count) ?? numberValue(input.minCount);
763
+ if (type === "selector_count_at_least" && minCount === void 0) {
738
764
  throw new Error(`checks[${index}] selector_count_at_least requires min_count.`);
739
765
  }
740
766
  const expectedCount = numberValue(input.expected_count) ?? numberValue(input.expectedCount) ?? numberValue(input.count);
@@ -750,6 +776,21 @@ function normalizeCheck(input, index) {
750
776
  if (type === "route_inventory" && !expectedRoutes?.length) {
751
777
  throw new Error(`checks[${index}] route_inventory requires expected_routes.`);
752
778
  }
779
+ const isLinkStatusCheck = type === "link_status" || type === "artifact_link_status";
780
+ const expectedStatus = isLinkStatusCheck ? normalizeHttpStatus(input.expected_status ?? input.expectedStatus ?? input.status, `checks[${index}] expected_status`) : void 0;
781
+ const allowedStatuses = isLinkStatusCheck ? normalizeHttpStatuses(
782
+ input.allowed_statuses ?? input.allowedStatuses ?? input.expected_statuses ?? input.expectedStatuses,
783
+ `checks[${index}] allowed_statuses`
784
+ ) : void 0;
785
+ const maxLinks = isLinkStatusCheck ? normalizePositiveInteger(input.max_links ?? input.maxLinks ?? input.limit, `checks[${index}] max_links`, 500) : void 0;
786
+ if (isLinkStatusCheck) {
787
+ if (minCount !== void 0 && (!Number.isInteger(minCount) || minCount < 0)) {
788
+ throw new Error(`checks[${index}] ${type} min_count must be a non-negative integer.`);
789
+ }
790
+ if (expectedCount !== void 0 && (!Number.isInteger(expectedCount) || expectedCount < 0)) {
791
+ throw new Error(`checks[${index}] ${type} expected_count must be a non-negative integer.`);
792
+ }
793
+ }
753
794
  return {
754
795
  type,
755
796
  label: stringValue(input.label),
@@ -775,8 +816,15 @@ function normalizeCheck(input, index) {
775
816
  allowed_console_patterns: normalizeStringList(input.allowed_console_patterns ?? input.allowedConsolePatterns ?? input.allow_console_patterns ?? input.allowConsolePatterns, `checks[${index}] allowed_console_patterns`),
776
817
  allowed_page_error_texts: normalizeStringList(input.allowed_page_error_texts ?? input.allowedPageErrorTexts ?? input.allow_page_error_texts ?? input.allowPageErrorTexts, `checks[${index}] allowed_page_error_texts`),
777
818
  allowed_page_error_patterns: normalizeStringList(input.allowed_page_error_patterns ?? input.allowedPageErrorPatterns ?? input.allow_page_error_patterns ?? input.allowPageErrorPatterns, `checks[${index}] allowed_page_error_patterns`),
778
- min_count: numberValue(input.min_count),
819
+ min_count: minCount,
779
820
  expected_count: expectedCount,
821
+ expected_status: expectedStatus,
822
+ allowed_statuses: allowedStatuses,
823
+ max_links: maxLinks,
824
+ same_origin_only: isLinkStatusCheck ? input.same_origin_only === true || input.sameOriginOnly === true : void 0,
825
+ dedupe: isLinkStatusCheck ? input.dedupe === false ? false : true : void 0,
826
+ require_nonzero_bytes: isLinkStatusCheck ? input.require_nonzero_bytes === true || input.requireNonzeroBytes === true : void 0,
827
+ allow_get_fallback: isLinkStatusCheck ? input.allow_get_fallback === false || input.allowGetFallback === false ? false : true : void 0,
780
828
  max_overflow_px: numberValue(input.max_overflow_px),
781
829
  timeout_ms: numberValue(input.timeout_ms) ?? numberValue(input.timeoutMs),
782
830
  run_direct_routes: input.run_direct_routes === false || input.runDirectRoutes === false ? false : true,
@@ -869,6 +917,97 @@ function checkLabel(check) {
869
917
  function selectorKey(check) {
870
918
  return check.selector || "";
871
919
  }
920
+ function linkStatusSelector(check) {
921
+ return check.selector || check.link_selector || "a[href]";
922
+ }
923
+ function linkStatusAllowedStatuses(check) {
924
+ if (check.allowed_statuses?.length) return check.allowed_statuses;
925
+ if (check.expected_status !== void 0) return [check.expected_status];
926
+ return void 0;
927
+ }
928
+ function linkStatusIsAllowed(status, check) {
929
+ if (status === void 0) return false;
930
+ const allowed = linkStatusAllowedStatuses(check);
931
+ return allowed?.length ? allowed.includes(status) : status >= 200 && status < 400;
932
+ }
933
+ function linkStatusResultOk(result, check) {
934
+ const status = numberValue(result.status);
935
+ if (!linkStatusIsAllowed(status, check)) return false;
936
+ if (stringValue(result.error)) return false;
937
+ if (result.ok === false) return false;
938
+ if (check.require_nonzero_bytes) {
939
+ const bytes = numberValue(result.bytes);
940
+ const contentLength = numberValue(result.content_length);
941
+ return bytes !== void 0 && bytes > 0 || contentLength !== void 0 && contentLength > 0;
942
+ }
943
+ return true;
944
+ }
945
+ function linkStatusEvidenceForCheck(viewport, check) {
946
+ const evidence = viewport.link_statuses?.[linkStatusSelector(check)];
947
+ return isRecord(evidence) ? evidence : void 0;
948
+ }
949
+ function summarizeLinkStatusEvidence(viewport, check) {
950
+ const linkEvidence = linkStatusEvidenceForCheck(viewport, check);
951
+ if (!linkEvidence) {
952
+ return {
953
+ viewport: viewport.name,
954
+ selector: linkStatusSelector(check),
955
+ total_count: 0,
956
+ ok_count: 0,
957
+ failed_count: 1,
958
+ failures: [{ code: "link_status_evidence_missing" }]
959
+ };
960
+ }
961
+ const results = Array.isArray(linkEvidence.results) ? linkEvidence.results.filter(isRecord) : [];
962
+ const totalCount = numberValue(linkEvidence.total_count) ?? results.length;
963
+ const okCount = results.filter((result) => linkStatusResultOk(result, check)).length;
964
+ const failures = results.filter((result) => !linkStatusResultOk(result, check)).map((result) => ({
965
+ code: "link_status_failed",
966
+ url: stringValue(result.url) ?? null,
967
+ status: numberValue(result.status) ?? null,
968
+ method: stringValue(result.method) ?? null,
969
+ error: stringValue(result.error) ?? null,
970
+ bytes: numberValue(result.bytes) ?? numberValue(result.content_length) ?? null
971
+ }));
972
+ if (stringValue(linkEvidence.error)) {
973
+ failures.push({ code: "link_status_capture_failed", error: stringValue(linkEvidence.error) ?? "" });
974
+ }
975
+ const recordedFailedCount = numberValue(linkEvidence.failed_count) ?? 0;
976
+ if (!failures.length && recordedFailedCount > 0) {
977
+ const recordedFailures = Array.isArray(linkEvidence.failures) ? linkEvidence.failures.slice(0, 20) : [];
978
+ failures.push({
979
+ code: "link_status_recorded_failures",
980
+ failed_count: recordedFailedCount,
981
+ failures: toJsonValue(recordedFailures)
982
+ });
983
+ }
984
+ if (linkEvidence.truncated === true) {
985
+ failures.push({
986
+ code: "link_status_probe_truncated",
987
+ discovered_count: numberValue(linkEvidence.discovered_count) ?? totalCount,
988
+ max_links: numberValue(linkEvidence.max_links) ?? check.max_links ?? 100
989
+ });
990
+ }
991
+ if (check.expected_count !== void 0 && totalCount !== check.expected_count) {
992
+ failures.push({ code: "link_status_count_mismatch", expected: check.expected_count, actual: totalCount });
993
+ }
994
+ if (check.min_count !== void 0 && totalCount < check.min_count) {
995
+ failures.push({ code: "link_status_count_below_minimum", min_count: check.min_count, actual: totalCount });
996
+ }
997
+ const statusCounts = isRecord(linkEvidence.status_counts) ? linkEvidence.status_counts : {};
998
+ return {
999
+ viewport: viewport.name,
1000
+ selector: linkStatusSelector(check),
1001
+ total_count: totalCount,
1002
+ discovered_count: numberValue(linkEvidence.discovered_count) ?? totalCount,
1003
+ ok_count: numberValue(linkEvidence.ok_count) ?? okCount,
1004
+ failed_count: failures.length,
1005
+ truncated: linkEvidence.truncated === true,
1006
+ max_links: numberValue(linkEvidence.max_links) ?? check.max_links ?? 100,
1007
+ status_counts: statusCounts,
1008
+ failures: failures.slice(0, 20)
1009
+ };
1010
+ }
872
1011
  function textKey(check) {
873
1012
  return check.pattern ? `pattern:${check.pattern}/${check.flags || ""}` : `text:${check.text || ""}`;
874
1013
  }
@@ -1348,6 +1487,26 @@ function assessCheckFromEvidence(check, evidence) {
1348
1487
  message: failed ? `Text assertion failed in ${failed} viewport(s).` : void 0
1349
1488
  };
1350
1489
  }
1490
+ if (check.type === "link_status" || check.type === "artifact_link_status") {
1491
+ const selector = linkStatusSelector(check);
1492
+ const summaries = viewports.map((viewport) => summarizeLinkStatusEvidence(viewport, check));
1493
+ const failed = summaries.filter((summary) => (numberValue(summary.failed_count) ?? 0) > 0);
1494
+ return {
1495
+ type: check.type,
1496
+ label: checkLabel(check),
1497
+ status: failed.length ? "failed" : "passed",
1498
+ evidence: {
1499
+ selector,
1500
+ expected_count: check.expected_count ?? null,
1501
+ min_count: check.min_count ?? null,
1502
+ allowed_statuses: linkStatusAllowedStatuses(check) || ["2xx", "3xx"],
1503
+ require_nonzero_bytes: check.require_nonzero_bytes === true,
1504
+ viewports: summaries.map((summary) => toJsonValue(summary)),
1505
+ failures: failed.flatMap((summary) => Array.isArray(summary.failures) ? summary.failures.map((failure) => toJsonValue({ viewport: stringValue(summary.viewport) ?? null, failure })) : [])
1506
+ },
1507
+ message: failed.length ? `Link status failed in ${failed.length} viewport(s).` : void 0
1508
+ };
1509
+ }
1351
1510
  if (check.type === "route_inventory") {
1352
1511
  const inventories = viewports.map((viewport) => ({ viewport: viewport.name, inventory: viewport.route_inventory })).filter((item) => isRecord(item.inventory));
1353
1512
  if (!inventories.length) {
@@ -1930,6 +2089,98 @@ function textOrderMatch(texts, expectedTexts) {
1930
2089
  }
1931
2090
  return { matched: true, positions };
1932
2091
  }
2092
+ function linkStatusSelector(check) {
2093
+ return check.selector || check.link_selector || "a[href]";
2094
+ }
2095
+ function linkStatusAllowedStatuses(check) {
2096
+ if (Array.isArray(check.allowed_statuses) && check.allowed_statuses.length) return check.allowed_statuses;
2097
+ if (typeof check.expected_status === "number" && Number.isFinite(check.expected_status)) return [check.expected_status];
2098
+ return undefined;
2099
+ }
2100
+ function linkStatusIsAllowed(status, check) {
2101
+ if (typeof status !== "number" || !Number.isFinite(status)) return false;
2102
+ const allowed = linkStatusAllowedStatuses(check);
2103
+ return Array.isArray(allowed) && allowed.length ? allowed.includes(status) : status >= 200 && status < 400;
2104
+ }
2105
+ function linkStatusResultOk(result, check) {
2106
+ if (!result || typeof result !== "object" || Array.isArray(result)) return false;
2107
+ if (!linkStatusIsAllowed(result.status, check)) return false;
2108
+ if (typeof result.error === "string" && result.error.trim()) return false;
2109
+ if (result.ok === false) return false;
2110
+ if (check.require_nonzero_bytes === true) {
2111
+ const bytes = typeof result.bytes === "number" && Number.isFinite(result.bytes) ? result.bytes : undefined;
2112
+ const contentLength = typeof result.content_length === "number" && Number.isFinite(result.content_length) ? result.content_length : undefined;
2113
+ return (bytes !== undefined && bytes > 0) || (contentLength !== undefined && contentLength > 0);
2114
+ }
2115
+ return true;
2116
+ }
2117
+ function summarizeLinkStatusEvidence(viewport, check) {
2118
+ const selector = linkStatusSelector(check);
2119
+ const linkEvidence = viewport && viewport.link_statuses && viewport.link_statuses[selector];
2120
+ if (!linkEvidence || typeof linkEvidence !== "object" || Array.isArray(linkEvidence)) {
2121
+ return {
2122
+ viewport: viewport && viewport.name,
2123
+ selector,
2124
+ total_count: 0,
2125
+ ok_count: 0,
2126
+ failed_count: 1,
2127
+ failures: [{ code: "link_status_evidence_missing" }],
2128
+ };
2129
+ }
2130
+ const results = Array.isArray(linkEvidence.results) ? linkEvidence.results.filter((result) => result && typeof result === "object" && !Array.isArray(result)) : [];
2131
+ const totalCount = typeof linkEvidence.total_count === "number" && Number.isFinite(linkEvidence.total_count) ? linkEvidence.total_count : results.length;
2132
+ const okCount = results.filter((result) => linkStatusResultOk(result, check)).length;
2133
+ const failures = results
2134
+ .filter((result) => !linkStatusResultOk(result, check))
2135
+ .map((result) => ({
2136
+ code: "link_status_failed",
2137
+ url: typeof result.url === "string" ? result.url : null,
2138
+ status: typeof result.status === "number" && Number.isFinite(result.status) ? result.status : null,
2139
+ method: typeof result.method === "string" ? result.method : null,
2140
+ error: typeof result.error === "string" ? result.error : null,
2141
+ bytes: typeof result.bytes === "number" && Number.isFinite(result.bytes)
2142
+ ? result.bytes
2143
+ : typeof result.content_length === "number" && Number.isFinite(result.content_length)
2144
+ ? result.content_length
2145
+ : null,
2146
+ }));
2147
+ if (typeof linkEvidence.error === "string" && linkEvidence.error.trim()) {
2148
+ failures.push({ code: "link_status_capture_failed", error: linkEvidence.error });
2149
+ }
2150
+ const recordedFailedCount = typeof linkEvidence.failed_count === "number" && Number.isFinite(linkEvidence.failed_count) ? linkEvidence.failed_count : 0;
2151
+ if (!failures.length && recordedFailedCount > 0) {
2152
+ failures.push({
2153
+ code: "link_status_recorded_failures",
2154
+ failed_count: recordedFailedCount,
2155
+ failures: Array.isArray(linkEvidence.failures) ? linkEvidence.failures.slice(0, 20) : [],
2156
+ });
2157
+ }
2158
+ if (linkEvidence.truncated === true) {
2159
+ failures.push({
2160
+ code: "link_status_probe_truncated",
2161
+ discovered_count: typeof linkEvidence.discovered_count === "number" ? linkEvidence.discovered_count : totalCount,
2162
+ max_links: typeof linkEvidence.max_links === "number" ? linkEvidence.max_links : check.max_links || 100,
2163
+ });
2164
+ }
2165
+ if (typeof check.expected_count === "number" && Number.isFinite(check.expected_count) && totalCount !== check.expected_count) {
2166
+ failures.push({ code: "link_status_count_mismatch", expected: check.expected_count, actual: totalCount });
2167
+ }
2168
+ if (typeof check.min_count === "number" && Number.isFinite(check.min_count) && totalCount < check.min_count) {
2169
+ failures.push({ code: "link_status_count_below_minimum", min_count: check.min_count, actual: totalCount });
2170
+ }
2171
+ return {
2172
+ viewport: viewport && viewport.name,
2173
+ selector,
2174
+ total_count: totalCount,
2175
+ discovered_count: typeof linkEvidence.discovered_count === "number" ? linkEvidence.discovered_count : totalCount,
2176
+ ok_count: typeof linkEvidence.ok_count === "number" ? linkEvidence.ok_count : okCount,
2177
+ failed_count: failures.length,
2178
+ truncated: linkEvidence.truncated === true,
2179
+ max_links: typeof linkEvidence.max_links === "number" ? linkEvidence.max_links : check.max_links || 100,
2180
+ status_counts: linkEvidence.status_counts && typeof linkEvidence.status_counts === "object" && !Array.isArray(linkEvidence.status_counts) ? linkEvidence.status_counts : {},
2181
+ failures: failures.slice(0, 20),
2182
+ };
2183
+ }
1933
2184
  function frameEvidenceForSelector(viewport, selector) {
1934
2185
  const container = viewport.frames && viewport.frames[selector || ""];
1935
2186
  if (!container || typeof container !== "object" || Array.isArray(container)) return [];
@@ -2583,6 +2834,29 @@ function assessProfile(profile, evidence) {
2583
2834
  });
2584
2835
  continue;
2585
2836
  }
2837
+ if (check.type === "link_status" || check.type === "artifact_link_status") {
2838
+ const selector = linkStatusSelector(check);
2839
+ const summaries = checkViewports.map((viewport) => summarizeLinkStatusEvidence(viewport, check));
2840
+ const failed = summaries.filter((summary) => summary.failed_count > 0);
2841
+ checks.push({
2842
+ type: check.type,
2843
+ label: check.label || check.type,
2844
+ status: failed.length ? "failed" : "passed",
2845
+ evidence: {
2846
+ selector,
2847
+ expected_count: check.expected_count ?? null,
2848
+ min_count: check.min_count ?? null,
2849
+ allowed_statuses: linkStatusAllowedStatuses(check) || ["2xx", "3xx"],
2850
+ require_nonzero_bytes: check.require_nonzero_bytes === true,
2851
+ viewports: summaries,
2852
+ failures: failed.flatMap((summary) => Array.isArray(summary.failures)
2853
+ ? summary.failures.map((failure) => ({ viewport: summary.viewport || null, failure }))
2854
+ : []),
2855
+ },
2856
+ message: failed.length ? "Link status failed in " + failed.length + " viewport(s)." : undefined,
2857
+ });
2858
+ continue;
2859
+ }
2586
2860
  if (check.type === "route_inventory") {
2587
2861
  const inventories = checkViewports
2588
2862
  .map((viewport) => ({ viewport: viewport.name, inventory: viewport.route_inventory }))
@@ -3641,6 +3915,196 @@ async function selectorTextSequence(selector) {
3641
3915
  };
3642
3916
  }).catch((error) => ({ count: 0, visible_count: 0, texts: [], visible_texts: [], error: String(error && error.message ? error.message : error).slice(0, 500) }));
3643
3917
  }
3918
+ function linkProbeMaxLinks(check) {
3919
+ const value = Number(check.max_links || check.maxLinks || check.limit || 100);
3920
+ return Number.isInteger(value) && value > 0 ? Math.min(value, 500) : 100;
3921
+ }
3922
+ function linkProbeDedupe(check) {
3923
+ return check.dedupe !== false;
3924
+ }
3925
+ function linkProbeSameOriginOnly(check) {
3926
+ return check.same_origin_only === true;
3927
+ }
3928
+ async function collectLinkCandidates(selector) {
3929
+ return page.locator(selector).evaluateAll((elements) => {
3930
+ const compact = (value, limit) => String(value || "").replace(/\s+/g, " ").trim().slice(0, limit || 160);
3931
+ return elements.map((element, index) => {
3932
+ const tag = element && element.tagName ? element.tagName.toLowerCase() : "element";
3933
+ const href = element && typeof element.href === "string" ? element.href : "";
3934
+ const src = element && typeof element.src === "string" ? element.src : "";
3935
+ const currentSrc = element && typeof element.currentSrc === "string" ? element.currentSrc : "";
3936
+ const attrHref = element && typeof element.getAttribute === "function" ? element.getAttribute("href") || "" : "";
3937
+ const attrSrc = element && typeof element.getAttribute === "function" ? element.getAttribute("src") || "" : "";
3938
+ const attrPoster = element && typeof element.getAttribute === "function" ? element.getAttribute("poster") || "" : "";
3939
+ const raw = href || currentSrc || src || attrHref || attrSrc || attrPoster;
3940
+ if (!raw) return null;
3941
+ let url = "";
3942
+ try {
3943
+ url = new URL(raw, location.href).href;
3944
+ } catch {}
3945
+ if (!url) return null;
3946
+ return {
3947
+ index,
3948
+ tag,
3949
+ url,
3950
+ raw,
3951
+ text: compact(element.innerText || element.textContent || "", 160),
3952
+ alt: compact(element.getAttribute && element.getAttribute("alt"), 80),
3953
+ };
3954
+ }).filter(Boolean);
3955
+ }).catch((error) => ({ error: String(error && error.message ? error.message : error).slice(0, 500) }));
3956
+ }
3957
+ function linkProbeAllowed(status, check) {
3958
+ if (typeof status !== "number" || !Number.isFinite(status)) return false;
3959
+ const allowed = Array.isArray(check.allowed_statuses) && check.allowed_statuses.length
3960
+ ? check.allowed_statuses
3961
+ : typeof check.expected_status === "number" && Number.isFinite(check.expected_status)
3962
+ ? [check.expected_status]
3963
+ : null;
3964
+ return allowed ? allowed.includes(status) : status >= 200 && status < 400;
3965
+ }
3966
+ function linkProbeResponseFields(response, method) {
3967
+ const contentLengthHeader = response.headers && typeof response.headers.get === "function" ? response.headers.get("content-length") : null;
3968
+ const contentLength = contentLengthHeader && /^\d+$/.test(contentLengthHeader) ? Number(contentLengthHeader) : null;
3969
+ return {
3970
+ method,
3971
+ status: response.status,
3972
+ redirected: Boolean(response.redirected),
3973
+ final_url: response.url || null,
3974
+ content_type: response.headers && typeof response.headers.get === "function" ? response.headers.get("content-type") : null,
3975
+ content_length: contentLength,
3976
+ };
3977
+ }
3978
+ async function probeLinkStatus(candidate, check) {
3979
+ const requireNonzeroBytes = check.require_nonzero_bytes === true;
3980
+ const allowGetFallback = check.allow_get_fallback !== false;
3981
+ const result = {
3982
+ url: candidate.url,
3983
+ tag: candidate.tag,
3984
+ text: candidate.text || null,
3985
+ status: null,
3986
+ method: null,
3987
+ ok: false,
3988
+ content_type: null,
3989
+ content_length: null,
3990
+ bytes: null,
3991
+ redirected: false,
3992
+ final_url: null,
3993
+ error: null,
3994
+ };
3995
+ const applyResponse = async (response, method, readBytes) => {
3996
+ Object.assign(result, linkProbeResponseFields(response, method));
3997
+ if (readBytes || requireNonzeroBytes) {
3998
+ try {
3999
+ const buffer = await response.arrayBuffer();
4000
+ result.bytes = buffer.byteLength;
4001
+ } catch (error) {
4002
+ result.error = String(error && error.message ? error.message : error).slice(0, 500);
4003
+ }
4004
+ }
4005
+ result.ok = linkProbeAllowed(result.status, check)
4006
+ && (!requireNonzeroBytes || (typeof result.bytes === "number" ? result.bytes > 0 : typeof result.content_length === "number" && result.content_length > 0))
4007
+ && !result.error;
4008
+ };
4009
+ try {
4010
+ const response = await fetch(candidate.url, { method: "HEAD", redirect: "follow", cache: "no-store" });
4011
+ await applyResponse(response, "HEAD", false);
4012
+ if (result.ok || !allowGetFallback) return result;
4013
+ } catch (error) {
4014
+ result.error = String(error && error.message ? error.message : error).slice(0, 500);
4015
+ if (!allowGetFallback) return result;
4016
+ }
4017
+ try {
4018
+ result.error = null;
4019
+ const response = await fetch(candidate.url, {
4020
+ method: "GET",
4021
+ redirect: "follow",
4022
+ cache: "no-store",
4023
+ headers: { Range: "bytes=0-0" },
4024
+ });
4025
+ await applyResponse(response, "GET", requireNonzeroBytes);
4026
+ return result;
4027
+ } catch (error) {
4028
+ result.error = String(error && error.message ? error.message : error).slice(0, 500);
4029
+ result.ok = false;
4030
+ return result;
4031
+ }
4032
+ }
4033
+ async function collectLinkStatus(check) {
4034
+ const selector = linkStatusSelector(check);
4035
+ const candidateResult = await collectLinkCandidates(selector);
4036
+ if (candidateResult && candidateResult.error) {
4037
+ return {
4038
+ version: "riddle-proof.link-status.v1",
4039
+ selector,
4040
+ total_count: 0,
4041
+ discovered_count: 0,
4042
+ ok_count: 0,
4043
+ failed_count: 1,
4044
+ failures: [{ code: "link_status_capture_failed", error: candidateResult.error }],
4045
+ results: [],
4046
+ status_counts: {},
4047
+ error: candidateResult.error,
4048
+ };
4049
+ }
4050
+ const baseUrl = page.url() || targetUrl;
4051
+ let candidates = Array.isArray(candidateResult) ? candidateResult : [];
4052
+ if (linkProbeSameOriginOnly(check)) {
4053
+ const origin = new URL(baseUrl).origin;
4054
+ candidates = candidates.filter((candidate) => {
4055
+ try { return new URL(candidate.url).origin === origin; } catch { return false; }
4056
+ });
4057
+ }
4058
+ const discoveredCount = candidates.length;
4059
+ if (linkProbeDedupe(check)) {
4060
+ const seen = new Set();
4061
+ candidates = candidates.filter((candidate) => {
4062
+ if (seen.has(candidate.url)) return false;
4063
+ seen.add(candidate.url);
4064
+ return true;
4065
+ });
4066
+ }
4067
+ const maxLinks = linkProbeMaxLinks(check);
4068
+ const selected = candidates.slice(0, maxLinks);
4069
+ const results = [];
4070
+ for (const candidate of selected) {
4071
+ results.push(await probeLinkStatus(candidate, check));
4072
+ }
4073
+ const failures = results.filter((result) => !result.ok).map((result) => ({
4074
+ code: "link_status_failed",
4075
+ url: result.url,
4076
+ status: result.status,
4077
+ method: result.method,
4078
+ error: result.error,
4079
+ bytes: result.bytes == null ? result.content_length : result.bytes,
4080
+ }));
4081
+ const statusCounts = {};
4082
+ for (const result of results) {
4083
+ const key = result.status == null ? "error" : String(result.status);
4084
+ statusCounts[key] = (statusCounts[key] || 0) + 1;
4085
+ }
4086
+ return {
4087
+ version: "riddle-proof.link-status.v1",
4088
+ selector,
4089
+ max_links: maxLinks,
4090
+ same_origin_only: linkProbeSameOriginOnly(check),
4091
+ dedupe: linkProbeDedupe(check),
4092
+ require_nonzero_bytes: check.require_nonzero_bytes === true,
4093
+ allowed_statuses: Array.isArray(check.allowed_statuses) && check.allowed_statuses.length
4094
+ ? check.allowed_statuses
4095
+ : typeof check.expected_status === "number"
4096
+ ? [check.expected_status]
4097
+ : ["2xx", "3xx"],
4098
+ discovered_count: discoveredCount,
4099
+ total_count: selected.length,
4100
+ truncated: candidates.length > selected.length,
4101
+ ok_count: results.filter((result) => result.ok).length,
4102
+ failed_count: failures.length,
4103
+ status_counts: statusCounts,
4104
+ failures: failures.slice(0, 20),
4105
+ results,
4106
+ };
4107
+ }
3644
4108
  async function frameEvidence(selector) {
3645
4109
  const result = { selector, count: 0, frame_count: 0, frames: [], errors: [] };
3646
4110
  let handles = [];
@@ -4160,6 +4624,7 @@ async function captureViewport(viewport) {
4160
4624
  const frames = {};
4161
4625
  const text_sequences = {};
4162
4626
  const text_matches = {};
4627
+ const link_statuses = {};
4163
4628
  for (const check of profile.checks || []) {
4164
4629
  if (
4165
4630
  (
@@ -4184,6 +4649,10 @@ async function captureViewport(viewport) {
4184
4649
  selectors[check.selector] = selectors[check.selector] || await selectorStats(check.selector);
4185
4650
  frames[check.selector] = frames[check.selector] || await frameEvidence(check.selector);
4186
4651
  }
4652
+ if (check.type === "link_status" || check.type === "artifact_link_status") {
4653
+ const selector = linkStatusSelector(check);
4654
+ link_statuses[selector] = link_statuses[selector] || await collectLinkStatus(check);
4655
+ }
4187
4656
  }
4188
4657
  const screenshotLabel = profileSlug + "-" + viewport.name;
4189
4658
  try {
@@ -4247,6 +4716,7 @@ async function captureViewport(viewport) {
4247
4716
  frames,
4248
4717
  text_sequences,
4249
4718
  text_matches,
4719
+ link_statuses,
4250
4720
  route_inventory: routeInventory,
4251
4721
  setup_action_results: setupActionResults,
4252
4722
  screenshot_label: screenshotLabel,
@@ -4295,6 +4765,18 @@ function buildProfileEvidence(currentViewports) {
4295
4765
  ),
4296
4766
  })),
4297
4767
  })),
4768
+ link_status: currentViewports
4769
+ .filter((viewport) => viewport.link_statuses)
4770
+ .map((viewport) => ({
4771
+ viewport: viewport.name,
4772
+ selectors: Object.entries(viewport.link_statuses || {}).map(([selector, statusSet]) => ({
4773
+ selector,
4774
+ total_count: statusSet && statusSet.total_count,
4775
+ ok_count: statusSet && statusSet.ok_count,
4776
+ failed_count: statusSet && statusSet.failed_count,
4777
+ truncated: statusSet && statusSet.truncated === true,
4778
+ })),
4779
+ })),
4298
4780
  route_inventory: currentViewports
4299
4781
  .filter((viewport) => viewport.route_inventory)
4300
4782
  .map((viewport) => ({