Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions config/public-sites.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
}
20 changes: 19 additions & 1 deletion docs/seo-fleet-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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. `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.

## Local verification

Expand Down
230 changes: 230 additions & 0 deletions tests/test_seo_fleet_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -1193,6 +1288,141 @@ 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",
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 = [
{
"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"),
Expand Down
Loading