diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 9fc9b73..f2df881 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "omnirank",
- "version": "0.3.0",
+ "version": "0.4.0",
"description": "Audit any website across SEO, answer-engine and generative-engine criteria, and generate llms.txt, llms-full.txt and facts.json so AI engines can ingest it as ground truth.",
"author": { "name": "S M Moshiur Rahman", "email": "moshiur@publicpulse.com.bd" },
"homepage": "https://github.com/bemoshiur/OmniRank",
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1f18f5e..d199967 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,7 +16,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: ["3.11", "3.12", "3.13"]
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v7
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e6f88f6..23c7fa0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,82 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
+## [0.4.0] - 2026-08-04
+
+**"OmniRank finds where your site contradicts itself."** 18 new finding ids, a new
+`security` layer, and the scoring fix that had to land before any of them.
+
+This release writes nothing. `omnirank fix` still previews diffs and modifies no
+file — v0.3.0's strings promising `--write` "in v0.4.0" were corrected rather than
+honoured, because auditing better is zero-risk and a richer audit is what earns
+the right to edit files later.
+
+### Changed — scoring (behavioural)
+
+- Each layer's 100-point budget is now divided by the number of gates that layer
+ actually has, instead of being a flat constant. One maximally-broken gate costs
+ its layer exactly `1/N`, and a layer reaches 0 only when **every** one of its
+ gates is maxed. Under the old model, seven broken gates zeroed a layer whether
+ it had seven gates or thirty — so each of this release's eight new `seo` gates
+ would have made saturation cheaper (44% of the layer to floor it at 16 gates,
+ 29% at 24). `GATE_CAP` is unchanged and still caps any single gate.
+ **Scores from 0.3.0 and 0.4.0 are not comparable.**
+
+### Added — `security` layer
+
+`security.hsts.missing`, `security.hsts.short-max-age`, `security.nosniff.missing`,
+`security.csp.absent`, `security.referrer-policy.missing` — all `info`, all costing
+zero points. The gap analysis puts header grading out of scope by identity, so
+these are reported as inventory facts and never graded. `HSTS_MIN_MAX_AGE` is
+OmniRank's own floor and the finding says so; no claim is made about any browser
+preload list. CSP **absence** is reported; CSP **contents** deliberately are not.
+
+`security.mixed-content.subresource` (error) and `security.https-redirect.missing`
+(error) are the two that break something observable. Mixed content is suppressed by
+`upgrade-insecure-requests` and ignores `` rels that are not subresources.
+The layer enters `layersRun` only when it actually ran.
+
+### Added — indexability contradictions
+
+`seo.robots-sitemap.disallowed`, `seo.canonical-target.noindexed`,
+`seo.canonical-target.not-found`, `seo.canonical-target.redirects`,
+`seo.hreflang-noindex.alternate`. Every one is provable from the site's own
+declarations. Canonical targets outside the crawled set are probed once each,
+deduplicated, capped at 25 per audit; anything past the cap is `budget-exceeded`
+in `notEvaluated`, never skipped silently.
+
+### Added — structured data
+
+`seo.schema-required.missing-property` validates Google's required rich-result
+properties for Article/NewsArticle/BlogPosting, Product, FAQPage, BreadcrumbList,
+Organization and LocalBusiness. `warning`, not `error`: the table is a transcription
+on a fixed date with no freshness test, and a stale required-property table
+fabricates errors. Every finding states that the markup is valid schema.org and
+that what is unmet is Google's requirement — two different claims.
+
+### Added — on-page
+
+`seo.image-alt.missing` (never fires on `alt=""`, which is the spec's decorative
+marker), `seo.heading-order.skipped`, `seo.link-text.empty`, `seo.link-text.generic`
+(info, English-only word list), `seo.lang.missing` (error — a missing `lang` makes
+`bands.resolve_band()` measure a non-Latin page against the Latin word band, so
+`aeo.answer-block.length` reports a problem that does not exist).
+
+### Added — report schema (additive)
+
+`"security"` in the layer enum; `"matcher-unsupported"` and `"budget-exceeded"` in
+the `notEvaluated` reason enum. Reports written before 0.4.0 still validate.
+
+### Notes on Python version
+
+`urllib.robotparser` became RFC 9309 compliant in **Python 3.14**. On 3.11–3.13 it
+ignores path wildcards and matches rules in file order, so it can report a URL as
+disallowed that RFC 9309 says is allowed. `seo.robots-sitemap.disallowed` probes
+the interpreter's actual behaviour and, when the site's published rules need a
+capability this interpreter lacks, records `matcher-unsupported` in `notEvaluated`
+rather than answering. Coverage of that one gate is therefore broader on Python
+3.14+ than on 3.11–3.13; correctness is identical on both.
+
## [0.3.0] - 2026-08-04
"The locator." OmniRank can now say which file is wrong and show the diff that would
@@ -233,7 +309,8 @@ against a real 57-URL site.
path and a Node in-repo path producing identical output.
- `omnirank` CLI with a zero-config URL mode and CI-usable exit codes.
-[Unreleased]: https://github.com/bemoshiur/OmniRank/compare/v0.3.0...HEAD
+[Unreleased]: https://github.com/bemoshiur/OmniRank/compare/v0.4.0...HEAD
+[0.4.0]: https://github.com/bemoshiur/OmniRank/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/bemoshiur/OmniRank/compare/v0.2.1...v0.3.0
[0.2.1]: https://github.com/bemoshiur/OmniRank/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/bemoshiur/OmniRank/compare/v0.1.1...v0.2.0
diff --git a/CITATION.cff b/CITATION.cff
index 5ae5fc0..6538b65 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -22,5 +22,5 @@ keywords:
- structured-data
- llms-txt
license: MIT
-version: 0.3.0
+version: 0.4.0
date-released: "2026-08-04"
diff --git a/README.md b/README.md
index 75b2e8f..4226cf2 100644
--- a/README.md
+++ b/README.md
@@ -110,18 +110,27 @@ Exit codes: `0` clean · `1` a `--fail-on` gate failed · `2` usage or config er
non-zero exit is what makes it a CI gate, not just a report — see
[Use OmniRank as a CI gate](#use-omnirank-as-a-ci-gate) below.
-## What ships in v0.3.0
+## What ships in v0.4.0
| Skill | Status | What it does |
|---|---|---|
-| `audit` | **Shipped** | Scores SEO, AEO, GEO, perf and structured-data gates against a site's real HTML — including a cross-URL pass over the whole crawled set — and reports `observed` / `expected` / `fix` for every gap |
+| `audit` | **Shipped** | Scores SEO, AEO, GEO, perf, security and structured-data gates against a site's real HTML — including a cross-URL pass over the whole crawled set — and reports `observed` / `expected` / `fix` for every gap |
| `geo-artifacts` | **Shipped** | Generates `llms.txt`, `llms-full.txt` and `facts.json`, each with an explicit citation licence |
-| `fix` *(preview)* | **Shipped** | Resolves a finding's URL to the source file that owns it and prints the unified diff it would apply for the four `mechanical` findings. Writes nothing — there is no `--write` flag in 0.3.0 |
-
-Still exactly two skills. 0.3.0 added the locator — URL to source file — plus a
-`fixTier` on every finding and the `omnirank fix` diff preview. **This release writes
-nothing to your project.** File modification arrives in v0.4.0, deliberately after the
-locator has been proven. Everything under Roadmap below is not present in v0.3.0.
+| `fix` *(preview)* | **Shipped** | Resolves a finding's URL to the source file that owns it and prints the unified diff it would apply for the four `mechanical` findings. Writes nothing — there is no `--write` flag |
+
+Still exactly two skills. v0.4.0 adds 18 new finding ids across four groups: a new
+`security` layer (HSTS, `nosniff`, CSP absence, `Referrer-Policy`, mixed content, and a
+missing HTTP→HTTPS redirect); indexability contradictions provable from a site's own
+declarations (a sitemap URL its own `robots.txt` disallows, a canonical pointing at a
+noindexed/redirecting/missing page, a `hreflang` alternate that is itself noindexed);
+Google's required rich-result properties for eight structured-data types; and on-page
+accessibility overlap (missing `alt`, skipped heading levels, empty or generic link
+text, missing `lang`). It also normalises the score model so a layer's budget scales
+with its own gate count instead of a flat constant — **scores from 0.3.0 and 0.4.0 are
+not comparable.** `omnirank fix` still writes nothing to your project; file
+modification ships once the locator is proven against real repositories and the write
+guarantees in `docs/research/2026-08-04-automation-architecture.md` §2.5 are
+implemented and tested. Everything under Roadmap below is not present in v0.4.0.
## Roadmap
@@ -144,13 +153,15 @@ Google" will not trigger anything, because `aeo-onpage` and `indexing` are not b
## Every gate OmniRank checks, grouped by layer
-28 gate names exist in the config schema; 15 can fail a build and 13 are warning-only by
-design. (As of v0.2.1, `crawl-hygiene` — a gate name that could never actually fire,
-since its only source was never called from `audit_site()` — was removed from the schema
-rather than shipped as a config option that silently did nothing; `hygiene.check_sitemap()`
-was wired into `sitemap-health` instead, so a redirecting or dead sitemap URL now gets its
-own dedicated finding.) Full detail, including which gates can never trip `--fail-on`, is in
-[audit-guide.md](docs/audit-guide.md#gate-reference) and
+42 gate names exist in the config schema; 21 can fail a build (error-capable) and 21
+cannot — 16 are warning-only, 4 (the security header gates) are info-only, and one mixes
+warning and info. (As of v0.2.1, `crawl-hygiene` — a gate name that could never actually
+fire, since its only source was never called from `audit_site()` — was removed from the
+schema rather than shipped as a config option that silently did nothing;
+`hygiene.check_sitemap()` was wired into `sitemap-health` instead, so a redirecting or
+dead sitemap URL now gets its own dedicated finding.) Full detail, including which gates
+can never trip `--fail-on`, is in [audit-guide.md](docs/audit-guide.md#gate-reference)
+and
[ci-integration.md](docs/ci-integration.md#choosing---fail-on-gates--and-why-gate-on-everything-is-a-trap).
**SEO**
@@ -189,6 +200,19 @@ own dedicated finding.) Full detail, including which gates can never trip `--fai
|---|---|---|
| `schema` | At least one valid `application/ld+json` block with `@type` | error |
| `schema-fabrication` | `AggregateRating` has a real `ratingCount`; every `Review` has an `author` | error |
+| `schema-required` | Google's rich-result properties (headline/image/datePublished, offers, mainEntity, …) are present for the types that need them | warning |
+
+**Security** — new in v0.4.0; four header gates are info-only inventory, never graded
+
+| Gate | Rule | Severity |
+|---|---|---|
+| `hsts` | `Strict-Transport-Security` present, `max-age` ≥ 180 days | info |
+| `nosniff` | `X-Content-Type-Options: nosniff` present | info |
+| `csp` | A `Content-Security-Policy` exists, by header or meta | info |
+| `referrer-policy` | A `Referrer-Policy` header exists | info |
+| `mixed-content` | An `https://` page requests a blockable subresource (`script`, `iframe`, stylesheet/preload) over `http://` | error |
+| `mixed-content` | An `https://` page requests a passive subresource (`img`, favicon) over `http://` — browsers auto-upgrade these | warning |
+| `https-redirect` | The site's `http://` origin 3xx-redirects to `https://` | error |
**Site-level (cross-URL)** — new in 0.2.0; needs the whole crawled set, not one page
@@ -200,6 +224,15 @@ own dedicated finding.) Full detail, including which gates can never trip `--fai
| `canonical-cluster` | A canonical points at a page that itself canonicalises elsewhere | warning |
| `hreflang-reciprocity` | An `hreflang` alternate does not link back | warning |
+**Indexability contradictions** — new in v0.4.0; 100% precision, both halves of every
+contradiction come from the site's own declarations
+
+| Gate | Rule | Severity |
+|---|---|---|
+| `robots-sitemap` | A sitemap URL is `Disallow`-ed by the site's own `robots.txt` | error |
+| `canonical-target` | A canonical points at a page that is noindexed, 404s/410s, or redirects | error / warning |
+| `hreflang-noindex` | An `hreflang` alternate points at a page that is noindexed | error |
+
**Performance** — new in 0.2.0; derived from one HTTP response, no browser involved
| Gate | Rule | Severity |
@@ -215,6 +248,15 @@ Next Paint, or a Lighthouse score — OmniRank has no browser. See
constants and [faq.md](docs/faq.md#does-omnirank-measure-core-web-vitals) for the full
answer.
+**On-page** — new in v0.4.0; body markup, not the `
` indexing signals above
+
+| Gate | Rule | Severity |
+|---|---|---|
+| `image-alt` | Every `` has an `alt` attribute (an empty `alt=""` is never flagged) | warning |
+| `heading-order` | The heading outline never skips more than one level | warning |
+| `link-text` | Every link has an accessible name, and it isn't a generic phrase like "click here" | warning / info |
+| `lang` | `` declares a non-empty `lang` | error |
+
## Download and install OmniRank
**Option 1 — packaged skill for Claude Code, no clone required:**
@@ -282,7 +324,8 @@ python3 -m omnirank.cli fix --config omnirank.config.json --root .
```
Picking `--fail-on h1 canonical schema` (structural baseline) is a better starting point
-than listing all 28 gate names — 13 of them are warning-only and can never fail a build.
+than listing all 42 gate names — 21 of them are warning- or info-only and can never fail
+a build.
The full reasoning, plus a GitLab CI job and a generic shell script, is in
[ci-integration.md](docs/ci-integration.md).
@@ -403,14 +446,18 @@ will eventually read from `secrets`.
Why does my site score 0 on one layer?
-As of v0.2.1, each GATE's contribution to its layer is capped at `GATE_CAP = 15`
+Each GATE's contribution to its layer is capped at `GATE_CAP = 15`
(`min(15, 10*errors + 3*warnings)`), so one gate failing on every page of a large site
can no longer alone drag a layer to 0 — that would conflate issue COUNT with issue
-SEVERITY (one broken template is one problem, not fifty). A layer still reaches 0 when
-enough DISTINCT gates are broken for their capped costs to sum past 100 — seven or more
-independently-broken gates is enough on its own. A brand-new site missing a ``,
-canonical tag, JSON-LD, and `llms.txt` will commonly hit this on the GEO layer, since
-each missing artifact is a separate broken gate.
+SEVERITY (one broken template is one problem, not fifty). As of v0.4.0, the summed capped
+cost is then divided by the layer's own scoring SURFACE — how many distinct gates could
+move that layer's score — so a layer reaches 0 only when **every one of its registered
+gates** is maxed, not after a fixed number of them. `seo` ships 24 scoring gates, and
+measured directly: 7 maxed gates score `71`, 12 score `50`, 16 score `33`, and only all
+24 reach `0`. A small layer still floors easily — `security` ships only 2 scoring gates
+(`mixed-content`, `https-redirect`; its four header gates are `info` and excluded from
+the surface entirely) — but a big layer like `seo` no longer floors on a handful of
+broken gates the way it used to.
More questions, including secrets handling and where the JSON report schema lives, are
diff --git a/docs/audit-guide.md b/docs/audit-guide.md
index 0b8fa8a..be9db9e 100644
--- a/docs/audit-guide.md
+++ b/docs/audit-guide.md
@@ -1,10 +1,11 @@
# Audit guide
-`audit` scores a site across four content layers — SEO, AEO, GEO, and perf — plus a
-site-level cross-URL pass and one sitemap-hygiene check, and produces a prioritised,
-actionable fix list. It never edits the site; it only diagnoses. This page documents
-the CLI flags, every gate, the exact scoring formula, and a worked example, all verified
-against the source in `scripts/py/omnirank/` and `schemas/`.
+`audit` scores a site across five content layers — SEO, AEO, GEO, perf and security —
+plus a site-level cross-URL pass, an indexability-contradictions pass, and one
+sitemap-hygiene check, and produces a prioritised, actionable fix list. It never edits
+the site; it only diagnoses. This page documents the CLI flags, every gate, the exact
+scoring formula, and a worked example, all verified against the source in
+`scripts/py/omnirank/` and `schemas/`.
As of 0.2.0, `audit_site()` keeps every fetched page's HTML alive as a `PageData` record
instead of discarding it after the per-page gates run. That is what makes the site-level
@@ -78,21 +79,26 @@ per-finding `id` (`seo.h1.missing`, `seo.canonical.relative`, ...). `has_failure
matches on `finding.gate`, never on `finding.id`. The distinction matters because several
gates emit multiple different `id`s under one `gate` name — see the tables below.
-## The four layers, plus one cross-URL pass
+## The five layers, plus two cross-URL passes
| Layer | Audience | What it wants |
|---|---|---|
-| SEO | Googlebot, Bingbot | Crawlable, canonical, correctly sized metadata, valid structured data |
+| SEO | Googlebot, Bingbot | Crawlable, canonical, correctly sized metadata, valid structured data, and no self-contradictions between what the site submits and what it forbids |
| AEO | AI Overviews, Copilot, voice assistants | A short, liftable, factual answer near the top of the page, sized for the page's script (see [configuration.md#aeoanswerblock](configuration.md#aeoanswerblock)) |
| GEO | ChatGPT, Claude, Perplexity, Gemini | Machine-ingestible ground truth (`llms.txt`, `facts.json`) plus explicit permission to cite |
| perf | Every crawler and user agent | A fast time-to-first-byte, reasonable HTML weight, compression, and no excess render-blocking `` scripts — derived from one HTTP response, no browser involved |
-
-Crawl hygiene and the site-level cross-URL pass
-([below](#site-level-cross-url-gates--seo-layer)) both carry `layer: "seo"` in the report — there is no separate `"hygiene"` or `"site"`
-value in the `Layer` type (`report.py` defines `Layer = Literal["seo", "aeo", "geo",
-"offsite", "smm", "perf"]`). As of 0.2.0 `perf` is a real, populated layer — every
-audited page runs `perf.run(page)`. `offsite` and `smm` remain reserved for roadmap
-skills and are unused by any gate today.
+| security | Browsers rendering the page | No mixed content and an http→https redirect (the two that break crawling/rendering); response headers reported as inventory, never graded (v0.4.0) |
+
+Crawl hygiene, the site-level cross-URL pass
+([below](#site-level-cross-url-gates--seo-layer)) and the indexability-contradictions
+pass ([below](#indexability-contradictions-v040)) all carry `layer: "seo"` in the
+report — there is no separate `"hygiene"`, `"site"` or `"contradictions"` value in the
+`Layer` type (`report.py` defines `Layer = Literal["seo", "aeo", "geo", "offsite", "smm",
+"perf", "security"]`). `perf` has been a real, populated layer since 0.2.0 — every
+audited page runs `perf.run(page)`. `security` is new in v0.4.0 and is populated the
+same way (`security.run_page(page)` per page, plus one site-level `http://` redirect
+probe). `offsite` and `smm` remain reserved for roadmap skills and are unused by any gate
+today.
## Gate reference
@@ -189,6 +195,16 @@ block trips this gate.
| `schema` | Every top-level node has `@context` | warning |
| `schema-fabrication` | Every `AggregateRating` node has a non-zero `ratingCount` (or `reviewCount`) | error |
| `schema-fabrication` | Every `Review` node has an `author` | error |
+| `schema-required` | Google's documented rich-result properties are present for Article/NewsArticle/BlogPosting, Product, FAQPage, BreadcrumbList, Organization and LocalBusiness (v0.4.0) | warning |
+
+**`schema-required` (v0.4.0) checks GOOGLE's requirement, not schema.org's.** schema.org
+marks no property required at all, so a node can be perfectly valid schema.org and still
+miss a Google rich result — every `seo.schema-required.missing-property` finding says so
+explicitly, and names both `RICH_RESULT_RULES` and the date the table was transcribed
+(`RICH_RESULT_RULES_AS_OF` in `gates/jsonld.py`). It is `warning`, not `error`, because
+the table is a hand-transcribed snapshot with no freshness test yet, and a stale
+required-property table would produce fabricated errors at `error` severity. One finding
+per node, listing every missing property.
"Top-level node" means each `
A Good Title
%s
@@ -34,13 +35,24 @@ def mock_site(page_status=200):
# by their declared content-encoding and errors on a mismatched body.
respx.get(f"{SITE}/").mock(return_value=httpx.Response(
page_status, content=gzip.compress(PAGE.encode()),
- headers={"content-encoding": "gzip"}))
+ headers={"content-encoding": "gzip",
+ # v0.4.0: the clean fixture must be clean on the security layer
+ # too, or every assertion of `report.findings == []` breaks on
+ # four info findings.
+ "strict-transport-security": "max-age=31536000",
+ "x-content-type-options": "nosniff",
+ "content-security-policy": "default-src 'self'",
+ "referrer-policy": "strict-origin-when-cross-origin"}))
respx.get(f"{SITE}/llms.txt").mock(
return_value=httpx.Response(200, text="# X\n## How to cite us\nCC BY 4.0."))
respx.get(f"{SITE}/llms-full.txt").mock(return_value=httpx.Response(200, text="full"))
respx.get(f"{SITE}/facts.json").mock(return_value=httpx.Response(200, text='{"a":1}'))
respx.get(f"{SITE}/robots.txt").mock(
return_value=httpx.Response(200, text="User-agent: *\nAllow: /\n"))
+ # v0.4.0: audit_site probes the http:// form once per audit. Unmocked, respx
+ # raises rather than returning, so every @respx.mock audit test needs this.
+ respx.get("http://x.example/").mock(
+ return_value=httpx.Response(301, headers={"location": f"{SITE}/"}))
def test_default_config_needs_only_a_url():
@@ -98,6 +110,8 @@ def test_falls_back_to_root_when_sitemap_absent():
respx.get(f"{SITE}/llms-full.txt").mock(return_value=httpx.Response(404))
respx.get(f"{SITE}/facts.json").mock(return_value=httpx.Response(404))
respx.get(f"{SITE}/robots.txt").mock(return_value=httpx.Response(200, text="Allow: /"))
+ respx.get("http://x.example/").mock(
+ return_value=httpx.Response(301, headers={"location": f"{SITE}/"}))
report = audit_site(default_config(SITE), make_client())
assert report.urls_checked == 1
@@ -112,6 +126,8 @@ def test_missing_sitemap_reports_a_finding_not_a_silent_fallback():
respx.get(f"{SITE}/llms-full.txt").mock(return_value=httpx.Response(404))
respx.get(f"{SITE}/facts.json").mock(return_value=httpx.Response(404))
respx.get(f"{SITE}/robots.txt").mock(return_value=httpx.Response(200, text="Allow: /"))
+ respx.get("http://x.example/").mock(
+ return_value=httpx.Response(301, headers={"location": f"{SITE}/"}))
report = audit_site(default_config(SITE), make_client())
missing = [f for f in report.findings if f.id == "seo.sitemap.missing"]
@@ -140,7 +156,7 @@ def test_unreachable_page_flags_its_per_page_gates_as_not_evaluated():
report = audit_site(default_config(SITE), make_client())
unreachable_url = f"{SITE}/"
gates = {e.gate for e in report.not_evaluated if e.url == unreachable_url}
- assert gates == {"seo", "aeo", "perf"}
+ assert gates == {"seo", "aeo", "perf", "security", "onpage"}
assert all(e.reason == "page-unreachable"
for e in report.not_evaluated if e.url == unreachable_url)
@@ -183,6 +199,8 @@ def test_a_healthy_sitemap_url_is_not_refetched_by_check_sitemap():
for art in ("llms.txt", "llms-full.txt", "facts.json"):
respx.get(f"{SITE}/{art}").mock(return_value=httpx.Response(404))
respx.get(f"{SITE}/robots.txt").mock(return_value=httpx.Response(200, text="Allow: /"))
+ respx.get("http://x.example/").mock(
+ return_value=httpx.Response(301, headers={"location": f"{SITE}/"}))
audit_site(default_config(SITE), make_client())
assert route.call_count == 1, (
@@ -223,6 +241,8 @@ def test_findings_are_ordered_by_target_position():
respx.get(f"{SITE}/{art}").mock(return_value=httpx.Response(404))
respx.get(f"{SITE}/robots.txt").mock(return_value=httpx.Response(200, text="Allow: /"))
respx.get(f"{SITE}/sitemap.xml").mock(return_value=httpx.Response(404))
+ respx.get("http://x.example/").mock(
+ return_value=httpx.Response(301, headers={"location": f"{SITE}/"}))
report = audit_site(default_config(SITE), make_client(),
urls=[f"{SITE}/a", f"{SITE}/b", f"{SITE}/c"])
@@ -251,6 +271,8 @@ def test_layers_that_never_ran_are_absent_when_every_target_404s():
respx.get(f"{SITE}/llms-full.txt").mock(return_value=httpx.Response(404))
respx.get(f"{SITE}/facts.json").mock(return_value=httpx.Response(404))
respx.get(f"{SITE}/robots.txt").mock(return_value=httpx.Response(404))
+ respx.get("http://x.example/").mock(
+ return_value=httpx.Response(301, headers={"location": f"{SITE}/"}))
report = audit_site(default_config(SITE), make_client())
score = report.score()
@@ -259,11 +281,46 @@ def test_layers_that_never_ran_are_absent_when_every_target_404s():
"zero pages were parsed; aeo must not be scored a silent 100")
assert "perf" not in score, (
"zero pages were parsed; perf must not be scored a silent 100")
+ # S5: security is a per-page layer exactly like aeo/perf (security.run_page()
+ # only runs inside the pages loop) -- it must not score a silent 100 just
+ # because check_https_redirect()'s site-level probe happened to come back
+ # clean. The `http://x.example/` mock above redirects cleanly (301 to
+ # https), so before the fix this reproduced the real rust-lang.org report:
+ # `security: 100` with `urlsChecked: 1` and zero pages parsed.
+ assert "security" not in score, (
+ "zero pages were parsed; security must not be scored a silent 100 just "
+ "because the https-redirect probe alone came back clean")
assert "seo" in score, "seo.page.unreachable and the sitemap gates always run"
assert "geo" in score, "geo probes site-level artifacts regardless of page fetches"
assert any(f.id == "seo.page.unreachable" for f in report.findings)
+@respx.mock
+def test_a_real_https_redirect_finding_still_scores_security_with_zero_pages():
+ # The flip side of S5: layers_run gating a fabricated 100 must not also hide a
+ # REAL security problem. Report.score() admits any layer with an actual
+ # finding on its own merits regardless of layers_run, so a genuine
+ # https-redirect error still reaches the score map even though zero pages
+ # were parsed and mixed-content therefore never ran.
+ respx.get(f"{SITE}/sitemap.xml").mock(return_value=httpx.Response(404))
+ respx.get(f"{SITE}/").mock(return_value=httpx.Response(404))
+ respx.get(f"{SITE}/llms.txt").mock(return_value=httpx.Response(404))
+ respx.get(f"{SITE}/llms-full.txt").mock(return_value=httpx.Response(404))
+ respx.get(f"{SITE}/facts.json").mock(return_value=httpx.Response(404))
+ respx.get(f"{SITE}/robots.txt").mock(return_value=httpx.Response(404))
+ # http:// origin serves content directly instead of redirecting -- a real
+ # security.https-redirect.missing error.
+ respx.get("http://x.example/").mock(return_value=httpx.Response(200, text="hi"))
+
+ report = audit_site(default_config(SITE), make_client())
+ score = report.score()
+
+ assert any(f.id == "security.https-redirect.missing" for f in report.findings)
+ assert "security" in score, (
+ "a real finding must reach the score map even with zero pages parsed")
+ assert score["security"] < 100
+
+
@respx.mock
def test_site_pass_sees_duplicates_across_urls():
dup = ("Same"
@@ -281,6 +338,8 @@ def test_site_pass_sees_duplicates_across_urls():
respx.get(f"{SITE}/{art}").mock(return_value=httpx.Response(200, text=body))
respx.get(f"{SITE}/robots.txt").mock(
return_value=httpx.Response(200, text="User-agent: *\nAllow: /\n"))
+ respx.get("http://x.example/").mock(
+ return_value=httpx.Response(301, headers={"location": f"{SITE}/"}))
report = audit_site(default_config(SITE), make_client())
found = {f.id for f in report.findings}
@@ -346,6 +405,8 @@ def test_duplicate_sitemap_entries_are_fetched_once():
for art in ("llms.txt", "llms-full.txt", "facts.json"):
respx.get(f"{SITE}/{art}").mock(return_value=httpx.Response(404))
respx.get(f"{SITE}/robots.txt").mock(return_value=httpx.Response(200, text="Allow: /"))
+ respx.get("http://x.example/").mock(
+ return_value=httpx.Response(301, headers={"location": f"{SITE}/"}))
report = audit_site(default_config(SITE), make_client())
assert route.call_count == 1, "a URL listed twice must not be fetched twice"
@@ -368,6 +429,8 @@ def test_duplicate_sitemap_entries_do_not_inflate_duplicate_groups():
for art in ("llms.txt", "llms-full.txt", "facts.json"):
respx.get(f"{SITE}/{art}").mock(return_value=httpx.Response(404))
respx.get(f"{SITE}/robots.txt").mock(return_value=httpx.Response(200, text="Allow: /"))
+ respx.get("http://x.example/").mock(
+ return_value=httpx.Response(301, headers={"location": f"{SITE}/"}))
report = audit_site(default_config(SITE), make_client())
dup = [f for f in report.findings if f.id == "seo.duplicate-title.shared"]
diff --git a/tests/test_cli.py b/tests/test_cli.py
index b97a764..15c8282 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -343,16 +343,17 @@ def mock_fixable_site():
respx.get(f"{SITE}/").mock(return_value=httpx.Response(200, text=FIXABLE_PAGE))
-def test_fix_rejects_write_before_touching_the_network(tmp_path, capsys):
+def test_write_flag_explains_itself_without_promising_a_version(tmp_path, capsys):
# No respx.mock decorator on purpose: if this reached audit_site() the test
# would error on an unmocked request instead of passing.
assert main(["fix", SITE, "--root", str(tmp_path), "--write"]) == 2
- assert "v0.4.0" in capsys.readouterr().err
-
-
-def test_write_unavailable_message_names_the_release_and_the_reason():
- assert "v0.4.0" in WRITE_UNAVAILABLE
- assert "--write" in WRITE_UNAVAILABLE
+ err = capsys.readouterr().err
+ assert "writes nothing" in err
+ assert "v0.4.0" not in err, (
+ "v0.4.0 IS this release and it ships no --write; the string would be a "
+ "promise the running binary is already breaking")
+ assert "v0." not in WRITE_UNAVAILABLE, (
+ "name the condition writing ships under, never a version number")
@respx.mock
diff --git a/tests/test_config_schema.py b/tests/test_config_schema.py
index 077250d..a901641 100644
--- a/tests/test_config_schema.py
+++ b/tests/test_config_schema.py
@@ -1,7 +1,6 @@
import json
from pathlib import Path
-import pytest
from jsonschema import Draft202012Validator
ROOT = Path(__file__).resolve().parents[1]
@@ -136,3 +135,51 @@ def test_the_original_framework_values_still_validate():
cfg = minimal()
cfg["stack"] = {"framework": name}
assert list(validator().iter_errors(cfg)) == [], name
+
+
+SECURITY_GATES = ["hsts", "nosniff", "csp", "referrer-policy"]
+
+
+def test_security_gates_are_accepted_in_fail_on():
+ cfg = minimal()
+ cfg["audit"] = {"failOn": SECURITY_GATES}
+ assert list(validator().iter_errors(cfg)) == []
+
+
+def test_the_two_scoring_security_gates_are_accepted_in_fail_on():
+ cfg = minimal()
+ cfg["audit"] = {"failOn": ["mixed-content", "https-redirect"]}
+ assert list(validator().iter_errors(cfg)) == []
+
+
+def test_the_contradiction_gates_are_accepted_in_fail_on():
+ cfg = minimal()
+ cfg["audit"] = {"failOn": ["robots-sitemap"]}
+ assert list(validator().iter_errors(cfg)) == []
+
+
+def test_canonical_target_is_accepted_in_fail_on():
+ cfg = minimal()
+ cfg["audit"] = {"failOn": ["canonical-target"]}
+ assert list(validator().iter_errors(cfg)) == []
+
+
+def test_hreflang_noindex_is_accepted_in_fail_on():
+ cfg = minimal()
+ cfg["audit"] = {"failOn": ["hreflang-noindex"]}
+ assert list(validator().iter_errors(cfg)) == []
+
+
+def test_schema_required_is_accepted_in_fail_on():
+ cfg = minimal()
+ cfg["audit"] = {"failOn": ["schema-required"]}
+ assert list(validator().iter_errors(cfg)) == []
+
+
+ONPAGE_GATES = ["image-alt", "heading-order", "link-text", "lang"]
+
+
+def test_onpage_gates_are_accepted_in_fail_on():
+ cfg = minimal()
+ cfg["audit"] = {"failOn": ONPAGE_GATES}
+ assert list(validator().iter_errors(cfg)) == []
diff --git a/tests/test_docs.py b/tests/test_docs.py
index 46c9d23..3adf98a 100644
--- a/tests/test_docs.py
+++ b/tests/test_docs.py
@@ -1,9 +1,11 @@
+import re
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
DOCS = ROOT / "docs"
+WIKI = DOCS / "wiki"
MIN_LENGTH = 1500
@@ -48,12 +50,20 @@ def test_readme_index_links_every_page():
assert name in body, f"docs/README.md does not reference {name}"
+# One representative finding id per gate ADDED in v0.4.0 -- 14 new gates: 6 security
+# (gates/security.py), 3 contradictions (gates/contradictions.py), 1 schema-required
+# (gates/jsonld.py), 4 onpage (gates/onpage.py). Was a stale v0.3.0-era list (S1, final
+# review) that let test_audit_guide_documents_the_new_gates pass despite audit-guide.md
+# having zero v0.4.0 content.
NEW_GATE_IDS = [
- "seo.duplicate-title.shared", "seo.duplicate-description.shared",
- "seo.noindex.in-sitemap", "seo.canonical.chained",
- "seo.hreflang.not-reciprocal",
- "perf.response-time.slow", "perf.page-weight.heavy",
- "perf.compression.missing", "perf.render-blocking.head-scripts",
+ "security.hsts.missing", "security.nosniff.missing", "security.csp.absent",
+ "security.referrer-policy.missing", "security.mixed-content.subresource",
+ "security.https-redirect.missing",
+ "seo.robots-sitemap.disallowed", "seo.canonical-target.noindexed",
+ "seo.hreflang-noindex.alternate",
+ "seo.schema-required.missing-property",
+ "seo.image-alt.missing", "seo.heading-order.skipped", "seo.link-text.empty",
+ "seo.lang.missing",
]
@@ -99,9 +109,12 @@ def test_docs_no_longer_present_auto_fixable_as_a_current_field():
def test_fix_preview_doc_states_that_nothing_is_written():
body = (ROOT / "docs" / "fix-preview.md").read_text()
- assert "v0.4.0" in body
assert "--write" in body
assert "omnirank fix" in body
+ assert "writes nothing" in body
+ assert "v0.4.0" not in body, (
+ "0.4.0 broadens the audit and deliberately ships no writing; the doc must "
+ "not carry a promise the release is not keeping")
for tier in ("mechanical", "templated", "drafted", "advisory", "infrastructure"):
assert tier in body, tier
@@ -117,4 +130,68 @@ def test_gate_reference_documents_every_mechanical_fix():
def test_readme_documents_the_fix_subcommand():
body = (ROOT / "README.md").read_text()
assert "omnirank fix" in body
- assert "v0.3.0" in body
+ assert "v0.4.0" in body
+
+
+# S3: gate counts documented across primary docs were stale (28/15/13, a v0.2.0/v0.2.1
+# figure) even after v0.4.0 added 14 new gates. Computed here directly from the source
+# of truth -- the config schema's failOn enum and the finding registry -- rather than
+# hardcoded, so the count self-heals: whichever number is correct after the NEXT gate
+# lands is the number this test requires, not today's 42/21.
+GATE_COUNT_DOCS = [
+ ROOT / "README.md",
+ ROOT / "docs" / "ci-integration.md",
+ ROOT / "docs" / "troubleshooting.md",
+ WIKI / "Configuration-Reference.md",
+ WIKI / "CI-Recipes.md",
+ WIKI / "Audit-Skill.md",
+]
+
+
+@pytest.mark.parametrize("path", GATE_COUNT_DOCS, ids=lambda p: p.name)
+def test_documented_gate_counts_match_the_registry(path):
+ import json
+
+ from omnirank.registry import REGISTRY
+
+ schema = json.loads(
+ (ROOT / "schemas" / "omnirank.config.schema.json").read_text())
+ enum_gates = schema["properties"]["audit"]["properties"]["failOn"]["items"]["enum"]
+ total_gates = len(enum_gates)
+ error_capable = len({
+ entry.gate for entry in REGISTRY.values()
+ if entry.reachable and entry.severity == "error"
+ })
+
+ body = path.read_text()
+ assert str(total_gates) in body, (
+ f"{path.relative_to(ROOT)} does not mention the real failOn gate count "
+ f"({total_gates}) -- it has drifted, or the doc still names a stale one")
+ assert str(error_capable) in body, (
+ f"{path.relative_to(ROOT)} does not mention the real error-capable gate "
+ f"count ({error_capable}) -- it has drifted, or the doc still names a "
+ f"stale one")
+
+
+# B3: commit 32209f7 stopped `docs/` and `skills/` promising "--write arrives in
+# vX.Y.Z" -- a version-numbered promise that becomes a false statement the moment
+# that version ships without it, which is exactly what happened to v0.4.0's own
+# wiki copy. The replacement names the CONDITION writing ships under (the locator
+# proven, the write guarantees implemented and tested), never a version number.
+# This pattern-matches the phrasing that caused it, not just "v0.4.0" literally,
+# so a *future* version-numbered promise ("arrives in v0.5.0") trips it too.
+FUTURE_VERSION_PROMISE = re.compile(
+ r"(arrives in|targeted at|planned for|deferred to|wait(ing)? for|until) "
+ r"v\d+\.\d+(\.\d+)?",
+ re.IGNORECASE,
+)
+
+
+@pytest.mark.parametrize("path", sorted(WIKI.glob("*.md")), ids=lambda p: p.name)
+def test_no_wiki_page_promises_write_at_a_version_number(path):
+ body = path.read_text()
+ match = FUTURE_VERSION_PROMISE.search(body)
+ assert match is None, (
+ f"{path.relative_to(ROOT)} contains {match.group(0)!r} -- name the "
+ f"condition a feature ships under instead of a version number, which "
+ f"becomes a false statement the moment that version tags without it")
diff --git a/tests/test_fixes_canonical.py b/tests/test_fixes_canonical.py
index ede4948..9b82b8f 100644
--- a/tests/test_fixes_canonical.py
+++ b/tests/test_fixes_canonical.py
@@ -150,8 +150,8 @@ def test_canonical_missing_refuses_a_tsx_location(tmp_path):
location = write(tmp_path, "app/page.tsx", "export default function P() {}\n")
made = canonical.missing(finding(), location, tmp_path, 1, [])
assert not made.fixed
- assert "v0.4.0" in made.reason
assert "metadataBase" in made.reason
+ assert "v0." not in made.reason, "deferral text must not name a version"
def test_canonical_missing_refuses_a_markdown_location_with_markdown_specific_wording(tmp_path):
@@ -162,10 +162,10 @@ def test_canonical_missing_refuses_a_markdown_location_with_markdown_specific_wo
location = write(tmp_path, "pricing.md", "---\nlayout: page\n---\n# Pricing\n")
made = canonical.missing(finding(), location, tmp_path, 1, [])
assert not made.fixed
- assert "v0.4.0" in made.reason
assert "metadataBase" not in made.reason
assert "metadata.alternates.canonical" not in made.reason
assert "markdown" in made.reason.lower()
+ assert "v0." not in made.reason
def test_canonical_missing_refuses_a_file_with_no_head(tmp_path):
diff --git a/tests/test_fixes_schema.py b/tests/test_fixes_schema.py
index 04b0ddf..c1fdb55 100644
--- a/tests/test_fixes_schema.py
+++ b/tests/test_fixes_schema.py
@@ -214,8 +214,8 @@ def test_no_context_declines_on_a_tsx_location(tmp_path):
made = schema.no_context(
finding(), Location(path="page.tsx", confidence="exact"), tmp_path, 1, [])
assert not made.fixed
- assert "v0.4.0" in made.reason
assert "metadataBase" in made.reason
+ assert "v0." not in made.reason, "deferral text must not name a version"
def test_no_context_declines_on_a_markdown_location_with_markdown_specific_wording(tmp_path):
@@ -227,9 +227,9 @@ def test_no_context_declines_on_a_markdown_location_with_markdown_specific_wordi
made = schema.no_context(
finding(), Location(path="pricing.md", confidence="exact"), tmp_path, 1, [])
assert not made.fixed
- assert "v0.4.0" in made.reason
assert "metadataBase" not in made.reason
assert "markdown" in made.reason.lower()
+ assert "v0." not in made.reason
def test_no_context_preserves_every_other_byte(tmp_path):
diff --git a/tests/test_gates_contradictions.py b/tests/test_gates_contradictions.py
new file mode 100644
index 0000000..a85007e
--- /dev/null
+++ b/tests/test_gates_contradictions.py
@@ -0,0 +1,349 @@
+import httpx
+import respx
+
+from omnirank import robots
+from omnirank.fetch import make_client
+from omnirank.gates import contradictions
+
+SITE = "https://x.example"
+
+
+def robots_txt(body: str):
+ respx.get(f"{SITE}/robots.txt").mock(return_value=httpx.Response(200, text=body))
+
+
+@respx.mock
+def test_a_sitemap_url_disallowed_by_robots_is_an_error():
+ robots_txt("User-agent: *\nDisallow: /private/\n")
+ findings, not_evaluated = contradictions.check_sitemap_vs_robots(
+ make_client(), SITE, [f"{SITE}/open", f"{SITE}/private/secret"])
+ assert not_evaluated == []
+ assert [f.id for f in findings] == ["seo.robots-sitemap.disallowed"]
+ assert findings[0].severity == "error"
+ assert findings[0].layer == "seo"
+ assert findings[0].gate == "robots-sitemap"
+ assert findings[0].url == f"{SITE}/private/secret"
+ assert "Disallow" in findings[0].observed
+
+
+@respx.mock
+def test_one_finding_per_disallowed_url():
+ robots_txt("User-agent: *\nDisallow: /p/\n")
+ findings, _ = contradictions.check_sitemap_vs_robots(
+ make_client(), SITE, [f"{SITE}/p/a", f"{SITE}/p/b", f"{SITE}/ok"])
+ assert sorted(f.url for f in findings) == [f"{SITE}/p/a", f"{SITE}/p/b"]
+
+
+@respx.mock
+def test_a_fully_permissive_robots_emits_nothing():
+ robots_txt("User-agent: *\nDisallow:\n")
+ findings, not_evaluated = contradictions.check_sitemap_vs_robots(
+ make_client(), SITE, [f"{SITE}/a"])
+ assert findings == []
+ assert not_evaluated == []
+
+
+@respx.mock
+def test_an_unreachable_robots_is_not_evaluated_rather_than_passed():
+ respx.get(f"{SITE}/robots.txt").mock(return_value=httpx.Response(404))
+ findings, not_evaluated = contradictions.check_sitemap_vs_robots(
+ make_client(), SITE, [f"{SITE}/a"])
+ assert findings == [], "no robots.txt means no verdict, never a pass"
+ assert [(e.gate, e.reason, e.url) for e in not_evaluated] == [
+ ("robots-sitemap", "page-unreachable", f"{SITE}/robots.txt")]
+
+
+@respx.mock
+def test_no_sitemap_means_the_gate_is_not_evaluated():
+ findings, not_evaluated = contradictions.check_sitemap_vs_robots(
+ make_client(), SITE, None)
+ assert findings == []
+ assert [(e.gate, e.reason, e.site) for e in not_evaluated] == [
+ ("robots-sitemap", "no-sitemap", SITE)]
+ assert not respx.calls, "with no sitemap there is nothing to check robots against"
+
+
+@respx.mock
+def test_an_empty_sitemap_list_is_treated_the_same_as_no_sitemap():
+ findings, not_evaluated = contradictions.check_sitemap_vs_robots(
+ make_client(), SITE, [])
+ assert findings == []
+ assert [e.reason for e in not_evaluated] == ["no-sitemap"]
+
+
+@respx.mock
+def test_rules_the_interpreter_cannot_match_produce_not_evaluated_not_a_pass():
+ robots_txt("User-agent: *\nDisallow: /*.pdf$\n")
+ findings, not_evaluated = contradictions.check_sitemap_vs_robots(
+ make_client(), SITE, [f"{SITE}/a/b.pdf"])
+ if robots.matcher_supports_wildcards():
+ assert [f.url for f in findings] == [f"{SITE}/a/b.pdf"]
+ assert not_evaluated == []
+ else:
+ assert findings == []
+ assert [(e.gate, e.reason) for e in not_evaluated] == [
+ ("robots-sitemap", "matcher-unsupported")]
+
+
+@respx.mock
+def test_the_matcher_unsupported_finding_names_the_interpreter_limitation():
+ robots_txt("User-agent: *\nDisallow: /docs/\nAllow: /docs/public/\n")
+ _, not_evaluated = contradictions.check_sitemap_vs_robots(
+ make_client(), SITE, [f"{SITE}/docs/public/x"])
+ if not robots.matcher_supports_longest_match():
+ assert not_evaluated[0].reason == "matcher-unsupported"
+ else:
+ assert not_evaluated == []
+
+
+@respx.mock
+def test_a_cross_host_sitemap_url_is_not_judged_and_is_not_applicable():
+ # B1: a sitemap index legitimately listing a different host's URLs (blog.,
+ # cdn., ...) must never have those URLs judged against THIS host's robots.txt --
+ # RobotFileParser.can_fetch() discards the host and matches on path alone, so
+ # without this filter a cross-host URL that happens to share a disallowed path
+ # produces a fabricated finding.
+ robots_txt("User-agent: *\nDisallow: /p/\n")
+ findings, not_evaluated = contradictions.check_sitemap_vs_robots(
+ make_client(), SITE, [f"{SITE}/ok", "https://blog.example/p/x"])
+ assert findings == [], "a cross-host URL must never be judged by this host's robots.txt"
+ assert [(e.gate, e.reason, e.url) for e in not_evaluated] == [
+ ("robots-sitemap", "not-applicable", "https://blog.example/p/x")]
+
+
+@respx.mock
+def test_same_host_urls_still_evaluate_normally_alongside_cross_host_ones():
+ robots_txt("User-agent: *\nDisallow: /p/\n")
+ findings, not_evaluated = contradictions.check_sitemap_vs_robots(
+ make_client(), SITE,
+ [f"{SITE}/p/a", f"{SITE}/ok", "https://cdn.example/p/a"])
+ assert [f.url for f in findings] == [f"{SITE}/p/a"]
+ assert [(e.reason, e.url) for e in not_evaluated] == [
+ ("not-applicable", "https://cdn.example/p/a")]
+
+
+@respx.mock
+def test_a_sitemap_entirely_off_host_yields_zero_findings():
+ robots_txt("User-agent: *\nDisallow: /p/\n")
+ findings, not_evaluated = contradictions.check_sitemap_vs_robots(
+ make_client(), SITE,
+ ["https://blog.example/p/a", "https://shop.example/p/b"])
+ assert findings == [], "with nothing on this host to judge, nothing can be flagged"
+ assert len(not_evaluated) == 2
+ assert {e.reason for e in not_evaluated} == {"not-applicable"}
+ assert {e.url for e in not_evaluated} == {
+ "https://blog.example/p/a", "https://shop.example/p/b"}
+
+
+@respx.mock
+def test_the_finding_says_both_ways_to_resolve_the_contradiction():
+ robots_txt("User-agent: *\nDisallow: /p/\n")
+ findings, _ = contradictions.check_sitemap_vs_robots(
+ make_client(), SITE, [f"{SITE}/p/a"])
+ fix = findings[0].fix.lower()
+ assert "sitemap" in fix and "robots" in fix, (
+ "either edit resolves it, and only the owner knows which is correct")
+
+
+from omnirank.page import PageData
+
+HEAD = "{}
H
"
+
+
+def page(url: str, canonical: str | None = None, noindex: bool = False) -> PageData:
+ head = ""
+ if canonical is not None:
+ head += f""
+ if noindex:
+ head += ""
+ return PageData(url=url, html=HEAD.format(head), status=200, elapsed_ms=1,
+ headers={})
+
+
+@respx.mock
+def test_a_canonical_pointing_at_a_noindexed_page_in_the_crawled_set_is_an_error():
+ pages = [page(f"{SITE}/a", canonical=f"{SITE}/b"),
+ page(f"{SITE}/b", canonical=f"{SITE}/b", noindex=True)]
+ findings, not_evaluated = contradictions.check_canonical_targets(make_client(), pages)
+ assert not_evaluated == []
+ assert [(f.id, f.url) for f in findings] == [
+ ("seo.canonical-target.noindexed", f"{SITE}/a")]
+ assert findings[0].severity == "error"
+ assert findings[0].gate == "canonical-target"
+ assert not respx.calls, "a target already in the crawled set is never re-fetched"
+
+
+@respx.mock
+def test_a_self_canonical_on_a_noindexed_page_is_not_flagged():
+ # The page canonicalises to itself; noindex+self-canonical is a deliberate,
+ # coherent configuration and seo.noindex.in-sitemap owns the sitemap question.
+ pages = [page(f"{SITE}/a", canonical=f"{SITE}/a", noindex=True)]
+ findings, _ = contradictions.check_canonical_targets(make_client(), pages)
+ assert findings == []
+
+
+@respx.mock
+def test_a_canonical_target_that_404s_is_an_error():
+ respx.get(f"{SITE}/gone").mock(return_value=httpx.Response(404))
+ pages = [page(f"{SITE}/a", canonical=f"{SITE}/gone")]
+ findings, not_evaluated = contradictions.check_canonical_targets(make_client(), pages)
+ assert not_evaluated == []
+ assert [(f.id, f.url) for f in findings] == [
+ ("seo.canonical-target.not-found", f"{SITE}/a")]
+ assert findings[0].severity == "error"
+ assert f"{SITE}/gone" in findings[0].observed
+
+
+@respx.mock
+def test_a_canonical_target_that_redirects_is_a_warning_naming_the_destination():
+ respx.get(f"{SITE}/old").mock(return_value=httpx.Response(
+ 301, headers={"location": f"{SITE}/new"}))
+ pages = [page(f"{SITE}/a", canonical=f"{SITE}/old")]
+ findings, _ = contradictions.check_canonical_targets(make_client(), pages)
+ assert [f.id for f in findings] == ["seo.canonical-target.redirects"]
+ assert findings[0].severity == "warning"
+ assert f"{SITE}/new" in findings[0].fix
+
+
+@respx.mock
+def test_a_probed_target_that_is_noindexed_is_an_error():
+ respx.get(f"{SITE}/hidden").mock(return_value=httpx.Response(
+ 200, text=HEAD.format("")))
+ pages = [page(f"{SITE}/a", canonical=f"{SITE}/hidden")]
+ findings, _ = contradictions.check_canonical_targets(make_client(), pages)
+ assert [f.id for f in findings] == ["seo.canonical-target.noindexed"]
+
+
+@respx.mock
+def test_a_healthy_probed_target_emits_nothing():
+ respx.get(f"{SITE}/good").mock(return_value=httpx.Response(200, text=HEAD.format("")))
+ pages = [page(f"{SITE}/a", canonical=f"{SITE}/good")]
+ findings, not_evaluated = contradictions.check_canonical_targets(make_client(), pages)
+ assert findings == []
+ assert not_evaluated == []
+
+
+@respx.mock
+def test_a_server_error_on_a_probe_is_not_evaluated_rather_than_called_missing():
+ respx.get(f"{SITE}/flaky").mock(return_value=httpx.Response(503))
+ pages = [page(f"{SITE}/a", canonical=f"{SITE}/flaky")]
+ findings, not_evaluated = contradictions.check_canonical_targets(make_client(), pages)
+ assert findings == [], "a 503 is transient; calling it not-found would be a guess"
+ assert [(e.gate, e.reason, e.url) for e in not_evaluated] == [
+ ("canonical-target", "page-unreachable", f"{SITE}/flaky")]
+
+
+@respx.mock
+def test_each_distinct_target_is_probed_exactly_once():
+ respx.get(f"{SITE}/shared").mock(return_value=httpx.Response(404))
+ pages = [page(f"{SITE}/a", canonical=f"{SITE}/shared"),
+ page(f"{SITE}/b", canonical=f"{SITE}/shared"),
+ page(f"{SITE}/c", canonical=f"{SITE}/shared")]
+ findings, _ = contradictions.check_canonical_targets(make_client(), pages)
+ assert len(respx.calls) == 1, "one probe per distinct target, not per pointing page"
+ assert sorted(f.url for f in findings) == [f"{SITE}/a", f"{SITE}/b", f"{SITE}/c"], (
+ "every page pointing at the broken target is still told about it")
+
+
+@respx.mock
+def test_probes_stop_at_the_budget_and_the_rest_are_not_evaluated():
+ over = contradictions.MAX_CANONICAL_PROBES + 3
+ for i in range(over):
+ respx.get(f"{SITE}/t{i}").mock(return_value=httpx.Response(200, text=HEAD.format("")))
+ pages = [page(f"{SITE}/p{i}", canonical=f"{SITE}/t{i}") for i in range(over)]
+ findings, not_evaluated = contradictions.check_canonical_targets(make_client(), pages)
+ assert findings == []
+ assert len(respx.calls) == contradictions.MAX_CANONICAL_PROBES
+ assert len(not_evaluated) == 3
+ assert {e.reason for e in not_evaluated} == {"budget-exceeded"}
+ assert {e.gate for e in not_evaluated} == {"canonical-target"}
+
+
+@respx.mock
+def test_a_page_with_no_canonical_is_left_to_the_per_url_gate():
+ pages = [page(f"{SITE}/a")]
+ findings, not_evaluated = contradictions.check_canonical_targets(make_client(), pages)
+ assert (findings, not_evaluated) == ([], [])
+ assert not respx.calls
+
+
+@respx.mock
+def test_a_relative_canonical_is_resolved_before_being_judged():
+ respx.get(f"{SITE}/target").mock(return_value=httpx.Response(404))
+ pages = [page(f"{SITE}/deep/a", canonical="/target")]
+ findings, _ = contradictions.check_canonical_targets(make_client(), pages)
+ assert [f.id for f in findings] == ["seo.canonical-target.not-found"]
+
+
+@respx.mock
+def test_a_trailing_slash_difference_still_counts_as_the_crawled_page():
+ pages = [page(f"{SITE}/a", canonical=f"{SITE}/b/"),
+ page(f"{SITE}/b", canonical=f"{SITE}/b", noindex=True)]
+ findings, _ = contradictions.check_canonical_targets(make_client(), pages)
+ assert [f.id for f in findings] == ["seo.canonical-target.noindexed"]
+ assert not respx.calls, "canonical_key normalises the trailing slash"
+
+
+def alt_page(url: str, alternates: dict[str, str], noindex: bool = False) -> PageData:
+ head = "".join(f""
+ for lang, href in alternates.items())
+ if noindex:
+ head += ""
+ return PageData(url=url, html=HEAD.format(head), status=200, elapsed_ms=1,
+ headers={})
+
+
+def test_an_alternate_that_is_noindexed_is_an_error_on_the_declaring_page():
+ pages = [alt_page(f"{SITE}/en", {"en": f"{SITE}/en", "fr": f"{SITE}/fr"}),
+ alt_page(f"{SITE}/fr", {"en": f"{SITE}/en", "fr": f"{SITE}/fr"},
+ noindex=True)]
+ findings = contradictions.check_hreflang_noindex(pages)
+ assert [(f.id, f.url) for f in findings] == [
+ ("seo.hreflang-noindex.alternate", f"{SITE}/en")]
+ assert findings[0].severity == "error"
+ assert findings[0].gate == "hreflang-noindex"
+ assert f"{SITE}/fr" in findings[0].observed
+
+
+def test_an_indexable_alternate_set_emits_nothing():
+ pages = [alt_page(f"{SITE}/en", {"en": f"{SITE}/en", "fr": f"{SITE}/fr"}),
+ alt_page(f"{SITE}/fr", {"en": f"{SITE}/en", "fr": f"{SITE}/fr"})]
+ assert contradictions.check_hreflang_noindex(pages) == []
+
+
+def test_a_noindexed_page_declaring_indexable_alternates_is_not_flagged():
+ # The contradiction belongs to whoever DECLARES a forbidden target, not to the
+ # page that opted itself out.
+ pages = [alt_page(f"{SITE}/en", {"fr": f"{SITE}/fr"}),
+ alt_page(f"{SITE}/fr", {"en": f"{SITE}/en"}, noindex=True)]
+ findings = contradictions.check_hreflang_noindex(pages)
+ assert [f.url for f in findings] == [f"{SITE}/en"]
+
+
+def test_an_alternate_outside_the_crawled_set_is_never_judged():
+ pages = [alt_page(f"{SITE}/en", {"de": "https://de.example/"})]
+ assert contradictions.check_hreflang_noindex(pages) == [], (
+ "OmniRank never guesses about a URL it did not fetch")
+
+
+def test_a_self_referencing_alternate_is_not_a_contradiction():
+ pages = [alt_page(f"{SITE}/en", {"en": f"{SITE}/en"}, noindex=True)]
+ assert contradictions.check_hreflang_noindex(pages) == []
+
+
+def test_x_default_is_exempt():
+ # x-default is a fallback pointer, not a language pair; site.alternates already
+ # excludes it, and this pins that so a change there turns this red.
+ pages = [alt_page(f"{SITE}/en", {"x-default": f"{SITE}/fr"}),
+ alt_page(f"{SITE}/fr", {}, noindex=True)]
+ assert contradictions.check_hreflang_noindex(pages) == []
+
+
+def test_one_finding_per_declaring_page_and_bad_target_pair():
+ pages = [alt_page(f"{SITE}/en", {"fr": f"{SITE}/fr", "de": f"{SITE}/de"}),
+ alt_page(f"{SITE}/fr", {}, noindex=True),
+ alt_page(f"{SITE}/de", {}, noindex=True)]
+ findings = contradictions.check_hreflang_noindex(pages)
+ assert len(findings) == 2
+ assert {f.url for f in findings} == {f"{SITE}/en"}
+ assert sorted(f.observed for f in findings) != [], "both targets are named"
diff --git a/tests/test_gates_jsonld.py b/tests/test_gates_jsonld.py
index 1378913..052cfc1 100644
--- a/tests/test_gates_jsonld.py
+++ b/tests/test_gates_jsonld.py
@@ -170,3 +170,166 @@ def test_malformed_block_does_not_hide_other_blocks():
found = ids(jsonld.run(html, URL))
assert "seo.schema.malformed" in found
assert "seo.schema-fabrication.anonymous-review" in found
+
+
+from omnirank.gates.jsonld import RICH_RESULT_RULES, RICH_RESULT_RULES_AS_OF
+
+
+def ld(payload: str) -> str:
+ return (f''
+ "")
+
+
+def required_findings(html: str) -> list:
+ return [f for f in jsonld.run(html, "https://x.example/")
+ if f.id == "seo.schema-required.missing-property"]
+
+
+def test_an_article_without_date_published_is_flagged():
+ html = ld('{"@context":"https://schema.org","@type":"Article",'
+ '"headline":"H","image":"https://x.example/i.png"}')
+ found = required_findings(html)
+ assert len(found) == 1
+ assert found[0].severity == "warning"
+ assert found[0].layer == "seo"
+ assert found[0].gate == "schema-required"
+ assert "Article" in found[0].observed
+ assert "datePublished" in found[0].observed
+
+
+def test_a_complete_article_is_not_flagged():
+ html = ld('{"@context":"https://schema.org","@type":"Article","headline":"H",'
+ '"image":"https://x.example/i.png","datePublished":"2026-01-01"}')
+ assert required_findings(html) == []
+
+
+def test_blogposting_and_newsarticle_share_the_article_rules():
+ for type_name in ("BlogPosting", "NewsArticle"):
+ html = ld(f'{{"@context":"https://schema.org","@type":"{type_name}",'
+ '"headline":"H"}')
+ found = required_findings(html)
+ assert len(found) == 1, type_name
+ assert "image" in found[0].observed and "datePublished" in found[0].observed
+
+
+def test_one_finding_per_node_listing_every_missing_property():
+ html = ld('{"@context":"https://schema.org","@type":"Article"}')
+ found = required_findings(html)
+ assert len(found) == 1, "one finding per node, not one per property"
+ for prop in ("headline", "image", "datePublished"):
+ assert prop in found[0].observed, prop
+
+
+def test_product_accepts_any_one_of_offers_rating_or_review():
+ for alternative in ("offers", "aggregateRating", "review"):
+ html = ld('{"@context":"https://schema.org","@type":"Product","name":"P",'
+ f'"{alternative}":{{"@type":"Thing"}}}}')
+ assert required_findings(html) == [], alternative
+
+
+def test_a_product_with_none_of_the_three_alternatives_is_flagged():
+ html = ld('{"@context":"https://schema.org","@type":"Product","name":"P"}')
+ found = required_findings(html)
+ assert len(found) == 1
+ for alternative in ("offers", "aggregateRating", "review"):
+ assert alternative in found[0].observed, alternative
+
+
+def test_a_faqpage_whose_questions_have_no_accepted_answer_is_flagged():
+ html = ld('{"@context":"https://schema.org","@type":"FAQPage","mainEntity":'
+ '[{"@type":"Question","name":"Q?"}]}')
+ found = required_findings(html)
+ assert len(found) == 1
+ assert "acceptedAnswer" in found[0].observed
+
+
+def test_a_faqpage_with_an_accepted_answer_passes():
+ html = ld('{"@context":"https://schema.org","@type":"FAQPage","mainEntity":'
+ '[{"@type":"Question","name":"Q?","acceptedAnswer":'
+ '{"@type":"Answer","text":"A"}}]}')
+ assert required_findings(html) == []
+
+
+def test_a_breadcrumb_item_missing_position_is_flagged():
+ html = ld('{"@context":"https://schema.org","@type":"BreadcrumbList",'
+ '"itemListElement":[{"@type":"ListItem","name":"Home"}]}')
+ found = required_findings(html)
+ assert len(found) == 1
+ assert "position" in found[0].observed
+
+
+def test_a_breadcrumb_name_nested_under_item_is_accepted():
+ # Google documents BOTH shapes. Flagging the nested one would be a fabricated
+ # error on entirely valid markup.
+ html = ld('{"@context":"https://schema.org","@type":"BreadcrumbList",'
+ '"itemListElement":[{"@type":"ListItem","position":1,'
+ '"item":{"@id":"https://x.example/","name":"Home"}}]}')
+ assert required_findings(html) == []
+
+
+def test_organization_needs_name_and_url():
+ html = ld('{"@context":"https://schema.org","@type":"Organization","name":"X"}')
+ found = required_findings(html)
+ assert len(found) == 1 and "url" in found[0].observed
+
+
+def test_localbusiness_needs_name_and_address():
+ html = ld('{"@context":"https://schema.org","@type":"LocalBusiness","name":"X"}')
+ found = required_findings(html)
+ assert len(found) == 1 and "address" in found[0].observed
+
+
+def test_an_untyped_or_unknown_type_is_left_alone():
+ html = ld('{"@context":"https://schema.org","@type":"WebPage","name":"X"}')
+ assert required_findings(html) == [], (
+ "OmniRank has no rich-result rule for WebPage and must not invent one")
+
+
+def test_a_pure_id_reference_stub_is_not_flagged():
+ # A node that is only @type + @id is a REFERENCE to an entity declared
+ # elsewhere, not a declaration missing its properties.
+ html = ld('{"@context":"https://schema.org","@graph":['
+ '{"@type":"Organization","@id":"https://x.example/#org"},'
+ '{"@type":"WebPage","@id":"https://x.example/#page"}]}')
+ assert required_findings(html) == []
+
+
+def test_a_whitespace_only_value_does_not_count_as_present():
+ html = ld('{"@context":"https://schema.org","@type":"Organization",'
+ '"name":" ","url":"https://x.example/"}')
+ found = required_findings(html)
+ assert len(found) == 1 and "name" in found[0].observed
+
+
+def test_an_empty_list_value_does_not_count_as_present():
+ html = ld('{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[]}')
+ found = required_findings(html)
+ assert len(found) == 1 and "mainEntity" in found[0].observed
+
+
+def test_a_multi_typed_node_is_checked_against_every_rule_it_declares():
+ html = ld('{"@context":"https://schema.org",'
+ '"@type":["Organization","LocalBusiness"],"name":"X"}')
+ found = required_findings(html)
+ assert len(found) == 1
+ assert "url" in found[0].observed and "address" in found[0].observed
+
+
+def test_the_finding_distinguishes_schema_org_validity_from_google_eligibility():
+ html = ld('{"@context":"https://schema.org","@type":"Article"}')
+ fix = required_findings(html)[0].fix
+ assert "schema.org" in fix, "the text must name whose requirement is unmet"
+ assert "Google" in fix
+ assert RICH_RESULT_RULES_AS_OF in fix, (
+ "the table's transcription date must be inspectable from the finding")
+ assert "RICH_RESULT_RULES" in fix
+
+
+def test_every_documented_type_has_at_least_one_requirement_group():
+ assert set(RICH_RESULT_RULES) >= {
+ "Article", "BlogPosting", "NewsArticle", "Product", "FAQPage",
+ "BreadcrumbList", "Organization", "LocalBusiness"}
+ for type_name, groups in RICH_RESULT_RULES.items():
+ assert groups, type_name
+ for group in groups:
+ assert group and all(isinstance(p, str) for p in group), type_name
diff --git a/tests/test_gates_onpage.py b/tests/test_gates_onpage.py
new file mode 100644
index 0000000..f371cb5
--- /dev/null
+++ b/tests/test_gates_onpage.py
@@ -0,0 +1,151 @@
+from omnirank.gates import onpage
+
+URL = "https://x.example/"
+
+
+def run(body: str, html_attrs: str = " lang='en'") -> list:
+ return onpage.run(
+ f"T"
+ f"{body}", URL)
+
+
+def ids(findings) -> list[str]:
+ return [f.id for f in findings]
+
+
+# --- images ---------------------------------------------------------------
+
+def test_an_img_with_no_alt_attribute_is_a_warning():
+ found = [f for f in run("") if f.id == "seo.image-alt.missing"]
+ assert len(found) == 1
+ assert found[0].severity == "warning"
+ assert found[0].layer == "seo"
+ assert found[0].gate == "image-alt"
+ assert "a.png" in found[0].observed
+
+
+def test_an_empty_alt_is_a_legitimate_decorative_marker_and_is_never_flagged():
+ assert "seo.image-alt.missing" not in ids(run("")), (
+ 'alt="" is how the spec says an image is decorative; flagging it would '
+ "tell users to make their markup worse")
+
+
+def test_explicit_decorative_markers_suppress_the_finding():
+ for marker in ("role='presentation'", "role='none'", "aria-hidden='true'"):
+ assert "seo.image-alt.missing" not in ids(run(f"")), marker
+
+
+def test_one_finding_per_page_naming_the_first_few_sources():
+ found = [f for f in run("")
+ if f.id == "seo.image-alt.missing"]
+ assert len(found) == 1
+ assert "3" in found[0].observed
+ assert "a.png" in found[0].observed
+
+
+# --- heading order --------------------------------------------------------
+
+def test_h1_to_h3_with_no_h2_is_a_warning():
+ found = [f for f in run("
A
B
") if f.id == "seo.heading-order.skipped"]
+ assert len(found) == 1
+ assert found[0].severity == "warning"
+ assert found[0].gate == "heading-order"
+ assert "h1" in found[0].observed and "h3" in found[0].observed
+
+
+def test_a_contiguous_outline_passes():
+ assert "seo.heading-order.skipped" not in ids(run("
A
B
C
"))
+
+
+def test_descending_more_than_one_level_is_not_a_skip():
+ # h3 -> h1 closes two sections; only an INCREASE of more than one skips a level.
+ assert "seo.heading-order.skipped" not in ids(run("
A
B
C
D
"))
+
+
+def test_only_the_first_skip_is_reported():
+ found = [f for f in run("
A
B
C
D
")
+ if f.id == "seo.heading-order.skipped"]
+ assert len(found) == 1, "one finding per page; the outline is fixed once"
+
+
+def test_a_page_with_no_headings_emits_no_order_finding():
+ assert "seo.heading-order.skipped" not in ids(run("
text
"))
+
+
+# --- link text ------------------------------------------------------------
+
+def test_a_link_with_no_accessible_name_is_a_warning():
+ found = [f for f in run("") if f.id == "seo.link-text.empty"]
+ assert len(found) == 1
+ assert found[0].severity == "warning"
+ assert found[0].gate == "link-text"
+
+
+def test_generic_anchor_text_is_info_and_says_it_is_english_only():
+ found = [f for f in run("click here")
+ if f.id == "seo.link-text.generic"]
+ assert len(found) == 1
+ assert found[0].severity == "info", (
+ "the word list is English-only, so this must never fail a build")
+ assert "English" in found[0].fix
+
+
+def test_generic_matching_ignores_case_and_surrounding_whitespace():
+ for text in ("Click Here", " READ MORE ", "Learn more"):
+ assert "seo.link-text.generic" in ids(run(f"{text}")), text
+
+
+def test_descriptive_anchor_text_passes():
+ assert ids(run("OmniRank pricing plans")) == []
+
+
+def test_an_aria_label_supplies_the_accessible_name():
+ assert "seo.link-text.empty" not in ids(run(""))
+
+
+def test_an_image_alt_inside_the_link_supplies_the_accessible_name():
+ assert "seo.link-text.empty" not in ids(
+ run(""))
+
+
+def test_links_inside_nav_are_exempt():
+ # Navigation labels are terse by design and their context comes from the nav
+ # itself; flagging them is noise that trains users to ignore the report.
+ assert ids(run("")) == []
+
+
+def test_an_anchor_with_no_href_is_not_a_link():
+ assert ids(run("here")) == []
+
+
+def test_link_findings_are_one_per_page_per_condition():
+ found = run("click hereread more"
+ "")
+ assert sorted(ids(found)) == ["seo.link-text.empty", "seo.link-text.generic"]
+
+
+# --- lang -----------------------------------------------------------------
+
+def test_html_without_lang_is_an_error():
+ found = [f for f in run("
x
", html_attrs="") if f.id == "seo.lang.missing"]
+ assert len(found) == 1
+ assert found[0].severity == "error"
+ assert found[0].gate == "lang"
+ assert "answer-block" in found[0].fix or "band" in found[0].fix, (
+ "the fix must say why this is an error: page.lang drives band selection, "
+ "so its absence makes another gate emit a wrong finding")
+
+
+def test_an_empty_lang_attribute_counts_as_missing():
+ assert "seo.lang.missing" in ids(run("
x
", html_attrs=" lang=' '"))
+
+
+def test_a_present_lang_passes():
+ assert "seo.lang.missing" not in ids(run("
"
+ "OmniRank pricing", URL) == []
diff --git a/tests/test_gates_security.py b/tests/test_gates_security.py
new file mode 100644
index 0000000..bbc0f7c
--- /dev/null
+++ b/tests/test_gates_security.py
@@ -0,0 +1,242 @@
+import httpx
+import respx
+from omnirank.fetch import make_client
+from omnirank.gates import security
+from omnirank.gates.security import HSTS_MIN_MAX_AGE
+from omnirank.page import PageData
+
+CLEAN_HEADERS = {
+ "strict-transport-security": "max-age=31536000; includeSubDomains",
+ "x-content-type-options": "nosniff",
+ "content-security-policy": "default-src 'self'",
+ "referrer-policy": "strict-origin-when-cross-origin",
+}
+
+BODY = "T"
+
+
+def page(headers=None, url="https://x.example/", html=BODY) -> PageData:
+ return PageData(url=url, html=html, status=200, elapsed_ms=5,
+ headers=dict(CLEAN_HEADERS if headers is None else headers))
+
+
+def ids(findings) -> set[str]:
+ return {f.id for f in findings}
+
+
+def test_a_fully_headed_https_page_emits_nothing():
+ assert security.run_page(page()) == []
+
+
+def test_missing_hsts_on_https_is_info():
+ found = [f for f in security.run_page(page(headers={})) if f.id == "security.hsts.missing"]
+ assert len(found) == 1
+ assert found[0].severity == "info", (
+ "a missing HSTS header breaks no crawl; it is reported as a fact, not graded")
+ assert found[0].layer == "security"
+ assert found[0].gate == "hsts"
+
+
+def test_hsts_is_not_evaluated_on_a_plain_http_page():
+ # HSTS is meaningless over http -- browsers ignore the header entirely.
+ found = security.run_page(page(headers={}, url="http://x.example/"))
+ assert "security.hsts.missing" not in ids(found)
+ assert "security.hsts.short-max-age" not in ids(found)
+
+
+def test_a_short_hsts_max_age_is_flagged_and_names_the_constant():
+ headers = dict(CLEAN_HEADERS, **{"strict-transport-security": "max-age=86400"})
+ found = [f for f in security.run_page(page(headers=headers))
+ if f.id == "security.hsts.short-max-age"]
+ assert len(found) == 1
+ assert str(HSTS_MIN_MAX_AGE) in found[0].expected
+ assert "HSTS_MIN_MAX_AGE" in found[0].fix, (
+ "the threshold is OmniRank's own choice and the finding must name the "
+ "constant rather than imply an industry standard")
+ assert "preload list" not in found[0].fix.lower(), (
+ "OmniRank must claim nothing about the Chromium preload list")
+
+
+def test_an_exactly_threshold_max_age_is_not_flagged():
+ headers = dict(CLEAN_HEADERS,
+ **{"strict-transport-security": f"max-age={HSTS_MIN_MAX_AGE}"})
+ assert "security.hsts.short-max-age" not in ids(security.run_page(page(headers=headers)))
+
+
+def test_an_unparseable_hsts_max_age_is_not_guessed_at():
+ headers = dict(CLEAN_HEADERS, **{"strict-transport-security": "includeSubDomains"})
+ found = ids(security.run_page(page(headers=headers)))
+ assert "security.hsts.short-max-age" not in found, (
+ "no max-age directive is not a SHORT max-age; do not invent a number")
+ assert "security.hsts.missing" not in found, "the header is present"
+
+
+def test_hsts_max_age_is_matched_case_insensitively():
+ headers = dict(CLEAN_HEADERS, **{"strict-transport-security": "Max-Age=31536000"})
+ assert "security.hsts.short-max-age" not in ids(security.run_page(page(headers=headers)))
+
+
+def test_missing_nosniff_csp_and_referrer_policy_are_each_info():
+ found = {f.id: f for f in security.run_page(page(headers={}))}
+ for finding_id, gate in (("security.nosniff.missing", "nosniff"),
+ ("security.csp.absent", "csp"),
+ ("security.referrer-policy.missing", "referrer-policy")):
+ assert finding_id in found, finding_id
+ assert found[finding_id].severity == "info", finding_id
+ assert found[finding_id].gate == gate, finding_id
+
+
+def test_a_csp_delivered_by_meta_tag_counts_as_present():
+ html = (""
+ ""
+ "")
+ headers = {k: v for k, v in CLEAN_HEADERS.items() if k != "content-security-policy"}
+ assert "security.csp.absent" not in ids(security.run_page(page(headers=headers,
+ html=html)))
+
+
+def test_csp_absence_is_reported_without_grading_the_policy():
+ found = [f for f in security.run_page(page(headers={})) if f.id == "security.csp.absent"]
+ assert "no Content-Security-Policy" in found[0].observed
+ assert "weak" not in found[0].fix.lower(), (
+ "grading a policy's strength is a judgement OmniRank must not make")
+
+
+MIXED = (""
+ ""
+ ""
+ ""
+ ""
+ ""
+ "")
+
+
+def test_active_http_subresources_on_an_https_page_are_an_error():
+ # S4: script, stylesheet and iframe are BLOCKABLE ("active") content -- browsers
+ # refuse to load them over http:// at all -- so they carry the id that says the
+ # resource is not loading. `img` is passive and is reported separately below.
+ found = [f for f in security.run_page(page(html=MIXED))
+ if f.id == "security.mixed-content.subresource"]
+ assert len(found) == 1, "one finding per page, not one per subresource"
+ assert found[0].severity == "error", "browsers block this; the page is broken"
+ assert found[0].gate == "mixed-content"
+ assert "3" in found[0].observed
+ for fragment in ("script", "link", "iframe"):
+ assert fragment in found[0].observed, fragment
+ assert "img" not in found[0].observed
+
+
+def test_passive_http_subresources_on_an_https_page_are_a_warning():
+ # S4: `img` is OPTIONALLY-BLOCKABLE ("passive") content -- browsers rewrite the
+ # request to https before fetching it rather than blocking it outright -- so it
+ # is reported separately, at a lower severity, without claiming it failed to
+ # load (which OmniRank cannot observe from the HTML alone).
+ found = [f for f in security.run_page(page(html=MIXED))
+ if f.id == "security.mixed-content.passive-subresource"]
+ assert len(found) == 1
+ assert found[0].severity == "warning", (
+ "browsers auto-upgrade this; it is not a confirmed rendering failure")
+ assert found[0].gate == "mixed-content"
+ assert "1" in found[0].observed
+ assert "img" in found[0].observed
+ assert "not loading for your visitors at all" not in found[0].fix, (
+ "OmniRank did not observe a load failure for auto-upgraded passive content"
+ )
+
+
+def test_mixed_content_is_not_reported_on_a_plain_http_page():
+ found = security.run_page(page(html=MIXED, url="http://x.example/"))
+ assert "security.mixed-content.subresource" not in ids(found)
+ assert "security.mixed-content.passive-subresource" not in ids(found)
+
+
+def test_upgrade_insecure_requests_suppresses_mixed_content():
+ headers = dict(CLEAN_HEADERS,
+ **{"content-security-policy": "upgrade-insecure-requests"})
+ found = security.run_page(page(headers=headers, html=MIXED))
+ assert "security.mixed-content.subresource" not in ids(found), (
+ "the browser rewrites these to https before requesting them")
+ assert "security.mixed-content.passive-subresource" not in ids(found)
+
+
+def test_a_non_subresource_http_link_is_not_mixed_content():
+ # A canonical or an hreflang alternate is not a subresource: the browser never
+ # fetches it while rendering, so it is not mixed content. Flagging it would be
+ # a fabricated error, and seo.canonical.* already owns that judgement.
+ html = (""
+ ""
+ ""
+ "x"
+ "")
+ found = security.run_page(page(html=html))
+ assert "security.mixed-content.subresource" not in ids(found)
+ assert "security.mixed-content.passive-subresource" not in ids(found)
+
+
+def test_protocol_relative_subresources_are_not_mixed_content():
+ # //cdn.example/a.js inherits the page's scheme, so on an https page it is https.
+ html = (""
+ "")
+ assert "security.mixed-content.subresource" not in ids(security.run_page(page(html=html)))
+
+
+def test_only_passive_subresources_produce_no_active_finding():
+ html = (""
+ ""
+ "")
+ found = security.run_page(page(html=html))
+ assert "security.mixed-content.subresource" not in ids(found)
+ passive = [f for f in found if f.id == "security.mixed-content.passive-subresource"]
+ assert len(passive) == 1
+ assert "2" in passive[0].observed
+ assert "icon" in passive[0].observed and "img" in passive[0].observed
+
+
+@respx.mock
+def test_http_that_redirects_to_https_emits_nothing():
+ respx.get("http://x.example/").mock(return_value=httpx.Response(
+ 301, headers={"location": "https://x.example/"}))
+ findings, not_evaluated = security.check_https_redirect(make_client(),
+ "https://x.example")
+ assert findings == []
+ assert not_evaluated == []
+
+
+@respx.mock
+def test_http_serving_200_is_an_error():
+ respx.get("http://x.example/").mock(return_value=httpx.Response(200, text="hi"))
+ findings, not_evaluated = security.check_https_redirect(make_client(),
+ "https://x.example")
+ assert not_evaluated == []
+ assert [f.id for f in findings] == ["security.https-redirect.missing"]
+ assert findings[0].severity == "error"
+ assert findings[0].gate == "https-redirect"
+ assert findings[0].url == "http://x.example/"
+
+
+@respx.mock
+def test_http_redirecting_to_another_http_url_is_still_an_error():
+ respx.get("http://x.example/").mock(return_value=httpx.Response(
+ 302, headers={"location": "http://x.example/home"}))
+ findings, _ = security.check_https_redirect(make_client(), "https://x.example")
+ assert [f.id for f in findings] == ["security.https-redirect.missing"]
+ assert "http://x.example/home" in findings[0].observed
+
+
+@respx.mock
+def test_an_http_url_that_cannot_be_reached_is_not_evaluated_rather_than_passed():
+ respx.get("http://x.example/").mock(side_effect=httpx.ConnectError("refused"))
+ findings, not_evaluated = security.check_https_redirect(make_client(),
+ "https://x.example")
+ assert findings == [], "a gate that could not run must never emit a verdict"
+ assert [(e.gate, e.reason) for e in not_evaluated] == [
+ ("https-redirect", "page-unreachable")]
+
+
+def test_the_probe_is_not_applicable_to_an_http_site():
+ findings, not_evaluated = security.check_https_redirect(make_client(),
+ "http://x.example")
+ assert findings == []
+ assert [(e.gate, e.reason) for e in not_evaluated] == [
+ ("https-redirect", "not-applicable")]
+ assert not_evaluated[0].site == "http://x.example"
diff --git a/tests/test_plugin_manifest.py b/tests/test_plugin_manifest.py
index fa2f9f6..7f594fb 100644
--- a/tests/test_plugin_manifest.py
+++ b/tests/test_plugin_manifest.py
@@ -7,7 +7,7 @@
def test_plugin_manifest_is_valid():
manifest = json.loads((ROOT / ".claude-plugin" / "plugin.json").read_text())
assert manifest["name"] == "omnirank"
- assert manifest["version"] == "0.3.0"
+ assert manifest["version"] == "0.4.0"
assert manifest["skills"] == "./skills/"
assert manifest["license"] == "MIT"
assert manifest["homepage"] == "https://github.com/bemoshiur/OmniRank"
diff --git a/tests/test_registry.py b/tests/test_registry.py
index edcf40d..2b3cddf 100644
--- a/tests/test_registry.py
+++ b/tests/test_registry.py
@@ -19,7 +19,7 @@
ROOT = Path(__file__).resolve().parents[1]
SOURCE_DIR = ROOT / "scripts" / "py" / "omnirank"
-LAYERS = ("seo", "aeo", "geo", "offsite", "smm", "perf")
+LAYERS = ("seo", "aeo", "geo", "offsite", "smm", "perf", "security")
ID_LITERAL = re.compile(r'"([a-z0-9-]+\.[a-z0-9-]+\.[a-z0-9-]+)"')
# gates/geo.py builds these five ids by f-string from the artifact filename
@@ -57,8 +57,11 @@ def test_every_registered_id_is_emitted_by_some_gate():
assert not orphans, f"registered but never emitted: {orphans}"
-def test_registry_covers_all_forty_eight_findings():
- assert len(REGISTRY) == 48
+def test_registry_covers_all_sixty_seven_findings():
+ assert len(REGISTRY) == 67, (
+ "48 through v0.3.0, plus 19 in v0.4.0: 8 security (S4's final-review fix "
+ "split mixed-content into an active/passive pair), 5 contradictions, "
+ "1 schema-required, 5 onpage")
def test_tier_distribution_matches_the_fixability_classification():
@@ -66,8 +69,25 @@ def test_tier_distribution_matches_the_fixability_classification():
("mechanical", "templated", "drafted", "advisory", "infrastructure")}
for entry in REGISTRY.values():
counts[entry.tier] += 1
- assert counts == {"mechanical": 4, "templated": 15, "drafted": 12,
- "advisory": 11, "infrastructure": 6}
+ # mechanical stays at exactly 4: a new mechanical id would need a new fix
+ # generator (test_generators_cover_exactly_the_mechanical_tier), and v0.4.0
+ # deliberately adds no fix surface.
+ assert counts == {"mechanical": 4, "templated": 19, "drafted": 16,
+ "advisory": 16, "infrastructure": 12}
+ assert sum(counts.values()) == 67
+
+
+def test_the_seo_layer_has_twenty_four_scoring_gates_after_v0_4_0():
+ from omnirank.registry import SCORING_GATES_BY_LAYER
+
+ assert len(SCORING_GATES_BY_LAYER["seo"]) == 24, (
+ "16 through v0.3.0 plus 8 in v0.4.0. This number is the denominator "
+ "Report.score() divides the seo layer's cost by -- if it changes, the "
+ "scoring arithmetic in the v0.4.0 plan needs redoing, not the assertion "
+ "loosening")
+ assert len(SCORING_GATES_BY_LAYER["security"]) == 2, (
+ "only mixed-content and https-redirect can move the score; the four "
+ "header gates are info and must not sit in the denominator")
def test_mechanical_tier_is_exactly_the_four_documented_ids():
diff --git a/tests/test_repo_docs.py b/tests/test_repo_docs.py
index 38f01dc..5603542 100644
--- a/tests/test_repo_docs.py
+++ b/tests/test_repo_docs.py
@@ -104,13 +104,17 @@ def test_changelog_documents_v0_3_0():
assert "## [0.3.0]" in (ROOT / "CHANGELOG.md").read_text()
+def test_changelog_documents_v0_4_0():
+ assert "## [0.4.0]" in (ROOT / "CHANGELOG.md").read_text()
+
+
def test_every_version_declaration_agrees():
import json
import re
init = (ROOT / "scripts" / "py" / "omnirank" / "__init__.py").read_text()
version = re.search(r'__version__ = "([^"]+)"', init).group(1)
- assert version == "0.3.0"
+ assert version == "0.4.0"
pyproject = (ROOT / "scripts" / "py" / "pyproject.toml").read_text()
assert f'version = "{version}"' in pyproject
diff --git a/tests/test_report.py b/tests/test_report.py
index d53a80e..b363969 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -1,9 +1,11 @@
import json
+import random
from pathlib import Path
import pytest
from jsonschema import Draft202012Validator, FormatChecker
+from omnirank.registry import REGISTRY, SCORING_GATES_BY_LAYER, scoring_gate_count
from omnirank.report import Finding, NotEvaluated, Report
ROOT = Path(__file__).resolve().parents[1]
@@ -23,38 +25,12 @@ def test_empty_report_scores_100_overall():
assert r.score()["overall"] == 100
-def test_error_costs_ten_points():
- r = Report(site="https://x.example", kind="audit")
- r.add(f())
- assert r.score()["seo"] == 90
-
-
-def test_warning_costs_three_points():
- r = Report(site="https://x.example", kind="audit")
- r.add(f(severity="warning"))
- assert r.score()["seo"] == 97
-
-
-def test_score_floors_at_zero():
- # v0.2.1: under the old flat-per-finding model, 20 findings from the SAME gate
- # were enough to floor a layer (20*10=200). Under the capped model that same
- # gate now maxes out at GATE_CAP (15) no matter how many times it fires, so
- # flooring requires enough DISTINCT broken gates for their capped costs to sum
- # past 100: ceil(100/15) = 7 gates, each firing twice (10*2=20, capped to 15).
- r = Report(site="https://x.example", kind="audit")
- for gate_n in range(7):
- for i in range(2):
- r.add(f(url=f"https://x.example/{gate_n}-{i}",
- id=f"seo.issue-{gate_n}.broken", gate=f"gate-{gate_n}"))
- assert r.score()["seo"] == 0
-
-
def test_overall_is_mean_of_layers_that_ran():
r = Report(site="https://x.example", kind="audit")
- r.add(f()) # seo -> 90
- r.add(f(layer="aeo", id="aeo.faq.missing", gate="faq")) # aeo -> 90
+ r.add(f(layer="smm", id="smm.alpha.broken", gate="alpha")) # -> 33
+ r.add(f(layer="offsite", id="offsite.beta.broken", gate="beta")) # -> 33
s = r.score()
- assert s["overall"] == 90
+ assert s["overall"] == 33
assert "geo" not in s
@@ -180,7 +156,7 @@ def test_clean_layer_that_ran_scores_100():
r.layers_run.update({"seo", "aeo", "geo"})
r.add(f()) # one seo error
s = r.score()
- assert s["seo"] == 90
+ assert s["seo"] < 100, "a real finding must cost something"
assert s["aeo"] == 100, "a layer that ran with no findings scored 100"
assert s["geo"] == 100
@@ -212,14 +188,6 @@ def test_layer_that_did_not_run_is_absent():
# systemic issue (one gate firing on every URL of a real site) cannot alone zero
# the layer and drown out every other signal. ---
-def test_one_gate_firing_fifty_seven_times_does_not_zero_its_layer():
- r = Report(site="https://x.example", kind="audit")
- for i in range(57):
- r.add(f(url=f"https://x.example/{i}")) # all id=seo.h1.multiple, gate=h1
- assert r.score()["seo"] > 0
- assert r.score()["seo"] == 85, "100 - GATE_CAP(15), regardless of the 57 URLs"
-
-
def test_two_distinct_gates_cost_more_than_one_gate_firing_twice_as_often():
one_gate_twice = Report(site="https://x.example", kind="audit")
one_gate_twice.add(f(url="https://x.example/a"))
@@ -285,3 +253,192 @@ def test_a_report_without_not_evaluated_key_still_validates():
del d["notEvaluated"]
validator = Draft202012Validator(SCHEMA, format_checker=FormatChecker())
assert list(validator.iter_errors(d)) == []
+
+
+# --- v0.4.0: the layer budget is normalised by the layer's own gate count, so
+# adding gates can never make saturation cheaper. `smm` and `offsite` have no
+# registered gates, so their surface is exactly the number of gates the test
+# itself creates -- an exact, registry-independent denominator that stays
+# correct as seo grows from 16 gates to 24. ---
+
+def test_an_error_costs_ten_raw_points_normalised_by_a_one_gate_surface():
+ # One gate seen, none registered -> surface 1, denominator 15*1 = 15.
+ # penalty = (100*10 + 7) // 15 = 1007 // 15 = 67.
+ r = Report(site="https://x.example", kind="audit")
+ r.add(f(layer="smm", id="smm.alpha.broken", gate="alpha"))
+ assert r.score()["smm"] == 33
+
+
+def test_a_warning_costs_three_raw_points_normalised_by_a_one_gate_surface():
+ # penalty = (100*3 + 7) // 15 = 307 // 15 = 20.
+ r = Report(site="https://x.example", kind="audit")
+ r.add(f(layer="smm", id="smm.alpha.broken", gate="alpha", severity="warning"))
+ assert r.score()["smm"] == 80
+
+
+def test_info_severity_never_moves_the_score():
+ r = Report(site="https://x.example", kind="audit")
+ r.layers_run.add("smm")
+ r.add(f(layer="smm", id="smm.alpha.noted", gate="alpha", severity="info"))
+ assert r.score()["smm"] == 100
+
+
+def test_one_maxed_gate_costs_exactly_one_gates_worth_of_its_layer():
+ # B2 regression: `beta`'s finding is info-severity and therefore zero-cost, so
+ # it must NOT be admitted into the scoring surface -- only `alpha` (which
+ # actually cost something) counts. Surface stays 1, not 2: an info-only gate
+ # is exactly the kind registry.py's SCORING_GATES_BY_LAYER deliberately
+ # excludes from the denominator (its comment: counting it "would put a floor
+ # under the layer's score"), and Report.score() must honour that even for an
+ # ad-hoc gate the registry has never heard of.
+ #
+ # Gate `alpha` is maxed (2 errors = 20 raw, capped to GATE_CAP = 15) and is the
+ # only gate in the surface, so it costs the layer's ENTIRE budget, not a
+ # fraction of it: denominator = 15 * 1 = 15,
+ # penalty = (100*15 + 7) // 15 = 1507 // 15 = 100.
+ #
+ # Before the fix, `seen_gates[layer].add(gate)` ran unconditionally, so
+ # `beta` inflated the surface to 2 and the SAME findings scored 50 instead of
+ # 0 -- i.e. adding a zero-cost finding on a new gate RAISED the score, which
+ # is exactly the non-monotonicity this test now pins shut.
+ r = Report(site="https://x.example", kind="audit")
+ for i in range(2):
+ r.add(f(layer="smm", id="smm.alpha.broken", gate="alpha",
+ url=f"https://x.example/{i}"))
+ r.add(f(layer="smm", id="smm.beta.noted", gate="beta", severity="info"))
+ assert r.score()["smm"] == 0
+
+
+def test_zeroing_a_layer_requires_every_one_of_its_registered_gates():
+ """The whole point of v0.4.0's score change, asserted against the live registry.
+
+ Reads the gate list from the registry rather than hardcoding a count, so it
+ stays true as Tasks 3-10 add gates instead of needing an edit per task.
+ """
+ gates = sorted(SCORING_GATES_BY_LAYER["seo"])
+ assert len(gates) >= 16, "sanity: the seo layer has gates to enumerate"
+
+ r = Report(site="https://x.example", kind="audit")
+ for n, gate in enumerate(gates[:-1]): # every gate but the last, maxed
+ for i in range(2): # 2 errors = 20 raw -> capped 15
+ r.add(f(id=f"seo.{gate}.x", gate=gate, url=f"https://x.example/{n}-{i}"))
+ assert r.score()["seo"] > 0, (
+ "one clean gate must keep the layer off the floor, however many gates exist")
+
+ last = gates[-1]
+ for i in range(2):
+ r.add(f(id=f"seo.{last}.x", gate=last, url=f"https://x.example/last-{i}"))
+ assert r.score()["seo"] == 0, "a layer floors only when every gate is maxed"
+
+
+def test_one_gate_firing_fifty_seven_times_costs_the_same_as_firing_twice():
+ # GATE_CAP, preserved verbatim from v0.2.1: repetition of the SAME gate stops
+ # compounding. Asserted as an equality against the 2-firing case rather than a
+ # magic number, so it survives the seo surface growing from 16 to 24.
+ many = Report(site="https://x.example", kind="audit")
+ for i in range(57):
+ many.add(f(url=f"https://x.example/{i}")) # all id=seo.h1.multiple, gate=h1
+
+ twice = Report(site="https://x.example", kind="audit")
+ for i in range(2):
+ twice.add(f(url=f"https://x.example/{i}"))
+
+ assert many.score()["seo"] == twice.score()["seo"]
+ assert many.score()["seo"] > 0
+
+
+def test_scoring_gate_count_excludes_unreachable_and_info_only_gates():
+ assert "crawl-hygiene" not in SCORING_GATES_BY_LAYER["seo"], (
+ "crawl-hygiene is reachable=False; a gate that can never fire must not "
+ "sit in the denominator inflating every score")
+ assert scoring_gate_count("seo") == len(SCORING_GATES_BY_LAYER["seo"])
+ assert scoring_gate_count("not-a-layer") == 0
+
+
+# --- v0.4.0: the security layer and the two new notEvaluated reasons ---
+
+def test_a_security_layer_finding_validates_against_the_report_schema():
+ r = Report(site="https://x.example", kind="audit")
+ r.layers_run.add("security")
+ r.add(Finding(id="security.mixed-content.subresource", severity="error",
+ layer="security", url="https://x.example/", gate="mixed-content",
+ observed="1 http:// subresource", expected="every subresource over https",
+ fix="Serve it over https."))
+ errors = list(Draft202012Validator(
+ SCHEMA, format_checker=FormatChecker()).iter_errors(r.to_dict()))
+ assert errors == [], errors
+
+
+def test_the_security_layer_is_scored_only_when_it_ran():
+ r = Report(site="https://x.example", kind="audit")
+ r.layers_run.update({"seo"})
+ assert "security" not in r.score(), (
+ "a site that was never security-checked must not score 100 on security")
+
+
+def test_the_new_not_evaluated_reasons_validate():
+ r = Report(site="https://x.example", kind="audit")
+ r.flag_not_evaluated(NotEvaluated(gate="robots-sitemap",
+ url="https://x.example/robots.txt",
+ reason="matcher-unsupported"))
+ r.flag_not_evaluated(NotEvaluated(gate="canonical-target",
+ url="https://x.example/z",
+ reason="budget-exceeded"))
+ errors = list(Draft202012Validator(
+ SCHEMA, format_checker=FormatChecker()).iter_errors(r.to_dict()))
+ assert errors == [], errors
+ assert {e.reason for e in r.not_evaluated} == {"matcher-unsupported",
+ "budget-exceeded"}
+
+
+# --- B2 property test: score() must be monotone -- adding a finding can never ---
+# --- raise a score. This is the guarantee score()'s own docstring claims and, ---
+# --- before the one-line fix above, nothing actually enforced it. -------------
+
+_ALL_ENTRIES = list(REGISTRY.values())
+_ALL_LAYERS = sorted({e.layer for e in _ALL_ENTRIES})
+
+
+def _finding_for(entry, n):
+ return Finding(id=entry.id, severity=entry.severity, layer=entry.layer,
+ url=f"https://x.example/{n}", gate=entry.gate,
+ observed="observed", expected="expected", fix="fix")
+
+
+def _report_from(pool):
+ r = Report(site="https://x.example", kind="audit")
+ r.layers_run.update(_ALL_LAYERS)
+ for n, entry in enumerate(pool):
+ r.add(_finding_for(entry, n))
+ return r
+
+
+def test_adding_any_finding_never_raises_any_layer_score_or_overall():
+ """40,000-trial randomised check, using only severities the registry actually
+ assigns: a pool of 0-12 real findings is built, one more real finding is
+ added, and every layer's score (plus `overall`) must be equal or lower
+ afterwards, never higher.
+
+ Before the report.py:192 fix this failed within the first few hundred trials
+ -- an info-severity finding on a gate not yet `seen` would enlarge that
+ layer's scoring surface, which can raise the score of a DIFFERENT gate's
+ existing findings in that same layer even though nothing about them changed.
+ """
+ rng = random.Random(20260805) # fixed seed: deterministic, not flaky
+
+ for trial in range(40_000):
+ pool = rng.choices(_ALL_ENTRIES, k=rng.randint(0, 12))
+ r = _report_from(pool)
+ before = r.score()
+
+ extra = rng.choice(_ALL_ENTRIES)
+ r.add(_finding_for(extra, len(pool)))
+ after = r.score()
+
+ for layer in _ALL_LAYERS:
+ assert after[layer] <= before[layer], (
+ f"trial {trial}: layer {layer!r} rose from {before[layer]} to "
+ f"{after[layer]} after adding {extra.id!r} on gate {extra.gate!r}")
+ assert after["overall"] <= before["overall"], (
+ f"trial {trial}: overall rose from {before['overall']} to "
+ f"{after['overall']} after adding {extra.id!r}")
diff --git a/tests/test_robots.py b/tests/test_robots.py
new file mode 100644
index 0000000..7948829
--- /dev/null
+++ b/tests/test_robots.py
@@ -0,0 +1,117 @@
+
+from omnirank import robots
+
+SIMPLE = "User-agent: *\nDisallow: /private/\n"
+WILDCARD = "User-agent: *\nDisallow: /*.pdf$\n"
+OVERLAPPING = "User-agent: *\nDisallow: /docs/\nAllow: /docs/public/\n"
+DISJOINT = "User-agent: *\nDisallow: /admin/\nAllow: /blog/\n"
+
+
+def test_a_file_with_no_wildcards_or_allows_is_always_evaluated():
+ verdict = robots.disallowed_urls(SIMPLE, ["https://x.example/private/a",
+ "https://x.example/open"])
+ assert verdict.evaluated is True
+ assert verdict.reason is None
+ assert verdict.disallowed == ("https://x.example/private/a",)
+
+
+def test_rules_need_wildcards_detects_star_and_dollar():
+ assert robots.rules_need_wildcards(WILDCARD) is True
+ assert robots.rules_need_wildcards("User-agent: *\nDisallow: /a/*/b\n") is True
+ assert robots.rules_need_wildcards(SIMPLE) is False
+ assert robots.rules_need_wildcards(OVERLAPPING) is False, (
+ "the * on the User-agent line is a group selector, not a path wildcard")
+
+
+def test_rules_need_longest_match_only_when_an_allow_and_a_disallow_overlap():
+ assert robots.rules_need_longest_match(OVERLAPPING) is True
+ assert robots.rules_need_longest_match("User-agent: *\nAllow: /\nDisallow: /a/\n") is True
+ assert robots.rules_need_longest_match(DISJOINT) is False, (
+ "non-overlapping paths give the same answer under either matcher")
+ assert robots.rules_need_longest_match(SIMPLE) is False
+
+
+def test_rules_need_longest_match_does_not_cross_group_boundaries():
+ text = ("User-agent: Googlebot\nDisallow: /docs/\n"
+ "User-agent: Bingbot\nAllow: /docs/public/\n")
+ assert robots.rules_need_longest_match(text) is False, (
+ "rules in different user-agent groups never compete with one another")
+
+
+def test_comments_and_blank_lines_are_ignored():
+ text = "# hello\n\nUser-agent: *\nDisallow: /p/ # trailing\n"
+ verdict = robots.disallowed_urls(text, ["https://x.example/p/a"])
+ assert verdict.evaluated is True
+ assert verdict.disallowed == ("https://x.example/p/a",)
+
+
+def test_a_wildcard_file_is_refused_when_the_interpreter_cannot_match_it():
+ verdict = robots.disallowed_urls(WILDCARD, ["https://x.example/a/b.pdf"])
+ if robots.matcher_supports_wildcards():
+ assert verdict.evaluated is True
+ assert verdict.disallowed == ("https://x.example/a/b.pdf",)
+ else:
+ assert verdict.evaluated is False
+ assert verdict.reason == robots.UNSUPPORTED_WILDCARDS
+ assert verdict.disallowed == (), (
+ "a refused verdict must carry no judgement at all")
+
+
+def test_an_overlapping_file_is_refused_when_the_interpreter_is_order_dependent():
+ verdict = robots.disallowed_urls(OVERLAPPING, ["https://x.example/docs/public/x"])
+ if robots.matcher_supports_longest_match():
+ assert verdict.evaluated is True
+ assert verdict.disallowed == (), "longest-match: Allow wins over the shorter Disallow"
+ else:
+ assert verdict.evaluated is False
+ assert verdict.reason == robots.UNSUPPORTED_LONGEST_MATCH
+
+
+def test_the_capability_probes_agree_with_the_matcher_they_probe():
+ """The probes must match OBSERVED matcher behaviour, never a version number.
+
+ The intent here is to catch a probe that has silently started returning a
+ constant. An earlier version cross-checked against `sys.version_info >= (3, 14)`,
+ reasoning that both capabilities arrived in CPython's 3.14 RFC 9309 rewrite.
+ That was wrong, and CI caught it: the change was BACKPORTED, so 3.13.7 reports no
+ wildcard support while 3.13.14 reports it. Any version inference calls one of
+ those wrong — which is precisely the failure the probe exists to prevent, so
+ asserting one here would have hard-coded the bug into its own regression test.
+
+ Observing the matcher directly keeps the original intent (a stuck constant still
+ fails, because these two witnesses are computed independently of the probe) and
+ is immune to whatever CPython backports next.
+ """
+ import urllib.robotparser
+
+ def matcher_observes_wildcards() -> bool:
+ parser = urllib.robotparser.RobotFileParser()
+ parser.parse(["User-agent: *", "Disallow: /*.pdf$"])
+ return not parser.can_fetch("*", "/a/b.pdf")
+
+ def matcher_observes_longest_match() -> bool:
+ parser = urllib.robotparser.RobotFileParser()
+ parser.parse(["User-agent: *", "Disallow: /docs/", "Allow: /docs/public/"])
+ return parser.can_fetch("*", "/docs/public/x")
+
+ assert robots.matcher_supports_wildcards() is matcher_observes_wildcards()
+ assert robots.matcher_supports_longest_match() is matcher_observes_longest_match()
+
+
+def test_a_googlebot_group_overrides_the_wildcard_group():
+ text = "User-agent: *\nDisallow: /\nUser-agent: Googlebot\nDisallow:\n"
+ verdict = robots.disallowed_urls(text, ["https://x.example/a"])
+ assert verdict.evaluated is True
+ assert verdict.disallowed == ()
+
+
+def test_the_wildcard_group_applies_when_there_is_no_agent_specific_group():
+ verdict = robots.disallowed_urls("User-agent: *\nDisallow: /admin/\n",
+ ["https://x.example/admin/x"])
+ assert verdict.disallowed == ("https://x.example/admin/x",)
+
+
+def test_an_empty_robots_file_disallows_nothing():
+ verdict = robots.disallowed_urls("", ["https://x.example/a"])
+ assert verdict.evaluated is True
+ assert verdict.disallowed == ()
diff --git a/tests/test_workflows.py b/tests/test_workflows.py
index 691ddfb..702ee3e 100644
--- a/tests/test_workflows.py
+++ b/tests/test_workflows.py
@@ -36,6 +36,19 @@ def test_ci_lints():
assert "ruff" in CI.read_text()
+def test_ci_python_matrix_includes_3_14():
+ # S7 (final v0.4.0 review): the robots-sitemap contradiction gate's matcher
+ # probe (omnirank/robots.py) has two behaviourally different code paths --
+ # `evaluated is True` on 3.14 (RFC 9309 compliant) and `evaluated is False`
+ # on 3.11-3.13 (matcher-unsupported). A matrix that stops at 3.13 never
+ # exercises the firing path at all, so a regression there would go green on
+ # every CI leg.
+ matrix = load(CI)["jobs"]["python"]["strategy"]["matrix"]["python-version"]
+ assert "3.14" in matrix, (
+ "the CI matrix must include 3.14 so the robots matcher's RFC "
+ "9309-compliant `evaluated is True` path is actually exercised")
+
+
def test_dependabot_configured():
config = load(ROOT / ".github" / "dependabot.yml")
ecosystems = {u["package-ecosystem"] for u in config["updates"]}