diff --git a/keel/web/render.py b/keel/web/render.py index fbd8419..36a6158 100644 --- a/keel/web/render.py +++ b/keel/web/render.py @@ -24,8 +24,20 @@ from collections.abc import Iterable, Sequence from decimal import Decimal from typing import Any +from urllib.parse import quote + +#: The published documentation root. Spelled here and in `static/js/docs.js`, and +#: `tests/web/test_doc_links.py` pins that the two agree. +DOCS_URL = "https://keeltrading.com/en/docs/" #: Nav order, and the labels. `/` first because the status page is the answer to "is it alive". +#: +#: **The eighth entry is an OUTBOUND link, and used to be a page (#539).** `/glossary` rendered +#: `docs/glossary.md` read from the working directory -- which no installed deployment has, since +#: `docs/` sits at the repository root, outside the `keel/` module `uv_build` packages. Every +#: install therefore rendered an empty glossary, and `help_console.load_glossary`'s docstring said +#: so. It is a link now, for the same reason the client's is: linking is the only form of this +#: that reaches an installed deployment at all. NAV: tuple[tuple[str, str], ...] = ( ("/", "Status"), ("/setup", "Setup"), @@ -34,7 +46,7 @@ ("/rules", "Rules"), ("/venues", "Venues"), ("/gates", "Gates"), - ("/glossary", "Glossary"), + (DOCS_URL, "Docs"), ) _STYLE = """ @@ -304,6 +316,7 @@ def page( path: str, body: str, build: str = "", + version: str = "", refresh_sec: int | None = None, ) -> str: """The document shell. `refresh_sec` emits a `` -- a zero-JS @@ -313,7 +326,29 @@ def page( nav_items = [] for href, label in NAV: on = ' class="on"' if href == path else "" - nav_items.append(f'{esc(label)}') + # An outbound entry is navigation, not a subresource, so `default-src 'none'` does not + # reach it. `noopener` still does matter: a new tab opened without it holds a + # `window.opener` handle back to a trading console on a token-bearing origin. + # + # `?v=` carries the running build, exactly as `static/js/docs.js` does for the client: + # the site pins `main` while an operator runs a tagged release, so a linked page can + # describe behaviour their build does not have. That skew is made VISIBLE rather than + # solved -- the build ends up in the URL bar of the page they are reading. `quote`, not + # an f-string, because a full version is `0.11.2+c1634a3fa17f` and a raw `+` in a query + # string decodes to a space. + # + # `version`, NOT `build`, and the two are different strings: `build` is the footer's + # human-readable LINE -- `keel 0.11.2+c1634a3fa17f (DIRTY) [checkout]` -- and the first + # spelling of this used it, putting that whole sentence percent-encoded into the query. + # Caught by looking at the rendered href, not by a test: both forms are non-empty + # strings and every assertion about "the link carries a version" passed. + away = "" + target = href + if href.startswith("https://"): + away = ' target="_blank" rel="noopener noreferrer"' + if version: + target = href + "?v=" + quote(version, safe="") + nav_items.append(f'{esc(label)}') nav = "".join(nav_items) meta_refresh = ( f'' if refresh_sec else "" @@ -948,34 +983,5 @@ def render_gates(gates: Sequence[Any], capabilities: Sequence[Any]) -> str: return "".join(parts) -def render_glossary(terms: Sequence[Any]) -> str: - parts = [ - '

Glossary

keel\'s vocabulary, and the fiqh terms it anchors to

', - '
', - ] - for term in terms: - marker = ' fiqh' if term.fiqh else "" - if term.fiqh and not term.stated: - marker = ' not stated in fiqh-basis' - parts.append(f"
{esc(term.term)}{marker}
") - parts.append(f"
{esc(term.definition)}
") - source = term.citation or term.source - if source: - parts.append(f'
{esc(source)}
') - parts.append("
") - if not terms: - # The normal state of an INSTALLED deployment, not a bug: `docs/glossary.md` is read from - # the working directory, and a deployment folder is a config, a database and an .env -- - # there is no docs checkout beside them. The TUI's help screen shows the same empty state - # for the same reason. Packaging the docs inside the artifact is D5's business (#438); - # until then this says which file is missing rather than implying the glossary is empty. - parts.append( - '

No glossary here. keel reads docs/glossary.md from ' - "the folder it is run in, and an installed deployment has no docs checkout beside " - "its config and database.

" - ) - return "".join(parts) - - def render_message(heading: str, detail: str) -> str: return f'

{esc(heading)}

{esc(detail)}

