From aafab27018fddf229f969bffb9c6fde5c6ceb7ea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:54:48 +0000 Subject: [PATCH 1/3] fix(cli): an os create --in-repo plugin passes its own typecheck and build The in-repo placement declares the workspace's TypeScript (^6.0.3, the range the root and every sibling plugin declare) instead of the standalone SCAFFOLD_TYPESCRIPT_RANGE, whose 5.x install refused the base's ignoreDeprecations "6.0" with TS5103. The starter's unused init parameter is _context, so it compiles under the base's noUnusedParameters (TS6133). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_019uZ84EhCM1NaBkJM8y1sjE --- packages/cli/src/commands/create.ts | 26 ++- ...create-in-repo-scaffold-typechecks.test.ts | 172 ++++++++++++++++++ 2 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 packages/cli/test/create-in-repo-scaffold-typechecks.test.ts diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 8dbe328d3b0..899e082545d 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -168,6 +168,26 @@ export function objectstackDependencySpec(placement: ScaffoldPlacement): string return placement === 'in-repo' ? 'workspace:*' : getCliVersion(); } +/** + * The TypeScript range an emitted manifest declares, by placement. + * + * `standalone` is `SCAFFOLD_TYPESCRIPT_RANGE`, the support floor the docs + * promise a developer's own project. `in-repo` is this workspace's TypeScript: + * the scaffold extends `tsconfig.base.json`, whose `ignoreDeprecations: "6.0"` + * only TypeScript 6 accepts, so the standalone floor (installed as 5.x) failed + * the new package's first `tsc` with TS5103. ⛔ Never `SCAFFOLD_TYPESCRIPT_RANGE` + * here, and never this range for `standalone`. + * + * `test/create-in-repo-scaffold-typechecks.test.ts` pins it equal to the range + * the repository root and every sibling under `packages/plugins/` declare, so a + * workspace TypeScript bump reddens there until this moves with it. + */ +export const IN_REPO_TYPESCRIPT_RANGE = '^6.0.3'; + +export function typescriptDependencySpec(placement: ScaffoldPlacement): string { + return placement === 'in-repo' ? IN_REPO_TYPESCRIPT_RANGE : SCAFFOLD_TYPESCRIPT_RANGE; +} + /** * The `extends` an in-repo scaffold needs to reach this repo's shared * `tsconfig.base.json` — the file every package here extends — DERIVED from @@ -421,7 +441,7 @@ export const templates: Record = { }, devDependencies: { '@types/node': SCAFFOLD_TYPES_NODE_RANGE, - typescript: SCAFFOLD_TYPESCRIPT_RANGE, + typescript: typescriptDependencySpec(placement), vitest: SCAFFOLD_VITEST_RANGE, }, // The same npm override `os init` emits, from the same constant, so @@ -475,7 +495,9 @@ export const ${sanitizeIdentifier(name)}Plugin: Plugin = { name: '${name}', version: '0.1.0', - async init(context) { + // \`_context\` is the kernel's plugin context: register services and hooks + // through it. Drop the underscore once init reads it. + async init(_context) { console.log('Initializing ${name} plugin...'); // Plugin initialization logic }, diff --git a/packages/cli/test/create-in-repo-scaffold-typechecks.test.ts b/packages/cli/test/create-in-repo-scaffold-typechecks.test.ts new file mode 100644 index 00000000000..2a6f33f76f7 --- /dev/null +++ b/packages/cli/test/create-in-repo-scaffold-typechecks.test.ts @@ -0,0 +1,172 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN (#26332) — a fresh `os create plugin NAME --in-repo` passes its own + * `typecheck` and `build` scripts. + * + * ## The defect + * + * Once the in-repo scaffold extended `tsconfig.base.json` and reached its own + * `src/`, its first `pnpm typecheck` in the new package was red twice over: + * + * - TS5103 "Invalid value for '--ignoreDeprecations'": the manifest declared + * `typescript` as `SCAFFOLD_TYPESCRIPT_RANGE` (`^5.3.0`, installed as + * 5.9.3), while the base sets `ignoreDeprecations: "6.0"`, a value only + * TypeScript 6 accepts. Every sibling package declares the workspace's + * TypeScript 6 range. + * - TS6133 "'context' is declared but its value is never read": the starter + * `init(context)` against the base's `noUnusedParameters`. + * + * ## What is run, and where + * + * The in-repo emission is materialised through `create`'s own renderers into a + * sandbox laid out like this repository: the real `tsconfig.base.json` copied + * to the sandbox root, and the scaffold at `packages/plugins/plugin-NAME` + * beneath it, so the emitted `extends` resolves exactly as it does in the + * monorepo. The emitted `typecheck` and `build` scripts are then spawned with + * the workspace's own `tsc`, the compiler the in-repo manifest now declares. + * + * The sandbox lives under this package's `node_modules` (git-ignored), the + * placement `scaffold-emission-typechecks.test.ts` documents: an emitted + * `import … from '@objectstack/spec/…'` resolves by the ordinary upward walk. + * TS2307 on the spec package means its `dist/` is not built; build it with + * `pnpm --filter '@objectstack/cli^...' build`. + * + * The TypeScript VERSION a contributor installs cannot be spawned here without + * an install, so the TS5103 half is pinned on the declaration instead: the + * in-repo range is the workspace's range, and its floor is a major that + * accepts the base's `ignoreDeprecations`. The standalone range is pinned + * unchanged beside it. + * + * ## The control + * + * A planted unused parameter must fail the same `typecheck` with TS6133. Green + * there would mean the scaffold no longer compiles under the base's + * strictness, and the verdict on the starter would mean nothing. + */ + +import { afterAll, describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { IN_REPO_TYPESCRIPT_RANGE, templates } from '../src/commands/create.js'; +import { SCAFFOLD_TYPESCRIPT_RANGE } from '../src/commands/init.js'; +import { childEnv } from './helpers/serve-process.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '../../..'); +const BASE_TSCONFIG = path.resolve(HERE, '../../..', 'tsconfig.base.json'); +const PLUGINS_DIR = path.resolve(HERE, '../../..', 'packages/plugins'); + +const require = createRequire(import.meta.url); +const TSC = require.resolve('typescript/bin/tsc'); +const ts = require('typescript') as typeof import('typescript'); + +const PROJECT = 'probe'; +const template = templates.plugin; + +const TMP_ROOT = fs.mkdtempSync(path.join(HERE, '..', 'node_modules', '.create-in-repo-typecheck-')); +afterAll(() => { + fs.rmSync(TMP_ROOT, { recursive: true, force: true }); +}); + +interface Manifest { + scripts: Record; + devDependencies: Record; +} + +function manifestFor(placement: 'in-repo' | 'standalone'): Manifest { + return template.filesFor(placement)['package.json'](PROJECT) as Manifest; +} + +/** Materialise the in-repo emission under a copy of this repo's base; returns the scaffold dir. */ +function emitInRepo(): string { + const workspace = fs.mkdtempSync(path.join(TMP_ROOT, 'ws-')); + fs.copyFileSync(BASE_TSCONFIG, path.join(workspace, 'tsconfig.base.json')); + const dir = path.join(workspace, template.inRepoDir, template.dirName(PROJECT)); + for (const [filePath, render] of Object.entries(template.filesFor('in-repo'))) { + const content = render(PROJECT); + fs.mkdirSync(path.dirname(path.join(dir, filePath)), { recursive: true }); + fs.writeFileSync( + path.join(dir, filePath), + typeof content === 'string' ? content : JSON.stringify(content, null, 2) + '\n', + ); + } + return dir; +} + +/** Spawn one emitted script, its `tsc` resolved to the workspace's compiler. */ +function runScript(dir: string, script: string): { code: number | null; output: string } { + const [bin, ...args] = manifestFor('in-repo').scripts[script].trim().split(/\s+/); + expect(bin, `the in-repo \`${script}\` script no longer runs tsc`).toBe('tsc'); + const res = spawnSync(process.execPath, [TSC, '--pretty', 'false', ...args], { + cwd: dir, + encoding: 'utf-8', + env: childEnv({ NO_COLOR: '1' }), + }); + return { code: res.status, output: `${res.stdout ?? ''}${res.stderr ?? ''}` }; +} + +/** The `ignoreDeprecations` value the base sets, read through the compiler's own config parser. */ +function baseIgnoreDeprecations(): string { + const read = ts.readConfigFile(BASE_TSCONFIG, ts.sys.readFile); + expect(read.error, 'tsconfig.base.json did not parse').toBeUndefined(); + return String(read.config.compilerOptions.ignoreDeprecations); +} + +describe('os create plugin --in-repo: the scaffold passes its own scripts', () => { + it('a planted unused parameter fails `typecheck` with TS6133 (control)', () => { + const dir = emitInRepo(); + fs.writeFileSync(path.join(dir, 'src', 'canary.ts'), 'export function canary(unusedProbe: string) {}\n'); + + const { code, output } = runScript(dir, 'typecheck'); + + expect(code, `the canary compiled, so the scaffold is not under the base's strictness:\n${output}`).not.toBe(0); + expect(output).toContain('TS6133'); + expect(output).toContain('unusedProbe'); + }, 120_000); + + it('`typecheck` and `build` exit 0, and `build` leaves the starter test out of dist', () => { + const dir = emitInRepo(); + + const typecheck = runScript(dir, 'typecheck'); + expect(typecheck.code, `a fresh in-repo scaffold fails its own \`typecheck\`:\n${typecheck.output}`).toBe(0); + + const build = runScript(dir, 'build'); + expect(build.code, `a fresh in-repo scaffold fails its own \`build\`:\n${build.output}`).toBe(0); + expect(fs.existsSync(path.join(dir, 'dist', 'index.js'))).toBe(true); + expect(fs.existsSync(path.join(dir, 'dist', 'index.d.ts'))).toBe(true); + expect(fs.existsSync(path.join(dir, 'dist', 'index.test.js'))).toBe(false); + }, 120_000); +}); + +describe('os create plugin: the TypeScript each placement declares', () => { + it('in-repo declares the workspace\'s TypeScript, the range the root and every sibling plugin declare', () => { + const root = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')) as Manifest; + expect(manifestFor('in-repo').devDependencies.typescript).toBe(root.devDependencies.typescript); + expect(IN_REPO_TYPESCRIPT_RANGE).toBe(root.devDependencies.typescript); + + const siblings = fs + .readdirSync(PLUGINS_DIR) + .map((name) => path.join(PLUGINS_DIR, name, 'package.json')) + .filter((file) => fs.existsSync(file)) + .map((file) => JSON.parse(fs.readFileSync(file, 'utf8')) as Partial) + .map((pkg) => pkg.devDependencies?.typescript) + .filter((range): range is string => typeof range === 'string'); + expect(siblings.length, 'no sibling under packages/plugins declares typescript').toBeGreaterThan(0); + expect([...new Set(siblings)]).toEqual([IN_REPO_TYPESCRIPT_RANGE]); + }); + + it('in-repo\'s TypeScript floor accepts the base\'s ignoreDeprecations (TS5103)', () => { + const floorMajor = Number(/^\^?(\d+)\./.exec(manifestFor('in-repo').devDependencies.typescript)?.[1]); + const acceptedFrom = Number(/^(\d+)\./.exec(baseIgnoreDeprecations())?.[1]); + expect(Number.isFinite(acceptedFrom), 'the base sets no numeric ignoreDeprecations').toBe(true); + expect(floorMajor).toBeGreaterThanOrEqual(acceptedFrom); + }); + + it('standalone still declares SCAFFOLD_TYPESCRIPT_RANGE', () => { + expect(manifestFor('standalone').devDependencies.typescript).toBe(SCAFFOLD_TYPESCRIPT_RANGE); + }); +}); From 27bcd6bdb0dbb9d3da053f16941231c97e4eb821 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:56:44 +0000 Subject: [PATCH 2/3] chore(changeset): @objectstack/cli patch for the in-repo scaffold TypeScript Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_019uZ84EhCM1NaBkJM8y1sjE --- .changeset/26332-create-in-repo-typescript.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/26332-create-in-repo-typescript.md diff --git a/.changeset/26332-create-in-repo-typescript.md b/.changeset/26332-create-in-repo-typescript.md new file mode 100644 index 00000000000..94f47234c6e --- /dev/null +++ b/.changeset/26332-create-in-repo-typescript.md @@ -0,0 +1,14 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): an `os create plugin NAME --in-repo` scaffold passes its own `typecheck` and `build` + +Clause-②: no +Metadata-protocol: none + +- **Before.** The in-repo plugin declared `typescript` as the standalone scaffold range (`^5.3.0`, installed as 5.9.3). It extends `tsconfig.base.json`, whose `ignoreDeprecations: "6.0"` only TypeScript 6 accepts, so `tsc` stopped with TS5103. With the workspace's TypeScript 6 it then stopped on the starter `init(context)` with TS6133, under the base's `noUnusedParameters`. +- **After.** The in-repo placement declares the workspace's TypeScript (`^6.0.3`, the range the repository root and every sibling plugin declare). The starter's `init` parameter is `_context`, with a comment saying what it is for. A fresh in-repo scaffold's `typecheck` and `build` both exit 0. +- The standalone (default) placement still declares `^5.3.0`, the documented TypeScript floor. Its starter `src/index.ts` takes the same `_context` rename, since both placements share one starter. + +What to do: nothing for new scaffolds. An in-repo plugin scaffolded earlier: set its `devDependencies.typescript` to `^6.0.3`, and rename `init(context)` to `init(_context)` unless `init` reads it. From 89522f6b56da1a49da6881f728ccdaea7ec7fee1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:19:46 +0000 Subject: [PATCH 3/3] test(cli): declare tsconfig.base.json as an input of the cli test task The in-repo scaffold typecheck test copies the repo-root base into its sandbox, so check:cross-package-test-inputs requires the read declared: the cli roster glob, the turbo.json input, and the pinned root-file list. The test now reads the cli package's own typescript range instead of the root manifest and the sibling plugins. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_019uZ84EhCM1NaBkJM8y1sjE --- packages/cli/src/commands/create.ts | 4 +-- ...create-in-repo-scaffold-typechecks.test.ts | 28 ++++++------------- scripts/check-cross-package-test-inputs.mjs | 5 +++- scripts/cross-package-test-inputs.mjs | 7 +++++ turbo.json | 1 + 5 files changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 899e082545d..7bff7dda5b7 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -179,8 +179,8 @@ export function objectstackDependencySpec(placement: ScaffoldPlacement): string * here, and never this range for `standalone`. * * `test/create-in-repo-scaffold-typechecks.test.ts` pins it equal to the range - * the repository root and every sibling under `packages/plugins/` declare, so a - * workspace TypeScript bump reddens there until this moves with it. + * this package declares (the root and every sibling package declare the same + * one), so a workspace TypeScript bump reddens there until this moves with it. */ export const IN_REPO_TYPESCRIPT_RANGE = '^6.0.3'; diff --git a/packages/cli/test/create-in-repo-scaffold-typechecks.test.ts b/packages/cli/test/create-in-repo-scaffold-typechecks.test.ts index 2a6f33f76f7..5b6f0bda4c6 100644 --- a/packages/cli/test/create-in-repo-scaffold-typechecks.test.ts +++ b/packages/cli/test/create-in-repo-scaffold-typechecks.test.ts @@ -34,9 +34,10 @@ * * The TypeScript VERSION a contributor installs cannot be spawned here without * an install, so the TS5103 half is pinned on the declaration instead: the - * in-repo range is the workspace's range, and its floor is a major that - * accepts the base's `ignoreDeprecations`. The standalone range is pinned - * unchanged beside it. + * in-repo range is the range this package declares for the very `tsc` the + * cases spawn (the workspace's TypeScript, which the root and every sibling + * declare too), and its floor is a major that accepts the base's + * `ignoreDeprecations`. The standalone range is pinned unchanged beside it. * * ## The control * @@ -56,9 +57,8 @@ import { SCAFFOLD_TYPESCRIPT_RANGE } from '../src/commands/init.js'; import { childEnv } from './helpers/serve-process.js'; const HERE = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.resolve(HERE, '../../..'); +const PKG = path.resolve(HERE, '..'); const BASE_TSCONFIG = path.resolve(HERE, '../../..', 'tsconfig.base.json'); -const PLUGINS_DIR = path.resolve(HERE, '../../..', 'packages/plugins'); const require = createRequire(import.meta.url); const TSC = require.resolve('typescript/bin/tsc'); @@ -143,20 +143,10 @@ describe('os create plugin --in-repo: the scaffold passes its own scripts', () = }); describe('os create plugin: the TypeScript each placement declares', () => { - it('in-repo declares the workspace\'s TypeScript, the range the root and every sibling plugin declare', () => { - const root = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')) as Manifest; - expect(manifestFor('in-repo').devDependencies.typescript).toBe(root.devDependencies.typescript); - expect(IN_REPO_TYPESCRIPT_RANGE).toBe(root.devDependencies.typescript); - - const siblings = fs - .readdirSync(PLUGINS_DIR) - .map((name) => path.join(PLUGINS_DIR, name, 'package.json')) - .filter((file) => fs.existsSync(file)) - .map((file) => JSON.parse(fs.readFileSync(file, 'utf8')) as Partial) - .map((pkg) => pkg.devDependencies?.typescript) - .filter((range): range is string => typeof range === 'string'); - expect(siblings.length, 'no sibling under packages/plugins declares typescript').toBeGreaterThan(0); - expect([...new Set(siblings)]).toEqual([IN_REPO_TYPESCRIPT_RANGE]); + it('in-repo declares the workspace\'s TypeScript, the range of the tsc the cases above spawn', () => { + const own = JSON.parse(fs.readFileSync(path.join(PKG, 'package.json'), 'utf8')) as Manifest; + expect(manifestFor('in-repo').devDependencies.typescript).toBe(own.devDependencies.typescript); + expect(IN_REPO_TYPESCRIPT_RANGE).toBe(own.devDependencies.typescript); }); it('in-repo\'s TypeScript floor accepts the base\'s ignoreDeprecations (TS5103)', () => { diff --git a/scripts/check-cross-package-test-inputs.mjs b/scripts/check-cross-package-test-inputs.mjs index 1ef216f5cb7..dc22c4cc181 100644 --- a/scripts/check-cross-package-test-inputs.mjs +++ b/scripts/check-cross-package-test-inputs.mjs @@ -1458,8 +1458,11 @@ export const TOP_LEVEL_DIRECTORIES_AWAITING_IMPORT = Object.freeze({}); * Repo-root FILES declared as `$TURBO_ROOT$` inputs -- the fourth shape, pinned, not collected. * `vitest.preset.mts` is the shared vitest preset the package configs re-export by * relative import; it is the `test` task's input so an edit to it reruns their suites. + * `tsconfig.base.json` is `@objectstack/cli#test`'s: the in-repo scaffold test copies + * it into its sandbox. It is a turbo `globalDependencies` entry too, which this gate + * does not read, so the task declares it as well. */ -export const TURBO_ROOT_FILE_INPUTS = Object.freeze(['sdui.manifest.json', 'vitest.preset.mts']); +export const TURBO_ROOT_FILE_INPUTS = Object.freeze(['sdui.manifest.json', 'tsconfig.base.json', 'vitest.preset.mts']); /** `.` is the only regex metacharacter these names carry today; escape the class anyway. */ const TOP_LEVEL_ALTERNATION = RECOGNISED_TOP_LEVEL_DIRECTORIES diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 3e1fef248ff..de7677ceb0a 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -728,6 +728,13 @@ export const CROSS_PACKAGE_TEST_INPUTS = { // their trees: the pin reads nothing else from either package. 'packages/spec/src/data/hook.zod.ts', 'packages/runtime/src/sandbox/body-runner.ts', + // The repo-root base every package extends. test/create-in-repo-scaffold-typechecks.test.ts + // copies it into a sandbox laid out like this repo and type-checks an + // `os create plugin --in-repo` scaffold under it, so a strictness or + // `ignoreDeprecations` change in the base is exactly what must re-run + // this suite. It is a turbo `globalDependencies` entry as well, which + // this roster does not read. + 'tsconfig.base.json', ], heldBy: { // The parity pin joins each barrel path from `SCAFFOLD_WIRED_BARRELS`, a diff --git a/turbo.json b/turbo.json index c3a91cad383..4b075928cb3 100644 --- a/turbo.json +++ b/turbo.json @@ -255,6 +255,7 @@ "!**/node_modules/**", "$TURBO_ROOT$/scripts/nightly-tiers.mjs", "$TURBO_ROOT$/scripts/nightly-tiers.d.mts", + "$TURBO_ROOT$/tsconfig.base.json", "$TURBO_ROOT$/packages/verify/src/**", "$TURBO_ROOT$/packages/plugins/plugin-security/src/**", "$TURBO_ROOT$/packages/services/service-cluster/src/**",