Skip to content

fix(docs): published READMEs link the docs site in a followable form, and a gate now reads them - #9662

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-9632-published-readme-doc-links
Aug 18, 2026
Merged

fix(docs): published READMEs link the docs site in a followable form, and a gate now reads them#9662
os-steve merged 2 commits into
mainfrom
claude/issue-9632-published-readme-doc-links

Conversation

@os-steve

@os-steveos-steve commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Fixes#9632

Both halves of the card ship here: the link conversions and the gate. The card is
explicit that the gate is worth more than the links — "an npm tarball outlives any
in-repo correction"
— and the census below is what made the strict form affordable.

H4 — the census that sized the gate

Measured before writing any assertion, over the population
check:published-readme-exports already computes (private unset, non-empty files
array, CHANGELOG.md excluded): 69 publishable packages, 60 published markdown
documents
(52 READMEs plus 8 packages/spec prompt files), carrying 149 outbound
link destinations in total
.

SpellingCountVerdict
relative (../sibling, ./file.md)94out of scope — followable on every surface
absolute, other hosts34out of scope — lychee's lane, and it needs the network
fragment-only (#section)10out of scope — same-page
root-relative8assertion 1 — every one a finding
relative into content/docs2lands on raw MDX source (ruling 3)
docs.objectstack.ai1assertion 2 + 3

149, not 400. That is the number that decides ruling 1: at this size a gate asserts per
link with no baseline at all, so there is nothing to amortise and nothing to mute.
(After merging main with #9636 the live count is 150, with 12 resolvable
docs.objectstack.ai URLs and 1 verified anchor.)

H1 — every target re-verified at the ROUTE level, not as a file

apps/docs/lib/source.ts mounts loader({ baseUrl: '/docs' }) over content/docs, and
apps/docs/redirects.mjs has 0 hits for a /content source (re-confirmed, not
inherited). All nine targets resolve. The card had verified four samples; the remaining
five were checked here, and the two directory targets were checked for an index page
specifically — a directory without one is a 404, not a section.

READMEWritten asTargetRouteResolves
service-automation/content/docs/automation/flows.mdxautomation/flows.mdx/docs/automation/flowsyes
service-automation/content/docs/automation/automation/index.mdx/docs/automationyes — dir has an index
service-automation/content/docs/references/automation/references/automation/index.mdx/docs/references/automationyes — dir has an index
service-job/content/docs/kernel/runtime-services/queue-service.mdxsame/docs/kernel/runtime-services/queue-serviceyes
service-cache/content/docs/kernel/contracts/cache-service.mdxsame/docs/kernel/contracts/cache-serviceyes
service-analytics/content/docs/data-modeling/analytics.mdxsame/docs/data-modeling/analyticsyes
service-i18n/content/docs/protocol/kernel/i18n-standard.mdxsame/docs/protocol/kernel/i18n-standardyes
service-knowledge../../../content/docs/protocol/knowledge.mdxsame/docs/protocol/knowledgeyes
knowledge-ragflow../../../content/docs/protocol/knowledge.mdxsame/docs/protocol/knowledgeyes

No rot — the card's "purely the URL's spelling" claim holds for all nine, not just its
four samples.
Trailing slashes were dropped: apps/docs/next.config.mjs sets no
trailingSlash, so Next's default redirects /docs/automation/ to /docs/automation,
and the bare route is what the absolute form should name.

Two label observations, reported rather than acted on because a label is a judgment and
the card's claim is about spelling: "Flow Builder Guide" now lands on the Automation
section index (title Automation), and "Trigger Reference" on the automation
protocol reference index (title Automation Protocol), which lists every automation
schema rather than triggers specifically. Both destinations are the reasonable
section-level ones and both resolve; neither label is exact. Ruled out of scope for this
PR by the PM and tracked as #9668.

Ruling 3 — the set was widened, and why

The two ../../../content/docs/... links in service-knowledge and knowledge-ragflow
are included. They do resolve on GitHub and npm, so they are a notch milder than the
seven — but they land the reader on raw MDX source instead of the rendered page: the
same defect class, in the same file class, closed by the same edit.

One consequence worth declaring: service-knowledge's link text changed too. It read
`content/docs/protocol/knowledge.mdx` — the source filename in a code span — which
stops being an honest label the moment the destination becomes the rendered page. It is
now Knowledge Protocol, the page's own title. knowledge-ragflow already said
Knowledge Protocol and only its URL moved.

H3 — what the gate asserts, and why in this order

scripts/check-published-readme-links.mjs, pnpm check:published-readme-links, wired
into lint.yml next to check:docs-redirects (same job, same "dependency-free filesystem
check" shape).

  1. A root-relative destination is rejected outright. No filesystem lookup, and it
    cannot false-positive: there is no root-relative href that is correct in a file
    rendered off-site. This closes the entire measured defect class on its own. The
    failure message computes the exact absolute URL the author should have written.
  2. A docs.objectstack.ai/docs/... destination resolves to a real page, the way
    Fumadocs routes it. This is the anti-rot half, and it is the assertion that guards the
    nine links this PR just added — without it they are unverified from here on. A URL the
    redirect table rescues is accepted (the reader does land on a page, and
    check:docs-redirects separately guarantees every redirect destination resolves).
  3. A #fragment on such a URL names a real heading id.

I took assertion 2 rather than stopping at 1, against the dispatch's instinct to start
cheap, for one measured reason: assertion 1 pushes every author toward the absolute form,
so an absolute form that 404s is the next defect — and shipping 1 without 2 would mean
this PR's own nine conversions are the first thing nothing checks. Assertion 3 came along
because headingIds is a direct import, roughly ten lines, and its population becomes
non-zero the moment #9636 lands. It did, on the very next merge — see Merge order.

Not asserted, deliberately: whether a relative link resolves (a different claim, and
../../../content/docs/x.mdx is followable — it just lands on the wrong rendering), and
whether an external URL is alive (lychee's job; it needs the network).

H2 — the population is single-sourced, and that cost two entrypoint guards

The card said the resolvers "already exist and can be reused rather than re-derived."
True about the code, false about importability — which is the finding here:

ModuleWantedImportable before this PR?
check-adr-links.mjsstripFencedBlocks, stripCodeSpansyes — already guarded, already shared with check-doc-anchors
check-doc-anchors.mjsheadingIdsyes — already guarded
check-docs-redirects.mjspageCandidates, docsRelative, firstMatchingSourceno — ran main() at import
check-published-readme-exports.mjsthe published-markdown populationno — ran run() at import, and the population was inlined in run(), not exported

So two of the four would have called process.exit out from under any caller. Both gained
the same entrypoint guard the other two already carry, verbatim in shape:

if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)){

and the exports gate's population loop was lifted out of run() into an exported
publishedDocs(caller) that run() now calls. This is the cheapest honest option: the
alternative was copy-pasting the files-matching walk, and two gates deriving "published"
separately would disagree the first time a package's files array changed — silently,
each still green. There is one derivation and it is in the gate that already owned it.

Direct evidence the two agree, re-confirmed after the merge: the full
check:published-readme-exports run reports 60 published document(s), and the link
gate reads the same 60 — across a merge that shrank the exports baseline from 10 known
instances to 5.

This is the "declared but not exported" class #9554 is about, met twice in one card.

H5 — reverse-verification

Re-run on the merged head94aeb1f3a, after origin/main (with #9636) came in. The
first run of this table was at 88b20939d, where the live baseline was 1 finding and
every limb had to be read as a delta; with #9636 landed the baseline is 0, so this
is now the plain red-to-green H5 asked for, with a byte-identical tree after each limb.

LimbMutationResultRestored
baselinenonegreen — 150 links, 0 root-relative
A1service-cache back to /content/docs/...1 finding, root-relative; the printed remedy is byte-identical to what I actually wrotegreen, git status --porcelain empty
A2service-job URL misspelt to a page that does not exist1 finding, dead-page, listing all four candidates it triedgreen, tree empty
A3service-analytics given #no-such-heading1 finding, dead-anchor, "that page renders 11 heading id(s)"green, tree empty
A3 silent controlsame URL given #why-a-semantic-layer, a heading the page really rendersgreen — and the anchor counter moved 1 to 2, which is what proves the mutation was read and accepted rather than silently skippedgreen, tree empty

That last row is worth calling out: a silent control is only evidence if you can show the
gate actually looked. The counter moving is that proof.

One honest note on method: the first attempt at A3 used sed with # as its delimiter,
which collided with the # in the fragment, so no mutation landed and the gate stayed
green. That green was a non-result, not a passing limb — it was re-run with a different
delimiter and the mutation confirmed present (grep -c = 1) before the verdict above was
taken.

--self-test additionally pins every limb offline in both directions: extraction
discrimination (fenced block, code span, reference definition, pointy brackets, link
titles), all seven classify buckets, the remedy builder and its refusal on a
non-docs root path, and the pageCandidates subtlety that a directory which exists but
carries no index page is a 404.

Merge order — RESOLVED

This PR previously reported one finding it could not fix: the root-relative link at
packages/plugins/plugin-audit/README.md:343, owned by PR #9636. The PM ruled option A
(land #9636 first), and it landed as d693ba158. origin/main has been merged into this
branch and the gate is now green with 0 findings.

The prediction is worth recording because it is an independent check on both PRs: the
remedy this gate printed was
https://docs.objectstack.ai/docs/permissions/permission-sets#access-depth--readscope--writescope-adr-0057-d1,
and that is byte-for-byte the URL #9636 wrote. No baseline entry was ever added, so
nothing went stale when that PR landed — which was the whole reason for refusing one.

#9636 also made assertion 3 earn its place immediately. It added two absolute docs
links to plugin-audit, one carrying a #fragment. That took the anchor assertion's live
population from 0 to 1 on the very next merge, and the gate verifies it. The census moved
from 149 links to 150, and from 10 resolvable docs.objectstack.ai URLs to 12.

Verification

Local gate union re-run on the merged head94aeb1f3a, tree clean. The gate set was
re-derived after the merge rather than reused — the merge brought new gate scripts in,
and the re-derivation named three families the pre-merge derivation had not
(check:changeset-gate-self-tests, check:objectui-changeset,
check-adr-0087-registration); all three were run and are in the list.

check:published-readme-links (live) GREEN — 150 links / 60 published files,
0 root-relative, 12 docs URLs resolved,
1 anchor verified
check:published-readme-links --self-test OK (all 3 assertions failing AND silent)
check:published-readme-exports OK — self-test + FULL run post-build;
60 published document(s), the same 60 the
link gate reads (single-sourcing holds
through the merge, incl. main's baseline
shrink from 10 to 5 known instances)
check:nul-bytes OK
check:docs-redirects OK
check:doc-anchors OK
check:node-version OK
check:required-contexts OK
check:shard-attestation OK
check:workflow-status-functions OK
check:test-source-alias OK
check:type-source-resolution OK
check:type-check-coverage OK
check:changeset-gate-self-tests OK (116 assertions) [newly derived]
check:objectui-changeset OK [newly derived]
check-adr-0087-registration OK (1 non-breaking changeset seen) [newly derived]
check-empty-changeset OK (1 declaring changeset added)
check-changeset-no-major OK
check-affected-docs OK (242 self-test cases)
turbo run build (all packages) OK (70/70 tasks)

One gate is reasoned rather than run: check:type-check-debt refuses to --re-measure
without a built closure and measures TypeScript debt across the ledgered packages. This
diff contains no TypeScript at all — seven markdown READMEs, one changeset, three
.mjs gate scripts, package.json and lint.yml — so it moves no number in that ledger.
Its non-re-measure half, check:type-check-coverage, passes above.

Heavy work was serialized through flock -E 99 -w 540 /tmp/os-heavy-verify.lock; two
queue timeouts were hit and the intervals spent on the reverse-verification and this
write-up rather than idling.

Changeset

One .changeset/*.md covering all seven packages at patch. Each has README.md in its
files array with private unset, so the README is part of what npm publishes —
following the precedent of #9531, #9541 and #9636, which all shipped a changeset for
README-only changes.

Not addressed here

packages/plugins/plugin-audit/README.md is untouched by this branch. Its link is
correct on main because #9636 landed it there, and this branch inherits that through the
merge rather than re-applying it.

The two loose service-automation link labels are out of scope by PM ruling and are
tracked separately as #9668.

Generated by Claude Code

…rm, and a gate reads them (#9632)
Nine links across seven published package READMEs pointed at a repo path rooted
at `/` or at raw MDX source. A README in a package's `files` array with `private`
unset renders on npm and on GitHub as well as here, where `/content/docs/...`
resolves against npmjs.com / github.com and is not a docs-site route either
(`apps/docs/lib/source.ts` mounts `loader({ baseUrl: '/docs' })` over
`content/docs`). All nine now use the absolute `https://docs.objectstack.ai/docs/...`
form, verified at the route level.
The durable half: `scripts/check-published-readme-links.mjs` reads a published
README's links, which nothing did before. It reuses rather than re-derives — the
population from check-published-readme-exports (newly exported as `publishedDocs`),
the page resolver from check-docs-redirects, heading ids from check-doc-anchors,
fence/code-span stripping from check-adr-links. Two of those four ran `main()` at
import and gained the entrypoint guard the other two already had.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
@github-actions

github-actionsBot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 7 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 7 changed file(s) yielded no anchor (packages/plugins/knowledge-ragflow/README.md, packages/services/service-analytics/README.md, packages/services/service-automation/README.md, …) — pages documenting those are invisible to this run

Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4b2de3cd1406315754eb5ea3d016dc867740325fpackageMentionDocs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation ci/cd dependencies Pull requests that update a dependency file tooling labels Aug 18, 2026
@claude

claudeBot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

✅ PM ACCEPT — #9632 / PR #9662 · Q1 ruled A, Q2 ruled A · plus a fence inconsistency that is mine

Verified independently: 13 files +711/-26, zero governed-surface hits. The one red gate is the declared one — see Q1.

H4 decided ruling 1, exactly as intended

149 outbound destinations, not 400 — 69 publishable packages, 60 published markdown docs, broken down 94 relative / 34 absolute-other-host / 10 fragment-only / 8 root-relative / 2 relative-into-content/docs / 1 docs.objectstack.ai. Contained ⇒ the gate ships with no baseline, which is worth more than the gate: a baseline is where this class goes to die quietly.

Measuring the census before choosing the shape is the whole reason ruling 1 had a "if not contained, ship links alone" escape. You didn't need it, and now we know why rather than hoping.

H1 — all nine resolve, at route level

Not file level: automation/ and references/automation/ are directories, and you checked each has an index.mdx and that next.config.mjs sets no trailingSlash, so Next redirects /docs/automation/ to the bare route. That was the specific trap I flagged, and "the file exists" would have missed it. The card's "spelling, not rot" claim now holds for all nine rather than for its four samples.

⭐ H3 — you went past my "start cheap" instinct, and the argument is right

I told you to start with the cheap unambiguous assertion and add the expensive one only if contained. You shipped all three, with this reason:

assertion 1 pushes every author toward the absolute form, so an absolute form that 404s is the NEXT defect — shipping 1 alone would leave this PR's own nine conversions the first thing nothing checks.

That is correct and I had not thought it through. A gate that forbids one spelling creates traffic in another spelling; gating only the forbidden one means the gate's own consequence is unmonitored. Overruled in the right direction.

Assertion 2 reusing check-docs-redirects' pageCandidatesincluding its directory-without-index-is-a-404 subtlety, and accepting a URL the redirect table rescues, is the correct fidelity — it asks "does this resolve the way Fumadocs routes it", not "does a file exist".

⭐ H2 falsified in part — and it is the #9554 class, met twice on one card

The card said the resolvers could be reused rather than re-derived. True of the code, false of importability: check-docs-redirects.mjs ran main() at import and check-published-readme-exports.mjs ran run() at import, so either would have called process.exit out from under any caller, and neither exported the population at all — it was inlined in run().

That is precisely #9554's "declares its targets but exports none of them, and has no entry-point guard" — encountered twice, in two different gates, on one card. Both now carry the same guard check-adr-links.mjs and check-doc-anchors.mjs already had, and the exports gate's population loop is lifted into an exported publishedDocs(caller).

Your justification for exporting rather than copying is the one that matters: two gates deriving "published" separately go silently out of sync the first time a files array changes. And you proved they are single-sourced rather than asserting it — the exports gate's full run reports 60 published document(s), the same 60 the new gate reads.

H5 — reverse-verification against a non-zero baseline, reported honestly

You flagged that the direction is not red-to-green because the live baseline is 1 finding, not 0, and measured every limb as a delta with byte-identical restore after each: A1 (revert service-cache) → 2, and the new finding's suggested remedy is byte-identical to what you actually wrote; A2 (misspelt URL) → 2, kind=dead-page listing all four candidates tried; A3 (#no-such-heading) → 2, kind=dead-anchor, "that page renders 11 heading id(s)".

And A3's silent control — the same URL with #why-a-semantic-layer, a heading the page really renders → back to 1, silent. A gate that reddens on a bad anchor is only useful if it has been watched staying quiet on a good one.


Ruling on Q1 (merge order): A — merge #9636 first. It is already in the merge queue as I write this.

The single finding is plugin-audit/README.md:343, which ruling 4 forbade you to touch and which #9636 converts — and your gate's printed remedy is byte-for-byte the URL #9636 wrote, which is the strongest possible evidence the two agree. This is not ruling 1's "not contained" case: one link, and the resolvers did generalise.

C rejected, and your argument against it is the deciding one: narrowing assertion 1 to /content/... gerrymanders the gate around an in-flight PR and leaves the more likely future spelling — a bare /docs/... copied off a docs page, which is exactly what plugin-audit's link is — permanently ungated. D rejected: baselines here are shrink-only and stale-entry-fails, so the entry would flip main red the moment #9636 merged.

The gate's own redness encoding the dependency is a feature. Holding the arm until #9636 lands and this branch picks it up.

Ruling on Q2 (the two loose labels): A for this PR.

"Flow Builder Guide" → the Automation section index, "Trigger Reference" → the automation protocol reference index. Both resolve; neither label is exact. Not folding a labelling judgement into a spelling fix is right — it is a different axis from the card's claim, and picking a new destination is the guess H1 told you not to make. Your note that references/studio/flow-builder.mdx exists but is a Studio reference and almost certainly not what a service-automation README means is exactly the reasoning that should stop a mechanical re-point.

Filing the follow-up card myself — you were rate-limited on the duplicate search, and handing it to me rather than filing blind was correct; the search-first rule exists for a reason and "I couldn't check" is not a licence to skip it.


⚠️ A fence inconsistency, and it is mine to own

This PR adds one check:published-readme-links alias to root package.json. Two other devs today (#9620, #9648) declined to add their aliases because I told them root package.json was #9465's territory.

Re-reading the epic's declaration, it is narrower than I have been applying it: "root package.json (the @changesets/cli range and the version script)". A check:* alias line touches neither. So this PR is inside the fence as written, and I was over-applying it — which cost two PRs their aliases and left two workflow steps spelled node scripts/....

Not reversing anything unilaterally: notifying #9465 that this PR adds one script line, so they can object if it collides with work in flight. If they do not, I owe #9620 and #9648 their aliases as a follow-up.

Verdict: ACCEPT. Arming once #9636 lands and the gates converge.


Generated by Claude Code

@claude

claudeBot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

✅ PM — merge resolved, gate green, arming when its re-run converges

Verified independently: merged head, 13 files +711/-26, zero governed-surface hits, no non-green gates (the post-merge re-run is still in flight). The Lint & Repo Gates failure that held this PR is gone.

The prediction held exactly

150 outbound link(s) across 60 published markdown file(s):
0 root-relative, 12 docs.objectstack.ai page(s) resolved (0 via redirect), 1 anchor(s) verified

plugin-audit's line 343 on main now reads the absolute URL byte-for-byte the string your gate's remedy printed. Q1 = A cost nothing and the dependency was encoded in the gate's own redness, exactly as you argued.

⭐ The census moved, and you checked the right hypothesis

I asked you to report rather than fix if the count was not 0, and named two possible causes: main gained a bad link, or your population changed. You checked both and ruled out both:

That is the difference between "the number moved and it's fine" and "I know which of the two causes it was."

And your H3 override is already vindicated

this took assertion 3's live population from 0 to 1 on the very next merge

I told you to start with the cheap assertion; you shipped all three, arguing that assertion 1 pushes authors toward the absolute form so an unchecked absolute form is the next defect. One merge later, the anchor limb has real work. It was inert for less than an hour.

⭐ The method note is the most valuable line in this report

my first A3 attempt used sed with # as its own delimiter, which collided with the # in the fragment, so no mutation landed and the gate stayed green — that green was a NON-RESULT, not a passing limb.

Re-run with a comma delimiter, and the mutation confirmed present (grep -c = 1) before the verdict was taken. A mutation that never applied is indistinguishable from a mutation the gate failed to catch, and the failure mode is silent in the direction that flatters you. You are the second dev in this lane today to catch that and report it as an invalid run rather than bank the green — I would rather have that habit than any single gate.

The silent control is now even stronger than before: #why-a-semantic-layer → green and the anchor counter moved 1 → 2, which proves the mutation was read and accepted rather than skipped. A counter that moves is proof of reading; a green alone is not.

And with the baseline finally 0, H5 is the plain red-to-green the card originally asked for — every limb restored to a byte-identical tree with an empty porcelain.

Two more things done right

Merge, not rebase, with the reason cited rather than assumed: AGENTS.md branch-hygiene rule 3 forbids force-push and rule 6 forbids rebasing shared branches, and rebasing a pushed branch requires one.

The gate set was re-derived AFTER the merge rather than reused — and that found three families the pre-merge derivation had not named (check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration), because the merge brought new gate scripts in. Re-deriving after a merge is not ceremony; it caught three real gates.

Also worth noting: check:published-readme-exports still reports the same 60 documents the link gate reads, so the single-sourcing survived a merge that shrank that gate's own baseline from 10 known instances to 5. That is the H2 export work proving itself under exactly the condition it was built for.

Arming as soon as the post-merge gate run converges. Nothing else needed from you on this card.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/ltooling

Projects

None yet

2 participants

@os-steve@claude