Skip to content

Commit be7763a

Browse files
fix(client): the published README's packages.install example parses against ManifestSchema (#18771)
Fixes #18607 The `client.packages.install` example in the **published** `@objectstack/client` README shipped a manifest that the contract its own call site declares refuses on three counts. The example is what was wrong; `ManifestSchema`'s strict close is what is right, so nothing in `packages/spec` moves here. ## BEFORE — the three refusals, reproduced Driven against `PackageInstallRequestSchema` (whose `manifest` key **is** `ManifestSchema`), with the literal read out of the README **by content**, not by line number: ``` LITERAL AS PUBLISHED: { name: 'vendor_plugin', label: 'Vendor Plugin', version: '1.0.0', } success: false code=invalid_type path=[manifest, id] code=invalid_value path=[manifest, type] code=unrecognized_keys path=[manifest] keys=["label"] ISSUE COUNT: 3 ``` All three reproduce exactly as the card states them. ## AFTER — parsed and accepted ``` LITERAL AS PUBLISHED: { id: 'com.vendor.plugin', type: 'plugin', name: 'Vendor Plugin', version: '1.0.0', } success: true ``` Parsed value, defaults applied: `{"id":"com.vendor.plugin","defaultDatasource":"default","version":"1.0.0","type":"plugin","scope":"project","name":"Vendor Plugin"}` ### Controls — each one had to fire, and each did | candidate | verdict | |:--|:--| | chosen manifest | accepted | | chosen minus `id` | `invalid_type` at `[manifest, id]` | | chosen plus `label` | `unrecognized_keys` at `[manifest]` | | chosen with `type: 'vendor'` | `invalid_value` at `[manifest, type]` | ## `label` at the ROOT — what the schema actually declares Read off the schema itself, not off a neighbour: `ManifestSchema` declares **25** root keys and `label` is **not** one of them. The required-and-absent set, taken from `ManifestSchema.safeParse({})`, is `id name type version`. `name` — already present in the old example — is declared as the *human-readable* package name, so the old `label: 'Vendor Plugin'` value moves to `name`, and the machine identifier becomes the reverse-domain `id` that key documents. The retired `{ id, label, path }` shape carrying a `label` near this surface is `manifest.contributes.themes`, a **nested** block removed in v17.x — a sibling of the root, never the root itself. A root `label` was never declared and was therefore never retired: it gets the strict close's plain unrecognised-key refusal, with the surface's own history text attached. ## Why `type: 'plugin'` The closed set, quoted from the refusal the schema itself emits: `"plugin" | "ui" | "driver" | "server" | "app" | "theme" | "agent" | "objectql" | "module" | "gateway" | "adapter"`. `plugin` is the schema's own "general-purpose functionality extension", and it is what this example's subject already says it is — the literal was named `vendor_plugin`/`Vendor Plugin` and the line beneath it enables a plugin id. `app` is the consumer-installable business bundle (ADR-0019, the one user-visible noun a tenant browses and installs) and would change what the example teaches; `gateway` is marked deprecated in the enum's own docblock. Not the first member that parses — the member the example means. ## Duplicate copies — reported, NOT silently folded in Swept the tree for the same example and for the same defect class. * **The same literal: nowhere else.** `vendor_plugin` and `Vendor Plugin` each occur exactly once in the repository, both in this README. * **Same defect class, different literal, ⛔ NOT in this PR:** `content/docs/api/client-sdk.mdx` line 337 — `await client.packages.install({ id: 'com.objectstack.plugin-auth', version: '1.0.0' });` — refused on **two** counts, `invalid_value` at `[manifest, type]` and `invalid_type` at `[manifest, name]`. It is a hand-written page (it is listed in `scripts/docs-audit/handwritten-docs.json`). Left for a ruling rather than silently widened or silently left broken. * **Not a defect:** `packages/client/src/return-type-precision.test.ts` carries the same two-count shape, but as a `expectTypeOf` pin against a parameter typed `any`; nothing parses it and it is not published material. * **Not a duplicate:** the many `label:` keys under `content/docs/references/api/` belong to *sibling* collections (`apps`, `pages`, `flows`, `agents`, …) which each declare `label` legitimately. That is the root-versus-sibling distinction, confirmed rather than assumed. * `packages/client/CHANGELOG.md` also quotes an install call. Release-owned — untouched, by rule. ## Is there already a pin? Measured, with a control **No — and the two nearest instruments are provably blind to this class.** `scripts/measure-markdown-ts-blocks.mjs` compiles the fenced TypeScript blocks of package-root Markdown, which is exactly this file. Run on this README before and after the fix, its per-block diagnostic sets are **byte-identical**: ``` fixed : [{"i":0,"tf":true,"codes":"2307"},{"i":1,"tf":true,"codes":"2304,18046,18046,18046,18046,18046"},{"i":2,"tf":false,"codes":"2304"},{"i":3,"tf":true,"codes":"2304,2304,2304,2304,2552,2552,18004,2304"}] broken : [{"i":0,"tf":true,"codes":"2307"},{"i":1,"tf":true,"codes":"2304,18046,18046,18046,18046,18046"},{"i":2,"tf":false,"codes":"2304"},{"i":3,"tf":true,"codes":"2304,2304,2304,2304,2552,2552,18004,2304"}] IDENTICAL: true ``` That is a **lit** instrument, not a dark one: it reports live diagnostics on other blocks of the same file in both runs. It cannot see this defect because `install` declares its first parameter `any` (`packages/client/src/index.ts`, `install: async (manifest: any, …)`), so `tsc` has nothing to check. It is also a census by its own header, wired to no CI job. `check:published-readme-exports` has the right population but reads fenced blocks for **imported symbols** and has no notion of a schema. `check:skill-examples` type-checks marked prose blocks on a `client SDK` surface, but `packages/client/README.md` is not in its population at all, and the `client.packages.install` block on the one SDK docs page that *is* carries no check marker. So this PR adds the pin: `packages/client/src/readme-package-install-example.test.ts` extracts every `packages.install` manifest literal from this README with the TypeScript AST and parses it against `PackageInstallRequestSchema`. It refuses to pass on an empty corpus, and it throws rather than skipping on an object member it cannot model. ### Ablation — the pin can fail, proven from the committed state Restored the original broken literal, proved the mutation on disk (injected text count 1, removed text count 0, blob `93c5186941…` distinct from `HEAD` blob `59ea1c0638…`), ran the pin: ``` × README line 246 is accepted by PackageInstallRequestSchema → invalid_type at [manifest, id]; invalid_value at [manifest, type]; unrecognized_keys at [manifest] Test Files 1 failed (1) Tests 1 failed | 1 passed (2) ``` The `1 passed` is the anti-vacuity floor still finding the corpus, so the red is about the manifest and not about a lost anchor. Restored with `git checkout HEAD -- packages/client/README.md`; the restored blob hashes back to `59ea1c0638…` and `git diff HEAD` plus `git status --porcelain` are both empty. ## Verification | run | verdict | |:--|:--| | `pnpm --filter @objectstack/client test` | exit 0 — 45 files, 537 tests passed | | `pnpm --filter @objectstack/client typecheck` | exit 0 — test layer compiles, 0 errors, 0 pinned signatures | | `pnpm --filter '@objectstack/client^...' build` | exit 0 — dependency closure, 103 build successes | | dispatch-gates families | 58 derived, 58 run, **0 NOT-MEASURED, 0 UNRUN** (reconciled with exit codes via `--ran`) | | eslint, narrowed | exit 0 — 1 file linted, 0 errors, 0 warnings | Two gate families first answered PREREQUISITE NOT MET on an unbuilt checkout (`check:skill-examples`, `check:dual-build-cjs-loads`); both exit 0 after `pnpm build`. Readings taken at `a37583f9c1`. **The eslint narrowing is a measurement, not a skip.** Population, read from `eslint.config.mjs` itself: files matching `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus the config's five global ignores — so the two Markdown files in this diff are outside eslint's population by the config's own selectors. Count, read from `--format json`: 1 file. Invariance, quoted from that config: *"this repo runs one `eslint.config.mjs`, which never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file, test or not"* — so this diff cannot move the verdict on any untouched file. ## Changeset `patch` on `@objectstack/client`, measured rather than assumed: the package's `files[]` is `["dist", "README.md", "CHANGELOG.md"]` and the package is not private, so this README **is** published output and `skip-changeset` — the label for a diff that publishes nothing from any released package — does not apply. Clause-②: no No schema key, no closed-set member, no published export and no registry entry is added or narrowed; the only TypeScript this PR adds is a test. ## Acceptance notes * Triage placed an **ordering constraint** on this card: it must land before, or together with, #18604 / #18606 — closing that door without this repair turns a silently-wrong published example into a loudly-broken one for every reader who copied it. Noted for the landing seat; not something this PR can enforce. * The claim comment on the card carries **no** `Clause-②` declaration — read with the repo's own `readClause2Line`, which returns `null` for it. The declaration above is re-derived from this diff rather than inherited, and the divergence is reported rather than quietly resolved. * `noted, not filed:` the next line of the same example enables `'plugin-id'` rather than the manifest id just installed. Cosmetic only — `enable` takes any string and no contract is violated. No other PR or person is queued to touch this file, so: successor — none. * `noted, not filed:` this README already carries three unrelated tolerant-fail TypeScript blocks under the markdown census (TS2307 unpublished subpath, TS18046, TS18004). Pre-existing, unchanged by this PR, and outside this card. --- _Generated by [Claude Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_ --- ### Two corrections added by the dispatching seat (#6024) at landing The deliverer **reported** both of these rather than silently patching its own body, which is the right call — a body `PATCH` was outside its declared write budget. Appending rather than rewriting its text, so the record shows what was written when. **1. The Acceptance notes say this card's claim comment carries no `Clause-②` declaration.** That was true of comment `5719922216` — the one the dispatch order named — but ⛔ it is no longer the governing claim. The seat later posted a **shape-corrected** claim, `5720097817`, which carries `Claim:`, a separate `Branch:` directive line, and `Clause-②: no`; by the repo's own selection rule (newest claim parsing a branch) that one governs. The deliverer's re-derivation from the measured diff is also `no`, so there is **no divergence**: `check-clause2-carriers --pair 18771` exits **0** with both carriers agreeing. **2. The "duplicate copies" section predates the shape-based sweep and names only `client-sdk.mdx`.** The completed sweep — **1549** files, a predicate about the *manifest shape* rather than the call name, literals extracted with the TypeScript AST, and an unmodellable member throwing rather than being skipped (0 throws) — found **six** further live sites of the same defect class, not one: - `content/docs/api/client-sdk.mdx:337` — one more `packages.install` example, refused on 2 counts; - **five** `defineStack({ manifest: … })` snippets — `kernel/services-checklist.mdx:473`, `permissions/authorization.mdx:176`, `permissions/capabilities.mdx:103`, `permissions/record-view-auditing.mdx:86`, `docs/design/marketplace-publishing.md:398`. ⭐ The sweep's headline result is unchanged and is the one that matters for this PR: **the exact broken example is duplicated nowhere** — zero root-level `label` keys across 41 judged manifest literals — and **7** further literals refuse only because they are *declared* partials, correctly excluded as non-findings. ⛔ This PR was **not** widened into any of the six. They are filed separately and deliberately as **two** cards, because their remedies differ and one card would let the easy half carry the hard half: **#18776** (the install example, one obviously-correct repair) and **#18777** (the five `defineStack` snippets, whose remedy is a docs-convention ruling — complete the manifest, or mark it elided as their 7 siblings do). ⚠️ Landing-order constraint from triage comment `5716545361`, checked by the seat before arming: this card must land **before or with** #18604 / #18606. Both are still `pm:queue`, open and unassigned ⇒ landing now satisfies it. --- _Generated by [Claude Code](https://claude.ai/code)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent d9ba33d commit be7763a

