From 25d4edeadc76352b62811f799a498d36d8334b7f Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:40:25 +0200 Subject: [PATCH 1/9] Exempt hidden analytics beacons from the alt-text check The indexability suite already exempted the analytics beacon by hostname ("static.scarf.sh"). That predicate went stale when the pixel moved to our own CNAME (pixel.weaviate.cloud), so test_images_have_alt_text started failing on /weaviate/concepts/data-import, /cloud/quickstart and /cloud/manage-clusters/create. Repair the exemption structurally instead of by hostname: skip images hidden from both rendering and the accessibility tree (inline display:none or visibility:hidden, aria-hidden="true") and 1x1 tracking pixels. Alt text is meaningless on an image no reader, screen reader or crawler can reach, and the rule survives the next domain move. The scarf.sh case is kept. Content images missing alt text are still caught. Also mark the beacon itself as decorative in scarf.js with alt="" and aria-hidden="true", which is the correct markup for a hidden pixel. --- src/scripts/scarf.js | 5 +++++ tests/test_docs_indexability.py | 35 ++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/scripts/scarf.js b/src/scripts/scarf.js index 6e0d0384..af63b293 100644 --- a/src/scripts/scarf.js +++ b/src/scripts/scarf.js @@ -5,6 +5,11 @@ const scarfScript = { src: "https://pixel.weaviate.cloud/a.png?x-pxid=a41b0758-a3a9-4874-a880-8b5d5a363d40", referrerPolicy: "no-referrer-when-downgrade", style: "display: none;", + // Decorative, hidden analytics beacon: empty alt plus aria-hidden keeps it + // out of the accessibility tree instead of leaving assistive technology to + // announce an image with no alt attribute. + alt: "", + "aria-hidden": "true", }, }; diff --git a/tests/test_docs_indexability.py b/tests/test_docs_indexability.py index 56b3af6c..63afde1b 100644 --- a/tests/test_docs_indexability.py +++ b/tests/test_docs_indexability.py @@ -251,7 +251,17 @@ def _is_decorative_image(img) -> bool: # Skip language/site logo SVGs (e.g., /img/site/logo-py.svg) if src.endswith(".svg") and "/img/site/" in src: return True - # Skip analytics tracking pixels (e.g., Scarf) + # Skip images that carry no content by construction: analytics beacons, + # spacers, and anything hidden from both rendering and the accessibility + # tree. Such an image conveys nothing to a reader, a screen reader, or a + # crawler, so requiring alt text on it is meaningless. This is matched + # structurally rather than by hostname on purpose: the previous + # "static.scarf.sh" check went stale the moment the beacon moved to our own + # CNAME, and any hostname list will go stale again on the next move. + if _is_hidden_non_content(img): + return True + # Retained for the Scarf-hosted pixel, in case it is ever embedded without + # the hidden styling that the structural check above relies on. if "static.scarf.sh" in src: return True # Skip very small images (likely icons) @@ -262,6 +272,29 @@ def _is_decorative_image(img) -> bool: return False +def _is_hidden_non_content(img) -> bool: + """Check if an image is hidden from users and from the accessibility tree. + + Covers the ways a non-content image announces itself: + - inline `display: none` / `visibility: hidden` (removed from the render + tree, and therefore from the accessibility tree) + - `aria-hidden="true"` (explicitly withheld from assistive technology) + - 1x1 dimensions (a tracking pixel or a spacer) + """ + style = (img.get("style") or "").lower().replace(" ", "") + if "display:none" in style or "visibility:hidden" in style: + return True + + if str(img.get("aria-hidden", "")).lower() == "true": + return True + + dims = (img.get("width"), img.get("height")) + if all(d is not None and str(d).strip().isdigit() and int(d) <= 1 for d in dims): + return True + + return False + + @pytest.mark.indexability @pytest.mark.parametrize("path", ALL_PATHS) def test_llm_notice_present(path): From c1d1e0f30b31c5e116512822b0b1f7b922d9ca03 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:56:34 +0200 Subject: [PATCH 2/9] Wait for the geo write to land before the filter example queries The FilterByGeolocation block deletes a collection, recreates it, inserts one object and immediately queries it with a geo filter. That was safe while the block ran against a local instance, but it now runs against the shared remote cluster, where the object is not always visible the moment insert returns. The query then matched nothing and `assert len(response.objects) == 1` failed. The expected count of 1 is correct: the object sits 403 m from the query centre and the radius is 1000 m. Verified against a local instance (match cut-off falls between 400 m and 405 m) and against the CI run of 2026-07-18, which passed and printed exactly that one object. Add a bounded readiness wait to the test scaffolding, above the START marker so the rendered example is unchanged, and keep the assertion exact. --- _includes/code/howto/search.filters.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/_includes/code/howto/search.filters.py b/_includes/code/howto/search.filters.py index 10e924e2..f18a6581 100644 --- a/_includes/code/howto/search.filters.py +++ b/_includes/code/howto/search.filters.py @@ -573,6 +573,24 @@ }, ) +# Test scaffolding below; not rendered in the docs. +# This block used to run against a local instance, where a write is queryable +# the moment `insert` returns. It now runs against a remote cluster, where it is +# not, so the example could query the collection before the object was visible +# and get back nothing. Wait (bounded) for the write to land, so the assertion +# tests the geo filter and not write visibility. The assertion stays exact. +import time +from weaviate.classes.query import Filter, GeoCoordinate + +readiness_filter = Filter.by_property("headquartersGeoLocation").within_geo_range( + coordinate=GeoCoordinate(latitude=52.39, longitude=4.84), + distance=1000 +) +for _ in range(30): + if len(publications.query.fetch_objects(filters=readiness_filter).objects) >= 1: + break + time.sleep(1) + # START FilterbyGeolocation from weaviate.classes.query import Filter from weaviate.classes.query import GeoCoordinate From 0392d4d8c684f9a3ebd9c8214c95e5884e5caa5f Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:02:08 +0200 Subject: [PATCH 3/9] Scope the alt-text check to page content, not navbar chrome test_images_have_alt_text collected every img in the document, so on pages with no content images the only survivor was the Ask AI button logo in the navbar. The check passed on three pages by asserting on site furniture that is identical everywhere, and `len(content_images) > 0` held at exactly 1, so any navbar change would have flipped it red for no reason connected to those pages. Collect images from the main content region instead. That drops the navbar and footer without calling the Ask AI logo decorative, and it leaves the existing decorative filter untouched. Two of the labelled pages then have no content image at all and the third has only language-tab icons, so the check now skips them by name with a reason and records them in PAGES_MISSING_CONTENT_IMAGES. The label stays on the pages. Any other page labelled `images` without one fails, so a stale label cannot pass quietly. Add two pages that do carry screenshots, so the check enforces alt text on real images: 3 on the query tool page and 4 on tenant states. Verified that stripping their alt text fails the check, and that stripping alt from the Ask AI logo does not. --- tests/test_docs_indexability.py | 64 +++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/tests/test_docs_indexability.py b/tests/test_docs_indexability.py index 63afde1b..0721a74b 100644 --- a/tests/test_docs_indexability.py +++ b/tests/test_docs_indexability.py @@ -34,12 +34,31 @@ ("/weaviate/concepts/data-import", {"images"}), ("/cloud/quickstart", {"code", "images"}), ("/cloud/manage-clusters/create", {"images"}), + ("/cloud/tools/query-tool", {"images"}), + ("/weaviate/manage-collections/tenant-states", {"images"}), ("/query-agent/recipes/query-agent-ecommerce-assistant", {"code"}), ("/weaviate/search", set()), # landing page ] ALL_PATHS = [path for path, _ in TEST_PAGES] +# Pages labelled "images" that carry no content images of their own today. +# +# The label is kept on purpose: these pages walk a reader through a UI, so they +# are the pages that most want screenshots, and the label records that intent. +# Until the screenshots exist there is nothing on the page for the alt-text +# check to assert, so it skips them by name and says so, rather than passing on +# the strength of the navbar chrome that used to leak into the check. +# +# Drop a page from this set the moment it gains a real content image; the +# alt-text check then enforces alt text on it. Any *other* page labelled +# "images" with no content images fails, so the label cannot go stale silently. +PAGES_MISSING_CONTENT_IMAGES = { + "/weaviate/concepts/data-import", + "/cloud/quickstart", + "/cloud/manage-clusters/create", +} + # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -214,17 +233,22 @@ def test_details_content_present(path): ids=[p for p, f in TEST_PAGES if "images" in f], ) def test_images_have_alt_text(path): - """Content images have alt text (excludes SVG icons and badges).""" + """The page's own images have alt text (excludes SVG icons and badges).""" soup = _get_soup(path) - - # Find content images, excluding decorative ones - images = soup.find_all("img") - content_images = [ - img for img in images - if not _is_decorative_image(img) - ] - - assert len(content_images) > 0, f"{path}: no content images found" + content_images = _content_images(soup) + + if not content_images: + if path in PAGES_MISSING_CONTENT_IMAGES: + pytest.skip( + f"{path}: labelled 'images' but has no content image in the main " + "content region, so there is nothing here to assert on. This is a " + "content gap, tracked in PAGES_MISSING_CONTENT_IMAGES." + ) + pytest.fail( + f"{path}: labelled 'images' but has no content image in the main " + "content region. Either the page lost its images, or the 'images' " + "label in TEST_PAGES is wrong." + ) missing_alt = [ img.get("src", "unknown") @@ -236,6 +260,26 @@ def test_images_have_alt_text(path): ) +def _main_content(soup): + """The page's main content region. + + Global navbar and footer chrome lives outside it. Scoping to this region + keeps site furniture (the site logo, the Ask AI button logo) from standing + in for the page's own images: that chrome is identical on every page, so + counting it made the alt-text check assert on the layout instead of the + page, and a single navbar change could flip it. + """ + return soup.find("main") or soup.find("article") or soup + + +def _content_images(soup): + """Images that carry the page's own content.""" + return [ + img for img in _main_content(soup).find_all("img") + if not _is_decorative_image(img) + ] + + def _is_decorative_image(img) -> bool: """Check if an image is decorative (SVG icon, badge, etc.).""" src = img.get("src", "") From fe40180bb9ccce8d4ca857372ca1872afec3cc04 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:02:47 +0200 Subject: [PATCH 4/9] Remove stray debug prints from the null-state filter example The Python example for filtering on a null property carried two debug prints: `print("despot. othing")` and `print("despot"+o.properties)`. Both sit inside the START/END markers, so both are published on the filters page today. The second one is not just noise, it cannot run: concatenating a str and a dict raises TypeError. It only stays quiet in CI because the query returns no objects on the test cluster, so the loop body never executes. Restore the line to the plain property print used by every sibling example. --- _includes/code/howto/search.filters.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/_includes/code/howto/search.filters.py b/_includes/code/howto/search.filters.py index f18a6581..ec249425 100644 --- a/_includes/code/howto/search.filters.py +++ b/_includes/code/howto/search.filters.py @@ -538,9 +538,8 @@ filters=Filter.by_property("country").is_none(True) # Find objects where the `country` property is null # highlight-end ) -print("despot. othing") for o in response.objects: - print("despot"+o.properties) # Inspect returned objects + print(o.properties) # Inspect returned objects # END FilterByPropertyNullState From c70718880ef746164e30f1bc3bea5fbc29019e70 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:32:16 +0200 Subject: [PATCH 5/9] Warn at PR time when a snippet change strands an llms.txt block llms.txt is hand-maintained in weaviate-io, but its code blocks must match the START/END regions of the snippets in this repo. The only enforcement was the weekly test_llms_txt_snippets_are_covered job, which fetches the live file. So PR #485/#487 moved the quickstart snippets to `data.ingest`, nothing here told the author that weaviate-io/static/llms.txt had to move too, and the break surfaced days later in a scheduled run. It is still red: 2 of 51 blocks. Add tests/check_llms_txt_drift.py, run by llms_txt_snippet_sync.yml on any PR touching a file that matches SNIPPET_GLOBS. It answers one question: once this PR merges, which llms.txt blocks would the weekly coverage test no longer find? It reuses that test's globs, regexes, _normalize and _load_llms_txt, so the two cannot disagree about what "matches" means. The check is advisory and always exits 0. When a snippet PR is opened, weaviate-io has not merged or deployed yet, so the live llms.txt legitimately cannot match; a blocking check would fail every honest PR and would be routinely overridden, which teaches people to ignore it. Findings are GitHub warning annotations on the changed marker lines plus a job summary carrying the exact block to paste into llms.txt. It stays quiet when there is nothing to do: weaviate-io shipped first and llms.txt already carries the new block, only scaffolding outside the markers changed, or a region moved or was renamed with its code intact. It degrades to a generic reminder, never a failure, if the base ref or llms.txt cannot be read. The path filter also covers the Java and C# snippets, which live with their language suites rather than under _includes/code/llms-txt/. llms_txt_tests.yml is untouched; the scheduled job behaves exactly as before. --- .github/workflows/llms_txt_snippet_sync.yml | 57 ++++ tests/README-LLMS-TXT.md | 37 ++- tests/check_llms_txt_drift.py | 307 ++++++++++++++++++++ 3 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/llms_txt_snippet_sync.yml create mode 100644 tests/check_llms_txt_drift.py diff --git a/.github/workflows/llms_txt_snippet_sync.yml b/.github/workflows/llms_txt_snippet_sync.yml new file mode 100644 index 00000000..d64b54b6 --- /dev/null +++ b/.github/workflows/llms_txt_snippet_sync.yml @@ -0,0 +1,57 @@ +name: llms.txt Snippet Sync + +# llms.txt is hand-maintained in the weaviate-io repo, so a snippet change here can +# strand a block in the published file. Only the weekly llms_txt_tests.yml job notices, +# which means the break surfaces days later. This gives the author the signal at PR time. +# +# It is advisory by design and never fails: when a snippet PR is opened, weaviate-io has +# not merged or deployed yet, so the live llms.txt legitimately cannot match. A blocking +# check would fire on every honest PR and would just be overridden. + +permissions: + contents: read + +on: + pull_request: + paths: + # Kept in step with SNIPPET_GLOBS in tests/test_llms_txt_code.py. Java and C# + # snippets live with their language suites, not under _includes/code/llms-txt/. + - "_includes/code/llms-txt/**" + - "_includes/code/java-v6/src/test/java/LlmsTxtTest.java" + - "_includes/code/csharp/LlmsTxtTest.cs" + - "tests/test_llms_txt_code.py" + - "tests/check_llms_txt_drift.py" + +env: + PYTHON_VERSION: "3.11" + +jobs: + check-snippet-sync: + name: Check llms.txt snippet sync + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Fetch the PR base commit + # Shallow single-commit fetch: the check only needs the base tree to read the + # pre-change snippet files. If it fails the check reports a degraded warning + # rather than blocking. + continue-on-error: true + run: git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install pytest + # The check imports tests/test_llms_txt_code.py to reuse its matching logic, and + # that module imports pytest. Nothing else from the test suite is needed, so this + # stays far cheaper than the full setup-test-env composite. + run: python -m pip install --quiet "pytest>=8.3.5" + + - name: Check llms.txt snippet sync + run: python tests/check_llms_txt_drift.py --base "${{ github.event.pull_request.base.sha }}" diff --git a/tests/README-LLMS-TXT.md b/tests/README-LLMS-TXT.md index b465a0c4..7074057b 100644 --- a/tests/README-LLMS-TXT.md +++ b/tests/README-LLMS-TXT.md @@ -116,6 +116,8 @@ no Weaviate cluster. 2. Run that language's `test_llms_txt` and confirm it passes — **never** hand-write a snippet without running it. 3. Copy the verified marked region **verbatim** into `weaviate-io/static/llms.txt`. + The PR-time sync warning (see *CI* below) flags this for you and prints the exact + block to paste. 4. New file? Add its path to the `test_llms_txt` parametrize list in the matching `tests/test_*.py`. @@ -182,12 +184,13 @@ after the matching `weaviate-io` change is live; otherwise mark with ## CI -Two separate workflows cover this directory: +Three separate workflows cover this directory: | Workflow | What it runs | |---|---| | `.github/workflows/docs_tests.yml` | Per-language **execution** tests — `test_llms_txt*` in `test_python.py`, `test_typescript.py`, `test_java.py`, `test_csharp.py`. Rides the existing `pyv4` / `ts` / `java` / `csharp` / `agents` markers, so no separate job for these. | | `.github/workflows/llms_txt_tests.yml` | The three **guard tests** in `test_llms_txt_code.py` — snippet coverage, version freshness, link validity. Single job `test-llms-txt`, runs `uv run pytest tests/test_llms_txt_code.py -m "llms_txt"`. | +| `.github/workflows/llms_txt_snippet_sync.yml` | The **PR-time warning** (`check_llms_txt_drift.py`). Advisory only, see below. | The guard workflow triggers on: @@ -204,6 +207,38 @@ Results post to Slack via the shared `./.github/actions/handle-test-results` composite under `test-type: 'llms.txt'`, with `continue-on-error: true` on the pytest step so the notification still fires when a guard fails. +### PR-time snippet sync warning + +The guard workflow runs weekly, so a snippet change here can break the published +`llms.txt` days before anyone notices. `tests/check_llms_txt_drift.py` closes that +gap on the docs side. `.github/workflows/llms_txt_snippet_sync.yml` runs it on every +PR touching a file that matches `SNIPPET_GLOBS`, and it answers one question: once +this PR merges, which `llms.txt` code blocks would `test_llms_txt_snippets_are_covered` +no longer find? Those, and only those, are the blocks `weaviate-io/static/llms.txt` +has to update in lockstep. + +It reuses this directory's matching logic (`SNIPPET_GLOBS`, the marker and fence +regexes, `_normalize`, `_load_llms_txt`), so it cannot disagree with the weekly job +about what "matches" means. Findings surface as GitHub warning annotations on the +changed snippet lines, plus a job summary carrying the new block to paste into +`llms.txt`. + +**It is advisory and always exits 0.** When a snippet PR is opened, weaviate-io has +not merged or deployed yet, so the live `llms.txt` legitimately cannot match yet; a +blocking check would fire on every honest PR and would just get overridden. It also +stays quiet whenever there is nothing to do: weaviate-io shipped first and `llms.txt` +already carries the new block, only scaffolding outside the markers changed, or a +region was moved or renamed without its code changing. + +Run it locally against uncommitted edits: + +```bash +uv run python tests/check_llms_txt_drift.py --base HEAD +``` + +The reverse direction (an `llms.txt` edit in weaviate-io that no longer matches these +snippets) is checked by a workflow in the weaviate-io repo. + ## Per-language gotchas - **Python** — `text2vec_ollama` / `generative_ollama` take `api_endpoint` and diff --git a/tests/check_llms_txt_drift.py b/tests/check_llms_txt_drift.py new file mode 100644 index 00000000..835fa6fa --- /dev/null +++ b/tests/check_llms_txt_drift.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +"""Advisory PR check: warn when a snippet change strands a block in llms.txt. + +`llms.txt` is hand-maintained in the **weaviate-io** repo (`static/llms.txt`), but its +code blocks must appear verbatim between `START`/`END` markers in this repo's snippet +files. The only thing enforcing that today is the weekly +`test_llms_txt_code.py::test_llms_txt_snippets_are_covered` job, so a snippet change +here can break the published `llms.txt` days before anyone notices. + +This script answers one question at PR time: once this PR merges, which `llms.txt` code +blocks would that weekly coverage test no longer find? Those blocks, and only those, are +the ones `weaviate-io/static/llms.txt` has to update in lockstep. + +It is deliberately advisory and **always exits 0**. A legitimate snippet PR cannot have a +matching live `llms.txt` yet, because weaviate-io has not merged or deployed, so a +blocking check here would fail every honest PR and get routinely overridden. Findings are +reported as GitHub warning annotations plus a job summary instead. + +Usage: + + python tests/check_llms_txt_drift.py --base + +The pre-change state is read with `git show :`; the post-change state is read +from the working tree, so the changed-file set is `git diff --name-only `. That also +makes the script easy to exercise locally: edit a snippet file and run it with +`--base HEAD`. + +Honors `LLMS_TXT_PATH` exactly like the guard tests do, so it can be pointed at a local +weaviate-io checkout instead of the live https://weaviate.io/llms.txt. +""" +import argparse +import os +import subprocess +import sys +from dataclasses import dataclass +from fnmatch import fnmatch +from typing import Optional + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# Reuse the guard test's matching logic (SNIPPET_GLOBS, marker/fence regexes, +# normalization, llms.txt loading) so this check and the weekly job can never +# disagree about what "matches" means. +import test_llms_txt_code as guard # noqa: E402 + +LLMS_TXT_SOURCE = "weaviate-io/static/llms.txt" + +# guard._load_llms_txt() calls pytest.skip() when the fetch fails, and Skipped derives +# from BaseException, so a bare `except Exception` would let it through. +_LOAD_FAILURES = (Exception, guard.pytest.skip.Exception) + + +@dataclass +class Finding: + """A marked region that exists at the base ref but not after the change.""" + + path: str + language: str + marker: str + old_code: str + new_code: Optional[str] # same marker's code after the change, if it still exists + new_line: Optional[int] # 1-based line of the surviving `START` marker + + +def _git(*args): + return subprocess.run(["git", *args], capture_output=True, text=True, check=False) + + +def _repo_root(): + result = _git("rev-parse", "--show-toplevel") + return result.stdout.strip() if result.returncode == 0 else None + + +def _snippet_patterns(): + return [(lang, pattern) for lang, patterns in guard.SNIPPET_GLOBS.items() for pattern in patterns] + + +def _language_for(path): + for language, pattern in _snippet_patterns(): + if fnmatch(path, pattern): + return language + return None + + +def _changed_snippet_files(base): + """Paths matching SNIPPET_GLOBS that differ between `base` and the working tree.""" + result = _git("diff", "--name-only", base) + if result.returncode != 0: + return None, result.stderr.strip() or f"git diff against {base} failed" + changed = [path for path in result.stdout.splitlines() if _language_for(path)] + return sorted(changed), None + + +def _regions(text): + """[(marker, normalized code, 1-based START line)] for each region in `text`.""" + found = [] + for match in guard.MARKER_RE.finditer(text): + line = text.count("\n", 0, match.start()) + 1 + found.append((match.group(1), guard._normalize(match.group(2)), line)) + return found + + +def _blob_at(ref, path): + """File contents at `ref`, or None if the file did not exist there.""" + result = _git("show", f"{ref}:{path}") + return result.stdout if result.returncode == 0 else None + + +def _working_tree_text(path): + if not os.path.exists(path): + return "" # file deleted by the change + with open(path, encoding="utf-8") as handle: + return handle.read() + + +def _lost_regions(base, changed_paths): + """Regions present at `base` whose exact code no longer exists anywhere in the repo. + + Comparing against every snippet file (not just the changed one) means a region that + was merely moved or renamed is correctly treated as still covered. + """ + surviving = guard._collect_marked_regions() + lost = [] + for path in changed_paths: + language = _language_for(path) + base_text = _blob_at(base, path) + if base_text is None: + continue # new file: nothing can be stranded + head_regions = {marker: (code, line) for marker, code, line in _regions(_working_tree_text(path))} + for marker, code, _ in _regions(base_text): + if code in surviving[language]: + continue + new_code, new_line = head_regions.get(marker, (None, None)) + lost.append(Finding(path, language, marker, code, new_code, new_line)) + return lost + + +def _llms_txt_blocks(): + """({language: {normalized code}}, None), or (None, reason) if llms.txt is unreadable.""" + try: + content = guard._load_llms_txt() + except _LOAD_FAILURES as exc: + return None, str(exc) + blocks = {language: set() for language in guard.SNIPPET_GLOBS} + for match in guard.FENCE_RE.finditer(content): + language = guard.LANG_ALIASES.get(match.group(1).lower()) + if language is not None: + blocks[language].add(guard._normalize(match.group(2))) + return blocks, None + + +def _escape(value, is_property=False): + escaped = value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + if is_property: + escaped = escaped.replace(":", "%3A").replace(",", "%2C") + return escaped + + +def _annotate(level, message, path=None, line=None, title=None): + properties = [] + if path: + properties.append(f"file={_escape(path, True)}") + if line: + properties.append(f"line={line}") + if title: + properties.append(f"title={_escape(title, True)}") + joined = "," + ",".join(properties) if properties else "" + print(f"::{level}{joined}::{_escape(message)}") + + +def _write_summary(markdown): + print(markdown) + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_path: + with open(summary_path, "a", encoding="utf-8") as handle: + handle.write(markdown + "\n") + + +def _report_in_sync(headline): + _write_summary(f"## llms.txt snippet sync\n\n{headline}\n") + + +def _report_drift(findings): + for finding in findings: + if finding.new_code is None: + detail = f"The `{finding.marker}` region was removed or renamed" + else: + detail = f"The `{finding.marker}` region changed" + _annotate( + "warning", + f"{detail}, but {LLMS_TXT_SOURCE} still publishes the previous version. " + f"Update {LLMS_TXT_SOURCE} in lockstep with this PR, or the weekly llms.txt " + f"coverage job will fail once this merges.", + path=finding.path, + line=finding.new_line, + title="llms.txt needs a matching update", + ) + + rows = "\n".join( + f"| `{finding.path}` | `{finding.marker}` | " + f"{'removed or renamed' if finding.new_code is None else 'changed'} |" + for finding in findings + ) + blocks = "\n\n".join( + f"
{finding.marker} " + f"({'no replacement in this repo' if finding.new_code is None else 'new block to copy into llms.txt'})" + f"\n\n```{finding.language}\n" + f"{finding.new_code if finding.new_code is not None else finding.old_code}\n```\n\n
" + for finding in findings + ) + plural = "block" if len(findings) == 1 else "blocks" + _write_summary( + "## llms.txt snippet sync\n\n" + f"This PR strands {len(findings)} code {plural} that `{LLMS_TXT_SOURCE}` publishes " + "verbatim. **`weaviate-io/static/llms.txt` must be updated in the same window**, " + "otherwise the weekly llms.txt coverage job starts failing once this merges.\n\n" + "| Snippet file | Marked region | What happened |\n" + "|---|---|---|\n" + f"{rows}\n\n" + f"{blocks}\n\n" + "This check never blocks the merge: weaviate-io cannot have shipped yet when a " + "legitimate snippet PR is opened.\n" + ) + + +def main(): + parser = argparse.ArgumentParser( + description="Warn when a PR strands an llms.txt code block.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--base", + required=True, + help="git ref holding the pre-change state (on a PR, the base commit)", + ) + args = parser.parse_args() + + root = _repo_root() + if root is None: + _annotate("warning", "Not inside a git repository; skipping the llms.txt sync check.") + return 0 + os.chdir(root) + + changed, error = _changed_snippet_files(args.base) + if error is not None: + _annotate( + "warning", + f"Could not diff against {args.base} ({error}), so the llms.txt sync check was " + f"skipped. If this PR changes an llms.txt snippet, update {LLMS_TXT_SOURCE} too.", + title="llms.txt sync check could not run", + ) + return 0 + + if not changed: + _report_in_sync("No llms.txt-backed snippet files changed. Nothing to keep in sync.") + return 0 + + findings = _lost_regions(args.base, changed) + if not findings: + _report_in_sync( + f"{len(changed)} llms.txt snippet file(s) changed, but no `START`/`END` region " + f"lost its previous content, so `{LLMS_TXT_SOURCE}` stays valid." + ) + return 0 + + blocks, load_error = _llms_txt_blocks() + if blocks is None: + listed = ", ".join(f"`{path}`" for path in changed) + _annotate( + "warning", + f"Changed llms.txt snippet regions, but the published llms.txt could not be read " + f"({load_error}), so the comparison was skipped. Check {LLMS_TXT_SOURCE} by hand.", + title="llms.txt sync check could not run", + ) + _write_summary( + "## llms.txt snippet sync\n\n" + f"Could not read the published llms.txt ({load_error}). These files changed a " + f"marked region and may need a matching `{LLMS_TXT_SOURCE}` update: {listed}\n" + ) + return 0 + + stranded = [finding for finding in findings if finding.old_code in blocks[finding.language]] + if not stranded: + shipped = [ + finding + for finding in findings + if finding.new_code is not None and finding.new_code in blocks[finding.language] + ] + if shipped: + markers = ", ".join(f"`{finding.marker}`" for finding in shipped) + _report_in_sync( + f"`{LLMS_TXT_SOURCE}` already publishes the updated {markers} block(s). " + "weaviate-io shipped first, so there is nothing to do." + ) + else: + _report_in_sync( + f"Marked regions changed, but `{LLMS_TXT_SOURCE}` does not publish any of " + "their previous content, so nothing is stranded." + ) + return 0 + + _report_drift(stranded) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 67f9941f1de60cb628cea917c6d538733abdfe0a Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:41:10 +0200 Subject: [PATCH 6/9] Drop the images label from three pages that never had screenshots The pages were labelled `images` on the assumption that they should carry screenshots and were missing them. They were never meant to have any, so there is no coverage to preserve and the label is simply wrong. Removing it also removes the reason for PAGES_MISSING_CONTENT_IMAGES, a second hand-maintained list that contradicted the feature labels a few lines above it. The hard failure for a page labelled `images` with no content image stays, and is now the only enforcement: a stale label fails instead of being skipped by name. Two of the three pages have no structural features left. Keep them in TEST_PAGES, because four checks (200, meta tags, heading hierarchy, LLM notice) parametrize over every entry regardless of feature, and dropping the entries would lose that coverage. An empty feature set would have exempted them from the h2 assertion, which keyed "content page" off a non-empty feature set, so record landing pages explicitly in LANDING_PAGES instead. That leaves _features_for without a caller, so it goes too. --- tests/README-INDEXABILITY.md | 14 +++++----- tests/test_docs_indexability.py | 46 +++++++++------------------------ 2 files changed, 20 insertions(+), 40 deletions(-) diff --git a/tests/README-INDEXABILITY.md b/tests/README-INDEXABILITY.md index d0b77b37..ef180686 100644 --- a/tests/README-INDEXABILITY.md +++ b/tests/README-INDEXABILITY.md @@ -21,7 +21,7 @@ Documentation content that is hidden behind JavaScript interactions (tabs, colla ### HTML structure tests (Part 1) -These tests fetch ~11 representative pages from all doc sections and check: +These tests fetch ~13 representative pages from all doc sections and check: | Test | What it checks | |------|---------------| @@ -81,7 +81,7 @@ HTML structure tests always run. Agent tests only run if `ANTHROPIC_API_KEY` and ## Test pages -The suite tests 11 representative URLs covering all doc sections: +The suite tests 13 representative URLs covering all doc sections: | Page | Features tested | |------|----------------| @@ -91,9 +91,11 @@ The suite tests 11 representative URLs covering all doc sections: | `/weaviate/search/hybrid` | tabs, code | | `/weaviate/connections/connect-cloud` | tabs, code | | `/weaviate/config-refs/collections` | details, table | -| `/weaviate/concepts/data-import` | images | -| `/cloud/quickstart` | code, images | -| `/cloud/manage-clusters/create` | images | +| `/weaviate/concepts/data-import` | no structural features (200, meta tags, headings, LLM notice only) | +| `/cloud/quickstart` | code | +| `/cloud/manage-clusters/create` | no structural features (200, meta tags, headings, LLM notice only) | +| `/cloud/tools/query-tool` | images | +| `/weaviate/manage-collections/tenant-states` | images | | `/query-agent/recipes/query-agent-ecommerce-assistant` | code | | `/weaviate/search` | landing page | @@ -129,4 +131,4 @@ TEST_PAGES = [ ] ``` -Available feature tags: `tabs`, `code`, `details`, `images`, `table`. Pages are parametrized — each feature tag enables the corresponding structural test for that page. +Available feature tags: `tabs`, `code`, `details`, `images`, `table`. Pages are parametrized — each feature tag enables the corresponding structural test for that page. Tag only what the page actually has: a page tagged `images` with no content image fails rather than passing quietly, which is what keeps the tags from going stale. diff --git a/tests/test_docs_indexability.py b/tests/test_docs_indexability.py index 0721a74b..ff5ad51d 100644 --- a/tests/test_docs_indexability.py +++ b/tests/test_docs_indexability.py @@ -31,33 +31,25 @@ ("/weaviate/search/hybrid", {"tabs", "code"}), ("/weaviate/connections/connect-cloud", {"tabs", "code"}), ("/weaviate/config-refs/collections", {"details", "table"}), - ("/weaviate/concepts/data-import", {"images"}), - ("/cloud/quickstart", {"code", "images"}), - ("/cloud/manage-clusters/create", {"images"}), + ("/weaviate/concepts/data-import", set()), + ("/cloud/quickstart", {"code"}), + ("/cloud/manage-clusters/create", set()), ("/cloud/tools/query-tool", {"images"}), ("/weaviate/manage-collections/tenant-states", {"images"}), ("/query-agent/recipes/query-agent-ecommerce-assistant", {"code"}), - ("/weaviate/search", set()), # landing page + ("/weaviate/search", set()), ] ALL_PATHS = [path for path, _ in TEST_PAGES] -# Pages labelled "images" that carry no content images of their own today. +# Pages that route readers onward instead of carrying content of their own, and +# so are exempt from the "content pages have h2 headings" rule. # -# The label is kept on purpose: these pages walk a reader through a UI, so they -# are the pages that most want screenshots, and the label records that intent. -# Until the screenshots exist there is nothing on the page for the alt-text -# check to assert, so it skips them by name and says so, rather than passing on -# the strength of the navbar chrome that used to leak into the check. -# -# Drop a page from this set the moment it gains a real content image; the -# alt-text check then enforces alt text on it. Any *other* page labelled -# "images" with no content images fails, so the label cannot go stale silently. -PAGES_MISSING_CONTENT_IMAGES = { - "/weaviate/concepts/data-import", - "/cloud/quickstart", - "/cloud/manage-clusters/create", -} +# This is tracked separately from the feature sets because an empty feature set +# says only that a page has none of the structural features the checks below +# assert on. A page can be plain prose, with no tabs, code, details, table or +# image, and still be a content page that owes the reader h2 headings. +LANDING_PAGES = {"/weaviate/search"} # --------------------------------------------------------------------------- # Fixtures @@ -90,13 +82,6 @@ def _get_soup(path: str) -> BeautifulSoup: return BeautifulSoup(resp.text, "html.parser") -def _features_for(path: str) -> set[str]: - for p, features in TEST_PAGES: - if p == path: - return features - return set() - - # --------------------------------------------------------------------------- # Part 1: HTML Structure Tests (no API keys needed) # --------------------------------------------------------------------------- @@ -139,8 +124,7 @@ def test_heading_hierarchy(path): assert len(h1s) == 1, f"{path}: expected 1 h1, found {len(h1s)}" # Content pages (not landing pages) should have h2s - features = _features_for(path) - if features: # non-empty features means it's a content page + if path not in LANDING_PAGES: h2s = soup.find_all("h2") assert len(h2s) > 0, f"{path}: content page has no h2 headings" @@ -238,12 +222,6 @@ def test_images_have_alt_text(path): content_images = _content_images(soup) if not content_images: - if path in PAGES_MISSING_CONTENT_IMAGES: - pytest.skip( - f"{path}: labelled 'images' but has no content image in the main " - "content region, so there is nothing here to assert on. This is a " - "content gap, tracked in PAGES_MISSING_CONTENT_IMAGES." - ) pytest.fail( f"{path}: labelled 'images' but has no content image in the main " "content region. Either the page lost its images, or the 'images' " From 0386fabcb55e3a8a86e85c2dca11bf766c5e9fa4 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:36:37 +0200 Subject: [PATCH 7/9] Remove the empty alt that broke the site build Docusaurus validates headTags attributes with a Joi string() schema, which rejects the empty string, so `alt: ""` on the analytics beacon failed both `yarn build-dev` and the Netlify production build with: Error: "headTags[2].attributes.alt" is not allowed to be empty The attribute was never load-bearing. The alt-text check exempts the beacon structurally on its inline `display: none;`, and independently on `aria-hidden="true"`, so dropping the empty alt loses no coverage. --- src/scripts/scarf.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/scripts/scarf.js b/src/scripts/scarf.js index af63b293..eab4276a 100644 --- a/src/scripts/scarf.js +++ b/src/scripts/scarf.js @@ -5,10 +5,11 @@ const scarfScript = { src: "https://pixel.weaviate.cloud/a.png?x-pxid=a41b0758-a3a9-4874-a880-8b5d5a363d40", referrerPolicy: "no-referrer-when-downgrade", style: "display: none;", - // Decorative, hidden analytics beacon: empty alt plus aria-hidden keeps it - // out of the accessibility tree instead of leaving assistive technology to - // announce an image with no alt attribute. - alt: "", + // Decorative, hidden analytics beacon: aria-hidden is what keeps it out of + // the accessibility tree, so assistive technology never reaches it and never + // has to announce it. Do not add `alt: ""` here: Docusaurus validates + // headTags attributes with a Joi `string()` schema that rejects the empty + // string, so an empty alt fails the site build outright. "aria-hidden": "true", }, }; From 8853734e52d2c45bef5304bfb800c51d17c95854 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:34:58 +0200 Subject: [PATCH 8/9] Document the landing-page trap and guard the snippet glob parity Four follow-ups from review of this branch: - Assert that every SNIPPET_GLOBS pattern is covered by the `paths:` filter in llms_txt_snippet_sync.yml. Java and C# snippets already live outside _includes/code/llms-txt/, so a fifth language landing outside the filter would disable the drift check silently. The workflow YAML is parsed rather than copied, and pyyaml is now a declared dependency so the guard cannot skip. - Document LANDING_PAGES in README-INDEXABILITY. An empty feature set does not exempt a page from the h2 assertion, which is the confusing failure a new landing page would otherwise hit. - Describe the reverse-direction check as landing with weaviate-io PR #3669, since that workflow is not on weaviate-io main yet. - Correct the drift script's "always exits 0" claim: it never fails the job once it runs, but argparse still exits 2 on a missing --base. --- pyproject.toml | 1 + tests/README-INDEXABILITY.md | 17 +++++++++++ tests/README-LLMS-TXT.md | 5 ++-- tests/check_llms_txt_drift.py | 11 ++++--- tests/test_llms_txt_code.py | 56 +++++++++++++++++++++++++++++++++++ uv.lock | 2 ++ 6 files changed, 86 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1f5796bf..657b44ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "playwright>=1.40.0", "pytest>=8.3.5", "python-dotenv>=1.1.1", + "pyyaml>=6.0", "requests>=2.32.3", "tqdm>=4.67.1", "weaviate-agents>=1.7.0", diff --git a/tests/README-INDEXABILITY.md b/tests/README-INDEXABILITY.md index ef180686..ce68a3ce 100644 --- a/tests/README-INDEXABILITY.md +++ b/tests/README-INDEXABILITY.md @@ -132,3 +132,20 @@ TEST_PAGES = [ ``` Available feature tags: `tabs`, `code`, `details`, `images`, `table`. Pages are parametrized — each feature tag enables the corresponding structural test for that page. Tag only what the page actually has: a page tagged `images` with no content image fails rather than passing quietly, which is what keeps the tags from going stale. + +### Landing pages + +If the page you are adding routes readers onward instead of carrying content of its own — a hub page that is essentially a list of links to its children — also add its path to the `LANDING_PAGES` set in the same file: + +```python +LANDING_PAGES = {"/weaviate/search"} +``` + +`LANDING_PAGES` is the **only** thing that exempts a page from the "content pages have h2 headings" assertion in `test_heading_hierarchy`. An empty feature set does not exempt it. The two mean different things: + +- an empty feature set says the page has none of the structural features listed above (no tabs, no code, no details, no table, no images); +- `LANDING_PAGES` says the page owes the reader no h2 headings at all. + +A page can be plain prose with an empty feature set and still be a content page that must have h2s, which is why the exemption is tracked separately. + +So if you add a landing page with `set()` and leave it out of `LANDING_PAGES`, it fails with `content page has no h2 headings` — a confusing failure, because the feature set already looks like it says "this page has nothing". Add the path to `LANDING_PAGES` instead. diff --git a/tests/README-LLMS-TXT.md b/tests/README-LLMS-TXT.md index 7074057b..1eab51da 100644 --- a/tests/README-LLMS-TXT.md +++ b/tests/README-LLMS-TXT.md @@ -223,7 +223,7 @@ about what "matches" means. Findings surface as GitHub warning annotations on th changed snippet lines, plus a job summary carrying the new block to paste into `llms.txt`. -**It is advisory and always exits 0.** When a snippet PR is opened, weaviate-io has +**It is advisory and never fails the job once it runs.** When a snippet PR is opened, weaviate-io has not merged or deployed yet, so the live `llms.txt` legitimately cannot match yet; a blocking check would fire on every honest PR and would just get overridden. It also stays quiet whenever there is nothing to do: weaviate-io shipped first and `llms.txt` @@ -237,7 +237,8 @@ uv run python tests/check_llms_txt_drift.py --base HEAD ``` The reverse direction (an `llms.txt` edit in weaviate-io that no longer matches these -snippets) is checked by a workflow in the weaviate-io repo. +snippets) is the companion check that lands with weaviate-io PR #3669, as a workflow in +that repo. Until it merges there, this direction is the only one that is automated. ## Per-language gotchas diff --git a/tests/check_llms_txt_drift.py b/tests/check_llms_txt_drift.py index 835fa6fa..0f32277d 100644 --- a/tests/check_llms_txt_drift.py +++ b/tests/check_llms_txt_drift.py @@ -11,10 +11,13 @@ blocks would that weekly coverage test no longer find? Those blocks, and only those, are the ones `weaviate-io/static/llms.txt` has to update in lockstep. -It is deliberately advisory and **always exits 0**. A legitimate snippet PR cannot have a -matching live `llms.txt` yet, because weaviate-io has not merged or deployed, so a -blocking check here would fail every honest PR and get routinely overridden. Findings are -reported as GitHub warning annotations plus a job summary instead. +It is deliberately advisory: **once it runs it never fails the job**, because every +reporting path exits 0. (Only a malformed invocation, such as omitting `--base`, exits +non-zero, and that comes from argparse before any checking happens.) A legitimate snippet +PR cannot have a matching live `llms.txt` yet, because weaviate-io has not merged or +deployed, so a blocking check here would fail every honest PR and get routinely +overridden. Findings are reported as GitHub warning annotations plus a job summary +instead. Usage: diff --git a/tests/test_llms_txt_code.py b/tests/test_llms_txt_code.py index e89ca3c3..d0df9d7f 100644 --- a/tests/test_llms_txt_code.py +++ b/tests/test_llms_txt_code.py @@ -120,6 +120,62 @@ def test_llms_txt_snippets_are_covered(): ) +# The PR-time drift check only runs when the PR touches a path in this workflow's +# `paths:` filter, so a SNIPPET_GLOBS entry outside that filter disables the check +# for that language without any visible failure. Java and C# snippets already live +# outside `_includes/code/llms-txt/`, so a fifth language landing outside it is a +# realistic way to lose coverage silently. +SNIPPET_SYNC_WORKFLOW = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + ".github", "workflows", "llms_txt_snippet_sync.yml", +) + + +def _glob_to_regex(pattern): + """Compile a GitHub `paths:` filter, where `**` spans directories and `*` does not.""" + out, i = [], 0 + while i < len(pattern): + if pattern.startswith("**", i): + out.append(".*") + i += 2 + elif pattern[i] == "*": + out.append("[^/]*") + i += 1 + else: + out.append(re.escape(pattern[i])) + i += 1 + return re.compile("".join(out) + r"\Z") + + +@pytest.mark.llms_txt +def test_snippet_globs_are_covered_by_workflow_paths(): + """Every SNIPPET_GLOBS pattern must trigger the snippet-sync workflow.""" + # Imported here, not at module scope: check_llms_txt_drift.py imports this + # module in a workflow that installs pytest and nothing else. + import yaml + + with open(SNIPPET_SYNC_WORKFLOW, encoding="utf-8") as handle: + workflow = yaml.safe_load(handle) + + # PyYAML reads the bare `on:` key as the YAML 1.1 boolean True. + triggers = workflow.get("on", workflow.get(True)) + filters = [_glob_to_regex(path) for path in triggers["pull_request"]["paths"]] + + uncovered = [ + pattern + for patterns in SNIPPET_GLOBS.values() + for pattern in patterns + if not any(filt.match(pattern) for filt in filters) + ] + + assert not uncovered, ( + "SNIPPET_GLOBS pattern(s) not covered by the `paths:` filter in " + f"{os.path.basename(SNIPPET_SYNC_WORKFLOW)}: {uncovered}. A snippet change under " + "these paths would not trigger the sync check, so llms.txt could drift unnoticed. " + "Add a matching entry to the workflow's `paths:` list." + ) + + # Each library: (weaviate/* GitHub repo, regex matching the # "**Library**: vX.Y.Z+" bullet in llms.txt). The regex anchors to the bullet's # prefix so adding new libraries to llms.txt doesn't break it. diff --git a/uv.lock b/uv.lock index 3b19c9f5..b0f0e87f 100644 --- a/uv.lock +++ b/uv.lock @@ -1884,6 +1884,7 @@ dependencies = [ { name = "playwright" }, { name = "pytest" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "requests" }, { name = "tqdm" }, { name = "weaviate-agents" }, @@ -1903,6 +1904,7 @@ requires-dist = [ { name = "playwright", specifier = ">=1.40.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "python-dotenv", specifier = ">=1.1.1" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "requests", specifier = ">=2.32.3" }, { name = "tqdm", specifier = ">=4.67.1" }, { name = "weaviate-agents", specifier = ">=1.7.0" }, From 86d12d521f6fd8f6cefb48df320e4b74316f8ad5 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:56:37 +0200 Subject: [PATCH 9/9] Correct the llms.txt sync docs: the reverse direction is not automated --- tests/README-LLMS-TXT.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/README-LLMS-TXT.md b/tests/README-LLMS-TXT.md index 1eab51da..0b22bf4a 100644 --- a/tests/README-LLMS-TXT.md +++ b/tests/README-LLMS-TXT.md @@ -237,8 +237,10 @@ uv run python tests/check_llms_txt_drift.py --base HEAD ``` The reverse direction (an `llms.txt` edit in weaviate-io that no longer matches these -snippets) is the companion check that lands with weaviate-io PR #3669, as a workflow in -that repo. Until it merges there, this direction is the only one that is automated. +snippets) is not automated. There is no check in weaviate-io; this repo's PR-time warning +and the weekly `llms_txt_tests.yml` job are the only automation. An `llms.txt` edit made +directly in weaviate-io that breaks the verbatim match is not caught until the weekly job +runs. ## Per-language gotchas