Skip to content

Seven docs links in five published READMEs are spelled /content/docs/..., a path that resolves on none of the three surfaces — and no gate reads a published README's links at all #9632

Description

@os-steve

Found while implementing #9589 (restoring one such link in plugin-audit). Reported rather than fixed: the seven links below are in four other packages' published READMEs, outside that card's file surface.

The defect

A README listed in a package's files array with private unset is what the npm package page renders, and what a reader browsing GitHub sees. Seven links across five published packages are spelled as a repo path rooted at /:

File Line Link
packages/services/service-automation/README.md 155 [Flows](/content/docs/automation/flows.mdx)
packages/services/service-automation/README.md 394 [Flow Builder Guide](/content/docs/automation/)
packages/services/service-automation/README.md 395 [Trigger Reference](/content/docs/references/automation/)
packages/services/service-job/README.md 366 [Job Scheduling Guide](/content/docs/kernel/runtime-services/queue-service.mdx)
packages/services/service-cache/README.md 292 [Caching Best Practices](/content/docs/kernel/contracts/cache-service.mdx)
packages/services/service-analytics/README.md 395 [Analytics Guide](/content/docs/data-modeling/analytics.mdx)
packages/services/service-i18n/README.md 375 [I18n Best Practices Guide](/content/docs/protocol/kernel/i18n-standard.mdx)

All five packages are published (private unset, README.md in files).

This form resolves on none of the three surfaces, which is what separates it from a merely-ugly link:

  • npm — a root-relative href resolves against npmjs.com, not the docs site.
  • GitHub — it resolves against github.com, not against the repo path it looks like.
  • the docs site itself — apps/docs/lib/source.ts mounts loader({ baseUrl: '/docs' }) over content/docs, so the route for that first entry is /docs/automation/flows. /content/docs/... is not a route, and apps/docs/redirects.mjs contains no /content source to rescue it (grep -n "'/content" apps/docs/redirects.mjs → 0 hits).

Every target file exists — this is purely the URL's spelling, not rot. The pages are reachable; the links are not.

The established convention is the absolute form, already used by packages/create-objectstack/README.md:90 and the blank template README (lines 39, 70, 108): https://docs.objectstack.ai/docs/... with the path taken under content/docs. Verified against four sample targets, all of which exist.

A separate, milder form appears in packages/services/service-knowledge/README.md:8 and packages/plugins/knowledge-ragflow/README.md:5 (](../../../content/docs/protocol/knowledge.mdx)). Those relative paths do resolve on GitHub and npm, but they land a reader on raw MDX source rather than the rendered page. Worth converting in the same pass; not the same severity, and listed here so the sweep is complete rather than as part of the claim above.

The durable half: nothing gates this

