Skip to content

fix(scripts): check-doc-links resolves the #fragment, not just the file (objectui#7644) - #7657

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-7644-doc-links-anchor-fragments
Sep 4, 2026
Merged

fix(scripts): check-doc-links resolves the #fragment, not just the file (objectui#7644)#7657
os-sam merged 1 commit into
mainfrom
claude/issue-7644-doc-links-anchor-fragments

Conversation

@os-sam

@os-sam os-sam commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7644

The defect

scripts/check-doc-links.mjs validated the document a link named and never the
anchor inside it. hrefPath() and routeExists() both opened by cutting the href at
its #, and EXTERNAL_HREF_RE waved a pure #anchor through by shape — so
[label](#some-heading) was judged by nothing at all.

Renaming a heading therefore orphaned every cross-reference to it, silently, with the gate
green. Not a projection: in PR #7643 ### KanbanSchema became ### DeclarativeKanbanSchema
and the [...](#kanbanschema) one screen away had to be corrected by hand.

Reproduced as a red first — the two-arm control

The card's control was re-run on this branch before any change, each arm proved on disk
by anchored count plus git hash-object movement off the HEAD blob, each restored with
git checkout HEAD -- ABSOLUTE_PATH under an EXIT INT TERM trap and verified by blob
equality plus an empty git diff HEAD. #declarativekanbanschema does not exist on main
(PR #7643 has not landed), so the same link was mutated in its pre-rename spelling.

arm mutation before this PR after this PR
subject one in-page anchor rewritten to a non-heading exit 0Links are valid across 17 scan roots. exit 1[anchor] ...:1193 -> #totally-not-a-heading-xyz
control [control probe](./no-such-page-xyz.md) appended exit 1[relative] ...:1285 exit 1[relative] ...:1285 unchanged

The control arm is what makes the zero a reading rather than a dead instrument: the same
invocation, on the same file, caught a broken file path and missed a broken fragment.

What the check now decides

A fragment is judged when, and only when, the document it names is markdown in this tree.
Every form was proved to fire by planting one break at a time on the real tree — each
with the disk proof and trap-guarded restore above, against a lit control (the unmutated
tree exits 0), and each producing exactly one finding:

form planted break result
same page, docs rule content/docs/api/schema-reference.md #tableschema [anchor] ...:536
cross-file /docs/...#frag content/docs/guide/objectos-integration.mdx [anchor] ...:662
same page, disk rule packages/layout/README.md #registration [anchor] ...:193
this repo's blob/main/...#frag content/docs/guide/data-source.md [anchor] ...:206

The relative ./page.md#frag form has no live instance in the tree; it is covered by
fixture tests (reports a cross-file fragment naming a heading the target does not have).

The blob-URL row retires a waiver written four header sections up: objectui#3536 said a
self-repo blob URL's #fragment was out of scope because "resolving an anchor means
parsing the target, which is a different gate". This is that gate, so the bullet was
corrected in place rather than left standing.

Left undecidable, deliberately, and stated rather than implied: a fragment on a site
route outside the docs collection (apps/site/app is TSX, not headings), on a non-markdown
target, on any other origin, and a #L42 raw-view line reference. Those return no
opinion
— never a pass dressed as one.

The slug rule is the renderers' own, not this gate's

Both renderers use github-slugger, one instance per document, walked over the headings in
order so a repeated heading's -1/-2 suffix lines up. content/docs is fumadocs, which
also honours an explicit ## Heading [#custom-id]; every disk surface is GitHub, which
has no such syntax, so that branch is applied to the docs rule only.

The rule is vendored, not imported, and that is forced. check-doc-links.mjs is one of
the gates a workflow runs before pnpm installnode scripts/check-pre-install-import-graph.mjs --list
names it — so its whole static import graph must be node builtins plus repo-relative
modules. A github-slugger import would be ERR_MODULE_NOT_FOUND in docs-links.yml,
turning a gate that fails loudly into one that stops running on exactly the markdown-only
PRs it exists for. That gate confirms the new module is clean:

check-pre-install-import-graph: OK - 23 pre-install step(s) in 18 job(s) run 21 scripts/
gate(s); 24 module(s) walked, every non-relative leaf a node builtin.

The table is copied byte-for-byte, not re-derived. The tempting shortcut,
/[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gu, is not equivalent — compared over all 1,112,064
non-surrogate code points it disagrees on the No digits (², ¹, ¼, which
github-slugger strips and \p{N} keeps) and on every letter Unicode has added since the
table was generated (- Arabic Extended-B, ...). Either direction yields a
wrong anchor set: a false red on a correct link, or a false green on a dead one. Recorded
in LICENSE-THIRD-PARTY.md (ISC, Dan Flettre).

Proving the copy stays honest — the corpus pin

The equivalence is asserted by measurement, not by reading. The pin test compares:

  • the vendored table against the real github-slugger, character by character, over all
    1,112,064 non-surrogate code points, plus the duplicate-suffix counter;
  • this gate's anchor sets against fumadocs' own getTableOfContents, over every file in
    the scan surface — 273 files (184 docs, 89 disk), zero disagreements.

Both truth sources are resolved from the workspace package that declares them, so
neither is a phantom root dependency: fumadocs-core from apps/site (the docs renderer
itself), github-slugger from @object-ui/plugin-markdown. One test-only root devDependency
was added, remark-mdx: without it a JSX element followed by a heading with no blank line
between them parses as a single HTML block, and four real files here have that shape
the truth source would have been wrong, not the gate.

That corpus check is also why the flattener's one non-obvious rule is written the way it is:
a */_ run is emphasis only when it is not intraword. The obvious way (strip them all)
disagreed with mdast on two real headings here, NON_GRID_ROW_CEILING and a new_window in
ROADMAP.md.

Proving the new tests are not vacuous

A gate that passes because its loop body never runs is the #7070 failure mode, so each new
assertion was ablated — mutation proved on disk by anchored count plus hash movement off the
HEAD blob, restored under a trap and verified by blob equality plus an empty git diff HEAD.
No build step is involved and none was skipped: the test imports ../check-doc-links.mjs and
../github-slug.mjs by relative source path, so there is no dist/ for a mutation to
fail to reach.

leg mutation predicted observed
C1a drop toLowerCase() from the vendored slug() code-point pin RED, corpus RED met — 13 red, incl. all three new pins. Code-point pin's first differing char U+41 (A); corpus reported expected [ …(273) ] to deeply equal [] — every file disagreed
C1b make the duplicate counter never fire (no -1 suffix) duplicate-counter pin RED, code-point pin GREEN, corpus RED iff the tree has duplicate headings met — 3 red: duplicate behaviour, duplicate-counter pin, corpus. Code-point pin stayed green, as it never engages duplicates. The corpus red also settles the conditional: the tree does have duplicate heading slugs
C2 revert the flattener's intraword rule to the naive */_ strip corpus RED, code-point pin GREEN met — exactly 1 red, the corpus check, and nothing else. That is the whole argument for it: it is the only thing standing between the naive flattener and a silently wrong anchor set

Every leg proved its mutation on disk (anchored count plus git hash-object off the HEAD
blob), restored with git checkout HEAD -- ABSOLUTE_PATH, and verified the restore by blob
equality plus git diff HEAD exiting 0. Final tree state after the battery: 0 modified
files
. Each leg's lock line read VERDICT command-exit N — a real run, never a 99
"never acquired" mistaken for a result.

One prediction missed, reported as a miss. A first pass at the same-page leg predicted a
clean single-occurrence mutation of ](#tableschema); that anchor occurs twice, perl
replaced one, and the no-op guard correctly refused the reading. It also left the file
mutated, which contaminated the next leg — its gate output carried the previous leg's
finding first. Both legs were re-run isolated, with count-based expectations and a
restore between every leg; the table above and the per-form table earlier are from that
clean re-run.

The loops also carry their own lit-instrument assertions, so a zero can never come from an
empty loop: the code-point test asserts compared === 1_112_064, and the corpus test asserts
it saw more than 150 docs files and more than 50 disk files.

The backlog: counted from the tree, and it was one

The dispatch expected a shrink-only baseline. Counted rather than assumed: 143 hrefs carry
a non-empty fragment, 142 of them decidable here, and exactly one was dead

#q3-the-5127-judgement-surface in docs/audits/2026-08-zod-to-json-schema-fidelity.md,
whose heading reads ## Q3: the objectui#5127 judgement surface and therefore slugs to
q3-the-objectui5127-judgement-surface. Repaired in the same commit.

So this lands fully strict, with no baseline row. A baseline is a ratchet, not a prize,
and there was nothing to ratchet — the objectui#3572 shape, a check arriving with its
backlog already paid.

Gates run locally

Derived by hand from package.json and .github/workflows/ (this repo has no
scripts/pm/dispatch-gates.mjs; that script lives in objectstack and answers only about
its own tree). All at b780539d, each verdict quoted from the gate itself,
exit codes captured by redirect before any pipe:

gate verdict line it printed
node scripts/check-doc-links.mjs Links are valid across 17 scan roots. (exit 0)
pnpm exec vitest run scripts/__tests__/check-doc-links.test.ts Tests 121 passed (121)
node scripts/check-pre-install-import-graph.mjs OK - 23 pre-install step(s) in 18 job(s) run 21 scripts/ gate(s); 24 module(s) walked, every non-relative leaf a node builtin.
node scripts/check-control-bytes.mjs OK (scanned 6247 tracked text file(s); skipped 85 binary).
node scripts/check-lint-coverage.mjs lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).
node scripts/check-type-check-coverage.mjs type-check coverage: 45/46 via type-check ... 1 not compiled.
node scripts/check-phantom-dependencies.mjs Every in-scope import is declared by the package that publishes it.
node scripts/check-changeset-presence.mjs No source or published contract of a released package changed in this range, so no changeset is owed.
node scripts/check-changeset-fixed.mjs All workspace packages are in the changeset fixed group.
pnpm run type-check:scripts (tsc -p tsconfig.scripts.json) exit 0, no diagnostics
pnpm exec eslint --no-inline-config on the three changed script files exit 0

Repo-wide pnpm lint was not run — that is CI's, and this diff's only linted files are
the three named above, which eslint --no-inline-config passes.

Changeset: none owed, and the gate says so rather than me —
No source or published contract of a released package changed in this range, so no changeset is owed. Nothing here is published source: scripts/, docs/, a root
devDependency and the lockfile. Per AGENTS.md the skip-changeset label reads nothing and
exempts nothing in this repo, so it is not applied.

Clause-2 determination

No, independently reached and agreeing with the dispatching seat. scripts/ is not a
governed surface here, and this moves no schema key, no export, and nothing about what a
contract accepts or rejects. The one behavioural change is a repo-internal gate becoming
stricter. This can land through the merge queue.


🤖 Generated with Claude Code

https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3


Generated by Claude Code

…le (objectui#7644)

`scripts/check-doc-links.mjs` validated the DOCUMENT a link named and never the
ANCHOR inside it: `hrefPath()` and `routeExists()` both cut the href at its `#`,
and `EXTERNAL_HREF_RE` waved a pure `#anchor` through by shape. Renaming a
heading therefore orphaned every cross-reference to it, silently, with the gate
green — measured in objectui#7643, where `### KanbanSchema` became
`### DeclarativeKanbanSchema` and the `[...](#kanbanschema)` one screen away had
to be corrected by hand.

Anchors are now resolved wherever the target is markdown in this tree: same
page, another page, an absolute `/docs/...` route, an `objectui.org` URL, and
this repo's own `blob/main/...` URL — the last retiring an objectui#3536 waiver
that deferred anchors to "a different gate", which is this one. A `#L42` line
reference, a site route outside the collection and a non-markdown target return
no opinion rather than a pass dressed as one.

The slug rule is the renderers' own, vendored rather than imported: this gate
runs before `pnpm install`, so `scripts/github-slug.mjs` copies github-slugger's
generated table instead of importing it. The obvious shortcut is wrong — a
Unicode property escape disagrees with that table on the `No` digits and on
every letter added since it was generated — so the pin test compares the copy
against the real `github-slugger` over all 1,112,064 non-surrogate code points,
and compares this gate's anchor sets against fumadocs' own `getTableOfContents`
over all 273 files in the scan surface.

Counted from the tree rather than assumed: 143 hrefs carry a fragment, 142 are
decidable here, and exactly one was dead. It is repaired in this commit, so the
check lands fully strict with no baseline row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3180.1 KB 3191.4 KB
Main entry chunk (gzip) 143.2 KB 350 KB
Entry file index-CI1E9WNz.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 510.70KB 116.21KB
core (index.js) 6.70KB 2.68KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 180.00KB 50.20KB
fields (index.js) 242.27KB 61.22KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.98KB 10.98KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 48.05KB 13.34KB
plugin-charts (index.js) 70.92KB 19.75KB
plugin-chatbot (index.js) 196.19KB 46.43KB
plugin-dashboard (index.js) 132.86KB 34.68KB
plugin-designer (index.js) 212.86KB 43.19KB
plugin-detail (index.js) 250.55KB 64.06KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.87KB 32.66KB
plugin-gantt (index.js) 167.41KB 41.04KB
plugin-grid (index.js) 209.40KB 56.80KB
plugin-kanban (index.js) 52.71KB 14.55KB
plugin-list (index.js) 113.28KB 27.59KB
plugin-map (index.js) 20.57KB 6.82KB
plugin-markdown (index.js) 13.70KB 4.68KB
plugin-report (index.js) 43.57KB 11.96KB
plugin-timeline (index.js) 30.84KB 8.85KB
plugin-tree (index.js) 9.35KB 3.23KB
plugin-view (index.js) 85.24KB 20.94KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.58KB 2.23KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

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

Labels

configuration dependencies documentation Improvements or additions to documentation tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(gate): check-doc-links never validates in-page #anchor fragments — a renamed heading orphans every cross-reference to it with the gate green

2 participants