From df778ab4cb4fb099a0199cf37cd932ba44360ba2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:41:29 +0000 Subject: [PATCH 1/3] test(cli, spec): pin bundled-only plugin packaging and the native-addon refusal (red) Claude-Session: https://claude.ai/code/session_01L6bhgWLCWkh3MaLj1qTLCe Co-authored-by: Claude --- .../test/plugin-build-bundled-only.test.ts | 204 ++++++++++++ .../manifest-packaging-retirement.test.ts | 314 ++++++++++++++++++ 2 files changed, 518 insertions(+) create mode 100644 packages/cli/test/plugin-build-bundled-only.test.ts create mode 100644 packages/spec/src/kernel/manifest-packaging-retirement.test.ts diff --git a/packages/cli/test/plugin-build-bundled-only.test.ts b/packages/cli/test/plugin-build-bundled-only.test.ts new file mode 100644 index 00000000000..2b521ab1d8d --- /dev/null +++ b/packages/cli/test/plugin-build-bundled-only.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os plugin build` packs one strategy, `bundled`, and refuses a native addon + * (ADR-0025 v2 §3.3; M1 of #27123). + * + * Driven through the real door, `PluginBuild.run([dir])`, over a plugin + * project on disk with its own `node_modules`, so esbuild resolves exactly + * what an author's tree would hand it. Every refusal case asserts the three + * things a refusal owes: exit 1, the refused subject named, and NO artifact + * on disk — a build that prints a refusal but still writes the `.osplugin` + * would hand `os plugin sign` the very artifact it refused. + * + * `manifest-deps` (the retired second strategy) reaches this door through + * `ManifestSchema`, which `@objectstack/cli` resolves from + * `@objectstack/spec`'s `dist/` (no source alias), so that case reads the + * built spec. + */ + +import { describe, it, expect, afterAll, afterEach, vi } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { MANIFEST_FILENAME, readTarGz } from '../src/utils/osplugin.js'; +import PluginBuild from '../src/commands/plugin/build.js'; + +const ID = 'com.acme.bundled'; +const ARTIFACT = `${ID}-1.0.0.osplugin`; + +const dirs: string[] = []; +afterEach(() => vi.restoreAllMocks()); +afterAll(async () => { + for (const d of dirs) await rm(d, { recursive: true, force: true }); +}); + +/** Write `files` (project-relative path → contents) into a fresh plugin project. */ +async function project(manifest: Record, files: Record): Promise { + const dir = await mkdtemp(join(tmpdir(), 'osplugin-bundled-')); + dirs.push(dir); + await writeFile( + join(dir, MANIFEST_FILENAME), + JSON.stringify({ id: ID, name: 'Bundled', version: '1.0.0', type: 'plugin', runtime: 'node', ...manifest }), + ); + for (const [rel, contents] of Object.entries(files)) { + await mkdir(dirname(join(dir, rel)), { recursive: true }); + await writeFile(join(dir, rel), contents); + } + return dir; +} + +/** Run the build door; the exit code is oclif's, `undefined` when it finished. */ +async function build(dir: string): Promise<{ exit: number | undefined; printed: string }> { + const lines: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => void lines.push(a.join(' '))); + let exit: number | undefined; + try { + await PluginBuild.run([dir]); + } catch (err) { + exit = (err as { oclif?: { exit?: number } }).oclif?.exit; + if (typeof exit !== 'number') throw err; + } + return { exit, printed: lines.join('\n') }; +} + +/** The refusal lines (`✗`), the channel a refusal speaks on. */ +const refusals = (printed: string): string => printed.split('\n').filter((l) => l.includes('✗')).join('\n'); + +const PURE_DEP = { + 'node_modules/pure-dep/package.json': JSON.stringify({ name: 'pure-dep', version: '1.0.0', main: 'index.js' }), + 'node_modules/pure-dep/index.js': `module.exports = 'PURE_DEP_INLINED';\n`, +}; + +describe('os plugin build — `bundled` is the one packaging strategy', () => { + for (const packaging of ['bundled', undefined] as const) { + it(`packaging ${packaging === undefined ? 'unset' : `'${packaging}'`}: the dependency is inlined and no package.json or lockfile is packed`, async () => { + const dir = await project(packaging === undefined ? {} : { packaging }, { + ...PURE_DEP, + 'package.json': JSON.stringify({ name: 'demo', dependencies: { 'pure-dep': '1.0.0' } }), + 'pnpm-lock.yaml': `lockfileVersion: '9.0'\n`, + 'src/index.ts': `import pure from 'pure-dep';\nexport const value = pure;\n`, + }); + + const { exit } = await build(dir); + + expect(exit).toBeUndefined(); + const files = readTarGz(new Uint8Array(await readFile(join(dir, ARTIFACT)))); + const paths = files.map((f) => f.path).sort(); + expect(paths).toEqual(['SIGNATURE', 'dist/index.mjs', MANIFEST_FILENAME].sort()); + const bundle = Buffer.from(files.find((f) => f.path === 'dist/index.mjs')!.data).toString('utf8'); + expect(bundle).toContain('PURE_DEP_INLINED'); + expect(bundle).not.toMatch(/from\s*["']pure-dep["']|require\(\s*["']pure-dep["']\s*\)/); + }); + } + + it("refuses the retired `packaging: 'manifest-deps'` at the manifest: exit 1, the key named, no artifact", async () => { + const dir = await project({ packaging: 'manifest-deps' }, { + ...PURE_DEP, + 'package.json': JSON.stringify({ name: 'demo', dependencies: { 'pure-dep': '1.0.0' } }), + 'src/index.ts': `import pure from 'pure-dep';\nexport const value = pure;\n`, + }); + + const { exit, printed } = await build(dir); + + expect(exit).toBe(1); + expect(printed).toContain(`${MANIFEST_FILENAME} is invalid`); + expect(refusals(printed)).toMatch(/packaging/); + expect(printed).toMatch(/manifest-deps/); + expect(existsSync(join(dir, ARTIFACT))).toBe(false); + }); +}); + +describe('os plugin build — a native addon is refused (ADR-0025 v2 §3.3)', () => { + it('refuses a `.node` file the entry imports, naming the file and its importer', async () => { + const dir = await project({}, { + 'src/native/addon.node': 'not really a binary', + 'src/index.ts': `import addon from './native/addon.node';\nexport const value = addon;\n`, + }); + + const { exit, printed } = await build(dir); + + expect(exit).toBe(1); + const refused = refusals(printed); + expect(refused).toMatch(/native addon/i); + expect(printed).toContain('src/native/addon.node'); + expect(printed).toContain('src/index.ts'); + expect(existsSync(join(dir, ARTIFACT))).toBe(false); + }); + + it('refuses a dependency whose entry IS a `.node` file, naming the package', async () => { + const dir = await project({}, { + 'node_modules/main-node/package.json': JSON.stringify({ name: 'main-node', version: '1.0.0', main: 'addon.node' }), + 'node_modules/main-node/addon.node': 'not really a binary', + 'src/index.ts': `import addon from 'main-node';\nexport const value = addon;\n`, + }); + + const { exit, printed } = await build(dir); + + expect(exit).toBe(1); + expect(refusals(printed)).toMatch(/native addon/i); + expect(printed).toContain('node_modules/main-node/addon.node'); + expect(existsSync(join(dir, ARTIFACT))).toBe(false); + }); + + // The common shape in the wild: the package's JS is bundleable, and the + // binary is located at RUN time (`bindings`, `node-gyp-build`), so esbuild + // never sees a `.node` import — the package itself is what gives it away. + const nativePackages = [ + { + why: 'declares a node-gyp build (`binding.gyp`)', + files: { 'binding.gyp': `{ 'targets': [] }\n` }, + manifest: {}, + }, + { + why: 'declares a node-gyp build (`gypfile: true`)', + files: {}, + manifest: { gypfile: true }, + }, + { + why: 'ships a compiled `.node` binary', + files: { 'prebuilds/linux-x64/fake.node': 'not really a binary' }, + manifest: {}, + }, + ]; + for (const { why, files, manifest } of nativePackages) { + it(`refuses a bundled dependency that ${why}, naming the package`, async () => { + const pkgFiles: Record = {}; + for (const [rel, contents] of Object.entries(files)) pkgFiles[`node_modules/fake-native/${rel}`] = contents; + const dir = await project({}, { + ...PURE_DEP, + ...pkgFiles, + 'node_modules/fake-native/package.json': JSON.stringify({ name: 'fake-native', version: '1.0.0', main: 'index.js', ...manifest }), + 'node_modules/fake-native/index.js': + `const path = require('path');\nmodule.exports = require(path.join(__dirname, 'build', 'Release', 'fake.node'));\n`, + 'src/index.ts': `import fake from 'fake-native';\nimport pure from 'pure-dep';\nexport const value = [fake, pure];\n`, + }); + + const { exit, printed } = await build(dir); + + expect(exit).toBe(1); + expect(refusals(printed)).toMatch(/native addon/i); + expect(printed).toContain('fake-native'); + // Only the native package is named: the pure one it sits beside is not. + expect(printed).not.toContain('pure-dep'); + expect(existsSync(join(dir, ARTIFACT))).toBe(false); + }); + } + + it('refuses a `.node` file under `assets/`, naming it', async () => { + const dir = await project({}, { + 'assets/icon.svg': '', + 'assets/bin/addon.node': 'not really a binary', + 'src/index.ts': `export const value = 1;\n`, + }); + + const { exit, printed } = await build(dir); + + expect(exit).toBe(1); + expect(refusals(printed)).toMatch(/native addon/i); + expect(printed).toContain('assets/bin/addon.node'); + expect(printed).not.toContain('assets/icon.svg'); + expect(existsSync(join(dir, ARTIFACT))).toBe(false); + }); +}); diff --git a/packages/spec/src/kernel/manifest-packaging-retirement.test.ts b/packages/spec/src/kernel/manifest-packaging-retirement.test.ts new file mode 100644 index 00000000000..a72c9ceee21 --- /dev/null +++ b/packages/spec/src/kernel/manifest-packaging-retirement.test.ts @@ -0,0 +1,314 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `packaging: 'manifest-deps'` is RETIRED — `bundled` is a plugin manifest's + * one packaging strategy (ADR-0025 v2 §3.3, maintainer decision Q6; ADR-0049 + * enforce-or-remove; ADR-0087 conversion `manifest-packaging-manifest-deps-removed`, + * D3 entry `manifest-packaging-manifest-deps-retired`). + * + * The strategy existed on the build side only: `os plugin build` left the + * `package.json` dependencies out of the bundle and packed `package.json` + + * `pnpm-lock.yaml` "for install-time resolution", and nothing ever installed + * them — `os plugin install` and the boot-time loader unpack and import + * `dist/index.mjs` as it is. The build forks are gone, so the value is too. + * + * What this file holds, in the order an upgrading author meets it: + * + * 1. `PluginPackagingSchema` is exactly `['bundled']`, and the parse refuses + * `manifest-deps` BY NAME with its prescription (`enumWithRetiredValues`) + * on every carrier of `ManifestSchema` — the manifest itself, a stack's + * `manifest`, an artifact's `packages[].manifest`; + * 2. TypeScript refuses it at the authoring site; + * 3. `bundled` and an absent key still parse, and the key takes no default; + * 4. the D2 conversion strips the retired value from a stack's manifest and + * each `packages[].manifest`, is idempotent, and is NOT replayed on the + * authoring funnel; its D3 entry names it; + * 5. tree-scoped absence: nothing inside the spec package's declared radius + * still authors the retired value. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues carry + * `code` and `path` but no ADR-0112 `status` (that envelope belongs to the + * doors), so each schema refusal is pinned by the issue `code`, the `path` and + * the prescription's first sentence; the `defineStack` door adds the envelope's + * `code` + `status`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { listRepoWalkFiles } from '../../scripts/lib/repo-walk-testkit'; +import { applyConversions } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import type { ConversionNotice } from '../conversions/types'; +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; +import { normalizeStackInput } from '../shared/metadata-collection.zod'; +import { ArtifactPackageEntrySchema, defineStack } from '../stack.zod'; +import { ManifestSchema, PluginPackagingSchema, type ObjectStackManifest } from './manifest.zod'; + +const CONVERSION_ID = 'manifest-packaging-manifest-deps-removed'; +const D3_ID = 'manifest-packaging-manifest-deps-retired'; + +const FIRST_SENTENCE = + "`packaging: 'manifest-deps'` was removed in @objectstack/spec 18.3.0 (ADR-0025 v2 §3.3, ADR-0049 " + + 'enforce-or-remove) — nothing ever installed the dependencies it left out of the bundle, so a plugin ' + + 'built with it could not load them.'; +const FIX = 'Delete the key: `bundled` is the default and the only strategy'; +const MIGRATE_SENTENCE = + 'Run `os migrate meta --from 17` to list the mechanical edits for the stack manifest case;'; + +const legal = () => ({ + id: 'com.example.probe', + namespace: 'probe', + version: '1.0.0', + type: 'plugin' as const, + name: 'Probe', +}); + +type Issue = { code: string; path: PropertyKey[]; message: string }; +type Parse = { success: boolean; error?: { issues: Issue[] } }; + +/** Every carrier of `ManifestSchema`, with the issue path `packaging` sits at. */ +const DOORS: ReadonlyArray<{ name: string; parse: (packaging: unknown) => Parse; at: PropertyKey[] }> = [ + { name: 'ManifestSchema', parse: (p) => ManifestSchema.safeParse({ ...legal(), packaging: p }) as Parse, at: ['packaging'] }, + { + name: 'ArtifactPackageEntrySchema (packages[].manifest)', + parse: (p) => ArtifactPackageEntrySchema.safeParse({ manifest: { ...legal(), packaging: p } }) as Parse, + at: ['manifest', 'packaging'], + }, +]; + +const samePath = (a: PropertyKey[], b: PropertyKey[]) => a.length === b.length && a.every((seg, i) => seg === b[i]); + +describe('manifest.packaging — `bundled` is the one strategy', () => { + it('the vocabulary is exactly `bundled`', () => { + expect(PluginPackagingSchema.options).toEqual(['bundled']); + }); + + it.each(DOORS.map((d) => [d.name, d] as const))('%s refuses `manifest-deps` at `packaging`, with its prescription', (_, door) => { + const r = door.parse('manifest-deps'); + expect(r.success).toBe(false); + const issues = (r.error?.issues ?? []).filter((i) => samePath(i.path, door.at)); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('invalid_value'); + expect(issues[0]!.message.startsWith(FIRST_SENTENCE), issues[0]!.message).toBe(true); + expect(issues[0]!.message).toContain(FIX); + expect(issues[0]!.message).toContain(MIGRATE_SENTENCE); + }); + + it('CONTROL: a misspelt value keeps zod\'s own message — "was removed" would misinform its author', () => { + const r = ManifestSchema.safeParse({ ...legal(), packaging: 'bundle' }) as Parse; + expect(r.success).toBe(false); + const issue = r.error!.issues.find((i) => samePath(i.path, ['packaging'])); + expect(issue!.code).toBe('invalid_value'); + expect(issue!.message).not.toContain('was removed'); + }); + + it('`bundled` parses, and an absent key stays absent — no default is materialized', () => { + expect(ManifestSchema.parse({ ...legal(), packaging: 'bundled' }).packaging).toBe('bundled'); + expect(ManifestSchema.parse({ ...legal() }).packaging).toBeUndefined(); + }); + + it('defineStack refuses `manifest-deps` with the ADR-0112 envelope and the prescription', () => { + let refusal: unknown; + try { + defineStack({ manifest: { ...legal(), packaging: 'manifest-deps' } } as never); + } catch (error) { + refusal = error; + } + expect(refusal).toMatchObject({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + const issues = (refusal as { issues: Issue[] }).issues; + expect(issues.map((i) => i.path.join('.'))).toEqual(['manifest.packaging']); + expect(issues[0]!.message.startsWith(FIRST_SENTENCE)).toBe(true); + for (const manifest of [{ ...legal(), packaging: 'bundled' }, { ...legal() }]) { + expect(() => defineStack({ manifest } as never)).not.toThrow(); + } + }); + + it('fails tsc at the authoring site: the retired value is not in the input type', () => { + const manifest: ObjectStackManifest = { + ...legal(), + // @ts-expect-error — `manifest-deps` left `PluginPackagingSchema`; only `bundled` type-checks. + packaging: 'manifest-deps', + }; + const packaging: z.input = 'bundled'; + expect(ManifestSchema.safeParse(manifest).success).toBe(false); + expect(PluginPackagingSchema.safeParse(packaging).success).toBe(true); + }); +}); + +type Notice = Pick; +const brief = (n: ConversionNotice): Notice => ({ conversionId: n.conversionId, path: n.path, from: n.from, to: n.to }); +const ours = (n: ConversionNotice) => n.conversionId === CONVERSION_ID; + +const replay = (stack: Record) => { + const notices: ConversionNotice[] = []; + const out = applyConversions(stack, { includeRetired: true, onNotice: (n) => notices.push(n) }); + return { out, notices: notices.filter(ours) }; +}; + +describe(`the D2 conversion ${CONVERSION_ID}`, () => { + it('strips the retired value from a stack\'s manifest and from each `packages[].manifest`, and only there', () => { + const stack = { + manifest: { id: 'com.acme.x', packaging: 'manifest-deps', runtime: 'node' }, + packages: [ + { manifest: { id: 'com.acme.a', packaging: 'manifest-deps' } }, + // The live value is the author's and rides through untouched. + { manifest: { id: 'com.acme.b', packaging: 'bundled' } }, + ], + }; + const { out, notices } = replay(structuredClone(stack)); + expect(out.manifest).toEqual({ id: 'com.acme.x', runtime: 'node' }); + expect(out.packages).toEqual([ + { manifest: { id: 'com.acme.a' } }, + { manifest: { id: 'com.acme.b', packaging: 'bundled' } }, + ]); + expect(notices.map(brief)).toEqual([ + { conversionId: CONVERSION_ID, path: 'manifest.packaging', from: 'manifest-deps', to: '(removed)' }, + { conversionId: CONVERSION_ID, path: 'packages[0].manifest.packaging', from: 'manifest-deps', to: '(removed)' }, + ]); + // The converted manifests are ones the strict door accepts. + expect(ManifestSchema.safeParse({ ...legal(), ...(out.manifest as object) }).success).toBe(true); + }); + + it('leaves a stack without the retired value as it is (copy-on-write: the same reference)', () => { + const stack = { manifest: { id: 'com.acme.x', packaging: 'bundled' }, packages: [{ manifest: { id: 'com.acme.y' } }] }; + const { out, notices } = replay(stack); + expect(notices).toEqual([]); + expect(out.manifest).toBe(stack.manifest); + expect(out.packages).toBe(stack.packages); + }); + + it('is idempotent — the converted stack replays to itself with nothing applied', () => { + const once = replay({ manifest: { id: 'com.acme.x', packaging: 'manifest-deps' } }).out; + const twice = replay(once); + expect(twice.out).toBe(once); + expect(twice.notices).toEqual([]); + }); + + it('⛔ is NOT replayed on the authoring funnel — an author meets the refusal, never a silent rewrite', () => { + const notices: ConversionNotice[] = []; + const authored = { manifest: { ...legal(), packaging: 'manifest-deps' } }; + const out = normalizeStackInput(structuredClone(authored), { onConversionNotice: (n) => notices.push(n) }); + expect((out.manifest as { packaging?: unknown }).packaging).toBe('manifest-deps'); + expect(notices.filter(ours)).toEqual([]); + }); + + it('is wired into the step-18 chain as a retired entry stamped after 18.2.0', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion).toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.retiredAfter).toBe('18.2.0'); + }); + + it(`carries the D3 entry ${D3_ID}, naming its D2 conversion`, () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries).toHaveLength(1); + expect(entries[0]!.reason).toContain(`\`${CONVERSION_ID}\``); + expect(entries[0]!.conversionIds).toEqual([CONVERSION_ID]); + expect(entries[0]!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +// ─── Tree-scoped absence, inside the radius the package already declares ─── +// +// `tsc` sweeps the TYPED authoring sites; this walk covers every text file +// under the five repo roots `scripts/cross-package-test-inputs.mjs` declares +// for `@objectstack/spec#test` (mirrored in `turbo.json`), with the example +// apps' own `src/` trees. It judges the AUTHORING SHAPE: a `packaging` key +// (TS / JS / JSON, or a fenced block in Markdown) whose value is the retired +// one. The bound, stated: a value assembled by spread or a computed key, YAML, +// `docs/` (the ADR and the design note record the retirement and sit outside +// this radius) and the console under `ui/` are outside what this walk sees. +describe('tree-scoped absence: nothing inside the declared radius still authors `manifest-deps`', () => { + 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('/'); + + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx']); + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const AUTHORING = /["']?packaging["']?\s*:\s*["']manifest-deps["']/; + /** Inline code spans are prose; newline-bounded, so a fenced example is still judged. */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + const judge = (text: string): boolean => AUTHORING.test(stripInlineCode(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 the retired value. + */ + const EXCLUDED = new Set([ + // This pin authors the value to assert its refusal and its conversion. + THIS_FILE, + // The removal-version pin parses the retired value to read its prescription. + 'packages/spec/src/shared/retired-key-removal-version.pin.test.ts', + // The build door's own pin authors it to assert `os plugin build` refuses it. + 'packages/cli/test/plugin-build-bundled-only.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement manifests on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + ]; + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + 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 its neighbours (anti-vacuity)', () => { + expect(judge("manifest: { id: 'com.acme.x', packaging: 'manifest-deps' },")).toBe(true); + expect(judge('{ "id": "com.acme.x", "packaging": "manifest-deps" }')).toBe(true); + expect(judge("Prose.\n\n```json\n{ \"packaging\": \"manifest-deps\" }\n```\n")).toBe(true); + // Neighbours that must NOT match. + expect(judge("manifest: { packaging: 'bundled' },")).toBe(false); + expect(judge('the manifest-deps strategy was retired')).toBe(false); + expect(judge("a manifest once wrote `packaging: 'manifest-deps'`.")).toBe(false); + }); + + it('no `packaging` key authoring the retired value survives inside the declared radius', () => { + const offenders: string[] = []; + let visited = 0; + let mentioning = 0; + const skipDir = (name: string) => SKIPPED_DIRS.has(name) || name.startsWith('.'); + for (const root of WALK_ROOTS) { + for (const full of listRepoWalkFiles(path.join(REPO_ROOT, root), { skipDir })) { + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + const name = path.basename(full); + const ext = path.extname(name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (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(name)) continue; + visited += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + if (/["']?packaging["']?\s*:\s*["']bundled["']/.test(text)) mentioning += 1; + if (judge(text)) offenders.push(rel); + } + } + // Anti-vacuity: the walk covered the tree, and files that DO author a + // packaging value were really judged. + expect(visited).toBeGreaterThan(1000); + expect(mentioning).toBeGreaterThan(3); + expect(offenders, 'a manifest authoring `manifest-deps` means the retirement is being undone').toEqual([]); + }); +}); From c349e372b64199181f5e9e7d08b8801cbda1befb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:59:01 +0000 Subject: [PATCH 2/3] feat(spec, cli)!: `bundled` is the one plugin packaging strategy; `os plugin build` refuses a native addon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0025 v2 §3.3 (ruling B′, slice 3, server half): - spec: PluginPackagingSchema narrows to `bundled`; `manifest-deps` is a retired value refused at every manifest door with its prescription. D2 conversion manifest-packaging-manifest-deps-removed strips it from a stack's manifest and each packages[].manifest; D3 entry manifest-packaging-manifest-deps-retired and its STEP18 fragment carry it to the upgrade guide. The removal-version pin gains its JOIN row. The `packaging` liveness row is `planned` (one-member enum, no fork to read); the manifest state-count shard is regenerated. - cli: os plugin build drops both manifest-deps forks (externalize + pack package.json / lockfile) and refuses a native addon before writing the artifact: a `.node` module in the graph, a bundled package that declares a node-gyp build or ships a `.node` file, a `.node` file under assets/. - The archive layout docblock, the platform checklist item cli.plugin-manifest-build-contract (revision 4) and the generated reference pages follow. Claude-Session: https://claude.ai/code/session_01L6bhgWLCWkh3MaLj1qTLCe Co-authored-by: Claude --- .../27123-plugin-packaging-bundled-only.md | 22 +++ .../references/api/package-api-assembled.mdx | 6 +- content/docs/references/api/package-api.mdx | 12 +- content/docs/references/api/protocol.mdx | 2 +- content/docs/references/kernel/manifest.mdx | 5 +- .../references/kernel/package-registry.mdx | 4 +- docs/qa/platform-checklist/areas/cli.json | 47 +++-- packages/cli/src/commands/plugin/build.ts | 160 +++++++++++++++--- packages/cli/src/utils/osplugin.ts | 7 +- packages/spec/liveness/manifest.json | 8 +- .../spec/liveness/state-counts/manifest.md | 2 +- packages/spec/src/conversions/registry.ts | 100 +++++++++++ .../manifest-packaging-retirement.test.ts | 2 + packages/spec/src/kernel/manifest.zod.ts | 53 +++++- ...anifest-packaging-manifest-deps-retired.ts | 40 +++++ .../spec/src/migrations/registry.ts.template | 11 ++ .../retired-key-removal-version.pin.test.ts | 6 +- 17 files changed, 410 insertions(+), 77 deletions(-) create mode 100644 .changeset/27123-plugin-packaging-bundled-only.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.manifest-packaging-manifest-deps-retired.ts diff --git a/.changeset/27123-plugin-packaging-bundled-only.md b/.changeset/27123-plugin-packaging-bundled-only.md new file mode 100644 index 00000000000..533748d654b --- /dev/null +++ b/.changeset/27123-plugin-packaging-bundled-only.md @@ -0,0 +1,22 @@ +--- +'@objectstack/spec': minor +'@objectstack/cli': patch +--- + +feat(spec, cli)!: `bundled` is the one plugin packaging strategy — `manifest-deps` is retired, and `os plugin build` refuses a native addon (#27123) + +Clause-②: no (narrowing) +Metadata-protocol: narrowed + + + +ADR-0025 v2 §3.3 makes `bundled` the only dependency-packaging strategy. `manifest-deps` kept every dependency external and packed `package.json` and the lockfile beside the bundle, but nothing ever installed them, so a plugin built with it could not load them. A compiled `.node` addon can be neither bundled nor run by every host, so a plugin may not carry one. + +- **`@objectstack/spec`**: `PluginPackagingSchema` is `['bundled']`. `manifest-deps` is a retired value: every door that parses a manifest refuses it at `packaging` with the prescription — `ManifestSchema` (an `objectstack.plugin.json` at `os plugin build`, a stack's `manifest` at `defineStack`, the ObjectQL registry), a packaged artifact's `packages[].manifest`, and the `POST /packages` install body — and TypeScript no longer admits it. The ADR-0087 D2 conversion `manifest-packaging-manifest-deps-removed` deletes the value from a stack's `manifest` and from each `packages[].manifest` (`os migrate meta --from 17`), and the D3 entry `manifest-packaging-manifest-deps-retired` carries the change to the upgrade guide. The `packaging` liveness row is `planned`: a one-member enum has no fork left to read. +- **`@objectstack/cli`**: `os plugin build` packs one strategy. It inlines every dependency except the host-provided `@objectstack/*` packages and no longer packs `package.json` or `pnpm-lock.yaml`. It refuses a native addon before it writes anything, naming each one it finds: a `.node` file the bundle imports (with its importer), a bundled package that declares a node-gyp build or ships a `.node` binary, and a `.node` file under `assets/`. An addon loaded at run time from an absolute or computed path outside every bundled package is not visible to the build. + +**BREAKING** for a plugin manifest that writes `packaging: 'manifest-deps'`, and for a plugin that carries a native addon. + +- **Before.** `packaging: 'manifest-deps'` built an artifact that left the dependencies out and carried `package.json` for an install step that never ran. A plugin importing a `.node` file failed in esbuild with a bare "Bundle failed"; one whose dependency located its binary at run time, or that kept a `.node` file under `assets/`, built an artifact that could not load it on a host without that binary. +- **Now.** `manifest-deps` is refused at the manifest (`objectstack.plugin.json is invalid:` names `packaging`, exit 1, no artifact). A native addon is refused by name (`Native addon refused: …`, exit 1, no artifact). +- **Fix.** Delete the `packaging` key, or write `bundled`. A stack source gets the edit from `os migrate meta --from 17` (`--write` applies it); an `objectstack.plugin.json` is edited by hand. Replace a native addon with a pure-JavaScript or WebAssembly alternative. diff --git a/content/docs/references/api/package-api-assembled.mdx b/content/docs/references/api/package-api-assembled.mdx index 1d058da258e..fbbd805ff83 100644 --- a/content/docs/references/api/package-api-assembled.mdx +++ b/content/docs/references/api/package-api-assembled.mdx @@ -106,7 +106,7 @@ Installed package row whose manifest is the assembled package body | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | -| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **packaging** | `Enum<'bundled'>` | optional | Dependency packaging strategy (ADR-0025 v2 §3.3): `bundled`, the only one and the default — omit the key or write `bundled` | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | | **functions** | `Record }> \| { name: string; handler?: string; packageId?: string; effect?: Enum<'pure' \| 'writes'> }[]` | optional | Named handler functions, lowered to the refs a JSON document carries | @@ -289,7 +289,7 @@ This schema accepts one of the following structures: | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | -| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **packaging** | `Enum<'bundled'>` | optional | Dependency packaging strategy (ADR-0025 v2 §3.3): `bundled`, the only one and the default — omit the key or write `bundled` | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -352,7 +352,7 @@ This schema accepts one of the following structures: | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | -| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **packaging** | `Enum<'bundled'>` | optional | Dependency packaging strategy (ADR-0025 v2 §3.3): `bundled`, the only one and the default — omit the key or write `bundled` | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | | **functions** | `Record }> \| { name: string; handler?: string; packageId?: string; effect?: Enum<'pure' \| 'writes'> }[]` | optional | Named handler functions, lowered to the refs a JSON document carries | diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index 4a558c76519..a31200a510e 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -162,7 +162,7 @@ Install package request | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | -| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **packaging** | `Enum<'bundled'>` | optional | Dependency packaging strategy (ADR-0025 v2 §3.3): `bundled`, the only one and the default — omit the key or write `bundled` | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -208,7 +208,7 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | -| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **packaging** | `Enum<'bundled'>` | optional | Dependency packaging strategy (ADR-0025 v2 §3.3): `bundled`, the only one and the default — omit the key or write `bundled` | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -316,7 +316,7 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | -| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **packaging** | `Enum<'bundled'>` | optional | Dependency packaging strategy (ADR-0025 v2 §3.3): `bundled`, the only one and the default — omit the key or write `bundled` | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -433,7 +433,7 @@ Install package request | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | -| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **packaging** | `Enum<'bundled'>` | optional | Dependency packaging strategy (ADR-0025 v2 §3.3): `bundled`, the only one and the default — omit the key or write `bundled` | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -566,7 +566,7 @@ Upgrade package request | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | -| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **packaging** | `Enum<'bundled'>` | optional | Dependency packaging strategy (ADR-0025 v2 §3.3): `bundled`, the only one and the default — omit the key or write `bundled` | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -660,7 +660,7 @@ Resolve dependencies request | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | -| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **packaging** | `Enum<'bundled'>` | optional | Dependency packaging strategy (ADR-0025 v2 §3.3): `bundled`, the only one and the default — omit the key or write `bundled` | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 2b8219e3b04..535803ec2cd 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2113,7 +2113,7 @@ Install package request | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | -| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **packaging** | `Enum<'bundled'>` | optional | Dependency packaging strategy (ADR-0025 v2 §3.3): `bundled`, the only one and the default — omit the key or write `bundled` | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index 568a0c98ea1..225a3b89279 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -62,7 +62,7 @@ Install mode: managed (registered as code, upgradeable, sealed) | unmanaged (cop | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | -| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **packaging** | `Enum<'bundled'>` | optional | Dependency packaging strategy (ADR-0025 v2 §3.3): `bundled`, the only one and the default — omit the key or write `bundled` | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -179,12 +179,11 @@ Per-file content digests of the plugin artifact (ADR-0025 §3.2) ## PluginPackaging -Dependency packaging strategy (ADR-0025 §3.3) +Dependency packaging strategy (ADR-0025 v2 §3.3): `bundled` is the only one and the default — every dependency except the host-provided `@objectstack/*` packages is inlined into the artifact, and `os plugin build` refuses a native (`.node`) addon ### Allowed Values * `bundled` -* `manifest-deps` --- diff --git a/content/docs/references/kernel/package-registry.mdx b/content/docs/references/kernel/package-registry.mdx index 8aed9cf9f0e..b01891eb534 100644 --- a/content/docs/references/kernel/package-registry.mdx +++ b/content/docs/references/kernel/package-registry.mdx @@ -217,7 +217,7 @@ Install package request | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | -| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **packaging** | `Enum<'bundled'>` | optional | Dependency packaging strategy (ADR-0025 v2 §3.3): `bundled`, the only one and the default — omit the key or write `bundled` | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -315,7 +315,7 @@ Installed package with runtime lifecycle state | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | -| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **packaging** | `Enum<'bundled'>` | optional | Dependency packaging strategy (ADR-0025 v2 §3.3): `bundled`, the only one and the default — omit the key or write `bundled` | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | diff --git a/docs/qa/platform-checklist/areas/cli.json b/docs/qa/platform-checklist/areas/cli.json index 3eaf18dfbd5..c5ac57e82dd 100644 --- a/docs/qa/platform-checklist/areas/cli.json +++ b/docs/qa/platform-checklist/areas/cli.json @@ -1713,10 +1713,10 @@ }, { "id": "cli.plugin-manifest-build-contract", - "title": "os plugin build enforces the manifest at the packaging boundary: an invalid manifest is refused with located errors, `packaging` forks the artifact, and the compiled manifest the artifact carries is readable back", + "title": "os plugin build enforces the manifest at the packaging boundary: an invalid manifest is refused with located errors, `bundled` is the one packaging strategy (the retired `manifest-deps` is refused at the manifest, a native addon is refused by name), and the compiled manifest the artifact carries is readable back", "since": "v15", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "cli", "personas": [ @@ -1725,25 +1725,26 @@ "fixtures": { "app": "scaffold", "requires": [ - "a scratch plugin directory OUTSIDE the repo tree holding objectstack.plugin.json (the filename os plugin build reads — NOT objectstack.config.ts), an entry at src/index.ts, and a package.json declaring at least one real runtime dependency that the entry actually imports. ⛔ Without a genuinely imported dependency the two `packaging` modes produce the same bundle and the fork clause is unscoreable — an equal-artifacts observation would then be a fixture defect reported as a product fail", + "a scratch plugin directory OUTSIDE the repo tree holding objectstack.plugin.json (the filename os plugin build reads — NOT objectstack.config.ts), an entry at src/index.ts, and a package.json declaring at least one real, pure-JavaScript runtime dependency that the entry actually imports. ⛔ Without a genuinely imported dependency there is nothing to inline and the bundled clause is unscoreable — a bundle that merely lacks an external import would then be a fixture defect read as a product pass", "esbuild resolvable from that directory — os plugin build refuses with its own named error when it is not, which is a fixture failure rather than a clause verdict", - "a second scratch directory for the refusal probes, so a bad manifest never overwrites the good one" + "a second scratch directory for the refusal probes, so a bad manifest never overwrites the good one. The native-addon probes need no compiler: the build refuses a `.node` file by its path and never loads it, so any bytes in a file named addon.node will do, and a dependency that 'declares a node-gyp build' is a hand-written node_modules// holding a package.json, an index.js and a binding.gyp" ], "knownGaps": [ "os plugin publish CANNOT complete locally: after extracting the artifact manifest it requires a cloud token and posts to /api/v1/cloud/packages. Only its PRE-NETWORK legs are scoreable here — artifact discovery in cwd, manifest extraction, the id/version refusal, and the unsigned-artifact notice. Record blocked(environment) for anything past the auth step; the cloud control plane is not part of this checklist's fixture set", "the SERVER-side mirror of the identity refusal — POST /api/v1/packages/publish answering 400 PACKAGE_MANIFEST_INVALID when id or version is missing (packages/rest/src/package-routes.ts) — is deliberately not scored here: it needs a deployment composing the `package` service, and one without it answers 404 with the 'composes no `package` service' message, which is not a failure of anything. Score it from the api lane if a marketplace-capable deployment is available", - "the artifact is a gzipped ustar with mtime pinned to 0 and entries emitted in sorted path order, so identical inputs produce byte-identical archives — that is what makes the two packaging builds comparable, and it is also why a stale artifact from an earlier run is indistinguishable from a fresh one by name alone. Build each mode into its own --out path" + "the artifact is a gzipped ustar with mtime pinned to 0 and entries emitted in sorted path order, so identical inputs produce byte-identical archives — which is also why a stale artifact from an earlier run is indistinguishable from a fresh one by name alone. Check for the artifact's ABSENCE after every refusal probe in a directory that never held one", + "the native-addon refusal reads what the build can SEE: a `.node` module in esbuild's graph, a bundled package that declares a node-gyp build or ships a `.node` file, and a `.node` file under assets/. An addon loaded at run time from an absolute or computed path outside every bundled package is invisible to it, and is not scored here — the artifact would fail at load on a host that lacks it" ] }, "steps": [ - "in the scratch plugin dir, run `os plugin build` on the GOOD manifest (id, version, name, an imported dependency in package.json, no `packaging` key) → capture the full output: the Loaded line, Artifact, Plugin, Files, Integrity entries, Size, sha256. Keep the artifact at its own --out path", - "re-run with `packaging: \"manifest-deps\"` in objectstack.plugin.json, into a DIFFERENT --out path → capture the same output block", - "compare the two artifacts: extract each (gunzip + tar) and diff the FILE LISTS and the bundled entry. Expect package.json (and pnpm-lock.yaml when present) inside the manifest-deps artifact and absent from the bundled one, and expect the declared dependency's import to survive as an external import in the manifest-deps dist/index.mjs while its code is inlined in the bundled one", - "read the compiled manifest out of each artifact (the objectstack.plugin.json entry inside the archive) and compare it to the authored one: expect the authored keys plus `main` and an `integrity` map", + "in the scratch plugin dir, run `os plugin build` on the GOOD manifest (id, version, name, an imported dependency in package.json, no `packaging` key) → capture the full output: the Loaded line, Artifact, Plugin, Files, Integrity entries, Size, sha256", + "copy the good project into the second scratch dir, set `packaging: \"manifest-deps\"` in ITS objectstack.plugin.json and build there → capture the output, the exit code, and the directory listing after it", + "extract the good artifact (gunzip + tar) and read its FILE LIST and its entry bundle. Expect exactly objectstack.plugin.json, dist/index.mjs and SIGNATURE (plus any assets/ the project carries) — no package.json and no lockfile — and expect the declared dependency's code inlined in dist/index.mjs with no import or require of it surviving", + "read the compiled manifest out of the artifact (the objectstack.plugin.json entry inside the archive) and compare it to the authored one: expect the authored keys plus `main` and an `integrity` map", "run `os plugin publish` against the good artifact with no token → capture the output up to and including the auth refusal; then run it against an artifact whose compiled manifest lacks a version → capture that refusal", - "REFUSAL probes, each in the second scratch dir, each followed by a check that NO artifact file was written: (a) objectstack.plugin.json that is not valid JSON; (b) a manifest missing `id`; (c) a manifest whose `version` is not semver; (d) a manifest with `id: \"\"` — which the reverse-domain rule on manifest.id (MANIFEST_ID_PATTERN) refuses at safeParse with a located error; (e) a manifest with no `main` and no src/index.* entry candidate", + "REFUSAL probes, each in the second scratch dir, each followed by a check that NO artifact file was written: (a) objectstack.plugin.json that is not valid JSON; (b) a manifest missing `id`; (c) a manifest whose `version` is not semver; (d) a manifest with `id: \"\"` — which the reverse-domain rule on manifest.id (MANIFEST_ID_PATTERN) refuses at safeParse with a located error; (e) a manifest with no `main` and no src/index.* entry candidate. Then, on a VALID manifest: (f) an entry that imports ./native/addon.node; (g) an entry that imports a node_modules dependency declaring a node-gyp build (binding.gyp), beside a pure dependency it also imports; (h) an assets/bin/addon.node beside an ordinary asset", "run `os lint` on a scaffold config whose manifest declares NO engines range → capture the finding (rule, severity, path, fix); then run it on a bare metadata fragment that declares no manifest at all → confirm the rule does NOT fire there", - "teardown: delete both scratch directories. ⚠️ Do not re-run the packaging comparison over the artifacts of a previous run — each mode writes its own --out path and a leftover file from an earlier run reads exactly like a fresh one" + "teardown: delete both scratch directories. ⚠️ Do not re-run a refusal probe in a directory an earlier build wrote an artifact into — a leftover file reads exactly like a fresh one, and the no-artifact half of every refusal clause is then unscoreable" ], "acceptance": [ { @@ -1759,10 +1760,16 @@ "evidence": "the three refusals side by side, each with its exit code" }, { - "clause": "`packaging` FORKS the artifact rather than annotating it: the same source tree built as `bundled` (the default) and as `manifest-deps` produces two DIFFERENT artifacts — manifest-deps keeps every package.json dependency external in the bundle and packs package.json (plus pnpm-lock.yaml when present) into the archive; bundled inlines the dependency's code and packs neither", + "clause": "`bundled` is the ONE packaging strategy (ADR-0025 v2 §3.3): the build inlines every dependency the entry imports except the host-provided `@objectstack/*` packages and packs neither package.json nor a lockfile — and the retired `packaging: 'manifest-deps'` is REFUSED at the manifest, a located error under 'objectstack.plugin.json is invalid:' naming `packaging` and telling the author to delete the key, exiting nonzero with no artifact", "oracle": "build", - "verify": "diff the extracted file lists and the entry bundles of the two artifacts. Both differences must hold: the file-list difference alone could come from any archive change, and the external-import difference alone could come from an esbuild default", - "evidence": "the two file lists and the two entry-bundle excerpts showing the dependency inlined in one and imported in the other" + "verify": "step 3 for the bundled half — the file list AND the inlined dependency must both hold, since a missing package.json alone could come from any archive change; step 2 for the refusal half, read with its exit code and the directory listing. ⛔ A manifest-deps build that writes an artifact, or prints the Loaded line with `packaging: manifest-deps`, is the retired fork answering again", + "evidence": "the good artifact's file list and the entry-bundle excerpt showing the dependency inlined; the manifest-deps refusal with its exit code and the listing after it" + }, + { + "clause": "a native (`.node`) addon is REFUSED by name with no artifact (ADR-0025 v2 §3.3) — a `.node` file the entry imports (named with its importer), a bundled dependency that declares a node-gyp build or ships a compiled `.node` binary (named by package), and a `.node` file under assets/ (named by path) each stop the build with 'Native addon refused: …', listing every one found, before the artifact is written", + "oracle": "log", + "verify": "probes 6f, 6g and 6h, each read with its exit code and the directory listing after it. 6g is scored on BOTH halves: the native package is named, and the pure dependency imported beside it is NOT — a refusal that names every dependency cannot tell the author which one to replace. 6h likewise names assets/bin/addon.node and not the ordinary asset", + "evidence": "the three refusals with their exit codes and the listings taken after them" }, { "clause": "the artifact carries a COMPILED manifest — the authored manifest plus `main: \"dist/index.mjs\"` and an `integrity` map of archive-relative path → sha256 SRI digest, with the manifest itself and SIGNATURE excluded from the map — and os plugin publish reads that manifest back out of the artifact BEFORE any network call, refusing 'Artifact manifest is missing id or version.' when it cannot", @@ -1779,7 +1786,7 @@ ], "negative": [ "an artifact written from a manifest that failed the schema, or from one missing id/version, is the FAIL this item exists for — the refusal is worthless if the build still produces something publishable", - "the two packaging modes producing byte-identical artifacts is a FAIL of the fork clause — but check the fixture first: without a dependency the entry genuinely imports there is nothing to externalize, and that is a fixture defect, not a product one", + "a build that accepts `manifest-deps` — writes an artifact, or packs package.json and keeps a dependency external — is a FAIL of the bundled clause: that fork was retired because nothing ever installed the dependencies it left out, so its artifact could not load them. Likewise a native-addon refusal that prints and still writes the .osplugin hands os plugin sign the very artifact it refused", "⛔ the `integrity` map's PRESENCE is not evidence of an integrity CHECK. os plugin build computes it and writes it into the compiled manifest, and nothing re-verifies it at unpack — #11331 is open on exactly that. Scoring the printed 'Integrity entries' count as a working integrity guarantee is the false-compliance shape the enforce-or-remove ledger exists for, and this item asserts only that the map is PRODUCED", "⛔ likewise the plugin trust tier: build and publish both print `runtime: `, and nothing in this repo dispatches on it — #11330 is open. A run that reads that printed line as isolation has scored a log line, not a capability", "a lint warning that fires on a manifest-less metadata fragment is a FAIL of the scoping half even though the rule itself works" @@ -1789,10 +1796,10 @@ "destructive-in-place" ], "source": [ - "packages/cli/src/commands/plugin/build.ts#formatZodErrors — manifest read + named refusal; ManifestSchema.safeParse + formatZodErrors; the explicit id/version guard; packaging default and the Loaded line; entry resolution; the manifest-deps externalization; the manifest-deps package.json / pnpm-lock.yaml packing; computeIntegrity + the compiled manifest; the artifact write and its printed block", + "packages/cli/src/commands/plugin/build.ts#formatZodErrors — manifest read + named refusal; ManifestSchema.safeParse + formatZodErrors; the explicit id/version guard; packaging default and the Loaded line; entry resolution; the bundled-only esbuild call (only `@objectstack/*` external); findNativeAddons and the native-addon refusal ahead of the integrity map; computeIntegrity + the compiled manifest; the artifact write and its printed block", "packages/cli/src/utils/osplugin.ts#MANIFEST_FILENAME — MANIFEST_FILENAME = 'objectstack.plugin.json'; OSPLUGIN_EXT; computeIntegrity (manifest and SIGNATURE excluded, keys sorted); the ustar writer with mtime pinned to 0; readOspluginManifest", "packages/cli/src/commands/plugin/publish.ts#version — artifact discovery; readOspluginManifest before any network call; the id/version refusal; the unsigned-artifact notice; the auth refusal", - "packages/spec/src/kernel/manifest.zod.ts#ManifestSchema — ManifestSchema id and version (both required, version regex-pinned); PluginPackagingSchema ('bundled' | 'manifest-deps', ADR-0025 §3.3); PluginRuntimeSchema (the trust tier, ADR-0025 §3.6)", + "packages/spec/src/kernel/manifest.zod.ts#ManifestSchema — ManifestSchema id and version (both required, version regex-pinned); PluginPackagingSchema ('bundled' only — 'manifest-deps' is a retired value refused with its migration prescription, ADR-0025 v2 §3.3); PluginRuntimeSchema (the trust tier, ADR-0025 §3.6)", "packages/cli/src/commands/lint.ts#manifest — the protocol/missing-engines-range rule, its manifest scoping and its fix string", "packages/rest/src/package-routes.ts#PACKAGE_MANIFEST_INVALID — the server mirror (400 PACKAGE_MANIFEST_INVALID), recorded in knownGaps rather than scored here", "content/docs/releases/v15.mdx (the release that dated objectstack lint's protocol/missing-engines-range) · ADR-0025 §3.3 / §3.6", @@ -1816,6 +1823,12 @@ "date": "2026-10-08", "change": "text refresh only, no property change: A2 quoted the package-id refusal headline as 'Invalid package id '' on `manifest.id`. Expected reverse-domain notation …', which manifestIdRefusal (manifest.zod.ts) no longer prints — the headline now names the package id in product words and the key rides in the second sentence as a parenthetical locator. Requoted from the source at 6729e107e. The scored property is unchanged: probe (d) is still a located reverse-domain refusal at safeParse that names manifest.id and echoes the received ''", "ref": "#22093" + }, + { + "revision": 4, + "date": "2026-10-29", + "change": "A3 rewritten and a native-addon clause added: `packaging` no longer forks the artifact. PluginPackagingSchema is narrowed to `bundled` (manifest.zod.ts), so `manifest-deps` is refused at safeParse with its migration prescription and the build.ts externalize + package.json / pnpm-lock.yaml forks are gone — the old fork clause described a property the build no longer has. os plugin build now also refuses a native `.node` addon by name before writing the artifact (findNativeAddons, build.ts). Steps 2, 3, 4 and 8, the first fixture, the archive knownGap, the second negative and two source rows re-pointed to match; probes 6f-6h added for the addon refusal, and a knownGap records the addon an absolute or computed runtime path loads, which the build cannot see. Verified in the same PR through the real door (PluginBuild.run over on-disk projects, plugin-build-bundled-only.test.ts) — a verification re-run, no qa-run record. Untouched and not re-verified here: the integrity and trust-tier negatives", + "ref": "#27123" } ] }, diff --git a/packages/cli/src/commands/plugin/build.ts b/packages/cli/src/commands/plugin/build.ts index 381ef147b03..f5cb8ec5cca 100644 --- a/packages/cli/src/commands/plugin/build.ts +++ b/packages/cli/src/commands/plugin/build.ts @@ -9,12 +9,19 @@ * ManifestSchema (@objectstack/spec/kernel, ADR-0025 §3.2 — landed by * framework F1). Malformed manifests fail fast with zod diagnostics. * 2. esbuild-bundle the entry to `dist/index.mjs`, externalizing - * `@objectstack/*` (peer-provided by the host runtime; ADR §3.10 #2). - * For `packaging: manifest-deps`, dependencies are externalized too and - * `package.json` + lockfile are carried for install-time resolution. - * 3. Compute per-file `integrity` (`sha256-`, ADR §3.2) and write + * `@objectstack/*` (peer-provided by the host runtime; ADR §3.10 #2) and + * inlining every other dependency: `bundled` is the one packaging + * strategy (ADR-0025 v2 §3.3), since nothing installs a dependency at + * install time — `os plugin install` and the boot-time loader import + * `dist/index.mjs` as packed. + * 3. Refuse a native addon (ADR-0025 v2 §3.3): a `.node` module the bundle + * reaches, a bundled package that builds or ships one, or a `.node` file + * under `assets/`. A compiled binary can be neither bundled nor + * verified as JavaScript, so the refusal names every one at once and + * no artifact is written. + * 4. Compute per-file `integrity` (`sha256-`, ADR §3.2) and write * the compiled manifest with that map + a `dist/index.mjs` entry. - * 4. Pack everything (+ a `SIGNATURE` placeholder) into a reproducible + * 5. Pack everything (+ a `SIGNATURE` placeholder) into a reproducible * ustar+gzip `-.osplugin`. * * Signing is a separate step (`os plugin sign`); this command emits an @@ -22,8 +29,9 @@ */ import { readFile, readdir, stat, writeFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join, relative, resolve as resolvePath, sep as PATH_SEP } from 'node:path'; +import type { Metafile, Plugin as EsbuildPlugin } from 'esbuild'; import { Args, Command, Flags } from '@oclif/core'; import { ManifestSchema } from '@objectstack/spec/kernel'; import { @@ -63,6 +71,101 @@ async function collectDir(dir: string, prefix: string): Promise { return out; } +/** A compiled Node addon's file extension — the one binary shape ADR-0025 v2 §3.3 refuses. */ +const isNativeAddonPath = (p: string): boolean => p.toLowerCase().endsWith('.node'); + +/** + * Loads every `.node` module the bundle reaches as an empty one, so esbuild + * finishes the graph and the refusal can name every addon at once (its own + * answer is a bare "No loader is configured" for the first one only). The + * output is never written: the metafile names the modules and the build + * refuses. + */ +const nativeAddonCollector: EsbuildPlugin = { + name: 'os-plugin-native-addon-refusal', + setup(build) { + build.onLoad({ filter: /\.node$/i }, () => ({ contents: '', loader: 'empty' })); + }, +}; + +/** + * The package directory an esbuild input sits in — the segment after its LAST + * `node_modules` (two for a scoped name), so a pnpm store path + * (`node_modules/.pnpm/x@1/node_modules/x/…`) answers `x`. Inputs outside any + * `node_modules` are the plugin's own source and answer `undefined`. + */ +function packageRootOf(input: string): { name: string; dir: string } | undefined { + const segments = input.split('/'); + const at = segments.lastIndexOf('node_modules'); + if (at < 0 || at + 1 >= segments.length) return undefined; + const width = segments[at + 1]!.startsWith('@') ? 2 : 1; + if (at + width >= segments.length) return undefined; + return { name: segments.slice(at + 1, at + 1 + width).join('/'), dir: segments.slice(0, at + 1 + width).join('/') }; +} + +/** The first `.node` file under `dir`, skipping nested `node_modules` (another package's) and symlinks. */ +function findShippedAddon(dir: string, rel = ''): string | undefined { + let entries; + try { + entries = readdirSync(join(dir, rel), { withFileTypes: true }); + } catch { + return undefined; + } + for (const e of entries) { + const childRel = rel ? `${rel}/${e.name}` : e.name; + if (e.isFile() && isNativeAddonPath(e.name)) return childRel; + } + for (const e of entries) { + if (e.isDirectory() && e.name !== 'node_modules') { + const hit = findShippedAddon(dir, rel ? `${rel}/${e.name}` : e.name); + if (hit) return hit; + } + } + return undefined; +} + +/** Why the package at `dir` is a native addon, or `undefined` when it is not. */ +function nativePackageReason(dir: string): string | undefined { + if (existsSync(join(dir, 'binding.gyp'))) return 'declares a node-gyp build (binding.gyp)'; + try { + const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8')) as { gypfile?: unknown }; + if (pkg.gypfile === true) return 'declares a node-gyp build (gypfile: true)'; + } catch { + /* no readable package.json — the binary scan below still answers */ + } + const shipped = findShippedAddon(dir); + return shipped ? `ships a compiled addon (${shipped})` : undefined; +} + +/** + * Every native addon a build would pack or load (ADR-0025 v2 §3.3), one line + * each: a `.node` module the bundle reaches (named with its importers), a + * bundled package that builds or ships one (its JS may be bundleable, but the + * binary it locates at run time — `bindings`, `node-gyp-build` — is not in the + * artifact), and a `.node` file staged from `assets/`. + */ +function findNativeAddons(metafile: Metafile, cwd: string, assets: ArchiveFile[]): string[] { + const found: string[] = []; + const inputs = Object.keys(metafile.inputs); + for (const input of inputs.filter(isNativeAddonPath)) { + const importers = inputs.filter((i) => metafile.inputs[i]!.imports.some((imp) => imp.path === input)); + found.push(importers.length > 0 ? `${input}, imported by ${importers.join(', ')}` : input); + } + const packages = new Map(); + for (const input of inputs) { + const root = packageRootOf(input); + if (root && !packages.has(root.dir)) packages.set(root.dir, root); + } + for (const { name, dir } of packages.values()) { + const reason = nativePackageReason(resolvePath(cwd, dir)); + if (reason) found.push(`package \`${name}\` (${dir}) ${reason}`); + } + for (const asset of assets) { + if (isNativeAddonPath(asset.path)) found.push(asset.path); + } + return found; +} + export default class PluginBuild extends Command { static override description = 'Compile a plugin into a signed-ready `.osplugin` artifact (ADR-0025 §3.4)'; @@ -153,22 +256,14 @@ export default class PluginBuild extends Command { return; } - // Externalize peer-provided @objectstack/*; for manifest-deps, also keep - // declared dependencies external (resolved at install time). - const external = ['@objectstack/*']; - if (packaging === 'manifest-deps') { - try { - const pkg = JSON.parse(await readFile(resolvePath(cwd, 'package.json'), 'utf-8')); - external.push(...Object.keys(pkg.dependencies ?? {})); - } catch { - /* no package.json — nothing extra to externalize */ - } - } - + // Externalize only the peer-provided @objectstack/* (host singletons); + // every other dependency is inlined — `bundled` is the one strategy. let bundleBytes: Uint8Array; + let metafile: Metafile; try { const result = await esbuild.build({ entryPoints: [entryAbs], + absWorkingDir: cwd, bundle: true, format: 'esm', platform: 'node', @@ -177,7 +272,9 @@ export default class PluginBuild extends Command { outfile: resolvePath(cwd, 'dist/index.mjs'), sourcemap: false, minify: flags.minify, - external, + external: ['@objectstack/*'], + plugins: [nativeAddonCollector], + metafile: true, logLevel: 'silent', legalComments: 'none', banner: { js: '// @generated by `os plugin build` — do not edit.' }, @@ -186,27 +283,36 @@ export default class PluginBuild extends Command { const js = outputs.find((f) => f.path.endsWith('.mjs') || f.path.endsWith('.js')) ?? outputs[0]; if (!js) throw new Error('esbuild produced no output'); bundleBytes = js.contents; + metafile = result.metafile; } catch (err) { printError(`Bundle failed: ${(err as Error).message}`); this.exit(1); return; } - // 3. Stage archive files, compute integrity, compile manifest. ────── + // 3. Stage archive files; refuse a native addon. ───────────────────── const files: ArchiveFile[] = [{ path: 'dist/index.mjs', data: bundleBytes }]; const assetsDir = resolvePath(cwd, 'assets'); + const assets: ArchiveFile[] = []; if (existsSync(assetsDir) && (await stat(assetsDir)).isDirectory()) { - files.push(...(await collectDir(assetsDir, 'assets'))); + assets.push(...(await collectDir(assetsDir, 'assets'))); } + files.push(...assets); - if (packaging === 'manifest-deps') { - for (const dep of ['package.json', 'pnpm-lock.yaml']) { - const p = resolvePath(cwd, dep); - if (existsSync(p)) files.push({ path: dep, data: new Uint8Array(await readFile(p)) }); - } + const nativeAddons = findNativeAddons(metafile, cwd, assets); + if (nativeAddons.length > 0) { + printError( + 'Native addon refused: a plugin artifact carries bundled JavaScript only (ADR-0025 v2 §3.3), ' + + 'and a compiled `.node` binary can be neither bundled nor run by every host. Found:\n' + + nativeAddons.map((line) => ` - ${line}`).join('\n') + + '\n Replace each with a pure-JavaScript or WebAssembly alternative, or drop it.', + ); + this.exit(1); + return; } + // 4. Compute integrity, compile manifest. ──────────────────────────── const integrity = computeIntegrity(files); const compiledManifest = { ...manifest, main: 'dist/index.mjs', integrity }; const manifestBytes = new Uint8Array( @@ -216,7 +322,7 @@ export default class PluginBuild extends Command { // Unsigned placeholder; `os plugin sign` overwrites this (ADR §3.4). files.push({ path: SIGNATURE_FILENAME, data: new Uint8Array(Buffer.from('unsigned\n', 'utf-8')) }); - // 4. Pack the artifact. ───────────────────────────────────────────── + // 5. Pack the artifact. ───────────────────────────────────────────── const blob = createTarGz(files); const outPath = resolvePath(cwd, flags.out ?? `${id}-${version}${OSPLUGIN_EXT}`); // Bounded (#23858): an `--out` directory that can never be made is refused diff --git a/packages/cli/src/utils/osplugin.ts b/packages/cli/src/utils/osplugin.ts index 9fee8df5ec0..bc16f6c8265 100644 --- a/packages/cli/src/utils/osplugin.ts +++ b/packages/cli/src/utils/osplugin.ts @@ -7,9 +7,10 @@ * * objectstack.plugin.json ← compiled manifest (with `integrity`) * dist/** ← bundled, @objectstack/*-externalized code - * assets/** ← optional static assets - * package.json ← only for `packaging: manifest-deps` - * pnpm-lock.yaml ← only for `packaging: manifest-deps` + * (every other dependency inlined: `bundled` + * is the one packaging strategy, ADR §3.3) + * assets/** ← optional static assets (never a `.node` + * addon: `os plugin build` refuses one) * SIGNATURE ← detached publisher signature (placeholder * until `os plugin sign`; ADR §3.4) * diff --git a/packages/spec/liveness/manifest.json b/packages/spec/liveness/manifest.json index 008f24fcdc1..bb75bcdd4d9 100644 --- a/packages/spec/liveness/manifest.json +++ b/packages/spec/liveness/manifest.json @@ -289,11 +289,11 @@ "note": "ENFORCED AT LOAD SINCE #27123 (ADR-0025 §3.5 M1), AND STILL AT THE CLOUD PUBLISH GATE — so the verdict FLIPPED live-elsewhere → live on local evidence, the flip this note had named as the open half (\"if it lands, the verdict here flips to `live` with local evidence\"). LOCAL HALF, measured 2026-10-29: `os serve` (and `os dev` / `os start` through it) loads the plugins `os plugin install` installed through `loadInstalledPlugins` before the kernel bootstraps, and every one passes `dispatchPluginRuntime` before any of its code is imported. What the declared tier now DOES: `node` loads in process with a full PluginContext — T0, author code in the host process, NOT isolation; `sandbox` and `worker` are REFUSED by name until M3 routes them (the QuickJS runner under packages/runtime/src/sandbox/ is still the hook/action SCRIPT-BODY sandbox, not reached from a plugin's tier), so a declared `sandbox` is never run unconfined, it is not run at all; an installed plugin that declares no tier is refused (§3.6 default-deny). A plugin a stack's own `plugins` array names is the app's code and is not dispatched on this key. Both `runtime` describes state this, pinned in packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts, which also pins the retracted \"load-side enforcement is NOT implemented\" absent. The build/publish progress lines still echo the value. FOREIGN HALF, kept as attributed history and not re-read for this flip (cloud is out of this card's reach): #12400 measured cloud origin/main @15f55df on 2026-08-29 — `auditPluginPermissions` treats `runtime === 'node'` from an unverified publisher as a hard block, turned into HTTP 422 with forced manual review, and the tier is persisted to the version row. PROVENANCE: the row was the ledger's first `live-elsewhere` verdict (#13483) after the maintainer ruling of 2026-08-30 (executed by commit a9ee98992) took \"say it truthfully\" over retirement — retirement stays ruled OUT, because deleting the key would tear out the marketplace's trust-gate input — and `loading`'s former tombstone had redirected upgrading authors to this key as something \"which [is] enforced\" while nothing here dispatched on it (ADR-0049 false compliance with a shipped migration message attached)." }, "packaging": { - "status": "live", - "evidence": "packages/cli/src/commands/plugin/build.ts#PluginBuild (`const packaging = manifest.packaging ?? 'bundled'`, then two `if (packaging === 'manifest-deps')` forks — one that pushes the package.json dependencies onto esbuild's `external` list instead of bundling them, one further down)", - "verifiedAt": "2026-08-28", + "status": "planned", + "evidence": "packages/spec/src/kernel/manifest.zod.ts#PluginPackagingSchema (`enumWithRetiredValues(['bundled'], …)` — the one member, and the retired `manifest-deps` refused BY NAME with its prescription at every `ManifestSchema` door: a manifest, a stack's `manifest`, an artifact's `packages[].manifest`, `os plugin build`); packages/cli/src/commands/plugin/build.ts#PluginBuild (`const packaging = manifest.packaging ?? 'bundled'` — read only for the `Loaded` echo: the bundle is the same with the key or without it)", + "verifiedAt": "2026-10-29", "evidenceScope": "in-repo", - "note": "Live on a real behavioural fork, not on a read: the value selects whether esbuild bundles dependencies into the artifact or externalizes them for install-time resolution, so the two tiers produce different artifacts. Contrast its neighbour `runtime`, which is read only to be printed. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — both citations were still ACCURATE (`:126` names the read, `:159-161` the externalization), so this is a grammar migration with no repair. The file is 233 lines end to end, which is the batch's clearest correlation: every accurate citation in it sits in a small or structurally quiet file, and every rotted one in a file of 1800 to 20255 lines. Re-closed by hand against c459da6bc." + "note": "PLANNED since #27123 (ADR-0025 v2 §3.3, maintainer decision Q6), deliberately not `live` and deliberately not `dead`, on the one-member precedent (`sharing_rule.type`, `connector.authentication`): `bundled` is the only value an author may write, and anything else is LOUDLY REFUSED at parse rather than silently dropped — the retired `manifest-deps` with its own prescription. Not `live`: nothing dispatches on the value any more. `os plugin build` reads it only to echo it, and packs the same artifact whether the key says `bundled` or is absent — every dependency inlined except the host-provided `@objectstack/*`, a native (`.node`) addon refused. Not `dead`: the refusal is real, and the ADR keeps the key with `bundled` as its one live value, naming plugins that need native addons or very large dependencies as a fresh M3 decision. Flip this row back to `live` the day `PluginPackagingSchema` has a second member and the build branches on it. ⛔ Not an ADR-0049 enforce-or-remove candidate: removing the optional key would break every manifest that writes `bundled` to delete nothing. HISTORY: graded `live` 2026-08-28 on the two `packaging === 'manifest-deps'` forks in `os plugin build` (one pushed the package.json dependencies onto esbuild's `external` list instead of bundling them, one packed package.json + pnpm-lock.yaml into the artifact). Those forks were the key's only consumer and left with the value: nothing ever installed what they left out (`os plugin install` and the boot-time loader import `dist/index.mjs` as packed), so the strategy had zero consumers and no install path. The ADR-0087 D2 conversion `manifest-packaging-manifest-deps-removed` strips the value from stack sources and stored artifacts." }, "main": { "status": "live", diff --git a/packages/spec/liveness/state-counts/manifest.md b/packages/spec/liveness/state-counts/manifest.md index 6de4a273912..bd9f545640b 100644 --- a/packages/spec/liveness/state-counts/manifest.md +++ b/packages/spec/liveness/state-counts/manifest.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `manifest` | 29 | 0 | 0 | 13 | 0 | 42 | +| `manifest` | 28 | 0 | 0 | 13 | 1 | 42 | diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index ebda65c4017..087e05b194c 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -5721,6 +5721,105 @@ const recordHighlightsFieldIconRemoved: MetadataConversion = { }, }; +/** + * A plugin manifest's `packaging: 'manifest-deps'` → dropped (protocol 18 — + * ADR-0025 v2 §3.3, maintainer decision Q6; ADR-0049 enforce-or-remove). + * + * `PluginPackagingSchema` was `bundled | manifest-deps`. The second member + * existed on the build side only: `os plugin build` kept the `package.json` + * dependencies out of the bundle and packed `package.json` + `pnpm-lock.yaml` + * for an install-time resolution that nothing ever ran — `os plugin install` + * and the boot-time loader import `dist/index.mjs` as packed — so an artifact + * built with it could not load the dependencies it left out. The build forks + * are gone, `bundled` is the one strategy, and an absent key means the same. + * + * ## Why a strip and not a rewrite to `bundled` + * + * The key is optional with no default, and `bundled` is what the build does + * with or without it, so the delete is lossless and leaves no new authored + * value behind; writing `bundled` in would author a key the author never + * chose. + * + * ## Reach + * + * The manifest sits in two places a converted definition carries it, as for + * `manifest-permissions-string-list-removed` below: the stack's own + * `manifest`, and each `packages[].manifest` of a multi-package artifact + * (ADR-0130 D4). Only the exact retired string is this entry's surface; a + * live `bundled` or any other value is left for the schema to judge. + * `devPlugins[]` is not walked (its entries may be live plugin objects). + * `objectstack.plugin.json`, the plugin project's own manifest, is no stack + * source: `os plugin build` parses it straight through `ManifestSchema`, + * whose refusal carries the prescription. + * + * ## Why `retiredFromLoadPath` + * + * The schema refuses the value with its prescription, so a live author is + * taught rather than silently rewritten; the entry exists so a stored + * artifact built while the value was legal replays clean at the artifact + * door, and so `os migrate meta --from 17` lists the mechanical edit for + * author sources. Deletion is idempotent by construction: a manifest without + * the value is returned as is. + */ +const manifestPackagingManifestDepsRemoved: MetadataConversion = { + id: 'manifest-packaging-manifest-deps-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '18.2.0', + surface: 'manifest.packaging', + summary: + "manifest 'packaging: manifest-deps' removed (ADR-0025 v2 §3.3, ADR-0049 — nothing ever installed the " + + "dependencies it left out of the bundle; 'bundled' is the only strategy and the default, so the key is " + + 'deleted)', + apply(stack, emit) { + const stripValue = (manifest: unknown, path: string): unknown => { + if (!isDict(manifest) || manifest.packaging !== 'manifest-deps') return manifest; + const next: Dict = { ...manifest }; + delete next.packaging; + emit({ from: 'manifest-deps', to: '(removed)', path: `${path}.packaging` }); + return next; + }; + + let next = stack; + const manifest = stripValue(stack.manifest, 'manifest'); + if (manifest !== stack.manifest) next = { ...next, manifest }; + + const packages = stack.packages; + if (Array.isArray(packages)) { + let touched = false; + const nextPackages = packages.map((entry, i) => { + if (!isDict(entry)) return entry; + const converted = stripValue(entry.manifest, `packages[${i}].manifest`); + if (converted === entry.manifest) return entry; + touched = true; + return { ...entry, manifest: converted }; + }); + if (touched) next = { ...next, packages: nextPackages }; + } + return next; + }, + fixture: { + before: { + manifest: { id: 'com.acme.stripe', packaging: 'manifest-deps', runtime: 'node' }, + packages: [ + { manifest: { id: 'com.acme.stripe.webhooks', packaging: 'manifest-deps' } }, + // The live value rides through untouched — the strip dispatches on + // the retired string, never on the key. + { manifest: { id: 'com.acme.stripe.billing', packaging: 'bundled' } }, + ], + }, + after: { + manifest: { id: 'com.acme.stripe', runtime: 'node' }, + packages: [ + { manifest: { id: 'com.acme.stripe.webhooks' } }, + { manifest: { id: 'com.acme.stripe.billing', packaging: 'bundled' } }, + ], + }, + // One per stripped value — the stack's own manifest and one package entry. + expectedNotices: 2, + }, +}; + /** * A package manifest's `permissions` as a flat list of permission strings → * dropped (protocol 18 — ADR-0049 enforce-or-remove, ruled option A: retire @@ -15315,6 +15414,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: jobTimeoutToTimeoutMs, order: 22 }, { conversion: listViewOptionsBagFolded, order: 78 }, { conversion: listViewSortStringClauseToArray, order: 30 }, + { conversion: manifestPackagingManifestDepsRemoved, order: 80 }, { conversion: manifestPermissionsStringListRemoved, order: 61 }, { conversion: mappingLookupParamsRemoved, order: 11 }, { conversion: memoryPersistenceAutoSaveIntervalToMs, order: 27 }, diff --git a/packages/spec/src/kernel/manifest-packaging-retirement.test.ts b/packages/spec/src/kernel/manifest-packaging-retirement.test.ts index a72c9ceee21..801962af552 100644 --- a/packages/spec/src/kernel/manifest-packaging-retirement.test.ts +++ b/packages/spec/src/kernel/manifest-packaging-retirement.test.ts @@ -253,6 +253,8 @@ describe('tree-scoped absence: nothing inside the declared radius still authors 'packages/spec/src/shared/retired-key-removal-version.pin.test.ts', // The build door's own pin authors it to assert `os plugin build` refuses it. 'packages/cli/test/plugin-build-bundled-only.test.ts', + // The D3 entry's `surface` names the value it retires. + 'packages/spec/src/migrations/entries/semantic/18.manifest-packaging-manifest-deps-retired.ts', ]); const EXCLUDED_PREFIXES = [ // The D2 conversion's fixture authors the pre-retirement manifests on purpose. diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index d3bfd7c00e8..ed95910f98e 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { CORE_PLUGIN_TYPES } from './plugin.zod'; import { PROTOCOL_MAJOR } from './protocol-version'; import { SEMVER_2_0_0_VERSION_PATTERN } from './version-grammar'; -import { retiredKey } from '../shared/retired-key'; +import { enumWithRetiredValues, retiredKey } from '../shared/retired-key'; import { closedObject, strictObject, strictObjectError } from '../shared/strict-object'; import { formatSuggestion } from '../shared/suggestions.zod'; import { SeedSchema } from '../data/seed.zod'; @@ -223,14 +223,44 @@ export const PluginRuntimeSchema = z export type PluginRuntime = z.input; +// `packaging: 'manifest-deps'` — the retired member's prescription +// (`enumWithRetiredValues`, `../shared/retired-key.ts`). Declared with `//` on +// purpose: an enum's error map consumes it, it is no documented surface. +// +// ADR-0025 v2 §3.3 (maintainer decision Q6, 2026-10-28): the strategy existed +// on the build side only — `os plugin build` left the `package.json` +// dependencies out of the bundle and packed `package.json` + `pnpm-lock.yaml` +// for an install-time resolution nothing ever ran — so `bundled` is the one +// strategy and the build forks are gone. The ADR-0087 D2 conversion +// `manifest-packaging-manifest-deps-removed` (`conversions/registry.ts`) +// strips the value from existing stack sources and stored artifacts, which +// lands them on `bundled`, the build's only behaviour. +const PACKAGING_RETIRED = { + 'manifest-deps': + "`packaging: 'manifest-deps'` was removed in @objectstack/spec 18.3.0 (ADR-0025 v2 §3.3, ADR-0049 " + + 'enforce-or-remove) — nothing ever installed the dependencies it left out of the bundle, so a plugin ' + + 'built with it could not load them. Delete the key: `bundled` is the default and the only strategy, ' + + 'and `os plugin build` inlines every dependency except the host-provided `@objectstack/*` packages. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for the stack manifest case; `--write` ' + + 'applies the ones it can prove, and an `objectstack.plugin.json` is not a source it reads: delete the ' + + 'key there by hand.', +} as const; + /** - * Dependency packaging strategy (ADR-0025 §3.3): - * - `bundled` — deps pre-bundled into the artifact, no install-time npm - * - `manifest-deps`— deps resolved at install (`pnpm install`, opt-in) + * Dependency packaging strategy (ADR-0025 v2 §3.3): `bundled` — every + * dependency except the host-provided `@objectstack/*` packages is inlined + * into `dist/index.mjs`, so install is unpack + verify with no npm or + * toolchain at install time, and a native (`.node`) addon is refused by + * `os plugin build`. It is the one strategy and the default, so the key is + * optional and an absent one means the same. The former `manifest-deps` + * member was retired (#27123) and is refused at parse with its prescription. */ -export const PluginPackagingSchema = z - .enum(['bundled', 'manifest-deps']) - .describe('Dependency packaging strategy (ADR-0025 §3.3)'); +export const PluginPackagingSchema = enumWithRetiredValues(['bundled'], PACKAGING_RETIRED) + .describe( + 'Dependency packaging strategy (ADR-0025 v2 §3.3): `bundled` is the only one and the default — every ' + + 'dependency except the host-provided `@objectstack/*` packages is inlined into the artifact, and ' + + '`os plugin build` refuses a native (`.node`) addon', + ); export type PluginPackaging = z.input; @@ -1039,10 +1069,15 @@ export const ManifestSchema = strictObject({ ), /** - * Dependency packaging strategy for code-bearing plugins (ADR-0025 §3.3). + * Dependency packaging strategy for code-bearing plugins (ADR-0025 v2 §3.3). + * `bundled` is the only strategy and the default, so omitting the key means + * the same; see {@link PluginPackagingSchema}. */ packaging: PluginPackagingSchema.optional() - .describe('Dependency packaging strategy (ADR-0025 §3.3)'), + .describe( + 'Dependency packaging strategy (ADR-0025 v2 §3.3): `bundled`, the only one and the default — omit the key ' + + 'or write `bundled`', + ), /** * Entry module of a code-bearing plugin, relative to the plugin root diff --git a/packages/spec/src/migrations/entries/semantic/18.manifest-packaging-manifest-deps-retired.ts b/packages/spec/src/migrations/entries/semantic/18.manifest-packaging-manifest-deps-retired.ts new file mode 100644 index 00000000000..122c7496630 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.manifest-packaging-manifest-deps-retired.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0025 v2 §3.3 (maintainer decision Q6, 2026-10-28), ADR-0049 +// enforce-or-remove: `manifest-deps` leaves `PluginPackagingSchema`, and +// `bundled` is a plugin's one packaging strategy. Deleting the value is +// mechanical (the D2 conversion `manifest-packaging-manifest-deps-removed`); +// what this entry carries is the judgement the upgrader still owes: a plugin +// that relied on a dependency staying out of the bundle now has it inlined, +// and one whose dependency is a native addon is refused by the build. +export const entry: SemanticMigration = { + id: 'manifest-packaging-manifest-deps-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code span. + surface: + "manifest.packaging: 'manifest-deps' (and packages[].manifest.packaging, and objectstack.plugin.json) — " + + "the second packaging strategy left; PluginPackagingSchema is now 'bundled' alone", + replacement: + 'no `packaging` key, or `packaging: \'bundled\'` — `os plugin build` inlines every dependency except the ' + + 'host-provided `@objectstack/*` packages into `dist/index.mjs`, and refuses a native (`.node`) addon', + reason: + 'ADR-0049 enforce-or-remove: the strategy existed on the build side only. `os plugin build` kept the ' + + '`package.json` dependencies out of the bundle and packed `package.json` + `pnpm-lock.yaml` for an ' + + 'install-time resolution, and nothing ever ran one: `os plugin install` and the boot-time loader ' + + 'import `dist/index.mjs` as packed, so an artifact built with it could not load the dependencies it ' + + 'left out. The D2 conversion `manifest-packaging-manifest-deps-removed` deletes the value from a ' + + 'stack\'s manifest and every `packages[].manifest`, losslessly, since `bundled` is what the build ' + + 'does with or without the key. What it cannot do is reach a plugin project\'s own ' + + '`objectstack.plugin.json`, which `os plugin build` parses straight through the manifest schema, or ' + + 'judge the bundle: a dependency that was kept out on purpose is now inlined, and one that is a ' + + 'native addon is refused by the build. Authoring now refuses the value at parse with that ' + + 'prescription, and TypeScript rejects it', + acceptanceCriteria: + 'No manifest — the stack\'s own, any packages[] entry, any objectstack.plugin.json — declares ' + + '`packaging: \'manifest-deps\'`; the value is refused at parse with its prescription, and TypeScript ' + + 'rejects it. `os plugin build` builds each plugin clean: every dependency it imports is inlined into ' + + '`dist/index.mjs` (only `@objectstack/*` stays external), the artifact carries no `package.json` or ' + + 'lockfile, and no dependency is a native addon (the build refuses one by name).', + conversionIds: ['manifest-packaging-manifest-deps-removed'], +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 79baa7494d0..061569e2fe2 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -2273,6 +2273,17 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '`stack.views[]` as a lossless delete, and is retired from the load path, so authors are ' + 'refused at parse rather than rewritten.', }, + { + id: 'manifest-packaging-manifest-deps-retired', + order: 114, + text: + 'It also retires a plugin manifest\'s `packaging: \'manifest-deps\'` (ADR-0025 v2 §3.3, ADR-0049 ' + + 'enforce-or-remove): the strategy existed on the build side only — `os plugin build` kept the ' + + '`package.json` dependencies out of the bundle for an install-time resolution nothing ever ran — so ' + + '`bundled` is the one strategy and the default. The value is refused at parse with its prescription, ' + + 'and the D2 conversion `manifest-packaging-manifest-deps-removed` strips it from the stack\'s manifest ' + + 'and every `packages[].manifest`; an `objectstack.plugin.json` is edited by hand.', + }, { id: 'manifest-permissions-string-list-retired', order: 85, diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 97e9e64f1ef..569e74795d0 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -79,7 +79,7 @@ import { ALL_CONVERSIONS } from '../conversions/registry'; import { TimeUpdateInterval } from '../data/analytics.zod'; import { CurrencyConfigSchema } from '../data/field.zod'; import { IndexSchema, ObjectSchema } from '../data/object.zod'; -import { ManifestPermissionsSchema } from '../kernel/manifest.zod'; +import { ManifestPermissionsSchema, PluginPackagingSchema } from '../kernel/manifest.zod'; import { DeleteRecordConfigSchema, GetRecordConfigSchema, @@ -283,6 +283,10 @@ const JOIN: Readonly> = { 'manifest-permissions-string-list-removed': [ refusal('manifest.permissions as a list of strings', () => ManifestPermissionsSchema.safeParse(['system.user.read'])), ], + // #27123: `packaging: 'manifest-deps'`, refused by the enum's own error map. + 'manifest-packaging-manifest-deps-removed': [ + refusal("manifest.packaging 'manifest-deps'", () => PluginPackagingSchema.safeParse('manifest-deps')), + ], 'declared-index-unique-scope': [ refusal('indexes[].unique: true', () => IndexSchema.safeParse({ fields: ['code'], unique: true })), ], From b0bfccdd74d06f63a04a92bfc6350d26c8e42218 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:58:19 +0000 Subject: [PATCH 3/3] test(spec): list the packaging retirement pin in the repo project Its tree-scoped absence walk reads outside the package, so it runs under `@objectstack/spec#test:repo`, whose hash moves with the globs it walks (already declared in cross-package-test-inputs and turbo.json). Claude-Session: https://claude.ai/code/session_01L6bhgWLCWkh3MaLj1qTLCe 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 c7c443eda16..6dbf4e9f628 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -52,6 +52,7 @@ "src/integration/connector-resilience-keys-retirement.test.ts", "src/integration/connector-sync-retirement.test.ts", "src/integration/connector-triggers-retirement.test.ts", + "src/kernel/manifest-packaging-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",