3 files changed

Lines changed: 200 additions & 2 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/client": patch
3+
---
4+
5+
docs(client): the published README's `packages.install` example is a manifest `ManifestSchema` actually accepts (#18607)
6+
7+
The example shipped in the `@objectstack/client` npm tarball was refused on three
8+
counts when parsed against the contract its own call site declares
9+
(`PackageInstallRequestSchema`, whose `manifest` key is `ManifestSchema`):
10+
`invalid_type` at `[manifest, id]`, `invalid_value` at `[manifest, type]` — both
11+
required and absent — and `unrecognized_keys` at `[manifest]` for a `label` key
12+
that `ManifestSchema`'s `strictObject` close refuses by name.
13+
14+
```diff
15+
await client.packages.install({
16+
- name: 'vendor_plugin',
17+
- label: 'Vendor Plugin',
18+
+ id: 'com.vendor.plugin',
19+
+ type: 'plugin',
20+
+ name: 'Vendor Plugin',
21+
version: '1.0.0',
22+
});
23+
```
24+
25+
`label` is not a root manifest key and never was: the root shape declares `name`
26+
for the human-readable string (measured — `ManifestSchema` declares 25 root keys
27+
and `label` is not among them), so the example's `label` value moves to `name`
28+
and the machine identifier becomes the reverse-domain `id` the key documents.
29+
`type: 'plugin'` is the enum member the example's own subject names — a
30+
general-purpose functionality extension, not the consumer-installable `app`
31+
bundle. Required root keys, read off the schema rather than the prose: `id`,
32+
`name`, `type`, `version`.
33+
34+
Nothing parses that contract at the install door today, so the example "worked"
35+
by being posted unvalidated — which is what made it a timed charge rather than a
36+
live outage: closing the door turns a silently-wrong published example into a
37+
loudly-broken one for every reader who copied it.
38+
39+
Pinned in `packages/client/src/readme-package-install-example.test.ts`, which
40+
parses every `packages.install` manifest literal in this README against that
41+
schema and fails if the corpus is ever empty.
42+
43+
Clause-②: no
44+
45+
No schema, export, type or runtime behaviour changes. It ships because the README
46+
is listed in this package's `files[]` and is the first thing a new integrator
47+
copies.

