From feb0b82c7f551114950d668857120084b4f5f8fd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 05:23:25 +0000 Subject: [PATCH 01/11] chore(deps): take the 17.4.0 @objectstack/* line in the lockfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Float all seven `@objectstack/*` entries from 17.3.0 to 17.4.0 as one line: client, core, formula, lint, sdui-parser, spec, types. `package.json` is untouched — every declared range is a caret inside major 17 (`^17.0.0`, `^17.1.0`, `^17.2.0`, `^17.3.0`), all of which already admit 17.4.0. This is a lockfile refresh only. The re-resolve is pinned so the diff carries nothing but the seven identities. A plain `pnpm update -r "@objectstack/*"` also floats `zod ^4.4.3 -> 4.5.4`, which forks `ai@7.0.65`'s peer context and yields two physical copies each of spec, formula and lint — the objectui#8326 / #8333 defect class that `scripts/check-lockfile-integrity.mjs` exists to catch. That float is not caused by this bump: `@objectstack/spec` and `@objectstack/core` declare the identical `zod: ^4.4.3` at 17.3.0 and 17.4.0. It is `pnpm update` re-resolving an already-satisfied range. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019wtfW1ZxGnP1XKGc9uZVms --- pnpm-lock.yaml | 130 ++++++++++++++++++++++++------------------------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb2b342b8f..8555436611 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: version: link:packages/test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@playwright/test': specifier: ^1.62.1 version: 1.62.1 @@ -273,13 +273,13 @@ importers: version: link:../../packages/types '@objectstack/client': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@objectstack/lint': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -414,7 +414,7 @@ importers: version: link:../../packages/types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) fumadocs-core: specifier: 16.15.4 version: 16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.4.3) @@ -772,13 +772,13 @@ importers: version: link:../types '@objectstack/formula': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@objectstack/lint': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@sentry/react': specifier: ^10.70.0 version: 10.70.0(react@19.2.8) @@ -869,7 +869,7 @@ importers: dependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) better-auth: specifier: ^1.7.2 version: 1.7.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(mongodb@7.2.0(socks@2.8.9))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10) @@ -965,7 +965,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1001,7 +1001,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@radix-ui/react-accordion': specifier: ^1.2.20 version: 1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1182,10 +1182,10 @@ importers: version: link:../types '@objectstack/formula': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.2.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) devDependencies: '@object-ui/test-support': specifier: workspace:* @@ -1241,10 +1241,10 @@ importers: version: link:../types '@objectstack/client': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.2.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) devDependencies: '@object-ui/test-support': specifier: workspace:* @@ -1281,7 +1281,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -1385,7 +1385,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -1426,7 +1426,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1598,7 +1598,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1640,7 +1640,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@radix-ui/react-slot': specifier: ^1.3.3 version: 1.3.3(@types/react@19.2.18)(react@19.2.8) @@ -1750,7 +1750,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@types/react-grid-layout': specifier: ^2.1.0 version: 2.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1847,7 +1847,7 @@ importers: version: link:../i18n '@objectstack/spec': specifier: ^17.1.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -1972,7 +1972,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -2033,7 +2033,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -2094,7 +2094,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@tanstack/react-virtual': specifier: ^3.14.9 version: 3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2271,7 +2271,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -2445,7 +2445,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -2493,7 +2493,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2554,7 +2554,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -2621,7 +2621,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2686,7 +2686,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.1.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) react: specifier: 19.2.8 version: 19.2.8 @@ -2751,7 +2751,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -2790,13 +2790,13 @@ importers: version: link:../react '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) packages/test-support: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -2811,7 +2811,7 @@ importers: dependencies: '@objectstack/spec': specifier: ^17.3.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) zod: specifier: ^4.4.3 version: 4.4.3 @@ -4174,26 +4174,26 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@objectstack/client@17.3.0': - resolution: {integrity: sha512-EoxDSZPxdqiInaEQ2eZlRAL0LES73sT2xVUqbUW84pXcB2f+s2uztOZmdkE/IDS65oBrMe8NnJ++q5RrwgI+wg==} + '@objectstack/client@17.4.0': + resolution: {integrity: sha512-4Zsov0iRcZhaYAGiabX2XJPhDsE6XmRhlfYikxLECZ1cxbHhXjVpsNL3rl56wkTHuNV1jMzZZwzj2rJJUbVYcQ==} engines: {node: '>=22.0.0'} - '@objectstack/core@17.3.0': - resolution: {integrity: sha512-MgH+HYLEl7dCQJ9XW4Wi/mBX32+WMUSf5X65DplUvHvfOeLQHnDg/GoXjnNVFBI+82A0tgkBPpvsvn7haduY8A==} + '@objectstack/core@17.4.0': + resolution: {integrity: sha512-e/br10MaIZAJGgD7pb9AnW8zn9I18aJyiUYswMQGdjGheZYpp98ZV3WlAVpZFhnKkfd42VDMltB+X494oB8/1Q==} engines: {node: '>=22.0.0'} - '@objectstack/formula@17.3.0': - resolution: {integrity: sha512-zLR5DdFshJOvNMX9G4CPBPbWeVrWa/b1GxcMFhyNldtJCb92YlGsgd7vw8OWUkoxE+k9AnbfbxbDaxSxY2Ecgw==} + '@objectstack/formula@17.4.0': + resolution: {integrity: sha512-pWVkvXKXJxjnsx1i7mYIteltc6kjn/qS6swJ6bw5XG5B7bf8GjjBsuvcJR86nuTg+z8B9+snruaD9i9v8uw0LQ==} - '@objectstack/lint@17.3.0': - resolution: {integrity: sha512-pRFjIZ+sQmlPT2N24aM9szwHK50P3AjiwmHEFi6DUHJGtDq/Tt4HtZ+89nF7WP0MfT1AK8nlNadxsDrHPF8UCg==} + '@objectstack/lint@17.4.0': + resolution: {integrity: sha512-9Lst+TDusPVh3Fb1gxDPSsXpAFrkEIlusqLzBsCKQ0ebLaHxG2GrfMXQofDTox9vr7jOVSAKCW5jkHUmg5XpoA==} engines: {node: '>=22.0.0'} - '@objectstack/sdui-parser@17.3.0': - resolution: {integrity: sha512-LbzxlLBoDBd0yxoIYMhYuXJ+j3iEsqvvBtrB0JWWda8Iy6mhPqbpyjj11Y5bZ2A0yGf20z0b/vXLDi1ISNIwpA==} + '@objectstack/sdui-parser@17.4.0': + resolution: {integrity: sha512-pKKTqP6siwsLtcO7tDxMufHoCWuUeWjoRBv8NRGlptB+a/uhO0WKXHzZ3+zWVqz9a/Ac9N2Xwp+hBPoGtG4Z1w==} - '@objectstack/spec@17.3.0': - resolution: {integrity: sha512-mceFp9swHHISMycZk8TjdqjBhi0GboQoapRSNeFyaI8GBIpw5cRc37GrWrIFVTZLaVCc7TF3XHSi1qqjaBNmzA==} + '@objectstack/spec@17.4.0': + resolution: {integrity: sha512-bOtSFih0Ed67JZMGuLmlHuIv2zPN62W8NL2qENQCYjZB174NMlIIvHC6Jlz2SIy5EwsTnTGBF5EPF6C2/TmqoA==} engines: {node: '>=22.0.0'} peerDependencies: ai: ^7.0.0 @@ -4201,8 +4201,8 @@ packages: ai: optional: true - '@objectstack/types@17.3.0': - resolution: {integrity: sha512-G5c2+V0gzR5co5XcZ0Zs4uTOp42421Yj6fWEWVV6kEpKfIzaDYUzv1HfmwdOfLklrbtNiF3loBg6jB00BVge/g==} + '@objectstack/types@17.4.0': + resolution: {integrity: sha512-z7eCTBlGqAX8ChKifkItGg+woONgAMBbWtu8OmTqI6Fzn8z7JDCm07+K0gg0hivD1uoUUL253s8mshdiqpZnnQ==} engines: {node: '>=22.0.0'} '@open-draft/deferred-promise@2.2.0': @@ -12569,33 +12569,33 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@objectstack/client@17.3.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/client@17.4.0(ai@7.0.65(zod@4.4.3))': dependencies: - '@objectstack/core': 17.3.0(ai@7.0.65(zod@4.4.3)) - '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/core': 17.4.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/spec': 17.4.0(ai@7.0.65(zod@4.4.3)) transitivePeerDependencies: - ai - '@objectstack/core@17.3.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/core@17.4.0(ai@7.0.65(zod@4.4.3))': dependencies: - '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) - '@objectstack/types': 17.3.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/spec': 17.4.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/types': 17.4.0(ai@7.0.65(zod@4.4.3)) zod: 4.4.3 transitivePeerDependencies: - ai - '@objectstack/formula@17.3.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/formula@17.4.0(ai@7.0.65(zod@4.4.3))': dependencies: '@marcbachmann/cel-js': 8.0.0 - '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/spec': 17.4.0(ai@7.0.65(zod@4.4.3)) transitivePeerDependencies: - ai - '@objectstack/lint@17.3.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/lint@17.4.0(ai@7.0.65(zod@4.4.3))': dependencies: - '@objectstack/formula': 17.3.0(ai@7.0.65(zod@4.4.3)) - '@objectstack/sdui-parser': 17.3.0 - '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/formula': 17.4.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/sdui-parser': 17.4.0 + '@objectstack/spec': 17.4.0(ai@7.0.65(zod@4.4.3)) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) sucrase: 3.35.1 @@ -12603,18 +12603,18 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/sdui-parser@17.3.0': {} + '@objectstack/sdui-parser@17.4.0': {} - '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/spec@17.4.0(ai@7.0.65(zod@4.4.3))': dependencies: pg-connection-string: 2.14.0 zod: 4.4.3 optionalDependencies: ai: 7.0.65(zod@4.4.3) - '@objectstack/types@17.3.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/types@17.4.0(ai@7.0.65(zod@4.4.3))': dependencies: - '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/spec': 17.4.0(ai@7.0.65(zod@4.4.3)) transitivePeerDependencies: - ai From 4a5c1627e78f5d6e82dff403b897379e56c3d213 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 07:01:49 +0000 Subject: [PATCH 02/11] chore(deps): move the two pins that must track the lockfile to 17.4.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are mechanically coupled to the family move in the previous commit, and each carries its own prescription for exactly this event. `e2e/live/ci/backend.env` — `OBJECTSTACK_VERSION` 17.3.0 -> 17.4.0. The file's own header states the rule ("bump this in the same PR that bumps the console's @objectstack pins"), `content/docs/guide/ci-cd-pipeline.md` repeats it, and `scripts/__tests__/ci-cd-pipeline-doc.test.ts` enforces equality with the version `pnpm-lock.yaml` resolves. A mismatched pair makes the live-e2e lane carry no information either way (objectui#7689). `scripts/__tests__/check-lockfile-integrity.test.ts` — its two samples are transformations OF the real lockfile, anchored on the current pin, and the assertion that broke says so itself: "the spec snapshot key moved — rebuild this sample before trusting it". Only the anchor moves; the simulated downgrade target (17.2.0) and every assertion are unchanged, so both samples still reproduce objectui#8326 and objectui#8333 as before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019wtfW1ZxGnP1XKGc9uZVms --- e2e/live/ci/backend.env | 2 +- scripts/__tests__/check-lockfile-integrity.test.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/e2e/live/ci/backend.env b/e2e/live/ci/backend.env index 9646375756..0ebb0bced9 100644 --- a/e2e/live/ci/backend.env +++ b/e2e/live/ci/backend.env @@ -14,7 +14,7 @@ # # This file declares exactly ONE @objectstack pin, and that is the whole # design: the commit is derived from it, see below. -OBJECTSTACK_VERSION=17.3.0 +OBJECTSTACK_VERSION=17.4.0 # There is deliberately no OBJECTSTACK_REF key here. The objectstack-ai/objectstack # commit the showcase app metadata is sparse-checked-out from is DERIVED at diff --git a/scripts/__tests__/check-lockfile-integrity.test.ts b/scripts/__tests__/check-lockfile-integrity.test.ts index 7e9236aff7..e82b2b73f5 100644 --- a/scripts/__tests__/check-lockfile-integrity.test.ts +++ b/scripts/__tests__/check-lockfile-integrity.test.ts @@ -89,7 +89,7 @@ describe('green on main — the negative control', () => { * * ⛔ It cannot be produced by running the resolver, and that impossibility is * the card's central claim: every workspace range on `@objectstack/*` is a - * floating `^17.x` and the registry's latest is 17.3.0, so no fresh resolve can + * floating `^17.x` and the registry's latest is 17.4.0, so no fresh resolve can * pick 17.2.0. The sample is therefore a transformation of the real lockfile * reproducing the identity facts measured on PRs #7053 / #7058 — the family * down to 17.2.0, and `@objectstack/spec` resolved at BOTH versions. @@ -97,9 +97,9 @@ describe('green on main — the negative control', () => { function sampleA(): string { let out = realLock; for (const pkg of ['client', 'core', 'formula', 'lint', 'sdui-parser']) { - out = out.replace(new RegExp(`(@objectstack/${pkg}@)17\\.3\\.0`, 'g'), '$117.2.0'); + out = out.replace(new RegExp(`(@objectstack/${pkg}@)17\.4\.0`, 'g'), '$117.2.0'); } - const specKey = " '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))':"; + const specKey = " '@objectstack/spec@17.4.0(ai@7.0.65(zod@4.4.3))':"; expect(out, 'the spec snapshot key moved — rebuild this sample before trusting it').toContain( specKey, ); @@ -123,8 +123,8 @@ function sampleA(): string { function sampleB(): string { let out = realLock; const forked = [ - " '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))':", - " '@objectstack/formula@17.3.0(ai@7.0.65(zod@4.4.3))':", + " '@objectstack/spec@17.4.0(ai@7.0.65(zod@4.4.3))':", + " '@objectstack/formula@17.4.0(ai@7.0.65(zod@4.4.3))':", " ai@7.0.65(zod@4.4.3):", ]; for (const key of forked) { @@ -189,7 +189,7 @@ describe('cause 2 — a declared dependency forks with no version moving (object expect(spec?.headCount).toBe(2); // This is #8326's exact bundling mechanism — two real paths, nothing // dedupes them — arriving with no version change anywhere. - expect(spec?.head.every((k) => k.includes('@17.3.0'))).toBe(true); + expect(spec?.head.every((k) => k.includes('@17.4.0'))).toBe(true); }); }); From 571080a3b223f5cd116e320cfdf4d7fab031f684 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 07:03:56 +0000 Subject: [PATCH 03/11] fix(scripts): keep the RegExp dot escaped when rebuilding the lockfile sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit rewrote the sample anchor from 17.3.0 to 17.4.0 and lost one backslash level in the process. The pattern is built through `new RegExp(...)` from a template literal, so `17\.4\.0` reaches the constructor as `17.4.0` and both dots become "any character" — matching, but by accident. `no-useless-escape` caught it. Restored to `17\\.4\\.0`, which is byte-identical to the base line apart from the version digit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019wtfW1ZxGnP1XKGc9uZVms --- scripts/__tests__/check-lockfile-integrity.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/__tests__/check-lockfile-integrity.test.ts b/scripts/__tests__/check-lockfile-integrity.test.ts index e82b2b73f5..6a20c4fb4d 100644 --- a/scripts/__tests__/check-lockfile-integrity.test.ts +++ b/scripts/__tests__/check-lockfile-integrity.test.ts @@ -97,7 +97,7 @@ describe('green on main — the negative control', () => { function sampleA(): string { let out = realLock; for (const pkg of ['client', 'core', 'formula', 'lint', 'sdui-parser']) { - out = out.replace(new RegExp(`(@objectstack/${pkg}@)17\.4\.0`, 'g'), '$117.2.0'); + out = out.replace(new RegExp(`(@objectstack/${pkg}@)17\\.4\\.0`, 'g'), '$117.2.0'); } const specKey = " '@objectstack/spec@17.4.0(ai@7.0.65(zod@4.4.3))':"; expect(out, 'the spec snapshot key moved — rebuild this sample before trusting it').toContain( From f23764c9c9098bba79d64d511543b93f98a32f4d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 12:02:16 +0000 Subject: [PATCH 04/11] refactor(gantt): derive the ten extension keys from GanttConfigSchema.shape (objectui#7845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectstack#15469 closed `GanttConfigSchema` and declared the ten keys plugin-gantt used to read through its `.passthrough()` window. objectui carried its own copy of those ten in two places; with the spec declaring them, the copies became duplicates and `ObjectGantt.blockPrecedence.test.tsx`'s no-duplicates pin measured it (40 keys, 30 unique). Retired both copies: - `GANTT_CONFIG_EXTENSION_KEYS` (`plugin-gantt/src/ObjectGantt.tsx`) — the flat key list is now `GanttConfigSchema.shape` plus the `dependencyField` alias. - `GanttConfigExtensionFields` (`types/src/zod/objectql.zod.ts`) — the flat face takes all ten from `SpecGanttConfigSchema.shape` by reference like its other members, and the nested `gantt` block is the spec schema itself rather than the spec schema extended with the local map. Compared member for member before deleting, as the card required: the spec's declarations cover the same ten keys, the same `interactions` (move/resize/progress/link) and `timeSegments` (dayStart/bands/showMidnight) sub-shapes and the same band members, with strictly fuller describes. The local copy carried no describe, alias or member the spec's lacks, so there is no difference to file. Also corrected the prose that still called the spec loose: `types/objectql.ts` said `timeSegments` was "the one key the spec genuinely does not model" and that `GanttConfigSchema` is `$loose` upstream — both false, and the first read as an invitation to write undeclared sub-keys that the `strictObject` now refuses by name. `zod/objectql.zod.ts` justified its own local `.passthrough()` by pointing at the spec's gantt/tree windows, which no longer exist; the local reason (and the measurement that `swimlaneField` / `endField` are still spec-undeclared) replaces it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019wtfW1ZxGnP1XKGc9uZVms --- .../src/ObjectGantt.blockPrecedence.test.tsx | 11 +- packages/plugin-gantt/src/ObjectGantt.tsx | 59 ++++------ packages/types/src/objectql.ts | 26 +++-- packages/types/src/zod/objectql.zod.ts | 109 +++++++----------- 4 files changed, 85 insertions(+), 120 deletions(-) diff --git a/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx b/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx index c7dc5bb3a4..62490f91f6 100644 --- a/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx @@ -258,7 +258,10 @@ describe('the named key set cannot drift from `GanttConfig` (objectui#6469)', () // added to the spec arrives here without a second edit. expect(FLAT_GANTT_CONFIG_KEYS).toContain('startDateField'); expect(FLAT_GANTT_CONFIG_KEYS).toContain('quickFilters'); - // objectui's own members, lifted into `GanttConfig` by objectui#6472. + // The ten that used to be named by a second literal here: objectui read them + // through the schema's `.passthrough()` window, objectstack#15469 closed the + // window and DECLARED them, and objectui#7845 retired the literal. They must + // keep arriving — now by the same derivation as the line above, not by list. expect(FLAT_GANTT_CONFIG_KEYS).toContain('lockField'); expect(FLAT_GANTT_CONFIG_KEYS).toContain('timeSegments'); // The legacy singular alias the flat branch still reads. @@ -272,8 +275,10 @@ describe('the named key set cannot drift from `GanttConfig` (objectui#6469)', () * Compile-time coverage pin (`tsc -p tsconfig.test.json` type-checks this file). * `never` exactly while every `GanttConfig` key — the one declaration both faces * derive from — appears in `FLAT_GANTT_CONFIG_KEYS`. A `GanttConfig` key that - * neither `GanttConfigSchema.shape` nor `GANTT_CONFIG_EXTENSION_KEYS` models - * makes this line fail to compile, NAMING the missing key. + * `GanttConfigSchema.shape` does not model makes this line fail to compile, + * NAMING the missing key. Before objectui#7845 a second literal + * (`GANTT_CONFIG_EXTENSION_KEYS`) could also satisfy it; the spec now declares + * those ten, so the shape is the only source that can. */ type AssertNever = T; export type UncoveredGanttConfigKey = AssertNever< diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index e01d65ff9d..123180c93b 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -102,10 +102,10 @@ export interface QuickFilterDef { /** * The gantt config as THIS renderer consumes it: `GanttConfig` from - * `@object-ui/types` — the spec's `GanttConfigSchema` plus objectui's own - * extensions — with `quickFilters` and `timeSegments` narrowed to the plugin's - * runtime types, and the spec-declared members re-documented with the behaviour - * this renderer gives them. + * `@object-ui/types` — the spec's `GanttConfigSchema`, which since + * objectstack#15469 declares the whole vocabulary — with `quickFilters` and + * `timeSegments` narrowed to the plugin's runtime types, and the spec-declared + * members re-documented with the behaviour this renderer gives them. * * ⚠️ Nothing here may declare a key `GanttConfig` does not (objectui#6051). Nine * members that lived ONLY here — `lockField`, `objectField`, `summaryExtent`, @@ -122,9 +122,10 @@ type GanttConfigEx = GanttConfig & GanttConfigRestated; * measured against the shipped `GanttConfig` on `main` (objectui#6471; the card * counted eleven before objectui#6051/#6472 landed part of the lift). * - * All twelve RESTATE a key `GanttConfig` already declares — eleven arrive from - * the spec's `GanttConfigSchema` (19 keys), `timeSegments` is objectui's own — - * and every one is mutually assignable with its twin. That includes + * All twelve RESTATE a key `GanttConfig` already declares, and since + * objectstack#15469 closed the schema all twelve — `timeSegments` included — + * arrive from the spec's `GanttConfigSchema` (29 keys); every one is mutually + * assignable with its twin. That includes * `quickFilters` and `timeSegments`, which objectui#6471 called load-bearing * NARROWINGS: measured on `main` they narrow nothing. What those two still do is * NAME this plugin's runtime types (`QuickFilterDef[]` / @@ -367,31 +368,6 @@ type KnownKeys = keyof { /** The DECLARED members of `GanttConfig` — its index signature removed. */ export type KnownGanttConfigKey = KnownKeys; -/** - * objectui's own `GanttConfig` members — the ten the spec's `GanttConfigSchema` - * does not model. They lived in this file's private `GanttConfigEx` until - * objectui#6472 lifted them into `@object-ui/types`, which is what makes - * `GanttConfig` the single declaration BOTH faces derive from. - * - * Listed here rather than derived because the runtime object that models them - * (`GanttConfigExtensionFields` in `@object-ui/types/zod`) is module-private - * there. `satisfies` keeps every entry a real `GanttConfig` key, and the - * coverage pin in `ObjectGantt.blockPrecedence.test.tsx` fails to compile if - * `GanttConfig` grows a key that neither source models. - */ -const GANTT_CONFIG_EXTENSION_KEYS = [ - 'borderColorField', - 'lockField', - 'objectField', - 'summaryExtent', - 'defaultCollapsedDepth', - 'dependencyTypes', - 'timeZone', - 'exportFileName', - 'interactions', - 'timeSegments', -] as const satisfies readonly KnownGanttConfigKey[]; - /** * The FLAT spelling of `GanttConfig`'s keys — what `getGanttConfig`'s flat * branch reads, and what `ObjectView` / `ListView` EMIT. @@ -403,16 +379,21 @@ const GANTT_CONFIG_EXTENSION_KEYS = [ * authoring surface — and it is why the precedence flip below strands neither * producer. * - * The spec-modelled half is DERIVED from `GanttConfigSchema` — the same zod - * object the block branch validates against — so a key added to the spec reaches - * the shadow diagnostic without a second edit (the discipline - * `FLAT_MAP_CONFIG_KEYS` set in objectui#5177). `dependencyField` is the legacy - * singular alias the flat branch still reads beside `dependenciesField`; it is - * not a `GanttConfig` key, so it is named on its own. + * DERIVED from `GanttConfigSchema` in full — the same zod object the block + * branch validates against — so a key added to the spec reaches the shadow + * diagnostic without a second edit (the discipline `FLAT_MAP_CONFIG_KEYS` set in + * objectui#5177). Until objectstack#15469 that derivation covered only part of + * the vocabulary and a second literal, `GANTT_CONFIG_EXTENSION_KEYS`, named the + * ten keys the renderer read through the schema's then-open `.passthrough()` + * window. The spec now declares all ten, so the list is one source again and the + * literal is retired (objectui#7845) — keeping it would have made every one of + * the ten a DUPLICATE entry here, which is what the no-duplicates pin in + * `ObjectGantt.blockPrecedence.test.tsx` measured. `dependencyField` is the + * legacy singular alias the flat branch still reads beside `dependenciesField`; + * it is not a `GanttConfig` key, so it is named on its own. */ export const FLAT_GANTT_CONFIG_KEYS = [ ...(Object.keys(GanttConfigSchema.shape) as (keyof typeof GanttConfigSchema.shape)[]), - ...GANTT_CONFIG_EXTENSION_KEYS, 'dependencyField' as const, ]; diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index f9895d8453..a6b87602c9 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -163,13 +163,18 @@ export type { CalendarConfig } from '@objectstack/spec/ui'; * GanttConfigSchema". It is, as of rc.6, so the key now arrives from the * spec and the note is gone with it. * - * `timeSegments` is the one key the spec genuinely does not model, and it stays - * here declared as objectui's own. That is legal metadata rather than a second - * dialect: `GanttConfigSchema` is `$loose` upstream (see the note at - * `zod/objectql.zod.ts` — "the renderers grow config knobs"), so a key the spec - * does not declare passes its parse instead of being rejected. The intersection - * inherits that looseness, which is the spec's own decision for this vocabulary - * and not a widening taken here. + * ⚠️ THE LOOSENESS THIS PARAGRAPH USED TO REST ON IS GONE. `GanttConfigSchema` + * was `$loose` upstream, so a key the spec did not declare passed its parse + * instead of being rejected, and the members intersected below were legal + * metadata riding that window. objectstack#15469 CLOSED it: the schema is a + * `strictObject`, an undeclared key is refused by name, and the ten keys objectui + * read through the window — `timeSegments` among them — are DECLARED upstream + * with describes. So the members below no longer extend the spec's vocabulary; + * they restate part of it, and `SpecGanttConfig` already carries every one. + * + * ⛔ Do not read this block as an extension point. Writing an undeclared sub-key + * into `timeSegments` (or any member here) is refused at parse now, where it + * used to pass — the prose said the opposite until objectui#7845 corrected it. */ export type GanttConfig = SpecGanttConfig & { /** @@ -215,9 +220,10 @@ export type GanttConfig = SpecGanttConfig & { // referenced by neither declaration — so the vocabulary is lifted here rather // than restated, and the two faces derive from ONE source that cannot fork. // - // Like `timeSegments` above, each is legal metadata rather than a second - // dialect: `GanttConfigSchema` is `$loose` upstream, so a key the spec does not - // model passes its parse instead of being rejected. + // Like `timeSegments` above, each of the nine is now DECLARED by the spec — + // objectstack#15469 closed `GanttConfigSchema` and modelled them, so they are + // no longer objectui-only metadata riding a `$loose` parse. They stay written + // out here for their prose; the vocabulary itself is the spec's. /** * Record field marking a node as view-only (truthy → locked). A locked * row's bar can't be dragged/resized, its progress can't be dragged, no diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 0f36035562..3cd76919d2 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -535,17 +535,26 @@ const LIST_VIEW_LOCAL_OVERRIDES = [ // trade-off the spec-field import on `ListViewSchema` makes. // // `gantt` needs no local schema at all: the spec config already covers every field -// the renderer reads and is `.passthrough()` for renderer-ahead knobs, so it flows -// in with the rest of the imported spec fields. +// the renderer reads, so it flows in with the rest of the imported spec fields. +// It used to cover them by being `.passthrough()` for renderer-ahead knobs; +// objectstack#15469 closed that window and DECLARED the ten it was carrying, so +// today the coverage is by declaration (objectui#7845). // // The deprecated aliases below are the pre-#2231 objectui vocabulary. They stay // accepted so stored view metadata keeps validating, but the spec key is canonical // and wins at every read-site. // -// `.passthrough()` is kept from the pre-#2231 shapes for the same reason the spec -// puts it on `GanttConfigSchema`/`TreeConfigSchema`: the renderers grow config knobs -// ahead of the protocol (calendar's `allDayField`, for one), and stripping them here -// would silently disable a shipped capability. +// `.passthrough()` is kept from the pre-#2231 shapes because the renderers grow +// config knobs ahead of the protocol (calendar's `allDayField`, for one), and +// stripping them here would silently disable a shipped capability. ⚠️ It is NOT +// kept "for the same reason the spec puts it on +// `GanttConfigSchema`/`TreeConfigSchema`", which is what this note used to say: +// objectstack#15469 closed both of those upstream, so the spec-side precedent is +// gone and only the local reason survives (objectui#7845). Measured for the two +// shapes below: `swimlaneField` (kanban) and `endField` (timeline) are still +// absent from the spec's `KanbanConfigSchema` / `TimelineConfigSchema`, so these +// `.passthrough()`s still carry real authored values — +// `core/src/utils/__tests__/normalize-list-view.test.ts` pins exactly those two. const KanbanConfig = stripImportedDefaults(SpecKanbanConfigSchema).partial().extend({ /** @deprecated legacy alias for the spec's `groupByField` */ groupField: z.string().optional().describe('Deprecated alias for groupByField'), @@ -876,62 +885,6 @@ export const ObjectTreeSchema = BaseSchema.extend({ defaultExpandedDepth: z.number().optional().describe('Default expansion depth (0 = roots only)'), }); -/** - * objectui's own `GanttConfig` extensions — everything `../objectql.ts` declares - * on {@link GanttConfig} beyond the spec's `SpecGanttConfigSchema` (objectui#6051 - * lifted nine of them out of `plugin-gantt`'s package-private `GanttConfigEx`; - * `timeSegments` was already there). - * - * Held as ONE field map rather than inlined, so the flattened top-level spelling - * below is built from a single source — the same way the TS side derives its - * flattened members from `GanttConfig`. It is also the shape the nested `gantt` - * block is built from (objectui#6475): one line extending `SpecGanttConfigSchema` - * with this map — so both authoring faces share one vocabulary and cannot fork - * from each other. - * - * Not exported: the parity census in `__tests__/zod-mirror-parity.test.ts` reads - * `^export const` out of this directory and would require a registered TS - * counterpart for it. It has none of its own — it is a fragment of `GanttConfig`, - * and `GanttConfig` is checked through the two faces that carry it. - */ -const GanttConfigExtensionFields = { - borderColorField: z.string().optional().describe('Record field carrying a per-task alert stroke colour'), - lockField: z.string().optional().describe('Record field marking a row view-only (truthy → locked)'), - objectField: z.string().optional().describe("Record field carrying the row's own object API name"), - summaryExtent: z.enum(['children', 'self']).optional().describe("How a summary bar's span is computed"), - defaultCollapsedDepth: z.number().optional().describe('Auto-collapse tree nodes at/below this 0-indexed depth'), - dependencyTypes: z.boolean().optional().describe('Whether the store persists dependency link TYPES (fs/ss/ff/sf)'), - timeZone: z.string().optional().describe("Business time zone (IANA name) the chart's calendar renders in"), - exportFileName: z.string().optional().describe('Base name for exported PNG/PDF files'), - interactions: z - .object({ - move: z.boolean().optional().describe('Bar / subtree dragging'), - resize: z.boolean().optional().describe('Edge resize grips'), - progress: z.boolean().optional().describe('The progress drag handle'), - link: z.boolean().optional().describe('Dependency UI: drag-to-link dots and the create/delete menu'), - }) - .optional() - .describe('Per-interaction switches, each defaulting to true'), - timeSegments: z - .object({ - dayStart: z.string().optional().describe("Clock time the shift-day begins, 'HH:mm'"), - bands: z - .array( - z.object({ - key: z.string().optional().describe('Stable band id'), - label: z.string().describe('Display label'), - start: z.string().describe("Band start, 'HH:mm'"), - end: z.string().describe("Band end, 'HH:mm'"), - color: z.string().optional().describe('Accent colour for the column tint'), - }) - ) - .describe('Ordered bands covering the 24h shift-day'), - showMidnight: z.boolean().optional().describe('Draw the dashed calendar-midnight cue'), - }) - .optional() - .describe('Shift segmentation for the day-mode timeline'), -}; - /** * ObjectGantt Schema * @@ -1023,12 +976,32 @@ export const ObjectGanttSchema = BaseSchema.extend({ capacity: stripImportedDefaults(SpecGanttConfigSchema).shape.capacity, quickFilters: stripImportedDefaults(SpecGanttConfigSchema).shape.quickFilters, autoZoomToFilter: stripImportedDefaults(SpecGanttConfigSchema).shape.autoZoomToFilter, - // …and objectui's own ten, from the one field map above. - ...GanttConfigExtensionFields, + // …and the ten that used to be a SECOND declaration here. objectui read them + // through `GanttConfigSchema`'s then-open `.passthrough()` window and had to + // model them locally (`GanttConfigExtensionFields`, objectui#6051/#6475); + // objectstack#15469 closed the window and declared all ten upstream, so they + // arrive by reference like every member above and the local field map is + // retired (objectui#7845). Compared member for member before deleting: the + // spec's declarations cover the same keys, the same `interactions` + // (move/resize/progress/link) and `timeSegments` (dayStart/bands/showMidnight) + // sub-shapes and the same band members, with strictly fuller describes — the + // local copy carried no describe, alias or member the spec's lacks. + borderColorField: stripImportedDefaults(SpecGanttConfigSchema).shape.borderColorField, + lockField: stripImportedDefaults(SpecGanttConfigSchema).shape.lockField, + objectField: stripImportedDefaults(SpecGanttConfigSchema).shape.objectField, + summaryExtent: stripImportedDefaults(SpecGanttConfigSchema).shape.summaryExtent, + defaultCollapsedDepth: stripImportedDefaults(SpecGanttConfigSchema).shape.defaultCollapsedDepth, + dependencyTypes: stripImportedDefaults(SpecGanttConfigSchema).shape.dependencyTypes, + timeZone: stripImportedDefaults(SpecGanttConfigSchema).shape.timeZone, + exportFileName: stripImportedDefaults(SpecGanttConfigSchema).shape.exportFileName, + interactions: stripImportedDefaults(SpecGanttConfigSchema).shape.interactions, + timeSegments: stripImportedDefaults(SpecGanttConfigSchema).shape.timeSegments, // `gantt` — the BLOCK face `getGanttConfig`'s FIRST branch reads and prefers - // (objectui#6469 ruled block-over-flat) — objectui#6475. Built from the same - // field map as the flat face above, `SpecGanttConfigSchema` extended with - // `GanttConfigExtensionFields`, so the two authoring faces cannot fork. + // (objectui#6469 ruled block-over-flat) — objectui#6475. It is now + // `SpecGanttConfigSchema` itself, the same vocabulary the flat face above + // derives from key by key, so the two authoring faces cannot fork. The + // `.extend(GanttConfigExtensionFields)` this used to carry is gone with the + // field map (objectui#7845): the spec declares those ten itself. // // This is the one entry among the 28 that NARROWS rather than merely names: a // `gantt` block previously rode through `.passthrough()` entirely unvalidated; @@ -1042,7 +1015,7 @@ export const ObjectGanttSchema = BaseSchema.extend({ // configuration` on failure. Maintainer ruling, objectui#6475 (2026-08-27), // Option A: enforce as-is, no warning window (excluded by the startup-stage // no-gradualism rule, objectstack#12668 — no named external-user evidence). - gantt: stripImportedDefaults(SpecGanttConfigSchema).extend(GanttConfigExtensionFields).optional().describe( + gantt: stripImportedDefaults(SpecGanttConfigSchema).optional().describe( 'Nested gantt config block — the authoring face, and the winner over the flattened top-level keys whenever present' ), // The query/data keys the fetch path reads. They were declared on From f4debbbc5520e6bffeeaef3a0cd7e2b95b957d52 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 12:25:06 +0000 Subject: [PATCH 05/11] feat(dashboard)!: refreshInterval -> refreshIntervalSeconds, following the spec rename (objectui#7783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @objectstack/spec 17.4.0 renamed the dashboard's auto-refresh key (objectstack#15680, ruling B on objectstack#14478: a duration-shaped number carries its unit in the key name, not only in the describe prose) and left a `retiredKey` tombstone behind the old spelling. objectui's zod `DashboardComponentSchema` takes its spec-owned half from `SpecDashboardFields` by reference, so that face moved on its own the moment the lockfile did. What did NOT move was the TypeScript twin, and the pair's drift ledger caught it: `zod-mirror-parity.test.ts` refused to compile with `complex.zod.ts#DashboardComponentSchema` gaining `refreshInterval`. Moved here: the TS declaration; the four reader sites, keeping the `* 1000` arithmetic identical (the value is still seconds — the multiply is exactly the tell the rename exists to remove, and it now sits under a key that says so); the published registration input; the metadata-admin zh overlay key; the config panel's field key and the `DashboardConfig` model behind it; and the fixtures and pins that spell the key. ⛔ Deliberately NOT renamed, measured rather than swept: - `plugin-report` / `types` `reports.ts` / `reports.zod.ts` — the report component's own key, excluded by the card. - `DataBinding.refreshInterval` (`types/src/data.ts`) and `AIInsightsSchema.refreshInterval` (`types/src/ai.ts`). The card lists both among "the TS types" for this rename; they are not. Neither has a zod mirror or a `@objectstack/spec` counterpart (spec/ui exports nothing matching either name), so there is no producer behind a rename — and `DataBinding`'s is documented in MILLISECONDS, which makes `refreshIntervalSeconds` the wrong name for it outright. `content/docs/api/schema-reference.md` documented this key as milliseconds with a `30000` sample. It was never milliseconds; corrected with the rename. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019wtfW1ZxGnP1XKGc9uZVms --- .changeset/dashboard-declare-honoured-inputs.md | 2 +- content/docs/api/schema-reference.md | 4 ++-- content/docs/plugins/plugin-dashboard.mdx | 2 +- .../src/views/metadata-admin/SchemaForm.tsx | 2 +- .../metadata-admin/metadata-form-i18n.test.ts | 2 +- .../src/views/metadata-admin/metadata-form-i18n.ts | 2 +- packages/plugin-dashboard/README.md | 2 +- .../plugin-dashboard/src/DashboardConfigPanel.tsx | 2 +- .../plugin-dashboard/src/DashboardGridLayout.tsx | 10 ++++++---- .../plugin-dashboard/src/DashboardRenderer.tsx | 13 +++++++++---- .../src/__tests__/ConfigPanel.i18nWiring.test.tsx | 2 +- .../src/__tests__/dashboardAuthoredInputs.test.tsx | 13 +++++++------ packages/plugin-dashboard/src/index.tsx | 2 +- .../types/src/__tests__/dashboard-config.test.ts | 4 ++-- .../page-app-dashboard-spec-parity.test.ts | 7 +++++-- packages/types/src/complex.ts | 14 ++++++++++++-- packages/types/src/designer.ts | 4 ++-- packages/types/src/zod/complex.zod.ts | 9 ++++++--- 18 files changed, 60 insertions(+), 36 deletions(-) diff --git a/.changeset/dashboard-declare-honoured-inputs.md b/.changeset/dashboard-declare-honoured-inputs.md index c0129d95e5..ed25c5a84b 100644 --- a/.changeset/dashboard-declare-honoured-inputs.md +++ b/.changeset/dashboard-declare-honoured-inputs.md @@ -2,4 +2,4 @@ '@object-ui/plugin-dashboard': minor --- -`dashboard` now publishes the authoring inputs its renderer already honours — `widgets`, `label`, `description`, `header`, `globalFilters`, `dateRange`, `refreshInterval` — so `validateTree`, the generated `sdui.manifest.json` and `sdui-intrinsics.d.ts` stop warning authors off keys that work (previously only `columns`/`gap`/`className` were published, and every other honoured key drew `unknown-prop`). Each declared key is accepted by the spec's strict `DashboardSchema`, so the manifest never offers a key the save gate refuses. The legacy `title` spelling and the retired `aria` key stay deliberately unpublished and are pinned as such; the `schema.title || schema.label` fallback read is unchanged, so documents in the wild keep rendering their header title. +`dashboard` now publishes the authoring inputs its renderer already honours — `widgets`, `label`, `description`, `header`, `globalFilters`, `dateRange`, `refreshIntervalSeconds` — so `validateTree`, the generated `sdui.manifest.json` and `sdui-intrinsics.d.ts` stop warning authors off keys that work (previously only `columns`/`gap`/`className` were published, and every other honoured key drew `unknown-prop`). Each declared key is accepted by the spec's strict `DashboardSchema`, so the manifest never offers a key the save gate refuses. The legacy `title` spelling and the retired `aria` key stay deliberately unpublished and are pinned as such; the `schema.title || schema.label` fallback read is unchanged, so documents in the wild keep rendering their header title. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 9cd20de03d..bdb5a4cda7 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -997,7 +997,7 @@ A widget-based dashboard with configurable grid layout and auto-refresh. "type": "dashboard", "columns": 4, "gap": 6, - "refreshInterval": 30000, + "refreshIntervalSeconds": 30, "widgets": [ { "id": "revenue", @@ -1050,7 +1050,7 @@ A widget-based dashboard with configurable grid layout and auto-refresh. | `columns` | `number` | Number of grid columns. | | `gap` | `number` | Gap between widgets (Tailwind spacing scale). | | `widgets` | `DashboardWidgetSchema[]` | **Required.** Widget definitions with `id`, `title`, `colSpan`, `rowSpan`, and `body`. | -| `refreshInterval` | `number` | Auto-refresh interval in milliseconds. | +| `refreshIntervalSeconds` | `number` | Auto-refresh interval in **seconds** — the renderer multiplies by 1000. Spelled `refreshInterval` before @objectstack/spec 17.4.0, and documented here as milliseconds, which it never was (objectui#7783). | Each widget supports `colSpan` and `rowSpan` to control its size in the grid. The `body` can be any `SchemaNode`. diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index 323ccf3654..799d0c5117 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -51,7 +51,7 @@ npm install @object-ui/plugin-dashboard defaultRange?: string, // a spec date preset, or 'custom' allowCustomRange?: boolean }, - refreshInterval?: number, // Auto-refresh period in seconds; runs only when the host wires onRefresh + refreshIntervalSeconds?: number, // Auto-refresh period in seconds; runs only when the host wires onRefresh columns?: number, // Grid columns (default: 3) gap?: number, // Gap between widgets className?: string diff --git a/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx b/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx index 1b1a5054e8..a0c50561f4 100644 --- a/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx +++ b/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx @@ -1781,7 +1781,7 @@ function FieldControl({ // "pin it to today's default" in the saved metadata. // // ⚠️ Scope of the second half at the current spec pin: `columns` / `gap` / - // `refreshInterval` carry NO `default` in `DashboardSchema` — "(default 12)" + // `refreshIntervalSeconds` carry NO `default` in `DashboardSchema` — "(default 12)" // exists only as prose in the description, and the 12 is applied by the // renderer. Those three boxes therefore stay empty until the spec declares // the defaults it documents; filed upstream rather than hard-coded here, diff --git a/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.test.ts b/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.test.ts index 47d9b31ad8..e9be3372d4 100644 --- a/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.test.ts +++ b/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.test.ts @@ -86,7 +86,7 @@ describe('localizeMetadataForm — the spec form, in the author’s language', ( 'zh-CN', ); expect(fieldByName(zh, 'columns')).toMatchObject({ label: '列数', helpText: '栅格列数(默认 12)' }); - expect(fieldByName(zh, 'refreshInterval')?.label).toBe('自动刷新'); + expect(fieldByName(zh, 'refreshIntervalSeconds')?.label).toBe('自动刷新'); expect(fieldByName(zh, 'header')?.label).toBe('页眉'); }); diff --git a/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts b/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts index bb8e5d1fe0..14e05a27ab 100644 --- a/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts @@ -88,7 +88,7 @@ const METADATA_FORM_BUNDLE: TranslationBundle = { // only a developer can act on. Reported upstream rather than // "translated" literally; this says what the author can decide. gap: { label: '间距', helpText: '组件之间的间距,数值越大越松' }, - refreshInterval: { label: '自动刷新', helpText: '自动刷新间隔(秒),0 表示不自动刷新' }, + refreshIntervalSeconds: { label: '自动刷新', helpText: '自动刷新间隔(秒),0 表示不自动刷新' }, header: { label: '页眉', helpText: '页眉设置:标题、描述与操作按钮' }, // All three children of the `header` composite — see the ⛔ note above. 'header.showTitle': { label: '显示标题', helpText: '在页眉中显示仪表板名称' }, diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index ee6c54a67a..38f61ec7be 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -424,7 +424,7 @@ The authored shape is typed by `@object-ui/types`: | Import from `@object-ui/types` | What it types | | --- | --- | -| `DashboardComponentSchema` | the whole `type: 'dashboard'` node — `columns`, `gap`, `widgets`, `header`, `globalFilters`, `dateRange`, `refreshInterval`, … | +| `DashboardComponentSchema` | the whole `type: 'dashboard'` node — `columns`, `gap`, `widgets`, `header`, `globalFilters`, `dateRange`, `refreshIntervalSeconds`, … | | `DashboardWidgetSchema` | one entry of `widgets[]` — the spec's `DashboardWidget` keys, plus objectui's own (`component`, `layout`, `options`, …) | | `DashboardWidgetSlotComponentSchema` | the other kind of `widgets[]` entry — a component node placed directly in the slot, `type` one of the closed component set (`metric-card`); every other key is that component's own prop | | `DashboardWidgetLayout` | a widget's `{ x, y, w, h }` grid box | diff --git a/packages/plugin-dashboard/src/DashboardConfigPanel.tsx b/packages/plugin-dashboard/src/DashboardConfigPanel.tsx index 8cb0946ccc..1b629d0ffa 100644 --- a/packages/plugin-dashboard/src/DashboardConfigPanel.tsx +++ b/packages/plugin-dashboard/src/DashboardConfigPanel.tsx @@ -105,7 +105,7 @@ export function buildDashboardSchema(t: ConfigPanelTranslate): ConfigPanelSchema collapsible: true, fields: [ { - key: 'refreshInterval', + key: 'refreshIntervalSeconds', label: t('dashboard.config.field.autoRefresh'), type: 'select', defaultValue: '0', diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index c69fd67dcc..7276f44e5e 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -142,14 +142,16 @@ export const DashboardGridLayout: React.FC = ({ setTimeout(() => setRefreshing(false), 600); }, [onRefresh]); - // Auto-refresh interval + // Auto-refresh interval — seconds → milliseconds, as the key now says + // (objectui#7783; the spec renamed `refreshInterval` to + // `refreshIntervalSeconds`, value unchanged). React.useEffect(() => { - if (!schema.refreshInterval || schema.refreshInterval <= 0 || !onRefresh) return; - intervalRef.current = setInterval(handleRefresh, schema.refreshInterval * 1000); + if (!schema.refreshIntervalSeconds || schema.refreshIntervalSeconds <= 0 || !onRefresh) return; + intervalRef.current = setInterval(handleRefresh, schema.refreshIntervalSeconds * 1000); return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; - }, [schema.refreshInterval, onRefresh, handleRefresh]); + }, [schema.refreshIntervalSeconds, onRefresh, handleRefresh]); const [layouts, setLayouts] = React.useState<{ lg: RGLLayout[] }>( () => buildDefaultLayouts(schema), ); diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index d42a37a445..14fa791317 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -445,14 +445,19 @@ const DashboardRendererInner = forwardRef setRefreshing(false), 600); }, [onRefresh]); - // Auto-refresh interval + // Auto-refresh interval. The `* 1000` is seconds → milliseconds, and the + // key now says so itself: @objectstack/spec 17.4.0 renamed + // `refreshInterval` to `refreshIntervalSeconds` precisely because a reader + // multiplying by 1000 was the tell that the unit lived out of band + // (objectstack#15680, objectui#7783). The arithmetic is unchanged — the + // value is still seconds. useEffect(() => { - if (!schema.refreshInterval || schema.refreshInterval <= 0 || !onRefresh) return; - intervalRef.current = setInterval(handleRefresh, schema.refreshInterval * 1000); + if (!schema.refreshIntervalSeconds || schema.refreshIntervalSeconds <= 0 || !onRefresh) return; + intervalRef.current = setInterval(handleRefresh, schema.refreshIntervalSeconds * 1000); return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; - }, [schema.refreshInterval, onRefresh, handleRefresh]); + }, [schema.refreshIntervalSeconds, onRefresh, handleRefresh]); const handleWidgetClick = useCallback((e: React.MouseEvent, widgetId: string | undefined) => { if (!designMode || !onWidgetClick || !widgetId) return; diff --git a/packages/plugin-dashboard/src/__tests__/ConfigPanel.i18nWiring.test.tsx b/packages/plugin-dashboard/src/__tests__/ConfigPanel.i18nWiring.test.tsx index 561f150293..d1141c0749 100644 --- a/packages/plugin-dashboard/src/__tests__/ConfigPanel.i18nWiring.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/ConfigPanel.i18nWiring.test.tsx @@ -239,7 +239,7 @@ describe('leg 2 — each key sits where it belongs', () => { collapsible: true, fields: [ expect.objectContaining({ - key: 'refreshInterval', + key: 'refreshIntervalSeconds', label: 'dashboard.config.field.autoRefresh', options: [ { value: '0', label: 'dashboard.config.refresh.off' }, diff --git a/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx b/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx index bd77df2dcb..97a0c8058e 100644 --- a/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx @@ -13,7 +13,8 @@ * The `dashboard` registration published exactly three inputs (`columns`, * `gap`, `className`) while `DashboardRenderer` honoured `widgets`, `label` / * legacy `title`, `description`, `header`, `globalFilters`, `dateRange` and - * `refreshInterval`. `inputs` is not documentation: it is the published + * `refreshIntervalSeconds` (spelled `refreshInterval` until @objectstack/spec + * 17.4.0 renamed it — objectui#7783). `inputs` is not documentation: it is the published * authoring surface (`gen-manifest.ts` serializes it into * `sdui.manifest.json` — the save gate and parser whitelist — and into * `sdui-intrinsics.d.ts`, and `dashboard` is in `PUBLIC_BLOCKS`). So @@ -156,7 +157,7 @@ const DECLARED: Array<[string, unknown]> = [ ['header', { showTitle: false, actions: [{ label: 'Open', actionUrl: '/x' }] }], ['globalFilters', [{ field: 'region', label: 'Region', type: 'select' }]], ['dateRange', { field: 'created_at', defaultRange: 'last_30_days' }], - ['refreshInterval', 30], + ['refreshIntervalSeconds', 30], ]; /** Values matching NO declared arm — each must still be reported. */ @@ -167,7 +168,7 @@ const OFF_ARM: Array<[string, unknown]> = [ ['header', true], ['globalFilters', 'region'], ['dateRange', []], - ['refreshInterval', '30'], + ['refreshIntervalSeconds', '30'], ]; /** The two keys ruled OUT, with the evidence a reader can re-check. */ @@ -218,7 +219,7 @@ describe('the honoured keys now validate clean on an inline dashboard node (obje header: { showTitle: true }, columns: 4, gap: 6, - refreshInterval: 30, + refreshIntervalSeconds: 30, dateRange: { field: 'created_at', defaultRange: 'last_30_days' }, globalFilters: [{ field: 'region', label: 'Region', type: 'select' }], }); @@ -330,7 +331,7 @@ describe('the published artifacts carry the change — same generators as gen-ma 'header', 'globalFilters', 'dateRange', - 'refreshInterval', + 'refreshIntervalSeconds', 'columns', 'gap', 'className', @@ -350,7 +351,7 @@ describe('the published artifacts carry the change — same generators as gen-ma expect(block).toContain('header?: Record;'); expect(block).toContain('globalFilters?: unknown[];'); expect(block).toContain('dateRange?: Record;'); - expect(block).toContain('refreshInterval?: number;'); + expect(block).toContain('refreshIntervalSeconds?: number;'); // The exclusions stay out of the type surface an author compiles against. expect(block).not.toMatch(/\btitle\b/); expect(block).not.toMatch(/\baria\b/); diff --git a/packages/plugin-dashboard/src/index.tsx b/packages/plugin-dashboard/src/index.tsx index 94342cafd8..1479e50aab 100644 --- a/packages/plugin-dashboard/src/index.tsx +++ b/packages/plugin-dashboard/src/index.tsx @@ -91,7 +91,7 @@ ComponentRegistry.register( { name: 'header', type: 'object', description: 'Header block: { showTitle?, showDescription?, actions? }. Strict — the contract rejects any other key. Renders nothing (zero pixels) when everything it would show is suppressed.' }, { name: 'globalFilters', type: 'array', description: 'Dashboard-level filter bar — the spec’s GlobalFilter[]. Filter values live as dashboard variables (readable in widget expressions as page.) and are AND-merged into each bound widget’s query per its filterBindings.' }, { name: 'dateRange', type: 'object', description: 'Built-in date-range filter: { field?, defaultRange?, allowCustomRange? }. `defaultRange` takes the spec’s date presets plus "custom"; the bound field defaults to created_at.' }, - { name: 'refreshInterval', type: 'number', description: 'Auto-refresh period in seconds. Zero or a negative value disables the timer, and it only runs when the host wires an onRefresh handler.' }, + { name: 'refreshIntervalSeconds', type: 'number', description: 'Auto-refresh period in seconds. Zero or a negative value disables the timer, and it only runs when the host wires an onRefresh handler.' }, { name: 'columns', type: 'number' }, { name: 'gap', type: 'number' }, { name: 'className', type: 'string' } diff --git a/packages/types/src/__tests__/dashboard-config.test.ts b/packages/types/src/__tests__/dashboard-config.test.ts index 48ab5f4abd..06fb07b74a 100644 --- a/packages/types/src/__tests__/dashboard-config.test.ts +++ b/packages/types/src/__tests__/dashboard-config.test.ts @@ -34,7 +34,7 @@ describe('DashboardConfig TypeScript Types', () => { description: 'Overview of sales pipeline', columns: 12, gap: 16, - refreshInterval: 30, + refreshIntervalSeconds: 30, widgets: [ { id: 'w1', @@ -119,7 +119,7 @@ describe('DashboardConfig Zod Validation', () => { description: 'Overview of sales pipeline', columns: 12, gap: 16, - refreshInterval: 30, + refreshIntervalSeconds: 30, widgets: [ { id: 'w1', diff --git a/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts b/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts index 370c41ffe7..eab3e487f7 100644 --- a/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts +++ b/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts @@ -165,9 +165,12 @@ describe('spec-only keys are now VALIDATED, not passed through (objectstack#4115 expect(OuiAppSchema.safeParse({ type: 'app', branding: 'blue' }).success).toBe(false); }); - it('Dashboard declares header/refreshInterval/performance', () => { + it('Dashboard declares header/refreshIntervalSeconds/performance', () => { const keys = Object.keys(shapeOf(OuiDashboardSchema)); - for (const key of ['header', 'refreshInterval', 'performance', 'protection']) { + // `refreshIntervalSeconds` is the post-rename spelling (objectui#7783); the + // spec still carries `refreshInterval` as a `retiredKey` tombstone, so + // asserting the OLD name here would pass on the tombstone and prove nothing. + for (const key of ['header', 'refreshIntervalSeconds', 'performance', 'protection']) { expect(keys, `'${key}' is still undeclared`).toContain(key); } }); diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 0b86a76a1b..6c54032eac 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -1990,8 +1990,18 @@ export interface DashboardComponentSchema extends BaseSchema { * every `BaseSchema` slot. */ widgets: Array; - /** Auto-refresh interval in seconds. When set, the dashboard will periodically trigger onRefresh. */ - refreshInterval?: number; + /** + * Auto-refresh interval in seconds. When set, the dashboard will periodically + * trigger onRefresh. + * + * Renamed from `refreshInterval` in @objectstack/spec 17.4.0 (objectstack#15680, + * ruling B on objectstack#14478: a duration-shaped number carries its unit in + * the key name, never only in the describe prose). The spec's `DashboardSchema` + * — which this node derives its spec-owned half from — now REFUSES the old + * spelling with a `retiredKey` tombstone naming this one, so the two faces + * would disagree at parse if this declaration had stayed (objectui#7783). + */ + refreshIntervalSeconds?: number; /** * Dashboard header configuration. * Aligned with @objectstack/spec DashboardHeaderSchema. diff --git a/packages/types/src/designer.ts b/packages/types/src/designer.ts index 776f6aa86f..3c890e3a7c 100644 --- a/packages/types/src/designer.ts +++ b/packages/types/src/designer.ts @@ -597,8 +597,8 @@ export interface DashboardConfig { columns?: number; /** Grid gap in pixels */ gap?: number; - /** Auto-refresh interval in seconds */ - refreshInterval?: number; + /** Auto-refresh interval in seconds (renamed from `refreshInterval`, objectui#7783) */ + refreshIntervalSeconds?: number; /** Dashboard widgets */ widgets?: DashboardWidgetConfig[]; diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 489fe69f45..78eeab0e8f 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -1010,9 +1010,12 @@ export const GlobalFilterSchema = z.object({ * * `BaseSchema` is `.passthrough()` while the spec's `DashboardSchema` is * strict, so before this derivation every spec-only key rode through objectui - * unvalidated — `header`, `refreshInterval`, `performance`, `aria`, + * unvalidated — `header`, `refreshIntervalSeconds`, `performance`, `aria`, * `protection` and the `_lock*`/`_package*`/`_provenance` package-lock - * envelope were neither checked nor declared. + * envelope were neither checked nor declared. (That key was spelled + * `refreshInterval` until @objectstack/spec 17.4.0 renamed it — objectui#7783; + * the spec now carries a `retiredKey` tombstone under the old spelling, and it + * flows in here by reference like every other member of this set.) * * Omitted, each for a stated reason: * - `name`/`label`/`description` — component-envelope keys owned by BaseSchema; @@ -1099,7 +1102,7 @@ export const DashboardConfigSchema = z.object({ description: z.string().optional().describe('Dashboard description'), columns: z.number().min(1).max(24).optional().describe('Grid columns (1-24)'), gap: z.number().min(0).optional().describe('Grid gap in pixels'), - refreshInterval: z.number().min(0).optional().describe('Auto-refresh interval in seconds'), + refreshIntervalSeconds: z.number().min(0).optional().describe('Auto-refresh interval in seconds'), widgets: z.array(DashboardWidgetConfigSchema).optional().describe('Dashboard widgets'), globalFilters: z.array(z.any()).optional().describe('Global filter conditions'), dateRange: z.object({ From 8e4dcfcaca67bdcbb66832863f775acd072578ca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 12:30:59 +0000 Subject: [PATCH 06/11] fix(components): element:record_picker.filter declares the ViewFilterRule array arm (objectui#7663) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectstack#14406 converged `ComponentPropsMap['element:record_picker'].filter` from the MongoDB-style `FilterConditionSchema` record onto `z.array(ViewFilterRuleSchema)` — the last record-form `filter` in the map, closing the maintainer's 2026-08-25 ruling that the platform carries one filter orthography (objectui#6206-B). @objectstack/spec 17.4.0 ships it, so the declaration here now teaches a shape the save gate refuses and a JSX page writing the legal array form draws a false `type-mismatch`. Measured at the installed spec before flipping: the record form is refused by KIND at `filter` (`invalid_type`, expected array), a bare tuple is refused at `filter.0` (expected object), and `[{ field, operator, value }]` is accepted — with `operator: 'eq'` normalised to `'equals'` on parse, which is why the fixture spells the canonical form. - `inputs.filter` flips `type: 'object'` to `'array'` and its description is rewritten around `[{ field, operator, value }, …]`. The PRECEDENCE paragraph is kept verbatim in substance: `dataSource.filter ?? filter` did not change. - The two parity pins swap sides — the record form is now the refused shape, the rule array the accepted one — and the refusals are asserted as an envelope (issue code plus path), so a parse that failed for an unrelated reason cannot stand in for the contract refusing the record form. - The coarse-type agreement test moves with them, and now states WHERE the agreement ends: `'array'` has no member arm, so a tuple list passes the manifest check and is refused only by the spec. That gap is named rather than papered over — the description carries what the coarse type cannot say. No renderer change: `filter?: unknown` passes through to `$filter` and `find()` already lowers the array form. The console parity test's `element:record_picker` arm goes green by derivation once the declaration moves; only its MEMBER_PINS prose is edited, to describe the arm that is actually pinned. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019wtfW1ZxGnP1XKGc9uZVms --- .../registry-inputs-spec-parity.test.ts | 2 +- .../record-picker-inputs-spec-parity.test.ts | 105 +++++++++++++----- .../src/renderers/basic/record-picker.tsx | 37 +++--- 3 files changed, 99 insertions(+), 45 deletions(-) diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index a605e395a6..279cce7775 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2092,7 +2092,7 @@ const MEMBER_PINS: Record = { }, 'element:record_picker.filter': { file: 'packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts', - pins: 'The `object` arm is `FilterConditionSchema` — field keys plus `$and`/`$or`/`$not` — with the rule-ARRAY spelling every sibling `filter` uses rejected outright, and the description pinned to the renderer\'s own precedence read (`composed?.filter ?? props.filter`), objectui#3830.', + pins: 'The `array` arm is `ViewFilterRuleSchema[]` — the one filter orthography this map\'s array-declared `filter` doors share — with the MongoDB-style RECORD spelling (`{ status }`, `{ $and }`) refused by kind at `filter` and a bare tuple refused at `filter.0`, and the description pinned to the renderer\'s own precedence read (`composed?.filter ?? props.filter`). objectui#3830 declared the key; objectstack#14406 converged it off the record form and objectui#7663 moved this side onto the array arm.', }, 'element:text.content': { file: 'apps/console/src/__tests__/component-input-union-specimens.test.ts', diff --git a/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts b/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts index 913bc41210..60f64db1fb 100644 --- a/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts +++ b/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts @@ -69,6 +69,19 @@ const filterDescription = () => input('filter')?.description ?? ''; */ const withFilter = (filter: unknown) => ({ object: 'account', filter }); +/** + * The accepted `filter` value, in ONE place because five assertions read it. + * + * A `ViewFilterRule` list — what objectstack#14406 converged this key onto + * (objectui#7663), and the orthography every array-declared `filter` door in + * `ComponentPropsMap` shares. ⚠️ `operator` is spelled `equals` rather than the + * `eq` alias on purpose: the spec NORMALISES `eq` to `equals` on parse, so a + * fixture written with the alias would make the round-trip assertion below + * (`parsed.data?.filter` equals what went in) fail for a reason that has + * nothing to do with the key being reachable. + */ +const RULE_ARRAY = [{ field: 'status', operator: 'equals', value: 'open' }]; + /** * Does the installed spec REFUSE an undeclared top-level key, or drop it in * silence? (objectui#4910, measured on both pins.) @@ -114,9 +127,9 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => // parse only reports that no undeclared key was present. Survival is the // claim on both pins. expect(specTopLevelKeys()).toContain('filter'); - const parsed = ElementRecordPickerPropsSchema.safeParse(withFilter({ status: 'open' })); + const parsed = ElementRecordPickerPropsSchema.safeParse(withFilter(RULE_ARRAY)); expect(parsed.success).toBe(true); - expect(parsed.data?.filter).toEqual({ status: 'open' }); + expect(parsed.data?.filter).toEqual(RULE_ARRAY); // The contrast that makes the criterion meaningful: the SAME payload plus a // key the spec does not declare. Two contract spellings, one verdict — the @@ -126,7 +139,7 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => // to `notASpecKey` instead of to the declared key having gone bad, and the // green parse asserted just above is what proves the base is valid. const undeclared = ElementRecordPickerPropsSchema.safeParse({ - ...withFilter({ status: 'open' }), + ...withFilter(RULE_ARRAY), notASpecKey: 1, } as never); @@ -150,49 +163,85 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => // retired. expect(undeclared.success).toBe(true); expect(Object.keys(undeclared.data ?? {})).not.toContain('notASpecKey'); - expect(undeclared.data?.filter).toEqual({ status: 'open' }); + expect(undeclared.data?.filter).toEqual(RULE_ARRAY); } expect(inputNames()).toContain('filter'); expect(filterDescription()).not.toBe(''); }); - it('declares `object` as the type the spec actually accepts, not `array`', () => { - // objectui#3830's landing sketch guessed `'array'` and flagged the guess as - // needing checking against the resolved pin. It is wrong, and this is why: - // `ElementRecordPickerProps.filter` is `FilterConditionSchema`, which is - // `z.record(z.string(), z.unknown()).and(z.object({ $and, $or, $not }))` — - // an OBJECT. A rule array (an ObjectQL AST, a view's `ViewFilterRule[]`) is - // rejected outright. - expect(ElementRecordPickerPropsSchema.safeParse(withFilter({ status: 'open' })).success).toBe(true); - expect(ElementRecordPickerPropsSchema.safeParse(withFilter({ $and: [{ a: 1 }] })).success).toBe(true); - expect(ElementRecordPickerPropsSchema.safeParse(withFilter([['a', '=', 1]])).success).toBe(false); + it('declares `array` as the type the spec actually accepts, not `object`', () => { + // ⚠️ This pin was the mirror image of itself until objectui#7663. + // objectui#3830's landing sketch guessed `'array'`, flagged the guess as + // needing checking against the resolved pin, and the check said `'object'`: + // `ElementRecordPickerProps.filter` was `FilterConditionSchema` + // (`z.record(z.string(), z.unknown()).and(z.object({ $and, $or, $not }))`), + // so the rule ARRAY was the shape rejected outright. + // + // objectstack#14406 CONVERGED the key onto `z.array(ViewFilterRuleSchema)` + // — the last record-form `filter` in `ComponentPropsMap`, under the + // maintainer's one-orthography ruling (objectui#6206-B, 2026-08-25). The + // sketch's guess is the answer now, and the verdicts below simply swap + // sides. ⛔ The refusals are asserted as an ENVELOPE (kind and path), not as + // a bare `success === false`, so a parse that fails for some unrelated + // reason cannot stand in for the contract refusing the record form. + const accepted = ElementRecordPickerPropsSchema.safeParse(withFilter(RULE_ARRAY)); + expect(accepted.success).toBe(true); + + // The RECORD form — every spelling of it — is now refused BY KIND, at the + // key itself. + for (const recordForm of [{ status: 'open' }, { $and: [{ a: 1 }] }]) { + const refused = ElementRecordPickerPropsSchema.safeParse(withFilter(recordForm)); + expect(refused.success, JSON.stringify(recordForm)).toBe(false); + expect(refused.error?.issues.map((i) => i.code)).toContain('invalid_type'); + expect(refused.error?.issues.map((i) => i.path.join('.'))).toContain('filter'); + } + + // Still refused, and for the same reason as before: neither is an array. expect(ElementRecordPickerPropsSchema.safeParse(withFilter('a = 1')).success).toBe(false); expect(ElementRecordPickerPropsSchema.safeParse(withFilter(42)).success).toBe(false); - expect(input('filter')?.type).toBe('object'); + // An array whose MEMBERS are not rule objects is refused too — at + // `filter.0`, not at `filter`. The coarse `'array'` declaration cannot say + // this, which is why the input's description spells the member shape out. + const tuples = ElementRecordPickerPropsSchema.safeParse(withFilter([['a', '=', 1]])); + expect(tuples.success).toBe(false); + expect(tuples.error?.issues.map((i) => i.path.join('.'))).toContain('filter.0'); + + expect(input('filter')?.type).toBe('array'); }); - it('the coarse `object` type costs nothing here — it accepts exactly what the spec accepts', () => { + it('the coarse `array` type agrees with the spec at the KIND boundary, and stops there', () => { // The `element:text_input.defaultValue` sibling declares TWO arms, because // the spec's type there is the union `string | number` (objectui#3832). This - // key is the case where one arm agrees with the contract exactly: - // `checkType`'s `'object'` arm in - // `sdui-parser/src/validate.ts` passes a non-null non-array object and warns - // `type-mismatch` on everything else — the same partition `safeParse` draws - // above. Asserted through the real validator, not by reading its source, so - // a future widening of either side shows up here as a disagreement. + // key still needs only one — `checkType`'s `'array'` arm in + // `sdui-parser/src/validate.ts` passes an array and warns `type-mismatch` on + // everything else, which is the same KIND partition `safeParse` draws above + // now that objectstack#14406 converged the key onto a rule array + // (objectui#7663). Asserted through the real validator, not by reading its + // source, so a future widening of either side shows up here as a + // disagreement. const manifest = manifestFromConfigs([ - { type: TYPE, namespace: 'element', inputs: [{ name: 'filter', type: 'object' }] }, + { type: TYPE, namespace: 'element', inputs: [{ name: 'filter', type: 'array' }] }, ]); const codesFor = (literal: string) => compile(`<${TYPE} filter={${literal}} />`, manifest).diagnostics.map((d) => d.code); - expect(codesFor('{"status":"open"}')).toEqual([]); - expect(codesFor('{"$and":[{"a":1}]}')).toEqual([]); - expect(codesFor('[["a","=",1]]')).toContain('type-mismatch'); + expect(codesFor('[{"field":"status","operator":"equals","value":"open"}]')).toEqual([]); + // The record form is now the refused kind on BOTH authorities — this is the + // assertion that flipped, and the reason the declaration had to move with + // the spec rather than stay `'object'` and disagree with it. + expect(codesFor('{"status":"open"}')).toContain('type-mismatch'); + expect(codesFor('{"$and":[{"a":1}]}')).toContain('type-mismatch'); expect(codesFor('42')).toContain('type-mismatch'); expect(codesFor('null')).toContain('type-mismatch'); + + // ⚠️ WHERE THE AGREEMENT ENDS, stated rather than left to be discovered. + // The coarse vocabulary has no member arm, so a tuple list passes the + // manifest check and is refused by the spec at `filter.0` (asserted above). + // That is a KNOWN gap in the declaration's resolution, not a disagreement to + // repair here: the description carries what the type cannot say. + expect(codesFor('[["a","=",1]]')).toEqual([]); }); it('the `filter` description says which of the two filters an author writes wins', () => { @@ -242,7 +291,7 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => // unknown-prop for filter" would also be what a broken manifest, an // unregistered tag or a silent parse failure looks like. const r = compile( - `<${TYPE} object="account" filter={{"status":"open"}} searchFields={["name"]} />`, + `<${TYPE} object="account" filter={[{"field":"status","operator":"equals","value":"open"}]} searchFields={["name"]} />`, manifest, ); @@ -255,7 +304,7 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => // And the key survives into the compiled tree as itself — the whole point of // publishing it is that the author's `filter` reaches the renderer, which // turns it into the picker query's `$filter`. - expect(r.tree).toMatchObject({ type: TYPE, filter: { status: 'open' } }); + expect(r.tree).toMatchObject({ type: TYPE, filter: RULE_ARRAY }); expect(r.diagnostics.some((d) => d.severity === 'error')).toBe(false); }); }); diff --git a/packages/components/src/renderers/basic/record-picker.tsx b/packages/components/src/renderers/basic/record-picker.tsx index c44925d5a0..d38d9e0369 100644 --- a/packages/components/src/renderers/basic/record-picker.tsx +++ b/packages/components/src/renderers/basic/record-picker.tsx @@ -345,27 +345,32 @@ ComponentRegistry.register('record_picker', elementDataSourceBlock(ElementRecord { name: 'object', type: 'string' }, { name: 'filter', - // `'object'` is the spec's shape, not a chosen arm. `filter` is - // `FilterConditionSchema.optional()`, and that schema is - // `z.record(z.string(), z.unknown()).and(z.object({ $and, $or, $not }))` - // — a plain object. `checkType`'s `'object'` case in - // `sdui-parser/src/validate.ts` accepts exactly what the spec accepts - // here (a non-null non-array object) and rejects exactly what it rejects - // (arrays, strings, numbers, booleans — all verified against - // `ElementRecordPickerPropsSchema.safeParse` in the parity test next to - // this file). So this is the one case in the family where the coarse - // vocabulary lines up with the contract exactly as declared: one arm, no - // union to spell and nothing for the description to make up for. Contrast - // `element:text_input.defaultValue`, whose `string | number` needs two - // arms (objectui#3832), and `emptyText` below, which declares two for the - // same reason once its render site learned to resolve both + // `'array'` is the spec's shape, not a chosen arm. objectstack#14406 + // CONVERGED this key onto `z.array(ViewFilterRuleSchema)` — the last + // record-form `filter` in `ComponentPropsMap`, closing the maintainer's + // 2026-08-25 ruling that the platform carries one filter orthography + // (objectui#6206-B). It was `FilterConditionSchema` (the MongoDB-style + // `z.record(z.string(), z.unknown()).and(z.object({ $and, $or, $not }))`) + // until then, and this entry said `'object'` for exactly that reason. + // `checkType`'s `'array'` case in `sdui-parser/src/validate.ts` accepts + // what the spec now accepts here and rejects what it rejects — the record + // form included, which the spec refuses by KIND. Both halves are verified + // against `ElementRecordPickerPropsSchema.safeParse` in the parity test + // next to this file. So this stays the case in the family where the + // coarse vocabulary lines up with the contract exactly as declared: one + // arm, no union to spell. ⚠️ The array is a rule-OBJECT list, not a tuple + // list: `[['a', '=', 1]]` is refused at `filter.0`, and the coarse + // `'array'` cannot say so — which is what the description below is for. + // Contrast `element:text_input.defaultValue`, whose `string | number` + // needs two arms (objectui#3832), and `emptyText` below, which declares + // two for the same reason once its render site learned to resolve both // (objectui#5590). - type: 'object', + type: 'array', // Taken from what the renderer DOES with the key, because the one thing // an author cannot read off the spec is which of the two places they may // write a filter actually wins. description: - 'Filter criteria narrowing which records the picker offers, as a spec FilterCondition object — `{ status: "open" }`, or `{ $and: [ … ] }` for a group. It becomes the `$filter` of the picker\'s own query, so it decides which records exist for the user, not merely how they are shown. PRECEDENCE: a node-level `dataSource` binding wins outright. The renderer reads `dataSource.filter ?? filter`, so when the binding — or the saved view its `view` names, which AND-combine with each other because the spec calls the binding\'s filter *additional* — supplies a filter, THIS key is dropped entirely rather than merged into it; it applies only when the node carries no `dataSource`, or that `dataSource` and its view both leave `filter` unset. A rule ARRAY (an ObjectQL AST, or a view\'s rule list) is not a FilterCondition and the spec rejects it here.', + 'Filter rules narrowing which records the picker offers, as a ViewFilterRule ARRAY — `[{ field: "status", operator: "equals", value: "open" }, …]`, the one filter orthography this map\'s array-declared `filter` doors share. It becomes the `$filter` of the picker\'s own query, so it decides which records exist for the user, not merely how they are shown. PRECEDENCE: a node-level `dataSource` binding wins outright. The renderer reads `dataSource.filter ?? filter`, so when the binding — or the saved view its `view` names, which AND-combine with each other because the spec calls the binding\'s filter *additional* — supplies a filter, THIS key is dropped entirely rather than merged into it; it applies only when the node carries no `dataSource`, or that `dataSource` and its view both leave `filter` unset. MEMBERS ARE RULE OBJECTS: the MongoDB-style record form (`{ status: "open" }`, `{ $and: [ … ] }`) was retired by objectstack#14406 and the spec now refuses it by kind, and a bare tuple (`["a", "=", 1]`) is refused as a member because each entry must be an object.', }, { name: 'labelField', type: 'string' }, { name: 'valueField', type: 'string' }, From 8f80076dd84576b2fab11fadf7b4b628924dd937 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 12:31:16 +0000 Subject: [PATCH 07/11] feat(plugin-kanban): publish object-kanban `limit`, now that the spec declares it (objectui#8172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#8172 was filed because four faces taught `limit` on an `object-kanban` — the renderer sending `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT`, both `@object-ui/types` faces, and a documented section — while the strict `ComponentPropsMap` refused it BY NAME, so an author following this repo's own docs wrote a node the save gate rejected. The maintainer ruled option A (the contract catches up, 2026-09-07 「同意」), objectstack#16503 landed as objectstack#16562, and @objectstack/spec 17.4.0 ships `limit: z.number().int().positive().optional()`. ⭐ All three of the card's disagreeing faces were RE-MEASURED at this pin rather than assumed, as the card asked: - renderer — `ObjectKanban.tsx` still sends `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT`, with `schema.limit` in the effect deps. Unchanged. - `@object-ui/types` — BOTH faces declare `limit` now (`objectql.ts` and the zod mirror `objectql.zod.ts`). ⚠️ This falsifies the reading recorded on the card by the spec seat, which measured only the plugin's own `KanbanSchema` declaring it at pin `a472b07`; objectui's shared faces have since caught up, so there was no missing declaration to add. - docs — `plugin-kanban.mdx` teaches it correctly. Its one loose sentence is tightened here, because the key is now published with a precedence-bearing description and the two must agree: the binding's own `limit` wins outright, a named view's `pagination.pageSize` fills the cap only when the board leaves `limit` unset. Verified against `ElementDataSourceGate`'s `readLimit(base) === undefined` branch, not restated from the spec's describe. So the only face still out of step was this repo's registration, and that is what moves. ⛔ The declaration carries NO default: a materialised `limit` would defeat that same `readLimit(base) === undefined` branch and a bound view's `pageSize` would then never fill it — the spec seat's explicit warning. The `object-kanban::limit@spec` ledger row in `block-config-schema-parity-8216` is deleted, which is the ratchet working: a row that no longer describes a violation is as red there as a violation with no row. Its self-deleting probe went with it, so the spec-face ledger is now empty — and an empty probe list is indistinguishable from a broken oracle lookup, so a live control replaces it: the former violation must now parse clean AND an undeclared sibling must still be refused by name. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019wtfW1ZxGnP1XKGc9uZVms --- content/docs/plugins/plugin-kanban.mdx | 5 ++- .../block-config-schema-parity-8216.test.ts | 45 ++++++++++++------- packages/plugin-kanban/src/index.tsx | 21 +++++++-- 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index c5329f0768..e44f073414 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -166,8 +166,9 @@ const board: ObjectKanbanSchema = { } ``` -The `dataSource` binding sets it too — its own `limit`, or the -`pagination.pageSize` of the view it names (see +The `dataSource` binding sets it too, and the two do not rank the same way: the +binding's OWN `limit` wins outright, while the `pagination.pageSize` of a view it +names fills the cap only when the board leaves `limit` unset (see [Data source](/docs/guide/data-source)). diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts index 40c4a29ed1..d00bf42516 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts @@ -179,14 +179,15 @@ const EXEMPT: Readonly> = { * would absorb a future spec-face violation on the same name. */ const LEDGER: ReadonlyArray<{ block: string; path: string; face: OracleFace; card: string; why: string }> = [ - { - block: 'object-kanban', - path: 'limit', - face: 'spec', - card: 'objectui#8172', - why: - 'The renderer honours it — ObjectKanban.tsx sends `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT` — and both @object-ui/types faces declare it, but ComponentPropsMap[object-kanban] does not. The producer side is upstream, so removing the control would delete a working affordance to satisfy a schema that is behind it.', - }, + // `object-kanban::limit@spec` stood here until @objectstack/spec 17.4.0. It was + // the ledger working exactly as designed: the renderer honoured the key, both + // `@object-ui/types` faces declared it and the docs taught it, while + // `ComponentPropsMap['object-kanban']` refused it by name — so the row said + // "upstream owes this" instead of deleting a working affordance to satisfy a + // schema that was behind it. objectstack#16503 (landed as objectstack#16562) + // declared `limit` upstream, which is the maintainer's option-A ruling on + // objectui#8172, and the row went stale. Deleted rather than kept: a row that + // no longer describes a violation is as red here as a violation with no row. { block: 'object-form', path: 'formType', @@ -389,17 +390,27 @@ describe('BLOCK_CONFIG ↔ node-schema parity — the ratchet (objectui#8216)', // being ledgered with no probe behind it. `page:tabs::items[].key` was the // second row until objectui#8278 renamed that control to `value`; its // measurement moved WITH it, to `page-tabs-item-value-8278.test.tsx`. - expect(LEDGER.filter((r) => r.face === 'spec').map(ledgerId)).toEqual([ - 'object-kanban::limit@spec', - ]); - + expect(LEDGER.filter((r) => r.face === 'spec').map(ledgerId)).toEqual([]); + + // ⚠️ The spec-face ledger is EMPTY, so the self-deleting probe list above is + // empty too — and an empty list of probes is exactly what a broken oracle + // lookup also produces. The live control below is what separates the two: it + // re-measures the row this test used to carry (`object-kanban::limit@spec`, + // objectui#8172) and asserts the verdict that RETIRED it, so "no spec-face + // violations" stays a reading rather than a silence. const kanban = SPEC_ORACLES['object-kanban'] as { safeParse: (v: unknown) => any }; - const withLimit = kanban.safeParse({ objectName: 'opportunity', groupBy: 'stage', limit: 50 }); - expect(withLimit.success).toBe(false); - expect(withLimit.error.issues.flatMap((i: any) => i.keys ?? [])).toContain('limit'); + const base = { objectName: 'opportunity', groupBy: 'stage' }; expect( - kanban.safeParse({ objectName: 'opportunity', groupBy: 'stage' }).success, - 'the base kanban must parse clean, or the refusal above proves nothing', + kanban.safeParse(base).success, + 'the base kanban must parse clean, or neither verdict below proves anything', ).toBe(true); + // The former violation, now declared upstream (objectstack#16503). + expect(kanban.safeParse({ ...base, limit: 50 }).success).toBe(true); + // The oracle still refuses an undeclared sibling BY NAME — without this the + // line above would be satisfied just as well by an oracle that stopped + // refusing anything at all. + const bogus = kanban.safeParse({ ...base, notAKanbanKey: 1 }); + expect(bogus.success).toBe(false); + expect(bogus.error.issues.flatMap((i: any) => i.keys ?? [])).toContain('notAKanbanKey'); }); }); diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index 4df37c2f96..843eba8eeb 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -491,10 +491,22 @@ export const ObjectKanbanRenderer: React.FC<{ schema: any; [key: string]: any }> * objectui#8223 (`sort`) cleared on: the SPEC already declares all five and the * RENDERER already honours all five, so this restores `declared = enforced` * instead of publishing anything new. Measured with a control on the same - * `safeParse` call — because "the spec declares it" is exactly the assumption - * objectui#8172 falsified for `limit`, which four faces teach and the strict - * `ComponentPropsMap` refuses BY NAME. An unrecognised probe key draws - * `unrecognized_keys` on these calls while none of these five does. + * `safeParse` call — because "the spec declares it" is an assumption + * objectui#8172 once falsified for `limit`: four faces taught the key and the + * strict `ComponentPropsMap` refused it BY NAME. ⚠️ That reading is HISTORY as of + * @objectstack/spec 17.4.0. objectstack#16503 (landed as objectstack#16562) + * added `limit: z.number().int().positive().optional()` to + * `ComponentPropsMap['object-kanban']` — the maintainer's option-A ruling on + * objectui#8172, the contract catching up with a capability that was already + * implemented, typed, mapped and documented — so `limit` is DECLARED below with + * the others. Re-measured here rather than inherited: all four faces now agree + * (renderer `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT`; BOTH `@object-ui/types` + * faces, TS and zod; `content/docs/plugins/plugin-kanban.mdx`; the spec). + * ⛔ The declaration carries NO default: a materialised `limit` would defeat the + * gate's `readLimit(base) === undefined` branch, and a bound view's + * `pagination.pageSize` would then never fill it. `DEFAULT_KANBAN_LIMIT = 100` + * stays documented rather than declared. An unrecognised probe key draws + * `unrecognized_keys` on these calls while none of the declared keys does. * * ## What is deliberately NOT here yet * @@ -521,6 +533,7 @@ const OBJECT_KANBAN_INPUTS: ComponentInput[] = [ { name: 'objectName', type: 'string', required: true }, { name: 'columns', type: 'array' }, { name: 'filter', type: 'array', description: 'Filter criteria in JSON-rules form, narrowing the records the board fetches. Lowered to `$filter` on the query.' }, + { name: 'limit', type: 'number', description: 'Row cap — the most records the board fetches, lowered to the query’s top-level `$top` (renderer default 100). The board renders every fetched record into a lane and offers no pagination, so this is the author’s window on the object rather than a page size. PRECEDENCE: a node-level `dataSource` binding’s own `limit` wins outright; the `pagination.pageSize` of a view that binding names fills this key only when the node leaves it unset.' }, { name: 'groupBy', type: 'string', description: 'Record field whose value buckets cards into lanes. Its picklist options become the lanes when `columns` is absent, and a drag between lanes writes the target lane’s value back to the record. A value matching no lane lands in the trailing “Uncategorized” lane rather than disappearing.' }, { name: 'cardTitle', type: 'string', description: 'Record field rendered as the card title. Read AHEAD of `titleField`, which is the legacy spelling of the same choice; when neither yields a value the shared record-display resolver names the card.' }, { name: 'titleField', type: 'string', description: 'Legacy spelling of `cardTitle` — the record field rendered as the card title. `cardTitle` wins when both are authored.' }, From bd6f9c1335ac00fd415c74ecd8718245a3f80d6b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 12:31:46 +0000 Subject: [PATCH 08/11] fix(console): the three unannounced @objectstack/spec 17.4.0 changes this repo asserted against (objectui#8785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each was measured against BOTH spec versions before being repaired, so "changed in 17.4.0" is a reading rather than an inference. 1. `job.timeout` retired in favour of `timeoutMs`. The preview sample carried `timeout: 600000` and a comment arguing for that spelling ("the unit is already milliseconds"). The spec now refuses the key with a tombstone making the opposite argument — the unit belongs in the name, since the sibling `retryPolicy.backoffMs` already spelled its own and one surface was teaching two conventions. Same ruling family as objectui#7783. The SAMPLE moves; the value is unchanged. Measured: `timeoutMs: 600000` parses clean through `ObjectStackSchema`, `timeout: 600000` is refused at `jobs.0.timeout`. 2. The flow end-node `outcome` enum narrowed to `completed | refused`. The sample's `outcome: 'success'` is no longer a member. It becomes `'completed'` — ⛔ NOT `'refused'`, which the spec additionally requires a `message` beside (measured: `refused` with no message fails at `config.message`), and which would in any case describe the wrong ending for this sample. 3. The dashboard `gap` help text. The pin asserted that the ENGLISH producer copy still says "Tailwind"; 17.4.0 rewrote it to "Space between widgets, in steps of 0.25rem (4 = 1rem)". That leg is a non-vacuity control, not the subject: without it, "the zh copy does not say Tailwind" would pass just as well on copy that never said it. Re-pointing it at the new sentence buys exactly one release — this repo does not own that text. So the control now asserts the PROPERTY the overlay exists for: the producer's copy reaches for developer vocabulary (a framework name, or digits welded to a CSS unit) and the zh overlay carries none of it. Still non-vacuous, no wording pinned, and if the spec ever writes this hint in plain author language the control reddens and someone re-reads the overlay — which is correct, since the overlay would then have nothing left to do here. ⚠️ `\brem\b` does not work for this: the boundary before `rem` never matches in `0.25rem`, because `5` is a word character. Measured both ways against both spec versions' copy before landing. ⛔ None of the three is patched green by relaxing an assertion: in all three the spec is the producer and it moved deliberately. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019wtfW1ZxGnP1XKGc9uZVms --- apps/console/src/preview-samples.ts | 20 +++++++++---- .../metadata-admin/metadata-form-i18n.test.ts | 28 ++++++++++++++++--- .../metadata-admin/metadata-form-i18n.ts | 8 ++++-- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/apps/console/src/preview-samples.ts b/apps/console/src/preview-samples.ts index 18c6e3c34c..eb06a5d371 100644 --- a/apps/console/src/preview-samples.ts +++ b/apps/console/src/preview-samples.ts @@ -251,7 +251,12 @@ export const SAMPLES: Record> = { { id: 'review', type: 'screen', label: 'CSM review', config: { fields: [ { name: 'discount', label: 'Discount %', type: 'number', required: false }, { name: 'note', label: 'Note', type: 'text', required: true, visibleWhen: 'discount > 0' } ] } }, { id: 'notify', type: 'script', label: 'Email the owner', config: { actionType: 'email', template: 'renewal_reminder', recipients: ['owner.email', 'csm@example.com'], variables: { contractId: '{contractId}' } } }, { id: 'enrich', type: 'script', label: 'Score (code)', config: { script: "variables.score = 42;\nreturn variables;", outputVariables: ['score'] } }, - { id: 'end', type: 'end', label: 'End', config: { outcome: 'success' } }, + // `completed`, not `success`: @objectstack/spec 17.4.0 narrowed the end + // node's outcome enum to `completed | refused` (objectui#8785). `refused` + // is not interchangeable — the spec additionally REQUIRES a `message` + // beside it, because a refusal with no text is the shape that contract + // exists to replace. This sample ends successfully, so it is `completed`. + { id: 'end', type: 'end', label: 'End', config: { outcome: 'completed' } }, ], // `FlowEdgeSchema.id` is REQUIRED — the canvas already keys off it when // present (splitting an edge, adding a back-edge), and an id-less edge @@ -315,10 +320,15 @@ export const SAMPLES: Record> = { schedule: { type: 'cron', expression: '0 2 * * *', timezone: 'UTC' }, handler: 'syncOrders', retryPolicy: { maxRetries: 3 }, - // `timeout`, not `timeoutMs` — the unit is already milliseconds. There is - // no `concurrency` key on a job; `JobSchema` is `.strict()` since spec - // 17.0.0, so both spellings are now rejected by name instead of dropped. - timeout: 600000, + // `timeoutMs`, not `timeout` — @objectstack/spec 17.4.0 RETIRED the bare + // spelling and put the unit in the key name, the same ruling that renamed + // `dashboard.refreshInterval` (ruling B on objectstack#14478): the sibling + // `retryPolicy.backoffMs` already spelled its own unit, so one surface was + // teaching two conventions. The VALUE is unchanged — still milliseconds. + // There is no `concurrency` key on a job either; `JobSchema` is `.strict()` + // since spec 17.0.0, so both retired spellings are rejected by name rather + // than dropped (objectui#8785). + timeoutMs: 600000, }, agent: { diff --git a/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.test.ts b/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.test.ts index e9be3372d4..0a344079e3 100644 --- a/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.test.ts +++ b/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.test.ts @@ -98,10 +98,30 @@ describe('localizeMetadataForm — the spec form, in the author’s language', ( ); const gap = fieldByName(zh, 'gap'); expect(gap?.label).toBe('间距'); - expect(gap?.helpText).not.toMatch(/Tailwind/i); - // The English source still says it — that copy is the spec's to fix, and - // this overlay deliberately does not rewrite the producer's own text. - expect(fieldByName(dashboardForm, 'gap')?.helpText).toMatch(/Tailwind/i); + + // ⚠️ THE CONTROL IS NOT A SENTENCE PIN, and it used to be one. This asserted + // that the English producer copy still said "Tailwind" — true while the spec + // shipped `Grid gap (Tailwind units)`, and false from @objectstack/spec + // 17.4.0, which rewrote it to "Space between widgets, in steps of 0.25rem + // (4 = 1rem)" (objectui#8785). The producer was right to fix its own copy; + // the pin was wrong to name a sentence it does not own, and re-pointing it + // at the new sentence would buy exactly one release before rotting again. + // + // What this test actually claims is narrower and durable: the overlay hands + // the author text they can act on, where the producer's copy reaches for + // DEVELOPER vocabulary — a framework name, a CSS unit — to say the same + // thing. So the control asserts the English carries some of that vocabulary + // and the zh carries none of it. It stays non-vacuous (the English leg must + // fire for the zh leg to mean anything) without pinning any one wording, and + // if the spec ever writes this hint in plain author language the control + // reddens and someone re-reads the overlay — which is the correct outcome, + // not a false alarm: the overlay would have nothing left to do here. + // A framework name, or a CSS length written as an author never would — + // digits welded to a unit. ⚠️ `\brem\b` does NOT work here: the boundary + // before `rem` never matches in `0.25rem`, because `5` is a word character. + const DEVELOPER_UNITS = /tailwind|\d\s*(rem|px)\b/i; + expect(fieldByName(dashboardForm, 'gap')?.helpText).toMatch(DEVELOPER_UNITS); + expect(gap?.helpText).not.toMatch(DEVELOPER_UNITS); }); it('synthesizes ALL of the `header` composite’s sub-rows — a partial list would hide the rest', () => { diff --git a/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts b/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts index 14e05a27ab..c5ebc39c82 100644 --- a/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts @@ -84,9 +84,11 @@ const METADATA_FORM_BUNDLE: TranslationBundle = { label: { label: '显示名称', helpText: '展示给使用者的名称' }, description: { label: '描述' }, columns: { label: '列数', helpText: '栅格列数(默认 12)' }, - // The spec's own hint here is `Grid gap (Tailwind units)` — a unit - // only a developer can act on. Reported upstream rather than - // "translated" literally; this says what the author can decide. + // The spec's own hint reaches for developer vocabulary to say this — + // `Grid gap (Tailwind units)` before 17.4.0, and "Space between + // widgets, in steps of 0.25rem (4 = 1rem)" since. Either way it names + // a unit only a developer can act on, so this row says what the author + // can decide rather than transliterating it (objectui#8785). gap: { label: '间距', helpText: '组件之间的间距,数值越大越松' }, refreshIntervalSeconds: { label: '自动刷新', helpText: '自动刷新间隔(秒),0 表示不自动刷新' }, header: { label: '页眉', helpText: '页眉设置:标题、描述与操作按钮' }, From 539c74b42233595052d47a9e813b2d33ae452da3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 12:31:46 +0000 Subject: [PATCH 09/11] test: discharge the two self-declared tripwires the 17.4.0 pin reached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither has an open objectui card. Both are tests that predicted this exact event in their own prose and said what to do when it fired, so the repair is the one each prescribes — not a new decision. `MetadataService.specKeyReference.test.ts` — the VERSION SKEW row. Until 17.3.0 the spec's `reference` emptiness test was an equality against `''`, so `' '` parsed green while this writer refused it: a real divergence, declared rather than hidden, with the spec half written as a tripwire for the day objectstack#16920 reached the pin ("this line reddens when the pin reaches it — at which point the reading becomes `false` and nothing else here moves"). 17.4.0 carries the fix. Measured: `' '`, `''` and an absent target are now all refused under the same `custom` issue at the same `reference` path the writer names, while `'account'` and `' account '` still parse — the trim is for the TEST only, which is what the writer's own pass-it-through row depends on. The verdict flips and is asserted as an envelope (code plus path), because "refused" and "refused AS a blank target" are separable claims. `MetadataService.ts` is untouched, exactly as the note predicted. Closes the divergence objectui#8621 recorded; objectstack#16920 is the other repo's card and is not touched. `toolLabels-locale-parity-7481.test.ts` — `AHEAD_OF_PIN`, the hand-held list of tools the cloud runtime registers ahead of the spec snapshot. All five names it carried are in `PLATFORM_TOOLS_BY_PACKAGE` at 17.4.0, and the file's own rule is that a caught-up name must be REMOVED so the list never becomes a second registry. Emptied, ⛔ not deleted: the next tool registered ahead of a snapshot needs the same hatch. ⚠️ Emptying it turns three guards into tautologies — `[].filter(…)` is `[]`, `arrayContaining([])` passes on anything — including the one whose comment says it exists to stop the suite going vacuously green if the spec export disappears. So they are re-anchored: a `GRADUATED` constant records the five names and asserts they ARE in the registry, and the coverage floor is read off the registry rather than off the emptied list. If the registry ever stops listing one of them, that is now what reddens, and the repair is to put the name back on `AHEAD_OF_PIN`. ⛔ objectui#7481 is CLOSED (completed via PR #7710) and is not reopened. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019wtfW1ZxGnP1XKGc9uZVms --- .../MetadataService.specKeyReference.test.ts | 83 +++++++++++-------- .../toolLabels-locale-parity-7481.test.ts | 70 ++++++++++++---- 2 files changed, 101 insertions(+), 52 deletions(-) diff --git a/packages/app-shell/src/services/MetadataService.specKeyReference.test.ts b/packages/app-shell/src/services/MetadataService.specKeyReference.test.ts index 67e017efd3..08ad6018f0 100644 --- a/packages/app-shell/src/services/MetadataService.specKeyReference.test.ts +++ b/packages/app-shell/src/services/MetadataService.specKeyReference.test.ts @@ -268,23 +268,26 @@ describe('objectui#6041 · saveFields PUTs the relationship target as `reference * to be a declared divergence. * * ⚠️ Read the instrument note before adding a spec assertion here. This repo's - * pin is `@objectstack/spec` **17.3.0** (`pnpm-lock.yaml`; the 17.2.0 this note - * used to name was superseded by objectui#7122). At 17.3.0 the #13632 - * refinement refuses an absent and an EMPTY `reference` but spells its - * emptiness test as an equality against `''`, so `' '` still parses green. - * An assertion of the shape "the spec accepts `' '`" therefore measures the - * VERSION SKEW below, not a standing opinion of this repo's. + * pin is `@objectstack/spec` **17.4.0** (`pnpm-lock.yaml`; 17.3.0 before + * objectui#8772, 17.2.0 before objectui#7122). * - * The divergence itself is retired (objectui#8621): objectstack#16920 applies - * that emptiness test to the trimmed value, so upstream now refuses the same - * shape under the same `custom` issue at the same `reference` path, and this - * writer's predicate mirrors the contract instead of exceeding it. The fix is - * an unreleased `minor` at the time of writing, which is why the spec half - * below still reads `success = true`: it is pinned against the INSTALLED spec, - * and it is the tripwire that reddens when the pin reaches the fix. ⛔ The - * writer's refusal is asserted separately and does not depend on the installed - * spec at all — that separation is what makes the bump a one-line edit here and - * no behaviour change at all in `MetadataService.ts`. + * ⭐ THE SKEW IS CLOSED, AND THE TRIPWIRE THAT SAID SO HAS FIRED. Until 17.3.0 + * the #13632 refinement refused an absent and an EMPTY `reference` but spelled + * its emptiness test as an equality against `''`, so `' '` parsed green while + * this writer refused it — a real divergence, declared rather than hidden. + * objectstack#16920 (reported as objectstack#16126, retired here under + * objectui#8621) applies that test to the TRIMMED value, and it shipped in + * 17.4.0. Measured at this pin: `' '`, `''` and an absent target are all + * refused upstream under the same `custom` issue at the same `reference` path + * the writer names, so the writer's predicate now MIRRORS the contract rather + * than exceeding it. + * + * The row below was written as a tripwire against exactly this event and said + * what to do when it fired — "the reading becomes `false` and nothing else here + * moves" — which is what happened: the spec half flipped, and `MetadataService.ts` + * did not change at all. ⛔ The writer's refusal is still asserted separately and + * still does not depend on the installed spec; that separation is what kept this + * a prose-and-one-verdict edit. */ describe('objectui#7714 · the target states, and the retired divergence', () => { const puttable = async (reference: unknown) => { @@ -327,33 +330,45 @@ describe('objectui#7714 · the target states, and the retired divergence', () => expect(savedFields(puts)[0].reference).toBe(' account '); }); - it('the VERSION SKEW is real at THIS pin: 17.3.0 accepts `\' \'`, this writer refuses it', async () => { + it('the VERSION SKEW is CLOSED at THIS pin: 17.4.0 refuses `\' \'` too, exactly as this writer does', async () => { // The writer half — true regardless of which spec is installed, and // unchanged by objectstack#16920. This is the row that must never move. const { refused } = await puttable(' '); expect(refused).toBe(true); - // The spec half, stated honestly for the INSTALLED 17.3.0, where #13632's - // emptiness test is an equality against `''`. Discriminating on its own at - // this pin — an absent target IS refused here (asserted below), so a green - // `' '` isolates blankness rather than reflecting a schema that accepts - // everything. ⚠️ This is a TRIPWIRE, not an endorsement: objectstack#16920 - // trims before the test, so this line reddens when the pin reaches it — at - // which point the reading becomes `false` and nothing else here moves. - expect(FieldSchema.safeParse({ type: 'lookup', label: 'L', reference: ' ' }).success).toBe(true); - - // Falsifying control for the line above: the refinement IS attached and IS - // consulted at this pin — an absent target is refused, as `custom` at path - // `reference`. A passthrough schema would have made the green above - // meaningless. + // The spec half. This line read `.toBe(true)` while the pin was 17.3.0, + // where #13632's emptiness test was an equality against `''`; it was written + // as a TRIPWIRE for the day objectstack#16920 reached the pin, and + // objectui#8772 is that day. Asserted as an ENVELOPE rather than a bare + // `success === false`, because the two halves of the claim are separable: a + // blank target must be refused, and refused AS a blank target — the same + // `custom` issue at the same `reference` path the absent case draws, not an + // `invalid_type` or some unrelated failure that would satisfy a bare false. + const blank = FieldSchema.safeParse({ type: 'lookup', label: 'L', reference: ' ' }); + expect(blank.success).toBe(false); + expect(blank.error!.issues.map((i) => i.code)).toContain('custom'); + expect(blank.error!.issues.map((i) => i.path.join('.'))).toContain('reference'); + + // Non-vacuity for the refusal above: the schema has NOT simply become + // hostile to every `reference`. A real name still parses green, and a name + // that merely carries surrounding whitespace still does too — the trim is + // for the TEST only, which is the same reading the writer's own + // pass-it-through row above depends on. + expect(FieldSchema.safeParse({ type: 'lookup', label: 'L', reference: 'account' }).success).toBe(true); + expect(FieldSchema.safeParse({ type: 'lookup', label: 'L', reference: ' account ' }).success).toBe(true); + + // The absent case, which the blank case above now matches exactly — the + // convergence stated as a measurement rather than asserted in prose. const absent = FieldSchema.safeParse({ type: 'lookup', label: 'L' }); expect(absent.success).toBe(false); expect(absent.error!.issues.map((i) => i.code)).toContain('custom'); - // Second control, at value level rather than presence level: a non-string - // is refused as `invalid_type` by the base schema before the refinement - // runs. Blankness was simply not among the things the 17.3.0 test covered — - // the gap objectstack#16126 reported and objectstack#16920 closed. + // Value level rather than presence level: a non-string is refused as + // `invalid_type` by the base schema before the refinement runs — a + // different issue code from the blank case above, which is what makes + // "refused as a blank target" a distinguishable verdict at all. Blankness + // was simply not among the things the 17.3.0 test covered — the gap + // objectstack#16126 reported and objectstack#16920 closed. const nonString = FieldSchema.safeParse({ type: 'lookup', label: 'L', reference: 42 }); expect(nonString.success).toBe(false); expect(nonString.error!.issues.map((i) => i.code)).toContain('invalid_type'); diff --git a/packages/plugin-chatbot/src/__tests__/toolLabels-locale-parity-7481.test.ts b/packages/plugin-chatbot/src/__tests__/toolLabels-locale-parity-7481.test.ts index 7d20898219..0c4df376e5 100644 --- a/packages/plugin-chatbot/src/__tests__/toolLabels-locale-parity-7481.test.ts +++ b/packages/plugin-chatbot/src/__tests__/toolLabels-locale-parity-7481.test.ts @@ -14,14 +14,23 @@ * cloud AI packages register a tool. * * The registry (`@objectstack/spec`'s `PLATFORM_TOOLS_BY_PACKAGE`) is the - * contract, so it is what this suite reads. But the pinned spec LAGS the cloud - * runtime — the five names in {@link AHEAD_OF_PIN} are registered by - * `service-ai-studio` today and are not in the snapshot — and a step label the - * user reads must not wait on a pin bump. So the coverage set is - * `registry ∪ AHEAD_OF_PIN`, and a member of `AHEAD_OF_PIN` that the registry - * has caught up with must be REMOVED from that list, which the last case here - * enforces. That keeps the hand-held half shrinking rather than growing into a - * second, permanent registry. + * contract, so it is what this suite reads. The pinned spec could LAG the cloud + * runtime, and a step label the user reads must not wait on a pin bump, so the + * coverage set is `registry ∪ AHEAD_OF_PIN` — a hand-held escape hatch for names + * the snapshot has not listed yet. A member the registry has caught up with must + * be REMOVED from that list, which the last case here enforces, so the hand-held + * half shrinks rather than growing into a second, permanent registry. + * + * ⭐ IT HAS SHRUNK TO NOTHING, AND THAT IS THE MECHANISM WORKING. The five names + * this file was written around — `get_authoring_rules` (cloud#1837), + * `load_tools`, `open_record`, `test_flow`, `toggle_flow` — are all in + * `PLATFORM_TOOLS_BY_PACKAGE` as of `@objectstack/spec` 17.4.0, so + * {@link AHEAD_OF_PIN} is empty and coverage is the registry alone + * (objectui#8772 took the pin there). ⛔ The list is kept, not deleted: the next + * tool the cloud registers ahead of a snapshot needs the same hatch, and + * rebuilding it under pressure is how a permanent second registry starts. + * {@link GRADUATED} keeps this file's guards honest while the list is empty — + * see its note. * * The English pin is the other half. The packs' own comment states the rule — * "the `en` values are deliberately EQUAL to what that title-caser produces: @@ -43,11 +52,25 @@ const REGISTRY = ( /** * Tools the cloud AI runtime registers that the PINNED spec snapshot does not - * list yet. Sourced from cloud `service-ai-studio`'s `plugin.ts` tool - * definitions (`AUTHORING_RULE_`, `LOAD_TOOLS_`, `OPEN_RECORD_`, `FLOW_`). - * Shrink this list on a pin bump — never grow it as a habit. + * list yet — sourced from cloud `service-ai-studio`'s `plugin.ts` tool + * definitions. Shrink this list on a pin bump — never grow it as a habit. + * + * EMPTY at this pin: every name it carried has graduated (see {@link GRADUATED}). */ -const AHEAD_OF_PIN = ['get_authoring_rules', 'load_tools', 'open_record', 'test_flow', 'toggle_flow'] as const; +const AHEAD_OF_PIN: readonly string[] = []; + +/** + * The names that HAVE graduated from {@link AHEAD_OF_PIN} into the registry. + * + * ⛔ Not a record for its own sake — it is the live instrument this file needs + * while `AHEAD_OF_PIN` is empty. Every guard below was anchored on that list + * being non-empty, so with it emptied they would read green against a + * `PLATFORM_TOOLS_BY_PACKAGE` that had vanished, gone empty, or lost these very + * names: `[].filter(…)` is `[]` and `arrayContaining([])` passes on anything. + * Asserting these five ARE in the registry is the reading that cannot be + * satisfied by an absent export, and it is the same fact the emptying rests on. + */ +const GRADUATED = ['get_authoring_rules', 'load_tools', 'open_record', 'test_flow', 'toggle_flow'] as const; /** Every tool name that must carry a step label in every pack. */ const COVERED: string[] = [ @@ -73,9 +96,13 @@ describe('chatbot.tool.* covers every tool the runtime can show (objectui#7481)' it('reads a non-empty coverage set', () => { // Guards against the whole suite going vacuously green if the spec export - // disappears AND the hand-held list is emptied. - expect(COVERED.length).toBeGreaterThanOrEqual(AHEAD_OF_PIN.length); - expect(COVERED).toEqual(expect.arrayContaining([...AHEAD_OF_PIN])); + // disappears. With `AHEAD_OF_PIN` empty the coverage set is the REGISTRY + // alone, so the floor is read off the registry rather than off the + // hand-held list — the two `AHEAD_OF_PIN` assertions that used to stand here + // are both tautologies on an empty list. + expect(REGISTRY, 'PLATFORM_TOOLS_BY_PACKAGE did not resolve').toBeTruthy(); + expect(COVERED.length).toBeGreaterThan(GRADUATED.length); + expect(COVERED).toEqual(expect.arrayContaining([...GRADUATED, ...AHEAD_OF_PIN])); }); it.each(LANGS)('%s defines a non-empty label for every covered tool', (lang) => { @@ -96,12 +123,19 @@ describe('chatbot.tool.* covers every tool the runtime can show (objectui#7481)' }); it('AHEAD_OF_PIN carries only names the pinned registry still lacks', () => { - // The moment `.objectstack-sha` advances past a tool's registry entry, the - // hand-held list must lose it — otherwise this file becomes a second - // registry that nobody keeps in sync. + // The moment the pin advances past a tool's registry entry, the hand-held + // list must lose it — otherwise this file becomes a second registry that + // nobody keeps in sync. This is the case that fired on the 17.4.0 bump and + // named all five; they were removed rather than the assertion relaxed. const inRegistry = new Set(Object.values(REGISTRY ?? {}).flat()); const caughtUp = AHEAD_OF_PIN.filter((name) => inRegistry.has(name)); expect(caughtUp, 'remove these from AHEAD_OF_PIN — the pinned spec now lists them').toEqual([]); + + // ⚠️ The line above is a tautology while the list is empty, so it is not + // left standing alone: the graduation it records is asserted directly. If + // the registry ever stops listing one of these, THIS is what reddens — + // and the repair would be to put that name back on `AHEAD_OF_PIN`. + expect(GRADUATED.filter((name) => !inRegistry.has(name))).toEqual([]); }); }); From 99050ad0c03e27f6f4fb9c01e215ba4797e07766 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 13:37:10 +0000 Subject: [PATCH 10/11] fix(docs): re-anchor the two doc ratchets this branch's own edits moved Both surfaced in the full suite, both caused by edits in this branch and neither by a spec change: - `check-doc-example-types.mjs`'s `UNGATED_EXAMPLES` is keyed by `path:line symbol`, and the gantt prose corrections in `packages/types/src/objectql.ts` added six lines above `ObjectFormSchema`'s `@example`. Re-anchored 1607 to 1613; verified by reading the `@example` line at both revisions. - `doc-version-claims` refuses a version literal on a doc surface that nothing in this repo can re-check. The `refreshIntervalSeconds` row added to `schema-reference.md` carried "@objectstack/spec 17.4.0". Taken out rather than added to `KNOWN_CLAIMS`, which is the remedy the gate itself prefers: the sentence needs the RENAME, not the release it shipped in, and the card number already points at the full account. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019wtfW1ZxGnP1XKGc9uZVms --- content/docs/api/schema-reference.md | 2 +- scripts/check-doc-example-types.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index bdb5a4cda7..bbdbab4162 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -1050,7 +1050,7 @@ A widget-based dashboard with configurable grid layout and auto-refresh. | `columns` | `number` | Number of grid columns. | | `gap` | `number` | Gap between widgets (Tailwind spacing scale). | | `widgets` | `DashboardWidgetSchema[]` | **Required.** Widget definitions with `id`, `title`, `colSpan`, `rowSpan`, and `body`. | -| `refreshIntervalSeconds` | `number` | Auto-refresh interval in **seconds** — the renderer multiplies by 1000. Spelled `refreshInterval` before @objectstack/spec 17.4.0, and documented here as milliseconds, which it never was (objectui#7783). | +| `refreshIntervalSeconds` | `number` | Auto-refresh interval in **seconds** — the renderer multiplies by 1000. Renamed from `refreshInterval`, which this table documented as milliseconds and which it never was (objectui#7783). | Each widget supports `colSpan` and `rowSpan` to control its size in the grid. The `body` can be any `SchemaNode`. diff --git a/scripts/check-doc-example-types.mjs b/scripts/check-doc-example-types.mjs index de0cb2891d..be4c962691 100644 --- a/scripts/check-doc-example-types.mjs +++ b/scripts/check-doc-example-types.mjs @@ -1013,7 +1013,7 @@ export const UNGATED_EXAMPLES = { reason: 'usage fragment: references `save`, `storedPage`, which the example never declares', }, - 'packages/types/src/objectql.ts:1607 ObjectFormSchema': { + 'packages/types/src/objectql.ts:1613 ObjectFormSchema': { card: null, codes: [1005, 1109], reason: From 250b7423ba55115d0e315c5e5258d8f76d49d76e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 13:38:48 +0000 Subject: [PATCH 11/11] chore(changeset): declare the 17.4.0 line move and the contracts it follows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `node scripts/check-changeset-presence.mjs` refused without one: 26 of the changed files are published source of a package the release covers. Scored `minor` across the eight packages whose `src/` moved — never `major`, because this repo's fixed group tracks the @objectstack major (AGENTS.md §版本号策略, enforced by scripts/check-changeset-no-major.mjs). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019wtfW1ZxGnP1XKGc9uZVms --- .changeset/8772-objectstack-17-4-0-line.md | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .changeset/8772-objectstack-17-4-0-line.md diff --git a/.changeset/8772-objectstack-17-4-0-line.md b/.changeset/8772-objectstack-17-4-0-line.md new file mode 100644 index 0000000000..fa86c9753a --- /dev/null +++ b/.changeset/8772-objectstack-17-4-0-line.md @@ -0,0 +1,43 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-dashboard': minor +'@object-ui/plugin-gantt': minor +'@object-ui/plugin-kanban': minor +'@object-ui/components': minor +'@object-ui/app-shell': minor +'@object-ui/console': minor +--- + +Take the `@objectstack/*` line to 17.4.0 and follow every contract it moved +(objectui#8772, with #7783 · #7663 · #8172 · #7845 · #8785). + +**Breaking on one authored key, deliberately.** A dashboard node's +`refreshInterval` is now `refreshIntervalSeconds` — the value is unchanged +(seconds), and the spec refuses the old spelling by name with a message naming +the new one, so an existing document fails loudly at parse rather than silently +losing its auto-refresh. `os migrate meta --from 17` lists the mechanical edits. +The report component's own `refreshInterval` is a different key and is NOT +affected. (Scored `minor`, not `major`: this repo's fixed group tracks the +`@objectstack` major — AGENTS.md §版本号策略.) + +Also authored-surface changes, all following a published spec move rather than a +local decision: + +- `element:record_picker`'s `filter` input is now the `ViewFilterRule` array + form `[{ field, operator, value }, …]`. The MongoDB-style record form is + refused by the contract (objectstack#14406 converged the last record-form + `filter` in the map), so a JSX page writing the array form no longer draws a + false `type-mismatch` and one writing the record form is told. +- `object-kanban` now publishes `limit`, the row cap its renderer has always + lowered to `$top`. The spec declares it as of objectstack#16503, so the key + the docs teach is finally one the save gate stores. +- `object-gantt`'s ten extension keys (`timeSegments`, `interactions`, + `lockField`, …) are derived from the spec's `GanttConfigSchema` instead of + being re-declared locally. Same accept set on the flat face; the nested + `gantt` block narrows to the spec's, which now refuses an undeclared sub-key + by name where its `.passthrough()` window used to admit one. +- The console's preview-gallery samples — the worked examples an author copies — + move with two spec changes of their own: a job's `timeout` is now `timeoutMs` + (same unit-in-the-key-name ruling as the dashboard key above), and a flow's + end node writes `outcome: 'completed'`, the enum having narrowed to + `completed | refused`.