From 43e2df483293e827323a08e9c4488b7ef3bfbd96 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 04:29:23 +0000 Subject: [PATCH 1/2] docs(plugin-gantt): stop documenting a navigation key the spec refuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The record-navigation override example showed `{ mode: 'page', basePath: '/console/apps/.../campaign' }`. `basePath` is not a `NavigationConfig` member: `useNavigationOverlay` — where a gantt's `navigation` lands — builds no URL out of the config, and `ObjectGantt` calls it with no `onNavigate`, so a page-mode click falls through to the host's `onRowClick`. The route was never authorable through this key. `NavigationConfigSchema` is a strict object, so the key was worse than inert: it rejected the whole config with `unrecognized_keys`, and the `mode: 'page'` the sentence was teaching never took effect. Corrects the example to the shape the sentence actually demonstrates, says who owns the destination route, and points at the spec for the member list rather than restating it. Adds a pin that EXTRACTS the example from the README and parses it against the schema, with a control proving the parse still rejects an undeclared key. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L --- packages/plugin-gantt/README.md | 19 +- .../src/readme-navigation-example.test.ts | 210 ++++++++++++++++++ 2 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 packages/plugin-gantt/src/readme-navigation-example.test.ts diff --git a/packages/plugin-gantt/README.md b/packages/plugin-gantt/README.md index b97a3f7d2e..68ddba5dc7 100644 --- a/packages/plugin-gantt/README.md +++ b/packages/plugin-gantt/README.md @@ -44,9 +44,22 @@ When used through `ObjectGantt` (the wiring the framework uses for the fetched by `DetailView` itself when `dataSource.getObjectSchema` is available). - Override by setting `navigation` on the schema, e.g. - `{ mode: 'page', basePath: '/console/apps/.../campaign' }` to route - to the standalone detail page instead. + Override by setting `navigation` on the schema: set `{ "mode": "page" }` to + route to the standalone detail page instead. + + ```json + { "navigation": { "mode": "page" } } + ``` + + The destination route is **not** authorable here — `useNavigationOverlay` + builds no URL out of this config, so page mode hands the record to the + host's `onNavigate` / `onRowClick` and the host owns where it lands. To + choose *which* detail view opens, use the declared `view` member (a + form-view name, e.g. `"summary_view"`). `navigation` is the spec's + `NavigationConfig`, and its schema refuses any key it does not declare: an + undeclared key rejects the whole config, so the `mode` beside it never + takes effect either. `@objectstack/spec`'s `NavigationConfigSchema` owns the + member list. ### Drag-and-drop rescheduling diff --git a/packages/plugin-gantt/src/readme-navigation-example.test.ts b/packages/plugin-gantt/src/readme-navigation-example.test.ts new file mode 100644 index 0000000000..a9d48f80a4 --- /dev/null +++ b/packages/plugin-gantt/src/readme-navigation-example.test.ts @@ -0,0 +1,210 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `packages/plugin-gantt/README.md`'s record-navigation example is PARSED by + * the schema that validates it — `@objectstack/spec`'s `NavigationConfigSchema` + * (objectui#6050). + * + * ## Why this file exists + * + * The example documented `{ mode: 'page', basePath: '/console/apps/.../campaign' }`. + * `basePath` is not a `NavigationConfig` member, and no read site consumes it: + * `useNavigationOverlay` — where a gantt's `navigation` lands — builds no URL + * out of the config at all, and `ObjectGantt` calls it with no `onNavigate`, so + * a page-mode click falls through to the host's `onRowClick`. The destination + * route was never authorable through that key, by any spelling. + * + * That made the snippet worse than inert. `NavigationConfigSchema` is a strict + * object, so the undeclared key did not fall away quietly — it REJECTED the + * whole config (`unrecognized_keys`), and the `mode: 'page'` the sentence was + * actually teaching never took effect. An author copying the documented snippet + * got a rejected navigation config. + * + * It was found exactly this way: objectui#5903's pin test used this README's + * example verbatim as its "well-typed" fixture, and the fixture failed. + * + * ## No gate in this repo can catch it, which is the point + * + * `check-doc-snippet-types` compiles `ts`/`tsx` fences and + * `check-doc-component-types` reads `type` literals; both are structurally + * blind to a metadata key in a README. That gate's own header names the hole: + * schema-key validity is "a different question with a different answer … + * left unruled on purpose". A README example can be rejected by the spec while + * every gate stays green. This test is the measurement that closes it for this + * one example. + * + * ## The example is EXTRACTED, never retyped + * + * The fence is read out of the README on every run and parsed as JSON. A hand + * copy drifts from the file it claims to pin and therefore pins nothing — a + * snippet nobody re-measured is the defect this test exists to prevent, so + * reproducing it inside the test would be self-defeating. A moved heading, a + * removed fence or a non-JSON body fails LOUDLY here rather than vacuously + * passing on an empty fixture. + * + * ## The green carries its own control + * + * A `safeParse` that succeeds proves nothing on its own if the schema accepts + * everything, so the historical shape is parsed alongside and must be REJECTED + * by name. The pair is the measurement; neither half alone is one. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { NavigationConfigSchema } from '@objectstack/spec/ui'; + +/** Walk up to the workspace root, so the README is found by repo layout. */ +function repoRoot(): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 10; i += 1) { + if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return dir; + dir = resolve(dir, '..'); + } + throw new Error('repo root (pnpm-workspace.yaml) not found from this test file'); +} + +const README = join(repoRoot(), 'packages/plugin-gantt/README.md'); + +/** The bullet that documents the override, and the sentence that introduces it. */ +const SECTION_HEADING = '\n### Create / Edit / Delete / View\n'; +const ANCHOR = 'Override by setting `navigation` on the schema'; + +/** + * The declared member names, read out of the schema's OWN shape. Restating them + * here would fork the vocabulary — the drift `ObjectGanttSchema.navigation`'s + * doc comment (objectui#5903) exists to prevent — so they are derived instead. + */ +interface ShapeCarrier { + shape?: Record; + _def?: { + getter?: () => ShapeCarrier; + innerType?: ShapeCarrier; + shape?: Record | (() => Record); + }; +} + +function declaredMembers(): Set { + let node = NavigationConfigSchema as unknown as ShapeCarrier; + for (let hop = 0; hop < 6; hop += 1) { + if (node.shape) return new Set(Object.keys(node.shape)); + const def = node._def; + if (def?.getter) { node = def.getter(); continue; } + if (def?.innerType) { node = def.innerType; continue; } + if (typeof def?.shape === 'function') return new Set(Object.keys(def.shape())); + if (def?.shape) return new Set(Object.keys(def.shape)); + break; + } + throw new Error('NavigationConfigSchema no longer exposes an object shape — cannot derive its members'); +} + +/** The README section this example lives in, bounded to its own heading. */ +function navigationSection(): string { + const src = readFileSync(README, 'utf8'); + const start = src.indexOf(SECTION_HEADING); + if (start < 0) throw new Error(`"${SECTION_HEADING.trim()}" heading not found in ${README}`); + const next = src.indexOf('\n### ', start + 1); + const section = src.slice(start, next < 0 ? undefined : next); + + const anchors = section.split(ANCHOR).length - 1; + if (anchors !== 1) { + throw new Error(`expected exactly one "${ANCHOR}" in the section, found ${anchors}`); + } + return section; +} + +/** The `json` fence that follows the anchor sentence — the example itself. */ +function readmeExample(): Record { + const section = navigationSection(); + const after = section.slice(section.indexOf(ANCHOR)); + const fence = /^[ \t]*```json[ \t]*\n([\s\S]*?)\n[ \t]*```/m.exec(after); + if (!fence) throw new Error('no ```json fence follows the navigation-override sentence in the README'); + + let parsed: unknown; + try { + parsed = JSON.parse(fence[1]); + } catch (err) { + throw new Error(`the README's navigation example is not valid JSON: ${String(err)}\n${fence[1]}`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('the README\'s navigation example is not a JSON object'); + } + const doc = parsed as Record; + const nav = doc.navigation; + if (typeof nav !== 'object' || nav === null || Array.isArray(nav)) { + throw new Error('the README\'s navigation example no longer carries a `navigation` object'); + } + return nav as Record; +} + +const MEMBERS = declaredMembers(); +const EXAMPLE = readmeExample(); + +/** The historical shape, kept only as the control's input. */ +const REJECTED_KEY = 'basePath'; + +function unrecognizedKeys(issues: readonly { code: string }[]): string[] { + const out: string[] = []; + for (const issue of issues) { + if (issue.code === 'unrecognized_keys') { + out.push(...((issue as { keys?: string[] }).keys ?? [])); + } + } + return out; +} + +describe('plugin-gantt README: the record-navigation example', () => { + it('is a shape `NavigationConfigSchema` ACCEPTS', () => { + const result = NavigationConfigSchema.safeParse(EXAMPLE); + expect( + result.success ? [] : result.error.issues.map((i) => `${i.code} ${JSON.stringify(i.path)}: ${i.message}`), + 'The documented example must survive the schema that validates it. An author who copies ' + + 'this snippet is handing it to exactly this parse — a rejected config renders nothing ' + + 'the snippet promises, including the `mode` beside the offending key.', + ).toEqual([]); + }); + + it('CONTROL: the same parse still REJECTS an undeclared key by name', () => { + const result = NavigationConfigSchema.safeParse({ ...EXAMPLE, [REJECTED_KEY]: '/console/apps/.../campaign' }); + expect( + result.success, + 'This control is what makes the assertion above a measurement rather than a schema that ' + + 'accepts anything. If this ever passes, `NavigationConfigSchema` has stopped being ' + + 'strict and the green above no longer says what it claims.', + ).toBe(false); + expect(result.success ? [] : unrecognizedKeys(result.error.issues)).toContain(REJECTED_KEY); + }); + + it('names only members the schema declares', () => { + const undeclared = Object.keys(EXAMPLE).filter((k) => !MEMBERS.has(k)); + expect( + undeclared, + `The README example must not teach a key \`NavigationConfigSchema\` does not declare. ` + + `\`${REJECTED_KEY}\` was the original offender: a route prefix, in a config that owns no route.`, + ).toEqual([]); + }); + + it('still teaches the thing its sentence promises — page mode', () => { + expect( + EXAMPLE.mode, + 'The sentence promises "route to the standalone detail page instead". `mode` is what ' + + 'delivers that; an example that lost it would be valid and useless.', + ).toBe('page'); + }); + + it('does not reintroduce the route-prefix key anywhere in the section', () => { + expect( + navigationSection().includes(REJECTED_KEY), + `\`${REJECTED_KEY}\` is not authorable here in any spelling — prose or fence. ` + + '`useNavigationOverlay` builds no URL out of this config; the host owns the route.', + ).toBe(false); + }); +}); From 04facb0bd987c6fd1f54e970e4e6d0bf9fd97707 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 04:40:54 +0000 Subject: [PATCH 2/2] test(plugin-gantt): compile the README pin, and declare the change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tsconfig.test.json` names `node` in `types` so the README-reading pin compiles; its comment had recorded that no test in this package touches a Node global, and that is corrected rather than left standing. The extractor drops its wrapper try/catch — `JSON.parse`'s own SyntaxError is thrown from the extracting line, and a wrapper would need `Error.cause` (ES2022) to satisfy `preserve-caught-error` under this project's ES2020 lib. Adds the changeset `check-changeset-presence` asked for. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L --- .changeset/6050-gantt-navigation-basepath.md | 49 +++++++++++++++++++ .../src/readme-navigation-example.test.ts | 16 +++--- packages/plugin-gantt/tsconfig.test.json | 21 +++++--- 3 files changed, 72 insertions(+), 14 deletions(-) create mode 100644 .changeset/6050-gantt-navigation-basepath.md diff --git a/.changeset/6050-gantt-navigation-basepath.md b/.changeset/6050-gantt-navigation-basepath.md new file mode 100644 index 0000000000..e7b6c13c0a --- /dev/null +++ b/.changeset/6050-gantt-navigation-basepath.md @@ -0,0 +1,49 @@ +--- +'@object-ui/plugin-gantt': patch +--- + +The package README stops documenting a `navigation` key the spec refuses, and the corrected example is now parsed by the schema that validates it. + +`README.md`'s record-navigation override read +`{ mode: 'page', basePath: '/console/apps/.../campaign' }`. `basePath` is not a +member of the spec's `NavigationConfig`, and nothing consumes it: +`useNavigationOverlay` — where a gantt's `navigation` lands — builds no URL out +of the config, and `ObjectGantt` calls the hook with no `onNavigate`, so a +page-mode click falls through to the host's `onRowClick`. The destination route +is owned by the host and was never authorable through this key, under any +spelling. + +That made the snippet worse than inert. `NavigationConfigSchema` is a strict +object with no passthrough, so the undeclared key did not fall away quietly — it +rejected the **whole** config with `unrecognized_keys`, taking down the +`mode: 'page'` the sentence was actually teaching. An author who copied the +documented snippet got a rejected navigation config and no page navigation, which +is the copy-the-snippet-get-rejected shape objectui#5057 / #5012 named on other +keys. + +The example is corrected to `{ "navigation": { "mode": "page" } }` — the shape +the sentence demonstrates — and the prose now says who owns the destination route +and points at `@objectstack/spec`'s `NavigationConfigSchema` for the member list +instead of restating it, matching the derivation `ObjectGanttSchema.navigation`'s +doc comment (objectui#5903) adopted for the same concept. + +`view` is **not** substituted for `basePath`. It is a declared member, but it +names a form view (the spec: *"Name of the form view to use for details"*) and is +forwarded to `onNavigate` as the action argument — it is not a route, so putting +it where `basePath` stood would have replaced an invented key with a wrong one. +It is documented for what it does. + +No gate in this repo could have caught the original defect, and that is why the +fix ships with a measurement rather than a re-reading: `check-doc-snippet-types` +compiles `ts`/`tsx` fences and `check-doc-component-types` reads `type` literals, +and both are structurally blind to a metadata key in a README — the former's own +header records schema-key validity as "a different question … left unruled on +purpose". `src/readme-navigation-example.test.ts` closes that hole for this +example by EXTRACTING the fence from the README on every run and parsing it +against `NavigationConfigSchema`, with a control asserting the same parse still +rejects an undeclared key by name, so the green cannot come from a schema that +accepts everything. + +`tsconfig.test.json` names `node` in `types` for that test to compile, and its +comment — which had recorded that no test in this package touches a Node global +— is corrected rather than left standing. diff --git a/packages/plugin-gantt/src/readme-navigation-example.test.ts b/packages/plugin-gantt/src/readme-navigation-example.test.ts index a9d48f80a4..dc8811a778 100644 --- a/packages/plugin-gantt/src/readme-navigation-example.test.ts +++ b/packages/plugin-gantt/src/readme-navigation-example.test.ts @@ -45,8 +45,8 @@ * copy drifts from the file it claims to pin and therefore pins nothing — a * snippet nobody re-measured is the defect this test exists to prevent, so * reproducing it inside the test would be self-defeating. A moved heading, a - * removed fence or a non-JSON body fails LOUDLY here rather than vacuously - * passing on an empty fixture. + * removed fence or a non-JSON body throws out of the extractor rather than + * vacuously passing on an empty fixture. * * ## The green carries its own control * @@ -128,12 +128,12 @@ function readmeExample(): Record { const fence = /^[ \t]*```json[ \t]*\n([\s\S]*?)\n[ \t]*```/m.exec(after); if (!fence) throw new Error('no ```json fence follows the navigation-override sentence in the README'); - let parsed: unknown; - try { - parsed = JSON.parse(fence[1]); - } catch (err) { - throw new Error(`the README's navigation example is not valid JSON: ${String(err)}\n${fence[1]}`); - } + // Not wrapped in a try/catch: `JSON.parse`'s own `SyntaxError` names the + // offending token and is thrown from this line, which is louder than anything + // a re-throw could add. (A wrapper would also have to attach the caught error + // as a `cause` to satisfy `preserve-caught-error`, and `Error.cause` is ES2022 + // — above this project's ES2020 lib.) + const parsed: unknown = JSON.parse(fence[1]); if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { throw new Error('the README\'s navigation example is not a JSON object'); } diff --git a/packages/plugin-gantt/tsconfig.test.json b/packages/plugin-gantt/tsconfig.test.json index ed5f2a6083..00dce8ce94 100644 --- a/packages/plugin-gantt/tsconfig.test.json +++ b/packages/plugin-gantt/tsconfig.test.json @@ -9,12 +9,21 @@ // The package build emits `dist`; this project emits nothing, so it must // not inherit `composite` / `declaration` from the build config. "composite": false, - // No `types` override: nothing in this package's 39 test files reaches for a - // global augmentation. They assert on DOM nodes directly rather than through - // `@testing-library/jest-dom`'s matchers, and none of them touches a Node - // global — so the default automatic `@types/*` inclusion is exactly right - // here, and naming `types` would only switch it off. Same shape as - // `packages/providers/tsconfig.test.json`. + // These tests still assert on DOM nodes directly rather than through + // `@testing-library/jest-dom`'s matchers, so no matcher augmentation is + // named here. `node` is, and only because one test needs it: + // `readme-navigation-example.test.ts` reads this package's README off disk + // to hold the published `navigation` example to the schema that validates + // it (objectui#6050) — the same reason `packages/plugin-calendar` and + // `packages/layout` name it in their own test configs. Naming `types` at all + // switches off automatic `@types/*` inclusion, which is why this is a list + // rather than a single addition; `@types/react` / `@types/react-dom` keep + // resolving through the `react` imports the `.tsx` tests already make, as + // they do in `packages/plugin-calendar/tsconfig.test.json`. + // + // It stays OUT of `tsconfig.json`: package SOURCE ships to browsers and must + // not compile against Node APIs. + "types": ["node"], // // Drop the root tsconfig's source-tree `paths` so `@object-ui/*` and // `@objectstack/spec` resolve through the workspace dependency's built