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. </details> 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 `<head>` 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 `<script type="application/ld+json">` payload, or each child of a `@graph` array. A `@graph` container's `@context` is propagated down to its children @@ -203,6 +219,52 @@ The fabrication gates are not a style preference. An `AggregateRating` with no r `ratingCount` is a manual-action risk with Google, and it corrodes the trust the markup exists to build in the first place. +### Security (v0.4.0) + +`security.run_page(page)` derives four gates from the already-fetched response's headers +(plus a CSP `<meta http-equiv>` fallback), and `security.check_https_redirect()` makes one +extra request per audit against the site's `http://` origin. All findings carry +`layer: "security"` — a layer that did not exist before v0.4.0. + +Scope is deliberately narrow: OmniRank checks security only where insecurity +demonstrably breaks crawling, indexing or rendering. Mozilla Observatory and testssl.sh +already grade headers properly; an SEO tool scoring CSP strength would be doing a job it +cannot do well. + +| Gate | Finding id | Rule | Severity | +|---|---|---|---| +| `hsts` | `security.hsts.missing` | An `https://` response carries no `Strict-Transport-Security` header | info | +| `hsts` | `security.hsts.short-max-age` | `Strict-Transport-Security` present but `max-age` below `HSTS_MIN_MAX_AGE` (15,552,000 seconds / 180 days — OmniRank's own floor, not a vendor requirement) | info | +| `nosniff` | `security.nosniff.missing` | `X-Content-Type-Options` is not exactly `nosniff` | info | +| `csp` | `security.csp.absent` | No `Content-Security-Policy`, by header or `<meta http-equiv>` | info | +| `referrer-policy` | `security.referrer-policy.missing` | No `Referrer-Policy` header | info | +| `mixed-content` | `security.mixed-content.subresource` | An `https://` page requests a blockable subresource (`script`, `iframe`, or `link rel=stylesheet\|preload\|modulepreload`) over literal `http://` | **error** | +| `mixed-content` | `security.mixed-content.passive-subresource` | An `https://` page requests a passive subresource (`img`, or `link rel=icon\|apple-touch-icon\|manifest\|prefetch`) over literal `http://` — browsers auto-upgrade these rather than block them | warning | +| `https-redirect` | `security.https-redirect.missing` | The site's `http://` origin does not 3xx-redirect to `https://` | **error** | + +**Four of the eight ids are `info` and cost zero points — they can never fail a build.** +They are reported as inventory facts, not graded: whether a given HSTS `max-age` or CSP is +adequate is a judgement about your threat model that an SEO auditor has no business +making. Only `mixed-content`'s active-subresource id and `https-redirect` are `error`, +because only those two break something OmniRank can actually observe — browsers block +mixed *active* content outright, and a non-redirecting `http://` origin gives every page a +live duplicate that splits canonical signal between two URLs. The passive-subresource id +is `warning`: browsers auto-upgrade an `img` or icon request to `https://` first and only +fail if no `https://` version exists there, which OmniRank cannot verify from the HTML +alone, so it is reported without claiming a rendering failure it did not observe. +`security` enters the report's `layersRun` only when at least one page was actually +fetched — exactly the same rule `aeo` and `perf` follow, so a zero-page audit leaves +`security` absent from the score map rather than a fabricated `100`. + +CSP is parsed for exactly one thing beyond noting its total absence: +`upgrade-insecure-requests`, which browsers use to rewrite `http://` subresources before +requesting them, and which therefore suppresses both `mixed-content` ids — this module +never grades a policy's contents. + +Only `mixed-content` and `https-redirect` can move the `security` score — the four `info` +gates are excluded from the scoring surface for the same reason `crawl-hygiene` is: see +[Scoring](#scoring) below. + ### Site-level (cross-URL) gates — SEO layer `site.run(pages, sitemap_urls)` needs the whole crawled set at once and cannot be @@ -231,6 +293,44 @@ a URL excluded by `audit.sampleSize`, an external domain — produces no finding way; OmniRank never reports on a gate it could not actually evaluate. `noindex-in-sitemap` similarly only fires for pages OmniRank both fetched and found listed in `sitemap.xml`. +### Indexability contradictions (v0.4.0) + +`gates/contradictions.py` — defects provable from the site's own declarations, with no +external truth required: a URL submitted for crawling in the sitemap and forbidden in +`robots.txt`; a canonical pointing at a page that is noindexed, redirects, or 404s; a page +declared as an `hreflang` alternate while forbidding its own indexing. Every finding here +is 100% precision because both halves of the contradiction come from the site itself, and +every one carries `layer: "seo"`. + +| Gate | Finding id | Rule | Severity | +|---|---|---|---| +| `robots-sitemap` | `seo.robots-sitemap.disallowed` | A URL listed in `sitemap.xml` is also `Disallow`-ed to `Googlebot` in `robots.txt` | **error** | +| `canonical-target` | `seo.canonical-target.noindexed` | A page's canonical points at a URL that carries a `noindex` directive | **error** | +| `canonical-target` | `seo.canonical-target.not-found` | A canonical target returns `404` or `410` | **error** | +| `canonical-target` | `seo.canonical-target.redirects` | A canonical target itself 3xx-redirects | warning | +| `hreflang-noindex` | `seo.hreflang-noindex.alternate` | A page declares an `hreflang` alternate at a page that carries `noindex` (attached to the *declaring* page, not the noindexed target; `x-default` is exempt) | **error** | + +Two rules govern every check in this module: + +- **Never judge a URL OmniRank did not see.** `robots-sitemap` only evaluates sitemap + URLs on robots.txt's OWN host — `RobotFileParser.can_fetch()` discards the host and + matches on path alone, so a sitemap index listing other hosts' URLs (a CDN, a blog + subdomain) gets those reported `not-applicable` in `notEvaluated` rather than judged + by a robots.txt that never governed them. A canonical target already in the crawled set + is judged from the `PageData` already held — no second fetch. A target outside it is + probed once, deduplicated across every page pointing at it, up to `MAX_CANONICAL_PROBES` + (25 per audit); anything past that budget is `budget-exceeded` in `notEvaluated`, never + silently skipped. A 5xx or transport failure on a probe is `page-unreachable`, not + `.not-found` — a transient origin error is not a missing page, and calling it one would + be a guess dressed as a finding. +- **A gate that could not run reports why, never silently.** `robots-sitemap` returns + `no-sitemap`, `page-unreachable`, `not-applicable`, or `matcher-unsupported` in + `notEvaluated` rather than a pass when it cannot reach a verdict. `matcher-unsupported` + is real on Python 3.11–3.13: `urllib.robotparser` only became RFC 9309 compliant in + Python 3.14, so on earlier interpreters OmniRank refuses to answer rather than trust a + matcher it knows may ignore wildcards or mis-order `Allow`/`Disallow` overlaps — see + `omnirank/robots.py`. + ### Performance (`perf` layer) `perf.run(page)` derives every finding from the single HTTP response already captured @@ -273,6 +373,39 @@ supports them even if it does. The `_COMPRESSED` check still matches the literal `br` and `zstd` defensively, in case a misconfigured origin ignores `Accept-Encoding` and sends one anyway, but the gate's real, exercised coverage is `gzip`/`deflate` only. +### On-page (v0.4.0) + +`gates/onpage.py` — body markup that search engines and assistive technology both read. +Kept out of `seo.py`, which owns only the `<head>`'s indexing signals: canonical, title, +description, OpenGraph, hreflang. Every finding carries `layer: "seo"`. + +| Gate | Finding id | Rule | Severity | +|---|---|---|---| +| `image-alt` | `seo.image-alt.missing` | An `<img>` has no `alt` attribute at all | warning | +| `heading-order` | `seo.heading-order.skipped` | The outline jumps more than one level deeper (e.g. `h1` straight to `h3`) — only the first skip on the page is reported | warning | +| `link-text` | `seo.link-text.empty` | An `<a href>` has no accessible name (no text, `aria-label`, `title`, or `alt` on a contained `<img>`) | warning | +| `link-text` | `seo.link-text.generic` | An `<a href>`'s accessible name is a generic phrase ("click here", "read more", …) that conveys nothing on its own | info | +| `lang` | `seo.lang.missing` | `<html>` has no non-empty `lang` attribute | **error** | + +**`alt=""` is never flagged, under any circumstance.** An empty `alt` is the spec's own +way to mark an image decorative, and a check that fires on it would tell users to make +correct markup worse. `role="presentation"`, `role="none"` and `aria-hidden="true"` are +honoured the same way. Only a *missing* `alt` attribute — not an empty one — is a finding. + +**`lang` is `error`, the strongest severity in this group, because a missing `lang` makes +a DIFFERENT gate lie.** `page.lang` feeds `bands.resolve_band()`; with no `lang`, a page +written in a script with no word separators (Japanese, Thai, …) is measured against the +space-delimited English word band it structurally cannot meet, and +`aeo.answer-block.length` reports a length problem that does not exist. A missing check +here is not silence — it is a wrong finding somewhere else. + +**`link-text.generic` is `info`, and must never fail a build,** because the word list +behind it (`GENERIC_ANCHORS` in `gates/onpage.py`) is English-only by construction: on a +Bengali or Japanese page it matches nothing and the gate is silent by design, not because +the page is clean. Links inside `<nav>` are exempt from both `link-text` findings — +navigation labels are terse on purpose and take their meaning from the nav itself, and +flagging them is the kind of noise that trains users to ignore the report. + ### Sitemap `lastmod` inflation (`lastmod-inflation` — SEO layer) If **more than** 90% of at least 10 `<lastmod>` entries in `sitemap.xml` share one date, @@ -339,17 +472,50 @@ WARNING_COST = 3 GATE_CAP = 15 ``` -**Per gate, then per layer (as of v0.2.1):** each GATE's contribution to its layer is -capped first — `min(GATE_CAP, 10*errors + 3*warnings)` — and the layer's score is -`max(0, 100 - sum(capped gate costs))`. `info`-severity findings cost nothing. A layer -that ran and accumulated zero findings scores exactly `100`. This replaced a flat -per-finding cost with no cap: measured against a real 57-URL site, one gate failing on -every page (a missing `<h1>` on every template) cost `570` points against a `100`-point -layer under the old model, saturating `seo` and `aeo` to `0` and leaving nothing to act -on. One gate firing on every page of a site is one problem to fix, not fifty — repeating -the SAME gate no longer compounds past `GATE_CAP`, but a second, DISTINCT broken gate -still adds its own capped cost, so a site with many different problems still scores worse -than one with a single frequently-firing one. +**Per gate, then per layer, normalised by the layer's own surface (v0.4.0):** each +GATE's contribution to its layer is capped first — `min(GATE_CAP, 10*errors + +3*warnings)` — and those capped costs are **summed and divided by the layer's own +scoring surface**, not subtracted from a flat 100. Concretely: + +```python +surface = max(1, scoring_gate_count(layer), gates_actually_seen) +denominator = GATE_CAP * surface +penalty = (100 * capped_cost_sum + denominator // 2) // denominator # round-half-up +score = max(0, 100 - penalty) +``` + +That penalty expression is deliberately **not** Python's `round()` — `round()` is +banker's rounding (rounds `.5` to the nearest even number), and float division would +make the result depend on IEEE 754 detail. `(100*cost + denominator//2) // denominator` +is integer round-half-up throughout, so it is exactly reproducible. + +`scoring_gate_count(layer)` (`registry.py`) is how many DISTINCT gates in that layer can +ever move the score — `info`-severity-only gates (the four security header gates) and +unreachable gates (`crawl-hygiene`) are excluded, because counting a gate that can never +cost anything would put an artificial floor under the layer's score. `info`-severity +findings themselves also cost nothing and — as of the fix below — never enlarge the +surface either. + +**Why the division exists.** Before v0.4.0 the budget was a flat 100 per layer +regardless of how many gates that layer had, so seven maxed gates zeroed a layer whether +it had seven gates or thirty — every gate this release added made saturation *cheaper*. +It also put an unreachable floor under small layers: `security` ships only 2 scoring +gates, so under a flat budget its worst possible score would have been 70. Dividing by +`surface` fixes both directions at once: one maxed gate always costs exactly `1/surface` +of the layer, so a layer floors to `0` only when **every one of its registered gates** +is maxed, and a small layer can still reach `0`. + +**Adding a finding can never raise a score — `Report.score()`'s own guarantee, and a +real bug fixed against it (final review, B2).** `seen_gates[layer].add(gate)` used to run +for every gate with a finding, including `info`-only ones — which re-admitted exactly the +gates `scoring_gate_count()` deliberately excludes, and *widened* `surface` for free. A +harmless `info` finding on a gate the layer had never seen before could therefore RAISE +that layer's score by diluting the denominator under existing errors: verified directly +against `https://danluu.com`, `security` scored `87` before this fix and the correct +value, `67`, only after it — the SAME findings, same header gates present the whole time. +`seen_gates[layer].add(gate)` now runs only when that gate's raw cost is nonzero, and a +40,000-trial randomised property test (`tests/test_report.py`) now asserts adding any +finding, drawn from the real registry, never raises any layer's score or `overall`. **Overall:** the integer floor-division average of every layer's score — `sum(scores.values()) // len(scores)`. Not a rounded mean: `//` truncates, it does not @@ -359,29 +525,33 @@ round to nearest. With one layer at 90 and one at 91, `overall` is `90`, not `91 **`layers_run`:** the `Report` model can omit a layer that never ran from the score map entirely (`tests/test_report.py::test_layer_that_did_not_run_is_absent` exercises this directly against a bare `Report` object), and `audit_site()` — the function the CLI -actually calls — relies on exactly that behaviour. `seo` and `geo` are marked run -unconditionally at the start of `audit_site()`, because `seo` covers -`seo.page.unreachable` and the sitemap gates, and `geo.run()` probes site-level artifacts -(`llms.txt`, `robots.txt`, ...) regardless of whether any page was fetched. `aeo` and -`perf` are only marked run **after** `_collect()` has actually returned at least one -page — they are per-page gates, so if zero pages were parsed, `aeo` and `perf` are -absent from `layers_run` and therefore absent from the score map, not silently scored -`100`. A plain `omnirank audit` against a reachable site still reports all four layers; -a `overall` divisor of anything other than 4 is the signal that at least one layer never -ran. - -**The unreachable-URL edge case, reproduced directly:** if the single audited URL is -unreachable, the per-page loop in `_collect()` records one `seo.page.unreachable` error -and never adds that URL's page to the collection — `aeo.run()`, `perf.run()`, and the -JSON-LD checks never run for it. Net effect: a site whose homepage is completely down -shows **no `aeo` or `perf` key at all** in the score map, because neither ever touched -any content — they are absent, not a fabricated `100`: +actually calls — relies on exactly that behaviour, WITH one exception: a layer that +produced an actual finding reaches the score map regardless of `layers_run`, since +`Report.score()` derives `costs` from the findings themselves — `layers_run` only +controls whether a layer with **zero** findings is reported as a clean `100` or omitted +entirely. `seo` and `geo` are marked run unconditionally at the start of `audit_site()`, +because `seo` covers `seo.page.unreachable` and the sitemap gates, and `geo.run()` probes +site-level artifacts (`llms.txt`, `robots.txt`, ...) regardless of whether any page was +fetched. `aeo`, `perf` and `security` are only marked run **after** `_collect()` has +actually returned at least one page — they are per-page gates +(`security.run_page(page)` included, since v0.4.0), so if zero pages were parsed all +three are absent from `layers_run` and therefore absent from the score map, not silently +scored `100`. Before this was fixed (final review, S5), `security` was also admitted on +a successful `https-redirect` probe *alone*, so `mixed-content` — one of only two +`security` scoring gates — was silently counted clean on a zero-page audit; verified +against a real report, `rust-lang.org` showed `security: 100` with `urlsChecked: 1` and 0 +pages parsed while `aeo`/`perf` were correctly absent. A plain `omnirank audit` against a +reachable site still reports all five layers; an `overall` divisor of anything other +than 5 is the signal that at least one layer never ran. + +**The unreachable-URL edge case, reproduced directly, including the `layers_run` +exception above:** ``` $ python3 -m omnirank.cli audit https://example.com/nope-xyz -OmniRank 0.2.1 — https://example.com/nope-xyz - overall 72/100 geo 60 seo 85 - 1 URLs checked · 6 findings in 6 groups +OmniRank 0.4.0 — https://example.com/nope-xyz + overall 70/100 geo 47 security 67 seo 96 + 1 URLs checked · 7 findings in 7 groups ERRORS [1×] seo.page.unreachable — expected: HTTP 200 @@ -390,58 +560,85 @@ OmniRank 0.2.1 — https://example.com/nope-xyz [1×] seo.sitemap.missing — expected: a sitemap.xml enumerating the site's URLs fix: Publish a sitemap.xml so OmniRank -- and search engines -- can discover every page. Without one, this audit only sees the homepage. e.g. https://example.com/nope-xyz/sitemap.xml + [1×] security.https-redirect.missing — expected: a 3xx redirect to the https:// URL + fix: Redirect http:// to https:// at the origin or CDN. Serving both schemes gives every page a live duplicate, splitting the canonical and link signals between two URLs that engines must reconcile themselves. + e.g. http://example.com/nope-xyz/ [1×] geo.llms.missing ... ... - NOT EVALUATED (4 gate(s) across 2 target(s) — see the JSON report for the reason enum) - site — https://example.com/nope-xyz [no-sitemap] - aeo, perf, seo — https://example.com/nope-xyz/ [page-unreachable] + NOT EVALUATED (7 gate(s) across 2 target(s) — see the JSON report for the reason enum) + robots-sitemap, site — https://example.com/nope-xyz [no-sitemap] + aeo, onpage, perf, security, seo — https://example.com/nope-xyz/ [page-unreachable] ``` -(`overall 72` = `(85 + 60) // 2 = 145 // 2 = 72` — only `seo` and `geo` ran, so the -average is over **two** layers, not four. `seo` is `85`, not `90`: both -`seo.page.unreachable` and `seo.sitemap.missing` fire under the same gate, -`sitemap-health`, so their capped combined cost is `min(GATE_CAP, 20) = 15`.) Real -output, captured against `https://example.com/nope-xyz` on v0.2.1. Note the `NOT -EVALUATED` section — `nope-xyz` has no `sitemap.xml`, so the site-level gates could not -run meaningfully either, and that is now visible instead of silent. +Real output, captured against `https://example.com/nope-xyz` on v0.4.0. Neither `aeo`, +`perf` nor `onpage` appears in the score map at all — zero pages were parsed, so none of +the three ever touched any content. `security` DOES appear, at `67`, even though it too +is listed in `NOT EVALUATED` for this URL: the per-page header/mixed-content checks +never ran, but `check_https_redirect()` is a site-level probe that ran anyway and found +a real problem (`security.https-redirect.missing`), and that genuine finding reaches the +score map on its own merits regardless of `layers_run` — exactly the exception named +above. (`security 67`: one error, `min(GATE_CAP, 10) = 10` capped cost, over a 2-gate +surface — denominator `30`, penalty `(100*10 + 15) // 30 = 1015 // 30 = 33`, score +`100 - 33 = 67`.) `seo 96` similarly reflects two errors on the SAME gate, +`sitemap-health` (`seo.page.unreachable` and `seo.sitemap.missing`), capped together to +`min(GATE_CAP, 20) = 15` over `seo`'s 24-gate surface — denominator `360`, penalty +`(100*15 + 180) // 360 = 1680 // 360 = 4`, score `100 - 4 = 96`. `overall 70` = +`(47 + 67 + 96) // 3 = 210 // 3 = 70` — only `geo`, `security` and `seo` reached the +score map, so the average is over **three** layers here, not five. Read `seo.page.unreachable` as the signal that the whole run is unreliable, regardless of what the other layers show. ## Worked example -Running the CLI against `https://example.com` with no config (full output also shown in -[getting-started.md](getting-started.md#4-run-the-first-audit)): +Running the CLI against `https://example.com` with no config — real output, captured on +this machine against the live site on v0.4.0: ``` -OmniRank 0.2.0 — https://example.com - overall 76/100 aeo 80 geo 60 perf 100 seo 67 - 1 URLs checked, 10 findings +OmniRank 0.4.0 — https://example.com + overall 74/100 aeo 71 geo 47 perf 100 security 67 seo 88 + 1 URLs checked · 17 findings in 17 groups ``` -Deriving `seo 67`: the page is missing `rel=canonical` (error), missing a meta description -(error), and missing `og:title`/`og:image` (warning) — plus `seo.schema.absent` (error, no -JSON-LD at all). That is 3 errors + 1 warning = `10*3 + 3*1 = 33` cost, `100 - 33 = 67`. ✓ - -Deriving `aeo 80`: missing answer block (error) and fewer than 3 FAQ pairs (error) — 2 -errors = `10*2 = 20` cost, `100 - 20 = 80`. ✓ - -Deriving `geo 60`: `llms.txt`, `llms-full.txt`, `facts.json`, and `robots.txt` all return -404 — 4 errors = `10*4 = 40` cost, `100 - 40 = 60`. ✓ - -Deriving `perf 100`: `example.com` serves a small, gzip-compressed response with no -render-blocking `<head>` scripts and a fast full-response time from wherever this audit -ran — zero `perf` findings, `100 - 0 = 100`. ✓ - -Deriving `overall 76`: as of 0.2.0 the average is over **four** layers, not three — -`(67 + 80 + 60 + 100) // 4 = 307 // 4 = 76`. ✓ (Floor division truncates the remainder; -it does not round to `77`.) - -To work the list: fix every `[FAIL]` (error) before any `[WARN]` (warning) — errors carry -10x the score weight of warnings. Quote each finding's `fix` text directly when proposing -the change, and reference its `id` (not just `gate`) when tracking one specific -finding across successive runs, since a gate can emit several distinct ids. +Deriving `seo 88`: the page is missing `rel=canonical`, a meta description, +`seo.schema.absent` (no JSON-LD), and `seo.sitemap.missing` — four DISTINCT gates +(`canonical`, `description-length`, `schema`, `sitemap-health`), each an error, `10*4 = +40` raw — plus `og.missing` (warning, `3` raw) on a fifth gate. Cost `43`, `seo`'s +surface is `24` registered scoring gates, denominator `15*24 = 360`: +`(100*43 + 180) // 360 = 4480 // 360 = 12`, `100 - 12 = 88`. ✓ (`seo.link-text.generic` +also fires, but it is `info`-severity and costs nothing — see [Scoring](#scoring).) + +Deriving `aeo 71`: missing answer block (error) and fewer than 3 FAQ pairs (warning) — +two distinct gates, `10 + 3 = 13` cost, over `aeo`'s 3-gate surface (denominator `45`): +`(100*13 + 22) // 45 = 1322 // 45 = 29`, `100 - 29 = 71`. ✓ + +Deriving `geo 47`: `llms.txt`, `llms-full.txt`, `facts.json`, and `robots.txt` +(`ai-allowlist`) all return 404 — four distinct gates, `10*4 = 40` cost, over `geo`'s +5-gate surface (denominator `75`): `(100*40 + 37) // 75 = 4037 // 75 = 53`, +`100 - 53 = 47`. ✓ (`citation-licence`, `geo`'s fifth gate, never fired, so it costs +nothing — but it still counts toward the surface, which is why this isn't `0`.) + +Deriving `perf 100`: `example.com` serves a small response with no render-blocking +`<head>` scripts and a fast full-response time from wherever this audit ran — zero +`perf` findings, `100 - 0 = 100`. ✓ + +Deriving `security 67`: one error, `security.https-redirect.missing` (`example.com`'s +`http://` origin does not redirect to `https://`), `min(GATE_CAP, 10) = 10` cost, over +`security`'s 2-gate surface (denominator `30`): `(100*10 + 15) // 30 = 1015 // 30 = 33`, +`100 - 33 = 67`. ✓ The four `security.hsts.missing`/`nosniff.missing`/`csp.absent`/ +`referrer-policy.missing` findings are all `info` and change nothing. + +Deriving `overall 74`: the average is over **five** layers, not four — +`(88 + 71 + 47 + 100 + 67) // 5 = 373 // 5 = 74`. ✓ (Floor division truncates the +remainder; it does not round to `75`.) + +To work the list: fix every error before any warning — errors carry roughly 3x the +score weight of warnings per gate (`ERROR_COST` `10` vs `WARNING_COST` `3`), and a +distinct broken gate always costs more than a repeat firing of one you have already +fixed. Quote each finding's `fix` text directly when proposing the change, and +reference its `id` (not just `gate`) when tracking one specific finding across +successive runs, since a gate can emit several distinct ids. ## `--fail-on` as a CI gate @@ -459,7 +656,7 @@ gates are safe to gate a build on. Every finding carries a `fixTier` in the JSON report — `mechanical`, `templated`, `drafted`, `advisory` or `infrastructure` — describing what kind of information the correct edit requires. It is a static property of the finding id and is declared for -all 48 ids in `scripts/py/omnirank/registry.py`. It replaces `autoFixable`, which +all 67 ids in `scripts/py/omnirank/registry.py`. It replaces `autoFixable`, which recorded which module a finding lived in rather than whether fixing it was safe. Only the four `mechanical` ids can produce a diff today: `seo.canonical.missing`, diff --git a/docs/ci-integration.md b/docs/ci-integration.md index 924474a..8c891a3 100644 --- a/docs/ci-integration.md +++ b/docs/ci-integration.md @@ -107,21 +107,28 @@ run cleanup after a failure, as shown here. ## Choosing `--fail-on` gates — and why "gate on everything" is a trap Not every gate name in the schema's `audit.failOn` enum can actually cause `--fail-on` to -fail a build, and putting all 29 in the list creates false confidence rather than more +fail a build, and putting all 42 in the list creates false confidence rather than more protection. Recounted directly against every gate's severity in `scripts/py/omnirank/gates/` and against what `audit_site()` actually calls (not just what the schema accepts): -**13 gates are warning-only — listing them in `--fail-on` has zero effect on the exit -code, ever:** `og`, `hreflang`, `image-dims`, `citation-licence`, `lastmod-inflation`, -`faq` (downgraded from error in v0.2.1 — demanding 3+ FAQs on every page, including -pricing and 404 pages, was not defensible advice) from the original gate set, plus the -site-level gates `duplicate-title`, `duplicate-description`, `canonical-cluster`, -`hreflang-reciprocity`, and the `perf` gates `page-weight`, `compression`, -`render-blocking`. Every finding these gates can produce is `severity: "warning"`, and -`has_failures()` only counts `severity == "error"` findings. A CI job gating on `og` will -never fail because of a missing OpenGraph tag — not because your OpenGraph tags are fine, -but because that gate structurally cannot trigger a failure. +**16 gates are warning-only and 4 more are info-only — listing any of these 20 in +`--fail-on` has zero effect on the exit code, ever:** `og`, `hreflang`, `image-dims`, +`citation-licence`, `lastmod-inflation`, `faq` (downgraded from error in v0.2.1 — +demanding 3+ FAQs on every page, including pricing and 404 pages, was not defensible +advice) from the original gate set, the site-level gates `duplicate-title`, +`duplicate-description`, `canonical-cluster`, `hreflang-reciprocity`, the `perf` gates +`page-weight`, `compression`, `render-blocking`, and — new in v0.4.0 — `heading-order`, +`image-alt`, `schema-required` (all warning-only), plus `hsts`, `nosniff`, `csp`, +`referrer-policy` (all info-only: OmniRank reports these as inventory facts about your +security headers, never grades them, and `info` severity costs nothing — see +[audit-guide.md#security-v040](audit-guide.md#security-v040)). `link-text` is the one +exception that mixes: its `.empty` id is `warning` and its `.generic` id is `info`, but +neither is ever `error`, so the gate as a whole still cannot fail a build. Every finding +these 21 gates can produce is `severity: "warning"` or `"info"`, and `has_failures()` +only counts `severity == "error"` findings. A CI job gating on `og` will never fail +because of a missing OpenGraph tag — not because your OpenGraph tags are fine, but +because that gate structurally cannot trigger a failure. **As of v0.2.1, `crawl-hygiene` no longer exists as a `--fail-on` gate name at all.** The check that would have produced it, `hygiene.check_removed()`, is real and tested, but @@ -140,14 +147,18 @@ config-accepted gate name that can never fire, v0.2.1 removed it from entry (warning) from a genuinely dead one (error). Verify this yourself: `omnirank audit https://example.com/nope-xyz --fail-on sitemap-health` exits `1`. -**That leaves 15 gates that can actually produce an error-severity finding and gate a +**That leaves 21 gates that can actually produce an error-severity finding and gate a build:** `h1`, `canonical`, `title-length` (only when the title is *missing*, not when it's over-length), `description-length` (same — missing only), `answer-block`, `speakable`, `llms-txt`, `llms-full`, `facts-json`, `ai-allowlist`, `schema` (most of its checks are errors; a missing `@context` inside a non-`@graph` block is a warning), `schema-fabrication`, `noindex-in-sitemap`, `response-time` (error only once response -time crosses `RESPONSE_ERROR_MS`; its `.slow` finding below that is a warning), and -`sitemap-health` via the code paths above. +time crosses `RESPONSE_ERROR_MS`; its `.slow` finding below that is a warning), +`sitemap-health` via the code paths above, and — new in v0.4.0 — +`lang`, `robots-sitemap`, `canonical-target` (its `.redirects` id is a warning, but +`.noindexed` and `.not-found` are errors), `hreflang-noindex`, `mixed-content` (its +active-subresource id only — the passive-subresource id is a warning, see +[audit-guide.md#security-v040](audit-guide.md#security-v040)), and `https-redirect`. **The practical guidance:** pick gates that map to problems severe enough to block a merge, not the full list. A reasonable starting set for most sites is `h1 canonical @@ -157,10 +168,10 @@ production — this is the check that would have caught the OpenNext/CloudFront described in [geo-artifacts-guide.md](geo-artifacts-guide.md#the-opennextcloudfront-403-trap) before a human noticed). Add `answer-block` once you have deliberately built AEO-oriented pages — gating on it before you have any answer blocks just fails every build (`faq` is -warning-only as of v0.2.1, so it can no longer gate a build even if listed). Leave the 13 -warning-only gates out of `--fail-on` entirely; they add length to the command with no -corresponding protection, and a reviewer skimming a long `--fail-on` list may reasonably -(and incorrectly) assume every listed category is actually enforced. +warning-only as of v0.2.1, so it can no longer gate a build even if listed). Leave the 21 +warning- or info-only gates out of `--fail-on` entirely; they add length to the command +with no corresponding protection, and a reviewer skimming a long `--fail-on` list may +reasonably (and incorrectly) assume every listed category is actually enforced. Findings from gates you did **not** list in `--fail-on` are still computed and still land in the JSON report and terminal summary — they just do not fail the build. Nothing is diff --git a/docs/faq.md b/docs/faq.md index e4d67fe..9824089 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -79,9 +79,11 @@ existing site source files. ### Will OmniRank edit my files? -Not in 0.3.0. `omnirank fix` locates each finding, prints the unified diff it would +Not yet. `omnirank fix` locates each finding, prints the unified diff it would apply, and stops — there is no `--write` flag, and passing one exits `2`. File -modification arrives in v0.4.0. See [fix-preview.md](fix-preview.md). +modification ships once the locator is proven against real repositories and the +write guarantees it depends on are implemented and tested. See +[fix-preview.md](fix-preview.md). ### What's the difference between `audit` and `geo-artifacts`? diff --git a/docs/fix-preview.md b/docs/fix-preview.md index cf66587..5ae10a0 100644 --- a/docs/fix-preview.md +++ b/docs/fix-preview.md @@ -4,9 +4,14 @@ wrong?** It runs an audit, resolves each finding's URL to a source file, and prints the unified diff it would apply. -**It writes nothing.** There is no `--write` flag in 0.3.0. File modification arrives -in v0.4.0, after the locator has been proven in the field. Passing `--write` today -exits `2` with that message rather than silently doing nothing. +`omnirank fix` writes nothing. There is no `--write` flag, and passing one exits 2 +with an explanation rather than silently doing nothing. + +Writing is gated on the locator being proven against real repositories and on the +write guarantees in `docs/research/2026-08-04-automation-architecture.md` §2.5 +being implemented and tested — not on a release number. This release deliberately +spends its budget on audit coverage instead: auditing better is zero-risk, and a +richer audit is what earns the right to edit files later. ```bash omnirank fix https://example.com --root . @@ -27,10 +32,10 @@ of the finding id, in every report: | tier | meaning | count | |---|---|---| | `mechanical` | a constant, or a pure function of data already in the finding | 4 | -| `templated` | deterministic given config and repo facts the tool can read | 15 | -| `drafted` | prose or judgement a human must author or approve | 12 | -| `advisory` | two opposite correct answers exist; only the owner can choose | 11 | -| `infrastructure` | no source edit exists; the fix lives in CDN, origin or build config | 6 | +| `templated` | deterministic given config and repo facts the tool can read | 18 | +| `drafted` | prose or judgement a human must author or approve | 16 | +| `advisory` | two opposite correct answers exist; only the owner can choose | 16 | +| `infrastructure` | no source edit exists; the fix lives in CDN, origin or build config | 12 | **`applicability` — whether THIS occurrence may be applied unattended.** Computed per finding-instance as the minimum of four ceilings: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8427196..cb94ffc 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -148,13 +148,19 @@ Exit code `1`. Read the finding lines above `failOn gates:` — every `[FAIL]` l Apply the `fix` text for the flagged gate(s) and re-run. If a gate you listed in `--fail-on` never seems to go red no matter what you do, check -whether it is one of the 13 gates that only ever produce warning-severity findings -(`og`, `hreflang`, `image-dims`, `citation-licence`, `lastmod-inflation`, `faq` -(downgraded from error in v0.2.1), `duplicate-title`, `duplicate-description`, -`canonical-cluster`, `hreflang-reciprocity`, `page-weight`, `compression`, -`render-blocking`). `crawl-hygiene` no longer exists as a `--fail-on` gate name at all as -of v0.2.1 — it was removed from the schema because its dedicated check needs a -removed-URL list no config field supplies, so it could never trigger exit code `1`. +whether it is one of the 21 (of 42 total) gates that can never produce an +error-severity finding: 16 +that only ever produce warning findings (`og`, `hreflang`, `image-dims`, +`citation-licence`, `lastmod-inflation`, `faq` (downgraded from error in v0.2.1), +`duplicate-title`, `duplicate-description`, `canonical-cluster`, `hreflang-reciprocity`, +`page-weight`, `compression`, `render-blocking`, and — new in v0.4.0 — `heading-order`, +`image-alt`, `schema-required`); 4 that only ever produce info findings (`hsts`, +`nosniff`, `csp`, `referrer-policy`, all new in v0.4.0 — OmniRank reports these as +inventory facts about your security headers and never grades them); and `link-text` +(`.empty` is warning, `.generic` is info, neither is ever error). `crawl-hygiene` no +longer exists as a `--fail-on` gate name at all as of v0.2.1 — it was removed from the +schema because its dedicated check needs a removed-URL list no config field supplies, so +it could never trigger exit code `1`. `sitemap-health` is not inert: an unreachable target is reported as an error under `gate: "sitemap-health"`, and as of v0.2.1 `hygiene.check_sitemap()` is also wired in, distinguishing a redirecting sitemap entry (warning) from a genuinely dead one (error). diff --git a/docs/wiki/Audit-Skill.md b/docs/wiki/Audit-Skill.md index 012ea39..000da8d 100644 --- a/docs/wiki/Audit-Skill.md +++ b/docs/wiki/Audit-Skill.md @@ -1,9 +1,10 @@ # Audit Skill -The audit skill scores a site across four content layers — SEO, AEO, GEO and perf — plus -a site-level cross-URL pass and sitemap hygiene, returning findings that carry an -observed value, an expected value, a fix and a `fixTier`. It never edits the site; it -only diagnoses, then hands the list to a human, CI job, or `omnirank fix`. +The audit skill 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 +sitemap hygiene, returning findings that carry an observed value, an expected value, a +fix and a `fixTier`. It never edits the site; it only diagnoses, then hands the list to a +human, CI job, or `omnirank fix`. Everything on this page is verified against `scripts/py/omnirank/audit.py`, `scripts/py/omnirank/gates/`, `scripts/py/omnirank/report.py`, and `skills/audit/`. @@ -18,7 +19,7 @@ phrases and what will *not* trigger it: [[Claude-Code-Setup#what-should-i-say-to ## CLI reference -Verbatim `--help` output from v0.3.0: +Verbatim `--help` output, unchanged since v0.3.0: ``` $ python3 -m omnirank.cli audit --help @@ -68,20 +69,24 @@ Two behaviours worth being precise about: silently truncated. `--detail` keeps the historical ungrouped, 25-finding-capped view. The JSON report is unaffected by either flag: it always holds every finding individually. -## What are the four layers? +## What are the five layers? | 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, 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 | | GEO | ChatGPT, Claude, Perplexity, Gemini | Machine-ingestible ground truth (`llms.txt`, `facts.json`) plus explicit permission to cite | | perf | Every crawler and user agent | Fast full response time, reasonable HTML weight, compression, and no excess render-blocking `<head>` scripts — derived from one HTTP response, no browser involved | +| security | Browsers rendering the page | No mixed content and an http→https redirect; response headers reported as inventory, never graded (v0.4.0) | -Crawl hygiene and the site-level cross-URL pass both carry `layer: "seo"` in the report — -there is no separate `"hygiene"` or `"site"` value. `report.py` defines `Layer = -Literal["seo", "aeo", "geo", "offsite", "smm", "perf"]`. **`perf` is new in v0.2.0** and is -now 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. +Crawl hygiene, the site-level cross-URL pass, and the indexability-contradictions pass all +carry `layer: "seo"` in the report — there is no separate `"hygiene"`, `"site"` or +`"contradictions"` value. `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 +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. 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 @@ -92,7 +97,9 @@ pass below possible. Every finding carries a `gate` (what `--fail-on` matches against), an `id` (a stable, dotted identifier), and a `fixTier` (`mechanical`/`templated`/`drafted`/`advisory`/ `infrastructure`, described in [[Fix-Tiers-and-Applicability]]). For the complete, -registry-generated table of all 48 ids see [[Finding-Reference]]. +registry-generated table of all 67 ids see [[Finding-Reference]]. 42 distinct gate names +exist in `audit.failOn`'s schema enum; only 21 of them are error-capable and can actually +gate a build — see [[CI-Recipes#which-gates-can-actually-fail-a-build-with---fail-on]]. ### SEO gates @@ -183,6 +190,39 @@ and `rel=` attribute VALUES case-insensitively, closing five confirmed false pos `application/LD+JSON` and `application/ld+json; charset=utf-8` are now recognised correctly. The shared comparison lives in `omnirank/html.py`. +`schema-required` (v0.4.0, `warning`) checks Google's rich-result requirements, not +schema.org's — schema.org marks no property required at all, so a node can be valid +schema.org and still miss a rich result. Every +`seo.schema-required.missing-property` finding names the property and the type +(Article/NewsArticle/BlogPosting, Product, FAQPage, BreadcrumbList, Organization, +LocalBusiness) it applies to. + +### Security (v0.4.0) + +`security.run_page(page)` derives four `info`-severity header gates plus the +`mixed-content` gate from each fetched page; `security.check_https_redirect()` makes one +extra request per audit against the site's `http://` origin. All findings carry +`layer: "security"`. Scope is deliberately narrow: OmniRank checks security only where +insecurity demonstrably breaks crawling, indexing or rendering — Mozilla Observatory and +testssl.sh already grade headers properly. + +| Gate | Finding id | Rule | Severity | +|---|---|---|---| +| `hsts` | `security.hsts.missing` / `.short-max-age` | `Strict-Transport-Security` present, `max-age` ≥ 180 days | info | +| `nosniff` | `security.nosniff.missing` | `X-Content-Type-Options: nosniff` present | info | +| `csp` | `security.csp.absent` | A `Content-Security-Policy` exists, by header or meta | info | +| `referrer-policy` | `security.referrer-policy.missing` | A `Referrer-Policy` header exists | info | +| `mixed-content` | `security.mixed-content.subresource` | An `https://` page requests a BLOCKABLE subresource (`script`, `iframe`, `link rel=stylesheet\|preload\|modulepreload`) over `http://` | **error** | +| `mixed-content` | `security.mixed-content.passive-subresource` | An `https://` page requests a passive subresource (`img`, favicon-family `link`) over `http://` — browsers auto-upgrade these rather than block them | warning | +| `https-redirect` | `security.https-redirect.missing` | The site's `http://` origin 3xx-redirects to `https://` | **error** | + +Only `mixed-content` (active id) and `https-redirect` are `error` — the four header +gates are reported as inventory facts and never graded, and the passive-subresource id +is `warning` because OmniRank cannot confirm the browser's silent https upgrade failed. +`security` joins `layersRun` the same way `aeo`/`perf` do: only after at least one page +was fetched, never on a clean `https-redirect` probe alone. A genuine `https-redirect` +finding still reaches the score map on its own merits regardless of `layersRun`. + ### Site-level (cross-URL) gates — new in v0.2.0 `site.run(pages, sitemap_urls)` needs the whole crawled set at once, so `audit_site()` @@ -204,6 +244,27 @@ either way. `noindex-in-sitemap` no longer fires on a full hreflang cluster (loc that correctly declare each other as alternates) — hreflang exists precisely to stop engines consolidating those, so flagging them as duplicates was backwards. +### Indexability contradictions (v0.4.0) + +`gates/contradictions.py` — defects provable from the site's own declarations, with no +external truth required. Every finding is 100% precision because both halves of the +contradiction come from the site itself, and every one carries `layer: "seo"`. + +| Gate | Finding id | Rule | Severity | +|---|---|---|---| +| `robots-sitemap` | `seo.robots-sitemap.disallowed` | A sitemap URL is also `Disallow`-ed to `Googlebot` in `robots.txt` | **error** | +| `canonical-target` | `seo.canonical-target.noindexed` | A canonical points at a URL carrying `noindex` | **error** | +| `canonical-target` | `seo.canonical-target.not-found` | A canonical target returns `404`/`410` | **error** | +| `canonical-target` | `seo.canonical-target.redirects` | A canonical target itself 3xx-redirects | warning | +| `hreflang-noindex` | `seo.hreflang-noindex.alternate` | A declared `hreflang` alternate carries `noindex` (attaches to the declaring page; `x-default` exempt) | **error** | + +`robots-sitemap` only judges sitemap URLs on robots.txt's OWN host — a sitemap index +listing another host's URLs (a CDN, a blog subdomain) gets those reported +`not-applicable` in `notEvaluated` rather than judged by a robots.txt that never governed +them. `matcher-unsupported` is real on Python 3.11–3.13: `urllib.robotparser` only became +RFC 9309 compliant in 3.14, so OmniRank refuses to answer rather than trust a matcher it +knows may be wrong — see `omnirank/robots.py`. + ### Performance gates (`perf` layer) — new in v0.2.0 `perf.run(page)` derives every finding from the single HTTP response already captured for @@ -226,6 +287,26 @@ and never shipped in a release under the old name. Treat every `perf` finding as to investigate from wherever OmniRank's own request ran (a laptop, a CI runner), never as a metric a real visitor experienced. Full detail: `docs/audit-guide.md#performance-perf-layer`. +### On-page (v0.4.0) + +`gates/onpage.py` — body markup search engines and assistive technology both read, kept +out of `seo.py` (which owns only `<head>` indexing signals). Every finding carries +`layer: "seo"`. + +| Gate | Finding id | Rule | Severity | +|---|---|---|---| +| `image-alt` | `seo.image-alt.missing` | An `<img>` has no `alt` attribute at all (`alt=""` is never flagged) | warning | +| `heading-order` | `seo.heading-order.skipped` | The outline jumps more than one level deeper (only the first skip is reported) | warning | +| `link-text` | `seo.link-text.empty` | A link has no accessible name | warning | +| `link-text` | `seo.link-text.generic` | A link's accessible name is a generic phrase ("click here", …) | info | +| `lang` | `seo.lang.missing` | `<html>` has no non-empty `lang` attribute | **error** | + +`lang` is `error` because a missing `lang` makes a DIFFERENT gate lie: `page.lang` feeds +`bands.resolve_band()`, and with no `lang` a CJK page gets measured against a +space-delimited word band it structurally cannot meet. `link-text.generic`'s word list +(`GENERIC_ANCHORS`) is English-only by construction, so it is `info` and silent-by-design +on other languages. Links inside `<nav>` are exempt from both `link-text` findings. + ### Sitemap `lastmod` inflation If **more than** 90% of at least 10 `<lastmod>` entries in `sitemap.xml` share one date, @@ -281,71 +362,105 @@ WARNING_COST = 3 GATE_CAP = 15 ``` -**Per gate, then per layer, as of v0.2.1.** Each GATE's contribution to its layer is -capped first — `min(GATE_CAP, 10*errors + 3*warnings)` — and the layer's score is -`max(0, 100 - sum(capped gate costs))`. `info`-severity findings cost nothing. +**Per gate, then per layer, normalised by the layer's own surface (v0.4.0).** Each +GATE's contribution to its layer is capped first — `min(GATE_CAP, 10*errors + +3*warnings)` — and those capped costs are summed and divided by the layer's own scoring +surface, not subtracted from a flat 100: + +```python +surface = max(1, scoring_gate_count(layer), gates_actually_seen) +denominator = GATE_CAP * surface +penalty = (100 * capped_cost_sum + denominator // 2) // denominator # round-half-up +score = max(0, 100 - penalty) +``` -This replaced a flat per-finding cost with no cap. On a real 57-URL site, one gate -failing on every page (a missing `<h1>` on every template) used to cost `570` points -against a `100`-point layer, saturating `seo` and `aeo` to `0` with nothing left to act -on. One gate firing on every page is one problem to fix, not fifty — a distinct SECOND -broken gate still adds its own capped cost, so a site with many different problems still -scores worse than one with a single frequently-firing one. +`scoring_gate_count(layer)` (`registry.py`) excludes gates whose every id is `info` +(the four security header gates) and unreachable gates (`crawl-hygiene`) — counting a +gate that can never cost anything would put a floor under the layer's score. Before +v0.4.0 the budget was a flat 100 regardless of gate count, so seven maxed gates zeroed a +layer whether it had seven gates or thirty; the division fixes that and also removes the +unreachable floor a small layer like `security` (2 scoring gates) used to have. One +maxed gate now always costs exactly `1/surface` of its layer, so a layer floors to `0` +only when **every one of its registered gates** is maxed. + +**Adding a finding can never raise a score — and a real bug was fixed against that +guarantee (final v0.4.0 review, B2).** `seen_gates[layer].add(gate)` used to run for +every gate with a finding, including `info`-only ones, which widened `surface` for free +and let a harmless `info` finding RAISE a layer's score. Verified directly against +`https://danluu.com`: `security` scored `87` before the fix and the correct `67` after +it, with the identical findings present the whole time. Fixed by only widening the +surface when a gate's raw cost is nonzero; a 40,000-trial randomised property test now +asserts adding any finding never raises any layer's score or `overall`. **Overall:** the integer floor-division average of every layer's score — `sum(scores.values()) // len(scores)`. Not a rounded mean. -**`layers_run`.** `seo` and `geo` are marked run unconditionally at the start of -`audit_site()`. `aeo` and `perf` are only marked run **after** at least one page was -actually fetched — if zero pages parse, they are absent from the score map entirely, -never a fabricated `100`. A plain `omnirank audit` against a reachable site reports all -four layers; an `overall` divisor other than 4 is the signal at least one layer never ran. +**`layers_run`.** A layer that produced an actual finding reaches the score map +regardless of `layers_run` — `layers_run` only controls whether a CLEAN layer (zero +findings) is reported `100` or omitted. `seo` and `geo` are marked run unconditionally at +the start of `audit_site()`. `aeo`, `perf` and `security` are only marked run **after** +at least one page was actually fetched — if zero pages parse, all three are absent from +the score map entirely, never a fabricated `100`. (Before the final v0.4.0 review fixed +it, `security` was also admitted on a clean `https-redirect` probe alone — verified +against a real report, `rust-lang.org` showed `security: 100` with `urlsChecked: 1` and 0 +pages parsed.) A plain `omnirank audit` against a reachable site reports all five layers; +an `overall` divisor other than 5 is the signal at least one layer never ran. -**The unreachable-URL edge case,** reproduced directly: +**The unreachable-URL edge case,** reproduced directly, showing the `layers_run` +exception above: ``` $ python3 -m omnirank.cli audit https://example.com/nope-xyz -OmniRank 0.2.1 — https://example.com/nope-xyz - overall 72/100 geo 60 seo 85 - 1 URLs checked · 6 findings in 6 groups +OmniRank 0.4.0 — https://example.com/nope-xyz + overall 70/100 geo 47 security 67 seo 96 + 1 URLs checked · 7 findings in 7 groups - NOT EVALUATED (4 gate(s) across 2 target(s) — see the JSON report for the reason enum) - site — https://example.com/nope-xyz [no-sitemap] - aeo, perf, seo — https://example.com/nope-xyz/ [page-unreachable] + NOT EVALUATED (7 gate(s) across 2 target(s) — see the JSON report for the reason enum) + robots-sitemap, site — https://example.com/nope-xyz [no-sitemap] + aeo, onpage, perf, security, seo — https://example.com/nope-xyz/ [page-unreachable] ``` -Neither `aeo` nor `perf` appears in the score map — they never touched any content, so -they are absent rather than a false `100`. The console's `NOT EVALUATED` section (new in -v0.2.1) makes this visible without opening the JSON. Read `seo.page.unreachable` as the -signal that the whole run is unreliable, regardless of what the other layers show. +Neither `aeo`, `perf` nor `onpage` appears in the score map — zero pages were parsed, so +none touched any content. `security` DOES appear, at `67`, even though it too is listed +in `NOT EVALUATED` for this URL: `check_https_redirect()` is a site-level probe that ran +anyway and found a real `security.https-redirect.missing` error, and that genuine +finding reaches the score map on its own merits regardless of `layers_run`. Read +`seo.page.unreachable` as the signal that the whole run is unreliable, regardless of what +the other layers show. ## Worked example -Running the CLI against `https://example.com` with no config: +Running the CLI against `https://example.com` with no config — real output, captured on +v0.4.0: ``` -OmniRank 0.3.0 — https://example.com - overall 76/100 aeo 80 geo 60 perf 100 seo 67 - 1 URLs checked, 10 findings +OmniRank 0.4.0 — https://example.com + overall 74/100 aeo 71 geo 47 perf 100 security 67 seo 88 + 1 URLs checked · 17 findings in 17 groups ``` -- `seo 67`: missing `rel=canonical` (error), missing meta description (error), missing - `og:title`/`og:image` (warning), no JSON-LD at all (error) = 3 errors + 1 warning = - `10*3 + 3*1 = 33` cost, `100 - 33 = 67`. -- `aeo 80`: missing answer block (error) and fewer than 3 FAQ pairs — as of v0.2.1 that - second one is a warning, not an error, so this example (captured pre-v0.2.1 wording) - still nets 2 error-equivalent findings on a page with no answer block content at all; - see [[Report-Schema]] for the current severities. -- `geo 60`: `llms.txt`, `llms-full.txt`, `facts.json`, and `robots.txt` all 404 = 4 errors - = `10*4 = 40` cost, `100 - 40 = 60`. -- `perf 100`: a small, gzip-compressed response with no render-blocking scripts and a fast - full-response time = zero findings, `100 - 0 = 100`. -- `overall 76`: as of 0.2.0 the average is over **four** layers, not three — - `(67 + 80 + 60 + 100) // 4 = 307 // 4 = 76`. - -Work the list by fixing every `[FAIL]` (error) before any `[WARN]` (warning) — errors -carry 10x the score weight of warnings, and reference `id`, not just `gate`, when -tracking one specific finding across runs. +- `seo 88`: missing `rel=canonical`, meta description, JSON-LD (`schema.absent`) and + `sitemap.missing` — four distinct error gates, `10*4 = 40` cost — plus `og.missing` + (warning, `3`) on a fifth. Cost `43` over `seo`'s 24-gate surface (denominator `360`): + `(4300 + 180) // 360 = 12`, `100 - 12 = 88`. +- `aeo 71`: missing answer block (error) and fewer than 3 FAQ pairs (warning) — two + gates, `13` cost, over `aeo`'s 3-gate surface (denominator `45`): + `(1300 + 22) // 45 = 29`, `100 - 29 = 71`. +- `geo 47`: `llms.txt`, `llms-full.txt`, `facts.json`, and `robots.txt` (`ai-allowlist`) + all 404 — four gates, `40` cost, over `geo`'s 5-gate surface (denominator `75`): + `(4000 + 37) // 75 = 53`, `100 - 53 = 47`. +- `perf 100`: a small response with no render-blocking scripts and a fast full-response + time = zero findings, `100 - 0 = 100`. +- `security 67`: one error, `security.https-redirect.missing` (`example.com`'s `http://` + origin does not redirect), `10` cost over `security`'s 2-gate surface (denominator + `30`): `(1000 + 15) // 30 = 33`, `100 - 33 = 67`. The four `info`-severity header + findings change nothing. +- `overall 74`: the average is over **five** layers, not four — + `(88 + 71 + 47 + 100 + 67) // 5 = 373 // 5 = 74`. + +Work the list by fixing every error before any warning — errors carry roughly 3x the +score weight of warnings per gate, and reference `id`, not just `gate`, when tracking one +specific finding across runs. ## Using `--fail-on` as a CI gate @@ -361,7 +476,7 @@ build on. Every finding also carries a `fixTier` (`mechanical`, `templated`, `drafted`, `advisory` or `infrastructure`), a static property of the finding id declared in -`scripts/py/omnirank/registry.py` for all 48 ids. It replaced `autoFixable`, which +`scripts/py/omnirank/registry.py` for all 67 ids. It replaced `autoFixable`, which recorded which module a finding lived in rather than whether fixing it was safe. Only the four `mechanical` ids can produce a diff today. See [[Fix-Tiers-and-Applicability]] and [[Fix-Preview]]. diff --git a/docs/wiki/CI-Recipes.md b/docs/wiki/CI-Recipes.md index 66f56a6..c57c1d9 100644 --- a/docs/wiki/CI-Recipes.md +++ b/docs/wiki/CI-Recipes.md @@ -109,14 +109,16 @@ run cleanup after a failure, as shown here. ## Which gates can actually fail a build with `--fail-on`? Not every gate name in the schema's `audit.failOn` enum can actually cause `--fail-on` to -fail a build, and putting all 28 in the list creates false confidence rather than more +fail a build, and putting all 42 in the list creates false confidence rather than more protection. Verified directly against every gate's severity in `scripts/py/omnirank/gates/`: | Group | Gates | Effect on `--fail-on` | |---|---|---| -| Warning-only (13) | `og`, `hreflang`, `image-dims`, `citation-licence`, `lastmod-inflation`, `faq` (downgraded from error in v0.2.1), `duplicate-title`, `duplicate-description`, `canonical-cluster`, `hreflang-reciprocity`, `page-weight`, `compression`, `render-blocking` | Every finding these gates can produce is `severity: "warning"`; `has_failures()` only counts errors. Listing them has zero effect on the exit code, ever. | +| Warning-only (16) | `og`, `hreflang`, `image-dims`, `citation-licence`, `lastmod-inflation`, `faq` (downgraded from error in v0.2.1), `duplicate-title`, `duplicate-description`, `canonical-cluster`, `hreflang-reciprocity`, `page-weight`, `compression`, `render-blocking`, `heading-order`, `image-alt`, `schema-required` (last three new in v0.4.0) | Every finding these gates can produce is `severity: "warning"`; `has_failures()` only counts errors. Listing them has zero effect on the exit code, ever. | +| Info-only (4, all new in v0.4.0) | `hsts`, `nosniff`, `csp`, `referrer-policy` | Every finding these gates can produce is `severity: "info"`. OmniRank reports your security headers as inventory facts and never grades them — see [[Audit-Skill#security-v040]]. | +| Mixed, but never error (1, new in v0.4.0) | `link-text` | Its `.empty` id is `warning`, its `.generic` id is `info` — neither is ever `error`, so the gate as a whole cannot gate a build. | | Removed from the enum entirely (v0.2.1) | `crawl-hygiene` | Its dedicated check (`hygiene.check_removed()`) needs a removed-URL list no config field supplies, so it could never fire from a plain run — v0.2.1 dropped it from the schema rather than ship a dead gate name. | -| Can actually fail a build (15) | `h1`, `canonical`, `title-length` (missing only), `description-length` (missing only), `answer-block`, `speakable`, `llms-txt`, `llms-full`, `facts-json`, `ai-allowlist`, `schema`, `schema-fabrication`, `noindex-in-sitemap`, `response-time`, `sitemap-health` | These can produce an error-severity finding and gate a build | +| Can actually fail a build (21) | `h1`, `canonical`, `title-length` (missing only), `description-length` (missing only), `answer-block`, `speakable`, `llms-txt`, `llms-full`, `facts-json`, `ai-allowlist`, `schema`, `schema-fabrication`, `noindex-in-sitemap`, `response-time`, `sitemap-health`, and — new in v0.4.0 — `lang`, `robots-sitemap`, `canonical-target` (its `.redirects` id is a warning; `.noindexed`/`.not-found` are errors), `hreflang-noindex`, `mixed-content` (active-subresource id only), `https-redirect` | These can produce an error-severity finding and gate a build | **The practical guidance:** pick gates that map to problems severe enough to block a merge, not the full list. A reasonable starting set for most sites is `h1 canonical @@ -125,8 +127,8 @@ schema` (structural SEO baseline) plus, once `geo-artifacts` is wired into your production — this is the check that would have caught the OpenNext/CloudFront 403 trap described in [[GEO-Artifacts-Skill#what-is-the-opennextcloudfront-403-trap]] before a human noticed). Add `answer-block` once you have deliberately built AEO-oriented pages — -gating on it before you have any answer blocks just fails every build. Leave the 13 -warning-only gates out of `--fail-on` entirely. +gating on it before you have any answer blocks just fails every build. Leave the 21 +warning- or info-only gates out of `--fail-on` entirely. Findings from gates you did **not** list in `--fail-on` are still computed and still land in the JSON report and terminal summary — they just do not fail the build. Nothing is diff --git a/docs/wiki/Claude-Code-Setup.md b/docs/wiki/Claude-Code-Setup.md index 1db626e..f23f239 100644 --- a/docs/wiki/Claude-Code-Setup.md +++ b/docs/wiki/Claude-Code-Setup.md @@ -58,7 +58,7 @@ ls ~/.claude/plugins/omnirank/skills/geo-artifacts/SKILL.md All three must exist. If `.claude-plugin/plugin.json` is missing, the ZIP was unzipped to the wrong location or the clone did not complete; if a `SKILL.md` is missing, re-download -or re-clone. `plugin.json`'s `"version"` field reads `"0.3.0"` on a current checkout. +or re-clone. `plugin.json`'s `"version"` field reads `"0.4.0"` on a current checkout. **Confirm each skill's identity by reading its frontmatter** (what Claude Code actually parses to register the skill and decide when to trigger it): diff --git a/docs/wiki/Configuration-Reference.md b/docs/wiki/Configuration-Reference.md index 9f64596..e8b839e 100644 --- a/docs/wiki/Configuration-Reference.md +++ b/docs/wiki/Configuration-Reference.md @@ -186,13 +186,17 @@ Free-form string map. **Schema-only** — validated, not read. | `sampleSize` | integer, minimum `0` | no | `200` | Maximum URLs pulled from the sitemap for a crawl. `0` means no limit. | | `failOn` | array of gate-name enum values | no | `[]` | Gate names that make `omnirank audit` exit `1` when they carry an error-severity finding. Overridden by `--fail-on` whenever that flag is present at all, even with zero names. | -`failOn`'s allowed values grew to **28 gate names** as of v0.2.0/v0.2.1: `h1`, +`failOn`'s allowed values grew to **42 gate names** as of v0.4.0: `h1`, `canonical`, `title-length`, `description-length`, `hreflang`, `og`, `image-dims`, `answer-block`, `faq`, `speakable`, `llms-txt`, `llms-full`, `facts-json`, `ai-allowlist`, `citation-licence`, `sitemap-health`, `lastmod-inflation`, `schema`, `schema-fabrication`, `duplicate-title`, `duplicate-description`, `noindex-in-sitemap`, `canonical-cluster`, `hreflang-reciprocity`, `response-time`, `page-weight`, -`compression`, `render-blocking`. +`compression`, `render-blocking` (28 through v0.3.0), plus 14 in v0.4.0: `hsts`, +`nosniff`, `csp`, `referrer-policy`, `mixed-content`, `https-redirect` (`security`), +`robots-sitemap`, `canonical-target`, `hreflang-noindex` (indexability contradictions), +`schema-required` (structured data), and `image-alt`, `heading-order`, `link-text`, +`lang` (on-page). **`crawl-hygiene` was removed from this enum in v0.2.1** — it validated successfully but matched no finding a plain `omnirank audit` run could ever produce, since the check that @@ -200,6 +204,11 @@ would emit it needs an explicit removed-URL list no config field supplies. `site is not inert: `hygiene.check_sitemap()` is wired in as of v0.2.1, distinguishing a redirecting sitemap entry (warning) from a dead one (error). +Only **21 of the 42** can actually produce an error-severity finding and gate a build — +see [[CI-Recipes#which-gates-can-actually-fail-a-build-with---fail-on]] for the full +breakdown, including the four v0.4.0 security gates that are `info`-severity and can +never fail a build regardless of what you list. + ## `smm` | Field | Type | Description | diff --git a/docs/wiki/FAQ.md b/docs/wiki/FAQ.md index d1d92e5..fa732cc 100644 --- a/docs/wiki/FAQ.md +++ b/docs/wiki/FAQ.md @@ -29,12 +29,13 @@ or `fix` code paths. ### Will OmniRank edit my files? -Not in v0.3.0, and not automatically ever from the `audit` skill. `omnirank fix` locates +Not automatically, ever, from the `audit` skill. `omnirank fix` locates each finding, prints the unified diff it would apply, and stops — **there is no `--write` -flag**, and passing one exits `2` with a message naming v0.4.0, before any network call. -File modification is planned for v0.4.0, after the locator described in [[The-Locator]] -has been proven in the field. See [[Fix-Preview]] for the full behaviour and a worked -example of its output. +flag**, and passing one exits `2` with an explanation, before any network call. +File modification ships once the locator described in [[The-Locator]] +has been proven against real repositories and the write guarantees it depends on are +implemented and tested — not on a release number. See [[Fix-Preview]] for the full +behaviour and a worked example of its output. ### Is `llms.txt` a real, established standard? diff --git a/docs/wiki/Fix-Preview.md b/docs/wiki/Fix-Preview.md index d6602b1..6c50b56 100644 --- a/docs/wiki/Fix-Preview.md +++ b/docs/wiki/Fix-Preview.md @@ -2,7 +2,7 @@ `omnirank fix` answers the question no URL-keyed SEO tool can: which file is wrong? It audits a site, resolves each finding's URL to a source file, and prints the unified diff -it would apply. It writes nothing — there is no `--write` flag in 0.3.0, and passing one +it would apply. It writes nothing — there is no `--write` flag, and passing one exits before any network call runs. Verified against `scripts/py/omnirank/cli.py`, `scripts/py/omnirank/fixes/`, and a real @@ -29,7 +29,7 @@ omnirank fix --config omnirank.config.json --json | `--root PATH` | Repository root to locate findings in. Default: `.` | | `--json` | Emit the fix plan as structured JSON, including an explicit `"wrote": []` | | `--top N` | Limit the grouped "NOT FIXED" summary to the top N groups (default: all) | -| `--write` | **Not available in 0.3.0.** Exits `2` with an explanation, before any network call | +| `--write` | **Not available.** Exits `2` with an explanation, before any network call | Exit codes: `0` nothing to fix · `1` at least one diff was produced (gate CI on this, distinct from `audit`'s own `--fail-on` exit code) · `2` usage or config error, including @@ -97,7 +97,7 @@ OmniRank 0.3.0 — fix preview (writes nothing) [1×] pricing/index.html has no </head> to insert before e.g. seo.canonical.missing http://127.0.0.1:8791/pricing/ - This release writes nothing. --write arrives in v0.4.0. + This release writes nothing; --write is not available. ``` Exit code `1`, since two diffs were produced. Three real things happened here, worth @@ -116,25 +116,27 @@ naming individually: rather than guessing where `<head>` was meant to end. This is the applicability model working as designed: a plausible insertion point is not the same as a correct one. -## Does `omnirank fix` write anything, ever, in 0.3.0? +## Does `omnirank fix` write anything, ever? No. Not with any flag, any config, or any combination of the two. Passing `--write` prints a refusal and exits `2` before `load_config()` or any network call runs: ``` $ python3 -m omnirank.cli fix http://127.0.0.1:8791 --root scripts/fixtures/demo-site --write -omnirank: omnirank fix has no --write path in 0.3.0. This release locates findings and -prints the diff it would apply; it modifies nothing. File modification arrives -in v0.4.0, behind the write guarantees in -docs/research/2026-08-04-automation-architecture.md section 2.5. Shipping ---write as a no-op would be worse than not shipping it. +omnirank: omnirank fix has no --write path. This release locates findings and prints +the diff it would apply; it writes nothing. Writing ships once the locator has been +proven against real repositories and the write guarantees in +docs/research/2026-08-04-automation-architecture.md section 2.5 are implemented and +tested -- not on a release number. Shipping --write as a no-op, or before those +guarantees exist, would be worse than not shipping it. ``` `fixes/base.py` states the same guarantee in its own module docstring: "NOTHING IN THIS PACKAGE OPENS A FILE FOR WRITING." A test in the repository's own suite asserts it -directly. File modification is planned for v0.4.0, safe-tier only, behind the write -guarantees (git-dirty checks, a journal, `omnirank undo`) described in -`docs/research/2026-08-04-automation-architecture.md`. +directly. File modification ships once the locator is proven against real repositories +and the write guarantees (git-dirty checks, a journal, `omnirank undo`) described in +`docs/research/2026-08-04-automation-architecture.md` are implemented and tested — not +on a release number. ## What is fixable today, and why so little? diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 616af3b..3b024ed 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -92,9 +92,11 @@ Full walkthrough with real pasted output, exit codes, and what to do if a step f send, never dispatched automatically. - **It does not generate AI slop.** Every generated page expects a human edit pass before publishing. -- **It does not edit your site, even in 0.3.0.** `audit` only diagnoses; `geo-artifacts` +- **It does not edit your site.** `audit` only diagnoses; `geo-artifacts` writes only the three GEO artifacts it generates; `omnirank fix` prints a diff and - writes nothing — there is no `--write` flag until v0.4.0. + writes nothing — there is no `--write` flag. Writing ships once the locator is proven + against real repositories and the write guarantees it depends on are implemented and + tested, not on a release number. See the project README's ["What OmniRank does not do"](https://github.com/bemoshiur/OmniRank#what-omnirank-does-not-do) section for the complete list, and [[FAQ]] for the honest, uncomfortable questions diff --git a/docs/wiki/Quick-Start.md b/docs/wiki/Quick-Start.md index e48cb98..e662362 100644 --- a/docs/wiki/Quick-Start.md +++ b/docs/wiki/Quick-Start.md @@ -160,7 +160,7 @@ Full model and worked example: [[Fix-Preview]]. |---|---| | `0` | Clean — no `--fail-on` gate had an error-severity finding (`audit`); nothing to fix (`fix`); artifacts written (`geo`) | | `1` | At least one gate in `--fail-on` had an error-severity finding (`audit`); at least one diff was produced (`fix`) | -| `2` | Usage or configuration error, including passing `fix --write`, which does not exist in 0.3.0 | +| `2` | Usage or configuration error, including passing `fix --write`, which does not exist | The run above returned `0` because no `--fail-on` gates were specified. diff --git a/docs/wiki/Roadmap.md b/docs/wiki/Roadmap.md index 8a0200a..0c1b66e 100644 --- a/docs/wiki/Roadmap.md +++ b/docs/wiki/Roadmap.md @@ -39,8 +39,10 @@ archive attached to every release. Beyond v1.0: framework adapters for WordPress, Jekyll, Shopify, Astro, Vue and Svelte gain their own locators, alongside `next-app-router`, `static`, `hugo` and `jekyll`, which the locator already resolves as of v0.3.0 — see [[The-Locator]]. File-write -capability (`omnirank fix --write`) is targeted at v0.4.0, safe-tier only, described in -`docs/research/2026-08-04-automation-architecture.md`. +capability (`omnirank fix --write`), safe-tier only, ships once the locator is proven +against real repositories and the write guarantees described in +`docs/research/2026-08-04-automation-architecture.md` are implemented and tested — not +on a release number. ## Why the targets above have already slipped diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index b3c319f..9845918 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -37,20 +37,21 @@ that interpreter. [[Quick-Start#2-create-a-virtual-environment]]. Do not add `--break-system-packages` to work around it — that flag disables the exact protection this error exists to provide. -## `omnirank: <write> has no --write path in 0.3.0` +## `omnirank: <write> has no --write path` ``` $ python3 -m omnirank.cli fix https://example.com --write -omnirank: omnirank fix has no --write path in 0.3.0. This release locates findings and -prints the diff it would apply; it modifies nothing. File modification arrives -in v0.4.0, behind the write guarantees in -docs/research/2026-08-04-automation-architecture.md section 2.5. Shipping ---write as a no-op would be worse than not shipping it. +omnirank: omnirank fix has no --write path. This release locates findings and prints +the diff it would apply; it writes nothing. Writing ships once the locator has been +proven against real repositories and the write guarantees in +docs/research/2026-08-04-automation-architecture.md section 2.5 are implemented and +tested -- not on a release number. Shipping --write as a no-op, or before those +guarantees exist, would be worse than not shipping it. ``` Exit code `2`, printed and refused **before any network call** — even before `load_config()` runs. There is nothing to fix in your setup here; `--write` genuinely does not exist yet. -Drop the flag and read the printed diff, or wait for v0.4.0. See [[Fix-Preview]]. +Drop the flag and read the printed diff. See [[Fix-Preview]]. ## `omnirank: Config not found: <path>` @@ -246,7 +247,7 @@ just the four it could generate a diff for. Common reasons, verbatim from |---|---| | "no source file was located" | The locator returned `NOT_LOCATED` for this URL — see [[The-Locator]] | | "the located file serves N routes; a literal canonical there would make every one of them claim the same URL" | Blast radius exceeded 1 route — see [[Fix-Tiers-and-Applicability#what-is-blast-radius]] | -| "editing a framework metadata export is not mechanical" | The file is a Next.js `page.tsx`; the correct edit needs `metadataBase` too, which is a two-file change deferred to v0.4.0 | +| "editing a framework metadata export is not mechanical" | The file is a Next.js `page.tsx`; the correct edit needs `metadataBase` too, which is a two-file change deferred until the locator is proven against real repositories | | "N JSON-LD blocks ... are a single X object missing @context ... only exactly one is unambiguous" | More than one candidate node matched; the generator refuses rather than guess | None of these are bugs — they are the applicability model declining to guess. See diff --git a/schemas/omnirank.config.schema.json b/schemas/omnirank.config.schema.json index dc7c815..49bf564 100644 --- a/schemas/omnirank.config.schema.json +++ b/schemas/omnirank.config.schema.json @@ -140,7 +140,11 @@ "lastmod-inflation", "schema", "schema-fabrication", "duplicate-title", "duplicate-description", "noindex-in-sitemap", "canonical-cluster", "hreflang-reciprocity", - "response-time", "page-weight", "compression", "render-blocking"] + "response-time", "page-weight", "compression", "render-blocking", + "hsts", "nosniff", "csp", "referrer-policy", + "mixed-content", "https-redirect", "robots-sitemap", + "canonical-target", "hreflang-noindex", "schema-required", + "image-alt", "heading-order", "link-text", "lang"] } } } diff --git a/schemas/report.schema.json b/schemas/report.schema.json index 93af14a..abf37b9 100644 --- a/schemas/report.schema.json +++ b/schemas/report.schema.json @@ -42,7 +42,7 @@ "properties": { "id": { "type": "string", "pattern": "^[a-z0-9-]+\\.[a-z0-9-]+\\.[a-z0-9-]+$" }, "severity": { "enum": ["error", "warning", "info"] }, - "layer": { "enum": ["seo", "aeo", "geo", "offsite", "smm", "perf"] }, + "layer": { "enum": ["seo", "aeo", "geo", "offsite", "smm", "perf", "security"] }, "url": { "type": "string" }, "gate": { "type": "string" }, "observed": { "type": "string" }, @@ -74,7 +74,9 @@ "properties": { "gate": { "type": "string" }, "reason": { - "enum": ["no-sitemap", "page-unreachable", "not-applicable", "adapter-absent"] + "enum": ["no-sitemap", "page-unreachable", "not-applicable", + "adapter-absent", "matcher-unsupported", "budget-exceeded"], + "description": "Why the gate could not run. Additive: matcher-unsupported and budget-exceeded arrive in 0.4.0, so a report written earlier contains neither and still validates. matcher-unsupported means the check needed a stdlib capability this interpreter lacks (urllib.robotparser is RFC 9309 compliant only on Python 3.14+); OmniRank refuses to judge rather than answer from a matcher it knows is wrong." }, "url": { "type": "string", "description": "Set for a per-page gate that could not run." }, "site": { "type": "string", "description": "Set for a site-level gate that could not run." } diff --git a/scripts/node/package.json b/scripts/node/package.json index dcf901d..afdbd8d 100644 --- a/scripts/node/package.json +++ b/scripts/node/package.json @@ -1,6 +1,6 @@ { "name": "@omnirank/generators", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/scripts/py/omnirank/__init__.py b/scripts/py/omnirank/__init__.py index c22ebda..6d72d35 100644 --- a/scripts/py/omnirank/__init__.py +++ b/scripts/py/omnirank/__init__.py @@ -11,4 +11,4 @@ # rather than at every call site. warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning) -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/scripts/py/omnirank/audit.py b/scripts/py/omnirank/audit.py index f2e918b..8d8cf8a 100644 --- a/scripts/py/omnirank/audit.py +++ b/scripts/py/omnirank/audit.py @@ -5,16 +5,28 @@ from .bands import resolve_band from .config import Config from .fetch import fetch, make_client, read_sitemap -from .gates import aeo, geo, hygiene, jsonld, perf, seo, site +from .gates import ( + aeo, + contradictions, + geo, + hygiene, + jsonld, + onpage, + perf, + security, + seo, + site, +) from .page import PageData from .report import Finding, NotEvaluated, Report # The per-page gate modules whose findings all carry layer="seo" (seo.py AND -# jsonld.py -- schema findings are seo.* too), plus aeo and perf. When a page -# cannot be fetched, none of these ran for it, and each is recorded as its own -# notEvaluated entry rather than merged into one -- a caller filtering -# notEvaluated by gate (e.g. "did aeo run for this URL?") needs them distinct. -PER_PAGE_GATES = ("seo", "aeo", "perf") +# jsonld.py -- schema findings are seo.* too), plus aeo, perf, security and +# onpage. When a page cannot be fetched, none of these ran for it, and each is +# recorded as its own notEvaluated entry rather than merged into one -- a caller +# filtering notEvaluated by gate (e.g. "did aeo run for this URL?") needs them +# distinct. +PER_PAGE_GATES = ("seo", "aeo", "perf", "security", "onpage") def default_config(url: str) -> Config: @@ -92,7 +104,18 @@ def audit_site(config: Config, client: httpx.Client | None = None, pages = _collect(client, targets, report) if pages: - report.layers_run.update({"aeo", "perf"}) + # `security` follows the exact same rule as `aeo` and `perf`: it is a + # per-page layer (security.run_page() is called only inside the `for + # page in pages` loop below), so it must not be marked run -- and + # therefore must not be eligible to score a fabricated 100 -- until at + # least one page was actually parsed. `check_https_redirect()` below is + # the one site-level exception: a REAL finding from it still reaches + # the score map on its own merits (Report.score() admits any layer + # that produced an actual finding, regardless of layers_run), but a + # clean or not-applicable verdict from it must not, by itself, count + # as "security ran" when mixed-content -- one of only two scoring + # gates -- never got a single page to look at (S5). + report.layers_run.update({"aeo", "perf", "security"}) if urls is None and sitemap_urls: # hygiene.check_sitemap() distinguishes a redirecting sitemap entry @@ -113,9 +136,31 @@ def audit_site(config: Config, client: httpx.Client | None = None, resolve_band(page.lang, config))) report.extend(jsonld.run(page.html, page.url)) report.extend(perf.run(page)) + report.extend(security.run_page(page)) + report.extend(onpage.run(page.html, page.url)) + + https_findings, https_not_evaluated = security.check_https_redirect( + client, config.site_url) + report.extend(https_findings) + for entry in https_not_evaluated: + report.flag_not_evaluated(entry) report.extend(site.run(pages, sitemap_urls)) + robots_findings, robots_not_evaluated = contradictions.check_sitemap_vs_robots( + client, config.site_url, sitemap_urls) + report.extend(robots_findings) + for entry in robots_not_evaluated: + report.flag_not_evaluated(entry) + + canonical_findings, canonical_not_evaluated = ( + contradictions.check_canonical_targets(client, pages)) + report.extend(canonical_findings) + for entry in canonical_not_evaluated: + report.flag_not_evaluated(entry) + + report.extend(contradictions.check_hreflang_noindex(pages)) + report.urls_checked = len(targets) report.extend(geo.run(client, config.site_url)) diff --git a/scripts/py/omnirank/cli.py b/scripts/py/omnirank/cli.py index 7ae5457..8778122 100644 --- a/scripts/py/omnirank/cli.py +++ b/scripts/py/omnirank/cli.py @@ -19,13 +19,14 @@ MAX_EXAMPLE_URLS = 3 WRITE_UNAVAILABLE = ( - "omnirank fix has no --write path in 0.3.0. This release locates findings and " - "prints the diff it would apply; it modifies nothing. File modification arrives " - "in v0.4.0, behind the write guarantees in " - "docs/research/2026-08-04-automation-architecture.md section 2.5. Shipping " - "--write as a no-op would be worse than not shipping it." + "omnirank fix has no --write path. This release locates findings and prints " + "the diff it would apply; it writes nothing. Writing ships once the locator " + "has been proven against real repositories and the write guarantees in " + "docs/research/2026-08-04-automation-architecture.md section 2.5 are " + "implemented and tested -- not on a release number. Shipping --write as a " + "no-op, or before those guarantees exist, would be worse than not shipping it." ) -NOTHING_WRITTEN = "This release writes nothing. --write arrives in v0.4.0." +NOTHING_WRITTEN = "This release writes nothing; --write is not available." def _group_findings(findings: list) -> list[dict]: @@ -285,8 +286,8 @@ def _build_parser() -> argparse.ArgumentParser: help="Limit the grouped NOT FIXED summary to the top N " "groups (default: all). Ignored with --json.") fix.add_argument("--write", action="store_true", - help="Not available in 0.3.0: exits 2 with an explanation. " - "File modification arrives in v0.4.0.") + help="Not available: exits 2 with an explanation. This " + "release previews diffs and modifies nothing.") return parser diff --git a/scripts/py/omnirank/fixes/__init__.py b/scripts/py/omnirank/fixes/__init__.py index 0fd5734..10226cc 100644 --- a/scripts/py/omnirank/fixes/__init__.py +++ b/scripts/py/omnirank/fixes/__init__.py @@ -1,8 +1,8 @@ """Fix generators for the MECHANICAL tier. This release PREVIEWS fixes: every generator returns a unified diff and nothing -in this package opens a file for writing. Actual modification arrives in -v0.4.0, and the split is deliberate -- the locator is the riskiest component in +in this package opens a file for writing. Actual modification ships in a later +release, and the split is deliberate -- the locator is the riskiest component in the product, so it ships and gets proven before anything gains write access. `generate()` is the ONLY entry point, and it is where applicability is diff --git a/scripts/py/omnirank/fixes/base.py b/scripts/py/omnirank/fixes/base.py index f53f575..a5587e4 100644 --- a/scripts/py/omnirank/fixes/base.py +++ b/scripts/py/omnirank/fixes/base.py @@ -3,9 +3,11 @@ Kept out of the package `__init__` so a generator module can import it without importing the package that imports the generators. -NOTHING IN THIS PACKAGE OPENS A FILE FOR WRITING. v0.3.0 produces diffs and -prints them; file modification arrives in v0.4.0 behind the guarantees in -docs/research/2026-08-04-automation-architecture.md SS2.5. +NOTHING IN THIS PACKAGE OPENS A FILE FOR WRITING. This release produces diffs +and prints them; file modification ships once the locator is proven against +real repositories and the guarantees in +docs/research/2026-08-04-automation-architecture.md SS2.5 are implemented and +tested -- not on a release number. """ from __future__ import annotations @@ -25,8 +27,9 @@ TSX_DEFERRED = ( "editing a framework metadata export is not mechanical -- the correct App " "Router canonical is a RELATIVE metadata.alternates.canonical plus a " - "metadataBase in the root layout, which is a two-file edit. It arrives in " - "v0.4.0; the generic <link rel=canonical> insertion is wrong here." + "metadataBase in the root layout, which is a two-file edit. That is deferred " + "until the locator is proven; the generic <link rel=canonical> insertion is " + "wrong here." ) MARKDOWN_DEFERRED = ( @@ -34,15 +37,16 @@ "this fix would target does not exist in the source file at all; the tag " "comes from a layout template (Jekyll `_layouts/*.html`, Hugo's theme) " "that every other page on the site shares, and a template edit fans out " - "site-wide rather than to this one page. It arrives in v0.4.0; the " - "generic <link rel=canonical> / @context insertion is wrong here." + "site-wide rather than to this one page. That is deferred until the locator " + "is proven; the generic <link rel=canonical> / @context insertion is wrong " + "here." ) MARKDOWN_SUFFIXES = (".md", ".markdown") def deferred_reason(path: Path) -> str: - """Why a non-HTML location's mechanical fix is deferred to v0.4.0. + """Why a non-HTML location's mechanical fix is deferred. `TSX_DEFERRED` talks about `metadata.alternates.canonical` and `metadataBase` -- Next.js App Router concepts. Showing that paragraph for diff --git a/scripts/py/omnirank/gates/__init__.py b/scripts/py/omnirank/gates/__init__.py index d53429f..ea63a88 100644 --- a/scripts/py/omnirank/gates/__init__.py +++ b/scripts/py/omnirank/gates/__init__.py @@ -26,4 +26,22 @@ `hygiene` has no `run`. It exposes three functions instead, each returning `list[Finding]`: `check_removed(client, urls)`, `check_sitemap(client, site_url, sample)` and `check_lastmod(sitemap_xml, site_url)`. + +`security` exposes `run_page(page: PageData) -> list[Finding]` for the per-response +header and markup gates, plus `check_https_redirect(client: httpx.Client, site_url: +str) -> tuple[list[Finding], list[NotEvaluated]]` for the one site-level probe. It +returns notEvaluated entries alongside findings rather than mutating a Report, +keeping the gate testable without one. + +`contradictions` has no `run`, for the same reason `hygiene` does not: its checks +take different inputs. It exposes `check_sitemap_vs_robots(client, site_url, +sitemap_urls) -> tuple[list[Finding], list[NotEvaluated]]`, +`check_canonical_targets(client, pages) -> tuple[list[Finding], list[NotEvaluated]]` +and `check_hreflang_noindex(pages) -> list[Finding]`. The first two return +notEvaluated entries because they depend on network I/O that can fail. + +`onpage` exposes `run(html: str, url: str) -> list[Finding]`, the same shape as +`seo` and `jsonld`. It is separate from `seo` because that module owns the <head>'s +indexing signals while this one owns body markup engines and assistive technology +both read. """ diff --git a/scripts/py/omnirank/gates/contradictions.py b/scripts/py/omnirank/gates/contradictions.py new file mode 100644 index 0000000..93d4150 --- /dev/null +++ b/scripts/py/omnirank/gates/contradictions.py @@ -0,0 +1,280 @@ +"""Defects provable from the site's OWN declarations, with no external truth. + +The category `docs/research/2026-08-04-competitive-gap-analysis.md` SS1 identifies +as the one OmniRank can credibly own: a URL submitted for crawling in the sitemap +and forbidden in robots.txt; a canonical pointing at a page that is noindexed, that +redirects, or that 404s; a page declared as an hreflang alternate while forbidding +its own indexing. Every one is 100% precision because both halves of the +contradiction come from the site itself. + +Two rules govern every function here, and both are load-bearing: + + 1. Never judge a URL OmniRank did not see. A target outside the crawled set is + either probed (bounded, deduplicated) or left unjudged -- never guessed at. + 2. A gate that could not run returns a NotEvaluated entry, never silence. Silence + is indistinguishable from a pass in the report, which is the failure this + project exists to refuse. + +Like `hygiene`, this module has no `run()`: its three checks take genuinely +different inputs (robots text, the crawled set, probed targets), and a single +entry point would have to take all of them. +""" +from __future__ import annotations + +from urllib.parse import urlsplit + +import httpx + +from .. import robots +from ..fetch import Fetched, fetch +from ..page import PageData +from ..report import Finding, NotEvaluated +from . import site + +GATE_ROBOTS_SITEMAP = "robots-sitemap" +GATE_CANONICAL_TARGET = "canonical-target" +GATE_HREFLANG_NOINDEX = "hreflang-noindex" + +# How many canonical targets outside the crawled set OmniRank will fetch in one +# audit. A cap rather than an unbounded pass: a 200-page site whose every canonical +# points at an uncrawled URL would otherwise double the audit's request count +# without warning. Targets past the cap are reported as budget-exceeded, never +# skipped silently. +MAX_CANONICAL_PROBES = 25 + +_MATCHER_LIMITATION = { + robots.UNSUPPORTED_WILDCARDS: + "the published rules use path wildcards (* or $)", + robots.UNSUPPORTED_LONGEST_MATCH: + "the published rules pair an Allow with an overlapping Disallow", +} + + +def _f(id_: str, gate: str, url: str, severity: str, observed: str, + expected: str, fix: str) -> Finding: + return Finding(id=id_, severity=severity, layer="seo", url=url, gate=gate, + observed=observed, expected=expected, fix=fix) + + +def check_sitemap_vs_robots( + client: httpx.Client, site_url: str, sitemap_urls: list[str] | None, +) -> tuple[list[Finding], list[NotEvaluated]]: + """Sitemap URLs that the site's own robots.txt forbids crawling. + + The same class of self-contradiction as `seo.noindex.in-sitemap`, and rated the + same: the site submits a URL for crawling in one file and forbids it in + another, so one of the two declarations is wrong and no external data is needed + to know that. + + Returns no verdict at all when robots.txt is unreachable, when there is no + sitemap, or when this interpreter's matcher cannot evaluate the rules the site + actually published -- see omnirank/robots.py for why the last one is real. + + Only sitemap URLs on robots.txt's OWN host are judged against it. + `RobotFileParser.can_fetch()` discards the host and matches on path alone, so + passing it a cross-host URL would judge that URL by a robots.txt that never + governed it -- a sitemap index legitimately listing `blog.`/`cdn.` URLs would + otherwise get those paths judged by the audited host's rules and produce a + fabricated finding. Off-host URLs are reported `not-applicable` instead of + silently dropped. + """ + site_url = site_url.rstrip("/") + if not sitemap_urls: + return [], [NotEvaluated(gate=GATE_ROBOTS_SITEMAP, site=site_url, + reason="no-sitemap")] + + robots_url = f"{site_url}/robots.txt" + result = fetch(client, robots_url) + if not result.ok: + return [], [NotEvaluated(gate=GATE_ROBOTS_SITEMAP, url=robots_url, + reason="page-unreachable")] + + host = urlsplit(robots_url).netloc + same_host = [u for u in sitemap_urls if urlsplit(u).netloc == host] + off_host_not_evaluated = [ + NotEvaluated(gate=GATE_ROBOTS_SITEMAP, url=u, reason="not-applicable") + for u in sitemap_urls if urlsplit(u).netloc != host + ] + if not same_host: + return [], off_host_not_evaluated + + verdict = robots.disallowed_urls(result.text, same_host) + if not verdict.evaluated: + return [], off_host_not_evaluated + [NotEvaluated( + gate=GATE_ROBOTS_SITEMAP, url=robots_url, reason="matcher-unsupported")] + + findings = [ + _f("seo.robots-sitemap.disallowed", GATE_ROBOTS_SITEMAP, url, "error", + f"listed in sitemap.xml, and Disallow-ed to {robots.DEFAULT_AGENT} " + f"by {robots_url}", + "either crawlable and listed, or disallowed and unlisted", + "Remove the Disallow rule in robots.txt, or drop this URL from the " + "sitemap. Submitting a URL for crawling while forbidding the crawl is a " + "contradiction the site makes with itself: engines waste budget " + "discovering a URL they are then not permitted to fetch. Only you know " + "which of the two declarations is the intended one.") + for url in verdict.disallowed + ] + return findings, off_host_not_evaluated + + +def matcher_limitation(reason: str) -> str: + """Human-readable explanation for a `matcher-unsupported` notEvaluated entry. + + Kept next to the check that produces it so the two cannot drift; the CLI has no + other place to learn what the reason means. + """ + detail = _MATCHER_LIMITATION.get(reason, "the published rules") + return (f"robots.txt was fetched, but {detail}, and urllib.robotparser on this " + "interpreter does not evaluate those the way RFC 9309 specifies " + "(fixed in Python 3.14). OmniRank refuses to answer rather than report " + "a result it knows may be wrong. Re-run on Python 3.14 or later for " + "full coverage.") + + +def _canonical_pairs(pages: list[PageData]) -> list[tuple[PageData, str, str]]: + """(page, absolute target, target key) for every page with a foreign canonical. + + Self-canonicals are dropped: a page canonicalising to itself contradicts + nothing, even when it is noindexed -- that is a coherent "exclude this page" + configuration, and seo.noindex.in-sitemap owns the sitemap half of it. + """ + pairs: list[tuple[PageData, str, str]] = [] + for page in pages: + target = site.canonical_target(page) + if not target: + continue # the per-URL gate's problem + key = site.canonical_key(target) + if key == site.canonical_key(page.url): + continue # self-canonical, correct + pairs.append((page, target, key)) + return pairs + + +def _noindexed(page: PageData, target: str) -> Finding: + return _f("seo.canonical-target.noindexed", GATE_CANONICAL_TARGET, page.url, + "error", + f"canonical points to {target}, which carries a noindex directive", + "a canonical pointing at an indexable page", + f"Remove the noindex on {target}, or point this page's canonical " + "somewhere indexable. Naming a page engines are forbidden to index as " + "the canonical version of this one discards this page's signals " + "without transferring them anywhere: both URLs leave the index.") + + +def check_canonical_targets( + client: httpx.Client, pages: list[PageData], +) -> tuple[list[Finding], list[NotEvaluated]]: + """Canonicals whose target is noindexed, redirects, or does not exist. + + Targets already in the crawled set are judged from the PageData already held -- + no second fetch. Targets outside it are probed once each, deduplicated, up to + MAX_CANONICAL_PROBES; past that they are reported as budget-exceeded. Nothing + is ever judged from a URL OmniRank did not actually see. + + A 5xx or transport failure on a probe is `page-unreachable`, not `.not-found`: + a transient origin error is not a missing page, and calling it one would be a + guess dressed as a finding. + """ + crawled = {site.canonical_key(p.url): p for p in pages} + findings: list[Finding] = [] + not_evaluated: list[NotEvaluated] = [] + + # target key -> the outcome of probing it, so N pages sharing one broken target + # cost one request and still each get told. + probed: dict[str, Fetched | None] = {} + budget = MAX_CANONICAL_PROBES + + for page, target, key in _canonical_pairs(pages): + known = crawled.get(key) + if known is not None: + # Already fetched and 200 by construction (unreachable URLs never + # become PageData), so only the noindex question remains. + if site.is_noindex(known): + findings.append(_noindexed(page, target)) + continue + + if key not in probed: + if budget <= 0: + probed[key] = None + not_evaluated.append(NotEvaluated( + gate=GATE_CANONICAL_TARGET, url=target, reason="budget-exceeded")) + else: + budget -= 1 + probed[key] = fetch(client, target) + + result = probed[key] + if result is None: + continue # already recorded as unevaluated + + if result.is_redirect: + location = (result.headers.get("location", "") or "").strip() or "(no Location)" + findings.append(_f( + "seo.canonical-target.redirects", GATE_CANONICAL_TARGET, page.url, + "warning", + f"canonical points to {target}, which returns HTTP {result.status}", + "a canonical pointing at a URL that resolves 200", + f"Point this page's canonical straight at {location}. A canonical " + "that redirects makes engines resolve one more hop than they need " + "to, and a stale canonical is a symptom of a URL change the rest of " + "the markup has not caught up with.")) + elif result.status in (404, 410): + findings.append(_f( + "seo.canonical-target.not-found", GATE_CANONICAL_TARGET, page.url, + "error", + f"canonical points to {target}, which returns HTTP {result.status}", + "a canonical pointing at a URL that resolves 200", + f"Restore {target}, or point this page's canonical at a URL that " + "exists. Nominating a missing page as the canonical version of this " + "one asks engines to index a URL that is not there, so this page's " + "signals go nowhere.")) + elif result.ok: + if site.is_noindex(PageData.from_fetched(result)): + findings.append(_noindexed(page, target)) + else: + # 5xx, 401, 403, or a transport failure (status 0). None of these means + # "gone", and OmniRank does not guess which one it is. + not_evaluated.append(NotEvaluated( + gate=GATE_CANONICAL_TARGET, url=target, reason="page-unreachable")) + + return findings, not_evaluated + + +def check_hreflang_noindex(pages: list[PageData]) -> list[Finding]: + """Pages that declare a noindexed page as their locale alternate. + + An hreflang set asserts that these URLs are intentional per-locale versions of + one another. Naming a page engines are forbidden to index nullifies the pair: + engines discard hreflang annotations whose target they cannot resolve, so the + DECLARING page loses its international targeting too -- which is why the + finding attaches there rather than to the noindexed page. + + Crawled-set only, and no network I/O, so this gate can never fail to run and + returns a bare list. x-default is exempt: site.alternates excludes it, because + it is a fallback pointer rather than a language pair. + """ + by_key = {site.canonical_key(p.url): p for p in pages} + findings: list[Finding] = [] + + for page in pages: + page_key = site.canonical_key(page.url) + for href in sorted(site.alternates(page)): + target_key = site.canonical_key(href) + if target_key == page_key: + continue # self-reference is normal + target = by_key.get(target_key) + if target is None: + continue # outside the crawled set + if not site.is_noindex(target): + continue + findings.append(_f( + "seo.hreflang-noindex.alternate", GATE_HREFLANG_NOINDEX, page.url, + "error", + f"declares {href} as a locale alternate, and that page carries a " + "noindex directive", + "every hreflang alternate indexable", + f"Remove the noindex on {href}, or drop it from this page's hreflang " + "set. An alternate engines may not index cannot be returned for any " + "locale, and an annotation whose target cannot be resolved is " + "discarded -- taking this page's international targeting with it.")) + return findings diff --git a/scripts/py/omnirank/gates/jsonld.py b/scripts/py/omnirank/gates/jsonld.py index 985e814..3ded36b 100644 --- a/scripts/py/omnirank/gates/jsonld.py +++ b/scripts/py/omnirank/gates/jsonld.py @@ -98,6 +98,152 @@ def _fabrication(blocks: list[dict], url: str) -> list[Finding]: return findings +# The date this table was transcribed from Google's rich-result documentation. +# Named in every finding: the table is OmniRank's reading of Google's docs on one +# day, not a live contract, and a reader is entitled to judge its age. SS6 of +# docs/research/2026-08-04-competitive-gap-analysis.md is explicit that a stale +# required-property table produces FABRICATED errors -- which is why these findings +# are `warning` and not `error` until a freshness test exists to back them. +RICH_RESULT_RULES_AS_OF = "2026-08-04" + +# type -> the groups of properties Google documents as REQUIRED for that type's +# rich result. Each inner tuple is an alternatives group: at least one member must +# be present. That single shape expresses both "Article needs headline AND image +# AND datePublished" (three one-member groups) and "Product needs name AND (offers +# OR aggregateRating OR review)" (a one-member group plus a three-member one). +# +# These are GOOGLE's requirements, not schema.org's -- schema.org marks no property +# required at all, so a page can be flawless schema.org and still ineligible for +# the rich result. Every finding says so. +RICH_RESULT_RULES: dict[str, tuple[tuple[str, ...], ...]] = { + "Article": (("headline",), ("image",), ("datePublished",)), + "NewsArticle": (("headline",), ("image",), ("datePublished",)), + "BlogPosting": (("headline",), ("image",), ("datePublished",)), + "Product": (("name",), ("offers", "aggregateRating", "review")), + "FAQPage": (("mainEntity",),), + "BreadcrumbList": (("itemListElement",),), + "Organization": (("name",), ("url",)), + "LocalBusiness": (("name",), ("address",)), +} + +# A node carrying @id and nothing but @id/@type/@context is a REFERENCE to an +# entity declared elsewhere -- the `@id` cross-reference pattern the design spec +# recommends. Checking it for required properties would flag correct markup. +_REFERENCE_KEYS = frozenset({"@id", "@type", "@context"}) + + +def _types_of(node: dict) -> list[str]: + raw = node.get("@type", "") + values = raw if isinstance(raw, list) else [raw] + return [v for v in values if isinstance(v, str)] + + +def _present(node: dict, prop: str) -> bool: + """True when `prop` has a value worth calling present. + + An empty string, an all-whitespace string, an empty list and an empty object + are all absent for this purpose -- they are what a template emits when its data + did not arrive. A numeric 0 or a False is present: those are real values. + """ + value = node.get(prop) + if value is None: + return False + if isinstance(value, str): + return bool(value.strip()) + if isinstance(value, (list, tuple, dict)): + return len(value) > 0 + return True + + +def _as_nodes(value: object) -> list[dict]: + items = value if isinstance(value, list) else [value] + return [item for item in items if isinstance(item, dict)] + + +def _faq_missing(node: dict) -> list[str]: + """`acceptedAnswer` when no mainEntity Question carries one.""" + questions = _as_nodes(node.get("mainEntity")) + if questions and not any(_present(q, "acceptedAnswer") for q in questions): + return ["acceptedAnswer (on a mainEntity Question)"] + return [] + + +def _listitem_has_name(item: dict) -> bool: + """Google documents a ListItem name either at the top level or under `item`. + + Both shapes are valid and widely emitted; checking only the top level would + report a fabricated error on entirely correct markup. + """ + if _present(item, "name"): + return True + nested = item.get("item") + return isinstance(nested, dict) and _present(nested, "name") + + +def _breadcrumb_missing(node: dict) -> list[str]: + items = _as_nodes(node.get("itemListElement")) + if not items: + return [] + missing: list[str] = [] + if any(not _present(item, "position") for item in items): + missing.append("position (on an itemListElement)") + if any(not _listitem_has_name(item) for item in items): + missing.append("name (on an itemListElement)") + return missing + + +def _missing_for_type(node: dict, type_name: str) -> list[str]: + missing: list[str] = [] + for group in RICH_RESULT_RULES[type_name]: + if not any(_present(node, prop) for prop in group): + missing.append(" or ".join(group)) + if type_name == "FAQPage": + missing.extend(_faq_missing(node)) + elif type_name == "BreadcrumbList": + missing.extend(_breadcrumb_missing(node)) + return missing + + +def _required_properties(blocks: list[dict], url: str) -> list[Finding]: + """Google's REQUIRED rich-result properties, per top-level typed node. + + Top-level nodes only (extract_blocks has already flattened @graph). Walking + into nested objects would flag the reference stubs and partial sub-objects that + correct markup is full of. + + One finding per NODE, listing every missing property, and one finding ID for + the whole class: the fix is the same action every time (author the value), so + the fix tier is identical, and a page missing six properties is one bad + template rather than six separate defects -- which is also what keeps GATE_CAP + charging this the once. + """ + findings: list[Finding] = [] + for node in blocks: + if "@id" in node and not (set(node) - _REFERENCE_KEYS): + continue # a reference, not a declaration + missing: list[str] = [] + for type_name in _types_of(node): + if type_name in RICH_RESULT_RULES: + missing.extend(_missing_for_type(node, type_name)) + if not missing: + continue + types = "/".join(t for t in _types_of(node) if t in RICH_RESULT_RULES) + findings.append(_f( + "seo.schema-required.missing-property", "schema-required", url, + "warning", + f"{types} node missing {', '.join(missing)}", + f"the properties Google documents as required for a {types} rich result", + f"Add {', '.join(missing)} to the {types} markup. This node is valid " + "schema.org -- schema.org marks no property as required, so nothing " + "here is malformed. What is unmet is GOOGLE's rich-result requirement " + f"for {types}, as transcribed on {RICH_RESULT_RULES_AS_OF} into " + "RICH_RESULT_RULES in gates/jsonld.py: without these the page stays " + "valid and simply is not eligible for the rich result. Eligibility is " + "also not display -- Google decides per query whether to show one, and " + "OmniRank cannot observe that.")) + return findings + + def run(html: str, url: str) -> list[Finding]: raws = _raw_scripts(html) if not raws: @@ -132,4 +278,5 @@ def run(html: str, url: str) -> list[Finding]: "Add @context, or nest the node inside a block that declares it.")) findings.extend(_fabrication(blocks, url)) + findings.extend(_required_properties(blocks, url)) return findings diff --git a/scripts/py/omnirank/gates/onpage.py b/scripts/py/omnirank/gates/onpage.py new file mode 100644 index 0000000..4458bdd --- /dev/null +++ b/scripts/py/omnirank/gates/onpage.py @@ -0,0 +1,177 @@ +"""On-page markup gates that overlap accessibility. + +Kept out of `seo.py` so neither file carries two responsibilities: `seo.py` owns +the <head>'s indexing signals (canonical, title, description, og, hreflang), and +this module owns body markup that engines and assistive technology both read. + +These findings are worth shipping in an SEO tool for a reason beyond compliance: +LLM retrieval chunks on heading boundaries, so a broken outline degrades +extractability directly (SS5.4C of the gap analysis) -- a stronger argument for +heading-order integrity here than in any classic SEO tool. +""" +from __future__ import annotations + +from itertools import pairwise + +from bs4 import BeautifulSoup, Tag + +from ..report import Finding + +MAX_EXAMPLES = 3 + +# Explicitly decorative markers. An <img> carrying any of these is telling the +# accessibility tree to ignore it, and demanding alt text there is wrong advice. +# alt="" is handled separately and is the most important of the four: it is the +# spec's own way to mark an image decorative, so a check that flags it tells users +# to make their markup worse. +_DECORATIVE_ROLES = frozenset({"presentation", "none"}) + +# English-only by construction, which is exactly why seo.link-text.generic is +# `info`: on a Bengali or Japanese page this list matches nothing and the gate is +# silent, so it must never be able to fail a build. Kept deliberately short -- +# every entry has to be text that conveys nothing WITHOUT its surrounding sentence. +GENERIC_ANCHORS: frozenset[str] = frozenset({ + "click here", "click", "here", "read more", "more", "learn more", + "see more", "find out more", "this", "this link", "link", "continue", + "continue reading", "details", "go", "info", +}) + +_HEADINGS = ("h1", "h2", "h3", "h4", "h5", "h6") + + +def _f(id_: str, gate: str, url: str, severity: str, observed: str, + expected: str, fix: str) -> Finding: + return Finding(id=id_, severity=severity, layer="seo", url=url, gate=gate, + observed=observed, expected=expected, fix=fix) + + +def _attr(tag: Tag, name: str) -> str: + value = tag.get(name) + return value.strip().lower() if isinstance(value, str) else "" + + +def _image_alt(soup: BeautifulSoup, url: str) -> list[Finding]: + """<img> with no alt ATTRIBUTE AT ALL -- never alt="", which is correct.""" + bad = [ + img for img in soup.find_all("img") + if not img.has_attr("alt") + and _attr(img, "role") not in _DECORATIVE_ROLES + and _attr(img, "aria-hidden") != "true" + ] + if not bad: + return [] + srcs = ", ".join(img.get("src", "?") for img in bad[:MAX_EXAMPLES]) + return [_f("seo.image-alt.missing", "image-alt", url, "warning", + f"{len(bad)} <img> with no alt attribute ({srcs})", + "an alt attribute on every <img>", + 'Add alt text describing what the image conveys, or alt="" if it is ' + "purely decorative. An absent alt attribute is different from an " + 'empty one: alt="" says "ignore this image" and is correct for ' + "decoration, while no attribute at all leaves screen readers " + "announcing the filename and leaves engines with nothing to read.")] + + +def _heading_order(soup: BeautifulSoup, url: str) -> list[Finding]: + """The first place the outline jumps more than one level deeper. + + Only INCREASES count. Going h3 -> h1 closes two sections and is correct; going + h1 -> h3 leaves a level with no parent. One finding per page: the outline is + fixed once, and one finding per skip would flood a long document. + """ + levels = [int(tag.name[1]) for tag in soup.find_all(_HEADINGS)] + for previous, current in pairwise(levels): + if current > previous + 1: + return [_f("seo.heading-order.skipped", "heading-order", url, "warning", + f"outline jumps from h{previous} to h{current}", + "each heading at most one level deeper than the last", + f"Insert an h{previous + 1} above the h{current}, or demote " + f"the h{current} to h{previous + 1}. A skipped level leaves " + "a section with no parent, which breaks the document outline " + "assistive technology navigates by -- and answer engines " + "chunk retrieved passages on heading boundaries, so a broken " + "outline degrades what they can extract.")] + return [] + + +def _accessible_name(tag: Tag) -> str: + """The text a user or a crawler would perceive as this link's label.""" + text = tag.get_text(" ", strip=True) + if text: + return text + for attr in ("aria-label", "title"): + value = tag.get(attr) + if isinstance(value, str) and value.strip(): + return value.strip() + for img in tag.find_all("img"): + alt = img.get("alt") + if isinstance(alt, str) and alt.strip(): + return alt.strip() + return "" + + +def _link_text(soup: BeautifulSoup, url: str) -> list[Finding]: + """Links with no accessible name, and links whose name conveys nothing. + + Links inside <nav> are exempt: navigation labels are terse by design and take + their meaning from the nav itself, so flagging them is the kind of noise that + trains users to ignore a report. + """ + empty: list[str] = [] + generic: list[str] = [] + for link in soup.find_all("a", href=True): + if link.find_parent("nav") is not None: + continue + name = _accessible_name(link) + if not name: + empty.append(link["href"]) + elif " ".join(name.split()).lower() in GENERIC_ANCHORS: + generic.append(f"{name!r} -> {link['href']}") + + findings: list[Finding] = [] + if empty: + shown = ", ".join(empty[:MAX_EXAMPLES]) + findings.append(_f( + "seo.link-text.empty", "link-text", url, "warning", + f"{len(empty)} <a href> with no accessible name ({shown})", + "every link carrying text, an aria-label, or an image with alt", + "Give each link visible text, an aria-label, or an image with alt text. " + "A link with no name is announced as its URL by a screen reader and " + "passes no anchor context to whatever it points at.")) + if generic: + shown = ", ".join(generic[:MAX_EXAMPLES]) + findings.append(_f( + "seo.link-text.generic", "link-text", url, "info", + f"{len(generic)} link(s) whose text conveys nothing alone ({shown})", + "anchor text that describes the destination", + "Rewrite the anchor text to name the destination. Reported as info, not " + "a defect: the word list behind this check is English-only, so it is " + "silent on other languages by construction and must never fail a build. " + "Links inside <nav> are exempt.")) + return findings + + +def _lang(soup: BeautifulSoup, url: str) -> list[Finding]: + tag = soup.find("html") + if tag is not None: + value = tag.get("lang") + if isinstance(value, str) and value.strip(): + return [] + return [_f("seo.lang.missing", "lang", url, "error", + "<html> has no lang attribute", + "<html lang> naming the page's language", + 'Add lang to the <html> element (for example <html lang="en">). This ' + "is an error rather than a nicety because OmniRank's own AnswerBlock " + "sizing reads it: with no lang, bands.resolve_band() falls back to " + "the space-delimited Latin default, so a page written in a script " + "without word separators is measured against a word band it cannot " + "meet and aeo.answer-block.length reports a length problem that does " + "not exist. A missing lang makes another gate lie.")] + + +def run(html: str, url: str) -> list[Finding]: + """On-page markup gates for one already-fetched page.""" + soup = BeautifulSoup(html, "lxml") + findings: list[Finding] = [] + for check in (_image_alt, _heading_order, _link_text, _lang): + findings.extend(check(soup, url)) + return findings diff --git a/scripts/py/omnirank/gates/security.py b/scripts/py/omnirank/gates/security.py new file mode 100644 index 0000000..b7dcbb9 --- /dev/null +++ b/scripts/py/omnirank/gates/security.py @@ -0,0 +1,264 @@ +"""Response-header and markup security signals. + +Scope is deliberately narrow. `docs/research/2026-08-04-competitive-gap-analysis.md` +SS4 draws the line: OmniRank checks security only where insecurity demonstrably +breaks crawling, indexing or rendering -- Mozilla Observatory and testssl.sh grade +headers properly and free, and an SEO tool that scores CSP strength is doing a job +it cannot do well. So the four header gates here are `info`: they cost 0 points +(see ERROR_COST/WARNING_COST in report.py), never fail a build, and are reported as +inventory facts. Only the two gates in this module that DO break something -- +mixed content, which browsers block, and a missing http->https redirect, which +splits canonicalisation -- carry error severity. + +CSP is parsed for exactly one purpose beyond noting its total absence: +`upgrade-insecure-requests`, which suppresses the mixed-content finding. This +module never grades a policy. +""" +from __future__ import annotations + +import re + +import httpx + +from ..fetch import fetch +from ..html import has_rel +from ..page import PageData +from ..report import Finding, NotEvaluated + +# OmniRank's own floor for Strict-Transport-Security max-age: 180 days in seconds. +# Named here, and named in the finding text, because it is a CHOICE and not a +# vendor requirement -- Chromium's HSTS preload submission form asks for a longer +# max-age than this, and OmniRank deliberately claims nothing whatsoever about the +# preload list, which it cannot observe. +HSTS_MIN_MAX_AGE = 15_552_000 + +_MAX_AGE = re.compile(r"\bmax-age\s*=\s*\"?(\d+)", re.IGNORECASE) + + +def _f(id_: str, gate: str, url: str, severity: str, observed: str, + expected: str, fix: str) -> Finding: + return Finding(id=id_, severity=severity, layer="security", url=url, gate=gate, + observed=observed, expected=expected, fix=fix) + + +def _is_https(url: str) -> bool: + return url.lower().startswith("https://") + + +def csp_value(page: PageData) -> str: + """The page's CSP from the header and/or a meta http-equiv, lowercased. + + Both delivery mechanisms are real and a page may use either; treating only the + header as authoritative would report a meta-delivered policy as absent. + """ + header = page.headers.get("content-security-policy", "") or "" + meta = page.soup().find("meta", attrs={"http-equiv": lambda v: ( + v is not None and v.strip().lower() == "content-security-policy")}) + meta_content = (meta.get("content") or "") if meta else "" + return f"{header} {meta_content}".strip().lower() + + +def _hsts(page: PageData) -> list[Finding]: + if not _is_https(page.url): + # A browser ignores Strict-Transport-Security on a plain-http response + # entirely, so reporting its absence there would be advice with no effect. + return [] + value = page.headers.get("strict-transport-security", "") + if not value.strip(): + return [_f("security.hsts.missing", "hsts", page.url, "info", + "no Strict-Transport-Security header on an https response", + "a Strict-Transport-Security header", + "Send Strict-Transport-Security from the origin or CDN so browsers " + "refuse to downgrade to http. This is reported as a fact, not a " + "defect: OmniRank grades security headers only where their absence " + "breaks crawling, indexing or rendering, and this one does not.")] + match = _MAX_AGE.search(value) + if match is None: + # No max-age directive at all is malformed, not SHORT. Reporting a number + # here would mean inventing one. + return [] + seconds = int(match.group(1)) + if seconds >= HSTS_MIN_MAX_AGE: + return [] + return [_f("security.hsts.short-max-age", "hsts", page.url, "info", + f"Strict-Transport-Security max-age={seconds}", + f"max-age of at least {HSTS_MIN_MAX_AGE} seconds", + f"Raise max-age to at least {HSTS_MIN_MAX_AGE}. That figure is " + "HSTS_MIN_MAX_AGE in gates/security.py -- OmniRank's own floor " + "(180 days), not an industry standard and not a vendor requirement. " + "OmniRank makes no claim about any browser's HSTS preload " + "programme, which it cannot observe.")] + + +def _nosniff(page: PageData) -> list[Finding]: + value = (page.headers.get("x-content-type-options", "") or "").strip().lower() + if value == "nosniff": + return [] + return [_f("security.nosniff.missing", "nosniff", page.url, "info", + f"X-Content-Type-Options is {value!r}" if value + else "no X-Content-Type-Options header", + "X-Content-Type-Options: nosniff", + "Send X-Content-Type-Options: nosniff so browsers honour the declared " + "Content-Type. Reported as a fact: this is not an SEO or AEO signal " + "and OmniRank does not grade it.")] + + +def _csp(page: PageData) -> list[Finding]: + if csp_value(page): + return [] + return [_f("security.csp.absent", "csp", page.url, "info", + "no Content-Security-Policy, by header or meta http-equiv", + "a Content-Security-Policy", + "Consider adding a Content-Security-Policy. OmniRank reports only " + "total absence and deliberately does not assess a policy's contents: " + "whether a given CSP is adequate is a judgement about your threat " + "model, not something an SEO auditor can determine.")] + + +def _referrer_policy(page: PageData) -> list[Finding]: + if (page.headers.get("referrer-policy", "") or "").strip(): + return [] + return [_f("security.referrer-policy.missing", "referrer-policy", page.url, "info", + "no Referrer-Policy header", + "a Referrer-Policy header", + "Consider sending a Referrer-Policy. Reported as a fact: its absence " + "affects neither crawling nor indexing, and OmniRank does not grade " + "which policy value you should choose.")] + + +# The subresource-bearing (element, attribute) pairs OmniRank reads, split the way the +# W3C Mixed Content spec splits them. `link` is filtered further by rel below: a +# <link rel="canonical" href="http://..."> is not a subresource -- the browser never +# fetches it while rendering -- and reporting it as mixed content would be a fabricated +# error on a judgement seo.canonical.* already owns. +# +# BLOCKABLE ("active") content: browsers refuse to load this over http:// on an https +# page at all -- the request never happens, so the page is measurably broken as served. +_ACTIVE_ATTRS: tuple[tuple[str, str], ...] = (("script", "src"), ("iframe", "src")) +_ACTIVE_LINK_RELS: frozenset[str] = frozenset({"stylesheet", "preload", "modulepreload"}) + +# OPTIONALLY-BLOCKABLE ("passive") content: browsers silently rewrite the request to +# https:// before fetching it (Chrome/Firefox both auto-upgrade images and favicons), +# and only fail if no https:// version exists at that path -- something OmniRank +# cannot observe from the HTML alone. Claiming these "are not loading for your +# visitors at all" would be false for the common case where the upgrade succeeds. +_PASSIVE_ATTRS: tuple[tuple[str, str], ...] = (("img", "src"),) +_PASSIVE_LINK_RELS: frozenset[str] = frozenset({ + "prefetch", "icon", "apple-touch-icon", "manifest", +}) + + +def _insecure(bucket: list[str], tag_name: str, attr: str, tag) -> None: + value = (tag.get(attr) or "").strip() + if value.lower().startswith("http://"): + bucket.append(f"<{tag_name} {attr}={value}>") + + +def _insecure_subresources(page: PageData) -> tuple[list[str], list[str]]: + """(active, passive) http:// subresource descriptions on an https:// page. + + Only literal `http://` values count. A protocol-relative `//host/path` inherits + the page's own scheme and is therefore https here. + """ + soup = page.soup() + active: list[str] = [] + passive: list[str] = [] + + for tag_name, attr in _ACTIVE_ATTRS: + for tag in soup.find_all(tag_name): + _insecure(active, tag_name, attr, tag) + for tag_name, attr in _PASSIVE_ATTRS: + for tag in soup.find_all(tag_name): + _insecure(passive, tag_name, attr, tag) + for tag in soup.find_all("link"): + if any(has_rel(tag, rel) for rel in _ACTIVE_LINK_RELS): + _insecure(active, "link", "href", tag) + elif any(has_rel(tag, rel) for rel in _PASSIVE_LINK_RELS): + _insecure(passive, "link", "href", tag) + + return active, passive + + +def _shown(items: list[str]) -> str: + # Up to 5 examples -- a longer real-world list still truncates for readability. + return ", ".join(items[:5]) + ("…" if len(items) > 5 else "") + + +def _mixed_content(page: PageData) -> list[Finding]: + """http:// subresources declared by an https page, split by whether browsers + block the request outright (active) or silently upgrade it first (passive). + + Suppressed entirely when the page's CSP carries `upgrade-insecure-requests`, + which makes the browser rewrite ALL of these to https before requesting them -- + reporting either then would be a false positive, and it is the one thing SS4 of + the gap analysis says to parse CSP for. + """ + if not _is_https(page.url): + return [] + if "upgrade-insecure-requests" in csp_value(page): + return [] + + active, passive = _insecure_subresources(page) + findings: list[Finding] = [] + if active: + findings.append(_f( + "security.mixed-content.subresource", "mixed-content", page.url, "error", + f"{len(active)} http:// subresources on an https page ({_shown(active)})", + "every subresource requested over https", + "Serve these over https, or add upgrade-insecure-requests to the page's " + "Content-Security-Policy. Browsers block mixed active content outright, " + "so these resources are not loading for your visitors at all -- this is " + "a rendering failure, not a hardening suggestion.")) + if passive: + findings.append(_f( + "security.mixed-content.passive-subresource", "mixed-content", page.url, + "warning", + f"{len(passive)} http:// subresources on an https page ({_shown(passive)})", + "every subresource requested over https", + "Serve these over https, or add upgrade-insecure-requests to the page's " + "Content-Security-Policy. Browsers rewrite these requests to https " + "before fetching them, so they usually still load -- but the rewrite " + "fails silently if no https version exists at that path, which OmniRank " + "cannot verify from the HTML alone.")) + return findings + + +def check_https_redirect(client: httpx.Client, + site_url: str) -> tuple[list[Finding], list[NotEvaluated]]: + """Does the http:// form of the site redirect to https? + + One extra request per audit, against the site root only. Returns notEvaluated + entries alongside findings rather than a bare list: an http:// origin that + refuses the connection has not passed this gate, and silence would read as one. + """ + site_url = site_url.rstrip("/") + if not _is_https(site_url): + return [], [NotEvaluated(gate="https-redirect", site=site_url, + reason="not-applicable")] + + http_url = "http://" + site_url[len("https://"):] + "/" + result = fetch(client, http_url) + if result.status == 0: + return [], [NotEvaluated(gate="https-redirect", url=http_url, + reason="page-unreachable")] + + location = (result.headers.get("location", "") or "").strip() + if result.is_redirect and location.lower().startswith("https://"): + return [], [] + + observed = (f"HTTP {result.status} to {location}" if result.is_redirect + else f"HTTP {result.status} served directly over http") + return [_f("security.https-redirect.missing", "https-redirect", http_url, + "error", observed, "a 3xx redirect to the https:// URL", + "Redirect http:// to https:// at the origin or CDN. Serving both " + "schemes gives every page a live duplicate, splitting the canonical " + "and link signals between two URLs that engines must reconcile " + "themselves.")], [] + + +def run_page(page: PageData) -> list[Finding]: + """Security signals derivable from one already-fetched response.""" + findings: list[Finding] = [] + for check in (_hsts, _nosniff, _csp, _referrer_policy, _mixed_content): + findings.extend(check(page)) + return findings diff --git a/scripts/py/omnirank/gates/site.py b/scripts/py/omnirank/gates/site.py index eb21e87..7154b74 100644 --- a/scripts/py/omnirank/gates/site.py +++ b/scripts/py/omnirank/gates/site.py @@ -235,3 +235,14 @@ def run(pages: list[PageData], sitemap_urls: list[str] | None = None) -> list[Fi *_canonical_chains(pages), *_hreflang_reciprocity(pages), ] + + +# Public aliases. gates/contradictions.py needs these three predicates, and +# reimplementing them there would let the two modules disagree about what +# "noindex" means, what counts as the same URL, or which <link> is the canonical -- +# exactly the three-way drift html.py was created to end. Aliases rather than +# renames so this module's own call sites stay byte-identical. +canonical_key = _canonical_key +canonical_target = _canonical_target +is_noindex = _is_noindex +alternates = _alternates diff --git a/scripts/py/omnirank/locator.py b/scripts/py/omnirank/locator.py index 55468c0..8e678c8 100644 --- a/scripts/py/omnirank/locator.py +++ b/scripts/py/omnirank/locator.py @@ -240,8 +240,9 @@ def _resolve_candidate(root: Path, relative: str) -> Path | None: URL segments, so a `../` in the URL (or, after `.resolve()`, a symlink *inside* the repo whose target lives outside it) must not be allowed to walk out of the repository -- nothing writes through a `Location.path` - yet, but v0.4.0 opens `root / location.path` directly, and an escaped - candidate today is arbitrary-file-write the day that lands. Second, + yet, but the release that adds writing opens `root / location.path` + directly, and an escaped candidate today is arbitrary-file-write the day + that lands. Second, `Path.is_file()` follows the host filesystem's own case folding, which is on by default on macOS and Windows and off on Linux CI, so a naive check would match `/pricing` to an on-disk `Pricing.html` on a contributor's @@ -316,7 +317,7 @@ def _locate_jekyll(route: str, root: Path) -> Location: front matter is a durable edit target and serves exactly one route. Following its `layout:` up to `_layouts/*.html` is a second hop whose fan-out is every post on the site; that arrives with the edit engine in - v0.4.0, not here. + a later release, not here. """ if route == "/": names = ["index.html", "index.md", "index.markdown"] diff --git a/scripts/py/omnirank/registry.py b/scripts/py/omnirank/registry.py index cbb94b1..a808dab 100644 --- a/scripts/py/omnirank/registry.py +++ b/scripts/py/omnirank/registry.py @@ -85,6 +85,11 @@ def _r(id_: str, tier: FixTier, severity: str, layer: str, gate: str, "schema-fabrication"), _r("seo.schema-fabrication.anonymous-review", "advisory", "error", "seo", "schema-fabrication"), + # `drafted`: the missing value is content -- a headline, an image URL, a + # postal address -- that a human must author or approve. There is no + # constant and no repo fact that supplies it. + _r("seo.schema-required.missing-property", "drafted", "warning", "seo", + "schema-required"), # --- gates/aeo.py ----------------------------------------------------- _r("aeo.answer-block.missing", "drafted", "error", "aeo", "answer-block"), _r("aeo.answer-block.length", "drafted", "error", "aeo", "answer-block"), @@ -133,6 +138,59 @@ def _r(id_: str, tier: FixTier, severity: str, layer: str, gate: str, # --- audit.py --------------------------------------------------------- _r("seo.page.unreachable", "advisory", "error", "seo", "sitemap-health"), _r("seo.sitemap.missing", "templated", "error", "seo", "sitemap-health"), + # --- gates/security.py (v0.4.0) --------------------------------------- + # All five are `infrastructure`: the fix is an origin/CDN response-header + # change, and no edit to any file in the user's repo produces it. + _r("security.hsts.missing", "infrastructure", "info", "security", "hsts"), + _r("security.hsts.short-max-age", "infrastructure", "info", "security", "hsts"), + _r("security.nosniff.missing", "infrastructure", "info", "security", "nosniff"), + _r("security.csp.absent", "infrastructure", "info", "security", "csp"), + _r("security.referrer-policy.missing", "infrastructure", "info", "security", + "referrer-policy"), + # `templated`: the edit is a scheme rewrite in markup the repo owns, which is + # deterministic given repo facts. Whether the https origin actually serves the + # asset is a per-INSTANCE question, and applicability.py is where that demotes. + _r("security.mixed-content.subresource", "templated", "error", "security", + "mixed-content"), + # `warning`, not `error`: browsers auto-upgrade a passive subresource (img, + # icon-family link) to https before fetching it rather than blocking it, so + # OmniRank cannot claim the resource failed to load -- only that it asked for + # http:// (S4, final v0.4.0 review). + _r("security.mixed-content.passive-subresource", "templated", "warning", + "security", "mixed-content"), + # `infrastructure`: no source edit produces an origin-level redirect. + _r("security.https-redirect.missing", "infrastructure", "error", "security", + "https-redirect"), + # --- gates/contradictions.py (v0.4.0) --------------------------------- + # `advisory`: two opposite correct answers exist -- unblock it in robots.txt, + # or drop it from the sitemap -- and only the owner knows which was intended. + # Exactly parallel to seo.noindex.in-sitemap. + _r("seo.robots-sitemap.disallowed", "advisory", "error", "seo", + "robots-sitemap"), + # `advisory` for both errors: remove the noindex / restore the target, or + # repoint the canonical -- two opposite correct answers, owner's call. + _r("seo.canonical-target.noindexed", "advisory", "error", "seo", + "canonical-target"), + _r("seo.canonical-target.not-found", "advisory", "error", "seo", + "canonical-target"), + # `templated`, deliberately NOT `mechanical`: the redirect destination comes + # from a probe response, not from data already in the finding, and this release + # ships no new fix generator (see test_generators_cover_exactly_the_mechanical_tier). + _r("seo.canonical-target.redirects", "templated", "warning", "seo", + "canonical-target"), + # `advisory`: un-noindex the target, or drop it from the set. And an hreflang + # SET is an enumerated protected surface (see PROTECTED_SURFACES below). + _r("seo.hreflang-noindex.alternate", "advisory", "error", "seo", + "hreflang-noindex"), + # --- gates/onpage.py (v0.4.0) ----------------------------------------- + # `drafted` for the three that need authored content; `advisory` for the + # outline, where inserting a level and demoting one are opposite correct + # answers; `templated` for lang, whose value comes from config site.locales. + _r("seo.image-alt.missing", "drafted", "warning", "seo", "image-alt"), + _r("seo.heading-order.skipped", "advisory", "warning", "seo", "heading-order"), + _r("seo.link-text.empty", "drafted", "warning", "seo", "link-text"), + _r("seo.link-text.generic", "drafted", "info", "seo", "link-text"), + _r("seo.lang.missing", "templated", "error", "seo", "lang"), ) REGISTRY: dict[str, RegisteredFinding] = {entry.id: entry for entry in _ENTRIES} @@ -140,6 +198,41 @@ def _r(id_: str, tier: FixTier, severity: str, layer: str, gate: str, MECHANICAL_IDS: frozenset[str] = frozenset( entry.id for entry in _ENTRIES if entry.tier == "mechanical") +# Severities that can move a layer's score. `info` findings are inventory facts +# (see ERROR_COST/WARNING_COST in report.py, where info costs 0), so a gate whose +# every id is `info` can never contribute cost -- counting it in the denominator +# would put a floor under the layer's score that it could never cross. +_SCORING_SEVERITIES: frozenset[str] = frozenset({"error", "warning"}) + +# The scoring SURFACE of each layer: how many distinct gates could move its score. +# `Report.score()` divides the layer's capped cost by GATE_CAP * len(this), so one +# maxed gate always costs 1/N of its layer and a layer floors only when every one +# of its gates is maxed. Before v0.4.0 the budget was a flat 100 per layer, which +# meant seven maxed gates zeroed a layer whether it had seven gates or thirty -- +# so every gate added made saturation cheaper, and v0.4.0 adds eight to `seo`. +# Unreachable gates are excluded: a gate that can never fire must not inflate +# every score by sitting in the denominator. +SCORING_GATES_BY_LAYER: dict[str, frozenset[str]] = { + layer: frozenset( + entry.gate for entry in _ENTRIES + if entry.layer == layer and entry.reachable + and entry.severity in _SCORING_SEVERITIES + ) + for layer in sorted({entry.layer for entry in _ENTRIES}) +} + + +def scoring_gate_count(layer: str) -> int: + """How many gates could move `layer`'s score; 0 for an unknown layer. + + 0 is a legitimate answer, not an error: `offsite` and `smm` are declared in + the Layer enum and ship no gates yet. `Report.score()` floors the surface at + the number of gates it actually saw, so a zero here can never divide by zero + and can never make a real finding free. + """ + return len(SCORING_GATES_BY_LAYER.get(layer, frozenset())) + + # Surfaces where a wrong write de-indexes a business. Hard-capped at `unsafe` # regardless of tier or locator confidence, and never writable -- they can only # ever arrive as a reviewed change. Enumerated in @@ -147,6 +240,10 @@ def _r(id_: str, tier: FixTier, severity: str, layer: str, gate: str, # robots.txt and crawler directives; noindex and sitemap membership; canonical # and hreflang SETS (a single self-canonical on a single-route file is NOT a # set, and is governed by blast radius instead); and any licence grant. +# v0.4.0 adds seo.robots-sitemap.disallowed and seo.hreflang-noindex.alternate: +# the first touches robots.txt AND sitemap membership, the second an hreflang +# SET -- all three surfaces are already enumerated above, so both are +# hard-capped for the same reason. PROTECTED_SURFACES: frozenset[str] = frozenset({ "geo.ai-allowlist.missing", "geo.ai-allowlist.blocked", @@ -154,6 +251,8 @@ def _r(id_: str, tier: FixTier, severity: str, layer: str, gate: str, "seo.hreflang.no-x-default", "seo.hreflang.not-reciprocal", "geo.citation-licence.missing", + "seo.robots-sitemap.disallowed", + "seo.hreflang-noindex.alternate", }) # Capped at display-only permanently, at any tier, under any flag. Reversing a diff --git a/scripts/py/omnirank/report.py b/scripts/py/omnirank/report.py index 2f00c6e..14e2e50 100644 --- a/scripts/py/omnirank/report.py +++ b/scripts/py/omnirank/report.py @@ -9,10 +9,10 @@ from . import __version__ from .applicability import Applicability -from .registry import FixTier, tier_for +from .registry import FixTier, scoring_gate_count, tier_for Severity = Literal["error", "warning", "info"] -Layer = Literal["seo", "aeo", "geo", "offsite", "smm", "perf"] +Layer = Literal["seo", "aeo", "geo", "offsite", "smm", "perf", "security"] # Closed enum, mirrored in schemas/report.schema.json. "no-sitemap" and # "page-unreachable" are populated starting v0.2.1 (see audit.py). "not-applicable" @@ -21,8 +21,19 @@ # (an SMM platform with no adapter yet, a check that only applies to some stacks) # has a reason to report from day one instead of staying silent until someone # remembers to extend the enum. +# +# v0.4.0 adds two, both additive -- a report written before this release contains +# neither value and still validates: +# matcher-unsupported the check needs a capability this interpreter's stdlib +# does not have. urllib.robotparser only became RFC 9309 +# compliant (wildcards, longest-match) in Python 3.14; on +# 3.11-3.13 it silently gives the WRONG answer for such +# rules, so gates/contradictions.py refuses to judge rather +# than fabricate. See omnirank/robots.py. +# budget-exceeded a bounded probe pass hit its cap before reaching this URL. NotEvaluatedReason = Literal[ - "no-sitemap", "page-unreachable", "not-applicable", "adapter-absent" + "no-sitemap", "page-unreachable", "not-applicable", "adapter-absent", + "matcher-unsupported", "budget-exceeded", ] ERROR_COST = 10 @@ -134,11 +145,38 @@ def flag_not_evaluated(self, entry: NotEvaluated) -> None: self.not_evaluated.append(entry) def score(self) -> dict[str, int]: - """Per-layer score, with each gate's cost capped before summing (GATE_CAP). + """Per-layer score: capped per gate, then normalised by the layer's surface. + + Two stages, and they answer different questions. + + GATE_CAP (v0.2.1) answers "how much can ONE gate cost?" -- one gate failing + on every page of a site is ONE problem to fix, not fifty. + + The surface divisor (v0.4.0) answers "how much is one gate WORTH?" -- and + the answer has to be 1/N of the layer, not a fixed 15 out of 100. Under the + flat budget, seven maxed gates zeroed a layer whether that layer had seven + gates or thirty, so every gate added made saturation cheaper: at `seo`'s + pre-0.4.0 count of 16 it took 44% of the layer to floor it, and at 24 it + would have taken 29%. The same constant also put an unreachable FLOOR under + small layers -- `security` ships 2 scoring gates, so its worst possible + score under the flat budget was 70. Both symptoms are the same defect: a + constant budget divided among a variable number of gates. + + `surface` is floored at the number of gates actually seen, so a finding on + an unregistered gate (tests, or a gate added to code before the registry) + can never cost more than the layer has room for, and can never divide by + zero. It counts REGISTERED gates rather than gates that ran, which biases + the score upward for an audit where some gates could not run -- the + conservative direction, and `notEvaluated` is where that is reported. Grouped by (layer, gate) rather than just gate: two different layers could in principle share a gate id, and each layer's cap must apply independently to its own cost, not be shared across layers. + + The penalty is integer round-half-up, never float or `round()`: `round()` + is banker's rounding, and float division would make the result depend on + IEEE 754 detail. Both are monotone, so `min`, the sum and this division + together guarantee that adding a finding can never RAISE a score. """ raw_per_gate: dict[tuple[str, str], int] = defaultdict(int) for f in self.findings: @@ -148,10 +186,30 @@ def score(self) -> dict[str, int]: raw_per_gate[(f.layer, f.gate)] += cost costs: dict[str, int] = {layer: 0 for layer in self.layers_run} - for (layer, _gate), raw in raw_per_gate.items(): + seen_gates: dict[str, set[str]] = defaultdict(set) + for (layer, gate), raw in raw_per_gate.items(): costs[layer] = costs.get(layer, 0) + min(GATE_CAP, raw) + # A zero-cost gate (every finding on it is info-severity) needs no room + # in the budget -- it never happened in raw_per_gate at all before this + # loop, in fact, since ERROR_COST/WARNING_COST are the only nonzero + # costs and info contributes 0. Guarding on `raw` here is what stops a + # zero-cost gate from being counted as "seen" and inflating `surface`: + # without it, adding an info-only finding to a previously-unseen gate + # RAISES the score by admitting that gate into the denominator without + # it ever costing anything, re-creating exactly the floor + # SCORING_GATES_BY_LAYER's info-only exclusion (registry.py) exists to + # prevent -- and contradicting this method's own guarantee that adding + # a finding can never raise a score. + if raw: + seen_gates[layer].add(gate) + + scores: dict[str, int] = {} + for layer, cost in costs.items(): + surface = max(1, scoring_gate_count(layer), len(seen_gates[layer])) + denominator = GATE_CAP * surface + penalty = (100 * cost + denominator // 2) // denominator + scores[layer] = max(0, 100 - penalty) - scores = {layer: max(0, 100 - cost) for layer, cost in costs.items()} overall = sum(scores.values()) // len(scores) if scores else 100 return {**scores, "overall": overall} diff --git a/scripts/py/omnirank/robots.py b/scripts/py/omnirank/robots.py new file mode 100644 index 0000000..bb5f4af --- /dev/null +++ b/scripts/py/omnirank/robots.py @@ -0,0 +1,172 @@ +"""robots.txt evaluation that refuses to answer when it knows it would be wrong. + +`urllib.robotparser` is the matcher here -- no robots grammar is reimplemented -- +but it cannot be trusted unconditionally, because CPython rewrote it for RFC 9309 +in **3.14** and OmniRank's floor is 3.11. On 3.11-3.13 the stdlib parser has no +wildcard support and matches rules in FILE ORDER rather than longest-first: + + robots.txt URL 3.13 3.14 + Disallow: /*.pdf$ /a/b.pdf allow disallow + Disallow: /docs/ then Allow: /docs/pub/ /docs/pub/x disallow allow + +The first is a silent miss. The second is a FABRICATED disallow -- a wrong finding, +which is worse than a missing one and is the exact failure mode this project +defines itself against. Both would ship green on a 3.14 workstation and diverge on +every CI leg. + +So: probe the interpreter's actual behaviour, inspect the rules the site actually +published, and evaluate only when the two are compatible. Otherwise return an +unevaluated verdict and let the caller record `notEvaluated`. The probes are +behaviour tests, not version checks, so a backport or a future rewrite is picked +up automatically. + +Deliberately conservative: `Allow: /` alongside any `Disallow:` counts as +overlapping, because for a first-match parser that pairing genuinely is ambiguous. +That means many real robots.txt files go unevaluated on Python 3.11-3.13 and are +fully evaluated on 3.14+. Reporting less on an older interpreter is the correct +trade; reporting a guess would not be. + +This module emits no Findings -- it returns data. That is load-bearing: +tests/test_registry.py scans gates/*.py and audit.py for finding-id literals, and +an id defined here would be invisible to that scan. +""" +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from functools import cache +from urllib.robotparser import RobotFileParser + +# The user-agent OmniRank evaluates rules for. Googlebot rather than "*": a group +# addressed to Googlebot is the one that governs whether the page can be indexed, +# and RobotFileParser already falls back to the wildcard group when no +# Googlebot-specific group exists. +DEFAULT_AGENT = "Googlebot" + +UNSUPPORTED_WILDCARDS = "wildcard" +UNSUPPORTED_LONGEST_MATCH = "longest-match" + +_PROBE_HOST = "https://probe.invalid" + + +@dataclass(frozen=True) +class RobotsVerdict: + """Which of `urls` robots.txt disallows -- or why that could not be decided. + + `disallowed` is always empty when `evaluated` is False. Carrying a partial + judgement alongside a refusal is how a "could not check" turns into a silent + pass two call sites later. + """ + + evaluated: bool + reason: str | None + disallowed: tuple[str, ...] + + +def _groups(text: str) -> list[tuple[list[str], list[str]]]: + """(allow_paths, disallow_paths) for each consecutive User-agent group. + + Structural and permissive on purpose: this is NOT a matcher (that is + RobotFileParser's job). It answers exactly one question -- do the rules in this + file need a capability the interpreter's matcher may not have. Consecutive + `User-agent:` lines share one group, per RFC 9309. + """ + groups: list[tuple[list[str], list[str]]] = [] + allows: list[str] = [] + disallows: list[str] = [] + in_agent_run = False + + for raw in text.splitlines(): + line = raw.split("#", 1)[0].strip() + if not line or ":" not in line: + continue + field, _, value = line.partition(":") + field = field.strip().lower() + value = value.strip() + + if field == "user-agent": + if not in_agent_run and (allows or disallows): + groups.append((allows, disallows)) + allows, disallows = [], [] + in_agent_run = True + continue + + in_agent_run = False + if field == "allow" and value: + allows.append(value) + elif field == "disallow" and value: + disallows.append(value) + + if allows or disallows: + groups.append((allows, disallows)) + return groups + + +def rules_need_wildcards(text: str) -> bool: + """True when any rule PATH uses `*` or a terminal `$`. + + The `*` on a `User-agent: *` line is a group selector, not a path pattern, and + `_groups` never collects it -- treating it as one would send every robots.txt + in existence down the unevaluated path. + """ + return any( + "*" in path or path.endswith("$") + for allows, disallows in _groups(text) + for path in (*allows, *disallows) + ) + + +def rules_need_longest_match(text: str) -> bool: + """True when some group has an Allow and a Disallow whose paths overlap. + + Prefix overlap in EITHER direction: `Allow: /` versus `Disallow: /a/` is the + common real-world case and it is genuinely ambiguous under a first-match + parser, which returns whichever line appears first. Disjoint paths give the + same answer under either matcher, so those files stay evaluable everywhere. + """ + for allows, disallows in _groups(text): + for allow in allows: + for disallow in disallows: + if allow.startswith(disallow) or disallow.startswith(allow): + return True + return False + + +@cache +def matcher_supports_wildcards() -> bool: + """Does THIS interpreter's urllib.robotparser honour `*` and `$` in a path?""" + parser = RobotFileParser() + parser.parse(["User-agent: *", "Disallow: /*.pdf$"]) + return not parser.can_fetch("*", f"{_PROBE_HOST}/a/b.pdf") + + +@cache +def matcher_supports_longest_match() -> bool: + """Does THIS interpreter pick the longest matching rule rather than the first? + + The Disallow is written FIRST on purpose: a first-match parser returns it and + answers "disallowed", while RFC 9309 says the longer Allow wins. + """ + parser = RobotFileParser() + parser.parse(["User-agent: *", "Disallow: /a/", "Allow: /a/b"]) + return parser.can_fetch("*", f"{_PROBE_HOST}/a/b") + + +def disallowed_urls(robots_text: str, urls: Sequence[str], + agent: str = DEFAULT_AGENT) -> RobotsVerdict: + """Which of `urls` this robots.txt disallows to `agent`. + + Refuses -- `evaluated=False`, empty `disallowed` -- when the published rules + need a matcher capability this interpreter does not have. + """ + if rules_need_wildcards(robots_text) and not matcher_supports_wildcards(): + return RobotsVerdict(evaluated=False, reason=UNSUPPORTED_WILDCARDS, + disallowed=()) + if rules_need_longest_match(robots_text) and not matcher_supports_longest_match(): + return RobotsVerdict(evaluated=False, reason=UNSUPPORTED_LONGEST_MATCH, + disallowed=()) + + parser = RobotFileParser() + parser.parse(robots_text.splitlines()) + blocked = tuple(url for url in urls if not parser.can_fetch(agent, url)) + return RobotsVerdict(evaluated=True, reason=None, disallowed=blocked) diff --git a/scripts/py/pyproject.toml b/scripts/py/pyproject.toml index 13ab3c0..fa4696c 100644 --- a/scripts/py/pyproject.toml +++ b/scripts/py/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "omnirank" -version = "0.3.0" +version = "0.4.0" description = "SEO, AEO, GEO and SMM growth engine" requires-python = ">=3.11" license = { text = "MIT" } diff --git a/skills/audit/SKILL.md b/skills/audit/SKILL.md index 9930096..ee409b4 100644 --- a/skills/audit/SKILL.md +++ b/skills/audit/SKILL.md @@ -23,7 +23,7 @@ pre-deploy verification, or "why doesn't ChatGPT cite us?" Audit only diagnoses. It never edits the site. `omnirank fix` shows the diff it would apply for the four `mechanical` findings and -writes nothing — there is no `--write` flag in 0.3.0. Run it to answer "which file is +writes nothing — there is no `--write` flag. Run it to answer "which file is wrong?", then hand the diff to a human. ## How to run it diff --git a/skills/audit/references/gates.md b/skills/audit/references/gates.md index e43ffe3..e9b4aa2 100644 --- a/skills/audit/references/gates.md +++ b/skills/audit/references/gates.md @@ -70,6 +70,30 @@ id `geo.facts-json.invalid` (gate `facts-json`) — this one keeps the gate's fu | `schema` | Every top-level node has `@context` | warning | | `schema-fabrication` | `AggregateRating` has a non-zero `ratingCount` (or `reviewCount`) | error | | `schema-fabrication` | Every `Review` has an `author` | error | +| `schema-required` | Google's documented rich-result properties are present for Article/NewsArticle/BlogPosting, Product, FAQPage, BreadcrumbList, Organization and LocalBusiness — see below | 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`, for the same reason: the table is a hand-transcribed +snapshot with no freshness test yet, and a stale required-property table produces +fabricated errors. One finding per node, listing every missing property — not one +finding per property, and not one id per type: the fix (author the missing value) +is identical regardless of which type is short a property. + +| Type | Required (each item is one requirement; a tuple means "any one of") | +|---|---| +| Article / NewsArticle / BlogPosting | `headline`, `image`, `datePublished` | +| Product | `name`, and one of (`offers`, `aggregateRating`, `review`) | +| FAQPage | `mainEntity`, and every `Question` needs an `acceptedAnswer` | +| BreadcrumbList | `itemListElement`, and every item needs `position` and a `name` (top-level or nested under `item`) | +| Organization | `name`, `url` | +| LocalBusiness | `name`, `address` | + +A node that is only `@id` + `@type` (+ `@context`) is a reference to an entity +declared elsewhere, not a declaration missing its properties, and is never checked. "Top-level node" means each `<script type="application/ld+json">` payload, or each child of a `@graph` array — **a `@graph` container's `@context` is propagated down to @@ -83,6 +107,52 @@ hostile payload degrades instead of crashing the scan), so a `Review` or The fabrication gates are not style preferences. Unbacked ratings are a manual-action risk, and they corrode the trust the markup exists to build. +## Security (v0.4.0) + +`security.run_page(page)` derives four gates from the already-fetched response's headers +(plus a CSP `<meta http-equiv>` fallback), and `security.check_https_redirect()` makes one +extra request per audit against the site's `http://` origin. All findings carry +`layer: "security"`, a layer that did not exist before v0.4.0. + +Scope is deliberately narrow — `docs/research/2026-08-04-competitive-gap-analysis.md` §4 +draws the line at "security checked only where insecurity demonstrably breaks crawling, +indexing or rendering". Mozilla Observatory and testssl.sh already grade headers properly; +an SEO tool scoring CSP strength would be doing a job it cannot do well. + +| Gate | Finding id | Rule | Severity | +|---|---|---|---| +| `hsts` | `security.hsts.missing` | An `https://` response carries no `Strict-Transport-Security` header | info | +| `hsts` | `security.hsts.short-max-age` | `Strict-Transport-Security` present but `max-age` below `HSTS_MIN_MAX_AGE` (15,552,000 seconds / 180 days — OmniRank's own floor, not a vendor requirement) | info | +| `nosniff` | `security.nosniff.missing` | `X-Content-Type-Options` is not exactly `nosniff` | info | +| `csp` | `security.csp.absent` | No `Content-Security-Policy`, by header or `<meta http-equiv>` | info | +| `referrer-policy` | `security.referrer-policy.missing` | No `Referrer-Policy` header | info | +| `mixed-content` | `security.mixed-content.subresource` | An `https://` page requests a BLOCKABLE subresource (`script`, `iframe`, or `link rel=stylesheet\|preload\|modulepreload`) over literal `http://` | **error** | +| `mixed-content` | `security.mixed-content.passive-subresource` | An `https://` page requests an OPTIONALLY-BLOCKABLE subresource (`img`, or `link rel=icon\|apple-touch-icon\|manifest\|prefetch`) over literal `http://` | warning | +| `https-redirect` | `security.https-redirect.missing` | The site's `http://` origin does not 3xx-redirect to `https://` | **error** | + +**Four of the eight ids are `info` and cost zero points — they can never fail a build.** +They are reported as inventory facts, not graded: whether a given HSTS `max-age` or CSP is +*adequate* is a judgement about your threat model that an SEO auditor has no business +making. Only `mixed-content`'s active-subresource id and `https-redirect` are `error`, +because only those two break something OmniRank can actually observe — browsers block +mixed *active* content outright, and a non-redirecting `http://` origin gives every page a +live duplicate that splits canonical signal between two URLs. The passive-subresource id +(v0.4.0 final review, S4) is `warning`: browsers silently rewrite an `img` or icon request +to `https://` before fetching it, so it is only a confirmed failure when no `https://` +version exists at that path — something OmniRank cannot verify from the HTML alone, so it +does not claim the resource "is not loading" the way the active-content id does. +`security` enters the report's `layersRun` the same way `aeo` and `perf` do — only after +at least one page was actually fetched (v0.4.0 final review, S5; a successful +`https-redirect` probe alone no longer admits the layer, since that let `mixed-content` +be silently counted clean on a zero-page audit). A genuine `https-redirect` finding still +reaches the score map on its own merits regardless, since `Report.score()` scores any +layer with an actual finding independent of `layersRun`. + +CSP is parsed for exactly one thing beyond noting its total absence: +`upgrade-insecure-requests`, which browsers use to rewrite `http://` subresources before +requesting them, and which therefore suppresses BOTH `mixed-content` ids — this module +never grades a policy's contents. + ## Site-level (cross-URL) `site.run(pages, sitemap_urls)` needs the whole crawled set at once and cannot be @@ -110,6 +180,39 @@ Two things worth knowing before wiring these into `--fail-on`: crawled set produces no finding either way — OmniRank never reports on a gate it could not actually evaluate. +## Indexability contradictions (v0.4.0) + +`gates/contradictions.py` — defects provable from the site's own declarations, with no +external truth required: a URL submitted for crawling in the sitemap and forbidden in +`robots.txt`; a canonical pointing at a page that is noindexed, redirects, or 404s; a page +declared as an `hreflang` alternate while forbidding its own indexing. Every finding here +is 100% precision because both halves of the contradiction come from the site itself, and +every one carries `layer: "seo"`. + +| Gate | Finding id | Rule | Severity | +|---|---|---|---| +| `robots-sitemap` | `seo.robots-sitemap.disallowed` | A URL listed in `sitemap.xml` is also `Disallow`-ed to `*` in `robots.txt` | **error** | +| `canonical-target` | `seo.canonical-target.noindexed` | A page's canonical points at a URL that carries a `noindex` directive | **error** | +| `canonical-target` | `seo.canonical-target.not-found` | A canonical target returns `404` or `410` | **error** | +| `canonical-target` | `seo.canonical-target.redirects` | A canonical target itself 3xx-redirects | warning | +| `hreflang-noindex` | `seo.hreflang-noindex.alternate` | A page declares an `hreflang` alternate at a page that carries `noindex` (attached to the *declaring* page, not the noindexed target; `x-default` is exempt) | **error** | + +Two rules govern every check in this module: + +- **Never judge a URL OmniRank did not see.** A canonical target already in the crawled + set is judged from the `PageData` already held — no second fetch. A target outside it is + probed once, deduplicated across every page pointing at it, up to `MAX_CANONICAL_PROBES` + (25 per audit); anything past that budget is `budget-exceeded` in `notEvaluated`, never + silently skipped. A 5xx or transport failure on a probe is `page-unreachable`, not + `.not-found` — a transient origin error is not a missing page, and calling it one would + be a guess dressed as a finding. +- **A gate that could not run reports why, never silently.** `robots-sitemap` returns + `no-sitemap`, `page-unreachable`, or `matcher-unsupported` in `notEvaluated` rather than + a pass when it cannot reach a verdict. `matcher-unsupported` is real on Python 3.11–3.13: + `urllib.robotparser` only became RFC 9309 compliant in Python 3.14, so on earlier + interpreters OmniRank refuses to answer rather than trust a matcher it knows may ignore + wildcards or mis-order `Allow`/`Disallow` overlaps — see `omnirank/robots.py`. + ## Performance `perf.run(page)` derives every finding from the single HTTP response already captured @@ -142,6 +245,39 @@ depend on `brotli` or `zstandard`, so it never requests `br` or `zstd` via `Accept-Encoding` — an origin will not choose to send either back in response to a request that never asked for them. +## On-page (v0.4.0) + +`gates/onpage.py` — body markup that search engines and assistive technology both read. +Kept out of `seo.py`, which owns only the `<head>`'s indexing signals: canonical, title, +description, OpenGraph, hreflang. Every finding carries `layer: "seo"`. + +| Gate | Finding id | Rule | Severity | +|---|---|---|---| +| `image-alt` | `seo.image-alt.missing` | An `<img>` has no `alt` attribute at all | warning | +| `heading-order` | `seo.heading-order.skipped` | The outline jumps more than one level deeper (e.g. `h1` straight to `h3`) — only the first skip on the page is reported | warning | +| `link-text` | `seo.link-text.empty` | An `<a href>` has no accessible name (no text, `aria-label`, `title`, or `alt` on a contained `<img>`) | warning | +| `link-text` | `seo.link-text.generic` | An `<a href>`'s accessible name is a generic phrase ("click here", "read more", …) that conveys nothing on its own | info | +| `lang` | `seo.lang.missing` | `<html>` has no non-empty `lang` attribute | **error** | + +**`alt=""` is never flagged, under any circumstance.** An empty `alt` is the spec's own +way to mark an image decorative, and a check that fires on it would tell users to make +correct markup worse. `role="presentation"`, `role="none"` and `aria-hidden="true"` are +honoured the same way. Only a *missing* `alt` attribute — not an empty one — is a finding. + +**`lang` is `error`, the strongest severity in this group, because a missing `lang` makes +a DIFFERENT gate lie.** `page.lang` feeds `bands.resolve_band()`; with no `lang`, a page +written in a script with no word separators (Japanese, Thai, …) is measured against the +space-delimited English word band it structurally cannot meet, and +`aeo.answer-block.length` reports a length problem that does not exist. A missing check +here is not silence — it is a wrong finding somewhere else. + +**`link-text.generic` is `info`, and must never fail a build,** because the word list +behind it (`GENERIC_ANCHORS` in `gates/onpage.py`) is English-only by construction: on a +Bengali or Japanese page it matches nothing and the gate is silent by design, not because +the page is clean. Links inside `<nav>` are exempt from both `link-text` findings — +navigation labels are terse on purpose and take their meaning from the nav itself, and +flagging them is the kind of noise that trains users to ignore the report. + ## Fix tiers Every finding id carries a static `fixTier` in the report. Only `mechanical` findings @@ -156,4 +292,5 @@ agree: | `seo.schema.no-context` | mechanical | Adds `"@context": "https://schema.org"` to an unambiguous node | Everything else is `templated`, `drafted`, `advisory` or `infrastructure`, and is -reported rather than patched. `omnirank fix` writes nothing in 0.3.0. +reported rather than patched. `omnirank fix` writes nothing — see +[fix-preview.md](../../../docs/fix-preview.md) for the condition writing ships under. diff --git a/tests/test_audit.py b/tests/test_audit.py index 517d29e..e75588a 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -16,7 +16,8 @@ <meta property="og:title" content="A Good Title"> <meta property="og:image" content="https://x.example/og.png"> <script type="application/ld+json"> -{"@context":"https://schema.org","@type":"Organization","name":"X"} +{"@context":"https://schema.org","@type":"Organization","name":"X", + "url":"https://x.example"} </script> </head><body><h1>A Good Title</h1> <div class="answer-block">%s</div> @@ -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 = ("<!doctype html><html lang='en'><head><title>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("Pricing")) + + +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("

x

", html_attrs=" lang='bn-BD'")) + + +def test_a_fully_clean_page_emits_nothing(): + assert onpage.run( + "T" + "

A

B

A chart" + "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"]}