' diff --git a/keel/web/server.py b/keel/web/server.py index 65acdc8..4031740 100644 --- a/keel/web/server.py +++ b/keel/web/server.py @@ -280,17 +280,11 @@ def page_gates(_cfg: ServeConfig, _query: dict[str, list[str]]) -> tuple[str, st return "Gates", render.render_gates(GATES, CAPABILITIES), None -def page_glossary(_cfg: ServeConfig, _query: dict[str, list[str]]) -> tuple[str, str, int | None]: - from keel.commands.help_console import load_glossary - - return "Glossary", render.render_glossary(load_glossary()), None - - ROUTES: dict[str, Callable[[ServeConfig, dict[str, list[str]]], tuple[str, str, int | None]]] = { # First-run detection (#437): every page that reads the database serves the checklist when # there is no database to read, rather than a 500 whose real cause is that the user has not - # set anything up yet. `/venues`, `/gates` and `/glossary` are not wrapped -- none of them - # touches the deployment, and all three are useful before one exists. + # set anything up yet. `/venues` and `/gates` are not wrapped -- neither touches the + # deployment, and both are useful before one exists. "/": needs_database(page_status), "/setup": page_setup, "/activity": needs_database(page_activity), @@ -298,7 +292,6 @@ def page_glossary(_cfg: ServeConfig, _query: dict[str, list[str]]) -> tuple[str, "/rules": needs_database(page_rules), "/venues": page_venues, "/gates": page_gates, - "/glossary": page_glossary, } @@ -466,6 +459,22 @@ def run_setup_action(cfg: ServeConfig, key: str, form: dict[str, str]) -> Any: _JSON_CONTENT_TYPE = "application/json; charset=utf-8" +def _docs_version(cfg: ServeConfig) -> str: + """The build the nav's documentation link should report, or `""`. + + Read off `build_info` rather than off `cfg.build`, because those are different strings and + only one of them is a version: `cfg.build` is the footer's human-readable LINE + (`keel 0.11.2+c1634a3fa17f (DIRTY) [checkout]`), and putting it in a query string produced + `?v=keel%200.11.2%2B...%20%28DIRTY%29%20%5Bcheckout%5D`. This is the same field `/api/config` + hands the client for the same purpose (`payload.config_document`'s `"build"`), so both + front-ends report the identical string while both exist. + + `""` when there is no build info at all -- an unversioned link is honest, and a link claiming + `?v=unknown` is not. + """ + return str(getattr(cfg.build_info, "full_version", "") or "") + + def _static_headers(content_type: str) -> tuple[tuple[str, str], ...]: """`_STATIC_BASE_HEADERS` plus CSP, but ONLY when `content_type` is one of `_CSP_CONTENT_TYPES` -- see the comments on `_STATIC_BASE_HEADERS` and `_CSP_CONTENT_TYPES` @@ -571,6 +580,7 @@ def _refuse(self, code: int, heading: str, detail: str) -> None: path="", body=render.render_message(heading, detail), build=self.cfg.build, + version=_docs_version(self.cfg), ), ) @@ -906,6 +916,7 @@ def do_GET(self) -> None: # noqa: N802 - stdlib's naming, not ours path=parsed.path, body=body, build=self.cfg.build, + version=_docs_version(self.cfg), refresh_sec=refresh, ), ) diff --git a/keel/web/static/css/keel.css b/keel/web/static/css/keel.css index db9962e..de68061 100644 --- a/keel/web/static/css/keel.css +++ b/keel/web/static/css/keel.css @@ -181,6 +181,22 @@ h2 { font-size: 1.05rem; margin: 2rem 0 0.6rem; } } .kv .v { font-size: 1.05rem; font-variant-numeric: tabular-nums; } +/* #539: a label that names a documented term links out to its definition. + `--muted`, NOT `--accent`: the label is the quietest text on the card and a link colour there + would pull the eye off the figure beside it, which is the thing the operator came to read. The + dotted underline is what marks it as a link -- and it is an underline rather than colour alone, + because #532's whole finding was that this palette had been separating meanings by hue. + `text-decoration-thickness` is set because a dotted border-bottom would sit below the descenders + and read as a divider instead. */ +.kv .k .doclink { + color: inherit; + text-decoration: underline dotted; + text-underline-offset: 0.2em; + text-decoration-thickness: 1px; +} +.kv .k .doclink:hover, +.kv .k .doclink:focus-visible { color: var(--fg); text-decoration-style: solid; } + .tablewrap { overflow-x: auto; /* A scrollable region must be reachable by keyboard. `tabindex="0"` on the wrapper (set in diff --git a/keel/web/static/index.html b/keel/web/static/index.html index 52b1923..f6ec75c 100644 --- a/keel/web/static/index.html +++ b/keel/web/static/index.html @@ -71,6 +71,23 @@
  • Rules
  • Venues
  • Gates
  • + +
  • Docs
  • diff --git a/keel/web/static/js/docs.js b/keel/web/static/js/docs.js new file mode 100644 index 0000000..adeb0e1 --- /dev/null +++ b/keel/web/static/js/docs.js @@ -0,0 +1,141 @@ +// @ts-check +/** + * Outbound documentation links (#539). **The app fetches, bundles and caches nothing.** + * + * ── WHY LINKING IS THE ONLY OPTION, NOT THE CHEAP ONE ─────────────────────────────────────── + * `docs/` lives at the REPOSITORY root, outside `keel/`, and `uv_build` packages the module root + * -- so no wheel has ever carried it. Every installed deployment, the signed desktop bundle + * included, renders an empty glossary today, and `keel/commands/help_console.py` says so in its + * own docstring: "an installed deployment has no docs/ checkout, and the help screen renders + * that notice as its empty state." + * + * That is not fixable by adding a packaging glob -- it is structural. Measured at #535: building + * with `artifacts` set and with `artifacts = []` produces byte-identical wheels, because + * `uv_build` ships the whole module root regardless of the key. Linking out is the only option + * that reaches an installed deployment at all. + * + * ── NO OFFLINE FALLBACK, DELIBERATELY ─────────────────────────────────────────────────────── + * No inline definitions, no cached snapshot, no entry in the service worker's `PRECACHE`. An + * operator running a trading engine has network by definition, and #538's whole argument is that + * a cached copy of something authoritative is worse than no copy: a definition that has since + * changed, presented as current, with nothing on screen to say which it is. + * + * ── THE ANCHOR CONTRACT, AND WHY A TEST HOLDS IT ──────────────────────────────────────────── + * `docs/glossary.md` states its own rule -- "Each entry is a `## term` heading, a definition, + * and a `Source:` line" -- and Astro emits kebab-cased IDs for those headings, so the anchor for + * a term is its heading kebab-cased. Nothing in either repository enforces that from the other + * side: a heading renamed upstream would break every deep link here **silently**, because a bad + * fragment is not an error, it is a page that opens at the top. + * + * `tests/web/test_doc_links.py` closes that by parsing this table and asserting every anchor + * exists as a heading in the named document, in this repository, where `docs/` is the source. + */ + +/** The published documentation root. One string, spelled once. */ +const SITE = "https://keeltrading.com/en/docs/"; + +/** + * The running build, for `?v=`. + * + * **Version skew is made VISIBLE here, not solved.** The site pins `main` while an operator runs + * a tagged release, so a linked page can describe behaviour their build does not have. Per- + * version documentation paths were rejected: `keeltrading.com/en/docs/v0.11.0/glossary` 404s + * today, and building versioned trees is work in the other repository plus a retention policy, + * across three languages and a sitemap. Carrying the version in the query string costs nothing + * and puts the operator's build in the URL bar of the page they are reading. + * + * Module state, written exactly once, at boot, by `main.js` -- the alternative is threading a + * version string through every render function to reach the four places that build a link. + */ +let version = ""; + +/** + * Record the build every documentation link should carry. Called once from `main.js`, from the + * same `/api/config` read that fills the footer. + * + * @param {string} build + */ +export function rememberVersion(build) { + version = typeof build === "string" ? build : ""; +} + +/** + * The URL for one document, optionally at one anchor. + * + * The trailing slash on the slug is not cosmetic: the site serves `…/docs/glossary/index.html`, + * and the un-slashed form is a redirect that some browsers resolve by dropping the fragment -- + * a deep link that lands at the top of the page, which is exactly the failure this module's + * anchor table exists to prevent. + * + * @param {string} slug a document slug, or `""` for the documentation index. + * @param {string} anchor a heading anchor, or `""` for the top of the page. + * @returns {string} + */ +export function documentUrl(slug, anchor) { + let url = SITE; + if (slug) url = url + slug + "/"; + if (version) url = url + "?v=" + encodeURIComponent(version); + if (anchor) url = url + "#" + anchor; + return url; +} + +/** The documentation index, for the header's outbound link. @returns {string} */ +export function indexUrl() { + return documentUrl("", ""); +} + +/** + * The labels this client puts on screen that name a term the documentation defines, mapped to + * where it is defined. + * + * **Keyed by the LABEL, not by the term.** The alternative -- tagging each call site with a term + * name -- spreads the decision across six views and makes "which words on this screen are + * defined somewhere" unanswerable without reading all of them. Keyed by label, the whole answer + * is this table, and `kv` consults it for every pair it builds, so a label that names a term is + * a link wherever it appears without a call site knowing. + * + * **Deliberately NOT exhaustive, and the omissions are the point.** `mode` reads `paper` or + * `live` and would need two different targets for one label; `evidence required` sits on the + * CAPABILITY gates (`keel.capabilities.GATES`), not the promotion gate, and linking it to + * `#promotion-gate` would be confidently wrong. A missing link costs a reader one search. A + * wrong one costs them their trust in every other link on the page. + * + * @type {Record} + */ +export const TERMS = { + autonomy: { slug: "glossary", anchor: "autonomy" }, + "autonomy configured": { slug: "glossary", anchor: "autonomy" }, + "autonomy lapses": { slug: "glossary", anchor: "autonomy" }, + "kill switch": { slug: "glossary", anchor: "kill-switch" }, + "rail 11": { slug: "glossary", anchor: "rail" }, + "withdrawal attestation (rail 17)": { slug: "glossary", anchor: "attestation" }, + "market session": { slug: "glossary", anchor: "market-clock" }, + session: { slug: "glossary", anchor: "session-bound-venue" }, + "paper stage": { slug: "glossary", anchor: "paper-mode" }, +}; + +/** + * A label as an outbound link to its definition, or `null` if the label names no term. + * + * `rel="noopener"` with `target="_blank"`: a new tab opened without it gets a `window.opener` + * handle back to this page, and this page is a trading console on a token-bearing origin. + * `noreferrer` too -- the server already sends `Referrer-Policy: no-referrer`, and a link that + * states it as well is one that keeps holding if this markup is ever read somewhere the header + * is not sent. + * + * @param {string} label + * @returns {HTMLAnchorElement | null} + */ +export function termLink(label) { + const target = Object.prototype.hasOwnProperty.call(TERMS, label) ? TERMS[label] : null; + if (!target) return null; + const anchor = document.createElement("a"); + anchor.className = "doclink"; + anchor.textContent = label; + anchor.href = documentUrl(target.slug, target.anchor); + anchor.target = "_blank"; + anchor.rel = "noopener noreferrer"; + // Named for a reader who arrives on the link out of context, and hears only the link text. + anchor.title = "Definition on keeltrading.com — opens in a new tab"; + return anchor; +} diff --git a/keel/web/static/js/main.js b/keel/web/static/js/main.js index be84587..bbd9eb2 100644 --- a/keel/web/static/js/main.js +++ b/keel/web/static/js/main.js @@ -34,6 +34,7 @@ */ import { read } from "./api.js"; +import { indexUrl, rememberVersion } from "./docs.js"; import { available, subscribe } from "./live.js"; import { activityView, @@ -127,6 +128,8 @@ const engineNode = must("engine"); const contentNode = must("content"); /** @type {HTMLElement} */ const buildNode = must("build"); +/** The header's outbound documentation link (#539); its href gains `?v=` once the build is known. */ +const docsNode = /** @type {HTMLAnchorElement} */ (must("docs-link")); /** * An element that `index.html` guarantees. Throwing beats rendering half a page: the two files @@ -619,16 +622,26 @@ function registerWorker(config) { } /** - * The footer's build line, read once. + * The build, read once, and the three things that depend on it. * * Once, not per poll: `/api/config` describes the binary that is answering, and that cannot * change without the process restarting -- at which point the session token is new, every fetch * is a 403, and the banner says so. A version string re-read four times a minute would be four * times a minute spent confirming a constant. + * + * **The first view is painted from INSIDE this callback (#539), and that ordering is deliberate.** + * Every documentation link carries `?v=` (`docs.rememberVersion`), and a link built before + * the build is known would carry no version until the next poll -- fifteen seconds of links that + * quietly do not say which build the reader is running, on exactly the first screen they see. + * `/api/config` is the one endpoint that opens no database, so this costs a single round trip on + * a loopback socket, and it cannot hang the app: `api.read` resolves with a stopped reading + * rather than rejecting, so `show` runs even with nothing listening on the port. */ void read("config").then((reading) => { - buildLine(buildNode, reading.data); - registerWorker(reading.data); + const config = reading.data; + rememberVersion((config && (config.build || config.version)) || ""); + docsNode.href = indexUrl(); + buildLine(buildNode, config); + registerWorker(config); + show(booted, false); }); - -show(booted, false); diff --git a/keel/web/static/js/render.js b/keel/web/static/js/render.js index 64f7d11..dd7efe2 100644 --- a/keel/web/static/js/render.js +++ b/keel/web/static/js/render.js @@ -56,6 +56,7 @@ */ import { equityChart } from "./chart.js"; +import { termLink } from "./docs.js"; import { instant } from "./format.js"; /** @@ -158,7 +159,14 @@ function plain(value) { */ function kv(label, value) { const wrap = el("div", "kv"); - wrap.append(el("span", "k", label)); + // #539: a label that names a documented term becomes an outbound link to its definition, + // here rather than at each call site. `docs.TERMS` is the whole answer to "which words on + // this screen are defined somewhere", and a label it does not know stays plain text. + const linked = termLink(label); + const key = el("span", "k"); + if (linked) key.append(linked); + else key.textContent = label; + wrap.append(key); const holder = el("span", "v"); if (value instanceof Node) holder.append(value); else if (typeof value === "string") holder.textContent = value; diff --git a/keel/web/static/sw.js b/keel/web/static/sw.js index 82507f5..a7c463c 100644 --- a/keel/web/static/sw.js +++ b/keel/web/static/sw.js @@ -70,6 +70,7 @@ const PRECACHE = [ `${BASE}css/keel.css`, `${BASE}js/api.js`, `${BASE}js/chart.js`, + `${BASE}js/docs.js`, `${BASE}js/format.js`, `${BASE}js/live.js`, `${BASE}js/main.js`, diff --git a/tests/commands/test_console_thinness.py b/tests/commands/test_console_thinness.py index 0491a49..51cbc01 100644 --- a/tests/commands/test_console_thinness.py +++ b/tests/commands/test_console_thinness.py @@ -102,10 +102,17 @@ def _console_module_paths() -> list[str]: #: hand-rolling percent-decoding on attacker-influenced input, which is a strictly worse trade #: than one named, scoped allowance. #: -#: Scoped to the module and the exact import, so it cannot widen: `urllib.request` in either file +#: `render` joined them at #539, for the same reason in the other direction: it BUILDS a query +#: string rather than splitting one. The nav's documentation link carries `?v=`, a full +#: version is `0.11.2+c1634a3fa17f`, and a raw `+` in a query string decodes to a space -- so the +#: choice was `quote(build, safe="")` or a hand-rolled encoder for "the characters a version +#: string might contain", which is a guess about an alphabet rather than a rule about one. +#: (`render` is deleted at #540 and this entry goes with it.) +#: +#: Scoped to the module and the exact import, so it cannot widen: `urllib.request` in any of them #: still fails, and `urllib.parse` anywhere else still fails. RULE5_IMPORT_ALLOWLIST: frozenset[tuple[str, str]] = frozenset( - {("server", "urllib.parse"), ("staticfiles", "urllib.parse")} + {("server", "urllib.parse"), ("staticfiles", "urllib.parse"), ("render", "urllib.parse")} ) diff --git a/tests/web/test_client_assets.py b/tests/web/test_client_assets.py index 29d80b8..22e46f1 100644 --- a/tests/web/test_client_assets.py +++ b/tests/web/test_client_assets.py @@ -52,17 +52,16 @@ #: appearing under `js/` without a test author noticing fails `test_the_client_ships_exactly_the_ #: declared_modules` rather than shipping unexamined. #: -#: `chart.js` and `live.js` arrived at #537, which is what the previous revision of this line -#: predicted ("a fifth module is a design decision (#537 adds `chart`, `live`, `docs`, `sw`) and -#: should arrive with the list updated, not silently"). `docs` (#539) is still to come, and its -#: absence here is what will make it arrive the same way. +#: `chart.js` and `live.js` arrived at #537 and `docs.js` at #539, which is what the first +#: revision of this line predicted ("a fifth module is a design decision (#537 adds `chart`, +#: `live`, `docs`, `sw`) and should arrive with the list updated, not silently"). #: #: **`sw` arrived at #538 and is deliberately NOT in this list**, because it is not under `js/`: #: a service worker's scope is its own directory, so `js/sw.js` would be scoped to `/static/js/` #: and could not answer a navigation to `/static/insights`. It sits at the static root instead, #: and `tests/web/test_pwa.py::test_the_worker_is_served_from_the_scope_it_must_control` asserts #: that placement rather than leaving it to whoever next reads the spec's file list. -_MODULES = ("main.js", "api.js", "render.js", "chart.js", "live.js", "format.js") +_MODULES = ("main.js", "api.js", "render.js", "chart.js", "live.js", "format.js", "docs.js") #: The modules held to "no arithmetic, no judgement, no derived display string", and the ONLY two. #: @@ -567,9 +566,31 @@ def test_the_client_loads_nothing_from_a_third_party_origin() -> None: a browser. This asserts we never ship the attempt, so the header is never the only thing standing between the page and an external request.""" html = _markup_only(_INDEX.read_text(encoding="utf-8")) - remote = re.findall(r'(?:src|href)="((?:https?:)?//[^"]*)"', html) + + # **`` is navigation, not a load, and #539 makes that distinction load-bearing.** The + # documentation link opens keeltrading.com in a new tab; nothing is fetched into this page, + # nothing is bundled and nothing is cached, so `default-src 'self'` is untouched by it (the + # spec: "Outbound links are navigation, not connections"). Every OTHER way a URL can appear in + # this markup is a subresource, and those stay same-origin: `src` on a script or an image, + # and `href` on a `` -- the stylesheet, the manifest and the icons. + # + # The check is narrowed rather than dropped. An earlier spelling of this test matched `href` + # anywhere, which would now pass only by listing the docs URL as an exception -- and an + # exception list is what turns a rule into a habit of adding to a list. + loads = re.findall(r'<(?:script|img|iframe|source|embed)\b[^>]*\bsrc="([^"]*)"', html) + loads += re.findall(r']*\bhref="([^"]*)"', html) + remote = [url for url in loads if url.startswith(("http://", "https://", "//"))] assert remote == [], f"index.html loads from another origin: {remote}" + # And what IS allowed outbound is exactly one link, to the documentation, opened safely. + anchors = re.findall(r"]*>", html) + outbound = [tag for tag in anchors if re.search(r'href="(?:https?:)?//', tag)] + assert len(outbound) == 1, f"expected one outbound link, found {len(outbound)}: {outbound}" + assert 'href="https://keeltrading.com/en/docs/"' in outbound[0], outbound[0] + # `noopener` is the one that matters: a tab opened without it holds a `window.opener` handle + # back to a trading console on a token-bearing origin. + assert 'rel="noopener noreferrer"' in outbound[0], outbound[0] + css = _CSS.read_text(encoding="utf-8") assert "@import" not in css, "a stylesheet that imports another can import a remote one" # `url()` covers fonts, background images and cursors in one check. There are none today, and diff --git a/tests/web/test_doc_links.py b/tests/web/test_doc_links.py new file mode 100644 index 0000000..ba5ece4 --- /dev/null +++ b/tests/web/test_doc_links.py @@ -0,0 +1,335 @@ +"""Documentation is linked, never embedded (#539) -- and the links are checked against `docs/`. + +**Why this module exists at all.** A broken deep link is not an error. `…/glossary/#rial` opens +the glossary at the top of the page, looking exactly like a link to a term that happens not to +scroll, and nothing anywhere reports it. keel's `docs/` is the SOURCE and keeltrading.com is the +mirror (`engine-docs.manifest.json` pins `CodeGateSoftware/keel@main`), so this repository is the +one place where a rename and the links that depend on it can be compared at all -- and this is +the comparison. + +The anchor contract is the one `docs/glossary.md` states about itself: "Each entry is a `## term` +heading, a definition, and a `Source:` line." Astro's slugger kebab-cases those headings into +ids, and `_slug` below reproduces that transformation. If the site ever changes slugger, this +module is what fails. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from keel.web import render, staticfiles + +_REPO = Path(__file__).resolve().parents[2] +_DOCS = _REPO / "docs" +_DOCS_JS = staticfiles.STATIC_ROOT / "js" / "docs.js" +_RENDER_JS = staticfiles.STATIC_ROOT / "js" / "render.js" +_INDEX = staticfiles.STATIC_ROOT / "index.html" + +#: Slug -> the document in THIS repository it is published from. Mirrors +#: `keeltrading.com/engine-docs.manifest.json`, which is the site's own pin of the same pairs. +#: Only the slugs the app links to need an entry; a link to a slug absent here fails below, +#: which is the correct outcome for a link to a document nobody has confirmed is published. +_PUBLISHED: dict[str, str] = { + "glossary": "glossary.md", + "fiqh-basis": "fiqh-basis.md", + "operator-runbook": "operator-runbook.md", + "go-live-runbook": "go-live-runbook.md", +} + + +def _slug(heading: str) -> str: + """A `## heading` as the id the built site gives it. + + Lowercase, drop everything that is not a letter, digit, space or hyphen, then spaces to + hyphens -- GitHub's slugger, which is what `rehype-slug` implements and what the built site + was verified to emit (`id="rail"`, `id="instrument-attestation"`, `id="kill-switch"`). + """ + text = heading.strip().lower() + text = re.sub(r"[^\w\s-]", "", text, flags=re.UNICODE) + return re.sub(r"[\s_]+", "-", text).strip("-") + + +def _anchors(document: str) -> set[str]: + """Every anchor a document offers, from its `##`-and-deeper headings.""" + source = (_DOCS / document).read_text(encoding="utf-8") + return {_slug(match) for match in re.findall(r"^#{2,6}\s+(.+)$", source, re.MULTILINE)} + + +def _terms() -> dict[str, tuple[str, str]]: + """`docs.js`'s `TERMS`, as `{label: (slug, anchor)}`. + + Parsed rather than imported, for the same reason `test_client_assets.py` parses `main.js`'s + route table: there is no JavaScript runtime here. The parse is narrow on purpose, and + `test_the_term_parser_actually_found_something` is what stops a rewritten table from silently + matching nothing. + """ + source = _DOCS_JS.read_text(encoding="utf-8") + block = re.search(r"export const TERMS = \{(.*?)\n\};", source, re.DOTALL) + assert block is not None, "TERMS is not in the shape this parser understands" + found: dict[str, tuple[str, str]] = {} + pattern = re.compile( + r'^\s*(?:"(?P[^"]+)"|(?P[A-Za-z_][\w$]*))\s*:\s*' + r'\{\s*slug:\s*"(?P[^"]+)"\s*,\s*anchor:\s*"(?P[^"]+)"\s*\}\s*,\s*$' + ) + for line in block.group(1).splitlines(): + if not line.strip() or line.strip().startswith("//"): + continue + match = pattern.match(line) + assert match is not None, f"unparsed TERMS entry: {line}" + label = match.group("quoted") or match.group("bare") + found[label] = (match.group("slug"), match.group("anchor")) + return found + + +# -- the acceptance criterion ---------------------------------------------------------------------- + + +def test_every_anchor_the_app_emits_exists_in_the_source_document() -> None: + """**The acceptance criterion, and the reason a rename upstream cannot break a link quietly.** + + Each `(slug, anchor)` the client can emit is resolved to a document in `docs/` and checked + against the headings that document actually has. + """ + for label, (slug, anchor) in sorted(_terms().items()): + assert slug in _PUBLISHED, ( + f"{label!r} links to slug {slug!r}, which is not a published document -- add it to " + "_PUBLISHED here and to keeltrading.com's engine-docs.manifest.json, or link " + "somewhere that exists" + ) + available = _anchors(_PUBLISHED[slug]) + assert anchor in available, ( + f"{label!r} links to #{anchor} in docs/{_PUBLISHED[slug]}, which has no such heading. " + f"A heading was probably renamed; the link would open the page at the top and report " + f"nothing. Closest available: {sorted(a for a in available if anchor[:4] in a)}" + ) + + +def test_the_anchor_check_would_notice_a_renamed_heading() -> None: + """Mutation: the assertion above compares against real headings, not against anything. + + Without this, a `_anchors` that returned everything -- or a regex that matched nothing and so + made the set empty in a way `in` happened to tolerate -- would leave the criterion green and + meaningless. + """ + real = _anchors("glossary.md") + assert "kill-switch" in real, "the heading parser found no known term" + assert "kill-switch-renamed-upstream" not in real + + +def test_the_term_parser_actually_found_something() -> None: + """Guards every assertion that iterates `TERMS` against an empty parse.""" + terms = _terms() + assert len(terms) >= 5, terms + assert terms["kill switch"] == ("glossary", "kill-switch") + + +def test_the_slugger_matches_the_ids_the_built_site_emits() -> None: + """The transformation, pinned against ids observed in the built site rather than assumed. + + `dist/en/docs/glossary/index.html` was checked at #531 and carries `id="rail"`, + `id="attestation"`, `id="instrument-attestation"`, `id="kill-switch"`, `id="qabd"`, + `id="riba"`. These are those cases run backwards through `_slug`. + """ + assert _slug("rail") == "rail" + assert _slug("instrument attestation") == "instrument-attestation" + assert _slug("kill switch") == "kill-switch" + assert _slug("qabd") == "qabd" + assert _slug("DCA benchmark") == "dca-benchmark" + assert _slug("session-bound venue") == "session-bound-venue" + + +# -- the table cannot rot ---------- + + +def test_every_linked_label_is_a_label_the_client_actually_puts_on_screen() -> None: + """The other direction, and the one that keeps the table honest as views change. + + `kv` links a label by looking it up, so a label renamed in `render.js` does not break -- it + just silently stops being a link, and the entry here becomes an entry for a label that no + longer exists. This is what turns that into a failure. + """ + source = _RENDER_JS.read_text(encoding="utf-8") + emitted = set(re.findall(r'kv\("([^"]*)"', source)) + assert emitted, "no kv labels found -- this test would prove nothing" + for label in sorted(_terms()): + assert label in emitted, ( + f"{label!r} is in docs.TERMS but no view emits it as a kv label; it was probably " + "renamed in render.js, where the link would have vanished without a word" + ) + + +def test_the_ambiguous_labels_are_deliberately_absent() -> None: + """A wrong link costs more than a missing one, and these two are the wrong ones. + + `mode` reads `paper` or `live` -- one label, two definitions, and no way to pick. `evidence + required` sits on the CAPABILITY gates (`keel.capabilities.GATES`), not the promotion gate, so + `#promotion-gate` would be confidently and invisibly wrong. Pinned so that "the table looks + incomplete" never becomes a reason to complete it. + """ + terms = _terms() + assert "mode" not in terms + assert "evidence required" not in terms + + +# -- nothing is fetched, bundled or cached ---------- + + +def test_the_app_fetches_no_documentation() -> None: + """`docs.js` builds URLs and returns anchors. It opens no connection. + + `test_client_assets.py::test_fetch_appears_in_exactly_one_client_module` already pins that + `fetch` lives only in `api.js`; this is the narrower statement the issue asks for -- the + documentation module in particular never reads a document. + """ + source = _DOCS_JS.read_text(encoding="utf-8") + code = "\n".join( + line for line in source.splitlines() if not line.lstrip().startswith(("*", "/*", "//")) + ) + for forbidden in ("fetch(", "XMLHttpRequest", "EventSource", "import("): + assert forbidden not in code, f"docs.js reaches the network via {forbidden}" + + +def test_no_documentation_is_precached() -> None: + """#538's worker must not hold a copy either. A cached definition that has since changed, + presented as current, is the same failure as a cached balance in a milder register.""" + precache = (staticfiles.STATIC_ROOT / "sw.js").read_text(encoding="utf-8") + assert "keeltrading.com" not in precache + for name in sorted(p.name for p in _DOCS.glob("*.md")): + assert name not in precache, f"{name} is precached; documentation is linked, not shipped" + + +def test_no_documentation_prose_ships_inside_the_client() -> None: + """The definitions themselves stay in `docs/`. A copy in the client is a second source that + drifts, and drifts silently, because nothing compares them.""" + glossary = (_DOCS / "glossary.md").read_text(encoding="utf-8") + definitions = [ + line.strip() + for line in glossary.splitlines() + if len(line.strip()) > 60 and not line.startswith(("#", "Source:", "-", ">")) + ] + assert definitions, "no definitions found in the glossary -- this test would prove nothing" + client = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted(staticfiles.STATIC_ROOT.rglob("*")) + if path.is_file() and path.suffix in (".js", ".html", ".css") + ) + for definition in definitions: + assert definition not in client, ( + f"a glossary definition is embedded in the client: {definition[:60]!r}" + ) + + +# -- the version, and the one URL ---------- + + +def test_links_carry_the_running_version() -> None: + """Version skew is made visible rather than solved: the site pins `main` while an operator + runs a tagged release, so the build goes in the URL bar of the page they are reading.""" + source = _DOCS_JS.read_text(encoding="utf-8") + assert 'url = url + "?v=" + encodeURIComponent(version)' in source + main = (staticfiles.STATIC_ROOT / "js" / "main.js").read_text(encoding="utf-8") + assert "rememberVersion(" in main, "nothing ever tells docs.js which build is running" + + +def test_the_first_paint_already_knows_the_version() -> None: + """`show` is called from inside the `/api/config` callback, so no link is ever built before + the build is known -- otherwise the first screen an operator sees carries links that do not + say which build they are reading about, for a whole poll interval.""" + main = (staticfiles.STATIC_ROOT / "js" / "main.js").read_text(encoding="utf-8") + config_block = main[main.index('void read("config")') :] + assert "show(booted, false);" in config_block, ( + "the first paint no longer waits for the build; documentation links would be unversioned " + "until the first poll" + ) + assert main.count("show(booted, false);") == 1, "the first paint happens twice" + + +def test_the_rendered_pages_version_their_documentation_link_too(running) -> None: # type: ignore[no-untyped-def] + """Both front-ends exist until #540, and the criterion is not "the client carries `?v=`". + + `quote(..., safe="")` rather than an f-string: a full version is `0.11.2+c1634a3fa17f`, and a + raw `+` in a query string decodes to a space. + """ + from tests.web.test_server import _request, _session + + _status, _headers, body = _request(running, "/", cookie=_session(running)) + assert render.DOCS_URL in body + # The fixture's build may be empty, in which case no `?v=` is correct -- an empty version is + # not a version to report. Asserted through the renderer instead, where a build is present. + versioned = render.page( + title="t", + path="/", + body="", + # The two are DIFFERENT strings, and passing both here is the point of this assertion: + # `build` is the footer's human-readable line and the first spelling of this feature put + # it in the query, yielding `?v=keel%200.11.2%2B...%20%28DIRTY%29%20%5Bcheckout%5D`. + build="keel 0.11.2+c1634a3fa17f (DIRTY) [checkout]", + version="0.11.2+c1634a3fa17f", + ) + assert render.DOCS_URL + "?v=0.11.2%2Bc1634a3fa17f" in versioned, ( + "the rendered nav's documentation link carries no version, or carries a raw `+`" + ) + assert "keel%200.11.2" not in versioned, "the build LINE leaked into the query string" + assert 'rel="noopener noreferrer"' in versioned + + +def test_the_documentation_root_is_spelled_the_same_in_both_front_ends() -> None: + """`render.py` and `docs.js` both link out while both front-ends exist. Two spellings is two + things to update at #540, and one of them would be missed.""" + js = _DOCS_JS.read_text(encoding="utf-8") + site = re.search(r'const SITE = "([^"]+)"', js) + assert site is not None + assert site.group(1) == render.DOCS_URL + assert render.DOCS_URL in _INDEX.read_text(encoding="utf-8") + + +# -- the deletions ---------- + + +def test_the_glossary_page_is_gone() -> None: + """`/glossary` rendered a file no installed deployment has. A link replaced it.""" + from keel.web import server + + assert "/glossary" not in server.ROUTES + assert not hasattr(render, "render_glossary") + assert not hasattr(server, "page_glossary") + + +def test_the_web_layer_no_longer_reads_the_glossary_file() -> None: + """`help_console.load_glossary` stays for the TUI until #541, but nothing under `keel/web/` + calls it any more -- the whole point being that the file is not there to read. + + Read through `ast` rather than by substring, and the difference is not fastidiousness: the + NAV comment in `render.py` explains this deletion by NAMING `load_glossary`, and a substring + scan would fail on the prose that documents the change. An AST sees identifiers, so a + docstring can say the word and only a call can fail the test. + """ + import ast + + for path in sorted((_REPO / "keel" / "web").rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + used = { + node.id if isinstance(node, ast.Name) else node.attr + for node in ast.walk(tree) + if isinstance(node, (ast.Name, ast.Attribute)) + } + used |= { + alias.name + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + for alias in node.names + } + assert "load_glossary" not in used, path + assert "parse_glossary" not in used, path + + +@pytest.mark.parametrize("path", ["/glossary", "/static/glossary"]) +def test_the_glossary_path_is_a_404_on_both_front_ends(path: str, running) -> None: # type: ignore[no-untyped-def] + """Over the wire, on the rendered pages and on the client's own prefix.""" + from tests.web.test_server import _request, _session + + status, _headers, _body = _request(running, path, cookie=_session(running)) + assert status == 404, path diff --git a/tests/web/test_pwa.py b/tests/web/test_pwa.py index 9653268..e4e08f8 100644 --- a/tests/web/test_pwa.py +++ b/tests/web/test_pwa.py @@ -332,12 +332,13 @@ def test_the_client_registers_the_worker_only_after_a_successful_config_read() - leave the installed worker alone: it is the thing letting the operator read the page. """ main = (_STATIC / "js" / "main.js").read_text(encoding="utf-8") - assert "registerWorker(reading.data)" in main, ( + config_block = main[main.index('void read("config")') :] + assert "registerWorker(config)" in config_block, ( "the worker is not registered from the config read" ) assert re.search( r"const build = \(config && \(config\.build \|\| config\.version\)\) \|\| \"\";", main - ) + ), "registerWorker no longer derives the build from the config document" assert "if (!build) return;" in main, "a failed config read must register nothing" diff --git a/tests/web/test_render.py b/tests/web/test_render.py index f2999e2..a8f3cb4 100644 --- a/tests/web/test_render.py +++ b/tests/web/test_render.py @@ -11,7 +11,6 @@ from decimal import Decimal from keel.commands.brokers import BrokerInfo -from keel.commands.help_console import GlossaryTerm from keel.web import render XSS = '' @@ -70,20 +69,13 @@ def test_a_failed_adapter_row_shows_the_error_and_not_the_placeholders() -> None assert "WIRED" not in html -def test_a_fiqh_term_that_fiqh_basis_does_not_state_says_so() -> None: - """`stated=False` means fiqh-basis does not define the term and the glossary entry says that - rather than substituting a help-authored summary. Losing that marker in a new front-end would - turn a disclaimed gap into an apparent citation.""" - term = GlossaryTerm( - term="something", - definition="fiqh-basis does not state this.", - source="", - citation=None, - fiqh=True, - stated=False, - ) - html = render.render_glossary([term]) - assert "not stated in fiqh-basis" in html +# `test_a_fiqh_term_that_fiqh_basis_does_not_state_says_so` lived here and went with +# `render_glossary` at #539. The property it protected did NOT go: the "not stated" disclaimer is +# written into the definition text in `docs/glossary.md` itself -- which is why +# `help_console.parse_glossary` can DERIVE `stated` from it (`stated = "not stated" not in +# source.lower()`), and why `tests/commands/test_help_console.py` asserts it on the gharar entry. +# A reader following the deep link lands on that prose. What was deleted is a renderer for a file +# no installed deployment has ever had. def test_utc_is_used_and_a_broken_timestamp_does_not_raise() -> None: diff --git a/tests/web/test_server.py b/tests/web/test_server.py index fa2051d..714f1a7 100644 --- a/tests/web/test_server.py +++ b/tests/web/test_server.py @@ -35,7 +35,6 @@ "/rules", "/venues", "/gates", - "/glossary", ) @@ -969,10 +968,19 @@ def test_the_printed_url_is_the_one_that_carries_the_token( def test_the_nav_and_the_routing_table_agree() -> None: """A page with no nav entry is unreachable; a nav entry with no page is a 404 the user is - invited to click. Neither is caught by testing either side alone.""" + invited to click. Neither is caught by testing either side alone. + + **The nav has one entry that is deliberately not a route (#539).** `Docs` links out to + keeltrading.com, because `docs/` has never shipped inside a wheel and the page that used to + render it was empty in every installed deployment. It is separated here by its scheme rather + than by its label, so a second outbound entry needs no edit and an internal entry that loses + its route still fails.""" from keel.web import render - assert {href for href, _label in render.NAV} == set(web_server.ROUTES) + internal = {href for href, _label in render.NAV if not href.startswith("https://")} + outbound = {href for href, _label in render.NAV if href.startswith("https://")} + assert internal == set(web_server.ROUTES) + assert outbound == {render.DOCS_URL}, "an unexpected outbound nav entry" assert set(ROUTES) == set(web_server.ROUTES), "this test module's list drifted from the server"