*/ public static function checks(): array { return [ ['id' => 'disc-llms-route', 'category' => self::CATEGORY_DISCOVERABILITY, 'weight' => 1, 'title' => 'Public llms.txt route'], ['id' => 'disc-agent-map-route', 'category' => self::CATEGORY_DISCOVERABILITY, 'weight' => 2, 'title' => 'Public agent-map.json route'], ['id' => 'disc-llms-valid', 'category' => self::CATEGORY_DISCOVERABILITY, 'weight' => 1, 'title' => 'llms.txt response validity'], ['id' => 'disc-sitemap', 'category' => self::CATEGORY_DISCOVERABILITY, 'weight' => 8, 'title' => 'XML sitemap discovery'], ['id' => 'disc-robots-public', 'category' => self::CATEGORY_DISCOVERABILITY, 'weight' => 8, 'title' => 'Site crawl discovery signals'], ['id' => 'und-llms-emerging', 'category' => self::CATEGORY_UNDERSTANDING, 'weight' => 2, 'title' => 'llms.txt emerging publisher file'], ['id' => 'und-agent-map-format', 'category' => self::CATEGORY_UNDERSTANDING, 'weight' => 4, 'title' => 'agent-map.json product format'], ['id' => 'und-home-schema', 'category' => self::CATEGORY_UNDERSTANDING, 'weight' => 10, 'title' => 'Homepage structured data'], ['id' => 'und-artifact-manifest', 'category' => self::CATEGORY_UNDERSTANDING, 'weight' => 4, 'title' => 'Artifact manifest alignment'], ['id' => 'trn-https', 'category' => self::CATEGORY_TRUST, 'weight' => 6, 'title' => 'HTTPS in use'], ['id' => 'trn-url-consistency', 'category' => self::CATEGORY_TRUST, 'weight' => 5, 'title' => 'Home/site URL consistency'], ['id' => 'trn-robots-rules', 'category' => self::CATEGORY_TRUST, 'weight' => 5, 'title' => 'robots.txt agent rules'], ['id' => 'trn-search-visibility', 'category' => self::CATEGORY_TRUST, 'weight' => 4, 'title' => 'Search visibility settings'], ['id' => 'act-intent-declarations', 'category' => self::CATEGORY_ACTIONS, 'weight' => 10, 'title' => 'Local action/intent declarations'], ['id' => 'act-agent-map-actions', 'category' => self::CATEGORY_ACTIONS, 'weight' => 10, 'title' => 'agent-map action metadata'], ['id' => 'saf-content-policy', 'category' => self::CATEGORY_SAFETY, 'weight' => 10, 'title' => 'Content exposure policy'], ['id' => 'saf-kill-switch', 'category' => self::CATEGORY_SAFETY, 'weight' => 2, 'title' => 'Artifact kill switch'], ['id' => 'saf-artifact-stale', 'category' => self::CATEGORY_SAFETY, 'weight' => 6, 'title' => 'Published artifact freshness'], ['id' => 'saf-physical-llms-conflict', 'category' => self::CATEGORY_SAFETY, 'weight' => 2, 'title' => 'Physical llms.txt conflict'], ]; } /** @return array */ public static function category_caps(): array { return [ self::CATEGORY_DISCOVERABILITY => 20, self::CATEGORY_UNDERSTANDING => 20, self::CATEGORY_TRUST => 20, self::CATEGORY_ACTIONS => 20, self::CATEGORY_SAFETY => 20, ]; } /** @return list */ public static function allowed_statuses(): array { return [ self::STATUS_PASS, self::STATUS_FAIL, self::STATUS_WARNING, self::STATUS_UNKNOWN, self::STATUS_NOT_APPLICABLE, ]; } public static function normalize_status(string $status): string { return in_array($status, self::allowed_statuses(), true) ? $status : self::STATUS_UNKNOWN; } public static function total_weight(): int { $sum = 0; foreach (self::checks() as $check) { $sum += (int) $check['weight']; } return $sum; } public static function is_valid_check_id(string $id): bool { foreach (self::checks() as $check) { if ($check['id'] === $id) { return true; } } return false; } /** @return list */ public static function allowed_basis_values(): array { return [self::BASIS_ESTABLISHED, self::BASIS_EMERGING_PROPOSAL, self::BASIS_PRODUCT_METHODOLOGY]; } public static function is_allowed_basis(string $basis): bool { return in_array($basis, self::allowed_basis_values(), true); } public static function basis_for_check(string $check_id): string { return match ($check_id) { 'disc-llms-route', 'disc-llms-valid', 'und-llms-emerging' => self::BASIS_EMERGING_PROPOSAL, 'disc-agent-map-route', 'und-agent-map-format', 'und-artifact-manifest', 'act-intent-declarations', 'act-agent-map-actions', 'saf-content-policy', 'saf-kill-switch', 'saf-artifact-stale', 'saf-physical-llms-conflict' => self::BASIS_PRODUCT_METHODOLOGY, 'disc-sitemap', 'disc-robots-public', 'und-home-schema', 'trn-https', 'trn-url-consistency', 'trn-robots-rules', 'trn-search-visibility' => self::BASIS_ESTABLISHED, default => throw new InvalidArgumentException('Unknown readiness check id: ' . sanitize_key($check_id)), }; } public static function points_for_status(string $status, int $weight): int { $normalized = self::normalize_status($status); return match ($normalized) { self::STATUS_PASS => $weight, self::STATUS_WARNING => (int) floor($weight / 2), self::STATUS_FAIL, self::STATUS_UNKNOWN, self::STATUS_NOT_APPLICABLE => 0, default => 0, }; } public static function severity_for_status(string $status): string { return match (self::normalize_status($status)) { self::STATUS_FAIL => 'high', self::STATUS_WARNING, self::STATUS_UNKNOWN => 'medium', self::STATUS_PASS, self::STATUS_NOT_APPLICABLE => 'info', default => 'medium', }; } /** * @param list> $findings * @return array{score: int, categories: array, total_earned: int} */ public static function score_findings(array $findings): array { $by_id = []; foreach ($findings as $finding) { if (!is_array($finding) || empty($finding['id'])) { continue; } $by_id[(string) $finding['id']] = $finding; } $categories = array_fill_keys(array_keys(self::category_caps()), 0); $total_earned = 0; foreach (self::checks() as $check) { $id = (string) $check['id']; $weight = (int) $check['weight']; $category = (string) $check['category']; $raw_status = is_array($by_id[$id] ?? null) ? (string) ($by_id[$id]['status'] ?? self::STATUS_UNKNOWN) : self::STATUS_UNKNOWN; $earned = self::points_for_status($raw_status, $weight); $categories[$category] += $earned; $total_earned += $earned; } return [ 'score' => max(0, min(100, $total_earned)), 'categories' => $categories, 'total_earned' => $total_earned, ]; } /** @param array $partial @return array */ public static function normalize_finding(array $partial): array { $id = (string) ($partial['id'] ?? ''); $check = null; foreach (self::checks() as $definition) { if ($definition['id'] === $id) { $check = $definition; break; } } if ($check === null) { throw new InvalidArgumentException('Unknown readiness check id: ' . sanitize_key($id)); } $status = self::normalize_status((string) ($partial['status'] ?? self::STATUS_UNKNOWN)); $weight = (int) $check['weight']; $basis = self::basis_for_check($id); return [ 'id' => $id, 'category' => (string) $check['category'], 'title' => (string) $check['title'], 'severity' => self::severity_for_status($status), 'status' => $status, 'weight' => $weight, 'earnedPoints' => self::points_for_status($status, $weight), 'evidence' => self::bound_evidence($partial['evidence'] ?? []), 'whyItMatters' => (string) ($partial['whyItMatters'] ?? ''), 'nextStep' => (string) ($partial['nextStep'] ?? ''), 'lastChecked' => (string) ($partial['lastChecked'] ?? TheDotDevAgenticBridge_Readiness_Clock::iso()), 'basis' => $basis, ]; } /** @param mixed $evidence */ public static function bound_evidence(mixed $evidence): array { if (!is_array($evidence)) { return []; } $out = []; $count = 0; foreach ($evidence as $key => $value) { if ($count >= 12) { break; } if (!self::is_allowed_evidence_key((string) $key)) { continue; } if (self::is_forbidden_evidence_value($value)) { continue; } if (is_scalar($value) || $value === null) { $bounded = substr((string) $value, 0, 240); if (self::is_forbidden_evidence_value($bounded)) { continue; } $out[(string) $key] = $bounded; } elseif (is_array($value)) { $flat = []; foreach ($value as $item) { if (count($flat) >= 8) { break; } if (is_scalar($item) && !self::is_forbidden_evidence_value($item)) { $flat[] = substr((string) $item, 0, 120); } } if ($flat !== []) { $out[(string) $key] = $flat; } } ++$count; } return $out; } public static function is_allowed_evidence_key(string $key): bool { $normalized = self::normalize_evidence_key($key); return $normalized !== '' && isset(self::allowed_evidence_keys()[$normalized]); } /** @return array */ public static function allowed_evidence_keys(): array { static $keys = null; if ($keys !== null) { return $keys; } $list = [ 'httpstatus', 'contenttype', 'bytelength', 'error', 'blogpublic', 'generalroot', 'oairoot', 'robotsstatus', 'artifactspublished', 'routeconflict', 'path', 'root', 'candidateschecked', 'validationerrors', 'entrycount', 'semanticmatch', 'blocks', 'validblocks', 'malformedblocks', 'schemaorgcontext', 'types', 'namecount', 'urlcount', 'idcount', 'manifestcount', 'divergentcount', 'contentids', 'checkedcount', 'ineligiblecount', 'missingcount', 'killswitch', 'semanticchanged', 'ageseconds', 'thresholdseconds', 'physicalpath', 'https', 'host', 'scheme', 'homehost', 'sitehost', 'homescheme', 'sitescheme', 'oaisearchbot', 'gptbot', 'general', 'localcontrols', 'entrieswithactions', ]; $keys = array_fill_keys($list, true); return $keys; } private static function normalize_evidence_key(string $key): string { return preg_replace('/[^a-z0-9]/', '', strtolower($key)) ?? ''; } private static function is_forbidden_evidence_value(mixed $value): bool { if (!is_scalar($value)) { return false; } $normalized = strtolower((string) $value); return str_contains($normalized, 'secret_sentinel') || str_contains($normalized, 'password_sentinel') || str_contains($normalized, 'token_sentinel') || str_contains($normalized, 'credential_sentinel') || str_contains($normalized, 'nonce_sentinel'); } public static function evidence_key_label(string $key): string { $normalized = self::normalize_evidence_key($key); $labels = [ 'httpstatus' => __('HTTP status', 'agentic-bridge'), 'contenttype' => __('Content type', 'agentic-bridge'), 'bytelength' => __('Response size', 'agentic-bridge'), 'error' => __('Error', 'agentic-bridge'), 'blogpublic' => __('Search visibility', 'agentic-bridge'), 'generalroot' => __('General crawl rule', 'agentic-bridge'), 'oairoot' => __('OpenAI crawl rule', 'agentic-bridge'), 'robotsstatus' => __('robots.txt status', 'agentic-bridge'), 'artifactspublished' => __('Artifacts published', 'agentic-bridge'), 'routeconflict' => __('Route conflict', 'agentic-bridge'), 'path' => __('Path', 'agentic-bridge'), 'root' => __('Site root', 'agentic-bridge'), 'candidateschecked' => __('URLs checked', 'agentic-bridge'), 'validationerrors' => __('Validation issues', 'agentic-bridge'), 'entrycount' => __('Entries found', 'agentic-bridge'), 'semanticmatch' => __('Semantic match', 'agentic-bridge'), 'types' => __('Schema types', 'agentic-bridge'), 'https' => __('HTTPS', 'agentic-bridge'), 'host' => __('Host', 'agentic-bridge'), 'scheme' => __('Scheme', 'agentic-bridge'), 'killswitch' => __('Kill switch', 'agentic-bridge'), 'ageseconds' => __('Age (seconds)', 'agentic-bridge'), ]; return $labels[$normalized] ?? ucfirst(str_replace('_', ' ', $normalized)); } /** * @param array $evidence * @return list */ public static function evidence_lines(array $evidence, bool $include_raw_keys = false): array { $lines = []; foreach ($evidence as $key => $value) { if (!is_scalar($value) && !is_array($value)) { continue; } $label = $include_raw_keys ? (string) $key : self::evidence_key_label((string) $key); $rendered = is_array($value) ? implode(', ', array_map('strval', $value)) : (string) $value; $lines[] = $label . ': ' . $rendered; } return $lines; } }