‎packages/client/README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,9 @@ await client.auth.refreshToken('refresh-token-string');
245245
// Package Management
246246
await client.packages.list();
247247
await client.packages.install({
248-
name: 'vendor_plugin',
249-
label: 'Vendor Plugin',
248+
id: 'com.vendor.plugin',
249+
type: 'plugin',
250+
name: 'Vendor Plugin',
250251
version: '1.0.0',
251252
});
252253
await client.packages.enable('plugin-id');
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#18607] Every `client.packages.install(<manifest>)` example in THIS
5+
* package's PUBLISHED README is parsed against the contract that door
6+
* declares — `PackageInstallRequestSchema`, whose `manifest` key is
7+
* `ManifestSchema`.
8+
*
9+
* ## The defect it exists to prevent
10+
*
11+
* The README shipped this manifest in the npm tarball:
12+
*
13+
* await client.packages.install({
14+
* name: 'vendor_plugin',
15+
* label: 'Vendor Plugin',
16+
* version: '1.0.0',
17+
* });
18+
*
19+
* Parsed against the declared contract it is refused on THREE counts:
20+
* `invalid_type` at `[manifest, id]`, `invalid_value` at `[manifest, type]`
21+
* (both keys are required and absent), and `unrecognized_keys` at
22+
* `[manifest]` for `label` — a key `ManifestSchema`'s `strictObject` close
23+
* refuses BY NAME. `label` is not a root manifest key and never was: the
24+
* root shape declares `name` for the human-readable string, and the only
25+
* `label` anywhere near this surface belonged to the nested, since-RETIRED
26+
* `contributes.themes` `{ id, label, path }` entry — a sibling shape, not
27+
* this one.
28+
*
29+
* Nothing parses the contract at that door today, so the example "worked":
30+
* the SDK posts whatever literal it is handed, and `install(manifest: any)`
31+
* type-checks it away. That is what made this a timed charge rather than a
32+
* live outage — closing the door turns a silently-wrong published example
33+
* into a loudly-broken one for every reader who copied it.
34+
*
35+
* ## Why a pin, and why this one CAN fail
36+
*
37+
* Nothing else reads these literals. `check:published-readme-exports` has
38+
* the right population but reads fenced blocks for IMPORTED SYMBOLS and has
39+
* no notion of a schema; no gate parses an example payload against the
40+
* schema its own call site declares. Restore any of the three original
41+
* defects and this test reds on that specific issue code.
42+
*
43+
* Two shapes deliberately fail rather than pass quietly, because a pin that
44+
* measures nothing is worse than none (Route & surface ownership §3):
45+
* the corpus going EMPTY (the anchor renamed, the fence relabelled) and a
46+
* literal carrying a node kind the reader does not model.
47+
*/
48+
49+
import { readFileSync } from 'node:fs';
50+
import { fileURLToPath } from 'node:url';
51+
52+
import { PackageInstallRequestSchema } from '@objectstack/spec/api';
53+
import ts from 'typescript';
54+
import { describe, expect, it } from 'vitest';
55+
56+
/** This package's own published README — inside the package, no escape. */
57+
const README = fileURLToPath(new URL('../README.md', import.meta.url));
58+
59+
/** The call whose first argument IS the manifest. */
60+
const INSTALL_CALL = 'packages.install';
61+
62+
/** ```ts / ```typescript fences — the only regions read as code. */
63+
const TS_FENCE = /^```(?:ts|typescript)\s*$\n([\s\S]*?)^```\s*$/gm;
64+
65+
/**
66+
* An object literal, as a value. ⛔ Never a partial read: an unmodelled node
67+
* kind throws, because a manifest quietly missing the key that carried the
68+
* defect would parse green and pin nothing.
69+
*/
70+
function literalToValue(node: ts.Expression): unknown {
71+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
72+
if (ts.isNumericLiteral(node)) return Number(node.text);
73+
if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
74+
if (node.kind === ts.SyntaxKind.FalseKeyword) return false;
75+
if (node.kind === ts.SyntaxKind.NullKeyword) return null;
76+
if (ts.isArrayLiteralExpression(node)) return node.elements.map(literalToValue);
77+
if (ts.isObjectLiteralExpression(node)) {
78+
const out: Record<string, unknown> = {};
79+
for (const prop of node.properties) {
80+
if (!ts.isPropertyAssignment(prop)) {
81+
throw new Error(`Unmodelled object member in a README manifest: ${ts.SyntaxKind[prop.kind]}`);
82+
}
83+
const key = ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)
84+
? prop.name.text
85+
: undefined;
86+
if (key === undefined) {
87+
throw new Error(`Unmodelled property name in a README manifest: ${ts.SyntaxKind[prop.name.kind]}`);
88+
}
89+
out[key] = literalToValue(prop.initializer);
90+
}
91+
return out;
92+
}
93+
throw new Error(`Unmodelled expression in a README manifest: ${ts.SyntaxKind[node.kind]}`);
94+
}
95+
96+
interface InstallExample {
97+
/** 1-based line of the call inside the README, for the failure message. */
98+
readonly line: number;
99+
readonly manifest: unknown;
100+
readonly source: string;
101+
}
102+
103+
function collectInstallExamples(readme: string): InstallExample[] {
104+
const found: InstallExample[] = [];
105+
for (const fence of readme.matchAll(TS_FENCE)) {
106+
const code = fence[1] ?? '';
107+
const fenceLine = readme.slice(0, fence.index ?? 0).split('\n').length;
108+
const sourceFile = ts.createSourceFile('readme-fence.ts', code, ts.ScriptTarget.Latest, true);
109+
const visit = (node: ts.Node): void => {
110+
if (
111+
ts.isCallExpression(node)
112+
&& node.expression.getText(sourceFile).endsWith(INSTALL_CALL)
113+
&& node.arguments.length > 0
114+
) {
115+
const [first] = node.arguments;
116+
if (first !== undefined && ts.isObjectLiteralExpression(first)) {
117+
found.push({
118+
line: fenceLine + sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line,
119+
manifest: literalToValue(first),
120+
source: first.getText(sourceFile),
121+
});
122+
}
123+
}
124+
ts.forEachChild(node, visit);
125+
};
126+
visit(sourceFile);
127+
}
128+
return found;
129+
}
130+
131+
const EXAMPLES = collectInstallExamples(readFileSync(README, 'utf8'));
132+
133+
describe('published README — packages.install examples parse as manifests', () => {
134+
it('finds at least one `packages.install` manifest literal to judge', () => {
135+
// Anti-vacuity floor. An empty corpus means the anchor moved, not that
136+
// every example is correct.
137+
expect(EXAMPLES.length).toBeGreaterThan(0);
138+
});
139+
140+
it.each(EXAMPLES.map((e) => [e.line, e] as const))(
141+
'README line %i is accepted by PackageInstallRequestSchema',
142+
(_line, example) => {
143+
const result = PackageInstallRequestSchema.safeParse({ manifest: example.manifest });
144+
const refusals = result.success
145+
? []
146+
: result.error.issues.map((issue) => `${issue.code} at [${issue.path.join(', ')}]`);
147+
expect(refusals, `${example.source}\n→ ${refusals.join('; ')}`).toEqual([]);
148+
},
149+
);
150+
});

0 commit comments

Comments
 (0)