From 74387bb0470e4023e59f6366027e2b7a77558aee Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 12 Jul 2026 03:40:18 +0300 Subject: [PATCH] test(#522): make the internal-link subset invariant a real cross-package drift guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer follow-up on the #522 internal-link import fix. The fix itself is confirmed correct; these three changes harden its load-bearing subset invariant and fix a stale doc. Finding 1 (drift guard): the in-package test only compared isInternalPagePath against a HAND-COPIED server regex, so a later narrowing of the real server INTERNAL_LINK_REGEX would leave the test green while the client silently became WIDER than the server. Add apps/server/.../export/internal-link-parity.spec.ts: the top layer already depends on @docmost/prosemirror-markdown and exports isInternalPagePath, so this spec imports the LIVE server INTERNAL_LINK_REGEX AND the LIVE isInternalPagePath and asserts subset over an accept-corpus — a server narrowing now reddens CI. The in-package manual copy is demoted from its "drift guard" role (comment updated to say so; it stays as local documentation). Finding 2 (charset non-vacuity): the REJECT list was purely structural, so the mutation widening the slug charset [a-zA-Z0-9-] -> [a-zA-Z0-9-.] survived the whole suite. Add REJECT cases whose ONLY defect is a forbidden slug character (abc.def / abc_def / abc%20 / abc~x); assert both isInternalPagePath and the server regex reject them. The mutation now reddens the new reject test. Finding 3 (stale JSDoc): canonicalize.ts KNOWN_DEFAULTS module blurb claimed every entry was read from docmost-schema and import-materialized. The new link.internal default breaks both claims (source is editor-ext/src/lib/link.ts; external links leave internal absent/null, never false). Add a bullet documenting the one editor-sourced, non-materialized default (false ≡ absent/null). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../export/internal-link-parity.spec.ts | 70 +++++++++++++++++++ .../src/lib/canonicalize.ts | 11 ++- .../test/internal-links.test.ts | 37 ++++++++-- 3 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/integrations/export/internal-link-parity.spec.ts diff --git a/apps/server/src/integrations/export/internal-link-parity.spec.ts b/apps/server/src/integrations/export/internal-link-parity.spec.ts new file mode 100644 index 00000000..e08de660 --- /dev/null +++ b/apps/server/src/integrations/export/internal-link-parity.spec.ts @@ -0,0 +1,70 @@ +import { INTERNAL_LINK_REGEX } from './utils'; +import { isInternalPagePath } from '@docmost/prosemirror-markdown'; + +/** + * Cross-package DRIFT GUARD for the internal-link subset invariant (#522). + * + * The client-side `isInternalPagePath` + * (`packages/prosemirror-markdown/src/lib/internal-links.ts`) promotes a markdown + * link to `internal: true` on import. Every link it marks internal MUST be one + * the server would backlink and export-rewrite — i.e. the client matcher MUST be + * a STRICT SUBSET of the server's canonical `INTERNAL_LINK_REGEX` + * (`./utils.ts`). If the client ever accepts a path the server rejects, that link + * is stored internal but silently dropped from the backlink graph and broken on + * export — the exact bug #522 fixed. + * + * This spec is the load-bearing guard: it imports the LIVE server regex AND the + * LIVE `isInternalPagePath` (no hand-copied regex on either side). A narrowing of + * EITHER — most dangerously the server regex — reddens here. The in-package + * accept/reject test documents the client's behaviour but cannot see the server + * regex; this top-layer spec is what makes the subset relation mechanical + * (AGENTS.md rule #7: a CI test that fails on drift of the source of truth). + */ +describe('internal-link subset parity (client isInternalPagePath ⊆ server INTERNAL_LINK_REGEX)', () => { + // Every path the CLIENT accepts. Kept deliberately broad across the risky + // dimensions — the slug charset (digits, hyphens, mixed case, leading/trailing + // hyphen), the space charset, and the optional trailing slash — so a narrowing + // of the server regex on any of them reddens the subset assertion below. + const CLIENT_ACCEPTS = [ + '/s/eng/p/abc123', + '/s/eng/p/abc123/', // trailing slash + '/s/My-Space/p/A-b-C-9', // hyphens + mixed case in both segments + '/s/x/p/z', // shortest + '/s/eng/p/my-page-abc123', // slug with hyphens (extractPageSlugId shape) + '/s/eng/p/-lead', // leading hyphen in slug + '/s/eng/p/trail-', // trailing hyphen in slug + '/s/eng/p/0123456789', // all-digit slug + '/s/a.b/p/abc', // dot in the SPACE segment (space charset is [^/]+) + '/s/space with space/p/abc', // space char in the SPACE segment + ]; + + it('every corpus path is actually client-accepted (guards the corpus itself)', () => { + // If a path here stopped being client-accepted the subset test would pass + // vacuously; assert acceptance up front so the corpus stays meaningful. The + // filter-to-empty form names the offending paths on failure. + const notAccepted = CLIENT_ACCEPTS.filter((h) => !isInternalPagePath(h)); + expect(notAccepted).toEqual([]); + }); + + it('every client-accepted path also matches the LIVE server regex (subset)', () => { + // The mechanical drift guard: narrow the server INTERNAL_LINK_REGEX and at + // least one hyphen/charset/structure case appears here. + const notInServer = CLIENT_ACCEPTS.filter((h) => !INTERNAL_LINK_REGEX.test(h)); + expect(notInServer).toEqual([]); + }); + + it('the client rejects forbidden-slug-char paths the server also rejects', () => { + // Documents the subset BOUNDARY: correct shape, forbidden slug char. The + // client and the LIVE server regex must agree on rejection. + const forbidden = [ + '/s/eng/p/abc.def', + '/s/eng/p/abc_def', + '/s/eng/p/abc%20', + '/s/eng/p/abc~x', + ]; + const clientAccepts = forbidden.filter((h) => isInternalPagePath(h)); + const serverAccepts = forbidden.filter((h) => INTERNAL_LINK_REGEX.test(h)); + expect(clientAccepts).toEqual([]); + expect(serverAccepts).toEqual([]); + }); +}); diff --git a/packages/prosemirror-markdown/src/lib/canonicalize.ts b/packages/prosemirror-markdown/src/lib/canonicalize.ts index b1f7593e..39896132 100644 --- a/packages/prosemirror-markdown/src/lib/canonicalize.ts +++ b/packages/prosemirror-markdown/src/lib/canonicalize.ts @@ -25,13 +25,22 @@ * drop any attr whose value equals its known schema default. A non-default * value (e.g. `orderedList.start: 5`) is NOT a default, so it is KEPT. * - * Every entry below was read from `packages/docmost-client/src/lib/ + * Every entry below — with the single documented exception of the `link.internal` + * marker (see its own bullet) — was read from `packages/docmost-client/src/lib/ * docmost-schema.ts` (the line refs are the exact `default:` declarations) and * confirmed to be materialized by an export→import→export round-trip: * - mark `link` target / rel — DocmostAttributes + StarterKit link. * StarterKit's link extension defaults `target: "_blank"` and * `rel: "noopener noreferrer nofollow"`; both materialize on import * (empirically confirmed) even when the source had only `href`. + * - mark `link` internal (#522) — the ONE editor-sourced, NOT-import- + * materialized default here. Its source is `editor-ext/src/lib/link.ts` + * (default `internal: false`), not docmost-schema, and import does NOT + * materialize it (an imported external link leaves `internal` absent/null, + * never `false`). It is listed so that the editor's stored `internal:false` + * normalizes to the same "external" canon as absent/null (`false ≡ absent`), + * keeping a stored external link canonically equal to its re-import. The + * load-bearing `internal:true` is NON-default and therefore KEPT. * - mark `comment` resolved — docmost-schema.ts L213-214 (`default: false`). * - node `orderedList` start — provided by StarterKit's orderedList * (`default: 1`); materializes on import (empirically confirmed). diff --git a/packages/prosemirror-markdown/test/internal-links.test.ts b/packages/prosemirror-markdown/test/internal-links.test.ts index c330faa2..19c1037d 100644 --- a/packages/prosemirror-markdown/test/internal-links.test.ts +++ b/packages/prosemirror-markdown/test/internal-links.test.ts @@ -9,10 +9,14 @@ import { docsCanonicallyEqual, } from '../src/lib/canonicalize.js'; -// The server's canonical INTERNAL_LINK_REGEX -// (apps/server/src/integrations/export/utils.ts). `isInternalPagePath` MUST be a -// strict subset: everything it accepts must also match this. We assert the -// subset relation directly so drift is caught mechanically. +// A LOCAL, illustrative copy of the server's INTERNAL_LINK_REGEX +// (apps/server/src/integrations/export/utils.ts) used only to document the +// subset boundary WITHIN this package (this layer cannot import the server). +// It is NOT the cross-package drift guard: a hand copy can silently go stale if +// the server regex is later narrowed. The real, mechanical drift guard lives in +// the top layer, `apps/server/src/integrations/export/internal-link-parity.spec.ts`, +// which imports BOTH the LIVE server `INTERNAL_LINK_REGEX` and the LIVE +// `isInternalPagePath` and reddens if the client ever ceases to be a subset. const SERVER_INTERNAL_LINK_REGEX = /^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/; @@ -55,6 +59,18 @@ describe('isInternalPagePath', () => { '/s/a/b/p/abc', // space contains slash -> extra segment '', ]; + // Structurally VALID `/s//p/` shapes whose ONLY defect is a + // forbidden character in the slug segment. These pin the slug charset + // `[a-zA-Z0-9-]` itself (not just the surrounding structure): drop them and a + // mutation widening the charset (e.g. adding `.`) survives the suite. Each is + // ALSO rejected by the server regex — documenting that the subset boundary + // holds precisely on the charset, not just on structure. + const REJECT_SLUG_CHARSET = [ + '/s/eng/p/abc.def', // dot + '/s/eng/p/abc_def', // underscore + '/s/eng/p/abc%20', // percent-encoded space + '/s/eng/p/abc~x', // tilde + ]; it('accepts the space-qualified root-relative page path (+trailing slash)', () => { for (const href of ACCEPT) @@ -66,6 +82,19 @@ describe('isInternalPagePath', () => { expect(isInternalPagePath(href), href).toBe(false); }); + it('rejects a correctly-shaped path with a forbidden slug character', () => { + // Pins the slug charset itself: a mutation widening `[a-zA-Z0-9-]` reddens. + for (const href of REJECT_SLUG_CHARSET) + expect(isInternalPagePath(href), href).toBe(false); + }); + + it('the forbidden-slug-char paths are rejected by the server regex too', () => { + // The subset boundary holds on the charset, not just the structure: none of + // these match the server regex, so the client must not accept them either. + for (const href of REJECT_SLUG_CHARSET) + expect(SERVER_INTERNAL_LINK_REGEX.test(href), href).toBe(false); + }); + it('rejects non-string input (fail-toward-external)', () => { for (const v of [null, undefined, 123, {}, [], true]) expect(isInternalPagePath(v as unknown)).toBe(false);