From 4cafff89401cf85d5cf3eaac1862a2da40d328f0 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 17:17:29 +0200 Subject: [PATCH] Resolve the composer section's configPath against the file that declared it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prisma.config.ts is now a chain rather than a single file: the engine discovers every file from the command's cwd up to the repo root and merges their sections per key. A `composer` section written once at the repo root therefore reaches commands run in any subdirectory — and resolving its `configPath` against the process cwd names a different file in every one of them, which is almost never the file the author meant. The section validator now resolves `configPath` itself, through the engine's `resolveSectionPath`, against the file whose section declared that key; an absolute path passes through unchanged. Everything downstream — the pipeline and the config loader — receives an absolute path and no longer resolves anything against cwd. This needs @prisma/cli-engine 0.3.0, whose `ConfigSection.validate` takes the resolved section's provenance as a second argument and whose `LoadedConfig` is the chain rather than one file. Every engine declaration moves to 0.3.0 together, as check-cli-engine-pin requires. 0.3.0 is not published yet (it comes from prisma/prisma-cli#233), so `pnpm install` cannot resolve it and pnpm-lock.yaml cannot be regenerated here. CI stays red until the engine publishes; the lockfile update belongs to the commit that follows the publish. Signed-off-by: willbot Signed-off-by: Will Madden --- examples/auth/package.json | 2 +- examples/pn-widgets/package.json | 2 +- examples/store/modules/catalog/package.json | 2 +- examples/store/modules/orders/package.json | 2 +- .../0-framework/3-tooling/cli/package.json | 2 +- .../cli/src/__tests__/load-config.test.ts | 8 +-- .../src/family/__tests__/engine-cli.test.ts | 47 ++++++++++++++- .../src/family/__tests__/host-adapter.test.ts | 4 +- .../cli/src/family/__tests__/runtime.test.ts | 5 +- .../cli/src/family/__tests__/section.test.ts | 57 +++++++++++++++---- .../3-tooling/cli/src/family/section.ts | 40 +++++++++++-- .../3-tooling/cli/src/load-config.ts | 18 +++--- .../0-framework/3-tooling/cli/src/pipeline.ts | 9 +-- .../1-extensions/target/package.json | 2 +- packages/9-public/composer-cli/package.json | 4 +- 15 files changed, 156 insertions(+), 48 deletions(-) diff --git a/examples/auth/package.json b/examples/auth/package.json index 4b3256c9..dcf6daff 100644 --- a/examples/auth/package.json +++ b/examples/auth/package.json @@ -20,7 +20,7 @@ "hono": "^4.12.31" }, "devDependencies": { - "@prisma/cli-engine": "0.2.3", + "@prisma/cli-engine": "0.3.0", "@types/bun": "^1.3.13", "typescript": "^6.0.3" }, diff --git a/examples/pn-widgets/package.json b/examples/pn-widgets/package.json index e6364e44..d890b1cb 100644 --- a/examples/pn-widgets/package.json +++ b/examples/pn-widgets/package.json @@ -17,7 +17,7 @@ "pg": "8.22.0" }, "devDependencies": { - "@prisma/cli-engine": "0.2.3", + "@prisma/cli-engine": "0.3.0", "@prisma/composer": "workspace:0.14.0", "@prisma/composer-cli": "workspace:0.14.0", "@prisma/management-api-sdk": "^1.47.0", diff --git a/examples/store/modules/catalog/package.json b/examples/store/modules/catalog/package.json index f0d00d6f..298fb24f 100644 --- a/examples/store/modules/catalog/package.json +++ b/examples/store/modules/catalog/package.json @@ -23,7 +23,7 @@ "@prisma/composer-prisma-cloud": "workspace:0.14.0" }, "devDependencies": { - "@prisma/cli-engine": "0.2.3", + "@prisma/cli-engine": "0.3.0", "@prisma/composer": "workspace:0.14.0", "@prisma/composer-prisma-cloud": "workspace:0.14.0", "@types/bun": "^1.3.13", diff --git a/examples/store/modules/orders/package.json b/examples/store/modules/orders/package.json index b044c67a..44d58e58 100644 --- a/examples/store/modules/orders/package.json +++ b/examples/store/modules/orders/package.json @@ -24,7 +24,7 @@ "@prisma/composer-prisma-cloud": "workspace:0.14.0" }, "devDependencies": { - "@prisma/cli-engine": "0.2.3", + "@prisma/cli-engine": "0.3.0", "@prisma/composer": "workspace:0.14.0", "@prisma/composer-prisma-cloud": "workspace:0.14.0", "@types/bun": "^1.3.13", diff --git a/packages/0-framework/3-tooling/cli/package.json b/packages/0-framework/3-tooling/cli/package.json index 24b48530..7528589d 100644 --- a/packages/0-framework/3-tooling/cli/package.json +++ b/packages/0-framework/3-tooling/cli/package.json @@ -21,7 +21,7 @@ "@internal/assemble": "workspace:0.14.0", "@internal/core": "workspace:0.14.0", "@internal/foundation": "workspace:0.14.0", - "@prisma/cli-engine": "0.2.3", + "@prisma/cli-engine": "0.3.0", "c12": "^3.3.4", "chokidar": "^4.0.3" }, diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/load-config.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/load-config.test.ts index 874bd1e2..ad3384d1 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/load-config.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/load-config.test.ts @@ -129,7 +129,7 @@ describe('findConfigPathForEntry() — the walk-up', () => { }); describe('resolveConfigFile() — which file a load will use', () => { - test('a relative configPath resolves against the cwd the command runs in', () => { + test('the section-supplied absolute configPath is used as given, whatever the cwd', () => { const dir = makeTree(); const appDir = path.join(dir, 'apps', 'shop'); fs.mkdirSync(appDir, { recursive: true }); @@ -138,8 +138,8 @@ describe('resolveConfigFile() — which file a load will use', () => { const resolved = resolveConfigFile({ entryPath: path.join(dir, 'module.ts'), - configPath: `./apps/shop/${CONFIG_FILENAME}`, - cwd: dir, + configPath, + cwd: appDir, }); expect(resolved.path).toBe(configPath); @@ -164,7 +164,7 @@ describe('resolveConfigFile() — which file a load will use', () => { try { resolveConfigFile({ entryPath: path.join(dir, 'module.ts'), - configPath: './not-here.config.ts', + configPath: path.join(dir, 'not-here.config.ts'), cwd: dir, }); } catch (thrown: unknown) { diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/engine-cli.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/engine-cli.test.ts index 5944ee27..a1b51cd2 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/engine-cli.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/engine-cli.test.ts @@ -28,7 +28,7 @@ import { type ComposerSection, composerSection } from '../section.ts'; const VERSION = '0.6.0-test'; const NO_CONFIG = (): Promise => - Promise.resolve({ path: '/app/prisma.config.ts', sections: {}, diagnostics: [] }); + Promise.resolve({ files: [{ path: '/app/prisma.config.ts', sections: {} }], diagnostics: [] }); function fakeHost(cwd: string): HostProcess & { out: string[]; err: string[] } { const out: string[] = []; @@ -193,13 +193,54 @@ describe('the composer section through the engine', () => { expect(result.presented?.data).toEqual({}); }); - test('a configPath reaches the handler as ctx.config', async () => { + test('a configPath reaches the handler as ctx.config, resolved against the declaring file', async () => { + // The harness seeds the config file at the run's cwd, `/`, so the + // section's relative path resolves against that directory — not against + // wherever this test process happens to run. const result = await probeCli({ composer: { configPath: './x/prisma-composer.config.ts' }, }).run(['probe', '--json']); expect(result.exitCode).toBe(0); expect(result.presented?.data).toEqual({ - configPath: './x/prisma-composer.config.ts', + configPath: path.join(path.sep, 'x', 'prisma-composer.config.ts'), + } satisfies ComposerSection); + }); + + /** + * The reason the section resolves paths at all. prisma.config.ts files form a + * chain — discovered from cwd up to the repo root and merged per key — so a + * `composer` section written once at the root reaches commands run in any + * subdirectory. The root file is the only one declaring `configPath`, and the + * run happens two directories below it, so both wrong answers are visible: + * resolving against cwd or against the nearest file on the chain would name + * `/repo/apps/shop/prisma-composer.config.ts`. + */ + test('a configPath declared at the repo root names the same file from a subdirectory', async () => { + const repo = path.join(path.sep, 'repo'); + const appDir = path.join(repo, 'apps', 'shop'); + const cli = createTestCli({ + commandFamilies: [ + defineCommandFamily({ configSection: composerSection, commands: { probe } }), + ], + commands: { probe }, + loadConfig: () => + Promise.resolve({ + files: [ + { path: path.join(appDir, 'prisma.config.ts'), sections: {} }, + { + path: path.join(repo, 'prisma.config.ts'), + sections: { composer: { configPath: './prisma-composer.config.ts' } }, + }, + ], + diagnostics: [], + }), + }); + + const result = await cli.run(['probe', '--json'], { cwd: appDir }); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + configPath: path.join(repo, 'prisma-composer.config.ts'), } satisfies ComposerSection); }); diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/host-adapter.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/host-adapter.test.ts index 3e4f8c55..a54be863 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/host-adapter.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/host-adapter.test.ts @@ -140,6 +140,8 @@ describe('runComposerCli() — the real Runtime, on a command that needs config' // The section's own field, not just the fact that dev ran: `configPath` // travels in the operation's SECOND argument, so a handler that read the // section but forgot to pass it on would still satisfy the call count. - expect(double.calls.deps.dev[0]?.configPath).toBe('custom'); + // The value arrives resolved against the config file that declared it + // (which the loader realpaths), not against the run's cwd. + expect(double.calls.deps.dev[0]?.configPath).toBe(path.join(fs.realpathSync(dir), 'custom')); }); }); diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts index 68394848..1e907ae8 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts @@ -9,7 +9,7 @@ import type { HostProcess, LoadedConfig } from '@prisma/cli-engine'; import { createRuntime, detectPackageManager } from '../runtime.ts'; const noConfig = (): Promise => - Promise.resolve({ path: '/app/prisma.config.ts', sections: {}, diagnostics: [] }); + Promise.resolve({ files: [{ path: '/app/prisma.config.ts', sections: {} }], diagnostics: [] }); interface FakeHost extends HostProcess { readonly listeners: Map void>>; @@ -237,8 +237,7 @@ describe('createRuntime()', () => { test('the loader is exposed as loadConfig, and its result is passed through untouched', async () => { const config: LoadedConfig = { - path: '/app/prisma.config.ts', - sections: { composer: { configPath: 'x.ts' } }, + files: [{ path: '/app/prisma.config.ts', sections: { composer: { configPath: 'x.ts' } } }], diagnostics: [], }; const runtime = createRuntime(fakeHost(), () => Promise.resolve(config)); diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/section.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/section.test.ts index 209b6ead..c61e1d4b 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/section.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/section.test.ts @@ -6,31 +6,65 @@ * user's typo as a bug in composer. */ import { describe, expect, test } from 'bun:test'; +import * as path from 'node:path'; +import type { SectionProvenance } from '@prisma/cli-engine'; import { composerSection } from '../section.ts'; +const DECLARING_FILE = path.join(path.sep, 'repo', 'prisma.config.ts'); + +function provenance(file: string = DECLARING_FILE): SectionProvenance { + return { files: [file], keys: { configPath: file } }; +} + describe('composerSection.validate()', () => { test('absence is valid and yields an empty section', () => { - const result = composerSection.validate(undefined); + const result = composerSection.validate(undefined, provenance()); expect(result.ok).toBe(true); expect(result.ok && result.value).toEqual({}); expect(result.diagnostics).toEqual([]); }); test('an empty section is valid — every field is optional', () => { - const result = composerSection.validate({}); + const result = composerSection.validate({}, provenance()); expect(result.ok).toBe(true); expect(result.ok && result.value).toEqual({}); }); - test('a configPath comes through', () => { - const result = composerSection.validate({ configPath: './app/prisma-composer.config.ts' }); + /** + * The declaring file is `/repo/prisma.config.ts` and this test process runs + * somewhere else entirely, so a validator that resolved against the process + * cwd could not produce the expected path. + */ + test('a relative configPath resolves against the file that declared it', () => { + const result = composerSection.validate( + { configPath: './app/prisma-composer.config.ts' }, + provenance(), + ); + expect(result.ok).toBe(true); + expect(result.ok && result.value).toEqual({ + configPath: path.join(path.sep, 'repo', 'app', 'prisma-composer.config.ts'), + }); + }); + + test('an absolute configPath passes through unchanged', () => { + const absolute = path.join(path.sep, 'elsewhere', 'prisma-composer.config.ts'); + const result = composerSection.validate({ configPath: absolute }, provenance()); expect(result.ok).toBe(true); - expect(result.ok && result.value).toEqual({ configPath: './app/prisma-composer.config.ts' }); + expect(result.ok && result.value).toEqual({ configPath: absolute }); + }); + + test('a provenance without the configPath key fails instead of throwing', () => { + const result = composerSection.validate( + { configPath: './x.ts' }, + { files: [DECLARING_FILE], keys: {} }, + ); + expect(result.ok).toBe(false); + expect(result.diagnostics[0]?.code).toBe('CONFIG.FIELD_INVALID'); }); test('a non-object section fails with a field diagnostic', () => { for (const raw of ['nope', 42, [], null]) { - const result = composerSection.validate(raw); + const result = composerSection.validate(raw, provenance()); expect(result.ok).toBe(false); expect(result.diagnostics[0]?.code).toBe('CONFIG.FIELD_INVALID'); expect(result.diagnostics[0]?.severity).toBe('error'); @@ -39,23 +73,23 @@ describe('composerSection.validate()', () => { test('a non-string or empty configPath fails', () => { for (const configPath of [42, '', {}, true]) { - const result = composerSection.validate({ configPath }); + const result = composerSection.validate({ configPath }, provenance()); expect(result.ok).toBe(false); expect(result.diagnostics[0]?.summary).toContain('configPath'); } }); test('an unrecognized field warns but does not fail — a newer config still runs', () => { - const result = composerSection.validate({ configPath: 'x.ts', stage: 'prod' }); + const result = composerSection.validate({ configPath: 'x.ts', stage: 'prod' }, provenance()); expect(result.ok).toBe(true); - expect(result.ok && result.value).toEqual({ configPath: 'x.ts' }); + expect(result.ok && result.value).toEqual({ configPath: path.join(path.sep, 'repo', 'x.ts') }); expect(result.diagnostics).toHaveLength(1); expect(result.diagnostics[0]?.severity).toBe('warn'); expect(result.diagnostics[0]?.summary).toContain('stage'); }); test('every diagnostic carries the nextActions the engine renders', () => { - const result = composerSection.validate({ configPath: 42 }); + const result = composerSection.validate({ configPath: 42 }, provenance()); expect(result.diagnostics[0]?.nextActions.length).toBeGreaterThan(0); expect(result.diagnostics[0]?.nextActions[0]?.label).toBeTruthy(); }); @@ -86,7 +120,7 @@ describe('composerSection.validate()', () => { ), ]; for (const raw of hostile) { - expect(() => composerSection.validate(raw)).not.toThrow(); + expect(() => composerSection.validate(raw, provenance())).not.toThrow(); } }); @@ -100,6 +134,7 @@ describe('composerSection.validate()', () => { }, }, ), + provenance(), ); expect(result.ok).toBe(false); expect(result.diagnostics[0]?.code).toBe('CONFIG.FIELD_INVALID'); diff --git a/packages/0-framework/3-tooling/cli/src/family/section.ts b/packages/0-framework/3-tooling/cli/src/family/section.ts index 22ae7777..471d8c81 100644 --- a/packages/0-framework/3-tooling/cli/src/family/section.ts +++ b/packages/0-framework/3-tooling/cli/src/family/section.ts @@ -15,15 +15,19 @@ import { type ConfigSection, defineConfigSection, + resolveSectionPath, + type SectionProvenance, type SectionValidation, } from '@prisma/cli-engine'; import type { Diagnostic } from '@prisma/cli-engine/protocol'; export interface ComposerSection { /** - * Path to `prisma-composer.config.ts`, absolute or relative to the process - * cwd. Absent — the common case — means composer searches upward from the - * command's entry argument, as it always has. + * Absolute path to `prisma-composer.config.ts`. In the config file it may + * be written relative; the validator resolves it against the file that + * declared it, so a root-declared path means the same file from every + * subdirectory. Absent — the common case — means composer searches upward + * from the command's entry argument, as it always has. */ readonly configPath?: string | undefined; } @@ -46,7 +50,7 @@ function diagnostic(spec: { }; } -function validate(raw: unknown): SectionValidation { +function validate(raw: unknown, provenance: SectionProvenance): SectionValidation { // Absence is normal and is the validator's to own: with no section, // composer walks up from the entry exactly as it does today. if (raw === undefined) return { ok: true, value: {}, diagnostics: [] }; @@ -103,6 +107,32 @@ function validate(raw: unknown): SectionValidation { }; } + // A relative configPath means "relative to the file that declared it": a + // section written once at the repo root must name the same file from every + // subdirectory a command runs in. resolveSectionPath throws only when the + // key is missing from the provenance, which cannot happen for a key just + // read out of the section — but this validator must never throw, so even + // the impossible case becomes a diagnostic rather than an internal error. + let resolvedConfigPath: string | undefined; + if (configPath !== undefined) { + try { + resolvedConfigPath = resolveSectionPath(provenance, 'configPath', configPath); + } catch { + return { + ok: false, + diagnostics: [ + diagnostic({ + code: 'CONFIG.FIELD_INVALID', + severity: 'error', + summary: + '`composer.configPath` could not be resolved against the file that declared it.', + fix: 'Write `configPath` as an absolute path, or check the `composer` section of prisma.config.ts.', + }), + ], + }; + } + } + // An unrecognized field is a warning, not a failure: the section's fields // grow by contract amendment, so a config written for a newer composer must // still run on this one. A warning on an ok validation reaches stderr and @@ -111,7 +141,7 @@ function validate(raw: unknown): SectionValidation { return { ok: true, - value: configPath === undefined ? {} : { configPath }, + value: resolvedConfigPath === undefined ? {} : { configPath: resolvedConfigPath }, diagnostics: unknown.map((key) => diagnostic({ code: 'CONFIG.FIELD_UNKNOWN', diff --git a/packages/0-framework/3-tooling/cli/src/load-config.ts b/packages/0-framework/3-tooling/cli/src/load-config.ts index a6b22374..c147e731 100644 --- a/packages/0-framework/3-tooling/cli/src/load-config.ts +++ b/packages/0-framework/3-tooling/cli/src/load-config.ts @@ -253,17 +253,17 @@ export interface ConfigLoadRequest { /** The command's entry argument. Anchors the walk when no explicit path is given. */ readonly entryPath: string; /** - * The `composer` config section's `configPath`, absolute or relative to - * `cwd`. When present the section wins: the walk is skipped, and so is the - * same-file check — that check exists to catch a walk finding one file while - * c12 loaded another, and a path the user named directly has no walk to - * disagree with. + * The `composer` config section's `configPath`, absolute — the section + * validator resolved a relative path against the config file that declared + * it, so this code never resolves it against `cwd`. When present the + * section wins: the walk is skipped, and so is the same-file check — that + * check exists to catch a walk finding one file while c12 loaded another, + * and a path the user named directly has no walk to disagree with. */ readonly configPath?: string | undefined; /** - * The directory the command runs in: it anchors a relative `configPath` and - * is where the effect-resolution check looks for the installed tree. - * Defaults to the entry's directory. + * The directory the command runs in: where the effect-resolution check + * looks for the installed tree. Defaults to the entry's directory. */ readonly cwd?: string | undefined; } @@ -304,7 +304,7 @@ function configSource(request: ConfigLoadRequest): ConfigSource { : { ok: true, path: discovered, explicit: false }; } - const configPath = path.resolve(cwd, request.configPath); + const configPath = path.resolve(request.configPath); if (!fs.existsSync(configPath)) { return { ok: false, diff --git a/packages/0-framework/3-tooling/cli/src/pipeline.ts b/packages/0-framework/3-tooling/cli/src/pipeline.ts index 9b34ad09..29075e25 100644 --- a/packages/0-framework/3-tooling/cli/src/pipeline.ts +++ b/packages/0-framework/3-tooling/cli/src/pipeline.ts @@ -25,10 +25,11 @@ export interface PipelineDeps { readonly config?: PrismaAppConfig | undefined; /** * The config file to load, named explicitly instead of discovered — - * absolute, or relative to `cwd`. When present the entry-anchored walk is - * skipped entirely, so a config that does not sit above the entry is still - * usable — and the walk's path-mismatch check has nothing left to check. - * Supplied by the engine's `composer` config section. + * absolute. When present the entry-anchored walk is skipped entirely, so a + * config that does not sit above the entry is still usable — and the walk's + * path-mismatch check has nothing left to check. Supplied by the engine's + * `composer` config section, whose validator already resolved a relative + * path against the config file that declared it. */ readonly configPath?: string | undefined; } diff --git a/packages/1-prisma-cloud/1-extensions/target/package.json b/packages/1-prisma-cloud/1-extensions/target/package.json index b2d97af1..66251212 100644 --- a/packages/1-prisma-cloud/1-extensions/target/package.json +++ b/packages/1-prisma-cloud/1-extensions/target/package.json @@ -39,7 +39,7 @@ }, "devDependencies": { "@internal/tsdown-config": "workspace:0.14.0", - "@prisma/cli-engine": "0.2.3", + "@prisma/cli-engine": "0.3.0", "@types/bun": "^1.3.13", "tsdown": "^0.22.7", "typescript": "^6.0.3", diff --git a/packages/9-public/composer-cli/package.json b/packages/9-public/composer-cli/package.json index a8e1516f..310f9b24 100644 --- a/packages/9-public/composer-cli/package.json +++ b/packages/9-public/composer-cli/package.json @@ -28,12 +28,12 @@ "esbuild": "^0.28.1" }, "peerDependencies": { - "@prisma/cli-engine": "0.2.3" + "@prisma/cli-engine": "0.3.0" }, "devDependencies": { "@internal/cli": "workspace:0.14.0", "@internal/tsdown-config": "workspace:0.14.0", - "@prisma/cli-engine": "0.2.3", + "@prisma/cli-engine": "0.3.0", "@types/node": "^26.0.1", "tsdown": "^0.22.7", "typescript": "^6.0.3"