diff --git a/.changeset/31035-template-manifest-inert-keys-retired.md b/.changeset/31035-template-manifest-inert-keys-retired.md new file mode 100644 index 00000000000..37fae3c3efe --- /dev/null +++ b/.changeset/31035-template-manifest-inert-keys-retired.md @@ -0,0 +1,25 @@ +--- +'@objectstack/spec': minor +'@objectstack/cli': minor +'create-objectstack': patch +--- + +feat(spec)!: retire five `objectstack.manifest.json` keys nothing read — `name`, `specVersion`, `skills`, `scaffold` and `visibility` (#31035) + +Clause-②: no (narrowing) — five authorable keys of `TemplateManifestSchema` become tombstones, and `os package publish` refuses the file's `visibility` by name +Metadata-protocol: narrowed + + + +**BREAKING** for an `objectstack.manifest.json` that still writes any of the five keys, and for `os package publish` run on a manifest that declares `visibility`. + +- **Retired (ADR-0049 enforce-or-remove).** `TemplateManifest.name`, `.specVersion`, `.skills`, `.scaffold` and `.visibility` are `retiredKey()` tombstones: `tsc` refuses them and a parse of `TemplateManifestSchema` refuses each with its prescription. None ever had an effect: no reader of the file's `name`, `specVersion`, `skills` or `scaffold` existed, and `os package publish` read `visibility` only to warn that it ignores it. `RETIRED_KEYS_BY_MAJOR[18]` lists the five keys and the upgrade guide carries the semantic entry `marketplace-template-manifest-inert-keys-retired`. No ADR-0087 conversion: this file is not a stack collection member, so `os migrate meta` has nothing to rewrite; a runtime boot never reads it. The publish request's own `visibility` (`CreatePackageRequestSchema`) is unchanged. +- **Migration** — FROM → TO, in `objectstack.manifest.json`: + - `name` → delete it. The project slug is `package.json` `name`; the published package id is `manifestId` (or the one `os package publish` derives from the compiled artifact). + - `specVersion` → delete it. The protocol your package needs is `manifest.engines.protocol` in `objectstack.config.ts`; the spec release it builds against is its `@objectstack/spec` dependency. + - `skills` → delete it. create-objectstack installs the whole skills catalog. + - `scaffold` → delete it. Nothing prompted for `scaffold.variables` or ran `scaffold.postInstall`. + - `visibility` → delete it and pass `os package publish --visibility `. +- **`@objectstack/cli`.** `os package publish` refuses a manifest declaring `visibility` before any network call, naming `--visibility`. It names the other four in a warning for deletion and still publishes, because every project scaffolded from the blank template before this release carries them. +- **`create-objectstack`.** The blank template's manifest drops the five keys it wrote (`name`, `specVersion`, `skills`, `scaffold`), and scaffolding strips every retired key (`category` and these five, one list pinned to the spec's registry rows) from the template's manifest instead of writing `name`, so a template authored before this release still scaffolds a manifest the schema accepts. The release version pass (`scripts/sync-template-versions.mjs`) no longer stamps `specVersion`. +- **Not retired here:** `publisher` and `isStarter`. A first-party publish script sends both to the hosted packages route, so their fate is a maintainer decision. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index d89853bea0d..d165a218868 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -3307,7 +3307,9 @@ checked against the spec's schema before anything is sent, and a declared `solutionType` against the built artifact: a `template` needs the `unmanaged` install mode, a `connector` a declared connector. The retired `category` — in the file, or as `--category` — is refused before any network call, with the -facets that replace the value it held. `tags` has no flag and is +facets that replace the value it held, and so is a `visibility` in the file +(pass `--visibility`). The retired `name`, `specVersion`, `skills` and +`scaffold`, which nothing ever read, are named in a warning for deletion. `tags` has no flag and is sent as written: the control plane judges the list, and a refusal is its 400, printed as it is. A top-level `tagline` is used only as the description, when neither `--description` nor the file's `description` gives one; otherwise it is diff --git a/content/docs/deployment/marketplace-handbook.mdx b/content/docs/deployment/marketplace-handbook.mdx index cc7e94d33bd..43cae4b72a5 100644 --- a/content/docs/deployment/marketplace-handbook.mdx +++ b/content/docs/deployment/marketplace-handbook.mdx @@ -96,6 +96,9 @@ with no image to caption. The schema also accepts `publisher` and `isStarter`, but the platform decides both: an author's value has no effect, so `os package publish` never sends them and prints a warning naming each one the file declares. `tags` is different: it is sent as written (see the table above). +The file no longer accepts `visibility` (pass `--visibility`; publish refuses the +key by name) nor `name`, `specVersion`, `skills` or `scaffold`, which nothing +ever read: publish names each one still present so you can delete it. Put the icon, the description, the tags, the tagline and the README where the table above says, and nothing else. @@ -139,10 +142,11 @@ npx os verify # boots the app in-process and proves it through the real H npm run build # os build -> dist/objectstack.json ``` -Two declarations name the platform major your app is authored against: -`engines.protocol` in the `defineStack` manifest, and `specVersion` in -`objectstack.manifest.json`. A runtime outside the `engines.protocol` range -refuses the app rather than half-loading it. See +One declaration names the platform major your app is authored against: +`engines.protocol` in the `defineStack` manifest. A runtime outside that range +refuses the app rather than half-loading it. (The `specVersion` an older +scaffold wrote into `objectstack.manifest.json` was never read, and spec 18 +retired it: delete it.) See [Validating metadata](/docs/deployment/validating-metadata) for what the checks cover. @@ -261,8 +265,8 @@ loaded, and the app's page shows **Not loaded** with the protocol range it targets. To move, run `os migrate meta --from ` on your -source, apply what it lists, update `engines.protocol` and `specVersion`, -rebuild, and publish a new version. [Upgrading](/docs/upgrading#the-metadata-app) +source, apply what it lists, update `engines.protocol`, rebuild, and publish a +new version. [Upgrading](/docs/upgrading#the-metadata-app) owns the loop. ## Extras: dependencies and modules diff --git a/content/docs/getting-started/your-first-project.mdx b/content/docs/getting-started/your-first-project.mdx index a897b459605..558198e4a3b 100644 --- a/content/docs/getting-started/your-first-project.mdx +++ b/content/docs/getting-started/your-first-project.mdx @@ -104,7 +104,7 @@ between all four entry points by the artifact you want rather than by the word. ``` my-app/ ├── objectstack.config.ts # defineStack() — the single entry point -├── objectstack.manifest.json # name, namespace, specVersion +├── objectstack.manifest.json # displayName, namespace — the package listing ├── package.json ├── tsconfig.json ├── note.test.ts # the starter test `npm test` runs diff --git a/content/docs/references/marketplace/template-manifest.mdx b/content/docs/references/marketplace/template-manifest.mdx index 1f520065111..734b07a0cfb 100644 --- a/content/docs/references/marketplace/template-manifest.mdx +++ b/content/docs/references/marketplace/template-manifest.mdx @@ -8,8 +8,8 @@ description: "objectstack.manifest.json — on-disk descriptor for a template / `objectstack.manifest.json` — on-disk descriptor for a template / package source tree. Strict projection of `CreatePackageRequestSchema` (server- -managed fields excluded) plus scaffold-time extras (name slug, -specVersion, namespace, skills, preview, scaffold, readmePath), with +managed fields excluded) plus scaffold-time extras (namespace, preview, +readmePath), with `manifestId` locally relaxed to OPTIONAL — this file is a source tree, not a publish request (#7319; the field's own TSDoc carries the measurement). @@ -44,7 +44,7 @@ objectstack.manifest.json — template / package source descriptor | :--- | :--- | :--- | :--- | | **displayName** | `string` | ✅ | Display name shown in Studio and Marketplace | | **description** | `string` | optional | Short package description | -| **visibility** | `Enum<'private' \| 'org' \| 'marketplace'>` | optional | Package visibility: private = owner org only; org = all envs in owner org; marketplace = public registry | +| **visibility** | `never` | optional | [REMOVED] `TemplateManifest.visibility` was removed in @objectstack/spec 18 (ADR-0049) — `os package publish` never read it from this file. Delete the key and pass `--visibility` (`private`, `org` or `marketplace`) to `os package publish`. | | **category** | `never` | optional | [REMOVED] `TemplateManifest.category` was removed in @objectstack/spec 18 (ADR-0087) — one category had to say both who a package is for and what it is, and the platform and the cloud catalog spelled it from lists that disagreed. Delete the key. Declare `businessFunctions` (1-3 business functions, the primary one first), optionally `industries` (up to 3; absent means cross-industry) and `solutionType` (`app`, `template` or `connector`; absent means `app`). `LEGACY_CATEGORY_MAPPING` in @objectstack/spec/marketplace gives the facets for every old category value. | | **businessFunctions** | `Enum<'sales' \| 'marketing' \| 'customer-service' \| 'finance' \| 'hr' \| 'supply-chain' \| 'administration' \| 'project' \| 'legal' \| 'it' \| 'collaboration'>[]` | optional | Business functions the package serves: 1-3 distinct values, the first is primary | | **industries** | `Enum<'manufacturing' \| 'trade-distribution' \| 'retail' \| 'logistics' \| 'professional-services' \| 'financial-services' \| 'healthcare' \| 'education' \| 'public-sector' \| 'real-estate-construction'>[]` | optional | Industries the package is built for: up to 3 distinct values; absent means cross-industry | @@ -58,12 +58,12 @@ objectstack.manifest.json — template / package source descriptor | **isStarter** | `boolean` | optional | | | **translations** | `Record` | optional | Locale-keyed overrides; missing keys fall back to base columns | | **manifestId** | `string` | optional | Optional declarative default for the published package id (reverse-domain, e.g. com.acme.crm). Absent on a template source tree: `objectstack package publish` falls back to --manifest-id and then to a derived `local.`. NOT optional on the publish request itself | -| **name** | `string` | ✅ | CLI slug (kebab-case, no namespace prefix) | -| **specVersion** | `string` | ✅ | Compatible @objectstack/spec semver range | +| **name** | `never` | optional | [REMOVED] `TemplateManifest.name` was removed in @objectstack/spec 18 (ADR-0049) — nothing read it: `os package publish` addresses the package by `manifestId` (or derives one from the compiled artifact), and a project's slug is its `package.json` `name`. Delete the key. | +| **specVersion** | `never` | optional | [REMOVED] `TemplateManifest.specVersion` was removed in @objectstack/spec 18 (ADR-0049) — nothing read it. Delete the key. The protocol a package needs is `manifest.engines.protocol` in `objectstack.config.ts` (the handshake the runtime checks), and the spec release it builds against is the `@objectstack/spec` version in `package.json`. | | **namespace** | `string` | optional | Scaffold-only: the template’s own metadata namespace, rewritten by create-objectstack at scaffold time and read back as the fallback source for the template’s original namespace. NOT the publish namespace — publish reads that off the compiled artifact’s manifest.namespace (ADR-0048 addendum §A.2) | -| **skills** | `string[]` | optional | Skill ids exercised by this template (for docs / picker) | +| **skills** | `never` | optional | [REMOVED] `TemplateManifest.skills` was removed in @objectstack/spec 18 (ADR-0049) — nothing read it: create-objectstack installs the whole `@objectstack/skills` catalog whatever this file lists. Delete the key. | | **preview** | `{ screenshots?: string[]; demoUrl?: string }` | optional | | -| **scaffold** | `{ variables?: Record; postInstall?: string[] }` | optional | | +| **scaffold** | `never` | optional | [REMOVED] `TemplateManifest.scaffold` was removed in @objectstack/spec 18 (ADR-0049) — nothing read it: create-objectstack never prompted for `scaffold.variables` nor ran `scaffold.postInstall`. Delete the key. | | **readmePath** | `string` | optional | Path (relative to manifest) to long-form README | ### Nested Shape: `TemplateManifest.translations[string]` diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 29277a34273..bb9e3fb508f 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -83,11 +83,12 @@ command for this move; it is four steps you run yourself. what older scaffolds wrote) moves entirely here. Read whatever peer-dependency warnings this prints — the `--frozen-lockfile` install your CI runs skips resolution and will never print them. -3. **Check the two protocol declarations your app ships** — `specVersion` in - `objectstack.manifest.json`, and `engines.protocol` in your `defineStack` - manifest. Both name the protocol **major**, so a move inside one major leaves - both alone, and a major boundary is where they change. Move them deliberately - when you cross one; never edit them to silence a mismatch. +3. **Check the protocol declaration your app ships** — `engines.protocol` in + your `defineStack` manifest. It names the protocol **major**, so a move inside + one major leaves it alone, and a major boundary is where it changes. Move it + deliberately when you cross one; never edit it to silence a mismatch. (An + older scaffold also wrote `specVersion` into `objectstack.manifest.json`. + Nothing ever read it, and spec 18 retired it: delete it.) 4. **Re-install the AI skills from the moved catalog, and commit them.** The skills your coding agent reads come from the `@objectstack/skills` dev dependency, which moves only with the other `@objectstack/*` packages — in diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/marketplace.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/marketplace.md index d45dc313179..67709f1623c 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/marketplace.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/marketplace.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `marketplace/` | 31 | +| `marketplace/` | 30 | diff --git a/packages/apps/connector-docusign/objectstack.manifest.json b/packages/apps/connector-docusign/objectstack.manifest.json index 9029bad1725..665a942a862 100644 --- a/packages/apps/connector-docusign/objectstack.manifest.json +++ b/packages/apps/connector-docusign/objectstack.manifest.json @@ -1,8 +1,6 @@ { "$schema": "https://schemas.objectstack.dev/template-manifest.json", "manifestId": "app.objectstack.connector-docusign", - "name": "connector-docusign", - "specVersion": "^18.0.0", "displayName": "DocuSign eSignature", "description": "Send documents for signature, read envelopes and follow their signature status from flows, over DocuSign's eSignature OpenAPI document. Developer sandbox; your administrator registers their own DocuSign integration key.", "businessFunctions": ["collaboration", "legal"], diff --git a/packages/apps/connector-github/objectstack.manifest.json b/packages/apps/connector-github/objectstack.manifest.json index cb7058d6652..65c2a06016f 100644 --- a/packages/apps/connector-github/objectstack.manifest.json +++ b/packages/apps/connector-github/objectstack.manifest.json @@ -1,8 +1,6 @@ { "$schema": "https://schemas.objectstack.dev/template-manifest.json", "manifestId": "app.objectstack.connector-github", - "name": "connector-github", - "specVersion": "^18.0.0", "displayName": "GitHub", "description": "Search and read GitHub issues and pull requests from flows, through GitHub's remote MCP server. Read only.", "businessFunctions": ["it"], diff --git a/packages/apps/connector-stripe/objectstack.manifest.json b/packages/apps/connector-stripe/objectstack.manifest.json index 97a06944eef..7cf8cbb3f14 100644 --- a/packages/apps/connector-stripe/objectstack.manifest.json +++ b/packages/apps/connector-stripe/objectstack.manifest.json @@ -1,8 +1,6 @@ { "$schema": "https://schemas.objectstack.dev/template-manifest.json", "manifestId": "app.objectstack.connector-stripe", - "name": "connector-stripe", - "specVersion": "^18.0.0", "displayName": "Stripe", "description": "Read Stripe customers, charges, invoices and payment intents from flows, over Stripe's own OpenAPI document. Read only.", "businessFunctions": ["finance"], diff --git a/packages/cli/src/commands/package/publish.ts b/packages/cli/src/commands/package/publish.ts index 77058e567f5..3820651a56d 100644 --- a/packages/cli/src/commands/package/publish.ts +++ b/packages/cli/src/commands/package/publish.ts @@ -352,11 +352,39 @@ async function tryLoadTemplateManifest( return null; } +/** + * The refusal for a manifest that still declares the retired `visibility` + * (ADR-0049, #31035). This command never read it from the file — the + * `--visibility` flag carries it — so a value written there never took + * effect. Refused, like the retired `category`, before any network call: an + * author who wrote it meant to set the package's visibility, and publishing + * without saying so would hide that it did not. + */ +export function retiredVisibilityRefusal(): string { + return 'objectstack.manifest.json declares `visibility`, which was removed in @objectstack/spec 18: ' + + '`os package publish` never read it from the file. Delete it and pass --visibility (`private`, `org` ' + + 'or `marketplace`) instead.'; +} + +/** + * Retired `objectstack.manifest.json` keys nothing ever read (ADR-0049, + * #31035): a parse of the file refuses each one, so this command names them + * for deletion. A warning, not a refusal: every project scaffolded from the + * blank template before the retirement carries them, and they never changed + * what was published. + */ +export const RETIRED_INERT_MANIFEST_KEYS = ['name', 'specVersion', 'skills', 'scaffold'] as const; + +/** The {@link RETIRED_INERT_MANIFEST_KEYS} that `manifest` declares, in list order. */ +export function retiredInertManifestKeys(manifest: Record): string[] { + return RETIRED_INERT_MANIFEST_KEYS.filter((key) => manifest[key] !== undefined); +} + /** * Keys `objectstack.manifest.json` accepts that this command does NOT carry to - * the control plane. `publisher`, `isStarter` and `visibility` are inherited - * from `CreatePackageRequestSchema`; `preview` is a scaffold-side extra - * (publish has no screenshot upload path). + * the control plane. `publisher` and `isStarter` are inherited from + * `CreatePackageRequestSchema`; `preview` is a scaffold-side extra (publish + * has no screenshot upload path). * * - `publisher` and `isStarter` are {@link PLATFORM_DECIDED_MANIFEST_KEYS}: * the hosted control plane decides both and never reads an author's value @@ -365,7 +393,9 @@ async function tryLoadTemplateManifest( * self-declared in an author's file is a claim only the platform can judge. * - `preview` is not carried by the hosted catalog yet; that is tracked at * {@link CATALOG_SEND_ISSUE}. - * - `visibility` is flag-only on purpose (see the `--visibility` flag). + * + * `visibility` is no longer here: the file's key is retired and refused + * ({@link retiredVisibilityRefusal}); the `--visibility` flag carries it. * * `tags` is not here: the hosted route validates and stores it, so it is sent * (see the request body in `run()`). @@ -379,7 +409,7 @@ async function tryLoadTemplateManifest( * `test/package-publish-uncarried-manifest-keys.test.ts`, so a key the schema * gains later goes red there until publish either sends it or lists it here. */ -export const UNCARRIED_MANIFEST_KEYS = ['publisher', 'isStarter', 'preview', 'visibility'] as const; +export const UNCARRIED_MANIFEST_KEYS = ['publisher', 'isStarter', 'preview'] as const; /** The {@link UNCARRIED_MANIFEST_KEYS} the platform decides, whatever the file says. */ const PLATFORM_DECIDED_MANIFEST_KEYS: readonly string[] = ['publisher', 'isStarter']; @@ -394,15 +424,15 @@ export function uncarriedManifestKeys(manifest: Record): string /** * The warnings for the {@link uncarriedManifestKeys} a manifest declares: one - * for the platform-decided keys (an author's value has no effect), one for the - * catalog keys the hosted side does not carry yet, and one for `visibility` - * (carried by the flag instead). Empty when none. + * for the platform-decided keys (an author's value has no effect), and one for + * the catalog keys the hosted side does not carry yet — then one for the + * {@link retiredInertManifestKeys} it still declares. Empty when none. */ export function uncarriedManifestWarnings(manifest: Record): string[] { const declared = uncarriedManifestKeys(manifest); const named = (keys: string[]) => keys.map((key) => `\`${key}\``).join(', '); const platform = declared.filter((key) => PLATFORM_DECIDED_MANIFEST_KEYS.includes(key)); - const catalog = declared.filter((key) => key !== 'visibility' && !PLATFORM_DECIDED_MANIFEST_KEYS.includes(key)); + const catalog = declared.filter((key) => !PLATFORM_DECIDED_MANIFEST_KEYS.includes(key)); const warnings: string[] = []; if (platform.length > 0) { const one = platform.length === 1; @@ -420,10 +450,13 @@ export function uncarriedManifestWarnings(manifest: Record): st + `, so the published package will not show ${one ? 'it' : 'them'}. See ${CATALOG_SEND_ISSUE}.`, ); } - if (declared.includes('visibility')) { + const retired = retiredInertManifestKeys(manifest); + if (retired.length > 0) { + const one = retired.length === 1; warnings.push( - 'objectstack.manifest.json declares `visibility`, which `os package publish` does not read from ' - + 'the file. Pass --visibility to set it.', + `objectstack.manifest.json declares ${named(retired)}, which ${one ? 'was' : 'were'} removed in ` + + `@objectstack/spec 18: nothing ever read ${one ? 'it' : 'them'}, and a parse of the file now refuses ` + + `${one ? 'it' : 'them'}. Delete ${one ? 'it' : 'them'}.`, ); } return warnings; @@ -663,6 +696,11 @@ export default class PackagePublish extends Command { this.exit(1); return; } + if (m.visibility !== undefined) { + printError(retiredVisibilityRefusal()); + this.exit(1); + return; + } const taxonomy = readPublishTaxonomy(m, artifact); if (!taxonomy.ok) { printError(taxonomy.message); diff --git a/packages/cli/test/package-publish-namespace.test.ts b/packages/cli/test/package-publish-namespace.test.ts index 13b146d6644..69a55d0d326 100644 --- a/packages/cli/test/package-publish-namespace.test.ts +++ b/packages/cli/test/package-publish-namespace.test.ts @@ -132,7 +132,7 @@ describe('os package publish — namespace on the publish payload', () => { const path = await artifactAt({ namespace: 'crm' }); await writeFile( join(dir, 'objectstack.manifest.json'), - JSON.stringify({ name: 'acme-crm', namespace: 'squatted', displayName: 'Acme CRM' }), + JSON.stringify({ namespace: 'squatted', displayName: 'Acme CRM' }), ); process.chdir(dir); const calls = stubCloud(); diff --git a/packages/cli/test/package-publish-taxonomy.test.ts b/packages/cli/test/package-publish-taxonomy.test.ts index 03c77e66038..ce3dd740638 100644 --- a/packages/cli/test/package-publish-taxonomy.test.ts +++ b/packages/cli/test/package-publish-taxonomy.test.ts @@ -86,7 +86,7 @@ describe('os package publish — the marketplace taxonomy', () => { } } - const BASE = { name: 'acme-crm', specVersion: '^18.0.0' }; + const BASE = {}; describe('the retired category', () => { it('refuses a manifest `category` before any network call, naming the facets for its value', async () => { diff --git a/packages/cli/test/package-publish-uncarried-manifest-keys.test.ts b/packages/cli/test/package-publish-uncarried-manifest-keys.test.ts index 65405422ee5..c5529ce3788 100644 --- a/packages/cli/test/package-publish-uncarried-manifest-keys.test.ts +++ b/packages/cli/test/package-publish-uncarried-manifest-keys.test.ts @@ -5,15 +5,17 @@ * hosted catalog stores, and names every key it does not send, instead of * dropping it silently. * - * `TemplateManifestSchema` accepts `tags`, `publisher`, `isStarter`, `preview` - * and `visibility`, so an author (or an AI agent) writes them and the file - * validates. The hosted publish route validates and stores `tags`, so publish + * `TemplateManifestSchema` accepts `tags`, `publisher`, `isStarter` and + * `preview`, so an author (or an AI agent) writes them and the file validates. The hosted publish route validates and stores `tags`, so publish * sends them as written: the server owns the bounds, and its 400 is what the * author reads. `publisher` and `isStarter` are decided by the platform, so an * author's value has no effect and is not sent. `preview` still waits on what * the hosted side accepts (`CATALOG_SEND_ISSUE`). The top-level `tagline` has * no column on the hosted side: publish uses it as the `description` when none - * is given, and otherwise does not send it. + * is given, and otherwise does not send it. The retired `visibility` is refused + * by name (the `--visibility` flag carries it), and the four retired keys + * nothing ever read (`name`, `specVersion`, `skills`, `scaffold`) are named for + * deletion without failing the publish. * * These cases drive the real command and read the request body it sends, pin * that the warning does not fail the publish (the blank template ships @@ -28,7 +30,9 @@ import { join } from 'node:path'; import { TemplateManifestSchema } from '@objectstack/spec/marketplace'; import PackagePublish, { CATALOG_SEND_ISSUE, + RETIRED_INERT_MANIFEST_KEYS, UNCARRIED_MANIFEST_KEYS, + retiredVisibilityRefusal, uncarriedManifestKeys, } from '../src/commands/package/publish.js'; @@ -66,9 +70,9 @@ const SENT = [ * Retired keys the schema still declares as tombstones: publish refuses them, * naming the replacement (`test/package-publish-taxonomy.test.ts`). */ -const REFUSED = ['category'] as const; -/** Scaffold-time extras: read by `create-objectstack` or docs, never a publish key. */ -const SCAFFOLD_ONLY = ['name', 'specVersion', 'namespace', 'skills', 'scaffold'] as const; +const REFUSED = ['category', 'visibility'] as const; +/** Scaffold-time extras: read by `create-objectstack`, never a publish key. */ +const SCAFFOLD_ONLY = ['namespace'] as const; const DECLARES = 'objectstack.manifest.json declares'; @@ -116,7 +120,7 @@ describe('os package publish — manifest keys', () => { describe('tags', () => { it('sends the manifest’s tags as written, and does not warn about them', async () => { - const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', tags: ['crm', 'sales'] }); + const path = await projectWith({ tags: ['crm', 'sales'] }); const calls = stubCloud(); const out = captureOutput(); @@ -129,7 +133,7 @@ describe('os package publish — manifest keys', () => { }); it('sends a declared empty list, which clears the stored tags', async () => { - const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', tags: [] }); + const path = await projectWith({ tags: [] }); const calls = stubCloud(); captureOutput(); @@ -139,7 +143,7 @@ describe('os package publish — manifest keys', () => { }); it('sends no `tags` key when the manifest declares none, so the stored tags are left alone', async () => { - const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0' }); + const path = await projectWith({}); const calls = stubCloud(); captureOutput(); @@ -153,7 +157,7 @@ describe('os package publish — manifest keys', () => { // has no copy of those numbers, so the list reaches the wire unchanged // and the server's 400 is the author's answer. const tags = Array.from({ length: 21 }, (_, i) => `tag-${i}-${'x'.repeat(64)}`); - const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', tags }); + const path = await projectWith({ tags }); const refusal = 'tags: at most 20 tags of at most 64 characters each'; const calls = stubCloud({ status: 400, @@ -177,7 +181,7 @@ describe('os package publish — manifest keys', () => { describe('top-level `tagline`', () => { it('is sent as the `description` when neither --description nor the manifest gives one', async () => { - const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', tagline: ' CRM for small teams ' }); + const path = await projectWith({ tagline: ' CRM for small teams ' }); const calls = stubCloud(); const out = captureOutput(); @@ -190,7 +194,7 @@ describe('os package publish — manifest keys', () => { it('is not sent when the manifest gives a `description`, and a warning names it', async () => { const path = await projectWith({ - name: 'acme-crm', specVersion: '^18.0.0', description: 'Customer records', tagline: 'CRM for small teams', + description: 'Customer records', tagline: 'CRM for small teams', }); const calls = stubCloud(); const out = captureOutput(); @@ -203,7 +207,7 @@ describe('os package publish — manifest keys', () => { }); it('is not sent when --description is passed, and a warning names it', async () => { - const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', tagline: 'CRM for small teams' }); + const path = await projectWith({ tagline: 'CRM for small teams' }); const calls = stubCloud(); const out = captureOutput(); @@ -216,7 +220,7 @@ describe('os package publish — manifest keys', () => { it('leaves the per-locale `translations.*.tagline` as it was', async () => { const path = await projectWith({ - name: 'acme-crm', specVersion: '^18.0.0', translations: { 'zh-CN': { tagline: '小团队的 CRM' } }, + translations: { 'zh-CN': { tagline: '小团队的 CRM' } }, }); const calls = stubCloud(); captureOutput(); @@ -230,7 +234,7 @@ describe('os package publish — manifest keys', () => { describe('keys it does not send', () => { it('names `publisher` and `isStarter` as decided by the platform, and still publishes', async () => { const path = await projectWith({ - name: 'acme-crm', specVersion: '^18.0.0', displayName: 'Acme CRM', publisher: 'community', isStarter: true, + displayName: 'Acme CRM', publisher: 'community', isStarter: true, }); const calls = stubCloud(); const out = captureOutput(); @@ -252,7 +256,7 @@ describe('os package publish — manifest keys', () => { it('names `preview` as not carried by the hosted catalog yet, with the tracking issue', async () => { const path = await projectWith({ - name: 'acme-crm', specVersion: '^18.0.0', preview: { screenshots: ['shot.png'] }, + preview: { screenshots: ['shot.png'] }, }); const calls = stubCloud(); const out = captureOutput(); @@ -268,24 +272,30 @@ describe('os package publish — manifest keys', () => { expect('preview' in calls[0].body).toBe(false); }); - it('names `visibility` separately, with the flag that does carry it', async () => { - const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', visibility: 'marketplace' }); + it('names the retired keys nothing ever read for deletion, and still publishes', async () => { + // Every project scaffolded from the blank template before the retirement + // carries these, so naming them must not fail the publish. + const path = await projectWith({ + name: 'acme-crm', specVersion: '^18.0.0', skills: ['objectstack-data'], scaffold: { variables: {} }, + }); const calls = stubCloud(); const out = captureOutput(); await PackagePublish.run([path]); - expect('visibility' in calls[0].body).toBe(false); + expect(calls).toHaveLength(2); const warnings = warningsIn(out); expect(warnings).toHaveLength(1); - expect(warnings[0]).toContain('`visibility`'); - expect(warnings[0]).toContain('--visibility'); - // `visibility` is not waiting on the hosted catalog: the flag carries it today. + for (const key of RETIRED_INERT_MANIFEST_KEYS) { + expect(warnings[0]).toContain(`\`${key}\``); + expect(key in calls[0].body).toBe(false); + } + expect(warnings[0]).toContain('removed in @objectstack/spec 18'); expect(warnings[0]).not.toContain(CATALOG_SEND_ISSUE); }); it('stays quiet when the manifest declares only keys it sends', async () => { - const path = await projectWith({ name: 'acme-crm', specVersion: '^18.0.0', displayName: 'Acme CRM', tags: ['crm'] }); + const path = await projectWith({ displayName: 'Acme CRM', tags: ['crm'] }); stubCloud(); const out = captureOutput(); @@ -294,12 +304,46 @@ describe('os package publish — manifest keys', () => { expect(warningsIn(out)).toEqual([]); }); }); + + describe('the retired `visibility`', () => { + it('is refused by name before any network call, naming the flag that carries it', async () => { + const path = await projectWith({ displayName: 'Acme CRM', visibility: 'marketplace' }); + const calls = stubCloud(); + const out = captureOutput(); + + let exitCode: number | undefined; + try { + await PackagePublish.run([path]); + } catch (err: any) { + exitCode = err?.oclif?.exit ?? err?.exitCode; + } + + expect(exitCode).toBe(1); + expect(calls).toHaveLength(0); + const printed = out.join('\n'); + expect(printed).toContain(retiredVisibilityRefusal()); + expect(printed).toContain('--visibility'); + }); + + it('control: the --visibility flag alone still sets it, with no refusal', async () => { + const path = await projectWith({ displayName: 'Acme CRM' }); + const calls = stubCloud(); + captureOutput(); + + await PackagePublish.run([path, '--visibility', 'marketplace']); + + expect(calls).toHaveLength(2); + expect(calls[0].body.visibility).toBe('marketplace'); + }); + }); }); -describe('every TemplateManifestSchema key is sent, warned about, refused, or scaffold-only', () => { +describe('every TemplateManifestSchema key is sent, warned about, refused, retired, or scaffold-only', () => { it('partitions the schema keys exactly', () => { const schemaKeys = Object.keys(TemplateManifestSchema.shape).sort(); - const classified = [...SENT, ...UNCARRIED_MANIFEST_KEYS, ...REFUSED, ...SCAFFOLD_ONLY].sort(); + const classified = [ + ...SENT, ...UNCARRIED_MANIFEST_KEYS, ...REFUSED, ...SCAFFOLD_ONLY, ...RETIRED_INERT_MANIFEST_KEYS, + ].sort(); expect(classified).toEqual(schemaKeys); // No key sits in two buckets. expect(new Set(classified).size).toBe(classified.length); @@ -308,8 +352,16 @@ describe('every TemplateManifestSchema key is sent, warned about, refused, or sc it('warns only about keys the schema accepts', () => { // A warned key the schema rejects would never reach publish; the list // would then be naming a key no valid file can carry. - const fixture = { name: 'acme-crm', specVersion: '^18.0.0', displayName: 'Acme CRM', tags: ['crm'], publisher: 'community', isStarter: true, preview: { demoUrl: 'https://demo.example.com' }, visibility: 'org' }; + const fixture = { displayName: 'Acme CRM', tags: ['crm'], publisher: 'community', isStarter: true, preview: { demoUrl: 'https://demo.example.com' } }; expect(TemplateManifestSchema.safeParse(fixture).success).toBe(true); expect(uncarriedManifestKeys(fixture)).toEqual([...UNCARRIED_MANIFEST_KEYS]); }); + + it('names for deletion only keys the schema refuses', () => { + // Each retired key is a tombstone: a parse of the file refuses it, which + // is what the warning tells the author. + for (const key of RETIRED_INERT_MANIFEST_KEYS) { + expect(TemplateManifestSchema.safeParse({ [key]: 'x' }).success, key).toBe(false); + } + }); }); diff --git a/packages/create-objectstack/src/index.ts b/packages/create-objectstack/src/index.ts index d6d69c7c563..1057d2f6e8c 100644 --- a/packages/create-objectstack/src/index.ts +++ b/packages/create-objectstack/src/index.ts @@ -25,7 +25,7 @@ * After the files land in `targetDir`, four files are rewritten with the * user-supplied project name: * - package.json .name - * - objectstack.manifest.json .name + .displayName + * - objectstack.manifest.json .displayName (+ .namespace) * - objectstack.config.ts manifest.id and manifest.name string literals * - README.md first H1 * @@ -78,6 +78,7 @@ import { readTemplateNamespace, rewriteObjectNamePrefix, findStaleNamespacePrefixes, + stripRetiredManifestKeys, } from './rewrite-identity.js'; import { lookupTemplate, templateNames } from './template-registry.js'; import { readResolvedCliVersion, pinRuntimeImage } from './runtime-image.js'; @@ -187,7 +188,11 @@ function rewriteProjectIdentity( } } - // objectstack.manifest.json — set .name, .displayName, .namespace, and DROP + // objectstack.manifest.json — set .displayName and .namespace, DROP every + // key @objectstack/spec 18 retired from the file (`category` #30980; `name`, + // `specVersion`, `skills`, `scaffold`, `visibility` #31035): a parse of the + // file refuses each, and publish refuses `category` and `visibility` — the + // list is RETIRED_TEMPLATE_MANIFEST_KEYS in rewrite-identity.ts), and DROP // .description (#9263). The template ships a generic line describing // ITSELF ("Minimal ObjectStack environment — a clean slate for building."), // not the project being scaffolded, and there is nothing but the project @@ -202,7 +207,7 @@ function rewriteProjectIdentity( if (fs.existsSync(manifestPath)) { try { const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - m.name = projectName; + stripRetiredManifestKeys(m); m.displayName = title; if ('namespace' in m) m.namespace = namespace; delete m.description; diff --git a/packages/create-objectstack/src/package-name.test.ts b/packages/create-objectstack/src/package-name.test.ts index ca17eaa1629..491165de624 100644 --- a/packages/create-objectstack/src/package-name.test.ts +++ b/packages/create-objectstack/src/package-name.test.ts @@ -163,6 +163,8 @@ describe('the CLI refuses an illegal project name before writing anything', () = fs.readFileSync(path.join(projectDir, 'objectstack.manifest.json'), 'utf8'), ); expect(pkg.name).toBe(name); - expect(manifest.name).toBe(name); + // `objectstack.manifest.json` carries no `name`: the key is retired, and + // the slug lives in package.json alone. + expect(manifest).not.toHaveProperty('name'); }, 30_000); }); diff --git a/packages/create-objectstack/src/package-name.ts b/packages/create-objectstack/src/package-name.ts index 3c6e4f25bb8..35a31395e28 100644 --- a/packages/create-objectstack/src/package-name.ts +++ b/packages/create-objectstack/src/package-name.ts @@ -9,7 +9,7 @@ // ---------------------------------- // The resolved project name — the `[name]` argument, or the current // directory's basename when there is none — is written verbatim into -// `package.json` `name` and `objectstack.manifest.json` `name`. A display-style +// `package.json` `name`. A display-style // name (`My App!`, `MyApp`) used to land there unchecked: `validate` and // `build` pass on such a project, and the first refusal came from npm itself, // at `npm publish`, far from its cause. diff --git a/packages/create-objectstack/src/rewrite-identity.ts b/packages/create-objectstack/src/rewrite-identity.ts index 61ba4d27363..d0739809a78 100644 --- a/packages/create-objectstack/src/rewrite-identity.ts +++ b/packages/create-objectstack/src/rewrite-identity.ts @@ -132,6 +132,48 @@ export function deriveManifestId(projectName: string): string { */ export const SCAFFOLD_NAMESPACE_MAX_LENGTH = 13; +/** + * The `objectstack.manifest.json` keys `@objectstack/spec` 18 retired + * (ADR-0049): `category` (#30980) and `name`, `specVersion`, `skills`, + * `scaffold`, `visibility` (#31035). `TemplateManifestSchema` carries a + * `retiredKey()` tombstone for each, so a manifest still declaring one fails a + * parse, and `os package publish` refuses `category` and `visibility` + * outright. The scaffolder strips + * every one of them from the template's manifest, so a template authored + * before that release can never hand a new project a manifest the schema + * refuses. + * + * Restated rather than imported for the reason {@link SCAFFOLD_NAMESPACE_MAX_LENGTH} + * gives (the spec is not a runtime dependency of this two-dependency `npx` + * entry point). `scaffold-retired-manifest-keys.test.ts` pins this list equal + * to the spec's own registry rows (`RETIRED_KEYS_BY_MAJOR`, every + * `marketplace/TemplateManifest:` entry), so a key retired there without + * being added here goes red. + */ +export const RETIRED_TEMPLATE_MANIFEST_KEYS = [ + 'category', + 'name', + 'specVersion', + 'skills', + 'scaffold', + 'visibility', +] as const; + +/** + * Delete every {@link RETIRED_TEMPLATE_MANIFEST_KEYS} key from a parsed + * manifest, in place. Returns the keys it found, in list order. + */ +export function stripRetiredManifestKeys(manifest: Record): string[] { + const found: string[] = []; + for (const key of RETIRED_TEMPLATE_MANIFEST_KEYS) { + if (Object.prototype.hasOwnProperty.call(manifest, key)) { + delete manifest[key]; + found.push(key); + } + } + return found; +} + /** * Convert an npm package name into a valid ObjectStack namespace identifier * (regex `^[a-z][a-z0-9_]{1,19}$`, reserved: base/system/sys) that is at most diff --git a/packages/create-objectstack/src/scaffold-description.test.ts b/packages/create-objectstack/src/scaffold-description.test.ts index 378556fd33d..808242705e4 100644 --- a/packages/create-objectstack/src/scaffold-description.test.ts +++ b/packages/create-objectstack/src/scaffold-description.test.ts @@ -98,7 +98,7 @@ describe('scaffolded project description (#9263)', () => { const manifest = JSON.parse( fs.readFileSync(path.join(projectDir, 'objectstack.manifest.json'), 'utf8'), ); - expect(manifest.name).toBe('support-desk'); + expect(manifest).not.toHaveProperty('name'); expect(manifest.namespace).toBe('support_desk'); expect(manifest.displayName).toBe('Support Desk'); }); diff --git a/packages/create-objectstack/src/scaffold-retired-manifest-keys.test.ts b/packages/create-objectstack/src/scaffold-retired-manifest-keys.test.ts new file mode 100644 index 00000000000..7787a19511d --- /dev/null +++ b/packages/create-objectstack/src/scaffold-retired-manifest-keys.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// The scaffolder strips every `objectstack.manifest.json` key that +// @objectstack/spec 18 retired (`category` #30980, five more #31035), so a +// template authored before that release never hands a new project a manifest +// the schema refuses (or, for `category` and `visibility`, one +// `os package publish` refuses). The restated list is pinned equal to the +// spec's own registry rows, and the call site in index.ts is pinned to use it +// (index.ts runs `program.parse()` on import, so `rewriteProjectIdentity` +// cannot be called from a test; the bundled template no longer carries any +// retired key, so an end-to-end scaffold cannot reach the strip either). + +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { TemplateManifestSchema } from '@objectstack/spec/marketplace'; +import { RETIRED_KEYS_BY_MAJOR } from '@objectstack/spec/migrations'; +import { RETIRED_TEMPLATE_MANIFEST_KEYS, stripRetiredManifestKeys } from './rewrite-identity.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const INDEX_TS = path.join(HERE, 'index.ts'); + +/** A manifest as the blank template wrote it before #31035, plus `category` and `visibility`. */ +const PRE_18_MANIFEST = () => ({ + category: 'crm', + name: 'blank', + namespace: 'blank', + displayName: 'Blank Starter', + specVersion: '^18.0.0', + isStarter: true, + readmePath: 'README.md', + skills: ['objectstack-platform'], + scaffold: { variables: { appName: { type: 'string' } } }, + visibility: 'marketplace', +}); + +describe('RETIRED_TEMPLATE_MANIFEST_KEYS', () => { + it('is exactly the spec registry rows for TemplateManifest', () => { + const PREFIX = 'marketplace/TemplateManifest:'; + const fromSpec = Object.values(RETIRED_KEYS_BY_MAJOR) + .flat() + .filter((entry) => entry.startsWith(PREFIX)) + .map((entry) => entry.slice(PREFIX.length)); + expect([...RETIRED_TEMPLATE_MANIFEST_KEYS].sort()).toEqual([...new Set(fromSpec)].sort()); + }); + + it('names only keys the schema refuses', () => { + for (const key of RETIRED_TEMPLATE_MANIFEST_KEYS) { + expect(TemplateManifestSchema.safeParse({ [key]: 'x' }).success, key).toBe(false); + } + }); +}); + +describe('stripRetiredManifestKeys', () => { + it('turns a pre-18 manifest into one the schema accepts, touching nothing else', () => { + const m: Record = PRE_18_MANIFEST(); + expect(TemplateManifestSchema.safeParse(m).success).toBe(false); + expect(stripRetiredManifestKeys(m)).toEqual([...RETIRED_TEMPLATE_MANIFEST_KEYS]); + expect(m).toEqual({ + namespace: 'blank', + displayName: 'Blank Starter', + isStarter: true, + readmePath: 'README.md', + }); + expect(TemplateManifestSchema.safeParse(m).success).toBe(true); + }); + + it('is a no-op on a manifest that carries none of them', () => { + const m: Record = { displayName: 'Blank Starter', namespace: 'blank' }; + expect(stripRetiredManifestKeys(m)).toEqual([]); + expect(m).toEqual({ displayName: 'Blank Starter', namespace: 'blank' }); + }); +}); + +describe('rewriteProjectIdentity wiring', () => { + it('strips the retired keys from the scaffolded manifest, not only `name`', () => { + const src = fs.readFileSync(INDEX_TS, 'utf8'); + const start = src.indexOf("path.join(targetDir, 'objectstack.manifest.json')"); + expect(start, 'the manifest rewrite block').toBeGreaterThan(-1); + const block = src.slice(start, src.indexOf('writeFileSync(manifestPath', start)); + expect(block).toContain('stripRetiredManifestKeys(m);'); + expect(block).not.toMatch(/delete m\.name\b/); + }); +}); diff --git a/packages/create-objectstack/src/template-consistency.test.ts b/packages/create-objectstack/src/template-consistency.test.ts index 870d274ecca..507fa1cd14f 100644 --- a/packages/create-objectstack/src/template-consistency.test.ts +++ b/packages/create-objectstack/src/template-consistency.test.ts @@ -87,13 +87,14 @@ const REPO_READ_ENV: NodeJS.ProcessEnv = gitFreeEnv(); // ── Declared version surfaces, per bundled template (#9264) ───────────────── // -// Every bundled template declares the platform it targets in THREE places, and +// Every bundled template declares the platform it targets in TWO places, and // each one is committed to git, shipped in the tarball and copied into every // scaffolded project: the `@objectstack/*` dependencies at the scaffolder's -// EXACT version, `specVersion` at its major's range, and `engines.protocol` at -// its protocol major. `scripts/sync-template-versions.mjs` re-stamps all three -// at version time; these ratchets are the CI half, because that script runs on -// a changesets/action release PR that gets no CI at all. +// EXACT version, and `engines.protocol` at its protocol major. +// `scripts/sync-template-versions.mjs` re-stamps both at version time; these +// ratchets are the CI half, because that script runs on a changesets/action +// release PR that gets no CI at all. (A third, the manifest's `specVersion`, +// was retired under ADR-0049 (#31035): nothing read it.) // // The template list is DISCOVERED, not written down — the same directory walk // the sync script and `check-template-manifests.ts` both use. A hand-kept list @@ -276,60 +277,14 @@ describe('bundled template declared version surfaces', () => { ).toBe(ownMajor); }); - // The key #9264 is about. Required by TemplateManifestSchema, read by the - // template registry, and copied verbatim into every scaffolded project — - // `create-objectstack` rewrites name/displayName/namespace and drops - // description, and has never touched this one. - it('objectstack.manifest.json declares specVersion at the current @objectstack/spec range', () => { + // The key #9264 was about, retired under ADR-0049 (#31035): nothing read + // the manifest's `specVersion` — the protocol handshake is the + // `engines.protocol` stamp above, and the spec line is the exact + // dependency. `check:template-manifests` refuses it by parse; this pins + // that the version stamp did not put it back. + it('objectstack.manifest.json declares no specVersion (retired; engines.protocol carries the handshake)', () => { const manifest = JSON.parse(readTemplateFile('objectstack.manifest.json')); - expect( - typeof manifest.specVersion, - `${template}/objectstack.manifest.json must declare specVersion — it is REQUIRED by ` + - 'TemplateManifestSchema (packages/spec/src/marketplace/template-manifest.zod.ts)', - ).toBe('string'); - - const match = /^\^(\d+)\.\d+\.\d+$/.exec(manifest.specVersion); - expect( - match, - `specVersion "${manifest.specVersion}" must be a ^.0.0 package range — it is the ` + - 'compatible @objectstack/spec range, not the protocol major that engines.protocol carries', - ).not.toBeNull(); - expect( - Number(match![1]), - `${template} declares specVersion "${manifest.specVersion}" but create-objectstack is ` + - `v${ownMajor} — scripts/sync-template-versions.mjs re-stamps this at version time`, - ).toBe(ownMajor); - }); - - // The invariant that ties the two files together: the manifest's declared - // spec range and the dependency a scaffolded project actually installs - // must name the same line. Either alone can be self-consistently stale; - // only comparing them catches a stamp that covered one and not the other, - // which is the exact failure this card is about. They are no longer EQUAL - // — the dependency is an exact version, `specVersion` a range — so what is - // compared is the range the exact dependency's major implies. - it('specVersion is the range of the @objectstack/spec dependency the template installs', () => { - const manifest = JSON.parse(readTemplateFile('objectstack.manifest.json')); - const templatePkg = JSON.parse(readTemplateFile('package.json')); - const specDep = - templatePkg.dependencies?.['@objectstack/spec'] ?? - templatePkg.devDependencies?.['@objectstack/spec']; - - expect( - specDep, - `${template}/package.json must depend on @objectstack/spec for its manifest's ` + - 'specVersion to be checkable against something', - ).toBeDefined(); - const depMajor = /^(\d+)\.\d+\.\d+/.exec(String(specDep)); - expect( - depMajor, - `${template}/package.json pins @objectstack/spec at "${String(specDep)}", which is not an exact version`, - ).not.toBeNull(); - expect( - manifest.specVersion, - `${template} declares specVersion "${manifest.specVersion}" but installs ` + - `@objectstack/spec "${String(specDep)}" — the range must be ^${depMajor![1]}.0.0, the line the pin is on`, - ).toBe(`^${depMajor![1]}.0.0`); + expect(manifest).not.toHaveProperty('specVersion'); }); }); }); diff --git a/packages/create-objectstack/src/template-version-stamps.test.ts b/packages/create-objectstack/src/template-version-stamps.test.ts index 88d1831192f..c58f388ce95 100644 --- a/packages/create-objectstack/src/template-version-stamps.test.ts +++ b/packages/create-objectstack/src/template-version-stamps.test.ts @@ -2,7 +2,8 @@ // // The declaration surface of `scripts/sync-template-versions.mjs` (#9554). // -// That script stamps three version surfaces per bundled template and discovers +// That script stamps two version surfaces per bundled template (a third, the +// manifest's `specVersion`, was retired under ADR-0049, #31035) and discovers // the template set by WALKING `src/templates/` — "deliberately not a curated // list", because a hand-kept list is what let `specVersion` drift eleven majors // (#9264). But for one release it declared that set where nobody could read it: @@ -61,7 +62,7 @@ type SyncModule = { TEXT_STAMPS: { file: string; key: string; pattern: RegExp }[]; findTemplateDirs: (templateRoot?: string) => string[]; stampedPaths: (options?: { root?: string }) => string[]; - loadScaffolderVersion: (file?: string) => { version: string; major: string; range: string }; + loadScaffolderVersion: (file?: string) => { version: string; major: string }; }; const loadSync = async (file = SYNC_SCRIPT): Promise => @@ -159,10 +160,6 @@ beforeAll(() => { path.join(dir, 'objectstack.config.ts'), `export default defineStack({ manifest: { engines: { protocol: '^${STALE_MAJOR}' } } });\n`, ); - writeFixtureFile( - path.join(dir, 'objectstack.manifest.json'), - `{\n "specVersion": "^${STALE_MAJOR}.0.0",\n "scaffold": { "variables": [] }\n}\n`, - ); } // A file, not a directory, beside the templates: the walk must ignore it the @@ -180,7 +177,7 @@ afterAll(() => { /** Every fixture surface, as absolute paths. */ const allFixtureSurfaces = () => FIXTURE_TEMPLATES.flatMap((template) => - ['package.json', 'objectstack.config.ts', 'objectstack.manifest.json'].map((file) => + ['package.json', 'objectstack.config.ts'].map((file) => path.join(fixtureTemplateDir(template), file), ), ); @@ -252,29 +249,19 @@ describe('the entry-point guard leaves the CLI path working', () => { expect(fs.readFileSync(path.join(dir, 'objectstack.config.ts'), 'utf8')).toContain( "engines: { protocol: '^42' }", ); - - const manifest = fs.readFileSync(path.join(dir, 'objectstack.manifest.json'), 'utf8'); - expect( - manifest, - 'specVersion stays a RANGE while the deps beside it become exact', - ).toContain('"specVersion": "^42.0.0"'); - expect( - manifest, - 'the manifest is rewritten as TEXT, so unrelated compact structure survives', - ).toContain('"scaffold": { "variables": [] }'); } // A guard that made the version pass silently stop stamping would be far // worse than the finding it fixes, so the run is observed to REPORT both. expect(stdout).toContain('2 template(s) in lockstep with create-objectstack@42.0.0'); - expect(stdout).toContain('second/objectstack.manifest.json'); + expect(stdout).toContain('second/objectstack.config.ts'); }); - it('stamps a PRERELEASE verbatim into the deps, and only its major into specVersion', () => { + it('stamps a PRERELEASE verbatim into the deps, and only its major into engines.protocol', () => { // A release-candidate cut (`cut-rc.yml`) runs this same pass at a version // like `19.0.0-rc.0`. The deps take it verbatim, because a scaffold of the - // rc must install the rc; `specVersion` keeps the major's range, because it - // names the spec line the template is authored against, not a build. + // rc must install the rc; `engines.protocol` keeps the protocol major, + // because it is the runtime handshake, not a build. const prerelease = '43.0.0-rc.2'; const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sync-template-versions-rc-')); try { @@ -297,18 +284,11 @@ describe('the entry-point guard leaves the CLI path working', () => { path.join(templateDir, 'objectstack.config.ts'), `export default defineStack({ manifest: { engines: { protocol: '^${STALE_MAJOR}' } } });\n`, ); - writeFixtureFile( - path.join(templateDir, 'objectstack.manifest.json'), - `{\n "specVersion": "^${STALE_MAJOR}.0.0",\n "scaffold": { "variables": [] }\n}\n`, - ); execFileSync(process.execPath, [script], { encoding: 'utf8' }); const pkg = JSON.parse(fs.readFileSync(path.join(templateDir, 'package.json'), 'utf8')); expect(pkg.dependencies['@objectstack/spec']).toBe(prerelease); - expect(fs.readFileSync(path.join(templateDir, 'objectstack.manifest.json'), 'utf8')).toContain( - '"specVersion": "^43.0.0"', - ); expect(fs.readFileSync(path.join(templateDir, 'objectstack.config.ts'), 'utf8')).toContain( "engines: { protocol: '^43' }", ); @@ -328,10 +308,8 @@ describe('stampedPaths()', () => { expect(paths).toEqual([ `${prefix}/blank/objectstack.config.ts`, - `${prefix}/blank/objectstack.manifest.json`, `${prefix}/blank/package.json`, `${prefix}/second/objectstack.config.ts`, - `${prefix}/second/objectstack.manifest.json`, `${prefix}/second/package.json`, ]); diff --git a/packages/create-objectstack/src/templates/blank/objectstack.manifest.json b/packages/create-objectstack/src/templates/blank/objectstack.manifest.json index 40eda6ae5bb..4092936c60d 100644 --- a/packages/create-objectstack/src/templates/blank/objectstack.manifest.json +++ b/packages/create-objectstack/src/templates/blank/objectstack.manifest.json @@ -1,16 +1,8 @@ { "$schema": "https://schemas.objectstack.dev/template-manifest.json", - "name": "blank", "namespace": "blank", - "specVersion": "^18.0.0", "displayName": "Blank Starter", "description": "Minimal ObjectStack environment with a single object — a clean slate for building.", "isStarter": true, - "skills": ["objectstack-platform", "objectstack-data"], - "readmePath": "README.md", - "scaffold": { - "variables": { - "appName": { "type": "string", "prompt": "Application name", "default": "my-app" } - } - } + "readmePath": "README.md" } diff --git a/packages/create-objectstack/vitest.config.ts b/packages/create-objectstack/vitest.config.ts index 7983fa08a77..fd3602ff14a 100644 --- a/packages/create-objectstack/vitest.config.ts +++ b/packages/create-objectstack/vitest.config.ts @@ -19,6 +19,16 @@ export default defineConfig({ find: /^@objectstack\/spec\/kernel$/, replacement: path.resolve(HERE, '../spec/src/kernel/index.ts'), }, + // `scaffold-retired-manifest-keys.test.ts` pins the scaffolder's restated + // retired-key list to the spec's registry rows and schema (#31035). + { + find: /^@objectstack\/spec\/marketplace$/, + replacement: path.resolve(HERE, '../spec/src/marketplace/index.ts'), + }, + { + find: /^@objectstack\/spec\/migrations$/, + replacement: path.resolve(HERE, '../spec/src/migrations/index.ts'), + }, ], }, test: { diff --git a/packages/spec/authorable-surface/marketplace.json b/packages/spec/authorable-surface/marketplace.json index 2e38837db86..b0a3d2c3dcd 100644 --- a/packages/spec/authorable-surface/marketplace.json +++ b/packages/spec/authorable-surface/marketplace.json @@ -184,18 +184,18 @@ "marketplace/TemplateManifest:license", "marketplace/TemplateManifest:manifestId", "marketplace/TemplateManifest:maturity", - "marketplace/TemplateManifest:name", + "marketplace/TemplateManifest:name [RETIRED]", "marketplace/TemplateManifest:namespace", "marketplace/TemplateManifest:preview", "marketplace/TemplateManifest:publisher", "marketplace/TemplateManifest:readmePath", - "marketplace/TemplateManifest:scaffold", - "marketplace/TemplateManifest:skills", + "marketplace/TemplateManifest:scaffold [RETIRED]", + "marketplace/TemplateManifest:skills [RETIRED]", "marketplace/TemplateManifest:solutionType", - "marketplace/TemplateManifest:specVersion", + "marketplace/TemplateManifest:specVersion [RETIRED]", "marketplace/TemplateManifest:tags", "marketplace/TemplateManifest:translations", - "marketplace/TemplateManifest:visibility", + "marketplace/TemplateManifest:visibility [RETIRED]", "marketplace/UpdatePackageRequest:businessFunctions", "marketplace/UpdatePackageRequest:category [RETIRED]", "marketplace/UpdatePackageRequest:description", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index c21d56aa663..68d5ee42a3b 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -1026,7 +1026,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | analytics_cube | seeded 2026-09-17 (#18582) — the third debt, paid in the same diff as `connector`. Not a registered kind either: bound in `UNREGISTERED_KIND_SCHEMAS` by commit 2306a765c and reached through the same unregistered-kind fallback. **ONE Cube shape, THREE producers, one registry** is what decides every row: `cube-registry.ts` names them itself — authored cubes (`analyticsCubes[]` / `defineCube()`, threaded by the CLI into `AnalyticsServiceConfig.cubes`), COMPILED DATASETS (ADR-0021, where `dataset-compiler` mints a Cube), and ad-hoc query inference. Only the first is the authoring door governed here, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube however busy that reader is — the #4837 producer rule on a shape with three producers. That kept `dimensions.granularities` (read only by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format` (written by the compiler, threaded to the wire from the DATASET measure instead) `dead` until **#20282**'s second stage (2026-09-29) read both on the query doors off whichever cube answers the name: `analytics-service#withDeclaredMeasureFormats` describes each measure column's `fields[].format`, and `#withDeclaredGranularityDefaults` buckets a grouped time dimension at the default `dataset-executor#declaredDefaultGranularity` reads — the one reading (a single-entry list) the dataset path's `granularityOf` now shares. The query path is genuinely live: `sql` is the FROM table AND the object whose RLS read scope is injected, `measures.type` picks the aggregate, `measures.sql`/`dimensions.sql` the column, `joins[].name` the joined table. The 3 `dead` are the `refreshKey` tombstone and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity — RETIRED by #20300 (ADR-0049 enforce-or-remove) as `retiredKey()` tombstones on the member `strictObject`s, so those two rows STAY `dead` (the tombstone keeps the key in the walked shape) and the count does not move. It was 6 until **#20282**'s third stage (2026-09-29) published the three `description`s on discovery: `analytics-service#getMeta` copies each onto its `CubeMeta` entry, the read point the `title`/`label` rows already cite (display-shaped, the #7131 split). It was 7 until #20637 RETIRED `refreshKey` whole (ADR-0049 enforce-or-remove, maintainer letter C): the caching block's `every` and `sql` were two drilled `dead` rows — no refresh scheduler, pre-aggregation or analytics result cache exists anywhere, re-measured 2026-09-29 — and the `retiredKey()` tombstone that replaced the block is ONE leaf row, because the gate refuses `children` on a property that is no longer a container (the connector `health` precedent). **#20282** flips the tenth, the visibility flag `public`, `dead` → `live` 2026-09-27: seeded as a knob that was never wired (three internal mints wrote `false`, nothing read it), it is now read by `service-analytics`' `cube-visibility.ts#isCubePublic` — `getMeta` omits a hidden cube and `query()` / `generateSql()` refuse it — in the same change that moved its default from `false` to the Cube.dev `true`, since enforcing the old default would have hidden every authored cube. It was 12 until #18612 RETIRED `joins[].relationship` and the REQUIRED `joins[].sql` (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): the ON clause is SYNTHESISED as an FK equality and the authored one was never consulted, so a declared join condition came back REPLACED under a 200. `CubeJoinSchema` is a `strictObject`, so the route was strict deletion plus a `guidance` prescription and the two rows left this ledger with the keys — not the `retiredKey()` route, which keeps the row. **The end-to-end measurement is not prejudged**: whether cube authoring is live end to end is still its own measurement — this ledger answers the per-key question only | | queue | seeded 2026-10-26 with the type itself (#25769, ADR-0142 phase 2): registered governed, never a `PENDING_GOVERNANCE` debt. A declared ownership queue is never copied into a row (ADR-0131 D6, regime C): every consumer reads the registry item through the one reader they share, plugin-sharing's `OwnershipQueueService` (`resolveQueue`, `expandMembers`, `isMember`, `queuesOf`), and each row's producer is `registerMetadataCollections` loading the stack's `queues`. All rows `live`: `name` is the one reference the `queue` column, an assignment target, a queue approver and a sharing recipient carry, and the namespace an authored `sys_queue` row may not take; `members.type` is a one-member literal (`position`) that `expandSpec` still dispatches on beside the authored member kinds; `label` is served as the resolved queue's label; `label`/`description` are shown in the Studio metadata list's default columns (the `webhook` precedent). An organization's authored queues are `sys_queue`/`sys_queue_member` rows, governed by their object definitions, not here. | | assignment_rule | seeded 2026-10-26 with the type itself (#25769, ADR-0142 D5): registered governed. Declared only — organization-authored rules are deferred (ADR-0142 merge question 11) — and read on every insert by the engine's `AssignmentRuleEvaluator`, after every `beforeInsert` hook, for a row whose `owner_id` and `queue` are both still empty. All rows `live`, and `entries.assignTo` is drilled (`type`, `value`) rather than inherited. `name` and `entries.name` key the round-robin cursor (`sys_assignment_cursor`, per rule, entry and organization), so a rename restarts a rotation; `object` gates evaluation to objects carrying the platform `queue` column; `entries.load` is the `least_loaded` count predicate, required with that strategy and refused otherwise. `kernel:ready` refuses a stack that declares rules or queues with no `ownership-queue` service registered, so no row is inert by a missing service. | -| template_manifest | seeded 2026-11-06 with the marketplace taxonomy, through `SPEC_ONLY_SCHEMAS` (the sixth category: a package-listing file surface — `objectstack.manifest.json`, read by `os package publish` and create-objectstack, never by a boot). The facet keys `businessFunctions` / `industries` / `maturity` are `planned`: the CLI judges and forwards them, and their consumers are the cloud catalog (objectstack-ai/cloud#3661, which drops them until it lands) and the console marketplace card. `solutionType` is `live` through the publish cross-check. `category` is the tombstone. The other `dead` rows are what entering the denominator measured (`name`, `specVersion`, `skills`, `scaffold`, and `visibility` / `publisher` / `isStarter`, which publish names in a warning and does not send) — recorded, not retired. | +| template_manifest | seeded 2026-11-06 with the marketplace taxonomy, through `SPEC_ONLY_SCHEMAS` (the sixth category: a package-listing file surface — `objectstack.manifest.json`, read by `os package publish` and create-objectstack, never by a boot). The facet keys `businessFunctions` / `industries` / `maturity` are `planned`: the CLI judges and forwards them, and their consumers are the cloud catalog (objectstack-ai/cloud#3661, which drops them until it lands) and the console marketplace card. `solutionType` is `live` through the publish cross-check. `category` is the tombstone. The other `dead` rows are what entering the denominator measured: `name`, `specVersion`, `skills`, `scaffold` and the warned-and-unsent `visibility` were then retired as tombstones (ADR-0049); `publisher` / `isStarter`, which publish names in a warning and does not send but a first-party publish script does, stay recorded pending a maintainer decision. | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); a `dead` row warns its authors at compile time by its verdict alone, with no `authorWarn` diff --git a/packages/spec/liveness/state-counts/template_manifest.md b/packages/spec/liveness/state-counts/template_manifest.md index 2649b084ecb..c794dff3b25 100644 --- a/packages/spec/liveness/state-counts/template_manifest.md +++ b/packages/spec/liveness/state-counts/template_manifest.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `template_manifest` | 15 | 0 | 0 | 9 | 5 | 29 | +| `template_manifest` | 15 | 0 | 0 | 8 | 5 | 28 | diff --git a/packages/spec/liveness/template_manifest.json b/packages/spec/liveness/template_manifest.json index 42f77716b48..ab553a3515d 100644 --- a/packages/spec/liveness/template_manifest.json +++ b/packages/spec/liveness/template_manifest.json @@ -1,6 +1,6 @@ { "type": "template_manifest", - "_note": "TemplateManifestSchema (packages/spec/src/marketplace/template-manifest.zod.ts) — `objectstack.manifest.json`, the package-listing file an author writes beside objectstack.config.ts. Read by `os package publish` (packages/cli/src/commands/package/publish.ts) and by create-objectstack at scaffold; parsed for every shipped file by `pnpm --filter @objectstack/spec check:template-manifests`; never loaded by a runtime boot. GOVERNED VIA THE GATE'S SPEC-ONLY OVERRIDE (SPEC_ONLY_SCHEMAS), seeded 2026-11-06 with the marketplace taxonomy, whose facet keys (`businessFunctions`, `industries`, `solutionType`, `maturity`) have their consumers in the cloud catalog (objectstack-ai/cloud#3661) and the console — a row is where those consumers are named. MEASUREMENT: every row below was measured for this seeding against objectstack origin/main 7d9682ffd7 plus the taxonomy change, and the cloud leg against cloud origin/main 8f574b9 (its publish route reads `tags` and the category it retires; it drops every facet key today). The `dead` rows other than `category` are what entering the denominator found, not part of the taxonomy: they are recorded here, not retired.", + "_note": "TemplateManifestSchema (packages/spec/src/marketplace/template-manifest.zod.ts) — `objectstack.manifest.json`, the package-listing file an author writes beside objectstack.config.ts. Read by `os package publish` (packages/cli/src/commands/package/publish.ts) and by create-objectstack at scaffold; parsed for every shipped file by `pnpm --filter @objectstack/spec check:template-manifests`; never loaded by a runtime boot. GOVERNED VIA THE GATE'S SPEC-ONLY OVERRIDE (SPEC_ONLY_SCHEMAS), seeded 2026-11-06 with the marketplace taxonomy, whose facet keys (`businessFunctions`, `industries`, `solutionType`, `maturity`) have their consumers in the cloud catalog (objectstack-ai/cloud#3661) and the console — a row is where those consumers are named. MEASUREMENT: every row below was measured for this seeding against objectstack origin/main 7d9682ffd7 plus the taxonomy change, and the cloud leg against cloud origin/main 8f574b9 (its publish route reads `tags` and the category it retires; it drops every facet key today). The `dead` rows other than `category` are what entering the denominator found: `name`, `specVersion`, `skills`, `scaffold` and `visibility` were then retired as tombstones (ADR-0049, D3 `marketplace-template-manifest-inert-keys-retired`); `publisher` and `isStarter` stay recorded, pending a maintainer decision.", "props": { "manifestId": { "status": "live", @@ -26,7 +26,7 @@ "status": "dead", "verifiedAt": "2026-11-06", "evidenceScope": "in-repo", - "note": "Read only to be named in a warning: `uncarriedManifestWarnings` (packages/cli/src/commands/package/publish.ts) tells the author that `os package publish` does not read `visibility` from this file and that `--visibility` carries it. The value an author writes here changes nothing. Recorded on the taxonomy enrolment (the first time this file was in the denominator); not retired here." + "note": "REMOVED 2026-11-06 — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error). No D2 conversion: this file is not a stack collection member (D3 `marketplace-template-manifest-inert-keys-retired`). The entry stays because retiredKey keeps the key in the walked shape; pass `--visibility` to `os package publish`, which refuses the file's key by name before any network call. Before retiring it was read only to be named in a warning (`uncarriedManifestWarnings`). The publish request's own `visibility` (CreatePackageRequestSchema) stays live." }, "category": { "status": "dead", @@ -91,13 +91,13 @@ "status": "dead", "verifiedAt": "2026-11-06", "evidenceScope": "in-repo", - "note": "Decided by the platform: `os package publish` names it in a warning (`uncarriedManifestWarnings`, PLATFORM_DECIDED_MANIFEST_KEYS) and never sends it — the hosted control plane assigns the trust tier from the caller's credentials. An author's value has no effect. Recorded on the taxonomy enrolment; not retired here." + "note": "Decided by the platform: `os package publish` names it in a warning (`uncarriedManifestWarnings`, PLATFORM_DECIDED_MANIFEST_KEYS) and never sends it — the hosted control plane assigns the trust tier from the caller's credentials. An author's value has no effect. Recorded on the taxonomy enrolment. Not retired with the five inert keys: a first-party publish script (objectstack-ai/hotcrm scripts/lib/marketplace-listing.mjs#packageUpsertBody) sends it to the hosted packages route in service mode, so its fate is a maintainer decision." }, "isStarter": { "status": "dead", "verifiedAt": "2026-11-06", "evidenceScope": "in-repo", - "note": "Decided by the platform: `os package publish` names it in a warning and never sends it; the hosted control plane honours a starter flag only on a first-party service-mode seed (cloud @8f574b9 package-publish.ts, `isFirstPartySeed`). The bundled blank template still ships `isStarter: true`, which is why the warning does not fail the publish. Recorded on the taxonomy enrolment; not retired here." + "note": "Decided by the platform: `os package publish` names it in a warning and never sends it; the hosted control plane honours a starter flag only on a first-party service-mode seed (cloud @8f574b9 package-publish.ts, `isFirstPartySeed`). The bundled blank template still ships `isStarter: true`, which is why the warning does not fail the publish. Recorded on the taxonomy enrolment. Not retired with the five inert keys: a first-party publish script (objectstack-ai/hotcrm scripts/lib/marketplace-listing.mjs#packageUpsertBody) sends it to the hosted packages route in service mode, so its fate is a maintainer decision." }, "translations": { "status": "live", @@ -141,13 +141,13 @@ "status": "dead", "verifiedAt": "2026-11-06", "evidenceScope": "in-repo", - "note": "Written, never read: create-objectstack rewrites it to the project slug at scaffold (packages/create-objectstack/src/index.ts), and neither `os package publish` (which derives the package id from `manifestId` or the artifact) nor any other reader consults the file's value. Measured on the taxonomy enrolment; not retired here." + "note": "REMOVED 2026-11-06 — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error). No D2 conversion: this file is not a stack collection member (D3 `marketplace-template-manifest-inert-keys-retired`). The entry stays because retiredKey keeps the key in the walked shape; the project slug is `package.json` `name` and the published id is `manifestId` (or the one publish derives from the artifact). Measured before retiring: create-objectstack wrote it at scaffold and never read it back, and `os package publish` never consulted it." }, "specVersion": { "status": "dead", "verifiedAt": "2026-11-06", "evidenceScope": "in-repo", - "note": "No reader of the file's value. The protocol-compatibility advisory reads the artifact manifest's `engines.protocol` (packages/cli/src/utils/protocol-version-gap.ts), not this key. Measured on the taxonomy enrolment; not retired here." + "note": "REMOVED 2026-11-06 — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error). No D2 conversion: this file is not a stack collection member (D3 `marketplace-template-manifest-inert-keys-retired`). The entry stays because retiredKey keeps the key in the walked shape; the protocol a package needs is `manifest.engines.protocol` in `objectstack.config.ts` (read by packages/cli/src/utils/protocol-version-gap.ts). Measured before retiring: no reader of the file's value; scripts/sync-template-versions.mjs only stamped it." }, "namespace": { "status": "live", @@ -160,7 +160,7 @@ "status": "dead", "verifiedAt": "2026-11-06", "evidenceScope": "in-repo", - "note": "No reader: described as 'for docs / picker', and neither create-objectstack's template picker (template-registry.ts) nor any docs build reads it. Measured on the taxonomy enrolment; not retired here." + "note": "REMOVED 2026-11-06 — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error). No D2 conversion: this file is not a stack collection member (D3 `marketplace-template-manifest-inert-keys-retired`). The entry stays because retiredKey keeps the key in the walked shape; create-objectstack installs the whole `@objectstack/skills` catalog whatever the file listed. Measured before retiring: neither the template picker (template-registry.ts) nor any docs build read it." }, "preview": { "status": "planned", @@ -187,21 +187,7 @@ "status": "dead", "verifiedAt": "2026-11-06", "evidenceScope": "in-repo", - "note": "No reader: create-objectstack prompts for nothing from `scaffold.variables` and runs no `scaffold.postInstall` (packages/create-objectstack/src/index.ts names neither). Measured on the taxonomy enrolment; not retired here.", - "children": { - "variables": { - "status": "dead", - "verifiedAt": "2026-11-06", - "evidenceScope": "in-repo", - "note": "No reader; see the parent row." - }, - "postInstall": { - "status": "dead", - "verifiedAt": "2026-11-06", - "evidenceScope": "in-repo", - "note": "No reader; see the parent row." - } - } + "note": "REMOVED 2026-11-06 — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error). No D2 conversion: this file is not a stack collection member (D3 `marketplace-template-manifest-inert-keys-retired`). The entry stays because retiredKey keeps the key in the walked shape; nothing replaces it. Measured before retiring: create-objectstack never prompted for `scaffold.variables` nor ran `scaffold.postInstall`. Its two child rows went with the block: a tombstone has no children in the walked shape." }, "readmePath": { "status": "live", diff --git a/packages/spec/scripts/check-template-manifests.ts b/packages/spec/scripts/check-template-manifests.ts index 737176eb7e0..2291494ae26 100644 --- a/packages/spec/scripts/check-template-manifests.ts +++ b/packages/spec/scripts/check-template-manifests.ts @@ -53,6 +53,15 @@ const repoRoot = join(pkgRoot, '..', '..'); */ const TEMPLATE_ROOT = join(repoRoot, 'packages/create-objectstack/src/templates'); +/** + * The first-party marketplace apps (`packages/apps/connector-*`) ship the same + * file outside the template tree (#31035), so they are walked too. Each root + * must yield at least one manifest: a root that moved must not go quiet. + */ +const APPS_ROOT = join(repoRoot, 'packages/apps'); + +const MANIFEST_ROOTS = [TEMPLATE_ROOT, APPS_ROOT]; + const MANIFEST_FILENAME = 'objectstack.manifest.json'; /** The `$schema` these files claim. Verifying the claim is half the point. */ @@ -106,7 +115,7 @@ export function checkManifest(path: string): string[] { const problems: string[] = []; // The gate's authority comes from the FILENAME — every `objectstack.manifest.json` - // under the template root is judged against `TemplateManifestSchema`. So a file + // under a manifest root is judged against `TemplateManifestSchema`. So a file // claiming some other `$schema` is not an exemption, it is a contradiction: one // of the two statements is wrong and a reader cannot tell which. Absent is fine // (the claim is optional); present and different is not. @@ -153,9 +162,7 @@ function selfTest(): never { const base = { $schema: TEMPLATE_MANIFEST_SCHEMA_URL, - name: 'blank', namespace: 'blank', - specVersion: '^6.0.0', displayName: 'Blank Starter', }; @@ -203,6 +210,14 @@ function selfTest(): never { 'wrong-schema fixture: a manifest claiming a different $schema must FAIL', ); + // RED — a retired key is refused with its prescription (#31035): the + // tombstone, not a silent strip, is what this gate reports. + const retired = write('retired-key', { ...base, specVersion: '^18.0.0' }); + check( + checkManifest(retired).some((p) => p.startsWith('specVersion:') && p.includes('was removed')), + `retired-key fixture: a retired key must FAIL with its prescription; got ${JSON.stringify(checkManifest(retired))}`, + ); + // RED — malformed JSON is a finding, not a skip. const broken = write('broken', '{ not json'); check( @@ -213,8 +228,8 @@ function selfTest(): never { // Discovery walks recursively and finds exactly the manifests written above. const discovered = findManifests(dir); check( - discovered.length === 7, - `discovery: expected 7 fixture manifests, found ${discovered.length}`, + discovered.length === 8, + `discovery: expected 8 fixture manifests, found ${discovered.length}`, ); // The vacuous-green guard's input: an empty tree yields zero, which `main` @@ -237,7 +252,7 @@ function selfTest(): never { console.log( '✅ self-test: accepts a manifest with no manifestId (#7319) and one with a well-formed id,\n' + ' rejects a malformed manifestId / namespace (#6861), a missing required key, a\n' + - ' contradictory $schema and malformed JSON; discovery walks the template tree.', + ' retired key, a contradictory $schema and malformed JSON; discovery walks the template tree.', ); process.exit(0); } @@ -247,19 +262,23 @@ function main(): void { console.log(`🧪 Parsing every shipped ${MANIFEST_FILENAME} against TemplateManifestSchema...\n`); - const manifests = findManifests(TEMPLATE_ROOT); - - // Vacuous-green guard. Zero files is far more likely to mean "the templates - // moved" than "we ship no templates", and a gate that checked nothing must not - // report success. - if (manifests.length === 0) { - console.error( - `✗ No ${MANIFEST_FILENAME} found under ${rel(TEMPLATE_ROOT)}.\n\n` + - ` Every bundled template ships one, so this is almost certainly a moved\n` + - ` directory rather than an empty one — point TEMPLATE_ROOT at wherever the\n` + - ` templates live now. A gate that parses nothing must not report success.\n`, - ); - process.exit(1); + const manifests: string[] = []; + for (const root of MANIFEST_ROOTS) { + const found = findManifests(root); + // Vacuous-green guard, per root. Zero files is far more likely to mean "the + // templates (or apps) moved" than "we ship none", and a gate that checked + // nothing under a root must not report success for it. + if (found.length === 0) { + console.error( + `✗ No ${MANIFEST_FILENAME} found under ${rel(root)}.\n\n` + + ` Every bundled template and first-party marketplace app ships one, so this is\n` + + ` almost certainly a moved directory rather than an empty one — point\n` + + ` MANIFEST_ROOTS at wherever they live now. A gate that parses nothing must not\n` + + ` report success.\n`, + ); + process.exit(1); + } + manifests.push(...found); } let bad = 0; diff --git a/packages/spec/src/marketplace/package-namespace.test.ts b/packages/spec/src/marketplace/package-namespace.test.ts index bc7ae29630b..64a515b3802 100644 --- a/packages/spec/src/marketplace/package-namespace.test.ts +++ b/packages/spec/src/marketplace/package-namespace.test.ts @@ -188,8 +188,6 @@ describe('TemplateManifestSchema declares namespace as a scaffold-only extra', ( return { manifestId: 'com.acme.blank', displayName: 'Blank Starter', - name: 'blank', - specVersion: '^6.0.0', ...overrides, }; } diff --git a/packages/spec/src/marketplace/taxonomy.test.ts b/packages/spec/src/marketplace/taxonomy.test.ts index 282431324e7..da7d9a88e46 100644 --- a/packages/spec/src/marketplace/taxonomy.test.ts +++ b/packages/spec/src/marketplace/taxonomy.test.ts @@ -63,7 +63,7 @@ const PACKAGE_ROW = { createdBy: 'usr_1', }; const CREATE_REQUEST = { manifestId: 'com.acme.crm', ownerOrgId: 'org_1', displayName: 'Acme CRM', createdBy: 'usr_1' }; -const TEMPLATE_MANIFEST = { name: 'acme-crm', specVersion: '^18.0.0', displayName: 'Acme CRM' }; +const TEMPLATE_MANIFEST = { displayName: 'Acme CRM' }; const LISTING = { id: 'listing-001', packageId: 'com.acme.crm', diff --git a/packages/spec/src/marketplace/template-manifest-id.test.ts b/packages/spec/src/marketplace/template-manifest-id.test.ts index 00955de711b..af4d8067a3f 100644 --- a/packages/spec/src/marketplace/template-manifest-id.test.ts +++ b/packages/spec/src/marketplace/template-manifest-id.test.ts @@ -26,8 +26,6 @@ import { TemplateManifestSchema } from './template-manifest.zod'; /** A template manifest that is valid except for whatever a case changes. */ function templateManifest(overrides: Record = {}) { return { - name: 'blank', - specVersion: '^6.0.0', displayName: 'Blank Starter', ...overrides, }; @@ -69,7 +67,7 @@ describe('TemplateManifestSchema relaxes manifestId to optional', () => { }); it('relaxes manifestId ONLY — the other required keys are untouched', () => { - const result = TemplateManifestSchema.safeParse({ name: 'blank', specVersion: '^6.0.0' }); + const result = TemplateManifestSchema.safeParse({ namespace: 'blank' }); expect(result.success).toBe(false); const paths = result.success ? [] : result.error.issues.map((i) => i.path.join('.')); expect(paths).toContain('displayName'); diff --git a/packages/spec/src/marketplace/template-manifest-inert-keys-retirement.test.ts b/packages/spec/src/marketplace/template-manifest-inert-keys-retirement.test.ts new file mode 100644 index 00000000000..664d515feb4 --- /dev/null +++ b/packages/spec/src/marketplace/template-manifest-inert-keys-retirement.test.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Five `objectstack.manifest.json` keys nothing read are RETIRED (#31035, + * ADR-0049 enforce-or-remove): `name`, `specVersion`, `skills`, `scaffold` and + * `visibility`. + * + * Measured before removal on objectstack 115a43ad: no reader of the file's + * `name`, `specVersion`, `skills` or `scaffold` existed in `os package + * publish`, create-objectstack or anywhere else (create-objectstack WROTE + * `name` and never read it back; the protocol check reads the artifact's + * `engines.protocol`; the skills install is the whole catalog), and `os + * package publish` read `visibility` only to warn that it ignores it. + * `publisher` and `isStarter` stay: a first-party publish script sends both, + * so their fate is a maintainer decision, and the control case below holds + * that this retirement did not take them. + * + * The bookkeeping shape, pinned below: a `retiredKey()` tombstone per key (the + * schema is not strict, so a bare delete would strip silently), five + * `RETIRED_KEYS_BY_MAJOR[18]` rows, one D3 semantic entry, no D2 conversion + * (this file is not a stack collection member — the `category` precedent on + * the same schema), and a tree-scoped absence scan over every shipped + * manifest, including the ones `check:template-manifests` does not walk. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues + * carry `code` and `path` but no ADR-0112 `status`. Asserted: refusal, the + * issue `code`, the `path` naming the key, and the prescription's opening. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { TemplateManifestSchema, type TemplateManifest } from './template-manifest.zod'; + +const RETIRED = ['name', 'specVersion', 'skills', 'scaffold', 'visibility'] as const; +const D3_ID = 'marketplace-template-manifest-inert-keys-retired'; + +/** A value each key used to accept — the shape an old manifest still carries. */ +const OLD_VALUES: Record<(typeof RETIRED)[number], unknown> = { + name: 'blank', + specVersion: '^18.0.0', + skills: ['objectstack-platform'], + scaffold: { variables: { appName: { type: 'string' } } }, + visibility: 'marketplace', +}; + +const BASE = { displayName: 'Blank Starter' } as const; + +describe('TemplateManifestSchema — five inert keys are retired', () => { + it.each(RETIRED)('REFUSES `%s` at its own path, carrying the prescription', (key) => { + const result = TemplateManifestSchema.safeParse({ ...BASE, [key]: OLD_VALUES[key] }); + expect(result.success).toBe(false); + if (result.success) return; + const issue = result.error.issues.find((i) => i.path.join('.') === key); + expect(issue, JSON.stringify(result.error.issues)).toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.message).toMatch( + new RegExp(`^\`TemplateManifest\\.${key}\` was removed in @objectstack/spec 18 \\(ADR-0049\\) — `), + ); + }); + + it('names what to do instead for the two keys whose replacement lives elsewhere', () => { + const message = (key: string, value: unknown) => { + const result = TemplateManifestSchema.safeParse({ ...BASE, [key]: value }); + return result.success ? '' : result.error.issues.map((i) => i.message).join('\n'); + }; + expect(message('visibility', 'org')).toContain('--visibility'); + expect(message('specVersion', '^18.0.0')).toContain('engines.protocol'); + }); + + it('control: a manifest without them parses, and `publisher` / `isStarter` are NOT retired here', () => { + expect(TemplateManifestSchema.safeParse({ ...BASE }).success).toBe(true); + expect(TemplateManifestSchema.safeParse({ ...BASE, publisher: 'community', isStarter: true }).success).toBe(true); + }); + + it('the tsc channel: no value is assignable to a retired key', () => { + const ok: TemplateManifest = { ...BASE }; + // @ts-expect-error — `name` is a retiredKey() tombstone: no value is assignable. + const a: TemplateManifest = { ...BASE, name: 'blank' }; + // @ts-expect-error — `specVersion` is a retiredKey() tombstone. + const b: TemplateManifest = { ...BASE, specVersion: '^18.0.0' }; + // @ts-expect-error — `skills` is a retiredKey() tombstone. + const c: TemplateManifest = { ...BASE, skills: ['objectstack-platform'] }; + // @ts-expect-error — `scaffold` is a retiredKey() tombstone. + const d: TemplateManifest = { ...BASE, scaffold: {} }; + // @ts-expect-error — `visibility` is a retiredKey() tombstone on this file. + const e: TemplateManifest = { ...BASE, visibility: 'org' }; + expect([ok, a, b, c, d, e]).toHaveLength(6); + }); +}); + +describe('ADR-0087 registration', () => { + it('declares every tombstoned key under major 18', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toEqual( + expect.arrayContaining(RETIRED.map((key) => `marketplace/TemplateManifest:${key}`)), + ); + }); + + it('carries exactly one D3 semantic entry for the family, naming all five keys', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries).toHaveLength(1); + for (const key of RETIRED) expect(entries[0]!.surface).toContain(`TemplateManifest.${key}`); + expect(entries[0]!.reason.length).toBeGreaterThan(0); + expect(entries[0]!.acceptanceCriteria.length).toBeGreaterThan(0); + }); + + it('carries no D2 conversion — this file is not a stack collection member', () => { + expect(ALL_CONVERSIONS.filter((c) => /TemplateManifest/.test(c.surface)).map((c) => c.id)).toEqual([]); + }); +}); + +// Tree-scoped absence (the retirement playbook's rule: never file-scoped). +// `check:template-manifests` walks create-objectstack's templates and +// `packages/apps`; this walk covers every `objectstack.manifest.json` under +// `packages/` and `examples/`, so a manifest shipped anywhere else is held too. +// +// Radius, declared in `scripts/cross-package-test-inputs.mjs` under +// `@objectstack/spec`: the `packages/**/*.json` and `examples/**/*.json` rows, +// on which this file is a `heldBy` witness (the walk descends with +// `readdirSync` on a loop variable, so no literal path names the glob). +describe('tree-scoped absence: no shipped objectstack.manifest.json declares a retired key', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const WALK_ROOTS = ['packages', 'examples']; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + it('every manifest in the radius omits the five keys', () => { + const found: string[] = []; + const offenders: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile() || entry.name !== 'objectstack.manifest.json') continue; + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + found.push(rel); + const manifest = JSON.parse(fs.readFileSync(full, 'utf-8')) as Record; + for (const key of RETIRED) if (key in manifest) offenders.push(`${rel} declares \`${key}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the blank template and the three connector apps at least. + expect(found).toEqual(expect.arrayContaining(['packages/create-objectstack/src/templates/blank/objectstack.manifest.json'])); + expect(found.length).toBeGreaterThanOrEqual(4); + expect(offenders, 'a retired key in a shipped manifest means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/marketplace/template-manifest.zod.ts b/packages/spec/src/marketplace/template-manifest.zod.ts index 8b57ddb73a0..3d6bc79c81b 100644 --- a/packages/spec/src/marketplace/template-manifest.zod.ts +++ b/packages/spec/src/marketplace/template-manifest.zod.ts @@ -3,8 +3,8 @@ /** * `objectstack.manifest.json` — on-disk descriptor for a template / package * source tree. Strict projection of `CreatePackageRequestSchema` (server- - * managed fields excluded) plus scaffold-time extras (name slug, - * specVersion, namespace, skills, preview, scaffold, readmePath), with + * managed fields excluded) plus scaffold-time extras (namespace, preview, + * readmePath), with * `manifestId` locally relaxed to OPTIONAL — this file is a source tree, not * a publish request (#7319; the field's own TSDoc carries the measurement). * @@ -20,6 +20,37 @@ import { retiredKey } from '../shared/retired-key'; import { CreatePackageRequestSchema } from './package.zod'; import { retiredCategoryGuidance } from './taxonomy.zod'; +/** + * Why each retired `objectstack.manifest.json` key was inert, and what to do + * instead — the second half of each tombstone's prescription (ADR-0049 + * enforce-or-remove, #31035). Measured before retiring: no reader of the + * file's value in `os package publish`, create-objectstack or any other + * consumer; `visibility` was read only to warn that publish ignores it. + */ +const RETIRED_TEMPLATE_MANIFEST_KEY_REASONS = { + name: + 'nothing read it: `os package publish` addresses the package by `manifestId` (or derives one from the ' + + 'compiled artifact), and a project\'s slug is its `package.json` `name`. Delete the key.', + specVersion: + 'nothing read it. Delete the key. The protocol a package needs is `manifest.engines.protocol` in ' + + '`objectstack.config.ts` (the handshake the runtime checks), and the spec release it builds against is the ' + + '`@objectstack/spec` version in `package.json`.', + skills: + 'nothing read it: create-objectstack installs the whole `@objectstack/skills` catalog whatever this file ' + + 'lists. Delete the key.', + scaffold: + 'nothing read it: create-objectstack never prompted for `scaffold.variables` nor ran ' + + '`scaffold.postInstall`. Delete the key.', + visibility: + '`os package publish` never read it from this file. Delete the key and pass `--visibility` (`private`, ' + + '`org` or `marketplace`) to `os package publish`.', +} as const; + +function retiredTemplateManifestKeyGuidance(key: keyof typeof RETIRED_TEMPLATE_MANIFEST_KEY_REASONS): string { + return `\`TemplateManifest.${key}\` was removed in @objectstack/spec 18 (ADR-0049) — ` + + RETIRED_TEMPLATE_MANIFEST_KEY_REASONS[key]; +} + export const TemplateManifestSchema = lazySchema(() => CreatePackageRequestSchema // `namespace` is omitted from the inherited create-request projection and @@ -81,9 +112,12 @@ export const TemplateManifestSchema = lazySchema(() => manifestId: CreatePackageRequestSchema.shape.manifestId.optional().describe( 'Optional declarative default for the published package id (reverse-domain, e.g. com.acme.crm). Absent on a template source tree: `objectstack package publish` falls back to --manifest-id and then to a derived `local.`. NOT optional on the publish request itself' ), - name: z.string().regex(/^[a-z][a-z0-9-]*$/) - .describe('CLI slug (kebab-case, no namespace prefix)'), - specVersion: z.string().describe('Compatible @objectstack/spec semver range'), + // REMOVED (ADR-0049 enforce-or-remove, #31035) — `name`, `specVersion`, + // `skills`, `scaffold` and `visibility` below are retiredKey tombstones: + // nothing read the file's value of any of them. The prescription is the + // tombstone's guidance (see `retiredTemplateManifestKeyGuidance`). + name: retiredKey(retiredTemplateManifestKeyGuidance('name')), + specVersion: retiredKey(retiredTemplateManifestKeyGuidance('specVersion')), /** * The template's OWN metadata namespace — a scaffold-time value, not a * publish-time claim. @@ -117,16 +151,19 @@ export const TemplateManifestSchema = lazySchema(() => * shipped manifest against this schema, so none can still carry it. */ category: retiredKey(retiredCategoryGuidance('TemplateManifest.category')), - skills: z.array(z.string()).optional() - .describe('Skill ids exercised by this template (for docs / picker)'), + skills: retiredKey(retiredTemplateManifestKeyGuidance('skills')), preview: z.object({ screenshots: z.array(z.string()).optional(), demoUrl: z.string().url().optional(), }).optional(), - scaffold: z.object({ - variables: z.record(z.string(), z.any()).optional(), - postInstall: z.array(z.string()).optional(), - }).optional(), + scaffold: retiredKey(retiredTemplateManifestKeyGuidance('scaffold')), + /** + * REMOVED — inherited from `CreatePackageRequestSchema`, where the publish + * request's `visibility` stays live; re-declared here so this file refuses + * it. `os package publish` never read it from the file (the + * `--visibility` flag carries it) and now refuses it by name. + */ + visibility: retiredKey(retiredTemplateManifestKeyGuidance('visibility')), readmePath: z.string().optional() .describe('Path (relative to manifest) to long-form README'), }) diff --git a/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__name.ts b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__name.ts new file mode 100644 index 00000000000..4e6aee699de --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__name.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// ADR-0049 enforce-or-remove on `objectstack.manifest.json`: nothing read the +// file's `name`. `retiredKey()` carries the prescription and the `tsc` channel. +// No D2 conversion: this file is not a stack collection member (the +// `category` precedent on the same schema). The D3 record is +// `marketplace-template-manifest-inert-keys-retired`. +export const entry = 'marketplace/TemplateManifest:name'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__scaffold.ts b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__scaffold.ts new file mode 100644 index 00000000000..dd9d31459cf --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__scaffold.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// ADR-0049 enforce-or-remove on `objectstack.manifest.json`: nothing read the +// file's `scaffold`. `retiredKey()` carries the prescription and the `tsc` channel. +// No D2 conversion: this file is not a stack collection member (the +// `category` precedent on the same schema). The D3 record is +// `marketplace-template-manifest-inert-keys-retired`. +export const entry = 'marketplace/TemplateManifest:scaffold'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__skills.ts b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__skills.ts new file mode 100644 index 00000000000..970528c2d9a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__skills.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// ADR-0049 enforce-or-remove on `objectstack.manifest.json`: nothing read the +// file's `skills`. `retiredKey()` carries the prescription and the `tsc` channel. +// No D2 conversion: this file is not a stack collection member (the +// `category` precedent on the same schema). The D3 record is +// `marketplace-template-manifest-inert-keys-retired`. +export const entry = 'marketplace/TemplateManifest:skills'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__specVersion.ts b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__specVersion.ts new file mode 100644 index 00000000000..4949aeb51cb --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__specVersion.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// ADR-0049 enforce-or-remove on `objectstack.manifest.json`: nothing read the +// file's `specVersion`. `retiredKey()` carries the prescription and the `tsc` channel. +// No D2 conversion: this file is not a stack collection member (the +// `category` precedent on the same schema). The D3 record is +// `marketplace-template-manifest-inert-keys-retired`. +export const entry = 'marketplace/TemplateManifest:specVersion'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__visibility.ts b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__visibility.ts new file mode 100644 index 00000000000..4b4edb89115 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__visibility.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// ADR-0049 enforce-or-remove on `objectstack.manifest.json`: `os package +// publish` read the file's `visibility` only to warn that it ignores it (the +// `--visibility` flag carries it); the publish request's own `visibility` +// stays live. `retiredKey()` carries the prescription and the `tsc` channel. +// No D2 conversion: this file is not a stack collection member (the +// `category` precedent on the same schema). The D3 record is +// `marketplace-template-manifest-inert-keys-retired`. +export const entry = 'marketplace/TemplateManifest:visibility'; diff --git a/packages/spec/src/migrations/entries/semantic/18.marketplace-template-manifest-inert-keys-retired.ts b/packages/spec/src/migrations/entries/semantic/18.marketplace-template-manifest-inert-keys-retired.ts new file mode 100644 index 00000000000..557b5e2bc10 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.marketplace-template-manifest-inert-keys-retired.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0049 enforce-or-remove on `objectstack.manifest.json`: one family, one +// entry. Five `TemplateManifest` keys are in `RETIRED_KEYS_BY_MAJOR[18]`. +// `publisher` and `isStarter` are NOT in this family: a first-party publish +// script sends both, so their fate is a maintainer decision. +export const entry: SemanticMigration = { + id: 'marketplace-template-manifest-inert-keys-retired', + surface: + 'TemplateManifest.name / TemplateManifest.specVersion / TemplateManifest.skills / ' + + 'TemplateManifest.scaffold / TemplateManifest.visibility (objectstack.manifest.json)', + replacement: + 'delete the five keys from objectstack.manifest.json. The project slug is `package.json` `name`, and the ' + + 'published package id is `manifestId` (or the one `os package publish` derives from the compiled ' + + 'artifact). The protocol a package needs is `manifest.engines.protocol` in `objectstack.config.ts`, and ' + + 'the spec release it builds against is its `@objectstack/spec` dependency. create-objectstack installs ' + + 'the whole skills catalog, so there is nothing to list. Pass `--visibility` to `os package publish` to ' + + 'set a package\'s visibility', + reason: + 'Each key looked like configuration and configured nothing. No reader of the file\'s `name`, ' + + '`specVersion`, `skills` or `scaffold` existed in `os package publish`, create-objectstack or anywhere ' + + 'else: create-objectstack wrote `name` and never read it back, never prompted for `scaffold.variables` ' + + 'nor ran `scaffold.postInstall`, and installs every skill whatever `skills` lists; the protocol check ' + + 'reads the artifact\'s `engines.protocol`, never `specVersion`. `os package publish` read `visibility` ' + + 'only to warn that it ignores it. Why D3 semantic and not a D2 conversion: this file is not a stack ' + + 'collection member, so a conversion would be a transform with no seam that ever runs. Moving a ' + + '`visibility` value onto the publish command line is a judgement about the publish, not a mechanical ' + + 'edit of the file.', + acceptanceCriteria: + 'No objectstack.manifest.json writes `name`, `specVersion`, `skills`, `scaffold` or `visibility`: a ' + + 'TypeScript author fails `tsc` (each key is typed as the retired mark), a parse of TemplateManifestSchema ' + + 'refuses each with its prescription, and `os package publish` refuses a manifest carrying `visibility` ' + + 'before any network call, naming `--visibility`. Every shipped manifest omits the five keys and passes ' + + '`check:template-manifests`, and create-objectstack no longer writes `name` into a scaffolded ' + + 'project\'s manifest.', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 2e0d63ca37f..71d70652a96 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -2365,6 +2365,17 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'with no D2 conversion, because none of these shapes is a stack collection member; the D3 record ' + 'is the semantic entry `marketplace-category-retired`.', }, + { + id: 'marketplace-template-manifest-inert-keys-retired', + order: 120, + text: + 'It also retires five `objectstack.manifest.json` keys nothing read — `name`, `specVersion`, `skills`, ' + + '`scaffold`, and `visibility`, which `os package publish` only warned it ignores: the slug lives in ' + + '`package.json`, the protocol handshake in the stack\'s `engines.protocol`, the skills install is the whole ' + + 'catalog, and visibility is the `--visibility` flag. Each key is a retiredKey tombstone with no D2 ' + + 'conversion, because this file is not a stack collection member; the D3 record is the semantic entry ' + + '`marketplace-template-manifest-inert-keys-retired`.', + }, { id: 'memory-persistence-placeholder-refused', order: 1, diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index fbf980b353e..e18e3e3b9c3 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -56,6 +56,7 @@ "src/kernel/metadata-type-registry-allow-org-override-absence.test.ts", "src/kernel/package-upgrade-family-retirement.test.ts", "src/marketplace/dependency-spellings-retirement.test.ts", + "src/marketplace/template-manifest-inert-keys-retirement.test.ts", "src/qa/requires-plugins-retirement.test.ts", "src/security/admin-scope-binding-authoring-retirement.test.ts", "src/security/permission-set-managed-by-retirement.test.ts", diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index a05d602ff1e..5f05197ebd7 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -283,6 +283,9 @@ export const CROSS_PACKAGE_TEST_INPUTS = { 'packages/spec/src/system/compliance-families-retirement.test.ts', 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', 'packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts', + // The template-manifest retirement's absence walk: every + // `objectstack.manifest.json` under `packages/` and `examples/`. + 'packages/spec/src/marketplace/template-manifest-inert-keys-retirement.test.ts', ], 'packages/**/*.md': [ 'packages/spec/src/system/compliance-families-retirement.test.ts', @@ -308,6 +311,9 @@ export const CROSS_PACKAGE_TEST_INPUTS = { 'packages/spec/src/system/compliance-families-retirement.test.ts', 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', 'packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts', + // The template-manifest retirement's absence walk: every + // `objectstack.manifest.json` under `packages/` and `examples/`. + 'packages/spec/src/marketplace/template-manifest-inert-keys-retirement.test.ts', ], 'examples/**/*.md': [ 'packages/spec/src/system/compliance-families-retirement.test.ts', diff --git a/scripts/sync-template-versions.mjs b/scripts/sync-template-versions.mjs index a58bde0deb8..dbea0a6a9e4 100644 --- a/scripts/sync-template-versions.mjs +++ b/scripts/sync-template-versions.mjs @@ -14,7 +14,12 @@ // // node scripts/sync-template-versions.mjs // -// THREE SURFACES, NOT ONE, AND THE MISSING ONE IS WHY (#9264). This script used +// TWO SURFACES TODAY. The manifest's `specVersion` was the third until it was +// retired under ADR-0049 (#31035): nothing read it, the runtime handshake is +// `engines.protocol`, and the spec line is the exact dependency. The history +// below is why every stamp that remains is REQUIRED and DISCOVERED. +// +// THREE SURFACES, NOT ONE, AND THE MISSING ONE WAS WHY (#9264). This script used // to stamp two keys in two hard-coded `blank/` paths and never opened // `objectstack.manifest.json` at all. So `specVersion` — a REQUIRED field of // TemplateManifestSchema, copied verbatim into every scaffolded project by @@ -35,14 +40,10 @@ // skip. A silent skip is indistinguishable from a green run, which is // precisely the invisibility this card was filed about. // -// THREE VALUES, THREE MEANINGS — do not collapse them. +// TWO VALUES, TWO MEANINGS — do not collapse them. // // * `engines.protocol` is the ADR-0087 D1 runtime handshake range and // carries the PROTOCOL major (`^18`). -// * `specVersion` is documented by TemplateManifestSchema as the "Compatible -// @objectstack/spec semver range" and carries the PACKAGE range -// (`^18.0.0`). It states which spec line the template is authored -// against, so it stays a RANGE. // * every `@objectstack/*` dependency carries the scaffolder's EXACT version // (`18.1.0`, a prerelease such as `19.0.0-rc.0` verbatim). A breaking // change on the v18 line may ship in a minor or a patch, so the docs tell a @@ -52,12 +53,9 @@ // exact version is always published for every one of these names: they // all sit in the changesets `fixed` group with `create-objectstack`. // -// Deps and `specVersion` used to be one value, which is why the ratchet -// compared them for equality. They now agree on the MAJOR — the exact spec -// dependency lies on the line `specVersion` names — and that is what -// `template-consistency.test.ts` holds. `engines.protocol` and `specVersion` -// agree on the major only because the spec package's major and the protocol -// major are kept in lockstep; they are still different declarations. +// The retired `specVersion` carried a third, the PACKAGE range (`^18.0.0`); +// the exact dependency already names that line, which is part of why nothing +// needed it. // // ## THE DECLARATIONS ARE EXPORTED, AND NOTHING RUNS ON IMPORT (#9554) // @@ -132,14 +130,14 @@ import { isEntrypoint } from './invoked-as.mjs'; // remedy is to find what stopped registering. const SELF_TEST_BATTERIES = Object.freeze({ 'Control G: the fixture table covers every declared stamp': 1, - 'Control A: a CLEAN corpus is REACHED, and left byte-identical and UNWRITTEN': 20, + 'Control A: a CLEAN corpus is REACHED, and left byte-identical and UNWRITTEN': 14, 'Control B: a MISSING stamp is a hard failure naming the path': 4, - 'Control C: ONE run names EVERY unstamped surface': 6, + 'Control C: ONE run names EVERY unstamped surface': 4, 'Control D: a template with no @objectstack/* dependency exits 1': 2, 'Control E: a stamp FILE that does not exist exits 1 naming it': 2, 'Control F: an unparseable template package.json exits 1 naming it': 2, 'Control H: zero templates refuses a vacuous green': 3, - 'Control I: a PRERELEASE corpus is clean at its exact version, with specVersion still a range': 10, + 'Control I: a PRERELEASE corpus is clean at its exact version, with engines.protocol at its major': 9, 'Control J: a scaffolder version that is not a whole semver exits 1 and writes nothing': 3, }); @@ -184,10 +182,8 @@ const rel = (p) => relative(root, p).split(sep).join('/'); * `check-docs-image-tag.mjs` makes between its static `PATTERNS` and the * `expected` version threaded through as a parameter. * - * Rewritten as TEXT, not parse/re-serialize, and that matters for the manifest: - * `objectstack.manifest.json` keeps `scaffold.variables` compact on one line, so - * `JSON.stringify(…, null, 2)` would reformat 42 bytes of unrelated structure - * on every release. A targeted replace touches the value and nothing else. + * Rewritten as TEXT, not parse/re-serialize: a targeted replace touches the + * value and nothing else, so unrelated formatting never churns a release diff. */ export const TEXT_STAMPS = [ { @@ -200,18 +196,9 @@ export const TEXT_STAMPS = [ value: ({ major }) => `^${major}`, replacement: ({ major }) => `engines: { protocol: '^${major}' }`, }, - { - file: 'objectstack.manifest.json', - key: 'specVersion', - // Required by TemplateManifestSchema and read by the template registry; - // `create-objectstack` copies it verbatim into every scaffolded project - // (it rewrites name/displayName/namespace and drops description, and has - // never touched this key), so a stale value ships to real users. Carries - // the PACKAGE range — a range even though the deps beside it are exact. - pattern: /("specVersion"\s*:\s*)"[^"]*"/, - value: ({ range }) => range, - replacement: ({ range }) => `$1"${range}"`, - }, + // The manifest's `specVersion` row was retired with the key (ADR-0049, + // #31035): no reader of the file's value existed, so stamping it kept a + // declaration nothing honoured in step with the one the runtime checks. ]; /** @@ -290,8 +277,6 @@ export function loadScaffolderVersion(file = join(root, VERSION_SOURCE)) { /** The exact version: every `@objectstack/*` dependency, prereleases verbatim. */ version: String(version), major, - /** The manifest's `specVersion` range. ⛔ Not a dependency value — deps are exact. */ - range: `^${major}.0.0`, }; } @@ -305,7 +290,7 @@ function main() { console.error(`✗ sync-template-versions: ${err.message}`); process.exit(1); } - const { version, major, range } = scaffolder; + const { version, major } = scaffolder; const templates = findTemplateDirs(); @@ -379,7 +364,7 @@ function main() { // ── the text stamps ───────────────────────────────────────────────────── for (const stamp of TEXT_STAMPS) { const path = join(TEMPLATE_ROOT, template, stamp.file); - const value = stamp.value({ version, major, range }); + const value = stamp.value({ version, major }); let src; try { src = readFileSync(path, 'utf8'); @@ -403,7 +388,7 @@ function main() { continue; } - const stamped = src.replace(stamp.pattern, stamp.replacement({ version, major, range })); + const stamped = src.replace(stamp.pattern, stamp.replacement({ version, major })); if (stamped === src) { console.log(`✓ ${template}/${stamp.file} already stamps ${stamp.key} '${value}'`); } else { @@ -419,14 +404,14 @@ function main() { console.error( '\n Each of these is a declared version surface that would have shipped stale. The script\n' + ' fails rather than skipping, because a skipped stamp is indistinguishable from a synced\n' + - ' one in the log — which is how specVersion drifted eleven majors (#9264).\n', + ' one in the log — which is how a stamp once drifted eleven majors (#9264).\n', ); process.exit(1); } console.log( `\n✓ sync-template-versions: ${templates.length} template(s) in lockstep with ` + - `create-objectstack@${version} — deps at ${version}, specVersion at ${range}, engines.protocol at '^${major}'.`, + `create-objectstack@${version} — deps at ${version}, engines.protocol at '^${major}'.`, ); } @@ -531,7 +516,6 @@ const SELF_TEST_VERSION = '42.0.0'; const SELF_TEST_BODIES = { 'engines.protocol': (major) => `export default defineStack({ manifest: { engines: { protocol: '^${major}' } } });\n`, - specVersion: (major) => `{\n "specVersion": "^${major}.0.0",\n "scaffold": { "variables": [] }\n}\n`, }; function writeFixtureFile(file, contents) { @@ -541,8 +525,7 @@ function writeFixtureFile(file, contents) { /** * A throwaway checkout shaped like this repo, IN LOCKSTEP with `version`: every - * `@objectstack/*` dep at that EXACT version, `specVersion` at its major's - * range, `engines.protocol` at its major. Returns the path of the copied script + * `@objectstack/*` dep at that EXACT version, `engines.protocol` at its major. Returns the path of the copied script * to run. */ function buildFixture(dir, { templates = ['blank', 'second'], version = SELF_TEST_VERSION } = {}) { @@ -727,10 +710,10 @@ function selfTest() { { const dir = fixtureDir('missing-stamp'); const script = buildFixture(dir); - const stamp = TEXT_STAMPS.find((s) => s.key === 'specVersion'); + const stamp = TEXT_STAMPS.find((s) => s.key === 'engines.protocol'); writeFixtureFile( join(dir, TEMPLATE_DIR, 'second', stamp.file), - '{\n "scaffold": { "variables": [] }\n}\n', + 'export default defineStack({ manifest: {} });\n', ); const { status, output } = runFixture(script); @@ -764,7 +747,7 @@ function selfTest() { } const { status, output } = runFixture(script); - assert(status === 1, `four unstamped surfaces exit 1 — got ${status}\n${output}`); + assert(status === 1, `${broken.length} unstamped surfaces exit 1 — got ${status}\n${output}`); assert( output.includes(`${broken.length} unstamped surface(s)`), `the run counts all ${broken.length} problems in one pass\n${output}`, @@ -849,14 +832,14 @@ function selfTest() { ); } - // ── Control I: a PRERELEASE corpus is clean at its exact version, with specVersion still a range ── + // ── Control I: a PRERELEASE corpus is clean at its exact version, with engines.protocol at its major ── // // The two values a release-candidate cut stamps, observed in the direction // only a clean corpus can show. The deps carry `42.0.0-rc.1` verbatim and - // `specVersion` carries `^42.0.0`; a rewriter that collapsed the two values + // `engines.protocol` carries `^42`; a rewriter that collapsed the two values // back into one — either one — rewrites this corpus, and Control A would // not notice, because on a plain release both readings of "the major" agree. - battery('Control I: a PRERELEASE corpus is clean at its exact version, with specVersion still a range'); + battery('Control I: a PRERELEASE corpus is clean at its exact version, with engines.protocol at its major'); { const prerelease = '42.0.0-rc.1'; const dir = fixtureDir('prerelease'); @@ -878,10 +861,12 @@ function selfTest() { `${template}/${TEMPLATE_PKG_FILE} is judged clean at the EXACT prerelease, not at a range\n${output}`, ); } - assert( - output.includes(`specVersion at ^42.0.0`), - `the run reports specVersion as the major's range, not the prerelease\n${output}`, - ); + for (const template of ['blank', 'second']) { + assert( + output.includes(`${template}/objectstack.config.ts already stamps engines.protocol '^42'`), + `${template} is judged clean with engines.protocol at the major, not the prerelease\n${output}`, + ); + } for (const snapshot of before) { const rel = relative(dir, snapshot.file); assert( @@ -976,7 +961,7 @@ function selfTest() { 'A CLEAN corpus is observed REACHED, byte-identical and UNWRITTEN; a missing stamp, a missing file, an ' + 'unparseable package.json, a template with no @objectstack/* dependency and an empty templates directory ' + 'are each observed exiting 1 and naming the path; one run is observed naming EVERY unstamped surface; a ' + - 'prerelease corpus is observed clean with deps at the exact version and specVersion still a range; and a ' + + 'prerelease corpus is observed clean with deps at the exact version and engines.protocol at its major; and a ' + 'scaffolder version that is not a whole semver is observed exiting 1 with nothing written. ' + 'The STALE -> rewritten direction and the discovery walk belong to ' + 'packages/create-objectstack/src/template-version-stamps.test.ts and are deliberately not restated here.',