From 498e0fb159d2d810dbcaff85dce8878bcc051b93 Mon Sep 17 00:00:00 2001 From: Ralph Smith Date: Sat, 29 Aug 2026 18:08:01 -0600 Subject: [PATCH 1/2] fix(seo): classify non-production homepages --- config/public-sites.json | 14 ++ docs/seo-fleet-audit.md | 20 ++- tests/test_seo_fleet_audit.py | 211 +++++++++++++++++++++++++ tests/test_update_repo_homepages.py | 78 +++++++++- tools/seo_fleet_audit.py | 228 ++++++++++++++++++++++++++-- tools/update_repo_homepages.py | 18 ++- 6 files changed, 550 insertions(+), 19 deletions(-) diff --git a/config/public-sites.json b/config/public-sites.json index b19b969..4fbd178 100644 --- a/config/public-sites.json +++ b/config/public-sites.json @@ -77,5 +77,19 @@ "/settings" ] } + ], + "repository_homepages": [ + { + "repository": "QuantAlchemy/solbeauty", + "classification": "client", + "expected_homepage": "https://www.solbeauty.studio", + "note": "Client production site, excluded from the QuantAlchemy fleet." + }, + { + "repository": "QuantAlchemy/trading-journal", + "classification": "retired", + "expected_homepage": "", + "note": "Superseded by the Trading Journal at https://www.quant-companion.quantalchemy.io/journal." + } ] } diff --git a/docs/seo-fleet-audit.md b/docs/seo-fleet-audit.md index 726502f..89df33d 100644 --- a/docs/seo-fleet-audit.md +++ b/docs/seo-fleet-audit.md @@ -16,6 +16,7 @@ For every site in `config/public-sites.json`, the crawler verifies: - configured `required_canonical_paths` return direct HTTP 200 HTML and declare exactly one matching canonical link; - configured `required_noindex_paths` return direct HTTP 200 HTML, declare `noindex` through a `robots` meta tag or an `X-Robots-Tag` header, and stay crawlable for `User-agent: *`. A page `robots.txt` disallows never gets its `noindex` read, so that combination is reported as `REQUIRED_NOINDEX_UNREACHABLE` rather than passing. - configured `required_robots_disallow_paths` stay blocked for `User-agent: *`. Use this contract for private application routes and callbacks that should not be crawled. These paths are not fetched because their exclusion is the behavior under test. +- repositories with non-production homepage metadata are explicitly classified as `prototype`, `client`, or `retired`. The audit verifies each configured homepage policy and reports stale links as `REPOSITORY_HOMEPAGE_SHOULD_BE_EMPTY` when no supported public deployment exists. The run produces Markdown and JSON receipts with the exact requested URL, expected result, observed status/final URL, and defect code. The scheduled Hermes job delivers the Markdown receipt to the task thread; JSON is retained locally for machine processing. An authenticated `gh` CLI is required because some mapped repositories are private. A GitHub lookup failure exits with operational status `2`; homepage drift remains the normal defect status `1`. @@ -34,7 +35,24 @@ gh auth status python3 tools/update_repo_homepages.py --apply ``` -The default mode is read-only and prints copyable `gh api` commands. Apply mode checks the homepage returned by every GitHub update and stops if GitHub does not persist the canonical value. +The default mode is read-only and prints copyable `gh api` commands. Apply mode checks the homepage returned by every GitHub update and stops if GitHub does not persist the canonical value. The same command also clears homepage fields for classified non-production repositories whose `expected_homepage` is empty. + +## Non-production repository classification + +Add public production sites to `sites`. Add repository-owned deployments that must not enter the production fleet to `repository_homepages`: + +```json +{ + "repository": "QuantAlchemy/example", + "classification": "prototype", + "expected_homepage": "", + "note": "Internal prototype with no supported public deployment." +} +``` + +Use `prototype` for experiments, `client` for client-owned work that is not a QuantAlchemy production surface, and `retired` for superseded products. Set `expected_homepage` to an explicit HTTPS origin only when the non-production deployment should remain linked. Otherwise, keep it empty so the owner-action tool removes stale public metadata. A repository may appear only once across `sites` and `repository_homepages`; duplicate policies are rejected before any audit or update. + +Morning Edge and other dynamic inventory jobs must use this audit receipt as the classification source of truth. They must not treat every non-empty GitHub homepage field as a QuantAlchemy production website. ## Local verification diff --git a/tests/test_seo_fleet_audit.py b/tests/test_seo_fleet_audit.py index 04d7038..62d2f16 100644 --- a/tests/test_seo_fleet_audit.py +++ b/tests/test_seo_fleet_audit.py @@ -3,6 +3,7 @@ from io import StringIO import json from pathlib import Path +from tempfile import TemporaryDirectory import unittest from unittest.mock import Mock, patch @@ -167,6 +168,30 @@ def test_public_sites_config_contains_netly_canary_contract(self) -> None: netly, ) + def test_public_sites_config_classifies_zombie_homepages(self) -> None: + payload = json.loads(Path("config/public-sites.json").read_text(encoding="utf-8")) + + self.assertEqual( + [ + { + "repository": "QuantAlchemy/solbeauty", + "classification": "client", + "expected_homepage": "https://www.solbeauty.studio", + "note": "Client production site, excluded from the QuantAlchemy fleet.", + }, + { + "repository": "QuantAlchemy/trading-journal", + "classification": "retired", + "expected_homepage": "", + "note": ( + "Superseded by the Trading Journal at " + "https://www.quant-companion.quantalchemy.io/journal." + ), + }, + ], + payload["repository_homepages"], + ) + def test_repository_homepage_accepts_exact_origin_and_trailing_slash(self) -> None: site = SiteConfig( name="Example", @@ -315,6 +340,11 @@ def test_main_runs_repository_homepage_canary_and_preserves_operational_failure( with ( patch.object(seo_fleet_audit, "_load_sites", return_value=[site]), + patch.object( + seo_fleet_audit, + "_load_repository_homepages", + return_value=[], + ), patch.object(seo_fleet_audit, "audit_site", return_value=audit), patch.object( seo_fleet_audit, @@ -341,6 +371,11 @@ def test_main_preserves_repository_homepage_drift_as_defect_status(self) -> None with ( patch.object(seo_fleet_audit, "_load_sites", return_value=[site]), + patch.object( + seo_fleet_audit, + "_load_repository_homepages", + return_value=[], + ), patch.object(seo_fleet_audit, "audit_site", return_value=audit), patch.object( seo_fleet_audit, @@ -358,6 +393,66 @@ def test_main_preserves_repository_homepage_drift_as_defect_status(self) -> None [finding.code for finding in audit.findings], ) + def test_main_reports_classified_nonproduction_homepage_drift(self) -> None: + observed = { + "QuantAlchemy/solbeauty": "https://ben-hairstyle.vercel.app", + "QuantAlchemy/trading-journal": ( + "https://trading-journal-rho-sand.vercel.app" + ), + } + output = StringIO() + + with ( + patch.object(seo_fleet_audit, "_load_sites", return_value=[]), + patch.object( + seo_fleet_audit, + "fetch_repository_homepage", + side_effect=observed.__getitem__, + ), + redirect_stdout(output), + ): + status = seo_fleet_audit.main( + ["--config", "config/public-sites.json"] + ) + + report = output.getvalue() + self.assertEqual(1, status) + self.assertIn("2 classified non-production repositories", report) + self.assertIn("`QuantAlchemy/solbeauty`: **client**", report) + self.assertIn("`QuantAlchemy/trading-journal`: **retired**", report) + self.assertEqual(1, report.count("REPOSITORY_HOMEPAGE_MISMATCH")) + self.assertEqual(1, report.count("REPOSITORY_HOMEPAGE_SHOULD_BE_EMPTY")) + self.assertIn("https://ben-hairstyle.vercel.app", report) + self.assertIn("https://trading-journal-rho-sand.vercel.app", report) + + def test_main_accepts_classified_nonproduction_homepage_policies( + self, + ) -> None: + output = StringIO() + + with ( + patch.object(seo_fleet_audit, "_load_sites", return_value=[]), + patch.object( + seo_fleet_audit, + "fetch_repository_homepage", + side_effect={ + "QuantAlchemy/solbeauty": "https://www.solbeauty.studio/", + "QuantAlchemy/trading-journal": "", + }.__getitem__, + ), + redirect_stdout(output), + ): + status = seo_fleet_audit.main( + ["--config", "config/public-sites.json"] + ) + + report = output.getvalue() + self.assertEqual(0, status) + self.assertIn("2 classified non-production repositories", report) + self.assertIn("Client production site", report) + self.assertIn("Superseded by the Trading Journal", report) + self.assertNotIn("REPOSITORY_HOMEPAGE_SHOULD_BE_EMPTY", report) + def test_healthy_site_checks_root_robots_sitemap_and_every_loc(self) -> None: origin = "https://example.com" fetch = FakeFetcher( @@ -1193,6 +1288,122 @@ def test_site_contract_rejects_invalid_repository_identifiers(self) -> None: repository=repository, ) + def test_repository_homepage_classification_rejects_non_origins(self) -> None: + for homepage in ( + "http://example.com", + "https://example.com:8443", + "https://example.com/path", + "https://example.com?preview=1", + ): + with self.subTest(homepage=homepage): + with self.assertRaises(ValueError): + seo_fleet_audit.RepositoryHomepageConfig( + repository="Example/site", + classification="prototype", + expected_homepage=homepage, + note="Prototype deployment.", + ) + + with self.assertRaises(ValueError): + seo_fleet_audit.RepositoryHomepageConfig( + repository="Example/site", + classification="production", + note="Production belongs in the sites list.", + ) + + def test_repository_homepage_loader_rejects_duplicate_repositories(self) -> None: + duplicate_configs = [ + { + "sites": [ + { + "name": "One", + "origin": "https://one.example.com", + "repository": "Example/site", + }, + { + "name": "Two", + "origin": "https://two.example.com", + "repository": "example/SITE", + }, + ], + "repository_homepages": [], + }, + { + "sites": [ + { + "name": "Example", + "origin": "https://www.example.com", + "repository": "Example/site", + } + ], + "repository_homepages": [ + { + "repository": "example/SITE", + "classification": "prototype", + "expected_homepage": "", + "note": "Conflicts with the production site.", + } + ], + }, + { + "sites": [], + "repository_homepages": [ + { + "repository": "Example/site", + "classification": "prototype", + "expected_homepage": "", + "note": "First policy.", + }, + { + "repository": "example/SITE", + "classification": "retired", + "expected_homepage": "", + "note": "Conflicting policy.", + }, + ], + }, + ] + + for index, payload in enumerate(duplicate_configs): + with self.subTest(index=index), TemporaryDirectory() as directory: + config = Path(directory) / "sites.json" + config.write_text(json.dumps(payload), encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "duplicate repository"): + seo_fleet_audit._load_repository_homepages(config) + + def test_main_rejects_duplicate_repositories_before_auditing(self) -> None: + with TemporaryDirectory() as directory: + config = Path(directory) / "sites.json" + config.write_text( + json.dumps( + { + "sites": [ + { + "name": "One", + "origin": "https://one.example.com", + "repository": "Example/site", + }, + { + "name": "Two", + "origin": "https://two.example.com", + "repository": "example/SITE", + }, + ], + "repository_homepages": [], + } + ), + encoding="utf-8", + ) + + with ( + patch.object(seo_fleet_audit, "audit_site") as audit_site, + self.assertRaisesRegex(ValueError, "duplicate repository"), + ): + seo_fleet_audit.main(["--config", str(config)]) + + audit_site.assert_not_called() + def test_site_contract_rejects_non_path_expectations(self) -> None: for field, value in [ ("expected_text_paths", "https://foreign.example/llms.txt"), diff --git a/tests/test_update_repo_homepages.py b/tests/test_update_repo_homepages.py index c1157e9..3f37f70 100644 --- a/tests/test_update_repo_homepages.py +++ b/tests/test_update_repo_homepages.py @@ -38,7 +38,15 @@ def test_load_targets_uses_repository_and_origin_from_fleet_config(self) -> None "name": "No repository", "origin": "https://docs.example.com", }, - ] + ], + "repository_homepages": [ + { + "repository": "Example/client-site", + "classification": "client", + "expected_homepage": "https://client.example.com", + "note": "Client production site.", + } + ], } ), encoding="utf-8", @@ -51,7 +59,11 @@ def test_load_targets_uses_repository_and_origin_from_fleet_config(self) -> None update_repo_homepages.HomepageTarget( repository="Example/site", homepage="https://www.example.com", - ) + ), + update_repo_homepages.HomepageTarget( + repository="Example/client-site", + homepage="https://client.example.com", + ), ], targets, ) @@ -94,6 +106,35 @@ def test_find_updates_reports_missing_and_mismatched_homepages_only(self) -> Non updates, ) + def test_load_targets_rejects_duplicate_repository_classifications(self) -> None: + with TemporaryDirectory() as directory: + config = Path(directory) / "sites.json" + config.write_text( + json.dumps( + { + "sites": [ + { + "name": "Example", + "origin": "https://www.example.com", + "repository": "Example/site", + } + ], + "repository_homepages": [ + { + "repository": "example/SITE", + "classification": "prototype", + "expected_homepage": "", + "note": "Conflicts with the production site.", + } + ], + } + ), + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "duplicate repository"): + update_repo_homepages.load_targets(config) + def test_github_api_failure_preserves_actionable_error_text(self) -> None: failure = subprocess.CalledProcessError( 1, @@ -231,6 +272,39 @@ def test_dry_run_prints_scriptable_commands_without_writing(self) -> None: ) self.assertIn("Re-run with --apply", output.getvalue()) + def test_dry_run_labels_an_empty_homepage_target(self) -> None: + with TemporaryDirectory() as directory: + config = Path(directory) / "sites.json" + config.write_text( + json.dumps( + { + "sites": [], + "repository_homepages": [ + { + "repository": "Example/client-site", + "classification": "client", + "expected_homepage": "", + "note": "No supported public deployment.", + } + ], + } + ), + encoding="utf-8", + ) + output = StringIO() + + with redirect_stdout(output): + status = update_repo_homepages.main( + ["--config", str(config)], + homepage_fetcher=lambda _repository: "https://preview.example.net", + ) + + self.assertEqual(1, status) + self.assertIn( + "https://preview.example.net -> empty homepage", output.getvalue() + ) + self.assertIn("--field homepage=", output.getvalue()) + def test_apply_mode_reports_permission_failure_without_a_traceback(self) -> None: with TemporaryDirectory() as directory: config = Path(directory) / "sites.json" diff --git a/tools/seo_fleet_audit.py b/tools/seo_fleet_audit.py index dc1b2d0..a533cb3 100644 --- a/tools/seo_fleet_audit.py +++ b/tools/seo_fleet_audit.py @@ -28,6 +28,7 @@ USER_AGENT = "QuantAlchemy-SEOFleetAudit/1.0" MAX_BODY_BYTES = 5 * 1024 * 1024 XML_CONTENT_TYPES = ("application/xml", "text/xml", "+xml") +NON_PRODUCTION_CLASSIFICATIONS = frozenset({"prototype", "client", "retired"}) @dataclass(frozen=True) @@ -111,6 +112,54 @@ def __post_init__(self) -> None: ) +@dataclass(frozen=True) +class RepositoryHomepageConfig: + repository: str + classification: str + expected_homepage: str = "" + note: str = "" + + def __post_init__(self) -> None: + if not re.fullmatch( + r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", self.repository + ): + raise ValueError( + "repository homepage entries must use the GitHub owner/name format: " + f"{self.repository}" + ) + if self.classification not in NON_PRODUCTION_CLASSIFICATIONS: + raise ValueError( + f"{self.repository}: classification must be one of " + f"{', '.join(sorted(NON_PRODUCTION_CLASSIFICATIONS))}" + ) + if self.expected_homepage: + parsed = urlsplit(self.expected_homepage) + try: + port = parsed.port + except ValueError as exc: + raise ValueError( + f"{self.repository}: expected_homepage has an invalid port" + ) from exc + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username + or parsed.password + or port not in {None, 443} + or parsed.path.rstrip("/") + or parsed.query + or parsed.fragment + ): + raise ValueError( + f"{self.repository}: expected_homepage must be empty or an HTTPS origin" + ) + object.__setattr__( + self, "expected_homepage", self.expected_homepage.rstrip("/") + ) + if not self.note.strip(): + raise ValueError(f"{self.repository}: note must explain the classification") + + def _reject_nonstandard_json_constant(value: str) -> None: raise ValueError(f"non-standard JSON constant: {value}") @@ -155,6 +204,16 @@ def healthy(self) -> bool: return not self.findings +@dataclass +class RepositoryHomepageAudit: + config: RepositoryHomepageConfig + findings: list[Finding] = field(default_factory=list) + + @property + def healthy(self) -> bool: + return not self.findings + + class _NoRedirect(HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def] return None @@ -461,6 +520,61 @@ def audit_repository_homepages( return lookup_failed +def audit_classified_repository_homepages( + configs: Iterable[RepositoryHomepageConfig], + homepage_fetcher: Callable[[str], str], +) -> tuple[list[RepositoryHomepageAudit], bool]: + """Audit explicitly non-production repository homepage policies.""" + audits: list[RepositoryHomepageAudit] = [] + lookup_failed = False + for config in configs: + audit = RepositoryHomepageAudit(config) + repository_url = f"https://github.com/{config.repository}" + try: + observed = homepage_fetcher(config.repository).strip() + except Exception as exc: + audit.findings.append( + Finding( + "REPOSITORY_HOMEPAGE_LOOKUP_FAILED", + repository_url, + config.expected_homepage or "empty homepage", + f"{type(exc).__name__}: {exc}", + ) + ) + lookup_failed = True + else: + expected = config.expected_homepage + if not expected and observed: + audit.findings.append( + Finding( + "REPOSITORY_HOMEPAGE_SHOULD_BE_EMPTY", + repository_url, + "empty homepage", + observed, + ) + ) + elif expected and not observed: + audit.findings.append( + Finding( + "REPOSITORY_HOMEPAGE_MISSING", + repository_url, + expected, + "empty homepage", + ) + ) + elif expected and observed not in {expected, f"{expected}/"}: + audit.findings.append( + Finding( + "REPOSITORY_HOMEPAGE_MISMATCH", + repository_url, + expected, + observed, + ) + ) + audits.append(audit) + return audits, lookup_failed + + def fetch_repository_homepage(repository: str) -> str: """Read a repository homepage through the authenticated GitHub CLI.""" owner, separator, name = repository.partition("/") @@ -904,9 +1018,15 @@ def audit_loc(loc: str) -> None: list(executor.map(audit_loc, locs)) -def render_markdown(audits: Iterable[SiteAudit]) -> str: +def render_markdown( + audits: Iterable[SiteAudit], + repository_homepage_audits: Iterable[RepositoryHomepageAudit] = (), +) -> str: audit_list = list(audits) - finding_count = sum(len(audit.findings) for audit in audit_list) + homepage_audit_list = list(repository_homepage_audits) + finding_count = sum(len(audit.findings) for audit in audit_list) + sum( + len(audit.findings) for audit in homepage_audit_list + ) checked_count = sum(audit.checked_urls for audit in audit_list) sitemap_count = sum(audit.sitemap_urls for audit in audit_list) generated = datetime.now(timezone.utc).isoformat(timespec="seconds") @@ -916,8 +1036,10 @@ def render_markdown(audits: Iterable[SiteAudit]) -> str: f"Generated: `{generated}`", "", ( - f"**Summary:** {len(audit_list)} sites; {checked_count} fetched URLs; " - f"{sitemap_count} sitemap page URLs; {finding_count} defects." + f"**Summary:** {len(audit_list)} sites; " + f"{len(homepage_audit_list)} classified non-production repositories; " + f"{checked_count} fetched URLs; {sitemap_count} sitemap page URLs; " + f"{finding_count} defects." ), "", ] @@ -946,6 +1068,29 @@ def render_markdown(audits: Iterable[SiteAudit]) -> str: ) lines.append("") + if homepage_audit_list: + lines.extend(["## Repository Homepage Classifications", ""]) + for audit in homepage_audit_list: + icon = "✅" if audit.healthy else "❌" + lines.extend( + [ + ( + f"### {icon} `{audit.config.repository}`: " + f"**{audit.config.classification}**" + ), + "", + audit.config.note, + "", + ] + ) + for finding in audit.findings: + lines.append( + f"- **{finding.code}** — `{finding.url}` — " + f"expected: {finding.expected}; observed: {finding.observed}" + ) + if audit.findings: + lines.append("") + return "\n".join(lines).rstrip() + "\n" @@ -955,10 +1100,47 @@ def _load_sites(path: Path) -> list[SiteConfig]: return [SiteConfig(**row) for row in rows] -def _json_payload(audits: list[SiteAudit]) -> dict[str, object]: +def parse_repository_homepages(payload: object) -> list[RepositoryHomepageConfig]: + """Parse non-production homepage policies without duplicate repositories.""" + if not isinstance(payload, dict): + return [] + seen: dict[str, str] = {} + for row in payload.get("sites", []): + if not isinstance(row, dict) or not row.get("repository"): + continue + repository = str(row["repository"]) + key = repository.casefold() + if key in seen: + raise ValueError(f"duplicate repository homepage policy: {repository}") + seen[key] = repository + + configs: list[RepositoryHomepageConfig] = [] + for row in payload.get("repository_homepages", []): + config = RepositoryHomepageConfig(**row) + key = config.repository.casefold() + if key in seen: + raise ValueError( + f"duplicate repository homepage policy: {config.repository}" + ) + seen[key] = config.repository + configs.append(config) + return configs + + +def _load_repository_homepages(path: Path) -> list[RepositoryHomepageConfig]: + payload = json.loads(path.read_text(encoding="utf-8")) + return parse_repository_homepages(payload) + + +def _json_payload( + audits: list[SiteAudit], + repository_homepage_audits: list[RepositoryHomepageAudit] | None = None, +) -> dict[str, object]: + homepage_audits = repository_homepage_audits or [] return { "generatedAt": datetime.now(timezone.utc).isoformat(timespec="seconds"), - "healthy": all(audit.healthy for audit in audits), + "healthy": all(audit.healthy for audit in audits) + and all(audit.healthy for audit in homepage_audits), "sites": [ { "site": asdict(audit.site), @@ -969,6 +1151,14 @@ def _json_payload(audits: list[SiteAudit]) -> dict[str, object]: } for audit in audits ], + "repositoryHomepages": [ + { + "config": asdict(audit.config), + "healthy": audit.healthy, + "findings": [asdict(finding) for finding in audit.findings], + } + for audit in homepage_audits + ], } @@ -980,14 +1170,21 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--max-workers", type=int, default=8) args = parser.parse_args(argv) + sites = _load_sites(args.config) + repository_homepage_configs = _load_repository_homepages(args.config) audits = [ audit_site(site, max_workers=args.max_workers) - for site in _load_sites(args.config) + for site in sites ] homepage_lookup_failed = audit_repository_homepages( audits, fetch_repository_homepage ) - markdown = render_markdown(audits) + repository_homepage_audits, classification_lookup_failed = ( + audit_classified_repository_homepages( + repository_homepage_configs, fetch_repository_homepage + ) + ) + markdown = render_markdown(audits, repository_homepage_audits) print(markdown, end="") if args.markdown_out: @@ -996,12 +1193,21 @@ def main(argv: list[str] | None = None) -> int: if args.json_out: args.json_out.parent.mkdir(parents=True, exist_ok=True) args.json_out.write_text( - json.dumps(_json_payload(audits), indent=2) + "\n", encoding="utf-8" + json.dumps( + _json_payload(audits, repository_homepage_audits), indent=2 + ) + + "\n", + encoding="utf-8", ) - if homepage_lookup_failed: + if homepage_lookup_failed or classification_lookup_failed: return 2 - return 0 if all(audit.healthy for audit in audits) else 1 + return ( + 0 + if all(audit.healthy for audit in audits) + and all(audit.healthy for audit in repository_homepage_audits) + else 1 + ) if __name__ == "__main__": diff --git a/tools/update_repo_homepages.py b/tools/update_repo_homepages.py index f056ebb..1511a0b 100644 --- a/tools/update_repo_homepages.py +++ b/tools/update_repo_homepages.py @@ -14,9 +14,9 @@ from typing import Callable if __package__: - from tools.seo_fleet_audit import SiteConfig + from tools.seo_fleet_audit import SiteConfig, parse_repository_homepages else: - from seo_fleet_audit import SiteConfig + from seo_fleet_audit import SiteConfig, parse_repository_homepages MAX_GITHUB_RESPONSE_BYTES = 5 * 1024 * 1024 @@ -35,15 +35,22 @@ class HomepageUpdate: def load_targets(path: Path) -> list[HomepageTarget]: - """Load repo-to-origin targets from the fleet's canonical site map.""" + """Load every expected GitHub homepage from the fleet configuration.""" payload = json.loads(path.read_text(encoding="utf-8")) rows = payload["sites"] if isinstance(payload, dict) else payload sites = [SiteConfig(**row) for row in rows] - return [ + targets = [ HomepageTarget(site.repository, site.origin) for site in sites if site.repository ] + if isinstance(payload, dict): + classified = parse_repository_homepages(payload) + targets.extend( + HomepageTarget(item.repository, item.expected_homepage) + for item in classified + ) + return targets def find_updates( @@ -165,7 +172,8 @@ def main( print(f"{len(updates)} repository homepage {noun} required:") for update in updates: observed = update.observed or "empty homepage" - print(f"- {update.repository}: {observed} -> {update.homepage}") + target = update.homepage or "empty homepage" + print(f"- {update.repository}: {observed} -> {target}") print(f" {render_command(update)}") if not args.apply: From 607a14cfaa38cab7a9fefb1a004cb71637e06896 Mon Sep 17 00:00:00 2001 From: Ralph Smith Date: Sat, 29 Aug 2026 18:12:29 -0600 Subject: [PATCH 2/2] fix(seo): require explicit homepage targets --- docs/seo-fleet-audit.md | 2 +- tests/test_seo_fleet_audit.py | 19 ++++++++++++++++++ tests/test_update_repo_homepages.py | 31 +++++++++++++++++++++++++++++ tools/seo_fleet_audit.py | 8 ++++++-- 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/docs/seo-fleet-audit.md b/docs/seo-fleet-audit.md index 89df33d..2dee4a6 100644 --- a/docs/seo-fleet-audit.md +++ b/docs/seo-fleet-audit.md @@ -50,7 +50,7 @@ Add public production sites to `sites`. Add repository-owned deployments that mu } ``` -Use `prototype` for experiments, `client` for client-owned work that is not a QuantAlchemy production surface, and `retired` for superseded products. Set `expected_homepage` to an explicit HTTPS origin only when the non-production deployment should remain linked. Otherwise, keep it empty so the owner-action tool removes stale public metadata. A repository may appear only once across `sites` and `repository_homepages`; duplicate policies are rejected before any audit or update. +Use `prototype` for experiments, `client` for client-owned work that is not a QuantAlchemy production surface, and `retired` for superseded products. `expected_homepage` is required and must be a string. Set it to an explicit HTTPS origin only when the non-production deployment should remain linked. Use an explicit empty string only when the owner-action tool should remove stale public metadata. A repository may appear only once across `sites` and `repository_homepages`; duplicate policies are rejected before any audit or update. Morning Edge and other dynamic inventory jobs must use this audit receipt as the classification source of truth. They must not treat every non-empty GitHub homepage field as a QuantAlchemy production website. diff --git a/tests/test_seo_fleet_audit.py b/tests/test_seo_fleet_audit.py index 62d2f16..591682b 100644 --- a/tests/test_seo_fleet_audit.py +++ b/tests/test_seo_fleet_audit.py @@ -1308,9 +1308,28 @@ def test_repository_homepage_classification_rejects_non_origins(self) -> None: seo_fleet_audit.RepositoryHomepageConfig( repository="Example/site", classification="production", + expected_homepage="", note="Production belongs in the sites list.", ) + def test_repository_homepage_requires_an_explicit_string_target(self) -> None: + with self.assertRaises(TypeError): + seo_fleet_audit.RepositoryHomepageConfig( + repository="Example/site", + classification="prototype", + note="Missing target must not imply a destructive clear.", + ) + + for homepage in (None, 123, False): + with self.subTest(homepage=homepage): + with self.assertRaisesRegex(ValueError, "must be a string"): + seo_fleet_audit.RepositoryHomepageConfig( + repository="Example/site", + classification="prototype", + expected_homepage=homepage, # type: ignore[arg-type] + note="Invalid target must not imply a destructive clear.", + ) + def test_repository_homepage_loader_rejects_duplicate_repositories(self) -> None: duplicate_configs = [ { diff --git a/tests/test_update_repo_homepages.py b/tests/test_update_repo_homepages.py index 3f37f70..720fa3d 100644 --- a/tests/test_update_repo_homepages.py +++ b/tests/test_update_repo_homepages.py @@ -135,6 +135,37 @@ def test_load_targets_rejects_duplicate_repository_classifications(self) -> None with self.assertRaisesRegex(ValueError, "duplicate repository"): update_repo_homepages.load_targets(config) + def test_load_targets_rejects_missing_or_null_expected_homepage(self) -> None: + invalid_policies = [ + { + "repository": "Example/missing", + "classification": "prototype", + "note": "Missing target.", + }, + { + "repository": "Example/null", + "classification": "retired", + "expected_homepage": None, + "note": "Null target.", + }, + ] + + for policy in invalid_policies: + with self.subTest(repository=policy["repository"]), TemporaryDirectory() as directory: + config = Path(directory) / "sites.json" + config.write_text( + json.dumps( + { + "sites": [], + "repository_homepages": [policy], + } + ), + encoding="utf-8", + ) + + with self.assertRaises((TypeError, ValueError)): + update_repo_homepages.load_targets(config) + def test_github_api_failure_preserves_actionable_error_text(self) -> None: failure = subprocess.CalledProcessError( 1, diff --git a/tools/seo_fleet_audit.py b/tools/seo_fleet_audit.py index a533cb3..0a808c2 100644 --- a/tools/seo_fleet_audit.py +++ b/tools/seo_fleet_audit.py @@ -116,7 +116,7 @@ def __post_init__(self) -> None: class RepositoryHomepageConfig: repository: str classification: str - expected_homepage: str = "" + expected_homepage: str note: str = "" def __post_init__(self) -> None: @@ -132,6 +132,10 @@ def __post_init__(self) -> None: f"{self.repository}: classification must be one of " f"{', '.join(sorted(NON_PRODUCTION_CLASSIFICATIONS))}" ) + if not isinstance(self.expected_homepage, str): + raise ValueError( + f"{self.repository}: expected_homepage must be a string" + ) if self.expected_homepage: parsed = urlsplit(self.expected_homepage) try: @@ -156,7 +160,7 @@ def __post_init__(self) -> None: object.__setattr__( self, "expected_homepage", self.expected_homepage.rstrip("/") ) - if not self.note.strip(): + if not isinstance(self.note, str) or not self.note.strip(): raise ValueError(f"{self.repository}: note must explain the classification")