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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions api-drift-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
# debt real drift, too expensive to fix in the PR that found it
# limit the checker cannot tell this apart from drift, and the docs are right
#
# A json fence that is not a Saturday payload (a client's config file) is not a
# limit: mark it on the page with {/* docs-drift: not-api */} on the line before
# the fence. A limit entry for one of its keys goes stale, and fails a backend PR,
# the day any Go struct gains a tag with that name.
#
# Format: kind<TAB>identity<TAB>why

# documented field that exists nowhere in the API
field cramp limit: a symptom map KEY in "symptoms": { "cramp": 1 }, not a schema field. The checker reads every key in a json fence and cannot see that this map is open-ended
field headers limit: a key in the MCP CLIENT config example, which is a third-party config file that happens to be json, not a Saturday API payload
field saturday limit: the MCP server nickname a reader chooses inside their own mcpServers config, same fence as above
field transport limit: MCP client transport setting, same fence as above
2 changes: 2 additions & 0 deletions guides/mcp-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ There are two ways to reach Saturday's MCP server, depending on who you are:

Add Saturday to your MCP client configuration with your partner API key:

{/* docs-drift: not-api. The MCP client's config file, not a Saturday payload. */}

```json
{
"mcpServers": {
Expand Down
52 changes: 39 additions & 13 deletions scripts/check-docs-drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@
bodies, so checking against the spec alone reports real, correctly
documented fields as drift and blames the docs for spec debt.

A json fence that is not a Saturday payload, such as an MCP client's
config file, is marked on the page with an MDX comment on the line before
its opening fence: {/* docs-drift: not-api */} Its keys are then not
field-checked; its endpoints and event names still are. Marking the fence
beats baselining its keys: a baseline entry goes stale, and fails a
backend PR that has nothing to do with the docs, as soon as any Go struct
anywhere gains a tag with the same name (`headers`, from a Gmail
response struct, 2026-10-01).

3. events Every webhook event name in the docs is a member of
webhook.AllEventTypes(). Not cosmetic: registration returns on the FIRST
unknown name, so one stale event name in a docs example means a partner
Expand Down Expand Up @@ -132,20 +141,22 @@ def spec_property_names(spec_path):


def go_json_tags(backend):
"""Field names the Go code marshals, from struct tags. Tests are excluded:
a name that only ever appears in a fixture is not a shipped field."""
tags = set()
"""Field name -> first Go file (backend-relative) that tags it. Tests are
excluded: a name that only ever appears in a fixture is not a shipped field."""
tags = {}
for rel in GO_API_DIRS:
root_dir = os.path.join(backend, rel)
if not os.path.isdir(root_dir):
continue
for root, dirs, files in os.walk(root_dir):
dirs[:] = [d for d in dirs if d not in ("testdata", "vendor")]
for fn in files:
dirs[:] = sorted(d for d in dirs if d not in ("testdata", "vendor"))
for fn in sorted(files):
if not fn.endswith(".go") or fn.endswith("_test.go"):
continue
src = open(os.path.join(root, fn), errors="replace").read()
tags.update(re.findall(r'json:"([a-zA-Z_][a-zA-Z0-9_]*)', src))
path = os.path.join(root, fn)
src = open(path, errors="replace").read()
for tag in re.findall(r'json:"([a-zA-Z_][a-zA-Z0-9_]*)', src):
tags.setdefault(tag, os.path.relpath(path, backend))
return tags


Expand All @@ -169,28 +180,37 @@ def go_event_types(backend):


FENCE = re.compile(r"^\s*```(\S*)")
NOT_API = re.compile(r"^\s*\{/\*\s*docs-drift:\s*not-api\b")
ENDPOINT = re.compile(r"\b(GET|POST|PUT|PATCH|DELETE)\s+(/v1/[A-Za-z0-9_{}/.\-]*)")
JSON_KEY = re.compile(r'"([a-z][a-z0-9_]*)"\s*:')
BACKTICKED = re.compile(r"`([a-z][a-z0-9_]*\.[a-z][a-z0-9_.]*)`")


def read_pages(docs_dir):
"""Yield (relative path, list of (line number, text, inside-json-fence))."""
"""Yield (relative path, list of (line number, text, inside-json-fence)).
A json fence whose opening line follows a NOT_API marker (blank lines
between are fine) yields its lines as outside, so its keys are not
field-checked."""
for root, dirs, files in os.walk(docs_dir):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
for fn in sorted(files):
if not fn.endswith(".mdx") or fn.endswith(".draft.mdx"):
continue
rel = os.path.relpath(os.path.join(root, fn), docs_dir)
lines, lang, in_fence = [], None, False
lines, lang, in_fence, marked = [], None, False, False
for i, raw in enumerate(open(os.path.join(root, fn), errors="replace"), 1):
m = FENCE.match(raw)
if m:
if in_fence:
in_fence, lang = False, None
else:
in_fence, lang = True, m.group(1)
if marked and lang == "json":
lang = "json:not-api"
marked = False
continue
if not in_fence and raw.strip():
marked = bool(NOT_API.match(raw))
lines.append((i, raw.rstrip("\n"), in_fence and lang == "json"))
yield rel, lines

Expand Down Expand Up @@ -232,9 +252,12 @@ def near(name, known, n=3):


def collect(docs_dir, backend, spec_path):
"""Every finding, before the baseline is applied."""
"""Every finding, before the baseline is applied, plus where each known
field name comes from (the spec, else the first Go file tagging it)."""
ops = spec_operations(spec_path)
known_fields = spec_property_names(spec_path) | go_json_tags(backend)
field_source = go_json_tags(backend)
field_source.update({n: "the spec" for n in spec_property_names(spec_path)})
known_fields = set(field_source)
events = go_event_types(backend)
known_paths = {p for _, p in ops}
event_namespaces = {e.split(".", 1)[0] for e in events} if events else set()
Expand Down Expand Up @@ -282,7 +305,7 @@ def collect(docs_dir, backend, spec_path):
findings["event"].append((token, rel, lineno, token, detail))

undocumented = sorted(k for k in ops if k not in mentioned_ops)
return findings, ops, undocumented, events
return findings, ops, undocumented, events, field_source


LABELS = {
Expand Down Expand Up @@ -314,7 +337,8 @@ def main():
print("--backend must point at a fuel-backend checkout")
return 1

findings, ops, undocumented, events = collect(docs_dir, backend, spec_path)
findings, ops, undocumented, events, field_source = collect(
docs_dir, backend, spec_path)
if events is None:
print("cannot read webhook.AllEventTypes() from the backend checkout; "
"the event check cannot run")
Expand Down Expand Up @@ -377,6 +401,8 @@ def main():
f"Delete these lines from {BASELINE_NAME}.")
for identity in stale:
print(f" {identity} ({allowed[identity]})")
if kind == "field" and identity in field_source:
print(f" now known from {field_source[identity]}")

if args.list:
print(f"\nSpec operations no page mentions ({len(undocumented)}), "
Expand Down
Loading