From 6732d4930fa7577338241647e4a1779e09780aa3 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 22 Sep 2026 08:52:20 +0200 Subject: [PATCH 1/4] feat(engine): a config section declares its shape once, and the engine resolves its path fields A command family now declares the config section it accepts as a schema, with the fields that hold paths marked `path`, and the engine derives validation, the diagnostics naming the field and the file to fix, and the resolution of every path field from that one declaration. configSchema is arktype in a scope with one extra keyword: `path`, a string that validation resolves against the directory of the config file that declared the value's top-level key, using the provenance the chain merge already records. defineConfigSection({ name, schema }) derives the validator; a hand-written validate stays available for a section a schema cannot express. Each arktype error becomes a CLI.CONFIG_FIELD_INVALID diagnostic with meta.section, meta.field and where.path. A plain-object section comes back with baseDir, the nearest declaring file's directory. An absent section validates as {}. Why: the engine handed sections over as written and families resolved relative paths against cwd, so contract emit --config ./sub/prisma.config.ts run from the parent looked for ./contract.prisma in the parent. Only the family knows which fields are paths; only the engine knows which file wrote them. The declaration puts the first where the second can use it. Design: ADR 0005. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- ...005-config-sections-declare-their-shape.md | 73 +++++ docs/architecture/adrs/README.md | 1 + docs/reference/error-reference.md | 4 + packages/cli-engine/package.json | 1 + packages/cli-engine/src/config-schema.ts | 194 +++++++++++++ packages/cli-engine/src/config-section.ts | 52 +++- packages/cli-engine/src/exports/index.ts | 6 + .../cli-engine/tests/config-schema.test.ts | 256 ++++++++++++++++++ packages/cli-engine/tests/engine.test.ts | 2 + .../schema-chain/child/prisma.config.ts | 5 + .../config/schema-chain/prisma.config.ts | 5 + pnpm-lock.yaml | 3 + 12 files changed, 595 insertions(+), 7 deletions(-) create mode 100644 docs/architecture/adrs/0005-config-sections-declare-their-shape.md create mode 100644 packages/cli-engine/src/config-schema.ts create mode 100644 packages/cli-engine/tests/config-schema.test.ts create mode 100644 packages/cli-engine/tests/fixtures/config/schema-chain/child/prisma.config.ts create mode 100644 packages/cli-engine/tests/fixtures/config/schema-chain/prisma.config.ts diff --git a/docs/architecture/adrs/0005-config-sections-declare-their-shape.md b/docs/architecture/adrs/0005-config-sections-declare-their-shape.md new file mode 100644 index 00000000..86a4e18c --- /dev/null +++ b/docs/architecture/adrs/0005-config-sections-declare-their-shape.md @@ -0,0 +1,73 @@ +# ADR 0005 - Config sections declare their shape once, and the engine derives validation and path resolution from it + +## Status + +Accepted (operator, 2026-09-22). + +## Decision + +A command family declares the config section it accepts once, as a schema, and marks the fields that hold paths as `path`. The engine derives everything else from that declaration: structural validation, diagnostics that name the bad field and the file to fix, and the resolution of every `path` field against the config file that wrote it. + +```ts +import { configSchema, defineConfigSection } from "@prisma/cli-engine"; + +export const ormConfigSection = defineConfigSection({ + name: "orm", + schema: configSchema({ + "contract?": { + source: { "inputs?": "path[]", load: "Function" }, + "output?": "path", + }, + "migrations?": { "dir?": "path = './migrations'" }, + }), +}); +``` + +Given this file and this invocation: + +``` +exp/ + sub/ + prisma.config.ts # orm: { contract: { source: { inputs: ['./contract.prisma'] } } } + contract.prisma +``` + +``` +cd exp && prisma contract emit --config ./sub/prisma.config.ts +``` + +the handler receives `contract.source.inputs` as `['/…/exp/sub/contract.prisma']`, `migrations.dir` as `/…/exp/sub/migrations`, and `baseDir` as `/…/exp/sub`, whatever directory the command ran from. + +## Context + +A relative path in a config file is relative to that file; there is no other reading an author could mean. But the engine handed a section over exactly as written and the command family did not know which file it came from, so families resolved against the working directory. The ORM's `contract emit --config ./sub/prisma.config.ts`, run from `exp`, looked for `./contract.prisma` in `exp` and failed; from `exp/sub` the same file worked. + +Config discovery walks up to the repository root and merges files, most local value winning (`config-merge.ts`), and it records provenance: which file wrote each top-level key of the merged section. That is the information path resolution needs, per key, after the merge. What was missing was a way for the engine to know which fields are paths. A section was opaque to it. + +Several designs were tried before this one, and each put the knowledge in the wrong place: telling the command which file was loaded (wrong under layering, where one section merges several files); asking authors to pass `import.meta` (a value the loader already has); a resolver function attached to each section and called per file (a protocol two parties must implement); publishing the file's directory to the file while it evaluates (ambient state, and it moved resolution into `defineConfig`, which the ORM's rules reserve for normalisation only). Declaring the shape removes the question: the family says which fields are paths, and the engine, which has the provenance, resolves them. + +## How it works + +- `configSchema` is arktype's `type` in a scope with one extra keyword, `path`: a string that validation resolves against the directory of the file that declared the value's top-level key, using the section's provenance. An absolute value passes through unchanged. Every other arktype feature (optional keys, defaults, unions, narrows for cross-field rules) is available as is. +- `defineConfigSection({ name, schema })` derives the section's validator. The engine runs it on the merged section value with its provenance, after discovery and merging, so defaults declared in the schema apply once to the merged value and never let one file's default shadow another file's authored value. A `path` default resolves against the nearest file. +- Each arktype error becomes a `CLI.CONFIG_FIELD_INVALID` diagnostic carrying `meta.section`, `meta.field`, and `where.path`, the file that declared the field's top-level key, so a chain of files still tells the user which one to fix. +- The validated value of a plain-object section carries `baseDir`, the directory of the nearest file declaring the section, for commands that need the project's location rather than one of its files. +- An absent section is validated as an empty object: a schema whose fields are all optional accepts it, and a required field is reported by name. +- `defineConfigSection({ name, validate })` remains for a section a schema cannot express; such a validator resolves its own path fields through `resolveSectionPath`. + +The same declaration style is the contract for every product that mounts commands in the CLI: the ORM, Composer, and any future family declare their section with `configSchema` and get identical validation, diagnostics, and path semantics. + +## Consequences + +- A family with a schema writes no validation, resolution, or path-anchoring code. Its commands read absolute paths and `baseDir`. +- `@prisma/cli-engine` depends on arktype, which is what every product's schema is written in. +- A family whose section has `path` fields needs an engine that runs schemas. Under the exact peers of ADR 0004, a family release that adopts a schema moves its engine peer to the engine that ships this, and the engine ships first. +- Validation of one section is synchronous and self-contained; there is no ambient state to get wrong under concurrent loads. + +## Alternatives considered + +- **Command context carries the loaded file's path.** Fails as soon as one section merges several files: one path cannot anchor values from two directories. +- **Authors pass `import.meta` to the config helper.** Asks for a value the loader already knows, and because the helper runs before the outer call, still needs a deferred-resolution protocol. +- **A resolver function on the section, called by the loader per file.** Same result, through a protocol both the family and every loader must implement, plus per-layer resolution inside each loader. +- **A base directory published to the file while it evaluates.** Ambient state, needed an `AsyncLocalStorage` to survive concurrent loads, and moved resolution into the family's `defineConfig`, whose job is normalisation. +- **The engine resolves paths without a declaration.** It cannot: a section is opaque unless its owner declares which fields are paths. This ADR is that declaration. diff --git a/docs/architecture/adrs/README.md b/docs/architecture/adrs/README.md index c8a50bb5..680155de 100644 --- a/docs/architecture/adrs/README.md +++ b/docs/architecture/adrs/README.md @@ -15,6 +15,7 @@ long-term architecture boundaries. | [0002](0002-workflow-command-model.md) | Accepted | Group commands by developer workflow using `prisma `. | | [0003](0003-structured-output-and-errors.md) | Accepted | Treat structured output and stable error codes as public contracts. | | [0004](0004-engine-version-pinning.md) | Accepted | One engine per install: product CLI packages declare the engine as an exact peer, product libraries carry no engine relationship. | +| [0005](0005-config-sections-declare-their-shape.md) | Accepted | A command family declares its config section once as a schema with `path` fields; the engine derives validation, diagnostics, and path resolution from it. | ## ADR Template diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 205ce9d9..efbf270c 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -132,6 +132,10 @@ A config file declares `parent` with a value that is neither `false` nor a path A config file's explicit `parent` names a file that does not exist. Naming a parent is deliberate, so its absence is an error — unlike discovery, where finding no file is fine. Raised by the config loader while following the chain; the declaring file's absolute path is in `where.path` and the summary names the missing target. Meta: none. +### CLI.CONFIG_FIELD_INVALID + +One field of a config section declared by schema failed that schema: the wrong type, a missing required field, or a value outside the declared set. The summary names the section and the field with arktype's description of the problem; `where.path` is the config file that declared the field's top-level key (so the file to fix on a chain), and `meta.section` and `meta.field` carry the names. Travels as an accompanying diagnostic under `CLI.CONFIG_SECTION_INVALID`. Raised by the engine's schema validation before the handler runs. Meta: `section`, `field`. + ### CLI.CONFIG_SECTION_INVALID The config section a command declared in `needs.config` failed its validator; the individual problems travel as accompanying diagnostics on the envelope, and the summary names the section and the config file actually read (respecting `--config`). Raised by the engine's needs check before the handler runs. Meta: none. diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index c839f45f..742b81ef 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -48,6 +48,7 @@ "dependencies": { "@clack/prompts": "1.5.0", "@stricli/core": "1.3.0", + "arktype": "2.2.3", "c12": "3.3.4", "colorette": "^2.0.20", "package-manager-detector": "1.8.0", diff --git a/packages/cli-engine/src/config-schema.ts b/packages/cli-engine/src/config-schema.ts new file mode 100644 index 00000000..f9472752 --- /dev/null +++ b/packages/cli-engine/src/config-schema.ts @@ -0,0 +1,194 @@ +import { dirname, isAbsolute, resolve } from "node:path"; +import { type ArkErrors, scope, type Type, type } from "arktype"; +import type { SectionProvenance } from "./config-merge"; +import type { SectionValidation } from "./config-section"; +import type { Diagnostic } from "./protocol"; + +/** + * The provenance of the section being validated, published for the + * duration of one synchronous schema run so the `path` keyword can + * resolve each value against the file that declared its top-level key. + */ +let current: + | { readonly name: string; readonly provenance: SectionProvenance } + | undefined; + +/** The file that declared the top-level key a value sits under, else the nearest file. */ +function declaringFile( + provenance: SectionProvenance, + path: readonly PropertyKey[], +): string | undefined { + const top = path[0]; + return ( + (typeof top === "string" ? provenance.keys[top] : undefined) ?? + provenance.files[0] + ); +} + +/** + * Outside a section validation there is no file to resolve against, and + * the value is returned as written. arktype runs a field's morph when a + * default is declared, at schema definition time, so this branch is what + * a `path` default takes then; the default is resolved when it is applied. + */ +function resolvePathValue(value: string, path: readonly PropertyKey[]): string { + if (isAbsolute(value) || current === undefined) { + return value; + } + const file = declaringFile(current.provenance, path); + return file === undefined ? value : resolve(dirname(file), value); +} + +const configScope = scope({ + /** + * A string relative to the config file that wrote it. Validation turns it + * into an absolute path against that file's directory; an absolute value + * passes through unchanged. + */ + path: type("string").pipe((value, ctx) => resolvePathValue(value, ctx.path)), +}); + +/** + * Declares the shape of a config section once. Definitions are arktype + * definitions with one extra keyword, `path`, for a field holding a path + * relative to the config file. The declaration drives validation, the + * diagnostics that name the field and the file to fix, and path + * resolution; nothing else has to know which fields are paths. + * + * ```ts + * const toySchema = configSchema({ + * "out?": "path", + * "inputs?": "path[]", + * greeting: "string = 'hello'", + * }); + * ``` + */ +export const configSchema: typeof configScope.type = configScope.type; + +export type ConfigSchema = Type; + +/** The validated value a schema produces: its output type, plus `baseDir` on a plain object. */ +export type ConfigSchemaValue = S["infer"]; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Plain objects and arrays copied; anything else, functions included, by reference. */ +function copyPlainData(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(copyPlainData); + } + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, copyPlainData(entry)]), + ); + } + return value; +} + +function fieldDiagnostic( + name: string, + error: ArkErrors[number], + provenance: SectionProvenance, +): Diagnostic { + const field = error.path.map(String).join("."); + const file = declaringFile(provenance, error.path); + return { + code: "CLI.CONFIG_FIELD_INVALID", + severity: "error", + summary: `In the '${name}' section, ${error.message}`, + nextActions: [ + { + kind: "edit-file", + label: + file === undefined + ? `Correct ${field === "" ? name : `${name}.${field}`} in prisma.config.ts` + : `Correct ${field === "" ? name : `${name}.${field}`} in ${file}`, + }, + ], + ...(file === undefined ? {} : { where: { path: file } }), + meta: { section: name, field }, + }; +} + +/** + * Validates one section's resolved value against its schema. An absent + * section is validated as an empty object, so a schema whose fields are + * all optional accepts it and a required field is reported by name. A + * plain-object value comes back frozen and carrying `baseDir`, the + * directory of the nearest file declaring the section. Never throws for + * any input: arktype reports problems as errors, and a `path` value is + * only ever resolved here. + */ +export function validateSectionWithSchema( + name: string, + schema: S, + raw: unknown, + provenance: SectionProvenance, +): SectionValidation> { + current = { name, provenance }; + try { + // arktype applies defaults and morphs onto the objects it is handed, and + // the merged section value arrives frozen, so it validates a copy. + const input: unknown = raw === undefined ? {} : copyPlainData(raw); + const validated: unknown = schema(input); + if (validated instanceof type.errors) { + return { + ok: false, + diagnostics: [...validated].map((error) => + fieldDiagnostic(name, error, provenance), + ), + }; + } + // A `path` default is stored as written when the schema is defined and + // inserted verbatim, so a second pass over the validated value resolves + // it; every path already resolved passes through unchanged. + const out: unknown = schema(validated); + if (out instanceof type.errors) { + return { + ok: false, + diagnostics: [...out].map((error) => + fieldDiagnostic(name, error, provenance), + ), + }; + } + const nearest = provenance.files[0]; + const value = + isPlainObject(out) && nearest !== undefined + ? Object.freeze({ ...out, baseDir: dirname(nearest) }) + : out; + return { ok: true, value: value as ConfigSchemaValue, diagnostics: [] }; + } catch (cause) { + // A nested value the file froze, or a getter that throws when arktype + // reads it: config-file content, reported as such rather than as a bug. + return { + ok: false, + diagnostics: [unreadableDiagnostic(name, cause, provenance)], + }; + } finally { + current = undefined; + } +} + +function unreadableDiagnostic( + name: string, + cause: unknown, + provenance: SectionProvenance, +): Diagnostic { + const message = cause instanceof Error ? cause.message : String(cause); + const file = provenance.files[0]; + return { + code: "CLI.CONFIG_FIELD_INVALID", + severity: "error", + summary: `The '${name}' section could not be validated: ${message.split("\n", 1)[0].trim()}`, + nextActions: [ + { + kind: "edit-file", + label: `Export a plain configuration object for '${name}' in ${file ?? "prisma.config.ts"}`, + }, + ], + ...(file === undefined ? {} : { where: { path: file } }), + meta: { section: name, field: "" }, + }; +} diff --git a/packages/cli-engine/src/config-section.ts b/packages/cli-engine/src/config-section.ts index 7893a9f0..1e1c03c6 100644 --- a/packages/cli-engine/src/config-section.ts +++ b/packages/cli-engine/src/config-section.ts @@ -1,4 +1,9 @@ import type { SectionProvenance } from "./config-merge"; +import { + type ConfigSchema, + type ConfigSchemaValue, + validateSectionWithSchema, +} from "./config-schema"; import type { Diagnostic } from "./protocol"; /** @@ -42,17 +47,50 @@ export type SectionValidation = } | { readonly ok: false; readonly diagnostics: readonly Diagnostic[] }; +type SectionValidator = ( + raw: unknown | undefined, + provenance: SectionProvenance, +) => SectionValidation; + +type SectionMerge = (parent: unknown, child: unknown) => unknown; + +/** + * A section declared by its schema: validation, the diagnostics naming + * each bad field and the file to fix, and the resolution of every field + * declared `path` all derive from the one declaration (see configSchema). + * A section that needs logic a schema cannot express supplies `validate` + * instead; it then resolves its own path fields through resolveSectionPath. + */ +export function defineConfigSection(spec: { + readonly name: string; + readonly schema: S; + readonly merge?: SectionMerge; +}): ConfigSection>; export function defineConfigSection(spec: { readonly name: string; - readonly validate: ( - raw: unknown | undefined, - provenance: SectionProvenance, - ) => SectionValidation; - readonly merge?: (parent: unknown, child: unknown) => unknown; -}): ConfigSection { + readonly validate: SectionValidator; + readonly merge?: SectionMerge; +}): ConfigSection; +export function defineConfigSection(spec: { + readonly name: string; + readonly schema?: ConfigSchema; + readonly validate?: SectionValidator; + readonly merge?: SectionMerge; +}): ConfigSection { + const schema = spec.schema; + const validate: SectionValidator | undefined = + schema === undefined + ? spec.validate + : (raw, provenance) => + validateSectionWithSchema(spec.name, schema, raw, provenance); + if (validate === undefined) { + throw new Error( + `@prisma/cli-engine: config section '${spec.name}' declares neither a schema nor a validate function`, + ); + } return Object.freeze({ name: spec.name, - validate: spec.validate, + validate, merge: spec.merge, }); } diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index bb094a87..8ff861da 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -56,6 +56,12 @@ export { resolveSectionPath, type SectionProvenance, } from "../config-merge"; +export { + type ConfigSchema, + type ConfigSchemaValue, + configSchema, + validateSectionWithSchema, +} from "../config-schema"; export { type ConfigSection, defineConfigSection, diff --git a/packages/cli-engine/tests/config-schema.test.ts b/packages/cli-engine/tests/config-schema.test.ts new file mode 100644 index 00000000..99a26e36 --- /dev/null +++ b/packages/cli-engine/tests/config-schema.test.ts @@ -0,0 +1,256 @@ +/** + * A config section declared once as a schema: the declaration drives + * validation, the diagnostics naming the field and the file to fix, and + * the resolution of every field declared `path` against the file that + * wrote it. + */ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + configSchema, + defineCommand, + defineConfigSection, + loadConfig, + type SectionProvenance, + validateSectionWithSchema, +} from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { createTestCli } from "@prisma/cli-engine/testing"; +import { describe, expect, test } from "vitest"; + +const FIXTURES = join( + dirname(fileURLToPath(import.meta.url)), + "fixtures", + "config", +); + +const toySchema = configSchema({ + "dir?": "path", + "out?": "path", + "inputs?": "path[]", + "nested?": { "file?": "path" }, + greeting: "string = 'hello'", + "level?": "number", +}); + +const single: SectionProvenance = { + files: ["/app/prisma.config.ts"], + keys: { + dir: "/app/prisma.config.ts", + inputs: "/app/prisma.config.ts", + nested: "/app/prisma.config.ts", + }, +}; + +describe("validateSectionWithSchema", () => { + test("resolves a path field against the file that declared it and records baseDir", () => { + const result = validateSectionWithSchema( + "toy", + toySchema, + { + dir: "./migrations", + inputs: ["./a.prisma", "/abs/b.prisma"], + nested: { file: "x/y.ts" }, + }, + single, + ); + + expect(result).toEqual({ + ok: true, + value: { + dir: "/app/migrations", + inputs: ["/app/a.prisma", "/abs/b.prisma"], + nested: { file: "/app/x/y.ts" }, + greeting: "hello", + baseDir: "/app", + }, + diagnostics: [], + }); + }); + + test("a nested path resolves against the file that declared its top-level key", () => { + const provenance: SectionProvenance = { + files: ["/child/prisma.config.ts", "/parent/prisma.config.ts"], + keys: { + out: "/child/prisma.config.ts", + dir: "/parent/prisma.config.ts", + nested: "/parent/prisma.config.ts", + }, + }; + + const result = validateSectionWithSchema( + "toy", + toySchema, + { out: "./dist", dir: "./migrations", nested: { file: "./f" } }, + provenance, + ); + + expect(result.ok && result.value).toMatchObject({ + out: "/child/dist", + dir: "/parent/migrations", + nested: { file: "/parent/f" }, + baseDir: "/child", + }); + }); + + test("a defaulted key no file wrote is attributed to the nearest file", () => { + const schema = configSchema({ dir: "path = './migrations'" }); + const provenance: SectionProvenance = { + files: ["/child/prisma.config.ts", "/parent/prisma.config.ts"], + keys: {}, + }; + + const result = validateSectionWithSchema("toy", schema, {}, provenance); + + expect(result.ok && result.value).toMatchObject({ + dir: "/child/migrations", + }); + }); + + test("an absent section validates as the empty section", () => { + const result = validateSectionWithSchema("toy", toySchema, undefined, { + files: [], + keys: {}, + }); + + expect(result).toEqual({ + ok: true, + value: { greeting: "hello" }, + diagnostics: [], + }); + }); + + test("a wrong type is a diagnostic naming the field and the file to fix", () => { + const result = validateSectionWithSchema( + "toy", + toySchema, + { dir: 42, level: "high" }, + single, + ); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toEqual([ + { + code: "CLI.CONFIG_FIELD_INVALID", + severity: "error", + summary: expect.stringContaining("dir must be a string"), + nextActions: [ + { + kind: "edit-file", + label: "Correct toy.dir in /app/prisma.config.ts", + }, + ], + where: { path: "/app/prisma.config.ts" }, + meta: { section: "toy", field: "dir" }, + }, + expect.objectContaining({ + code: "CLI.CONFIG_FIELD_INVALID", + meta: { section: "toy", field: "level" }, + }), + ]); + }); + + test("a required field missing from an absent section is reported by name", () => { + const schema = configSchema({ dir: "path" }); + + const result = validateSectionWithSchema("toy", schema, undefined, { + files: [], + keys: {}, + }); + + expect(result.ok).toBe(false); + expect(result.diagnostics.map((diagnostic) => diagnostic.meta)).toEqual([ + { section: "toy", field: "dir" }, + ]); + }); + + test("validates a frozen value, as the engine hands a merged section over frozen", () => { + const result = validateSectionWithSchema( + "toy", + toySchema, + Object.freeze({ dir: "./d", nested: Object.freeze({ file: "./f" }) }), + single, + ); + + expect(result.ok && result.value).toMatchObject({ + dir: "/app/d", + nested: { file: "/app/f" }, + }); + }); + + test("never throws on hostile input", () => { + for (const raw of [ + null, + 7, + "x", + [], + { + dir: { + get x() { + throw new Error("boom"); + }, + }, + }, + { __proto__: { dir: 1 } }, + ]) { + expect(() => + validateSectionWithSchema("toy", toySchema, raw, single), + ).not.toThrow(); + } + }); + + test("the schema's own type is the validated value", () => { + const section = defineConfigSection({ name: "toy", schema: toySchema }); + const result = section.validate({ dir: "./d" }, single); + + if (!result.ok) throw new Error("expected ok"); + const dir: string | undefined = result.value.dir; + const greeting: string = result.value.greeting; + expect({ dir, greeting }).toEqual({ dir: "/app/d", greeting: "hello" }); + }); +}); + +describe("a schema-declared section on a discovery chain", { + timeout: 60_000, +}, () => { + const chain = join(FIXTURES, "schema-chain"); + const child = join(chain, "child"); + + function probe() { + const section = defineConfigSection({ name: "toy", schema: toySchema }); + return createTestCli({ + commands: { + probe: defineCommand({ + help: { summary: "Reports the validated toy section" }, + needs: { config: section }, + handler: async (_args, ctx) => + ok( + ctx.present( + { data: ctx.config, exitCode: 0 }, + { + human: () => [], + stdout: () => [], + json: () => ctx.config, + next: () => [], + }, + ), + ), + }), + }, + loadConfig: (request) => loadConfig(child, request), + }); + } + + test("each path resolves against the file that declared it; baseDir is the nearest file's", async () => { + const run = await probe().run(["probe", "--json"], { cwd: child }); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toEqual({ + dir: join(chain, "migrations"), + greeting: "from the parent", + out: join(child, "dist"), + inputs: [join(child, "a.prisma"), "/abs/b.prisma"], + baseDir: child, + }); + }); +}); diff --git a/packages/cli-engine/tests/engine.test.ts b/packages/cli-engine/tests/engine.test.ts index 08755f70..82f1b01f 100644 --- a/packages/cli-engine/tests/engine.test.ts +++ b/packages/cli-engine/tests/engine.test.ts @@ -22,6 +22,7 @@ describe("main export", () => { "authServiceError", "claimedExpiresAt", "claimedIdentity", + "configSchema", "createCli", "credentialRejectedError", "credentialWorkspaceId", @@ -45,6 +46,7 @@ describe("main export", () => { "resolveSectionOverChain", "resolveSectionPath", "telemetryCommandGroup", + "validateSectionWithSchema", ]); }); diff --git a/packages/cli-engine/tests/fixtures/config/schema-chain/child/prisma.config.ts b/packages/cli-engine/tests/fixtures/config/schema-chain/child/prisma.config.ts new file mode 100644 index 00000000..d34f3488 --- /dev/null +++ b/packages/cli-engine/tests/fixtures/config/schema-chain/child/prisma.config.ts @@ -0,0 +1,5 @@ +import { definePrismaConfig } from "@prisma/cli-engine"; + +export default definePrismaConfig({ + toy: { out: "./dist", inputs: ["./a.prisma", "/abs/b.prisma"] }, +}); diff --git a/packages/cli-engine/tests/fixtures/config/schema-chain/prisma.config.ts b/packages/cli-engine/tests/fixtures/config/schema-chain/prisma.config.ts new file mode 100644 index 00000000..a7a6cb2b --- /dev/null +++ b/packages/cli-engine/tests/fixtures/config/schema-chain/prisma.config.ts @@ -0,0 +1,5 @@ +import { definePrismaConfig } from "@prisma/cli-engine"; + +export default definePrismaConfig({ + toy: { dir: "./migrations", greeting: "from the parent" }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b94253d9..ad3246e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: '@stricli/core': specifier: 1.3.0 version: 1.3.0 + arktype: + specifier: 2.2.3 + version: 2.2.3 c12: specifier: 3.3.4 version: 3.3.4(magicast@0.5.3) From a899ca122cec8922bed7a28428512067a1a2f7e4 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 22 Sep 2026 09:09:27 +0200 Subject: [PATCH 2/4] chore(cli-engine): bump to 0.6.0 and carry the engine transition exceptions forward 0.5.0 reached the registry after #233, so the changed engine ships under 0.6.0. The recorded engine-pin exceptions move with it: they expire when the families release peering 0.6.0 and the follow-up bump pins those releases. The conformance task now also depends on the prisma package build. The import-purity sweep reads packages/prisma/dist, but nothing in the shell depends on that package, so turbo could run the sweep before the build finished and report that no built JavaScript was swept. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/package.json | 2 +- packages/cli/package.json | 2 +- packages/cli/scripts/conformance.ts | 14 +++++++------- packages/prisma/package.json | 2 +- pnpm-lock.yaml | 4 ++-- turbo.json | 6 ++++-- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index 742b81ef..df3165a8 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -1,6 +1,6 @@ { "name": "@prisma/cli-engine", - "version": "0.5.0", + "version": "0.6.0", "description": "The execution engine of the unified Prisma CLI.", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index a821d5ff..c03c9608 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,7 +49,7 @@ }, "dependencies": { "@manypkg/tools": "^2.1.2", - "@prisma/cli-engine": "workspace:0.5.0", + "@prisma/cli-engine": "workspace:0.6.0", "@prisma/composer-cli": "0.21.0", "@prisma/compute-sdk": "0.42.0", "@prisma/management-api-sdk": "1.69.0", diff --git a/packages/cli/scripts/conformance.ts b/packages/cli/scripts/conformance.ts index ee861936..8dd8621b 100644 --- a/packages/cli/scripts/conformance.ts +++ b/packages/cli/scripts/conformance.ts @@ -113,25 +113,25 @@ async function tarball(): Promise { // in flight: the engine must publish before a family can peer it, // so the mismatch is real until both families release against it. // The entries expire with the versions they name, and the PR that - // pins the families' 0.5.0-peering releases removes them; while + // pins the families' 0.6.0-peering releases removes them; while // they stand, a release could ship the two-engine install they // describe, which is why they must not outlive the transition. exceptions: [ { familyPackage: "@prisma/composer-cli", familyPin: "0.4.0", - shellPin: "0.5.0", - reason: "engine 0.5.0 must publish before composer-cli can peer it", + shellPin: "0.6.0", + reason: "engine 0.6.0 must publish before composer-cli can peer it", removeWhen: - "composer-cli releases peering 0.5.0 and the follow-up bump PR pins that release", + "composer-cli releases peering 0.6.0 and the follow-up bump PR pins that release", }, { familyPackage: "@prisma/orm-toolchain", familyPin: "0.4.0", - shellPin: "0.5.0", - reason: "engine 0.5.0 must publish before orm-toolchain can peer it", + shellPin: "0.6.0", + reason: "engine 0.6.0 must publish before orm-toolchain can peer it", removeWhen: - "orm-toolchain releases peering 0.5.0 and the follow-up bump PR pins that release", + "orm-toolchain releases peering 0.6.0 and the follow-up bump PR pins that release", }, ], channel: CHANNEL, diff --git a/packages/prisma/package.json b/packages/prisma/package.json index d976d855..69bd73fc 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -50,7 +50,7 @@ }, "dependencies": { "@manypkg/tools": "^2.1.2", - "@prisma/cli-engine": "workspace:0.5.0", + "@prisma/cli-engine": "workspace:0.6.0", "@prisma/composer-cli": "0.21.0", "@prisma/compute-sdk": "0.42.0", "@prisma/management-api-sdk": "1.69.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad3246e0..691fb15d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,7 @@ importers: specifier: ^2.1.2 version: 2.1.2 '@prisma/cli-engine': - specifier: workspace:0.5.0 + specifier: workspace:0.6.0 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.21.0 @@ -210,7 +210,7 @@ importers: specifier: ^2.1.2 version: 2.1.2 '@prisma/cli-engine': - specifier: workspace:0.5.0 + specifier: workspace:0.6.0 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.21.0 diff --git a/turbo.json b/turbo.json index b8a5e10e..154efb3b 100644 --- a/turbo.json +++ b/turbo.json @@ -49,8 +49,10 @@ "conformance": { // Packs and installs the real tarballs, so never cached; the // package's own build is a dependency because the entry reads - // dist/ before packing rebuilds it. - "dependsOn": ["build"], + // dist/ before packing rebuilds it, and so is the prisma + // package's, whose dist/ the import-purity sweep reads too but + // which nothing here depends on. + "dependsOn": ["build", "prisma#build"], "cache": false, // turbo passes only declared variables to a task, and the // dev-build check answers differently per channel. Undeclared, it From 37b11fdff45d5fbd0dafeba4bec0efe594bec781 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 22 Sep 2026 09:14:07 +0200 Subject: [PATCH 3/4] fix(engine): path defaults are thunks, baseDir is reserved and typed, validation runs the schema once Review follow-ups on the schema-declared sections: - arktype morphs a literal default when the schema is defined, but a thunk default when it is applied. A relative path default is therefore declared as ["path", "=", () => "./migrations"] and resolves against the nearest file like an authored value; a relative literal default is refused at definition with that guidance. The second schema pass that worked around literal defaults is gone, so no other morph runs twice. - baseDir is part of ConfigSchemaValue and reserved: a section that writes it is refused with a diagnostic naming the field. - Only objects with a plain prototype are copied before validation and extended with baseDir, so a Date, Map or class instance a schema accepts keeps its identity and data. - A validation started from inside another restores the outer context. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- ...005-config-sections-declare-their-shape.md | 6 +- packages/cli-engine/src/config-schema.ts | 90 +++++++++++++----- .../cli-engine/tests/config-schema.test.ts | 92 ++++++++++++++++++- 3 files changed, 158 insertions(+), 30 deletions(-) diff --git a/docs/architecture/adrs/0005-config-sections-declare-their-shape.md b/docs/architecture/adrs/0005-config-sections-declare-their-shape.md index 86a4e18c..18693d59 100644 --- a/docs/architecture/adrs/0005-config-sections-declare-their-shape.md +++ b/docs/architecture/adrs/0005-config-sections-declare-their-shape.md @@ -18,7 +18,7 @@ export const ormConfigSection = defineConfigSection({ source: { "inputs?": "path[]", load: "Function" }, "output?": "path", }, - "migrations?": { "dir?": "path = './migrations'" }, + "migrations?": { dir: ["path", "=", () => "./migrations"] }, }), }); ``` @@ -49,9 +49,9 @@ Several designs were tried before this one, and each put the knowledge in the wr ## How it works - `configSchema` is arktype's `type` in a scope with one extra keyword, `path`: a string that validation resolves against the directory of the file that declared the value's top-level key, using the section's provenance. An absolute value passes through unchanged. Every other arktype feature (optional keys, defaults, unions, narrows for cross-field rules) is available as is. -- `defineConfigSection({ name, schema })` derives the section's validator. The engine runs it on the merged section value with its provenance, after discovery and merging, so defaults declared in the schema apply once to the merged value and never let one file's default shadow another file's authored value. A `path` default resolves against the nearest file. +- `defineConfigSection({ name, schema })` derives the section's validator. The engine runs it on the merged section value with its provenance, after discovery and merging, so defaults declared in the schema apply once to the merged value and never let one file's default shadow another file's authored value. A relative `path` default is declared as a thunk, `["path", "=", () => "./migrations"]`, which arktype evaluates and morphs when the default is applied, so it resolves against the nearest file like an authored value; a relative literal default would be stored unresolved, and is refused when the schema is defined. - Each arktype error becomes a `CLI.CONFIG_FIELD_INVALID` diagnostic carrying `meta.section`, `meta.field`, and `where.path`, the file that declared the field's top-level key, so a chain of files still tells the user which one to fix. -- The validated value of a plain-object section carries `baseDir`, the directory of the nearest file declaring the section, for commands that need the project's location rather than one of its files. +- The validated value of a plain-object section carries `baseDir`, the directory of the nearest file declaring the section, for commands that need the project's location rather than one of its files. The key is reserved: a config file that writes it is refused. - An absent section is validated as an empty object: a schema whose fields are all optional accepts it, and a required field is reported by name. - `defineConfigSection({ name, validate })` remains for a section a schema cannot express; such a validator resolves its own path fields through `resolveSectionPath`. diff --git a/packages/cli-engine/src/config-schema.ts b/packages/cli-engine/src/config-schema.ts index f9472752..78d04115 100644 --- a/packages/cli-engine/src/config-schema.ts +++ b/packages/cli-engine/src/config-schema.ts @@ -26,15 +26,23 @@ function declaringFile( } /** - * Outside a section validation there is no file to resolve against, and - * the value is returned as written. arktype runs a field's morph when a - * default is declared, at schema definition time, so this branch is what - * a `path` default takes then; the default is resolved when it is applied. + * A `path` value reaches this morph in two situations. During a section + * validation `current` names the provenance and the value resolves + * against the file that declared its top-level key. Outside one, arktype + * is evaluating a literal default while the schema is defined; a relative + * literal would be stored already resolved against nothing, so it is + * refused with the thunk form, which arktype evaluates and morphs at + * application time instead. */ function resolvePathValue(value: string, path: readonly PropertyKey[]): string { - if (isAbsolute(value) || current === undefined) { + if (isAbsolute(value)) { return value; } + if (current === undefined) { + throw new Error( + `@prisma/cli-engine: a relative 'path' default must be a thunk so it resolves against the config file when applied: ["path", "=", () => ${JSON.stringify(value)}]`, + ); + } const file = declaringFile(current.provenance, path); return file === undefined ? value : resolve(dirname(file), value); } @@ -59,19 +67,36 @@ const configScope = scope({ * const toySchema = configSchema({ * "out?": "path", * "inputs?": "path[]", + * dir: ["path", "=", () => "./migrations"], * greeting: "string = 'hello'", * }); * ``` + * + * A relative `path` default is declared as a thunk, as above: arktype + * evaluates a thunk when the default is applied, so it resolves against + * the config file like an authored value. A relative literal default is + * refused when the schema is defined. */ export const configSchema: typeof configScope.type = configScope.type; export type ConfigSchema = Type; -/** The validated value a schema produces: its output type, plus `baseDir` on a plain object. */ -export type ConfigSchemaValue = S["infer"]; +/** + * The validated value a schema produces: its output type plus `baseDir`, + * the directory of the nearest file declaring the section, which the engine + * adds to a plain-object value. `baseDir` is reserved: a schema may not + * declare it and a config file may not write it. + */ +export type ConfigSchemaValue = S["infer"] & { + readonly baseDir?: string; +}; function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; } /** Plain objects and arrays copied; anything else, functions included, by reference. */ @@ -127,24 +152,18 @@ export function validateSectionWithSchema( raw: unknown, provenance: SectionProvenance, ): SectionValidation> { + if (isPlainObject(raw) && Object.hasOwn(raw, "baseDir")) { + return { + ok: false, + diagnostics: [reservedKeyDiagnostic(name, "baseDir", provenance)], + }; + } + const previous = current; current = { name, provenance }; try { // arktype applies defaults and morphs onto the objects it is handed, and // the merged section value arrives frozen, so it validates a copy. - const input: unknown = raw === undefined ? {} : copyPlainData(raw); - const validated: unknown = schema(input); - if (validated instanceof type.errors) { - return { - ok: false, - diagnostics: [...validated].map((error) => - fieldDiagnostic(name, error, provenance), - ), - }; - } - // A `path` default is stored as written when the schema is defined and - // inserted verbatim, so a second pass over the validated value resolves - // it; every path already resolved passes through unchanged. - const out: unknown = schema(validated); + const out: unknown = schema(raw === undefined ? {} : copyPlainData(raw)); if (out instanceof type.errors) { return { ok: false, @@ -160,17 +179,38 @@ export function validateSectionWithSchema( : out; return { ok: true, value: value as ConfigSchemaValue, diagnostics: [] }; } catch (cause) { - // A nested value the file froze, or a getter that throws when arktype - // reads it: config-file content, reported as such rather than as a bug. + // A getter that throws when arktype reads it, or a morph that throws: + // config-file content, reported as such rather than as a bug. return { ok: false, diagnostics: [unreadableDiagnostic(name, cause, provenance)], }; } finally { - current = undefined; + current = previous; } } +function reservedKeyDiagnostic( + name: string, + key: string, + provenance: SectionProvenance, +): Diagnostic { + const file = provenance.keys[key] ?? provenance.files[0]; + return { + code: "CLI.CONFIG_FIELD_INVALID", + severity: "error", + summary: `In the '${name}' section, ${key} is reserved: the CLI records it when the section is loaded`, + nextActions: [ + { + kind: "edit-file", + label: `Remove ${name}.${key} from ${file ?? "prisma.config.ts"}`, + }, + ], + ...(file === undefined ? {} : { where: { path: file } }), + meta: { section: name, field: key }, + }; +} + function unreadableDiagnostic( name: string, cause: unknown, diff --git a/packages/cli-engine/tests/config-schema.test.ts b/packages/cli-engine/tests/config-schema.test.ts index 99a26e36..9e15dd18 100644 --- a/packages/cli-engine/tests/config-schema.test.ts +++ b/packages/cli-engine/tests/config-schema.test.ts @@ -16,6 +16,7 @@ import { } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { createTestCli } from "@prisma/cli-engine/testing"; +import { type } from "arktype"; import { describe, expect, test } from "vitest"; const FIXTURES = join( @@ -93,8 +94,8 @@ describe("validateSectionWithSchema", () => { }); }); - test("a defaulted key no file wrote is attributed to the nearest file", () => { - const schema = configSchema({ dir: "path = './migrations'" }); + test("a thunk path default resolves against the nearest file when it is applied", () => { + const schema = configSchema({ dir: ["path", "=", () => "./migrations"] }); const provenance: SectionProvenance = { files: ["/child/prisma.config.ts", "/parent/prisma.config.ts"], keys: {}, @@ -107,6 +108,93 @@ describe("validateSectionWithSchema", () => { }); }); + test("a relative literal path default is refused when the schema is defined", () => { + expect(() => configSchema({ dir: "path = './migrations'" })).toThrow( + 'must be a thunk so it resolves against the config file when applied: ["path", "=", () => "./migrations"]', + ); + expect(() => + configSchema({ dir: "path = '/abs/migrations'" }), + ).not.toThrow(); + }); + + test("a morph other than path runs once", () => { + let runs = 0; + const schema = configSchema({ + dir: "path", + counted: type("string").pipe((value) => { + runs += 1; + return value.toUpperCase(); + }), + }); + + const result = validateSectionWithSchema( + "toy", + schema, + { dir: "./d", counted: "x" }, + single, + ); + + expect(result.ok && result.value).toMatchObject({ + dir: "/app/d", + counted: "X", + }); + expect(runs).toBe(1); + }); + + test("baseDir is reserved: a section that writes it is refused", () => { + const result = validateSectionWithSchema( + "toy", + toySchema, + { baseDir: "/elsewhere" }, + single, + ); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toMatchObject([ + { + code: "CLI.CONFIG_FIELD_INVALID", + meta: { section: "toy", field: "baseDir" }, + }, + ]); + }); + + test("baseDir is part of the validated value's type", () => { + const result = validateSectionWithSchema("toy", toySchema, {}, single); + + if (!result.ok) throw new Error("expected ok"); + const dir: string | undefined = result.value.baseDir; + expect(dir).toBe("/app"); + }); + + test("a value that is not a plain object keeps its identity and data", () => { + const schema = configSchema({ when: "Date" }); + const when = new Date(0); + + const result = validateSectionWithSchema("toy", schema, { when }, single); + + expect(result.ok && result.value.when).toBe(when); + }); + + test("a validation started by a morph inside another does not lose the outer context", () => { + const inner = configSchema({ dir: "path" }); + const outer = configSchema({ + first: type("string").pipe((value) => { + validateSectionWithSchema("other", inner, { dir: "./inner" }, single); + return value; + }), + dir: "path", + }); + + const result = validateSectionWithSchema( + "toy", + outer, + { first: "x", dir: "./d" }, + single, + ); + + expect(result.ok && result.value).toMatchObject({ dir: "/app/d" }); + }); + test("an absent section validates as the empty section", () => { const result = validateSectionWithSchema("toy", toySchema, undefined, { files: [], From 97628888d29b6829ffa217b44d914d6e6d3cd105 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 22 Sep 2026 09:33:47 +0200 Subject: [PATCH 4/4] fix(engine): name the offending default without composing code from it CodeQL read the JSON.stringify of a config value inside a code-shaped error message as code construction from unsanitised input. The message now quotes the value plainly and shows the thunk form with a placeholder. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/config-schema.ts | 2 +- packages/cli-engine/tests/config-schema.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli-engine/src/config-schema.ts b/packages/cli-engine/src/config-schema.ts index 78d04115..fa20bbe8 100644 --- a/packages/cli-engine/src/config-schema.ts +++ b/packages/cli-engine/src/config-schema.ts @@ -40,7 +40,7 @@ function resolvePathValue(value: string, path: readonly PropertyKey[]): string { } if (current === undefined) { throw new Error( - `@prisma/cli-engine: a relative 'path' default must be a thunk so it resolves against the config file when applied: ["path", "=", () => ${JSON.stringify(value)}]`, + `@prisma/cli-engine: the relative 'path' default '${value}' must be declared as a thunk, ["path", "=", () => "..."], so it resolves against the config file when the default is applied`, ); } const file = declaringFile(current.provenance, path); diff --git a/packages/cli-engine/tests/config-schema.test.ts b/packages/cli-engine/tests/config-schema.test.ts index 9e15dd18..01aaf9e8 100644 --- a/packages/cli-engine/tests/config-schema.test.ts +++ b/packages/cli-engine/tests/config-schema.test.ts @@ -110,7 +110,7 @@ describe("validateSectionWithSchema", () => { test("a relative literal path default is refused when the schema is defined", () => { expect(() => configSchema({ dir: "path = './migrations'" })).toThrow( - 'must be a thunk so it resolves against the config file when applied: ["path", "=", () => "./migrations"]', + "the relative 'path' default './migrations' must be declared as a thunk", ); expect(() => configSchema({ dir: "path = '/abs/migrations'" }),