From 2e69d3eee08c53226f65e745cd24f151ab88af66 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:00:37 +0000 Subject: [PATCH 1/3] feat(spec)!: retire PackageDependency.optional and AppManifest.dependencies, the two unread dependency spellings ADR-0141 D8 items 2 and 3: both keys are tombstoned with retiredKey(), registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry (dependency-unread-spellings-retired) and a step-18 rationale fragment. No D2 conversion: neither shape is a stack collection member. Claude-Session: https://claude.ai/code/session_01BkRZmjDUobsYmkGEjwjH9U Co-authored-by: Claude --- .../contracts/app-lifecycle-service.test.ts | 1 - .../package-dependency-dual-source.test.ts | 8 +- .../dependency-spellings-retirement.test.ts | 494 ++++++++++++++++++ .../src/marketplace/package-version.zod.ts | 28 +- ...arketplace__PackageDependency__optional.ts | 18 + .../18.system__AppManifest__dependencies.ts | 18 + .../18.dependency-unread-spellings-retired.ts | 38 ++ .../spec/src/migrations/registry.ts.template | 13 + packages/spec/src/system/app-install.test.ts | 4 +- packages/spec/src/system/app-install.zod.ts | 25 +- 10 files changed, 637 insertions(+), 10 deletions(-) create mode 100644 packages/spec/src/marketplace/dependency-spellings-retirement.test.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.marketplace__PackageDependency__optional.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__AppManifest__dependencies.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.dependency-unread-spellings-retired.ts diff --git a/packages/spec/src/contracts/app-lifecycle-service.test.ts b/packages/spec/src/contracts/app-lifecycle-service.test.ts index 25617419401..693f32dffe1 100644 --- a/packages/spec/src/contracts/app-lifecycle-service.test.ts +++ b/packages/spec/src/contracts/app-lifecycle-service.test.ts @@ -14,7 +14,6 @@ describe('App Lifecycle Service Contract', () => { views: ['contact_list', 'deal_board'], flows: ['new_deal_notification'], hasSeedData: true, - dependencies: [], }; it('should allow a minimal IAppLifecycleService implementation with all required methods', () => { diff --git a/packages/spec/src/kernel/package-dependency-dual-source.test.ts b/packages/spec/src/kernel/package-dependency-dual-source.test.ts index b57ebad5b07..ff8eb9264c6 100644 --- a/packages/spec/src/kernel/package-dependency-dual-source.test.ts +++ b/packages/spec/src/kernel/package-dependency-dual-source.test.ts @@ -16,7 +16,8 @@ import { // // ./cloud — since #16325 ./marketplace — the DECLARATION form an author // writes into a package manifest: -// `{ packageId, versionRange, optional }`, embedded in +// `{ packageId, versionRange, optional }` (`optional` since retired, +// ADR-0141 D8), embedded in // `PackageManifestSchema.dependencies[]` → `sys_package_version.manifest_json`; // ./kernel — the RESOLVER form the dependency resolver walks: // `{ name, versionConstraint, type, resolvedVersion }`, embedded in @@ -144,10 +145,10 @@ describe('PackageDependency dual-source retirement (C7)', () => { packageId: 'com.objectstack.core', versionRange: '^1.0.0', }); + // `optional` is a retired tombstone (ADR-0141 D8): it materializes nothing. expect(declared).toEqual({ packageId: 'com.objectstack.core', versionRange: '^1.0.0', - optional: false, // the `.default(false)` still applies }); }); @@ -163,13 +164,14 @@ describe('PackageDependency dual-source retirement (C7)', () => { const kernelKeys = keysOf(ResolvedPackageDependencySchema as never); const cloudKeys = keysOf(PackageDependencySchema as never); expect([...kernelKeys].sort()).toEqual(['name', 'resolvedVersion', 'type', 'versionConstraint']); + // `optional` stays in the shape as a retiredKey() tombstone (ADR-0141 D8). expect([...cloudKeys].sort()).toEqual(['optional', 'packageId', 'versionRange']); expect([...kernelKeys].filter((k) => cloudKeys.has(k)), 'key sets must stay disjoint').toEqual([]); // Neither side is `.strict()`, so cross-pasting a document does not throw — // it silently strips. That is exactly the failure mode the shared name hid, // and it is why the pin above forbids a "helpful" re-export alias. - const manifestDoc = { packageId: 'com.objectstack.core', versionRange: '^1.0.0', optional: true }; + const manifestDoc = { packageId: 'com.objectstack.core', versionRange: '^1.0.0' }; const strippedByResolver = ResolvedPackageDependencySchema.safeParse(manifestDoc); expect(strippedByResolver.success, 'resolver form rejects a manifest doc only on required keys').toBe(false); diff --git a/packages/spec/src/marketplace/dependency-spellings-retirement.test.ts b/packages/spec/src/marketplace/dependency-spellings-retirement.test.ts new file mode 100644 index 00000000000..ed620f9d6a0 --- /dev/null +++ b/packages/spec/src/marketplace/dependency-spellings-retirement.test.ts @@ -0,0 +1,494 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The two unread dependency spellings RETIRED (#25792 item 6) — ADR-0141 D8 + * items 2 and 3, ADR-0049 enforce-or-remove. + * + * ADR-0141 D2 judges one declaration, the manifest record + * `ManifestSchema.dependencies` (`{ [packageId]: versionRange }`), and treats + * every entry as required. Two more spellings stayed declared beside it: + * + * - `PackageDependency.optional` — the marketplace snapshot entry's + * "missing dep is a warning, not an error" flag. The record has no optional + * arm, no snapshot writer produces the array, and no install path honoured + * it (D8 item 2). + * - `AppManifest.dependencies` — a list of bare ids, no range, "other apps + * that must be installed first". No install path consulted it (D8 item 3). + * + * Measured at `origin/main` `6390fe52ce` (recorded in ADR-0141 §D8's addendum): + * zero readers and zero writers of either key outside `packages/spec` — + * `packages/`, `ui/`, `examples/`, `apps/`, and HotCRM at `9464673` (which + * declares its three module dependencies in the record, the live control). + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone on each key, so each refusal carries its + * prescription and each key's input type is the retired mark for `tsc`. + * 2. NO D2 conversion: neither shape is a stack collection member, so a + * conversion would be a transform with no seam that ever runs (the + * `kernel/Manifest:capabilities` precedent). + * 3. `RETIRED_KEYS_BY_MAJOR[18]` carries both keys, and the family's D3 entry + * is `dependency-unread-spellings-retired`. + * 4. The residue stage (`acceptRetiredDefaultResidue`) is NOT adopted: both + * keys carried a default, but no producer in this repository or HotCRM ever + * parsed either schema to emit one, so there is no emitted population. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues + * carry `code` and `path` but no ADR-0112 `status` — neither shape has an + * authoring door of its own. Asserted: refusal, the issue `code`, the `path` + * naming the key, and the prescription text. + */ + +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 { AppManifestSchema, type AppManifest } from '../system/app-install.zod'; +import { + PackageDependencySchema, + PackageManifestSchema, + type PackageDependency, +} from './package-version.zod'; + +const D3_ID = 'dependency-unread-spellings-retired'; + +const OPTIONAL_PRESCRIPTION = + /`PackageDependency\.optional` was removed in @objectstack\/spec 18 \(ADR-0049 enforce-or-remove, ADR-0141 D8\) — no install path ever honoured it.*Delete the key\..*`manifest\.dependencies`/s; +const APP_DEPENDENCIES_PRESCRIPTION = + /`AppManifest\.dependencies` was removed in @objectstack\/spec 18 \(ADR-0049 enforce-or-remove, ADR-0141 D8\) — nothing read it.*Delete the key\..*`manifest\.dependencies`/s; + +const DEPENDENCY = { packageId: 'com.acme.core', versionRange: '^1.0.0' } as const; +const APP = { name: 'project_management', label: 'Project Management', version: '1.0.0' } as const; +const SNAPSHOT = { id: 'com.acme.psa', version: '1.0.0', name: 'PSA' } as const; + +describe('PackageDependency.optional retirement — the tombstone', () => { + it('the entry schema refuses `optional` at its path, with the prescription', () => { + const r = PackageDependencySchema.safeParse({ ...DEPENDENCY, optional: true }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual(['optional']); + expect(issue.message).toMatch(OPTIONAL_PRESCRIPTION); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue.message.startsWith('`PackageDependency.optional` was removed')).toBe(true); + }); + + it('refuses EVERY value — `true`, its old DEFAULT `false`, and a value of another type', () => { + for (const value of [true, false, 'yes', 0, null, {}]) { + const r = PackageDependencySchema.safeParse({ ...DEPENDENCY, optional: value }); + expect(r.success, JSON.stringify(value)).toBe(false); + if (r.success) continue; + expect(r.error.issues).toHaveLength(1); + expect(r.error.issues[0]!.path).toEqual(['optional']); + expect(r.error.issues[0]!.message).toMatch(OPTIONAL_PRESCRIPTION); + } + }); + + it('the snapshot schema refuses it inside `dependencies[]`, at the entry\'s path', () => { + const r = PackageManifestSchema.safeParse({ ...SNAPSHOT, dependencies: [DEPENDENCY, { ...DEPENDENCY, optional: false }] }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.map((i) => i.path)).toEqual([['dependencies', 1, 'optional']]); + expect(r.error.issues[0]!.message).toMatch(OPTIONAL_PRESCRIPTION); + }); + + it('CONTROL: an entry without the key passes and grows no `optional`', () => { + const r = PackageDependencySchema.safeParse(DEPENDENCY); + expect(r.success).toBe(true); + if (!r.success) return; + // Absence stays absence: a tombstone materializes nothing. + expect(r.data).not.toHaveProperty('optional'); + expect(r.data).toEqual(DEPENDENCY); + const snapshot = PackageManifestSchema.parse({ ...SNAPSHOT, dependencies: [DEPENDENCY] }); + expect(snapshot.dependencies).toEqual([DEPENDENCY]); + }); + + it('fails tsc at the authoring site: the input type of the key is the retired mark', () => { + const entry: PackageDependency = { + ...DEPENDENCY, + // @ts-expect-error — `optional` is a retiredKey() tombstone: no value is assignable. + optional: true, + }; + // The parse channel agrees with the type channel on the same literal. + expect(() => PackageDependencySchema.parse(entry)).toThrow(OPTIONAL_PRESCRIPTION); + }); +}); + +describe('AppManifest.dependencies retirement — the tombstone', () => { + it('the schema refuses `dependencies` at its path, with the prescription', () => { + const r = AppManifestSchema.safeParse({ ...APP, dependencies: ['core_crm'] }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual(['dependencies']); + expect(issue.message).toMatch(APP_DEPENDENCIES_PRESCRIPTION); + expect(issue.message.startsWith('`AppManifest.dependencies` was removed')).toBe(true); + }); + + it('refuses EVERY value — a list, its old DEFAULT `[]`, and the record spelling it points to', () => { + // The record spelling belongs in `manifest.dependencies`, not here: an app + // manifest that writes it is still refused, never silently re-read. + for (const value of [['core_crm'], [], { 'com.acme.core': '^1.0.0' }, 'core_crm', null]) { + const r = AppManifestSchema.safeParse({ ...APP, dependencies: value }); + expect(r.success, JSON.stringify(value)).toBe(false); + if (r.success) continue; + expect(r.error.issues).toHaveLength(1); + expect(r.error.issues[0]!.path).toEqual(['dependencies']); + expect(r.error.issues[0]!.message).toMatch(APP_DEPENDENCIES_PRESCRIPTION); + } + }); + + it('CONTROL: the same manifest without the key passes, every live default intact, and grows no `dependencies`', () => { + const r = AppManifestSchema.safeParse(APP); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data).not.toHaveProperty('dependencies'); + // The live defaults still apply, so the empty reading above is the + // retirement and not a schema that stopped emitting. + expect(r.data.objects).toEqual([]); + expect(r.data.hasSeedData).toBe(false); + }); + + it('fails tsc at the authoring site: the input type of the key is the retired mark', () => { + const manifest: AppManifest = { + ...APP, + // @ts-expect-error — `dependencies` is a retiredKey() tombstone: no value is assignable. + dependencies: ['core_crm'], + }; + expect(() => AppManifestSchema.parse(manifest)).toThrow(APP_DEPENDENCIES_PRESCRIPTION); + }); +}); + +describe('dependency spellings retirement — ADR-0087 registration', () => { + it('declares both keys under major 18', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toEqual(expect.arrayContaining([ + 'marketplace/PackageDependency:optional', + 'system/AppManifest:dependencies', + ])); + }); + + it('carries no D2 conversion — neither shape is a stack collection member', () => { + const touching = ALL_CONVERSIONS.filter((c) => /PackageDependency|AppManifest|dependencies\[\]\.optional/.test(c.surface)); + expect(touching.map((c) => c.id)).toEqual([]); + }); + + it('carries ONE D3 entry for the family, pointing at the record', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries, 'the family needs its own D3 entry').toHaveLength(1); + const [entry] = entries; + expect(entry!.replacement).toContain('`manifest.dependencies`'); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — `retiredKey()` types both keys as the retired +// mark, so every TYPED authoring site fails to compile. The residue is what +// `tsc` never judges: JSON, YAML, MD/MDX code fences, untyped `.js`, and TS +// literals typed `any` / `unknown` / a record. This walk covers that residue +// across the five repo roots `scripts/cross-package-test-inputs.mjs` already +// declares for `@objectstack/spec#test`, plus the example apps' own `src/` +// trees, declared there as `examples/*/src/**/*.ts`. +// +// The matcher judges the AUTHORING SHAPE, never a mention. `optional` and +// `dependencies` are among the commonest keys in the tree (a plugin +// dependency's `optional`, a kernel plugin's `dependencies`, the manifest +// record itself), so a key alone proves nothing. An offence is the retired key +// at the TOP LEVEL of an object literal that also carries its shape's anchor: +// `optional` beside `versionRange` (no other dependency shape spells its range +// that way), and `dependencies` beside `minKernelVersion` or `hasSeedData` (keys +// no other metadata shape declares). In code, string and comment contents are +// masked first (a quoted key is kept, for JSON); in YAML, the sibling keys of a +// mapping that holds an anchor; in MD/MDX, each fenced block by its language. +// The bound, stated: an entry assembled by spread, under computed keys, or with +// its anchor in a different literal, and `docs/**`, `.claude/**`, `.github/**` +// and the repo-root files are outside what this walk sees. +describe('tree-scoped absence: nothing inside the declared radius still authors either retired dependency spelling', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + /** One retired spelling: the anchor keys that identify its shape, and the retired key. */ + interface Spelling { readonly name: string; readonly anchors: readonly string[]; readonly retired: string } + const SPELLINGS: readonly Spelling[] = [ + { name: 'PackageDependency.optional', anchors: ['versionRange'], retired: 'optional' }, + { name: 'AppManifest.dependencies', anchors: ['minKernelVersion', 'hasSeedData'], retired: 'dependencies' }, + ]; + const ANCHOR_WORDS = SPELLINGS.flatMap((s) => s.anchors); + + /** `key` in key position — never a tombstone declaration. */ + const keyPattern = (key: string) => new RegExp(`(^|[\\s{,])["']?${key}["']?\\s*:(?!\\s*retiredKey\\()`, 'g'); + + /** + * Code with string and comment CONTENTS blanked (same length, so offsets + * hold). A quoted string followed by `:` is a key and is kept — that is how + * JSON and quoted TS keys stay visible. A template literal is blanked whole, + * its `${…}` interpolations included: an entry is never authored inside one. + */ + const maskCode = (src: string): string => { + const out = src.split(''); + const blank = (from: number, to: number) => { + for (let k = from; k < to; k += 1) if (out[k] !== '\n') out[k] = ' '; + }; + let i = 0; + while (i < src.length) { + const c = src[i]!; + const next = src[i + 1]; + if (c === '/' && next === '/') { + const end = src.indexOf('\n', i); + const stop = end === -1 ? src.length : end; + blank(i, stop); + i = stop; + } else if (c === '/' && next === '*') { + const end = src.indexOf('*/', i + 2); + const stop = end === -1 ? src.length : end + 2; + blank(i, stop); + i = stop; + } else if (c === '"' || c === "'") { + let j = i + 1; + while (j < src.length && src[j] !== c && src[j] !== '\n') j += src[j] === '\\' ? 2 : 1; + const close = Math.min(j, src.length - 1); + const isKey = /^\s*:/.test(src.slice(close + 1, close + 40)); + if (!isKey) blank(i + 1, close); + i = close + 1; + } else if (c === '`') { + let j = i + 1; + let depth = 0; + while (j < src.length) { + const d = src[j]!; + if (d === '\\') { j += 2; continue; } + if (depth === 0 && d === '`') break; + if (d === '$' && src[j + 1] === '{') { depth += 1; j += 2; continue; } + if (depth > 0 && (d === '"' || d === "'")) { + let q = j + 1; + while (q < src.length && src[q] !== d && src[q] !== '\n') q += src[q] === '\\' ? 2 : 1; + j = q + 1; + continue; + } + if (depth > 0 && d === '{') depth += 1; + else if (depth > 0 && d === '}') depth -= 1; + j += 1; + } + blank(i + 1, Math.min(j, src.length)); + i = j + 1; + } else { + i += 1; + } + } + return out.join(''); + }; + + /** The `{ … }` span enclosing `at` in masked code, or null. */ + const enclosingObject = (code: string, at: number): [number, number] | null => { + let depth = 0; + let start = -1; + for (let k = at - 1; k >= 0; k -= 1) { + const c = code[k]; + if (c === '}') depth += 1; + else if (c === '{') { + if (depth === 0) { start = k; break; } + depth -= 1; + } + } + if (start === -1) return null; + depth = 0; + for (let k = start; k < code.length; k += 1) { + const c = code[k]; + if (c === '{') depth += 1; + else if (c === '}') { + depth -= 1; + if (depth === 0) return [start, k]; + } + } + return null; + }; + + /** Nesting depth of `{[(` between `from` (exclusive) and `to`. */ + const depthBetween = (code: string, from: number, to: number): number => { + let depth = 0; + for (let k = from + 1; k < to; k += 1) { + const c = code[k]; + if (c === '{' || c === '[' || c === '(') depth += 1; + else if (c === '}' || c === ']' || c === ')') depth -= 1; + } + return depth; + }; + + const judgeCode = (src: string): string | null => { + const code = maskCode(src); + for (const spelling of SPELLINGS) { + for (const anchor of spelling.anchors) { + for (const m of code.matchAll(keyPattern(anchor))) { + const span = enclosingObject(code, m.index! + m[1]!.length); + if (!span) continue; + const body = code.slice(span[0], span[1] + 1); + for (const k of body.matchAll(keyPattern(spelling.retired))) { + const keyAt = span[0] + k.index! + k[1]!.length; + if (depthBetween(code, span[0], keyAt) === 0) return `${spelling.name} (beside ${anchor})`; + } + } + } + } + return null; + }; + + /** A YAML mapping that holds an anchor and, at the same indentation, its retired key. */ + const judgeYaml = (src: string): string | null => { + const lines = src.split(/\r?\n/); + const keyOf = (line: string) => /^([ \t]*(?:-[ \t]+)?)([A-Za-z_][\w]*)[ \t]*:/.exec(line); + const ownIndent = (line: string) => /^[ \t]*/.exec(line)![0].length; + for (let n = 0; n < lines.length; n += 1) { + const m = keyOf(lines[n]!); + if (!m) continue; + const spelling = SPELLINGS.find((s) => s.anchors.includes(m[2]!)); + if (!spelling) continue; + const indent = m[1]!.length; + for (const step of [-1, 1]) { + for (let k = n + step; k >= 0 && k < lines.length; k += step) { + const line = lines[k]!; + if (line.trim() === '') continue; + const s = keyOf(line); + if (s && s[1]!.length === indent && s[2] === spelling.retired) return `${spelling.name} (beside ${m[2]})`; + // A list item opening this mapping (`- packageId: …` two columns left). + const item = /^([ \t]*)-[ \t]+([A-Za-z_]\w*)[ \t]*:/.exec(line); + if (item && item[1]!.length + 2 === indent) { + if (item[2] === spelling.retired) return `${spelling.name} (beside ${m[2]})`; + break; + } + if (ownIndent(line) < indent && !item) break; + } + } + } + return null; + }; + + const FENCE = /^```([\w-]*)[^\n]*\n([\s\S]*?)^```/gm; + const judgeMarkdown = (src: string): string | null => { + for (const m of src.matchAll(FENCE)) { + const lang = m[1]!.toLowerCase(); + const verdict = lang === 'yaml' || lang === 'yml' ? judgeYaml(m[2]!) : judgeCode(m[2]!); + if (verdict) return verdict; + } + return null; + }; + + const judge = (text: string, ext: string): string | null => { + if (ext === '.md' || ext === '.mdx') return judgeMarkdown(text); + if (ext === '.yaml' || ext === '.yml') return judgeYaml(text); + return judgeCode(text); + }; + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell a retired key. + */ + const EXCLUDED = new Set([ + // This pin authors both keys to assert their refusal. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output (`packages/spec/json-schema/`), reached only + // because this is a FILESYSTEM walk. Its sources are the two zod files. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a mention, another shape\'s key and the tombstone (anti-vacuity)', () => { + // Offenders — each retired shape, in each syntax the walk reads. + expect(judge("const d = { packageId: 'a', versionRange: '^1', optional: true };", '.ts')).not.toBeNull(); + expect(judge('{ "dependencies": [{ "packageId": "a", "versionRange": "^1", "optional": false }] }', '.json')).not.toBeNull(); + expect(judge("({ name: 'a', label: 'A', version: '1.0.0', hasSeedData: true, dependencies: ['b'] })", '.js')).not.toBeNull(); + expect(judge('{ "name": "a", "minKernelVersion": "3.0.0", "dependencies": [] }', '.json')).not.toBeNull(); + expect(judge('dependencies:\n - packageId: a\n versionRange: ^1\n optional: true\n', '.yaml')).not.toBeNull(); + expect(judge('name: a\nhasSeedData: false\ndependencies:\n - b\n', '.yml')).not.toBeNull(); + expect(judge("Prose.\n\n```ts\nconst d = { versionRange: '^1', optional: true };\n```\n", '.mdx')).not.toBeNull(); + // Neighbours that must NOT match. + // The tombstone declarations themselves, beside the live anchor declarations. + expect(judge("({ versionRange: z.string(), optional: retiredKey(GUIDANCE) })", '.ts')).toBeNull(); + expect(judge("({ hasSeedData: z.boolean(), dependencies: retiredKey(GUIDANCE) })", '.ts')).toBeNull(); + // The manifest record and a plugin dependency are other shapes. + expect(judge("manifest: { id: 'a', dependencies: { 'b': '^1.0.0' } }", '.ts')).toBeNull(); + expect(judge("({ pluginId: 'a', version: '^1', optional: true })", '.ts')).toBeNull(); + expect(judge("({ name: 'p', version: '1.0.0', dependencies: [] as string[] })", '.ts')).toBeNull(); + // A nested object's key is not the entry's. + expect(judge("({ versionRange: '^1', meta: { optional: true } })", '.ts')).toBeNull(); + // Mentions: comments, strings, and prose outside a fence. + expect(judge("// once: { versionRange: '^1', optional: true }\nconst x = 1;", '.ts')).toBeNull(); + expect(judge("const s = \"{ hasSeedData: true, dependencies: [] }\";", '.ts')).toBeNull(); + expect(judge('Delete `optional`; `versionRange` stays.', '.mdx')).toBeNull(); + expect(judge('"marketplace/PackageDependency:optional [RETIRED]",', '.json')).toBeNull(); + }); + + it('no retired dependency spelling survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + let anchorFiles = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + const text = readIfPresent(full); + if (text === undefined || !ANCHOR_WORDS.some((w) => text.includes(w))) continue; + anchorFiles += 1; + const verdict = judge(text, ext); + if (verdict) offenders.push(`${rel}: ${verdict}`); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree, the example apps' + // sources were really read, and the files the matcher judges were really + // reached (the two schemas, their tests, the core and metadata-core readers + // of the record, and the generated reference pages all carry an anchor). + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(anchorFiles).toBeGreaterThan(5); + expect(offenders, 'an authoring of a retired dependency spelling means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/marketplace/package-version.zod.ts b/packages/spec/src/marketplace/package-version.zod.ts index 55a0997c596..9eefee8e086 100644 --- a/packages/spec/src/marketplace/package-version.zod.ts +++ b/packages/spec/src/marketplace/package-version.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { SEMVER_2_0_0_VERSION_PATTERN } from '../kernel/version-grammar'; +import { retiredKey } from '../shared/retired-key'; /** * Package Version Protocol @@ -58,8 +59,31 @@ export const PackageDependencySchema = lazySchema(() => z.object({ packageId: z.string().describe('Manifest ID of the dependency'), /** Required version range (semver, e.g. "^1.0.0"). */ versionRange: z.string().describe('Semver version range (e.g. ^1.0.0)'), - /** Whether this dependency is optional (missing dep is a warning, not an error). */ - optional: z.boolean().default(false).describe('Whether this dependency is optional'), + /** + * REMOVED (#25792 item 6; ADR-0141 D8 item 2, ADR-0049 enforce-or-remove). + * + * Said "missing dep is a warning, not an error", and nothing ever made it + * so. The authored dependency is the manifest record + * `ManifestSchema.dependencies` (`{ [packageId]: versionRange }`): it has no + * optional arm, and the ADR-0141 D2 pre-install check reads it alone and + * treats every entry as required. This array is a projection of that record + * (D8), so an entry carries the id and the range and nothing the record + * cannot say. Measured at retirement: no reader and no writer of the key + * outside `packages/spec` (this repository and HotCRM). + * + * Tombstoned rather than deleted, so the rejection carries this prescription + * in both channels (`tsc` and the parse). No D2 conversion: a snapshot is not + * a stack collection member (the `kernel/Manifest:capabilities` precedent); + * the D3 record is `dependency-unread-spellings-retired`. + */ + optional: retiredKey( + '`PackageDependency.optional` was removed in @objectstack/spec 18 (ADR-0049 ' + + 'enforce-or-remove, ADR-0141 D8) — no install path ever honoured it: the ' + + 'pre-install check reads the package\'s `manifest.dependencies` record, which ' + + 'has no optional arm, and treats every entry as required. Delete the key. ' + + 'Declare a dependency in `manifest.dependencies` (`{ \'com.acme.core\': ' + + '\'^1.0.0\' }`) only when the package cannot install without it.', + ), }).describe('Package dependency declaration')); export type PackageDependency = z.input; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.marketplace__PackageDependency__optional.ts b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__PackageDependency__optional.ts new file mode 100644 index 00000000000..2a72eff6184 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__PackageDependency__optional.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #25792 item 6 — ADR-0141 D8 item 2, ADR-0049 enforce-or-remove. +// `PackageDependency.optional` ("missing dep is a warning, not an error") had +// no record spelling: the authored dependency is `ManifestSchema.dependencies` +// (`{ [packageId]: versionRange }`), the ADR-0141 D2 pre-install check reads it +// alone and treats every entry as required, and the marketplace snapshot's +// array is a projection of that record. Measured at `origin/main` `6390fe52ce`: +// no reader and no writer of the key outside `packages/spec`, and none in +// HotCRM at `9464673`. +// +// `retiredKey()` for the prescription and the `tsc` channel. The key was +// DEFAULTED (`false`); the `acceptRetiredDefaultResidue` stage is NOT adopted, +// because nothing in either repository parses the snapshot schema to emit the +// default. No D2 conversion: a snapshot is not a stack collection member (the +// `kernel/Manifest:capabilities` precedent). The D3 record is +// `dependency-unread-spellings-retired`. +export const entry = 'marketplace/PackageDependency:optional'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__AppManifest__dependencies.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__AppManifest__dependencies.ts new file mode 100644 index 00000000000..2bfeabfc7c8 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__AppManifest__dependencies.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #25792 item 6 — ADR-0141 D8 item 3, ADR-0049 enforce-or-remove. +// `AppManifest.dependencies` listed bare ids with no range, and no install path +// consulted it; the ADR-0141 D2 pre-install check judges the manifest record +// `ManifestSchema.dependencies` alone. D8 offered two fates, a projection from +// the record or a retirement, "decided with a measured reader count". Measured +// at `origin/main` `6390fe52ce`: no reader of the key, and no parse of +// `AppManifestSchema` at all, outside `packages/spec`; none in HotCRM at +// `9464673`. Zero readers leave a projection nobody would read, so it retires. +// +// `retiredKey()` for the prescription and the `tsc` channel. The key was +// DEFAULTED (`[]`); the `acceptRetiredDefaultResidue` stage is NOT adopted, +// because nothing parses the schema to emit the default. No D2 conversion: an +// app manifest is not a stack collection member (the +// `kernel/Manifest:capabilities` precedent). The D3 record is +// `dependency-unread-spellings-retired`. +export const entry = 'system/AppManifest:dependencies'; diff --git a/packages/spec/src/migrations/entries/semantic/18.dependency-unread-spellings-retired.ts b/packages/spec/src/migrations/entries/semantic/18.dependency-unread-spellings-retired.ts new file mode 100644 index 00000000000..06dbb08c5a8 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.dependency-unread-spellings-retired.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #25792 item 6 — ADR-0141 D8 items 2 and 3: the stated fates for the two +// dependency spellings that sat declared beside the record the install check +// reads. One family, one entry; both keys are in `RETIRED_KEYS_BY_MAJOR[18]`. +export const entry: SemanticMigration = { + id: 'dependency-unread-spellings-retired', + // No backticks in `surface`: build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'PackageDependency.optional (an entry of the marketplace snapshot manifestJson.dependencies[]) ' + + '/ AppManifest.dependencies (the bare-id list on the app install manifest)', + replacement: + 'delete both keys. A dependency is declared once, with its range, in the package\'s own ' + + '`manifest.dependencies` record (`{ \'com.acme.core\': \'^1.0.0\' }`); the ADR-0141 D2 ' + + 'pre-install check enforces every entry as required, and the marketplace snapshot array is a ' + + 'projection of that record carrying `packageId` and `versionRange` only', + reason: + 'ADR-0049 enforce-or-remove, deciding ADR-0141 D8 items 2 and 3 on a measured reader count. ' + + 'Measured at origin/main 6390fe52ce: no reader and no writer of either key outside ' + + 'packages/spec, across packages/, ui/, examples/ and apps/, and none in HotCRM at 9464673, ' + + 'whose three modules declare their dependency in the record (the live control). ' + + '`optional` promised "missing dep is a warning, not an error" and the record it projects ' + + 'from has no optional arm, so no install path could honour it; `AppManifest.dependencies` ' + + 'restated the record without its range and nothing consulted it. Why D3 semantic and not a ' + + 'D2 conversion: neither shape is a stack collection member, so a conversion would be a ' + + 'transform with no seam that ever runs (the kernel/Manifest:capabilities precedent).', + acceptanceCriteria: + 'No source, snapshot or app manifest writes either key: a TypeScript author fails `tsc` (each ' + + 'key is typed as the retired mark), and a parse of PackageDependencySchema, ' + + 'PackageManifestSchema or AppManifestSchema refuses the key with its prescription, the old ' + + 'default included. The live neighbour is untouched and must be verified as such: ' + + '`manifest.dependencies` keeps driving the pre-install check, the namespace co-owners and ' + + 'the hydrate order. Runtime behaviour is deliberately UNCHANGED: nothing ever read either ' + + 'key, so removing them removes no behaviour.', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 0bb08269cd6..033ef951d94 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -1701,6 +1701,19 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'it. Its D3 record is the semantic entry `declared-index-bare-unique-true-retired`: ' + 'whether each respelled index was really meant installation-wide is the author\'s call.', }, + { + id: 'dependency-unread-spellings-retired', + order: 110, + text: + 'It also states the fates ADR-0141 D8 left to a measured reader count for the two dependency ' + + 'spellings beside the record the pre-install check reads (ADR-0049 enforce-or-remove): ' + + '`PackageDependency.optional`, which promised a missing dependency would only warn though the ' + + 'record has no optional arm and every entry is required, and `AppManifest.dependencies`, a ' + + 'bare-id list without a range. Neither had a reader or a writer outside `packages/spec`, here or ' + + 'in HotCRM, so both are tombstoned and `manifest.dependencies` is the one way to declare a ' + + 'dependency. There is no D2 conversion, because neither shape is a stack collection member; the ' + + 'D3 record is the semantic entry `dependency-unread-spellings-retired`.', + }, { id: 'deployment-plumbing-organization-columns-retired', order: 86, diff --git a/packages/spec/src/system/app-install.test.ts b/packages/spec/src/system/app-install.test.ts index 3014f54f05d..46bbceadc0d 100644 --- a/packages/spec/src/system/app-install.test.ts +++ b/packages/spec/src/system/app-install.test.ts @@ -22,7 +22,6 @@ describe('AppManifestSchema', () => { views: ['project_board', 'task_list'], flows: ['auto_assign', 'notify_on_complete'], hasSeedData: true, - dependencies: ['core_crm'], }; const parsed = AppManifestSchema.parse(manifest); expect(parsed.name).toBe('project_management'); @@ -42,7 +41,8 @@ describe('AppManifestSchema', () => { expect(parsed.flows).toEqual([]); expect(parsed.hasSeedData).toBe(false); expect(parsed.seedData).toEqual([]); - expect(parsed.dependencies).toEqual([]); + // `dependencies` is a retired tombstone (ADR-0141 D8): it materializes nothing. + expect(parsed).not.toHaveProperty('dependencies'); }); it('should enforce snake_case name', () => { diff --git a/packages/spec/src/system/app-install.zod.ts b/packages/spec/src/system/app-install.zod.ts index c668062769f..f3a129ed70f 100644 --- a/packages/spec/src/system/app-install.zod.ts +++ b/packages/spec/src/system/app-install.zod.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; +import { retiredKey } from '../shared/retired-key'; /** * App Installation Protocol @@ -56,8 +57,28 @@ export const AppManifestSchema = lazySchema(() => z.object({ /** Seed data records to populate on install */ seedData: z.array(z.record(z.string(), z.unknown())).default([]).describe('Seed data records'), - /** App dependencies (other apps that must be installed first) */ - dependencies: z.array(z.string()).default([]).describe('Required app dependencies'), + /** + * REMOVED (#25792 item 6; ADR-0141 D8 item 3, ADR-0049 enforce-or-remove). + * + * A list of bare ids with no version range, "other apps that must be + * installed first", and no install path ever consulted it. The ADR-0141 D2 + * pre-install check judges one declaration, the manifest record + * `ManifestSchema.dependencies` (`{ [packageId]: versionRange }`), which says + * the same thing with a range. Measured at retirement: no reader and no + * writer of the key outside `packages/spec` (this repository and HotCRM). + * + * Tombstoned rather than deleted, so the rejection carries this prescription + * in both channels (`tsc` and the parse). No D2 conversion: an app manifest is + * not a stack collection member (the `kernel/Manifest:capabilities` + * precedent); the D3 record is `dependency-unread-spellings-retired`. + */ + dependencies: retiredKey( + '`AppManifest.dependencies` was removed in @objectstack/spec 18 (ADR-0049 ' + + 'enforce-or-remove, ADR-0141 D8) — nothing read it: it listed bare ids with ' + + 'no version range, and no install path consulted it. Delete the key. Declare ' + + 'each dependency with its range in the package\'s `manifest.dependencies` ' + + '(`{ \'com.acme.core\': \'^1.0.0\' }`), which the pre-install check enforces.', + ), }).describe('App manifest for marketplace installation')); export type AppManifest = z.input; From 654812102e8e550204cd69ffb17cfefdab59459e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:07:19 +0000 Subject: [PATCH 2/3] docs(spec): regenerate artifacts and record ADR-0141 D8's measured fates for the two dependency spellings Claude-Session: https://claude.ai/code/session_01BkRZmjDUobsYmkGEjwjH9U Co-authored-by: Claude --- .../25792-retire-dependency-spellings.md | 29 +++++++++++++++++++ .../marketplace/package-version.mdx | 6 ++-- .../docs/references/system/app-install.mdx | 2 +- ...endency-declared-checked-before-install.md | 11 +++++++ .../spec/authorable-defaults/marketplace.json | 1 - packages/spec/authorable-defaults/system.json | 1 - .../spec/authorable-surface/marketplace.json | 2 +- packages/spec/authorable-surface/system.json | 2 +- 8 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 .changeset/25792-retire-dependency-spellings.md diff --git a/.changeset/25792-retire-dependency-spellings.md b/.changeset/25792-retire-dependency-spellings.md new file mode 100644 index 00000000000..b103d9f7c34 --- /dev/null +++ b/.changeset/25792-retire-dependency-spellings.md @@ -0,0 +1,29 @@ +--- +'@objectstack/spec': minor +--- + +**BREAKING** — the two unread dependency spellings leave the authoring surface: `PackageDependency.optional` (an entry of the marketplace snapshot's `manifestJson.dependencies[]`) and `AppManifest.dependencies` (the bare-id list on the app install manifest) are `retiredKey()` tombstones (#25792, ADR-0141 D8 items 2 and 3). + +A `major`-class change, recorded as `minor` under the launch-window convention. ADR-0141 D8 left both fates to "a measured reader count"; the count was zero for both, so both retire at once, with no retirement stage (ADR-0049 enforce-or-remove). + +Clause-②: no (narrowing) +Metadata-protocol: narrowed + +**Why.** A dependency is declared in one place, the package's `manifest.dependencies` record (`{ [packageId]: versionRange }`), and the ADR-0141 D2 pre-install check reads that record alone and treats every entry as required. Two more spellings stayed declared beside it and nothing read either. `optional: true` promised "missing dep is a warning, not an error", but the record has no optional arm, so no install path could honour it. `AppManifest.dependencies` restated the record without its range, and no install path consulted it. An author, often an AI, who wrote either got nothing enforced and no error. Measured at `origin/main` `6390fe52ce`: zero readers and zero writers of either key outside `packages/spec` (`packages/`, `ui/`, `examples/`, `apps/`), and none in HotCRM at `9464673`, whose three modules declare their dependency in the record. + +### FROM → TO + +| removed | from | what to write instead | +| --- | --- | --- | +| `optional` on a `PackageDependency` entry (`PackageManifestSchema.dependencies[]`) | `@objectstack/spec/marketplace` | delete the key. An entry carries `packageId` and `versionRange` only. Declare a dependency in `manifest.dependencies` only when the package cannot install without it. | +| `dependencies` on `AppManifest` | `@objectstack/spec/system` | delete the key. Declare each dependency with its range in the package's `manifest.dependencies`, for example `{ 'com.acme.core': '^1.0.0' }`. | + +**The one-line fix: delete the key.** A TypeScript author fails `tsc` first (each key is typed as the retired mark), and a parse of `PackageDependencySchema`, `PackageManifestSchema` or `AppManifestSchema` refuses the key with its prescription, the old default (`false`, `[]`) included. The parsed output no longer carries either key, so `PackageDependencyParsed` loses `optional` and `AppManifestParsed` loses `dependencies`. + +⚠️ Runtime behaviour is deliberately **unchanged**: nothing ever read either key, so every install answers exactly as before. The removal retracts a false claim, not a capability. **No deprecation window** (startup-stage posture: retirements take effect immediately). + +⚠️ **The out-of-repo consumer population beyond HotCRM is NOT MEASURED.** `@objectstack/spec` is published, and the control plane's reading of `sys_package_version.manifest_json` is not readable from this repository. + +The ADR-0087 D3 semantic entry `dependency-unread-spellings-retired` carries the judgement, and both keys are registered in `RETIRED_KEYS_BY_MAJOR[18]`. There is no D2 conversion: neither shape is a stack collection member, so a conversion would have no source to rewrite. + + diff --git a/content/docs/references/marketplace/package-version.mdx b/content/docs/references/marketplace/package-version.mdx index b3b7a35df39..a8e81cf1cf0 100644 --- a/content/docs/references/marketplace/package-version.mdx +++ b/content/docs/references/marketplace/package-version.mdx @@ -64,7 +64,7 @@ Package dependency declaration | :--- | :--- | :--- | :--- | | **packageId** | `string` | ✅ | Manifest ID of the dependency | | **versionRange** | `string` | ✅ | Semver version range (e.g. ^1.0.0) | -| **optional** | `boolean` | optional (default: `false`) | Whether this dependency is optional | +| **optional** | `never` | optional | [REMOVED] `PackageDependency.optional` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0141 D8) — no install path ever honoured it: the pre-install check reads the package's `manifest.dependencies` record, which has no optional arm, and treats every entry as required. Delete the key. Declare a dependency in `manifest.dependencies` (`{ 'com.acme.core': '^1.0.0' }`) only when the package cannot install without it. | --- @@ -83,7 +83,7 @@ Package manifest snapshot embedded in a package version | **description** | `string` | optional | Short description | | **scope** | `Enum<'platform' \| 'environment'>` | optional (default: `"environment"`) | Package scope | | **minPlatformVersion** | `string` | optional | Minimum required platform version (semver) | -| **dependencies** | `{ packageId: string; versionRange: string; optional?: boolean }[]` | optional (default: `[]`) | Package dependencies | +| **dependencies** | `{ packageId: string; versionRange: string }[]` | optional (default: `[]`) | Package dependencies | | **metadataTypes** | `string[]` | optional (default: `[]`) | Metadata types provided by this package | | **migrations** | `string[]` | optional (default: `[]`) | Migration script identifiers (ordered) | | **configurationSchema** | `Record` | optional | JSON Schema for per-installation configuration properties | @@ -97,7 +97,7 @@ Package dependency declaration | :--- | :--- | :--- | :--- | | **packageId** | `string` | ✅ | Manifest ID of the dependency | | **versionRange** | `string` | ✅ | Semver version range (e.g. ^1.0.0) | -| **optional** | `boolean` | optional (default: `false`) | Whether this dependency is optional | +| **optional** | `never` | optional | [REMOVED] `PackageDependency.optional` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0141 D8) — no install path ever honoured it: the pre-install check reads the package's `manifest.dependencies` record, which has no optional arm, and treats every entry as required. Delete the key. Declare a dependency in `manifest.dependencies` (`{ 'com.acme.core': '^1.0.0' }`) only when the package cannot install without it. | --- diff --git a/content/docs/references/system/app-install.mdx b/content/docs/references/system/app-install.mdx index aab8111f8d4..909988f75b9 100644 --- a/content/docs/references/system/app-install.mdx +++ b/content/docs/references/system/app-install.mdx @@ -111,7 +111,7 @@ App manifest for marketplace installation | **flows** | `string[]` | optional (default: `[]`) | Flow names provided | | **hasSeedData** | `boolean` | optional (default: `false`) | Whether app includes seed data | | **seedData** | `Record[]` | optional (default: `[]`) | Seed data records | -| **dependencies** | `string[]` | optional (default: `[]`) | Required app dependencies | +| **dependencies** | `never` | optional | [REMOVED] `AppManifest.dependencies` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0141 D8) — nothing read it: it listed bare ids with no version range, and no install path consulted it. Delete the key. Declare each dependency with its range in the package's `manifest.dependencies` (`{ 'com.acme.core': '^1.0.0' }`), which the pre-install check enforces. | --- diff --git a/docs/adr/0141-inter-app-dependency-declared-checked-before-install.md b/docs/adr/0141-inter-app-dependency-declared-checked-before-install.md index 9e654b3b239..e9404aadd8c 100644 --- a/docs/adr/0141-inter-app-dependency-declared-checked-before-install.md +++ b/docs/adr/0141-inter-app-dependency-declared-checked-before-install.md @@ -513,6 +513,17 @@ other two on the runtime card: The resolver-side `ResolvedPackageDependencySchema` (`@objectstack/spec/kernel`) is unaffected: it is a resolution outcome, not a declaration, and `PackageDependencySchema`'s own header already tells the two apart. +**Addendum (2026-10-27, #25792 item 6) — items 2 and 3 decided on the measured count: both retire.** Measured at +`origin/main` `6390fe52ce`: `PackageDependencySchema.optional` has **0** readers and **0** writers outside `packages/spec`, +and `AppManifestSchema.dependencies` has **0** readers and **0** writers there, with no parse of `AppManifestSchema` at all +(this repository's `packages/`, `ui/`, `examples/` and `apps/`; HotCRM at `9464673`, whose three module manifests declare +the record, the live control). Inside `packages/spec` the only authors were the two schemas and three of their own tests. +Each key is a `retiredKey()` tombstone now (`marketplace/PackageDependency:optional` and `system/AppManifest:dependencies` +in `RETIRED_KEYS_BY_MAJOR[18]`), with the D3 record `dependency-unread-spellings-retired` and no D2 conversion, because +neither shape is a stack collection member. The projection above therefore carries `packageId` = the key and +`versionRange` = the value, nothing else: item 1's conversion writes no `optional`, and `manifest.dependencies` is the +one way to declare a dependency. + --- ## Consequences diff --git a/packages/spec/authorable-defaults/marketplace.json b/packages/spec/authorable-defaults/marketplace.json index bc6ce5a1192..1597c49e87d 100644 --- a/packages/spec/authorable-defaults/marketplace.json +++ b/packages/spec/authorable-defaults/marketplace.json @@ -13,7 +13,6 @@ "marketplace/Package:isStarter = false", "marketplace/Package:publisher = \"private\"", "marketplace/Package:visibility = \"org\"", - "marketplace/PackageDependency:optional = false", "marketplace/PackageManifest:dependencies = []", "marketplace/PackageManifest:metadataTypes = []", "marketplace/PackageManifest:migrations = []", diff --git a/packages/spec/authorable-defaults/system.json b/packages/spec/authorable-defaults/system.json index 825bdbb7469..731b4b7aed0 100644 --- a/packages/spec/authorable-defaults/system.json +++ b/packages/spec/authorable-defaults/system.json @@ -9,7 +9,6 @@ "system/AppInstallResult:createdTables = []", "system/AppInstallResult:installedObjects = []", "system/AppInstallResult:seededRecords = 0", - "system/AppManifest:dependencies = []", "system/AppManifest:flows = []", "system/AppManifest:hasSeedData = false", "system/AppManifest:objects = []", diff --git a/packages/spec/authorable-surface/marketplace.json b/packages/spec/authorable-surface/marketplace.json index bbf285ed0f4..73f865f11b6 100644 --- a/packages/spec/authorable-surface/marketplace.json +++ b/packages/spec/authorable-surface/marketplace.json @@ -102,7 +102,7 @@ "marketplace/Package:translations", "marketplace/Package:updatedAt", "marketplace/Package:visibility", - "marketplace/PackageDependency:optional", + "marketplace/PackageDependency:optional [RETIRED]", "marketplace/PackageDependency:packageId", "marketplace/PackageDependency:versionRange", "marketplace/PackageManifest:configurationSchema", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index cf6f38d8607..7841b2ae9d0 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -43,7 +43,7 @@ "system/AppInstallResult:seededRecords", "system/AppInstallResult:success", "system/AppInstallResult:version", - "system/AppManifest:dependencies", + "system/AppManifest:dependencies [RETIRED]", "system/AppManifest:description", "system/AppManifest:flows", "system/AppManifest:hasSeedData", From c65c5645cbeb0a7137e73bedd962003e40bc71e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:26:45 +0000 Subject: [PATCH 3/3] test(spec): run the dependency-spellings absence pin in the repo project Claude-Session: https://claude.ai/code/session_01BkRZmjDUobsYmkGEjwjH9U Co-authored-by: Claude --- packages/spec/vitest.repo-tests.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 55c2f1f0b4c..4ed480d2253 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -52,6 +52,7 @@ "src/integration/connector-triggers-retirement.test.ts", "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/qa/requires-plugins-retirement.test.ts", "src/security/admin-scope-binding-authoring-retirement.test.ts", "src/security/permission-set-managed-by-retirement.test.ts",