diff --git a/api-drift-baseline.txt b/api-drift-baseline.txt index 28ede0a..3ce0831 100644 --- a/api-drift-baseline.txt +++ b/api-drift-baseline.txt @@ -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: kindidentitywhy # 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 diff --git a/guides/mcp-integration.mdx b/guides/mcp-integration.mdx index 0a304eb..7b7eb89 100644 --- a/guides/mcp-integration.mdx +++ b/guides/mcp-integration.mdx @@ -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": { diff --git a/scripts/check-docs-drift.py b/scripts/check-docs-drift.py index 2612eeb..3e46226 100755 --- a/scripts/check-docs-drift.py +++ b/scripts/check-docs-drift.py @@ -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 @@ -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 @@ -169,20 +180,24 @@ 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: @@ -190,7 +205,12 @@ def read_pages(docs_dir): 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 @@ -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() @@ -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 = { @@ -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") @@ -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)}), "