Measured, not assumed. A published README's outbound links are read by no gate in this repo:

  • check:published-readme-exports (scripts/check-published-readme-exports.mjs, added by PR ci: a published README's symbol claims are checked against the package's built exports #9546) is the one gate whose population is published READMEs, and it is deliberately not a docs linter. It reads fenced code blocks only (typescript / ts / tsx / diff / untagged) and makes exactly one claim per import line and call site. Its only occurrences of ]( are in its own header prose and self-test fixtures. It has no notion of a link.
  • The lychee lane (.github/workflows/check-links.yml) runs over exactly four globs: content/**/*.md, content/**/*.mdx, README.md, ARCHITECTURE.md. packages/**/README.md is not among them.
  • check:doc-anchors takes its link sources from content/** plus EXTRA_SOURCES = ['README.md', 'ARCHITECTURE.md'] — the root README, not package READMEs.
  • check:adr-links is scoped to docs/adr/; check:docs-redirects to apps/docs/redirects.mjs.

So a published README can link anywhere, in any spelling, and ship to npm green. That is the half worth more than the seven links: an npm tarball outlives any in-repo correction, and the reader who follows the link is the one person who cannot file a bug about it.

Proposed shape, with sizing

A contained check, in the same spirit as check:published-readme-exports — narrow, one claim, no docs-linting ambition:

For every markdown file a package publishes (its files array, CHANGELOG.md excluded — same population the exports gate already computes), read markdown link destinations and assert:

  1. a destination beginning / is rejected outright with the absolute-form remedy in the message. This is the whole defect above, it needs no filesystem lookup, and it cannot false-positive: no root-relative href is correct in a file rendered off-site.
  2. a destination on docs.objectstack.ai resolves to a real page the way Fumadocs routes it — apps/docs/redirects.mjs's resolver already does exactly this walk (x.mdx / x.md / x/index.mdx / x/index.md) and can be reused rather than re-derived.
  3. optionally, that a #fragment on such a URL names a real heading id — scripts/check-doc-anchors.mjs already computes Fumadocs heading ids with github-slugger plus the [#custom-id] suffix, so this is a call into an existing implementation, not a second slugger.

Assertion 1 alone closes the measured defect class. 2 and 3 are the anti-rot half and are the reason to reuse the two existing resolvers rather than write a third.

Sizing: small-to-medium. The population computation and the fenced-block/prose split already exist in check-published-readme-exports.mjs; the two resolvers already exist. The risk is scope creep into general link-checking, which assertion 1's narrowness is meant to prevent. No baseline should be needed if the seven links are converted in the same PR — worth confirming with a run before committing to that.

Refs: #9589 · PR #9546 · PR #9531

Activity

  1. os-steve commented on Aug 18, 2026

    @os-steve
    CollaboratorAuthor

    Claiming this card.

    • session: session_01XqDQYVU5smx29ts9pAErja
    • branch: claude/issue-9632-published-readme-doc-links
    • worktree: ../objectstack-9632

    Scope per dispatch: the seven /content/docs/... links across five published service READMEs, the two ../../../content/docs/... relative links (service-knowledge, knowledge-ragflow), plus the gate half. Staying off packages/plugins/plugin-audit/README.md (PR #9636 owns it).


    Generated by Claude Code

  2. os-steve commented on Aug 18, 2026

    @os-steve
    CollaboratorAuthor
    {
      "issue": 9632,
      "status": "done",
      "branch": "claude/issue-9632-published-readme-doc-links",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9662",
      "premise_still_valid": true,
      "summary": "H4 CENSUS (measured first, it decides ruling 1): 69 publishable packages, 60 published markdown docs (52 READMEs + 8 packages/spec prompt files, CHANGELOG excluded), 149 outbound link destinations total -- 94 relative, 34 absolute-other-host, 10 fragment-only, 8 ROOT-RELATIVE, 2 relative-into-content/docs, 1 docs.objectstack.ai. 149, not 400, so the gate is contained and ships with NO baseline. H1 TARGET RESOLUTION, all nine at route level (not file level): every one resolves, so the card's 'purely spelling, not rot' claim holds for all nine and not just its four samples. automation/flows.mdx->/docs/automation/flows; automation/ ->/docs/automation (dir HAS index.mdx); references/automation/ ->/docs/references/automation (dir HAS index.mdx); kernel/runtime-services/queue-service; kernel/contracts/cache-service; data-modeling/analytics; protocol/kernel/i18n-standard; protocol/knowledge x2. Trailing slashes dropped -- apps/docs/next.config.mjs sets no trailingSlash so Next redirects /docs/automation/ to the bare route. Re-confirmed 0 hits for a '/content' source in redirects.mjs. H3 DECISION: shipped all three assertions, not just the cheap one. (1) root-relative rejected outright -- no fs lookup, cannot false-positive, closes the whole measured class. (2) a docs.objectstack.ai/docs/... URL resolves the way Fumadocs routes it, reusing check-docs-redirects' pageCandidates INCLUDING its directory-without-index-is-a-404 subtlety; a URL the redirect table rescues is accepted. (3) a #fragment names a real heading id via check-doc-anchors' headingIds. I took (2) against the dispatch's start-cheap instinct for one measured 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. H2 FALSIFIED IN PART: the card said the resolvers can be reused rather than re-derived -- true about the code, FALSE about importability. check-docs-redirects.mjs ran main() at import and check-published-readme-exports.mjs ran run() at import, so both would have called process.exit out from under any caller; neither exported the population at all (it was inlined in run()). Both gained the SAME entrypoint guard check-adr-links.mjs and check-doc-anchors.mjs already carry, and the exports gate's population loop was lifted into an exported publishedDocs(caller). Cheapest honest option -- the alternative was copy-pasting the files-matching walk, and two gates deriving 'published' separately go silently out of sync the first time a files array changes. This is exactly the #9554 declared-but-not-exported class, met twice on one card. RULING 3 widened set: the two ../../../content/docs links converted too; service-knowledge's link TEXT also changed because it was the source filename in a code span, which stops being honest once the destination is the rendered page. Nine links across seven packages, one changeset at patch for all seven (all have README.md in files with private unset).",
      "tests": "All run AFTER the final commit, at 88b20939d, tree clean (git status --porcelain empty). H5 REVERSE-VERIFICATION ON THE LIVE TREE, and the direction is NOT red-to-green -- reporting what I actually observed rather than the template's assumption: the live baseline is 1 finding, not 0 (see open_questions), so every limb was measured as a DELTA with a byte-identical restore after each. baseline=1 finding. A1: reverted service-cache to /content/docs/... -> 2 findings, the new one naming the file, and its suggested remedy is BYTE-IDENTICAL to what I actually wrote; restore -> 1, porcelain empty. A2: misspelt service-job's URL to a nonexistent page -> 2, kind=dead-page listing all four candidates tried; restore -> 1, empty. A3: gave service-analytics '#no-such-heading' -> 2, kind=dead-anchor, 'that page renders 11 heading id(s)'; restore -> 1, empty. A3 SILENT CONTROL: same URL with '#why-a-semantic-layer', a heading the page really renders -> 1 (silent, as required); restore -> 1, empty. --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 directory/index subtlety. GATE UNION at 88b20939d: check:nul-bytes OK (6178 files, plus my own grep -naP sweep of every changed file, clean); check:published-readme-links --self-test OK; check:published-readme-links live = 1 finding (plugin-audit, declared); check:published-readme-exports OK -- self-test AND the FULL run after a real turbo build, reporting '60 published document(s) across 77 workspace package(s)', the same 60 the new gate reads, which is the direct evidence the population is single-sourced; check:docs-redirects OK (48 assertions); check:doc-anchors OK (247 live fragment links); check:adr-links --self-test OK; check:node-version, check:required-contexts, check:shard-attestation, check:workflow-status-functions, check:test-source-alias, check:type-source-resolution, check:type-check-coverage (64/77, 13 ledgered, 1 exempt) all OK; check-empty-changeset OK (1 declaring changeset); check-changeset-no-major OK; check-affected-docs OK (242 cases); turbo run build over all packages OK (70/70). Gate set derived from the REAL changed paths with node scripts/pm/dispatch-gates.mjs rather than recalled -- it names the new check:published-readme-links itself, confirming the lint.yml wiring is discoverable. ONE GATE REASONED, NOT RUN: check:type-check-debt refuses to --re-measure without a built closure and measures TypeScript debt across 13 ledgered packages; this diff contains no TypeScript at all (7 markdown READMEs, 1 changeset, 3 .mjs gate scripts, package.json, lint.yml), so it moves no number in that ledger, and its non-re-measure half check:type-check-coverage passes. Heavy work was serialized through flock -E 99 -w 540 /tmp/os-heavy-verify.lock; one 99 was hit and the interval spent writing the PR body rather than idling.",
      "open_questions": [
        {
          "question": "MERGE ORDER: the gate is contained but not green on today's main. It reports exactly ONE finding -- packages/plugins/plugin-audit/README.md:343, the root-relative /docs/permissions/permission-sets#access-depth... link -- which dispatch ruling 4 forbids me to touch and which open PR #9636 already converts (my gate's printed remedy is byte-for-byte the URL #9636 wrote). This is not ruling 1's 'not contained' case: it is one link, not dozens, and the resolvers did generalise. But my PR cannot go green until #9636 lands.",
          "options": [
            "A -- merge #9636 first, then this. No extra work; the redness is self-enforcing and the failure message names the file and the exact fix.",
            "B -- I convert that one line in this PR, which directly contradicts ruling 4 and collides with an open PR on the same file.",
            "C -- I narrow assertion 1 to reject only /content/... , leaving the bare /docs/... spelling ungated.",
            "D -- baseline the single entry. I already rejected this one: the repo's baselines are shrink-only and stale-entry-fails, so the entry would flip main from green to red the moment #9636 merged."
          ],
          "recommendation": "A. It costs nothing, keeps the principled boundary (H3's point that no root-relative href can ever be correct off-site), and the gate's own redness encodes the dependency. C is the tempting one and I recommend against it: narrowing 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 what plugin-audit's link actually is -- permanently ungated."
        },
        {
          "question": "Two link LABELS in service-automation are loose, and I converted the URLs mechanically rather than guessing new destinations (H1 says report rather than guess). 'Flow Builder Guide' now lands on the Automation section index (page title: Automation), and 'Trigger Reference' on the automation protocol reference index (page title: Automation Protocol), which lists every automation schema rather than triggers specifically. Both destinations resolve and both are the reasonable section-level targets; neither label is exact. A content/docs/references/studio/flow-builder.mdx page does exist, but it is a Studio reference and almost certainly not what a service-automation README means.",
          "options": [
            "A -- leave as-is; the card's claim was spelling and the destinations are defensible.",
            "B -- relabel in place (e.g. 'Automation Guide' and 'Automation Protocol Reference') in a follow-up docs PR.",
            "C -- re-point 'Trigger Reference' at a specific trigger page such as references/automation/time-relative-trigger."
          ],
          "recommendation": "A for this PR, B as a cheap follow-up if you want it. I deliberately did not fold a labelling judgment into a spelling fix -- it is a different axis from the card's claim, and choosing a new destination is exactly the guess H1 told me not to make."
        }
      ],
      "out_of_scope_findings": [
        "NOT FILED -- GitHub search + issue-create are rate-limited for this user right now (search_issues returned 'API rate limit already exceeded'), and rule 3 requires a duplicate search before filing. Handing it to PM rather than filing blind: the two loose link labels in packages/services/service-automation/README.md described in open_questions[1]. Observation class, would carry the 'finding' label, no pm:queue, unassigned. Happy to file it on a retry if you want it as a card."
      ]
    }

    Generated by Claude Code

  3. os-steve commented on Aug 18, 2026

    @os-steve
    CollaboratorAuthor

    Follow-up report after the PM's Q1=A ruling and the main merge. Supersedes the report in #9632 (comment).

    {
      "issue": 9632,
      "status": "done",
      "branch": "claude/issue-9632-published-readme-doc-links",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9662",
      "premise_still_valid": true,
      "summary": "MERGE DONE, GATE GREEN. origin/main (carrying #9636 as d693ba158) merged into the branch as 94aeb1f3a, no conflicts, pushed. Merge not rebase: AGENTS.md branch hygiene rule 3 forbids force-push and rule 6 forbids rebasing shared branches, and a rebase of a pushed branch needs a force-push. YOUR EXPECTED RESULT CONFIRMED: pnpm check:published-readme-links is now GREEN with 0 findings -- '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'. The prediction held exactly: plugin-audit README line 343 on main now reads the absolute URL, byte-for-byte the string my gate's remedy printed. THE CENSUS MOVED, AND IN A WAY WORTH KNOWING (this is the 'tell me what it found' part, and it is good news rather than a second finding): links 149 -> 150, resolvable docs.objectstack.ai URLs 10 -> 12, verified anchors 0 -> 1. Cause is #9636 alone: it converted one existing link (no count change) and ADDED one new absolute docs link, and one of its two links carries a #fragment. Neither main gaining a bad link nor a population change -- the published-markdown population is still exactly 60 documents, unchanged across the merge, which I checked specifically because that was your other hypothesis. Notably this took assertion 3's live population from 0 to 1 on the very next merge, so the anchor limb I argued into the PR is already doing real work rather than sitting inert. NOT TOUCHED: packages/plugins/plugin-audit/README.md -- the branch inherits #9636's fix through the merge, it is not re-applied; git diff confirms my 13 changed paths are unchanged from before the merge. PR left in DRAFT and nothing armed, per your instruction.",
      "tests": "All re-run on the merged head 94aeb1f3a, tree clean. GATE SET RE-DERIVED AFTER THE MERGE rather than reused -- the merge brought new gate scripts in, and node scripts/pm/dispatch-gates.mjs over my real changed paths named THREE families the pre-merge derivation had not: check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration. All three run, all OK. H5 RE-RUN, and it is now the plain red-to-green the card originally asked for, because the baseline is finally 0: baseline GREEN; A1 (service-cache back to /content/docs/...) -> 1 finding root-relative, remedy byte-identical to what I wrote -> restore GREEN, porcelain empty; A2 (service-job URL misspelt) -> 1 finding dead-page listing all four candidates -> restore GREEN, empty; A3 (service-analytics given #no-such-heading) -> 1 finding dead-anchor 'that page renders 11 heading id(s)' -> restore GREEN, empty; A3 SILENT CONTROL (#why-a-semantic-layer, a heading the page really renders) -> GREEN and the anchor counter moved 1 to 2, which is what proves the mutation was READ AND ACCEPTED rather than silently skipped. METHOD NOTE I am flagging rather than hiding: 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 above was taken. FULL UNION at 94aeb1f3a: check:published-readme-links live GREEN + --self-test OK; check:published-readme-exports OK (self-test AND full run after a fresh 70/70 turbo build) reporting 60 published document(s) -- the same 60 the link gate reads, so single-sourcing survived a merge that shrank that gate's own baseline from 10 known instances to 5; check:nul-bytes, check:docs-redirects, check:doc-anchors, check:node-version, check:required-contexts, check:shard-attestation, check:workflow-status-functions, check:test-source-alias, check:type-source-resolution, check:type-check-coverage, check:changeset-gate-self-tests (116 assertions), check:objectui-changeset, check-adr-0087-registration, check-empty-changeset, check-changeset-no-major, check-affected-docs (242 cases) -- all OK. check:type-check-debt still reasoned rather than run: it refuses to --re-measure without a built closure and measures TypeScript debt, and this diff contains no TypeScript at all. Heavy work serialized through flock -E 99 -w 540 /tmp/os-heavy-verify.lock; two queue timeouts hit, both intervals spent on the reverse-verification and the PR body rather than idling. PR body updated to match the merged head (Merge order section rewritten as RESOLVED, H5 table replaced with the red-to-green run, verification block re-stamped at 94aeb1f3a). Worktree torn down without --force; no background processes or monitors were ever started.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed by PM as #9668: the two loose link labels in packages/services/service-automation/README.md ('Flow Builder Guide' lands on the Automation section index, 'Trigger Reference' on the Automation Protocol reference index). Ruled Q2=A, out of scope for this PR; PR body updated to point at #9668 instead of offering to act."
      ]
    }

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions