From 20ee1e45b20b8460b804460f02a12659ab8e6264 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 10:13:17 +0200 Subject: [PATCH 01/19] Record the config-file resolution slice: decided spec, dispatch plan, ledger The design discussion in specs/config-file-resolution.md becomes the decided slice contract (operator rulings 2026-08-25): per-key merging with section-owned merge semantics, automatic ancestor discovery with a reserved parent: false | "path" key superseding root: true, the repository-boundary search stop, post-merge validation with per-file provenance, declaring-file-relative paths, --config anchoring the chain at the named file, and no shadowing notices. The condensed decision history and the prior round's edge-case catalogue stay in the spec as requirements. plans/config-file-resolution.md decomposes it into five dispatches (chain discovery; per-key merge with provenance; resolver consolidation; subdirectory init; docs and verification) with the engine-version hazard called out at the plan level. The ledger closes the stale pathe entry (the realpath fix shipped with engine 0.2.2) and records the two rulings that close or supersede standing observations: the readProjectSkillsConfig consolidation and subdirectory init skipping skills, postinstall, and the devDependency. Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 9 +- .../plans/config-file-resolution.md | 46 ++++++++ .../specs/config-file-resolution.md | 105 ++++++++++-------- 3 files changed, 111 insertions(+), 49 deletions(-) create mode 100644 .drive/projects/prisma-cli-v8/plans/config-file-resolution.md diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index fcbeec03..66a486e6 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -485,7 +485,7 @@ The cleanup PR removed the compute config and `init`, made service commands para ## From the agent-skills delivery (project closed 2026-08-22) -- **Config evaluation fails through unrealpath'd pnpm symlinks; the verified fix waits for the next engine release.** The engine's lazy `await import("c12")` (packages/cli-engine/src/config-loader.ts) resolves through a pnpm symlink without realpathing, so c12's own `pathe` import walks up from the symlink path and misses its store siblings — `CLI.CONFIG_UNREADABLE: Cannot find package 'pathe'`. Affects this repo's dev layout and pnpm `hoist=false` installs only; npm and default-pnpm installs work. The fix is one line, verified empirically: import c12 via `pathToFileURL(realpathSync(fileURLToPath(import.meta.resolve("c12"))))`. It was implemented on PR #219 and reverted because any engine change forces a version bump and the families (`@prisma/orm-toolchain`, `@prisma/composer-cli`) peer-pin the engine exactly, requiring a coordinated family release. Ship it with the next engine version train; the init e2e's rerun workaround (removing the config before the second binary run) comes out at the same time. +- ~~**Config evaluation fails through unrealpath'd pnpm symlinks.**~~ Closed (2026-08-25): the one-line realpath fix shipped — `packages/cli-engine/src/config-loader.ts` imports c12 via `realpathSync(import.meta.resolve("c12"))` on main, released with engine 0.2.2 (#224). The init e2e's rerun workaround (`e2e/init.e2e.ts:189-200`) is still in place; it comes out with the config-file-resolution slice, which tracks it in its plan. The agent-skills project (skills sync/list, `prisma init`, the staleness notice; PR #219) closed with these items still open; details were in its own ledger, summarized here as the surviving record. @@ -504,3 +504,10 @@ The agent-skills project (skills sync/list, `prisma init`, the staleness notice; - **`prisma@8.0.0-rc.8` on npm is broken and immutable.** The `prisma` wrapper package carries its own copies of the product pins, and the grammar-cleanup branch bumped only `packages/cli/package.json` — so the published `prisma` bin resolved `@prisma/orm-toolchain@8.0.0-rc.4`, whose old family keys make the mount table's lookups undefined and every invocation crash ("Cannot read properties of undefined (reading 'needs')"). rc.9 fixes it. Consider `npm deprecate prisma@8.0.0-rc.8` (needs a maintainer's npm auth; CI publishes via OIDC and has no deprecate step). - ~~**The release checks did not catch a `prisma` bin that crashes on install.**~~ Closed (2026-08-24, on the rc.9 PR): worse than hoisting — check 3b never installed or started the wrapper's bin at all, only the shell's. Three guards now exist: `packages/cli/tests/manifest-pins.test.ts` (every PR: the wrapper's dependencies must deep-equal the shell's), the tarball check's new `sibling-pin-mismatch` finding (pack time: shared dependency names across packed manifests must carry identical specifiers), and per-package sandboxes in check 3b (every bin-bearing package installs and starts from its own tree). Each guard was proven against the planted rc.8 defect. - **Two manifests hand-carry the same pins.** `update-product-versions.mjs` rewrites both, and three checks now fail on divergence (see the closed entry above), so the class cannot ship again. Deriving one manifest from the other at pack time would remove the duplication itself — still a design call, no longer urgent. + +## Config-file resolution rulings (2026-08-25) + +The design in `specs/config-file-resolution.md` is decided (per-key merge with section-owned semantics, automatic ancestor discovery with a reserved `parent: false | "path"` key, repo-boundary stop, post-merge validation with provenance, declaring-file-relative paths, `--config` anchoring the chain at the named file, no shadowing notices); the dispatch plan is `plans/config-file-resolution.md`. Two rulings recorded here because they close or supersede standing observations: + +- **`readProjectSkillsConfig`'s hand-rolled resolution must consolidate into the engine resolver** (ruled 2026-08-25) — lands as D3 of the slice; until then the staleness notice and the commands can disagree about which config governs when run from a subdirectory. +- **Subdirectory `prisma init` skips the skills sync, postinstall script, and devDependency by default** (ruled 2026-08-25) — those steps belong to the repository root; lands as D4 of the slice, which needs D1's ancestor discovery to detect "subdirectory" at all. diff --git a/.drive/projects/prisma-cli-v8/plans/config-file-resolution.md b/.drive/projects/prisma-cli-v8/plans/config-file-resolution.md new file mode 100644 index 00000000..85ce22e9 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/plans/config-file-resolution.md @@ -0,0 +1,46 @@ +# Config-file resolution — dispatch plan + +Slice contract: `specs/config-file-resolution.md`. One PR into `main`. Sequential dispatches; each hands the next a state where `pnpm typecheck` and `pnpm exec turbo run test --concurrency=1` are green (the sequential run — the parallel `pnpm test` has a known engine-dist race). Engine-surface hazard applies to every dispatch: verify `@prisma/cli-engine`'s committed version is still unpublished before merging the PR; if a train shipped it, bump the engine version first. + +## D1 — The loader discovers a chain + +**Outcome:** `loadConfig` resolves an ordered chain of config files instead of one: anchor directory (cwd, or the `--config` file's directory) upward to the first `.git` directory; `parent: false` ends collection, `parent: "path"` names the next link explicitly (cycle-checked, may cross the boundary), no `.git` above means anchor-only. `parent` joins the reserved top-level keys (loader strips it like the marker; `reservedConfigSectionName` covers it; construction-time rejection includes it). Every file on the chain is evaluated with the existing marker/version/unreadable classification, each failure naming its file. `LoadedConfig` becomes a chain shape — per-file `{path, sections}` in nearest-first order plus file-level diagnostics — and every existing consumer (`needs.ts`, hosts, engine tests, the skills reader temporarily via a nearest-file adapter) compiles and passes against it with single-file behavior unchanged: one file in cwd behaves exactly as today. + +**Builds on:** clean main. **Hands to D2:** the chain type, discovery green under new engine tests (boundary stop, `parent` forms, cycle guard, anchored fixtures per the spec's test-anchoring requirement), all suites green. + +**Focus:** the loader doc comment says "cwd only, no walking up" — it and the `EVALUATE_ONE_FILE_ONLY` rationale need rewriting to the new truth. Symlink-resolve the anchor. Windows realpath on every chain comparison. The unknown-key check in `needs.ts` iterates the chain per file from this dispatch on. + +## D2 — Sections merge per key with provenance + +**Outcome:** `ConfigSection` gains optional `merge(parent, child)`; the engine default merges per key at the section's top level and replaces below. `checkConfiguration` folds each needed section over the chain nearest-first, validates the merged view (validators unchanged), and hands `ctx.config` the resolved value. Every resolved value carries provenance; post-merge validation diagnostics name the contributing file; relative-path resolution against the declaring file is provided as an engine helper the provenance makes possible (sections opt in by resolving paths through it). Merging never mutates the frozen exports. + +**Builds on:** D1's chain. **Hands to D3:** engine tests covering shadowing, fall-through (nested file lacking a section the root has), partial merge (root `skills.check` + package `skills.agents`), provenance in error copy, and the ORM absence-error firing only on a chain with no `orm` section anywhere; all suites green. + +**Focus:** `merge` must be optional and type-backward-compatible — the shipped orm/composer dists implement `ConfigSection` against the current engine and must keep working with the default. `__proto__`/`fromEntries` discipline extends to the merged object. + +## D3 — One resolver in the product + +**Outcome:** `readProjectSkillsConfig` and the out-of-handler reads (skills staleness notice, post-login tip) resolve through the engine's chain resolver; the hand-rolled `existsSync` + direct `loadConfig` path and D1's temporary adapter are deleted. From any subdirectory, the staleness notice and the skills commands agree on the governing config. The skills section keeps its null-collapsing contract for out-of-handler callers. + +**Builds on:** D2's resolver. **Hands to D4:** exactly one resolution code path, skills unit tests green from nested-directory fixtures. + +**Focus:** the "one stat before paying for transpile" property `readProjectSkillsConfig` had should survive — chain discovery is stat-only until a file exists; keep the no-config fast path. + +## D4 — Subdirectory init scaffolds only + +**Outcome:** `prisma init` run in a directory whose discovered chain contains an ancestor config skips the skills sync, the `postinstall` script, and the `prisma` devDependency by default, reporting each as skipped-with-reason; explicit flags still opt in; root init (no ancestor config) is byte-for-byte unchanged. Unit tests cover both shapes; the init e2e gains the subdirectory case. If D1's chain work removed the cause of the e2e rerun workaround (`e2e/init.e2e.ts:189-200`), the workaround comes out; otherwise its comment is updated to name what still forces it. + +**Builds on:** D3 (init detects the ancestor through the same resolver as everything else). **Hands to D5:** init behavior finished, suites and init e2e green. + +## D5 — Docs, records, full verification + +**Outcome:** user-facing docs describe discovery, merging, `parent`, and the two-config layout (the config documentation surface plus `docs/product/*` touchpoints that mention config today); the ledger closes the stale pathe entry (the loader realpath fix shipped in engine 0.2.2) and records this slice's rulings; the spec's status line gains the landed date. Full verification per AGENTS.md, including conformance (`pnpm exec turbo run conformance --filter @prisma/cli --force`) and the sequential test run; the engine-version-unpublished check from the plan header re-verified at PR-open. + +**Builds on:** D4. **Hands to:** slice-DoD; PR-open. + +## Hazard inventory (2026-08-25) + +- Engine surface changes ride the unpublished engine version or force the three-repo re-peer chain — check at start AND at merge; the version can publish out from under a long-running slice. +- This repository contains fixture `prisma.config.ts` files and will gain more; every loader/resolver test must pin its chain (temp dirs outside the repo, or explicit `parent: false` fixtures) or a real ancestor config leaks in — the exact failure the prior round's review caught. +- `tests/e2e-coverage.test.ts` parses `src/cli.ts` as text; D4 does not touch the mount table, but any drive-by edit there must keep the `mountedCommands` literal's shape. +- The parallel `pnpm test` race (engine dist rebuild vs cli tests) predates this slice; verify with the sequential run and do not chase it here. diff --git a/.drive/projects/prisma-cli-v8/specs/config-file-resolution.md b/.drive/projects/prisma-cli-v8/specs/config-file-resolution.md index b542a663..e6eaf2f0 100644 --- a/.drive/projects/prisma-cli-v8/specs/config-file-resolution.md +++ b/.drive/projects/prisma-cli-v8/specs/config-file-resolution.md @@ -1,75 +1,84 @@ -# Finding and resolving prisma.config.ts when a repository has more than one +# Config-file resolution: ancestor discovery and per-key merging -Status: design discussion. Nothing below is decided except where explicitly marked. This document exists so the discussion can start from the constraints and the options already explored, rather than rediscovering them. +Status: **decided** (operator rulings 2026-08-25). This file was the design discussion; it is now the slice contract. The discussion history survives condensed under "Design decisions"; the appendix's edge-case catalogue is carried forward as implementation requirements. -## Background: how the config file works in the Prisma 8 CLI +## At a glance -`prisma.config.ts` is the Prisma 8 CLI's configuration file. It is real TypeScript that the CLI evaluates, and its default export must be wrapped in `definePrismaConfig(...)`, which stamps a version marker on the object. A `prisma.config.ts` without the marker — for example one written for Prisma 7, which uses the same filename — is rejected with a clear error rather than half-interpreted. +`prisma.config.ts` resolution grows from "one file in cwd" to "a chain of files discovered upward", merged per key with the most-local value winning — the ESLint/webpack-merge model users already know. The primary layout served: one config at the repository root (deploy target, platform settings), one in the `db` package (ORM settings) — already the mainstream monorepo pattern and part of Composer best practices. Discovery is automatic; an explicit `parent` key overrides it. The hand-rolled skills-config reader consolidates into the same resolver, and `prisma init` in a subdirectory scaffolds config only. -Every top-level key in the config object is a **section**, and each section belongs to one part of the CLI: `skills` configures the agent-skills feature today, and the ORM will have its own section. The set of section names is closed — a key the CLI does not recognise is an error, on the theory that silently ignoring settings a user wrote is worse than failing. +## Decided behavior -Today the CLI reads **exactly one file**: the one named with `--config`, otherwise `prisma.config.ts` in the directory the command runs in. It never looks anywhere else. Commands that have no config settings never read or evaluate the file at all. +### Discovery -## The problem +- From the anchor directory, search upward collecting every `prisma.config.ts` on the path. The anchor is cwd, or with `--config ` the named file's own directory (the named file is the nearest layer; its ancestors and `parent` declarations apply as usual — never cwd's lineage). +- The search stops at the repository boundary: the first directory containing `.git`. No `.git` found anywhere above → the anchor directory only. Filesystem root and home directory are never reached implicitly. Rationale: every file on the chain is *executed* TypeScript; nothing outside the repository runs without explicit consent. +- A file may declare `parent: false` — "I am the root", collection ends here (supersedes the earlier `root: true` design; one field, not two) — or `parent: "path"`, naming its parent explicitly. An explicit `parent` path may cross the repository boundary (git submodules); crossing is the consent. Absent `parent` means automatic discovery continues. A `parent` chain must be cycle-checked. +- `parent` is an engine-reserved top-level key like the `$prismaConfig` marker: read by the loader, never a section, rejected as a section name at tree construction (`reservedConfigSectionName` + `rejectReservedSectionName`). +- Automatic discovery cannot be replaced by `parent`: the common case is a subdirectory with **no config file at all**, which has nowhere to declare a parent. `parent` only overrides what discovery would have chosen. -Two facts collide. +### Merging -**First: commands run from subdirectories.** If the config sits at the repository root and you run a command from `apps/api/`, a current-directory-only lookup finds nothing. The CLI needs to search upward. But the moment it searches upward, a repository can have several `prisma.config.ts` files on the path between the current directory and the root — and the CLI needs a rule for which one answers. +- Sections resolve **per key, most-local value winning**, over the discovered chain. A nested file shadows only the keys it actually writes; a root-level `skills: { check: false }` reaches every subdirectory that does not override it. +- Merge semantics are owned by the section type: `ConfigSection` gains an optional `merge(parent, child)` and the owner decides array/atomic behavior. The engine default, for sections that do not customize: per-key merge at the section's top level, replace below. +- No shadowing notices. Overriding parent config is the mechanism working as intended; introspection belongs in verbose output, not per-run warnings. +- `definePrismaConfig` freezes its result; merging constructs fresh objects and never mutates a file's export. -**Second: different commands need different files.** Take this repository, which reflects the most common real-world layout: +### Validation and provenance -``` -acme/ - prisma.config.ts ← deploy target, platform settings - packages/ - db/ - prisma.config.ts ← ORM settings (schema location, migrations) - api/ -``` +- Per-file checks: evaluation, marker, version, and the unknown-top-level-key check run for **every** file on the chain — a typo'd key in a nested file errors even when the command's sections resolved elsewhere. A broken file anywhere on the chain fails the command with an error naming that file; no skipping. +- Required-key/section validation runs **after** the merge, on the resolved view — a child file may hold a valid partial that the parent completes. Validators keep their current contract (own absence, never throw); the ORM section's "required" absence-error now fires only when no file on the chain supplies the section, which is the intended semantics. +- Every resolved value carries provenance (which file contributed it), so post-merge validation errors and diagnostics name the file to fix, and: +- **Relative paths resolve against the file that declared them**, never against cwd or the nearest config. A root config's `orm: { migrations: "./migrations" }` means the root's `migrations` directory from anywhere in the repo. -The ORM's settings live in the package that owns the database code — that is the mainstream pattern, not an edge case. But `prisma deploy`, `prisma project link`, and everything Composer-related is scoped to the repository as a whole; those settings live at the root. So when you run an ORM command from `packages/db`, the right file is `packages/db/prisma.config.ts` — and when you run `prisma deploy` from that same directory, the right file is the root one. +### Consolidation -No rule that picks **one file for everything** can satisfy both. That is the core finding of the design work so far, and the two rejected options below show each half of it failing. +- `readProjectSkillsConfig` and every other out-of-handler config read (the skills staleness notice, the post-login tip) goes through the engine resolver. The hand-rolled `existsSync(cwd/prisma.config.ts)` + direct `loadConfig` path is deleted; there is exactly one resolution behavior in the product. -## Rejected: "the highest file wins" +### `prisma init` in a subdirectory -Rule: search upward from the current directory; the file closest to the filesystem root wins, unless a file on the way declares `root: true`, which stops the search there (like ESLint's old `root: true`). +- `init` acts on cwd with no special-casing of the scaffold itself (init at root makes the root config; init in `packages/db` makes the package config; discovery wires them together). +- When init runs in a directory that has an ancestor config on the discovered chain (a subdirectory init), the skills sync, the `postinstall` script, and the `prisma` devDependency additions are **skipped by default** — those belong to the repository root. Explicit flags may still opt in. Root init behavior is unchanged. -In the example repo, every command run inside `packages/db` — including ORM commands — reads the **root** config. The ORM settings in `packages/db/prisma.config.ts` are ignored completely. The only escape is declaring `root: true` in the package's file, which then hides the root config from that package entirely — so `deploy` breaks from inside the package instead. One file, all or nothing, in either direction. This breaks the mainstream ORM layout, so it was rejected. (It was briefly implemented; see the appendix.) +## Design decisions (condensed history) -## Rejected: "the nearest file wins" +- **"Highest file wins"** (with `root: true`): rejected — ignores a `db` package's ORM config entirely; briefly implemented in the engine (commits `ba48d46`, `f72503c`, removed by `9b2f9d0`), recoverable from history as reference. +- **"Nearest file wins"**: rejected — root-scoped commands (`deploy`, `project link`) break from inside packages. +- **Per-scope declarations** (sections declare root- vs nearest-scoped): rejected as redundant — where a section is written already encodes it. +- **Atomic per-section resolution** (nearest definition of a section wins whole): superseded 2026-08-25 by per-key merging — with merge semantics delegated to typed section owners, merging does what users of ESLint/tsconfig already expect, and partial overrides (`skills.check` at the root, `skills.agents` in a package) work. +- **`export default merge(parentConfig, {...})`** as the layering mechanism: rejected — moves resolution into user code, defeating loader-controlled ordering, caching, error attribution, per-file validation, and the boundary rule. Fine as userland sugar; not the contract. +- **`--config` reads only the named file**: rejected in favor of the named file anchoring the normal chain. -Rule: search upward; the first file found wins. +## Implementation requirements (carried from the prior round's review) -Now the ORM case works: inside `packages/db`, the package's config answers. But run `prisma deploy` from `packages/db` and the CLI reads the package's config too — which has no deploy settings, and the root config that has them is never consulted. Root-scoped commands only work from the repository root. Rejected. +- Resolve the search's starting directory through symlinks so errors name real paths. +- Keep loader tests anchored so a stray `prisma.config.ts` in a real ancestor of the checkout cannot leak into them — this repository itself will contain fixture configs; the test harness must pin the chain. +- Enforce reserved-key handling on the engine side of the pluggable-loader boundary, not only inside the default loader. +- Name the offending value (and now its file) in validation errors. +- Windows: realpath both sides of any path comparison (the loaded-file identity check already does; the chain comparisons must too). -A third option — letting each part of the CLI declare "I am root-scoped" or "I am nearest-scoped" and searching accordingly — was rejected as redundant: where a section is *written* already encodes that, without inventing a declaration mechanism that every feature has to get right. +## Scope -## Proposed (not decided): resolve per section, nearest definition wins +**In:** engine loader (chain discovery, `parent`, boundary stop, per-file checks), `LoadedConfig` shape change and everything downstream of it (`needs.ts`, hosts, tests), `ConfigSection.merge` and the default merge, provenance, declaring-file-relative path contract, skills-reader consolidation, init subdirectory behavior, user docs, ledger updates. -Rule: search upward from the current directory and collect **every** `prisma.config.ts` on the path; a file declaring `root: true` ends the collection. Then resolve each **section** independently: a section comes from the nearest file that defines it. Sections are atomic — the nearest definition wins whole; there is no merging of a section across files. +**Deliberately out:** ORM and composer `merge()` customizations (upstream packages; the engine default covers them), any change to their validators, the topology of which sections exist, performance work beyond bounded-depth evaluation (cache within a run only if free), shadowing introspection UX. -In the example repo, from inside `packages/db`: +## Hazards -- ORM command → the `orm` section is defined in `packages/db/prisma.config.ts` → the package's settings apply. ✓ -- `prisma deploy` → the nested file has no deploy/Composer section → the search continues upward and finds it in the root file. ✓ -- `skills: { check: false }` written at the root reaches `packages/db` too, because the nested file only shadows the sections it actually defines. ✓ +- The engine's exact-peer discipline: this slice changes `@prisma/cli-engine`'s public surface (`LoadedConfig`, `ConfigSection`), so it must ride an **unpublished** engine version (0.2.3 at the time of writing) or trigger the three-repo family re-peer chain. Verify the version is unpublished at merge time; if a release train has shipped it, bump first. +- `ConfigSection` is implemented by the shipped orm-toolchain and composer dists against the current engine; adding `merge` must be optional and backward-compatible at the type level, or it forces the family chain regardless. +- c12/`extends` stays off; `parent` is ours, not c12's merge directive. `omit$Keys` stays off or the marker dies. +- The frozen exports: any in-place mutation during merge throws in strict mode. -The costs, stated plainly: +## Slice-specific done conditions -1. A command may evaluate more than one file — every config on the path up to the stopping point. Config files are executable TypeScript, so that is real user code running and a transpile per file. It is bounded by directory depth and cacheable within a run, and only commands that actually consume config trigger any of it. -2. A broken file anywhere on the path — Prisma 7 format, syntax error — fails the command with an error naming that file. The proposal is to fail early rather than skip broken files, on the theory that a half-read path is worse than an error that says exactly which file to fix. -3. The loader's result stops being "one file's contents" and becomes a resolved view over several files, and every diagnostic must say which file it is about. That is genuine engineering work in the CLI engine. +- The two-config monorepo layout works end to end: from `packages/db`, ORM commands read the package's `orm` section; `deploy`-scoped sections fall through to the root; a root `skills.check: false` reaches the package. +- `readProjectSkillsConfig`'s hand-rolled resolution is gone; the staleness notice and commands agree on which config governs from any directory. +- Subdirectory `prisma init` writes only the scaffold; root init unchanged; both covered by tests (unit + the existing init e2e extended). +- Ledger: the stale pathe entry is closed (the loader fix shipped in engine 0.2.2; the init e2e rerun workaround at `e2e/init.e2e.ts:189-200` comes out with this slice if the chain work removes its cause, else its entry is updated honestly). -## Open questions for this discussion +## References -- Is per-section nearest-first the right model, or is there a simpler rule that satisfies both the nested-ORM layout and root-scoped commands? -- Two files on the path define the same section: nearest silently wins, or wins with a printed notice? -- Where does the upward search stop when no file declares `root: true` — filesystem root, home directory, or a repository boundary such as the directory containing `.git`? (Under a one-file rule this mattered little; under collect-everything, every file on the path gets evaluated, so the stopping point deserves a fresh look.) -- What does `--config ` mean here: read only that file, or treat it as the nearest layer with the search continuing above it? -- Does the unknown-section check run per file, so a typo'd key in a nested file still errors even though the command's sections resolved elsewhere? -- Should `prisma init` scaffold a `prisma.config.ts` once this design lands, and does the `root: true` marker keep that name? - -## Appendix: prior implementation, kept as reference - -The "highest file wins" rule was implemented in the CLI engine and then removed when the discussion surfaced the nested-ORM problem (branch `claude/agent-skills-npm-packages-770857` in prisma/prisma-cli; commits `ba48d46` and `f72503c`, removed by `9b2f9d0` — recoverable from history). A code review of that implementation catalogued edge cases any future implementation should handle regardless of the chosen rule: resolve the search's starting directory through symlinks so errors name real paths; keep loader tests anchored so a stray config file in a real ancestor of the checkout cannot leak into them; enforce reserved-key handling on the engine side of the pluggable-loader boundary, not only inside the default loader; and name the offending value in validation errors. The full findings are in the same repository under `.drive/projects/agent-skills-npm-packages/reviews/code-review.md`, round "Init slice — Round 1". +- Engine: `packages/cli-engine/src/config-loader.ts`, `config-section.ts`, `runtime.ts` (`LoadedConfig`), `execution/needs.ts` (`checkConfiguration`), `execution/command-tree.ts` (reserved names), `execution/shared-flags.ts` (`--config`). +- Shell: `packages/cli/src/commands/skills/config.ts` (`readSkillsConfig`, `readProjectSkillsConfig`), `packages/cli/src/commands/init.ts` (`renderConfigScaffold`, the postinstall/devDependency steps), `packages/prisma/src/config.ts` (`prisma/config`). +- Section owners at current pins: skills (shell), `orm` (orm-toolchain rc.5+: absence is an error), `composer` (composer-cli 0.12.0+: absence is `{}`). +- Prior implementation for reference: commits `ba48d46`, `f72503c` (removed by `9b2f9d0`); review findings in `.drive/projects/agent-skills-npm-packages/reviews/code-review.md`, round "Init slice — Round 1". From 166450185e6464c777acde404afffc79dea92569 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 10:04:43 +0200 Subject: [PATCH 02/19] Remove the deprecated defineConfig alias before launch The engine's deprecated defineConfig re-export of definePrismaConfig was the only @deprecated surface in the repo. Nothing imports it: the prisma/config entry point re-exports definePrismaConfig by name, and the composer and ORM families' same-named helpers are their own. It rides the in-flight, unpublished engine 0.2.3, so no extra release chain. Doc prose that still said defineConfig now names definePrismaConfig. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/config-loader.ts | 6 +----- packages/cli-engine/src/exports/index.ts | 1 - packages/cli-engine/src/runtime.ts | 2 +- packages/cli-engine/tests/config.test.ts | 7 ------- packages/cli-engine/tests/engine.test.ts | 1 - 5 files changed, 2 insertions(+), 15 deletions(-) diff --git a/packages/cli-engine/src/config-loader.ts b/packages/cli-engine/src/config-loader.ts index 5942eaa2..6e44422a 100644 --- a/packages/cli-engine/src/config-loader.ts +++ b/packages/cli-engine/src/config-loader.ts @@ -1,7 +1,7 @@ /** * The prisma.config.ts loader behind Runtime.loadConfig: resolve the * file (the one `--config` named, otherwise prisma.config.ts in cwd — - * cwd only, no walking up), evaluate it, check the defineConfig version + * cwd only, no walking up), evaluate it, check the definePrismaConfig version * marker, and produce LoadedConfig. * * Which section names a CLI recognises is not this module's business: @@ -71,10 +71,6 @@ export function definePrismaConfig>( return Object.freeze({ ...config, $prismaConfig: PRISMA_CONFIG_VERSION }); } -/** @deprecated Renamed to {@link definePrismaConfig}: every family's - * config helper carries a unique name, so none needs an import alias. */ -export const defineConfig = definePrismaConfig; - function hasVersionMarker(value: unknown): value is Record { return ( typeof value === "object" && diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index 4e6e8375..23b3424c 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -45,7 +45,6 @@ export { type SpawnDeclarations, } from "../commands"; export { - defineConfig, definePrismaConfig, loadConfig, } from "../config-loader"; diff --git a/packages/cli-engine/src/runtime.ts b/packages/cli-engine/src/runtime.ts index fbc06ac6..dcf07b03 100644 --- a/packages/cli-engine/src/runtime.ts +++ b/packages/cli-engine/src/runtime.ts @@ -209,7 +209,7 @@ export interface LoadedConfig { } /** - * The config contract version defineConfig writes as the structural + * The config contract version definePrismaConfig writes as the structural * `$prismaConfig` marker; the loader checks it before interpreting * anything. */ diff --git a/packages/cli-engine/tests/config.test.ts b/packages/cli-engine/tests/config.test.ts index 65bfad5b..11bd2281 100644 --- a/packages/cli-engine/tests/config.test.ts +++ b/packages/cli-engine/tests/config.test.ts @@ -13,7 +13,6 @@ import { createCli, defineCommand, defineCommandFamily, - defineConfig, defineConfigSection, definePrismaConfig, flag, @@ -42,12 +41,6 @@ describe("definePrismaConfig", () => { $prismaConfig: PRISMA_CONFIG_VERSION, }); }); - - test("the deprecated defineConfig alias stamps the same marker", () => { - expect(defineConfig({ toy: { greeting: "hi" } })).toEqual( - definePrismaConfig({ toy: { greeting: "hi" } }), - ); - }); }); describe("loadConfig", { timeout: 60_000 }, () => { diff --git a/packages/cli-engine/tests/engine.test.ts b/packages/cli-engine/tests/engine.test.ts index 51856f28..b663fd60 100644 --- a/packages/cli-engine/tests/engine.test.ts +++ b/packages/cli-engine/tests/engine.test.ts @@ -29,7 +29,6 @@ describe("main export", () => { "credentialsRequiredError", "defineCommand", "defineCommandFamily", - "defineConfig", "defineConfigSection", "definePrismaConfig", "defineServerCommand", From 80110988a028f2e5d7a661a5118851ab03b8de93 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 12:03:06 +0200 Subject: [PATCH 03/19] Engine 0.3.0: this slice changes the engine's public surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config-file-resolution slice reshapes LoadedConfig and extends ConfigSection, and the folded-in defineConfig alias removal already dropped an export — breaking surface changes, so the engine takes a minor bump per ADR 0004. 0.2.3 published out from under the branch, so the bump is mandatory for the engine-version check. The conformance transition exceptions move to the new triple (families still peer 0.2.2 until they release against 0.3.0), per the #227 pattern. 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 | 32 ++++++++++++++++++++++------- packages/prisma/package.json | 2 +- pnpm-lock.yaml | 4 ++-- 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index ba0dd711..3b668dc4 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -1,6 +1,6 @@ { "name": "@prisma/cli-engine", - "version": "0.2.3", + "version": "0.3.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 35b7c702..d114e7fb 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.2.3", + "@prisma/cli-engine": "workspace:0.3.0", "@prisma/composer-cli": "0.14.0", "@prisma/compute-sdk": "0.39.0", "@prisma/management-api-sdk": "1.55.0", diff --git a/packages/cli/scripts/conformance.ts b/packages/cli/scripts/conformance.ts index 230308f5..397cadce 100644 --- a/packages/cli/scripts/conformance.ts +++ b/packages/cli/scripts/conformance.ts @@ -109,13 +109,31 @@ async function tarball(): Promise { shellPackage: "@prisma/cli", enginePackage: "@prisma/cli-engine", familyPackages: ["@prisma/composer-cli", "@prisma/orm-toolchain"], - // No exceptions. Both families declare @prisma/cli-engine as an - // exact peer at the version this repo ships, so one engine - // resolves in an install — what ADR 0004 asks for. An entry here - // exists only while an engine version transition is in flight - // (the engine must publish before a family can peer it), and the - // release PR that pins the families' new versions removes it. - exceptions: [], + // An entry here exists only while an engine version transition is + // 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.3.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.2.3", + shellPin: "0.3.0", + reason: "engine 0.3.0 must publish before composer-cli can peer it", + removeWhen: + "composer-cli releases peering 0.3.0 and the follow-up bump PR pins that release", + }, + { + familyPackage: "@prisma/orm-toolchain", + familyPin: "0.2.3", + shellPin: "0.3.0", + reason: "engine 0.3.0 must publish before orm-toolchain can peer it", + removeWhen: + "orm-toolchain releases peering 0.3.0 and the follow-up bump PR pins that release", + }, + ], channel: CHANNEL, sandboxDir: join(WORK_DIR, "sandbox"), }, diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 5a07e63d..193e063b 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -49,7 +49,7 @@ }, "dependencies": { "@manypkg/tools": "^2.1.2", - "@prisma/cli-engine": "workspace:0.2.3", + "@prisma/cli-engine": "workspace:0.3.0", "@prisma/composer-cli": "0.14.0", "@prisma/compute-sdk": "0.39.0", "@prisma/management-api-sdk": "1.55.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe390a9c..4221834e 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.2.3 + specifier: workspace:0.3.0 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.14.0 @@ -201,7 +201,7 @@ importers: specifier: ^2.1.2 version: 2.1.2 '@prisma/cli-engine': - specifier: workspace:0.2.3 + specifier: workspace:0.3.0 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.14.0 From 8e7964f24869f8c5a3e1d30c5dcd258f21e2e43b Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 12:19:01 +0200 Subject: [PATCH 04/19] loadConfig resolves a chain of config files instead of one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery walks from the anchor directory (cwd, or the --config file's directory) upward to the first directory containing .git, collecting every prisma.config.ts on the way; no .git above means the anchor directory only, so nothing outside the repository is ever executed implicitly. A file ends collection with parent: false or names its next link with parent: "path" (resolved against the declaring file, cycle-checked, allowed to cross the repository boundary — naming it is the consent). 'parent' joins the engine-reserved top-level keys: the loader strips it like the $prismaConfig marker, and declaring a section by that name fails construction. LoadedConfig is now a chain shape — per-file {path, sections} entries nearest-first plus file-level diagnostics. Every file on the chain gets the existing marker/version/unreadable classification, each failure naming its file, and a broken file anywhere fails resolution. The engine's unknown-key check runs per file over the chain; until per-key merging lands, the nearest file declaring a command's section supplies it whole, so one file in cwd behaves exactly as before. The skills reader keeps its cwd-only behavior through a nearest-file adapter for now. Loader tests pin their chains — a .git marker over the in-repo fixtures, temp trees outside the repository for the chain scenarios, parent: false in the cli package's named fixtures — so a config file appearing in this checkout's own directories can never leak into a test's ancestor walk. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/config-loader.ts | 343 +++++++++--- .../cli-engine/src/execution/command-tree.ts | 12 +- packages/cli-engine/src/execution/needs.ts | 16 +- packages/cli-engine/src/exports/index.ts | 1 + packages/cli-engine/src/runtime.ts | 53 +- packages/cli-engine/src/testing.ts | 5 +- .../cli-engine/tests/clack-isolation.test.ts | 3 +- .../cli-engine/tests/clack-prompts.test.ts | 3 +- .../cli-engine/tests/command-families.test.ts | 1 + packages/cli-engine/tests/config.test.ts | 491 +++++++++++++++--- packages/cli-engine/tests/engine.type-test.ts | 12 +- .../environment-credential-manager.test.ts | 3 +- packages/cli-engine/tests/execution.test.ts | 9 +- packages/cli-engine/tests/lifetimes.test.ts | 3 +- .../cli-engine/tests/management-api.test.ts | 3 +- packages/cli-engine/tests/prompts.test.ts | 3 +- packages/cli-engine/tests/spawn.test.ts | 3 +- packages/cli/e2e/init.e2e.ts | 4 +- packages/cli/src/commands/skills/config.ts | 7 +- packages/cli/tests/bin.test.ts | 15 +- .../config/composer-section.config.ts | 4 + .../tests/fixtures/config/elsewhere.config.ts | 4 + 22 files changed, 799 insertions(+), 199 deletions(-) diff --git a/packages/cli-engine/src/config-loader.ts b/packages/cli-engine/src/config-loader.ts index 6e44422a..d204261e 100644 --- a/packages/cli-engine/src/config-loader.ts +++ b/packages/cli-engine/src/config-loader.ts @@ -1,40 +1,54 @@ /** - * The prisma.config.ts loader behind Runtime.loadConfig: resolve the - * file (the one `--config` named, otherwise prisma.config.ts in cwd — - * cwd only, no walking up), evaluate it, check the definePrismaConfig version - * marker, and produce LoadedConfig. + * The prisma.config.ts loader behind Runtime.loadConfig: resolve an + * ordered chain of config files, evaluate each, check every one for the + * definePrismaConfig version marker, and produce LoadedConfig. + * + * Discovery starts at the anchor directory — cwd, or with `--config` + * the named file's own directory — and walks upward collecting every + * prisma.config.ts, stopping at the repository boundary: the first + * directory containing `.git`. No `.git` at or above the anchor means + * the anchor directory only — every file on the chain is executed + * TypeScript, and nothing outside the repository runs without explicit + * consent. A file may end collection itself with `parent: false`, or + * name its parent explicitly with `parent: "path"` (resolved against + * the declaring file's directory); an explicit parent may cross the + * repository boundary — naming it is the consent — and the chain is + * cycle-checked. After an explicit link, automatic discovery resumes + * from the parent file's own directory. * * Which section names a CLI recognises is not this module's business: - * it hands back every top-level key the file had, and the engine — not + * it hands back every top-level key each file had, and the engine — not * a Runtime member a host can replace — checks them against the * sections the mounted commands declare. * - * Absence of an undiscovered file is not an error: section validators - * own absence, so no prisma.config.ts in cwd yields no sections and no - * diagnostics. + * Finding no file is not an error: section validators own absence, so + * a chain with no files yields no sections and no diagnostics. * Absence of a file the user NAMED with --config is an error — they * said which file to read and it was not there. An evaluated file * WITHOUT the marker (a classic Prisma 7 config, which uses the same - * filename) fails early with one typed diagnostic — the loader never - * partially interprets an unmarked file, and never guesses. + * filename) fails early with one typed diagnostic naming that file — + * the loader never partially interprets an unmarked file, and never + * guesses. A broken file anywhere on the chain fails resolution. * * Evaluation goes through c12, the same loader prisma/prisma and * prisma/composer use, because the shipped CLI runs on ordinary Node, * which cannot import a .ts file. c12 transpiles it with jiti first. * Every c12 feature beyond "evaluate this one file" is switched off - * below so discovery and merging stay exactly as they were. + * below — chain discovery is this loader's, never c12's. */ -import { existsSync, realpathSync } from "node:fs"; +import { existsSync, realpathSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import type { Diagnostic } from "./protocol"; -import type { LoadedConfig } from "./runtime"; +import type { LoadedConfig, LoadedConfigFile } from "./runtime"; import { PRISMA_CONFIG_VERSION } from "./runtime"; export const CONFIG_FILE_NAME = "prisma.config.ts"; const MARKER_KEY = "$prismaConfig"; +const PARENT_KEY = "parent"; + /** * Top-level keys the config file format keeps for itself, so no * section may be named one of them; buildCommandTree rejects the @@ -43,19 +57,26 @@ const MARKER_KEY = "$prismaConfig"; * `$`-prefixed keys are metadata: `$prismaConfig` is the version * marker, and config loaders read `$env`, `$` and `$meta` — * c12 deletes `$meta` from the config object whatever else it is told. + * `parent` is the engine's chain directive: the loader reads it to + * follow or end the chain and strips it from the sections, like the + * marker. * `extends` is the key config loaders take as "merge another file into * this one". This loader switches that off (`extend: false`), so a key * by that name does reach the config object. It stays reserved anyway: - * the file format has no layering of its own, `extends` is the name it - * would want if it gained some, and a section called `extends` would - * disappear the moment anything switched merging back on. + * `parent` is this format's layering, and a section called `extends` + * would disappear the moment anything switched c12's merging back on. * * `__proto__` is reserved for the reason `$meta` is: c12 merges layers * with defu, which drops the key rather than let a config file reach an * object's prototype, so a section by that name could never be read. */ export function reservedConfigSectionName(name: string): boolean { - return name === "extends" || name === "__proto__" || name.startsWith("$"); + return ( + name === "extends" || + name === PARENT_KEY || + name === "__proto__" || + name.startsWith("$") + ); } /** @@ -79,8 +100,11 @@ function hasVersionMarker(value: unknown): value is Record { ); } -function fileLevelConfig(path: string, diagnostic: Diagnostic): LoadedConfig { - return { path, sections: {}, diagnostics: [{ section: null, diagnostic }] }; +function failedChain( + files: readonly LoadedConfigFile[], + diagnostic: Diagnostic, +): LoadedConfig { + return { files, diagnostics: [{ section: null, diagnostic }] }; } function missingMarkerDiagnostic(path: string): Diagnostic { @@ -131,13 +155,66 @@ function missingNamedFileDiagnostic(path: string): Diagnostic { { kind: "user-choice", label: - "Correct the path passed to --config, or drop the flag to use the prisma.config.ts in the current directory.", + "Correct the path passed to --config, or drop the flag to let the CLI discover prisma.config.ts from the current directory.", }, ], where: { path }, }; } +function invalidParentDiagnostic(path: string, value: unknown): Diagnostic { + const described = value === null ? "null" : typeof value; + return { + code: "CLI.CONFIG_PARENT_INVALID", + severity: "error", + summary: `${path} has a top-level 'parent' of type ${described}, but parent must be false or a path string.`, + why: "'parent' controls config discovery: false makes this file the last on the chain, and a path names the next file explicitly. Any other value is a mistake the CLI stops on rather than guesses about.", + nextActions: [ + { + kind: "user-choice", + label: + "Set parent to false or to the path of the parent config file, or remove it to let discovery continue upward.", + }, + ], + where: { path }, + }; +} + +function missingParentDiagnostic( + declaring: string, + target: string, +): Diagnostic { + return { + code: "CLI.CONFIG_PARENT_NOT_FOUND", + severity: "error", + summary: `${declaring} names ${target} as its parent config, and there is no file there.`, + nextActions: [ + { + kind: "user-choice", + label: + "Correct the parent path, set parent to false, or remove it to let discovery continue upward.", + }, + ], + where: { path: declaring }, + }; +} + +function parentCycleDiagnostic(declaring: string, target: string): Diagnostic { + return { + code: "CLI.CONFIG_PARENT_CYCLE", + severity: "error", + summary: `${declaring} names ${target} as its parent config, but that file is already on the config chain.`, + why: "Following the link again would loop forever, so resolution stops here.", + nextActions: [ + { + kind: "user-choice", + label: "Break the cycle: point parent elsewhere or set it to false.", + }, + ], + where: { path: declaring }, + }; +} + /** jiti reports an unresolvable import as "Cannot find module", Node's * own resolver as "Cannot find package"; either can appear in the * evaluation error chain depending on how the file is loaded. Node's @@ -276,28 +353,175 @@ function sectionsOf( exported: Record, ): Record { return Object.fromEntries( - Object.entries(exported).filter(([key]) => key !== MARKER_KEY), + Object.entries(exported).filter( + ([key]) => key !== MARKER_KEY && key !== PARENT_KEY, + ), ); } -/** - * The file to read, always absolute: the one --config named, resolved - * against cwd, or prisma.config.ts in cwd. - * - * Absolute is not cosmetic, and it is one more thing than either - * reference repository does — both are handed an absolute path before - * they reach c12, so neither has to resolve one. Given a relative path, - * c12 resolves it a second time against its own cwd and looks for a - * file that is not there, and jiti cannot import a relative specifier - * at all. Resolving here also makes the file's path in every diagnostic - * absolute, and makes the loaded-file comparison compare like with - * like. - */ -function fileToRead(cwd: string, configPath: string | undefined): string { - const root = resolve(cwd); - return configPath === undefined - ? join(root, CONFIG_FILE_NAME) - : resolve(root, configPath); +/** realpath where the path exists; the path itself where it does not + * (a cwd that is gone, a comparison candidate that was just checked). */ +function realpathOr(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +/** The first directory at or above `dir` containing a `.git` entry — + * a directory in an ordinary checkout, a file in worktrees and + * submodules — or null when there is none all the way up. */ +function repositoryBoundary(dir: string): string | null { + let current = dir; + while (true) { + if (existsSync(join(current, ".git"))) { + return current; + } + const parent = dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +/** The directories automatic discovery scans from `start`: upward to + * the repository boundary inclusive, or `start` alone when no `.git` + * exists at or above it. */ +function discoveryDirs(start: string): string[] { + const boundary = repositoryBoundary(start); + const dirs = [start]; + let current = start; + while (boundary !== null && current !== boundary) { + current = dirname(current); + dirs.push(current); + } + return dirs; +} + +/** Where discovery continues after the file in `dir`: the directories + * strictly above it, still inside its repository. Outside any + * repository, nothing above it is scanned. */ +function dirsAbove(dir: string): string[] { + return discoveryDirs(dir).slice(1); +} + +function nextDiscoveredFile( + dirs: readonly string[], + collected: ReadonlySet, +): string | null { + for (const dir of dirs) { + const candidate = join(dir, CONFIG_FILE_NAME); + if (existsSync(candidate) && !collected.has(realpathOr(candidate))) { + return candidate; + } + } + return null; +} + +/** The next chain link: a file an explicit step named (--config or a + * `parent` path, known to exist), or the directories to scan for one. */ +type NextLink = + | { readonly kind: "file"; readonly path: string } + | { readonly kind: "scan"; readonly dirs: readonly string[] }; + +type EvaluatedChainFile = + | { readonly ok: true; readonly exported: Record } + | { readonly ok: false; readonly diagnostic: Diagnostic }; + +/** One file's evaluation and marker/version classification, every + * outcome carrying the diagnostic that names this file. */ +async function evaluateChainFile( + path: string, + cliVersion: string | undefined, +): Promise { + let exported: unknown; + try { + exported = await evaluateConfigFile(path); + } catch (cause) { + return { + ok: false, + diagnostic: importsMissingPrismaPackage(cause) + ? prismaConfigUnresolvedDiagnostic(path, cliVersion) + : unreadableDiagnostic(path, cause), + }; + } + if (!hasVersionMarker(exported)) { + return { ok: false, diagnostic: missingMarkerDiagnostic(path) }; + } + const version = exported[MARKER_KEY] as number; + if (version !== PRISMA_CONFIG_VERSION) { + return { + ok: false, + diagnostic: unsupportedVersionDiagnostic(path, version), + }; + } + return { ok: true, exported }; +} + +type LinkOutcome = + | { readonly next: NextLink | null } + | { readonly diagnostic: Diagnostic }; + +/** Where the chain goes after the file at `path` declared `parent`: + * false ends it, a path names the next file (checked to exist and to + * not close a cycle), absence resumes automatic discovery above. */ +function followParent( + path: string, + parent: unknown, + collected: ReadonlySet, +): LinkOutcome { + if (parent === false) { + return { next: null }; + } + if (typeof parent === "string") { + const target = resolve(dirname(path), parent); + if (!existsSync(target) || !statSync(target).isFile()) { + return { diagnostic: missingParentDiagnostic(path, target) }; + } + if (collected.has(realpathOr(target))) { + return { diagnostic: parentCycleDiagnostic(path, target) }; + } + return { next: { kind: "file", path: target } }; + } + if (parent !== undefined) { + return { diagnostic: invalidParentDiagnostic(path, parent) }; + } + return { + next: { kind: "scan", dirs: dirsAbove(realpathOr(dirname(path))) }, + }; +} + +async function collectChain( + first: NextLink, + cliVersion: string | undefined, +): Promise { + const files: LoadedConfigFile[] = []; + const collected = new Set(); + let next: NextLink | null = first; + while (next !== null) { + const path = + next.kind === "file" + ? next.path + : nextDiscoveredFile(next.dirs, collected); + if (path === null) { + break; + } + // biome-ignore lint/performance/noAwaitInLoops: each file's parent link is read from its evaluated export, so the chain can only be followed one file at a time. + const evaluated = await evaluateChainFile(path, cliVersion); + if (!evaluated.ok) { + return failedChain(files, evaluated.diagnostic); + } + files.push({ path, sections: sectionsOf(evaluated.exported) }); + collected.add(realpathOr(path)); + const link = followParent(path, evaluated.exported[PARENT_KEY], collected); + if ("diagnostic" in link) { + return failedChain(files, link.diagnostic); + } + next = link.next; + } + return { files, diagnostics: [] }; } /** @@ -307,35 +531,32 @@ function fileToRead(cwd: string, configPath: string | undefined): string { * when the 'prisma/config' entry point cannot be resolved; absent, the * guidance names no version rather than an example that installs the * wrong one. + * + * Every path handed onward is absolute — one more thing than either + * reference repository does, since both are handed an absolute path + * before they reach c12. Given a relative path, c12 resolves it a + * second time against its own cwd and looks for a file that is not + * there, and jiti cannot import a relative specifier at all. Resolving + * here also makes the file's path in every diagnostic absolute, and + * makes every chain-path comparison compare like with like. The anchor + * directory is additionally resolved through symlinks, so discovered + * files and the errors about them name real paths. */ export async function loadConfig( cwd: string, configPath?: string, cliVersion?: string, ): Promise { - const path = fileToRead(cwd, configPath); - if (!existsSync(path)) { - return configPath === undefined - ? { path, sections: {}, diagnostics: [] } - : fileLevelConfig(path, missingNamedFileDiagnostic(path)); - } - let exported: unknown; - try { - exported = await evaluateConfigFile(path); - } catch (cause) { - return fileLevelConfig( - path, - importsMissingPrismaPackage(cause) - ? prismaConfigUnresolvedDiagnostic(path, cliVersion) - : unreadableDiagnostic(path, cause), + const root = resolve(cwd); + if (configPath === undefined) { + return collectChain( + { kind: "scan", dirs: discoveryDirs(realpathOr(root)) }, + cliVersion, ); } - if (!hasVersionMarker(exported)) { - return fileLevelConfig(path, missingMarkerDiagnostic(path)); - } - const version = exported[MARKER_KEY] as number; - if (version !== PRISMA_CONFIG_VERSION) { - return fileLevelConfig(path, unsupportedVersionDiagnostic(path, version)); + const named = resolve(root, configPath); + if (!existsSync(named)) { + return failedChain([], missingNamedFileDiagnostic(named)); } - return { path, sections: sectionsOf(exported), diagnostics: [] }; + return collectChain({ kind: "file", path: named }, cliVersion); } diff --git a/packages/cli-engine/src/execution/command-tree.ts b/packages/cli-engine/src/execution/command-tree.ts index 46ac2604..8785d1aa 100644 --- a/packages/cli-engine/src/execution/command-tree.ts +++ b/packages/cli-engine/src/execution/command-tree.ts @@ -181,10 +181,12 @@ function validateSectionOwnership( * a section may only be named something the file format leaves free: * not `$`-prefixed (those are metadata — $prismaConfig is the version * marker, and a $meta key is deleted before the loader ever sees it), - * and not `extends` (the key config loaders take as an instruction to - * merge another file in). Both the families and the commands mounted - * without one are checked: whoever declares the section, claiming a - * reserved name is broken at build time, not at a user's runtime. */ + * not `parent` (the engine's chain directive, read and stripped by + * the loader), and not `extends` (the key config loaders take as an + * instruction to merge another file in). Both the families and the + * commands mounted without one are checked: whoever declares the + * section, claiming a reserved name is broken at build time, not at a + * user's runtime. */ function validateConfigSectionNames(spec: EngineSpec): void { for (const commandFamily of spec.commandFamilies) { rejectReservedSectionName("command family", commandFamily.configSection); @@ -202,7 +204,7 @@ function rejectReservedSectionName( return; } throw constructionError( - `${owner} declares config section '${section.name}', a name the config file reserves ('extends' is a config loader's merge directive, and '$'-prefixed keys are metadata)`, + `${owner} declares config section '${section.name}', a name the config file reserves ('parent' is the engine's chain directive, 'extends' is a config loader's merge directive, and '$'-prefixed keys are metadata)`, ); } diff --git a/packages/cli-engine/src/execution/needs.ts b/packages/cli-engine/src/execution/needs.ts index 8bfec8a3..e3a7156d 100644 --- a/packages/cli-engine/src/execution/needs.ts +++ b/packages/cli-engine/src/execution/needs.ts @@ -231,14 +231,18 @@ function unknownSectionDiagnostic( }; } +/** Every file on the chain is checked — a typo'd key in a nested file + * errors even when the command's section resolved elsewhere. */ function unknownSections( loaded: LoadedConfig, configSections: readonly string[], ): readonly Diagnostic[] { const declared = new Set(configSections); - return Object.keys(loaded.sections) - .filter((key) => !declared.has(key)) - .map((key) => unknownSectionDiagnostic(loaded.path, key, configSections)); + return loaded.files.flatMap((file) => + Object.keys(file.sections) + .filter((key) => !declared.has(key)) + .map((key) => unknownSectionDiagnostic(file.path, key, configSections)), + ); } /** The config file is read HERE and nowhere else, so a command with no @@ -283,7 +287,11 @@ function validateConfigSection( invocation: Invocation, configFile: string, ): NeedsOutcome { - const raw = loaded.sections[section.name]; + // The nearest file declaring the section supplies it whole; per-key + // merging over the chain arrives with ConfigSection.merge. + const raw = loaded.files.find((file) => + Object.hasOwn(file.sections, section.name), + )?.sections[section.name]; let validation: SectionValidation; try { validation = section.validate(raw); diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index 23b3424c..d72efcc6 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -121,6 +121,7 @@ export { type HostProcess, type InputStream, type LoadedConfig, + type LoadedConfigFile, type OutputStream, PRISMA_CONFIG_VERSION, type Runtime, diff --git a/packages/cli-engine/src/runtime.ts b/packages/cli-engine/src/runtime.ts index dcf07b03..b76700fc 100644 --- a/packages/cli-engine/src/runtime.ts +++ b/packages/cli-engine/src/runtime.ts @@ -70,13 +70,14 @@ export interface Runtime { */ readonly onSignal: (cb: (signal: "SIGINT" | "SIGTERM") => void) => () => void; /** - * Reads prisma.config.ts, on demand. The engine calls it only when - * the command it is about to run declares a config section, so a run - * that needs no config never touches the file. `configPath` is the - * file `--config` named: the loader resolves it against the runtime's - * cwd and reports its absence. Absent means look for prisma.config.ts - * in cwd, where absence is not an error. The bin wires the real disk - * loader; tests hand in fixtures. + * Resolves the prisma.config.ts chain, on demand. The engine calls it + * only when the command it is about to run declares a config section, + * so a run that needs no config never touches a file. `configPath` is + * the file `--config` named: the loader resolves it against the + * runtime's cwd, reports its absence, and anchors discovery at its + * directory. Absent means discover from cwd, where finding no file is + * not an error. The bin wires the real disk loader; tests hand in + * fixtures. */ readonly loadConfig: (configPath?: string) => Promise; /** @@ -181,26 +182,34 @@ export interface HostProcess { exit(code: number): never; } -export interface LoadedConfig { - /** - * The file this config came from, absolute: the one `--config` named, - * or prisma.config.ts in cwd. A loader that found no file still names - * the file it looked for — with no file there are no sections, and - * the engine reads the path only to name the file when it reports a - * top-level key that is not one of the CLI's sections. - */ +/** One file on the resolved config chain. */ +export interface LoadedConfigFile { + /** Where the file is, absolute; every diagnostic about it names it. */ readonly path: string; /** - * Raw section values by name; validation happens per command via its - * command family's section token. The engine, not the loader, checks - * these names against the sections the CLI declares, so the closed - * set holds whatever loader a host wires. + * Raw section values by name — the file's top-level keys minus the + * engine-reserved ones (`$prismaConfig`, `parent`); validation + * happens per command via its command family's section token. The + * engine, not the loader, checks these names against the sections + * the CLI declares, so the closed set holds whatever loader a host + * wires. */ readonly sections: Readonly>; +} + +export interface LoadedConfig { + /** + * The resolved chain, nearest-first: the anchor file (the one + * `--config` named, or prisma.config.ts discovered from cwd), then + * each ancestor or declared parent. Empty when discovery found no + * file at all — the section validators own absence. + */ + readonly files: ReadonlyArray; /** - * File-level problems (unevaluable module, missing version marker) - * carry section: null and fail only commands with a needs.config - * section; commands with no config need run normally. + * File-level problems anywhere on the chain (unevaluable module, + * missing version marker, a broken `parent` link) carry + * section: null and fail only commands with a needs.config section; + * commands with no config need run normally. */ readonly diagnostics: ReadonlyArray<{ readonly section: string | null; diff --git a/packages/cli-engine/src/testing.ts b/packages/cli-engine/src/testing.ts index 4f73671c..0cf96467 100644 --- a/packages/cli-engine/src/testing.ts +++ b/packages/cli-engine/src/testing.ts @@ -298,8 +298,9 @@ export function createTestCli(spec: { const loadConfig: Runtime["loadConfig"] = spec.loadConfig ?? (async (configPath) => ({ - path: configPath ?? CONFIG_FILE_NAME, - sections: spec.config ?? {}, + files: [ + { path: configPath ?? CONFIG_FILE_NAME, sections: spec.config ?? {} }, + ], diagnostics: [], })); const spawnChild: SpawnChild = diff --git a/packages/cli-engine/tests/clack-isolation.test.ts b/packages/cli-engine/tests/clack-isolation.test.ts index 9eb40e7b..e0bc90b6 100644 --- a/packages/cli-engine/tests/clack-isolation.test.ts +++ b/packages/cli-engine/tests/clack-isolation.test.ts @@ -120,8 +120,7 @@ describe("scripted and non-TTY paths are clack-free", () => { }, onSignal: () => () => {}, loadConfig: async () => ({ - path: "/prisma.config.ts", - sections: {}, + files: [], diagnostics: [], }), managementApi: { baseUrl: "https://test.invalid" }, diff --git a/packages/cli-engine/tests/clack-prompts.test.ts b/packages/cli-engine/tests/clack-prompts.test.ts index dfca2c26..92b8fbd1 100644 --- a/packages/cli-engine/tests/clack-prompts.test.ts +++ b/packages/cli-engine/tests/clack-prompts.test.ts @@ -108,8 +108,7 @@ async function runInteractive( }, onSignal: () => () => {}, loadConfig: async () => ({ - path: "/prisma.config.ts", - sections: {}, + files: [], diagnostics: [], }), managementApi: { baseUrl: "https://test.invalid" }, diff --git a/packages/cli-engine/tests/command-families.test.ts b/packages/cli-engine/tests/command-families.test.ts index 05c20cf3..2e19585b 100644 --- a/packages/cli-engine/tests/command-families.test.ts +++ b/packages/cli-engine/tests/command-families.test.ts @@ -106,6 +106,7 @@ describe("section names the config file reserves", () => { test.each([ "extends", + "parent", "__proto__", "$env", "$meta", diff --git a/packages/cli-engine/tests/config.test.ts b/packages/cli-engine/tests/config.test.ts index 11bd2281..973465ed 100644 --- a/packages/cli-engine/tests/config.test.ts +++ b/packages/cli-engine/tests/config.test.ts @@ -1,11 +1,20 @@ /** - * The config loader behind Runtime.loadConfig — cwd-only discovery, + * The config loader behind Runtime.loadConfig — chain discovery from + * the anchor upward to the repository boundary, the `parent` directive, * definePrismaConfig marker semantics with the pinned Prisma 7 fail-early * diagnostic, the engine's closed set of section names, and * needs.config validation wired end to end through the harness. */ import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -29,7 +38,15 @@ import { afterAll, describe, expect, test } from "vitest"; const TESTS_DIR = dirname(fileURLToPath(import.meta.url)); -const FIXTURES = join(TESTS_DIR, "fixtures", "config"); +// realpath'd because the loader resolves the anchor directory through +// symlinks, so the paths it reports are real ones. +const FIXTURES = realpathSync(join(TESTS_DIR, "fixtures", "config")); + +// A .git marker making the fixtures directory its own repository +// boundary, so no fixture chain can walk into this repository's real +// ancestors and pick up a config file the checkout happens to contain. +// Created at run time: git refuses to track any path named .git. +writeFileSync(join(FIXTURES, ".git"), ""); const EPOCH = () => new Date(0); const T0 = "1970-01-01T00:00:00.000Z"; @@ -46,35 +63,38 @@ describe("definePrismaConfig", () => { describe("loadConfig", { timeout: 60_000 }, () => { test("a marked file yields raw sections without the marker key, and names the file it read", async () => { expect(await loadConfig(join(FIXTURES, "marked"))).toEqual({ - path: join(FIXTURES, "marked", "prisma.config.ts"), - sections: { toy: { greeting: "hello" }, other: { level: 2 } }, + files: [ + { + path: join(FIXTURES, "marked", "prisma.config.ts"), + sections: { toy: { greeting: "hello" }, other: { level: 2 } }, + }, + ], diagnostics: [], }); }); - /** With no file there are no sections, so nothing ever asks which - * file they came from; the path still names the one that was looked - * for rather than going absent. */ - test("no file at all yields no sections and no error — validators own absence", async () => { + test("no file at all yields an empty chain and no error — validators own absence", async () => { expect(await loadConfig(FIXTURES)).toEqual({ - path: join(FIXTURES, "prisma.config.ts"), - sections: {}, + files: [], diagnostics: [], }); }); - test("discovery is cwd-only: a config in the parent directory is not found", async () => { + test("discovery walks upward: a parent directory's config is the chain from a bare subdirectory", async () => { expect(await loadConfig(join(FIXTURES, "marked", "nested"))).toEqual({ - path: join(FIXTURES, "marked", "nested", "prisma.config.ts"), - sections: {}, + files: [ + { + path: join(FIXTURES, "marked", "prisma.config.ts"), + sections: { toy: { greeting: "hello" }, other: { level: 2 } }, + }, + ], diagnostics: [], }); }); test("an evaluated file without the marker fails early with the pinned Prisma 7 diagnostic", async () => { expect(await loadConfig(join(FIXTURES, "unmarked"))).toEqual({ - path: join(FIXTURES, "unmarked", "prisma.config.ts"), - sections: {}, + files: [], diagnostics: [ { section: null, @@ -99,8 +119,7 @@ describe("loadConfig", { timeout: 60_000 }, () => { test("a marker version other than the supported one fails with a file-level diagnostic", async () => { expect(await loadConfig(join(FIXTURES, "wrong-version"))).toEqual({ - path: join(FIXTURES, "wrong-version", "prisma.config.ts"), - sections: {}, + files: [], diagnostics: [ { section: null, @@ -127,8 +146,9 @@ describe("loadConfig", { timeout: 60_000 }, () => { test("a section value keeps its types — arrays and Dates survive the loader", async () => { const loaded = await loadConfig(join(FIXTURES, "passthrough")); expect(loaded.diagnostics).toEqual([]); - expect(Object.keys(loaded.sections)).toEqual(["values"]); - const values = loaded.sections.values as { + expect(loaded.files).toHaveLength(1); + expect(Object.keys(loaded.files[0].sections)).toEqual(["values"]); + const values = loaded.files[0].sections.values as { readonly list: unknown; readonly when: unknown; }; @@ -138,7 +158,7 @@ describe("loadConfig", { timeout: 60_000 }, () => { test("a file that throws while evaluating yields a file-level diagnostic", async () => { const loaded = await loadConfig(join(FIXTURES, "unreadable")); - expect(loaded.sections).toEqual({}); + expect(loaded.files).toEqual([]); expect(loaded.diagnostics).toHaveLength(1); expect(loaded.diagnostics[0].section).toBeNull(); expect(loaded.diagnostics[0].diagnostic.code).toBe("CLI.CONFIG_UNREADABLE"); @@ -157,7 +177,7 @@ describe("loadConfig", { timeout: 60_000 }, () => { test("a config whose 'prisma/config' import cannot resolve says to install prisma", async () => { const path = join(FIXTURES, "missing-prisma", "prisma.config.ts"); const loaded = await loadConfig(join(FIXTURES, "missing-prisma")); - expect(loaded.sections).toEqual({}); + expect(loaded.files).toEqual([]); expect(loaded.diagnostics).toEqual([ { section: null, @@ -226,6 +246,261 @@ describe("loadConfig", { timeout: 60_000 }, () => { }); }); +/** + * Chain discovery over real directory trees. Every tree lives in its + * own temp directory outside this repository, with `.git` markers + * written where the scenario's repository boundary belongs, so the + * checkout's own layout can never reach into a chain. The fixture + * files carry the version marker literally rather than importing + * definePrismaConfig: nothing resolves from a bare temp directory. + */ +describe("chain discovery", { timeout: 60_000 }, () => { + const sandboxes: string[] = []; + + function sandbox(): string { + const root = realpathSync( + mkdtempSync(join(tmpdir(), "prisma-config-chain-")), + ); + sandboxes.push(root); + return root; + } + + afterAll(() => { + for (const root of sandboxes) { + rmSync(root, { recursive: true, force: true }); + } + }); + + function writeConfig(dir: string, body: string): string { + mkdirSync(dir, { recursive: true }); + const path = join(dir, "prisma.config.ts"); + writeFileSync( + path, + `export default { $prismaConfig: ${PRISMA_CONFIG_VERSION}, ${body} };\n`, + ); + return path; + } + + function markRepository(dir: string): void { + mkdirSync(join(dir, ".git"), { recursive: true }); + } + + test("collects every config from the anchor up to the repository boundary, nearest first", async () => { + const repo = join(sandbox(), "repo"); + markRepository(repo); + const rootFile = writeConfig(repo, `deploy: { target: "root" }`); + const dbFile = writeConfig( + join(repo, "packages", "db"), + `orm: { fromDb: true }`, + ); + + expect(await loadConfig(join(repo, "packages", "db"))).toEqual({ + files: [ + { path: dbFile, sections: { orm: { fromDb: true } } }, + { path: rootFile, sections: { deploy: { target: "root" } } }, + ], + diagnostics: [], + }); + }); + + test("a directory between anchor and boundary with no config contributes nothing", async () => { + const repo = join(sandbox(), "repo"); + markRepository(repo); + const rootFile = writeConfig(repo, `deploy: {}`); + const deep = join(repo, "packages", "db", "src"); + mkdirSync(deep, { recursive: true }); + + const loaded = await loadConfig(deep); + expect(loaded.files.map((file) => file.path)).toEqual([rootFile]); + }); + + test("the walk stops at the first .git: a config above the repository is not collected", async () => { + const base = sandbox(); + writeConfig(base, `outer: {}`); + const repo = join(base, "repo"); + markRepository(repo); + const repoFile = writeConfig(repo, `inner: {}`); + + expect((await loadConfig(repo)).files.map((file) => file.path)).toEqual([ + repoFile, + ]); + }); + + test("no .git at or above the anchor means the anchor directory only", async () => { + const base = sandbox(); + writeConfig(join(base, "parent"), `above: {}`); + const childFile = writeConfig(join(base, "parent", "child"), `below: {}`); + + expect( + (await loadConfig(join(base, "parent", "child"))).files.map( + (file) => file.path, + ), + ).toEqual([childFile]); + + const bare = join(base, "parent", "empty"); + mkdirSync(bare); + expect(await loadConfig(bare)).toEqual({ files: [], diagnostics: [] }); + }); + + test("parent: false ends collection at that file, and the key never becomes a section", async () => { + const repo = join(sandbox(), "repo"); + markRepository(repo); + writeConfig(repo, `root: {}`); + const midFile = writeConfig(join(repo, "mid"), `mine: {}, parent: false`); + const leaf = join(repo, "mid", "leaf"); + mkdirSync(leaf); + + expect(await loadConfig(leaf)).toEqual({ + files: [{ path: midFile, sections: { mine: {} } }], + diagnostics: [], + }); + }); + + test('parent: "path" names the next link explicitly, and may cross the repository boundary', async () => { + const base = sandbox(); + const repo = join(base, "repo"); + markRepository(repo); + const repoFile = writeConfig( + repo, + `mine: {}, parent: "../shared/prisma.config.ts"`, + ); + const sharedFile = writeConfig(join(base, "shared"), `theirs: {}`); + + expect(await loadConfig(repo)).toEqual({ + files: [ + { path: repoFile, sections: { mine: {} } }, + { path: sharedFile, sections: { theirs: {} } }, + ], + diagnostics: [], + }); + }); + + test("after an explicit parent, automatic discovery resumes from the parent file's own directory", async () => { + const repo = join(sandbox(), "repo"); + markRepository(repo); + const rootFile = writeConfig(repo, `root: {}`); + const pkgFile = writeConfig( + join(repo, "pkg"), + `mine: {}, parent: "../vendor/lib/prisma.config.ts"`, + ); + const vendorFile = writeConfig(join(repo, "vendor", "lib"), `vendored: {}`); + + expect( + (await loadConfig(join(repo, "pkg"))).files.map((file) => file.path), + ).toEqual([pkgFile, vendorFile, rootFile]); + }); + + test("a parent cycle is a file-level error naming the file that closed it", async () => { + const base = sandbox(); + const aFile = writeConfig( + join(base, "a"), + `a: {}, parent: "../b/prisma.config.ts"`, + ); + const bFile = writeConfig( + join(base, "b"), + `b: {}, parent: "../a/prisma.config.ts"`, + ); + + const loaded = await loadConfig(join(base, "a")); + expect(loaded.files.map((file) => file.path)).toEqual([aFile, bFile]); + expect(loaded.diagnostics).toHaveLength(1); + const { diagnostic } = loaded.diagnostics[0]; + expect(diagnostic.code).toBe("CLI.CONFIG_PARENT_CYCLE"); + expect(diagnostic.summary).toContain(bFile); + expect(diagnostic.summary).toContain(aFile); + expect(diagnostic.where).toEqual({ path: bFile }); + }); + + test("a parent path with no file there is a file-level error naming both paths", async () => { + const base = sandbox(); + const file = writeConfig( + join(base, "a"), + `a: {}, parent: "./nowhere.config.ts"`, + ); + + const loaded = await loadConfig(join(base, "a")); + expect(loaded.files.map((entry) => entry.path)).toEqual([file]); + expect(loaded.diagnostics[0]?.diagnostic.code).toBe( + "CLI.CONFIG_PARENT_NOT_FOUND", + ); + expect(loaded.diagnostics[0]?.diagnostic.summary).toContain(file); + expect(loaded.diagnostics[0]?.diagnostic.summary).toContain( + join(base, "a", "nowhere.config.ts"), + ); + }); + + test("a parent that is neither false nor a string is a file-level error", async () => { + const base = sandbox(); + writeConfig(join(base, "a"), `a: {}, parent: true`); + + const loaded = await loadConfig(join(base, "a")); + expect(loaded.diagnostics[0]?.diagnostic.code).toBe( + "CLI.CONFIG_PARENT_INVALID", + ); + expect(loaded.diagnostics[0]?.diagnostic.summary).toContain("boolean"); + }); + + test("a broken file anywhere on the chain fails resolution, naming that file", async () => { + const repo = join(sandbox(), "repo"); + markRepository(repo); + mkdirSync(join(repo, "pkg"), { recursive: true }); + writeFileSync( + join(repo, "prisma.config.ts"), + `export default { toy: {} };\n`, + ); + const pkgFile = writeConfig(join(repo, "pkg"), `mine: {}`); + + const loaded = await loadConfig(join(repo, "pkg")); + expect(loaded.files.map((file) => file.path)).toEqual([pkgFile]); + expect(loaded.diagnostics[0]?.diagnostic.code).toBe( + "CLI.CONFIG_MISSING_MARKER", + ); + expect(loaded.diagnostics[0]?.diagnostic.summary).toContain( + join(repo, "prisma.config.ts"), + ); + }); + + test.skipIf(process.platform === "win32")( + "a symlinked anchor resolves to real paths before the walk", + async () => { + const base = sandbox(); + const repo = join(base, "repo"); + markRepository(repo); + const file = writeConfig(repo, `mine: {}`); + const link = join(base, "link"); + symlinkSync(repo, link, "dir"); + + expect((await loadConfig(link)).files.map((entry) => entry.path)).toEqual( + [file], + ); + }, + ); + + test("--config anchors the chain at the named file's directory, never cwd's lineage", async () => { + const base = sandbox(); + const repo = join(base, "repo"); + markRepository(repo); + const rootFile = writeConfig(repo, `root: {}`); + mkdirSync(join(repo, "pkg"), { recursive: true }); + const named = join(repo, "pkg", "custom.config.ts"); + writeFileSync( + named, + `export default { $prismaConfig: ${PRISMA_CONFIG_VERSION}, custom: {} };\n`, + ); + const elsewhere = join(base, "elsewhere"); + markRepository(elsewhere); + writeConfig(elsewhere, `cwdside: {}`); + + expect(await loadConfig(elsewhere, named)).toEqual({ + files: [ + { path: named, sections: { custom: {} } }, + { path: rootFile, sections: { root: {} } }, + ], + diagnostics: [], + }); + }); +}); + describe("top-level keys that are not sections", { timeout: 60_000 }, () => { /** The check is the engine's, not the loader's: loadConfig hands back * every top-level key the file had, and the run fails on the ones no @@ -274,8 +549,12 @@ describe("top-level keys that are not sections", { timeout: 60_000 }, () => { const cli = createTestCli({ commands: { show: showCommand(toySection(), ran) }, loadConfig: async () => ({ - path: "/host/picked/this.config.ts", - sections: { toy: { greeting: "hi" }, telemtry: { enabled: true } }, + files: [ + { + path: "/host/picked/this.config.ts", + sections: { toy: { greeting: "hi" }, telemtry: { enabled: true } }, + }, + ], diagnostics: [], }), }); @@ -288,6 +567,56 @@ describe("top-level keys that are not sections", { timeout: 60_000 }, () => { expect(run.stderr).toContain("recognises are: toy"); }); + /** The per-file check: a typo'd key in an ancestor file errors even + * when the command's section resolved from the nearest one. */ + test("an unrecognised key anywhere on the chain fails the run, naming that file", async () => { + const ran = { value: false }; + const cli = createTestCli({ + commands: { show: showCommand(toySection(), ran) }, + loadConfig: async () => ({ + files: [ + { + path: "/repo/pkg/prisma.config.ts", + sections: { toy: { greeting: "hi" } }, + }, + { + path: "/repo/prisma.config.ts", + sections: { telemtry: { enabled: true } }, + }, + ], + diagnostics: [], + }), + }); + const run = await cli.run(["show"], { isTty: { stdout: true } }); + expect(run.exitCode).toBe(2); + expect(ran.value).toBe(false); + expect(run.stderr).toContain("CLI.CONFIG_UNKNOWN_SECTION"); + expect(run.stderr).toContain("'telemtry'"); + expect(run.stderr).toContain("/repo/prisma.config.ts"); + }); + + /** Until sections merge per key, the nearest file declaring the + * section supplies it whole — and one declared nowhere near falls + * through to an ancestor. */ + test("a section declared only by an ancestor file still reaches the handler", async () => { + const cli = createTestCli({ + commands: { show: showCommand(toySection()) }, + loadConfig: async () => ({ + files: [ + { path: "/repo/pkg/prisma.config.ts", sections: {} }, + { + path: "/repo/prisma.config.ts", + sections: { toy: { greeting: "from the root" } }, + }, + ], + diagnostics: [], + }), + }); + const run = await cli.run(["show"], { isTty: { stdout: true } }); + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toEqual({ greeting: "from the root" }); + }); + /** * `extends` is an ordinary top-level key here. c12 would otherwise * read it as an instruction to merge another file in — pulling @@ -300,7 +629,7 @@ describe("top-level keys that are not sections", { timeout: 60_000 }, () => { */ test("a top-level 'extends' is data, not an instruction to merge another file", async () => { const loaded = await loadConfig(join(FIXTURES, "extends-key")); - expect(loaded.sections).toEqual({ + expect(loaded.files[0]?.sections).toEqual({ extends: "./base.config.ts", values: { list: [1, 2] }, }); @@ -312,7 +641,9 @@ describe("top-level keys that are not sections", { timeout: 60_000 }, () => { * it; here it is data like any other string. */ test("an 'extends' value that names a URL is not fetched", async () => { const loaded = await loadConfig(join(FIXTURES, "extends-remote")); - expect(loaded.sections.extends).toBe("http://127.0.0.1:1/evil.tar.gz"); + expect(loaded.files[0]?.sections.extends).toBe( + "http://127.0.0.1:1/evil.tar.gz", + ); expect(loaded.diagnostics).toEqual([]); }); @@ -326,7 +657,7 @@ describe("top-level keys that are not sections", { timeout: 60_000 }, () => { */ test("a top-level '$meta' can never be reported as an unknown section", async () => { const loaded = await loadConfig(join(FIXTURES, "meta-key")); - expect(loaded.sections).toEqual({}); + expect(loaded.files).toEqual([]); expect(loaded.diagnostics.map(({ diagnostic }) => diagnostic.code)).toEqual( ["CLI.CONFIG_UNREADABLE"], ); @@ -344,8 +675,8 @@ describe("top-level keys that are not sections", { timeout: 60_000 }, () => { */ test("a top-level '__proto__' never reaches the loader at all", async () => { const loaded = await loadConfig(join(FIXTURES, "proto-key")); - expect(loaded.sections).toEqual({ toy: { greeting: "hello" } }); - expect(Object.hasOwn(loaded.sections, "__proto__")).toBe(false); + expect(loaded.files[0]?.sections).toEqual({ toy: { greeting: "hello" } }); + expect(Object.hasOwn(loaded.files[0].sections, "__proto__")).toBe(false); expect(loaded.diagnostics).toEqual([]); }); @@ -353,8 +684,12 @@ describe("top-level keys that are not sections", { timeout: 60_000 }, () => { const cli = createTestCli({ commands: { show: showCommand(toySection()) }, loadConfig: async () => ({ - path: "/host/picked/this.config.ts", - sections: { toy: { greeting: "hi" } }, + files: [ + { + path: "/host/picked/this.config.ts", + sections: { toy: { greeting: "hi" } }, + }, + ], diagnostics: [], }), }); @@ -388,8 +723,12 @@ describe("--config", { timeout: 60_000 }, () => { test("the named file is loaded instead of the one in cwd", async () => { const loaded = await loadConfig(join(FIXTURES, "marked"), OUTSIDE); expect(loaded).toEqual({ - path: OUTSIDE, - sections: { toy: { greeting: "from the named file" } }, + files: [ + { + path: OUTSIDE, + sections: { toy: { greeting: "from the named file" } }, + }, + ], diagnostics: [], }); }); @@ -399,8 +738,8 @@ describe("--config", { timeout: 60_000 }, () => { FIXTURES, join("named", "elsewhere.config.ts"), ); - expect(loaded.path).toBe(OUTSIDE); - expect(loaded.sections).toEqual({ + expect(loaded.files[0]?.path).toBe(OUTSIDE); + expect(loaded.files[0]?.sections).toEqual({ toy: { greeting: "from the named file" }, }); }); @@ -408,7 +747,7 @@ describe("--config", { timeout: 60_000 }, () => { test("a named file that does not exist is an error, not an empty config", async () => { const missing = join(FIXTURES, "named", "nope.config.ts"); const loaded = await loadConfig(FIXTURES, missing); - expect(loaded.sections).toEqual({}); + expect(loaded.files).toEqual([]); expect(loaded.diagnostics).toEqual([ { section: null, @@ -421,7 +760,7 @@ describe("--config", { timeout: 60_000 }, () => { { kind: "user-choice", label: - "Correct the path passed to --config, or drop the flag to use the prisma.config.ts in the current directory.", + "Correct the path passed to --config, or drop the flag to let the CLI discover prisma.config.ts from the current directory.", }, ], where: { path: missing }, @@ -432,8 +771,7 @@ describe("--config", { timeout: 60_000 }, () => { test("without the flag, a missing prisma.config.ts stays an empty config", async () => { expect(await loadConfig(join(FIXTURES, "named"))).toEqual({ - path: join(FIXTURES, "named", "prisma.config.ts"), - sections: {}, + files: [], diagnostics: [], }); }); @@ -455,8 +793,12 @@ describe("--config", { timeout: 60_000 }, () => { const cli = createTestCli({ commands: { show: showCommand(toySection()) }, loadConfig: async (configPath) => ({ - path: configPath ?? "prisma.config.ts", - sections: { toy: { greeting: 5 } }, + files: [ + { + path: configPath ?? "prisma.config.ts", + sections: { toy: { greeting: 5 } }, + }, + ], diagnostics: [], }), }); @@ -482,7 +824,7 @@ describe("a relative cwd", { timeout: 60_000 }, () => { test("discovery reads the config under a relative cwd", async () => { const loaded = await loadConfig(join(RELATIVE_FIXTURES, "marked")); - expect(loaded.sections).toEqual({ + expect(loaded.files[0]?.sections).toEqual({ toy: { greeting: "hello" }, other: { level: 2 }, }); @@ -493,14 +835,14 @@ describe("a relative cwd", { timeout: 60_000 }, () => { RELATIVE_FIXTURES, join("named", "elsewhere.config.ts"), ); - expect(loaded.sections).toEqual({ + expect(loaded.files[0]?.sections).toEqual({ toy: { greeting: "from the named file" }, }); }); test("the path a diagnostic reports is absolute even so", async () => { const loaded = await loadConfig(join(RELATIVE_FIXTURES, "unmarked")); - expect(loaded.path).toBe(join(FIXTURES, "unmarked", "prisma.config.ts")); + expect(loaded.files).toEqual([]); expect(loaded.diagnostics[0]?.diagnostic.where).toEqual({ path: join(FIXTURES, "unmarked", "prisma.config.ts"), }); @@ -538,14 +880,14 @@ describe("NODE_ENV does not change the effective config", { const loaded = await underNodeEnv("production", () => loadConfig(join(FIXTURES, "env-overlay")), ); - expect(loaded.sections.toy).toEqual({ greeting: "plain" }); + expect(loaded.files[0]?.sections.toy).toEqual({ greeting: "plain" }); }); test("a $env block does not overlay a section value", async () => { const loaded = await underNodeEnv("production", () => loadConfig(join(FIXTURES, "env-block")), ); - expect(loaded.sections.toy).toEqual({ greeting: "plain" }); + expect(loaded.files[0]?.sections.toy).toEqual({ greeting: "plain" }); }); /** An empty diagnostics list is the marker being accepted; the unused @@ -556,7 +898,7 @@ describe("NODE_ENV does not change the effective config", { loadConfig(join(FIXTURES, "env-overlay")), ); expect(loaded.diagnostics).toEqual([]); - expect(loaded.sections.$production).toEqual({ + expect(loaded.files[0]?.sections.$production).toEqual({ toy: { greeting: "overlaid by $production" }, }); }); @@ -592,7 +934,7 @@ process.stdout.write("__PROBE__" + JSON.stringify({ loaded, named, missingNamed: { - sections: missing.sections, + files: missing.files, code: missing.diagnostics[0]?.diagnostic.code ?? null, }, })); @@ -609,18 +951,10 @@ export default definePrismaConfig({ toy: { greeting } }); interface ProbeResult { readonly directImportError: string | null; - readonly loaded: { - readonly path: unknown; - readonly sections: unknown; - readonly diagnostics: unknown; - }; - readonly named: { - readonly path: unknown; - readonly sections: unknown; - readonly diagnostics: unknown; - }; + readonly loaded: { readonly files: unknown; readonly diagnostics: unknown }; + readonly named: { readonly files: unknown; readonly diagnostics: unknown }; readonly missingNamed: { - readonly sections: unknown; + readonly files: unknown; readonly code: string | null; }; } @@ -632,7 +966,10 @@ function runProbeOnPlainNode( nodeArgs: string[], ): ProbeResult & { readonly configPath: string; readonly namedPath: string } { mkdirSync(SANDBOX_ROOT, { recursive: true }); - const root = mkdtempSync(join(SANDBOX_ROOT, "plain-node-")); + const root = realpathSync(mkdtempSync(join(SANDBOX_ROOT, "plain-node-"))); + // The sandbox is inside this repository, so it gets its own boundary + // marker: discovery from the project must never walk past it. + writeFileSync(join(root, ".git"), ""); const cwd = join(root, "project"); const elsewhere = join(root, "elsewhere"); mkdirSync(cwd); @@ -665,6 +1002,7 @@ function runProbeOnPlainNode( afterAll(() => { rmSync(SANDBOX_ROOT, { recursive: true, force: true }); + rmSync(join(FIXTURES, ".git"), { force: true }); }); describe("loadConfig on a Node that cannot execute TypeScript", { @@ -682,17 +1020,25 @@ export default definePrismaConfig({ toy: { greeting } }); ); expect(probe.directImportError).toBe("ERR_UNKNOWN_FILE_EXTENSION"); expect(probe.loaded).toEqual({ - path: probe.configPath, - sections: { toy: { greeting: "hello from plain node" } }, + files: [ + { + path: probe.configPath, + sections: { toy: { greeting: "hello from plain node" } }, + }, + ], diagnostics: [], }); expect(probe.named).toEqual({ - path: probe.namedPath, - sections: { toy: { greeting: "from the file --config named" } }, + files: [ + { + path: probe.namedPath, + sections: { toy: { greeting: "from the file --config named" } }, + }, + ], diagnostics: [], }); expect(probe.missingNamed).toEqual({ - sections: {}, + files: [], code: "CLI.CONFIG_NOT_FOUND", }); }); @@ -715,8 +1061,9 @@ export default definePrismaConfig({ toy: { greeting: Level.Verbose } }); // the direct import fails where loadConfig, below, succeeds. expect(probe.directImportError).toEqual(expect.any(String)); expect(probe.loaded).toEqual({ - path: probe.configPath, - sections: { toy: { greeting: "verbose" } }, + files: [ + { path: probe.configPath, sections: { toy: { greeting: "verbose" } } }, + ], diagnostics: [], }); }); @@ -1014,7 +1361,7 @@ describe("needs.config", { timeout: 60_000 }, () => { ); const cli = createTestCli({ commands: { show: showCommand(toySection()) }, - config: loaded.sections, + config: loaded.files[0]?.sections, }); const run = await cli.run(["show"], { isTty: { stdout: true } }); expect(run.exitCode).toBe(0); @@ -1041,8 +1388,12 @@ describe("--config on the command line", { timeout: 60_000 }, () => { loadConfig: async (configPath) => { asked.push(configPath); return { - path: configPath ?? "prisma.config.ts", - sections: { toy: { greeting: "hi" } }, + files: [ + { + path: configPath ?? "prisma.config.ts", + sections: { toy: { greeting: "hi" } }, + }, + ], diagnostics: [], }; }, diff --git a/packages/cli-engine/tests/engine.type-test.ts b/packages/cli-engine/tests/engine.type-test.ts index 86d31a60..62d733f7 100644 --- a/packages/cli-engine/tests/engine.type-test.ts +++ b/packages/cli-engine/tests/engine.type-test.ts @@ -462,8 +462,12 @@ export const invalidMessage: EngineEvent = { // ————————————————————————————————————————————————————————————————————— export const loadedConfig: LoadedConfig = { - path: "/project/prisma.config.ts", - sections: { check: { strict: true } }, + files: [ + { + path: "/project/prisma.config.ts", + sections: { check: { strict: true } }, + }, + ], diagnostics: [{ section: null, diagnostic }], }; @@ -479,9 +483,7 @@ export const runtimeShape: Runtime = { }, onSignal: () => () => {}, loadConfig: async (configPath?: string) => - configPath === undefined - ? { path: "/project/prisma.config.ts", sections: {}, diagnostics: [] } - : loadedConfig, + configPath === undefined ? { files: [], diagnostics: [] } : loadedConfig, managementApi: { baseUrl: "https://test.invalid" }, host: { runtime: { name: "node", version: "v22.12.0" }, diff --git a/packages/cli-engine/tests/environment-credential-manager.test.ts b/packages/cli-engine/tests/environment-credential-manager.test.ts index 29c4bbc6..64f87136 100644 --- a/packages/cli-engine/tests/environment-credential-manager.test.ts +++ b/packages/cli-engine/tests/environment-credential-manager.test.ts @@ -172,8 +172,7 @@ describe("wired as a Runtime's manager", () => { }, onSignal: () => () => {}, loadConfig: async () => ({ - path: "/prisma.config.ts", - sections: {}, + files: [], diagnostics: [], }), credentialManager: new EnvironmentCredentialManager({ env }), diff --git a/packages/cli-engine/tests/execution.test.ts b/packages/cli-engine/tests/execution.test.ts index e6467183..ed9bf9b5 100644 --- a/packages/cli-engine/tests/execution.test.ts +++ b/packages/cli-engine/tests/execution.test.ts @@ -606,8 +606,7 @@ describe("needs preconditions", () => { }, onSignal: () => () => {}, loadConfig: async () => ({ - path: "/prisma.config.ts", - sections: {}, + files: [], diagnostics: [ { section: null, @@ -826,8 +825,7 @@ describe("report() after the handler resolved", () => { }, onSignal: () => () => {}, loadConfig: async () => ({ - path: "/prisma.config.ts", - sections: {}, + files: [], diagnostics: [], }), managementApi: { baseUrl: "https://test.invalid" }, @@ -901,8 +899,7 @@ describe("credentials that cannot be read", () => { }, onSignal: () => () => {}, loadConfig: async () => ({ - path: "/prisma.config.ts", - sections: {}, + files: [], diagnostics: [], }), credentialManager: { diff --git a/packages/cli-engine/tests/lifetimes.test.ts b/packages/cli-engine/tests/lifetimes.test.ts index 842219ea..7e6543f7 100644 --- a/packages/cli-engine/tests/lifetimes.test.ts +++ b/packages/cli-engine/tests/lifetimes.test.ts @@ -274,8 +274,7 @@ describe("the engine owns the double-signal policy", () => { }; }, loadConfig: async () => ({ - path: "/prisma.config.ts", - sections: {}, + files: [], diagnostics: [], }), managementApi: { baseUrl: "https://test.invalid" }, diff --git a/packages/cli-engine/tests/management-api.test.ts b/packages/cli-engine/tests/management-api.test.ts index 224e89cf..006b1e6d 100644 --- a/packages/cli-engine/tests/management-api.test.ts +++ b/packages/cli-engine/tests/management-api.test.ts @@ -108,8 +108,7 @@ function makeRuntime(overrides?: { }, onSignal: () => () => {}, loadConfig: async () => ({ - path: "/prisma.config.ts", - sections: {}, + files: [], diagnostics: [], }), credentialManager: overrides?.credentialManager, diff --git a/packages/cli-engine/tests/prompts.test.ts b/packages/cli-engine/tests/prompts.test.ts index bb0044c7..75a6a885 100644 --- a/packages/cli-engine/tests/prompts.test.ts +++ b/packages/cli-engine/tests/prompts.test.ts @@ -529,8 +529,7 @@ describe("stdin cleanup", () => { }, onSignal: () => () => {}, loadConfig: async () => ({ - path: "/prisma.config.ts", - sections: {}, + files: [], diagnostics: [], }), managementApi: { baseUrl: "https://test.invalid" }, diff --git a/packages/cli-engine/tests/spawn.test.ts b/packages/cli-engine/tests/spawn.test.ts index 2430a362..062b996b 100644 --- a/packages/cli-engine/tests/spawn.test.ts +++ b/packages/cli-engine/tests/spawn.test.ts @@ -1784,8 +1784,7 @@ function controllableRuntime() { }; }, loadConfig: async () => ({ - path: "/prisma.config.ts", - sections: {}, + files: [], diagnostics: [], }), managementApi: { baseUrl: "https://test.invalid" }, diff --git a/packages/cli/e2e/init.e2e.ts b/packages/cli/e2e/init.e2e.ts index 7886f2ef..f7d6a4a7 100644 --- a/packages/cli/e2e/init.e2e.ts +++ b/packages/cli/e2e/init.e2e.ts @@ -181,7 +181,7 @@ describe("prisma init", () => { const loaded = await loadConfig(workdir); expect(loaded.diagnostics).toEqual([]); - expect(loaded.sections.skills).toEqual({ + expect(loaded.files[0]?.sections.skills).toEqual({ agents: ["claude", "cursor", "agents", "devin"], }); }); @@ -212,7 +212,7 @@ describe("prisma init", () => { const reloaded = await loadConfig(workdir); expect(reloaded.diagnostics).toEqual([]); - expect(reloaded.sections.skills).toEqual({ + expect(reloaded.files[0]?.sections.skills).toEqual({ agents: ["claude", "cursor", "agents", "devin"], }); }); diff --git a/packages/cli/src/commands/skills/config.ts b/packages/cli/src/commands/skills/config.ts index bd284328..42f4b537 100644 --- a/packages/cli/src/commands/skills/config.ts +++ b/packages/cli/src/commands/skills/config.ts @@ -146,6 +146,10 @@ export function readSkillsConfig(loaded: { * config file exists — decided with one stat, so a project without a * config never pays the file's TypeScript transpile — or the section * does not validate. + * + * Reads the nearest file of the loader's chain, which keeps this + * exactly the cwd-only behavior it always had; resolving through the + * whole chain comes with the engine-resolver consolidation. */ export async function readProjectSkillsConfig( cwd: string, @@ -158,7 +162,8 @@ export async function readProjectSkillsConfig( if (!existsSync(file)) { return null; } - return readSkillsConfig(await loadConfig(cwd, configPath)); + const loaded = await loadConfig(cwd, configPath); + return readSkillsConfig({ sections: loaded.files[0]?.sections ?? {} }); } export const skillsConfigSection = defineConfigSection({ diff --git a/packages/cli/tests/bin.test.ts b/packages/cli/tests/bin.test.ts index 08262931..7901ebaf 100644 --- a/packages/cli/tests/bin.test.ts +++ b/packages/cli/tests/bin.test.ts @@ -204,11 +204,8 @@ describe("assembleRuntime", () => { expect(runtime.isTty).toEqual({ stdin: true, stdout: true, stderr: false }); expect(runtime.packageManager).toBeUndefined(); expect(runtime.managementApi).toEqual({ baseUrl: "https://api.prisma.io" }); - // resolve, not join: the loader makes the path absolute, and on - // Windows that puts a drive on this cwd. expect(await runtime.loadConfig()).toEqual({ - path: join(resolve("/tmp/bin-test-cwd"), "prisma.config.ts"), - sections: {}, + files: [], diagnostics: [], }); @@ -284,13 +281,17 @@ describe("assembleRuntime", () => { const found = await runtime.loadConfig(NAMED_CONFIG_PATH); expect(found).toEqual({ - path: NAMED_CONFIG_PATH, - sections: { toy: { greeting: "from the named file" } }, + files: [ + { + path: NAMED_CONFIG_PATH, + sections: { toy: { greeting: "from the named file" } }, + }, + ], diagnostics: [], }); const missing = await runtime.loadConfig(`${NAMED_CONFIG_PATH}.gone`); - expect(missing.sections).toEqual({}); + expect(missing.files).toEqual([]); expect(missing.diagnostics[0]?.diagnostic.code).toBe( "CLI.CONFIG_NOT_FOUND", ); diff --git a/packages/cli/tests/fixtures/config/composer-section.config.ts b/packages/cli/tests/fixtures/config/composer-section.config.ts index 3f120904..cbe4c293 100644 --- a/packages/cli/tests/fixtures/config/composer-section.config.ts +++ b/packages/cli/tests/fixtures/config/composer-section.config.ts @@ -1,5 +1,9 @@ import { definePrismaConfig } from "@prisma/cli-engine"; +// parent: false pins the chain to this file alone, so a config file +// appearing in one of this repository's directories above these +// fixtures can never join a test's chain. export default definePrismaConfig({ composer: { configPath: "./named-by-the-section.config.ts" }, + parent: false, }); diff --git a/packages/cli/tests/fixtures/config/elsewhere.config.ts b/packages/cli/tests/fixtures/config/elsewhere.config.ts index a336e2c5..fe2c6045 100644 --- a/packages/cli/tests/fixtures/config/elsewhere.config.ts +++ b/packages/cli/tests/fixtures/config/elsewhere.config.ts @@ -1,5 +1,9 @@ import { definePrismaConfig } from "@prisma/cli-engine"; +// parent: false pins the chain to this file alone, so a config file +// appearing in one of this repository's directories above these +// fixtures can never join a test's chain. export default definePrismaConfig({ toy: { greeting: "from the named file" }, + parent: false, }); From 32a9dab8b484659ab620809978db6149eaefa654 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 12:30:02 +0200 Subject: [PATCH 05/19] Review rework for the chain loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A --config path is resolved through symlinks like the automatic anchor, so a symlinked file yields the same chain and real-path diagnostics as discovery. Discovery skips a directory named prisma.config.ts, matching the isFile check explicit parent links already had. The invalid-parent diagnostic names the offending value instead of its type. The fixture pinning no longer writes a .git marker into the repository — a killed run left it behind and broke git with 'invalid gitfile format'. Every marked fixture now declares parent: false, broken fixtures already fail resolution at themselves, and the absence tests moved to temp directories outside the repository. The skills-reader comment now says what actually happens: ancestors are evaluated, the nearest file supplies the answer. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/config-loader.ts | 28 ++++-- packages/cli-engine/tests/config.test.ts | 85 ++++++++++++++----- .../config/env-block/prisma.config.ts | 4 + .../config/env-overlay/prisma.config.ts | 4 + .../config/extends-key/prisma.config.ts | 4 + .../config/extends-remote/prisma.config.ts | 4 + .../fixtures/config/marked/prisma.config.ts | 4 + .../fixtures/config/named/elsewhere.config.ts | 4 + .../config/passthrough/prisma.config.ts | 4 + .../config/proto-key/prisma.config.ts | 4 + packages/cli/src/commands/skills/config.ts | 8 +- 11 files changed, 124 insertions(+), 29 deletions(-) diff --git a/packages/cli-engine/src/config-loader.ts b/packages/cli-engine/src/config-loader.ts index d204261e..e13e74d8 100644 --- a/packages/cli-engine/src/config-loader.ts +++ b/packages/cli-engine/src/config-loader.ts @@ -162,12 +162,22 @@ function missingNamedFileDiagnostic(path: string): Diagnostic { }; } +/** JSON where it can (true, null, 42, {}); String for what JSON has no + * spelling for (undefined cannot reach here, but bigint or a symbol + * can). */ +function showParentValue(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + function invalidParentDiagnostic(path: string, value: unknown): Diagnostic { - const described = value === null ? "null" : typeof value; return { code: "CLI.CONFIG_PARENT_INVALID", severity: "error", - summary: `${path} has a top-level 'parent' of type ${described}, but parent must be false or a path string.`, + summary: `${path} declares parent: ${showParentValue(value)}, but parent must be false or a path string.`, why: "'parent' controls config discovery: false makes this file the last on the chain, and a path names the next file explicitly. Any other value is a mistake the CLI stops on rather than guesses about.", nextActions: [ { @@ -407,13 +417,19 @@ function dirsAbove(dir: string): string[] { return discoveryDirs(dir).slice(1); } +/** A directory named prisma.config.ts is not a config file, for + * discovery and explicit `parent` links alike. */ +function isFileAt(path: string): boolean { + return existsSync(path) && statSync(path).isFile(); +} + function nextDiscoveredFile( dirs: readonly string[], collected: ReadonlySet, ): string | null { for (const dir of dirs) { const candidate = join(dir, CONFIG_FILE_NAME); - if (existsSync(candidate) && !collected.has(realpathOr(candidate))) { + if (isFileAt(candidate) && !collected.has(realpathOr(candidate))) { return candidate; } } @@ -477,7 +493,7 @@ function followParent( } if (typeof parent === "string") { const target = resolve(dirname(path), parent); - if (!existsSync(target) || !statSync(target).isFile()) { + if (!isFileAt(target)) { return { diagnostic: missingParentDiagnostic(path, target) }; } if (collected.has(realpathOr(target))) { @@ -558,5 +574,7 @@ export async function loadConfig( if (!existsSync(named)) { return failedChain([], missingNamedFileDiagnostic(named)); } - return collectChain({ kind: "file", path: named }, cliVersion); + // Realpath'd like the automatic anchor: a symlinked --config yields + // the same chain and the same real-path diagnostics as discovery. + return collectChain({ kind: "file", path: realpathOr(named) }, cliVersion); } diff --git a/packages/cli-engine/tests/config.test.ts b/packages/cli-engine/tests/config.test.ts index 973465ed..20abed5e 100644 --- a/packages/cli-engine/tests/config.test.ts +++ b/packages/cli-engine/tests/config.test.ts @@ -42,11 +42,11 @@ const TESTS_DIR = dirname(fileURLToPath(import.meta.url)); // symlinks, so the paths it reports are real ones. const FIXTURES = realpathSync(join(TESTS_DIR, "fixtures", "config")); -// A .git marker making the fixtures directory its own repository -// boundary, so no fixture chain can walk into this repository's real -// ancestors and pick up a config file the checkout happens to contain. -// Created at run time: git refuses to track any path named .git. -writeFileSync(join(FIXTURES, ".git"), ""); +// Every marked fixture under FIXTURES declares parent: false, and every +// broken one fails resolution at itself, so no fixture chain can walk +// into this repository's real ancestors and pick up a config file the +// checkout happens to contain. Tests about absence (no file anywhere) +// use temp directories outside the repository instead. const EPOCH = () => new Date(0); const T0 = "1970-01-01T00:00:00.000Z"; @@ -73,11 +73,17 @@ describe("loadConfig", { timeout: 60_000 }, () => { }); }); + /** In a temp directory outside the repository: an anchor with no + * config anywhere is the one shape parent: false cannot pin. */ test("no file at all yields an empty chain and no error — validators own absence", async () => { - expect(await loadConfig(FIXTURES)).toEqual({ - files: [], - diagnostics: [], - }); + const empty = realpathSync( + mkdtempSync(join(tmpdir(), "prisma-config-empty-")), + ); + try { + expect(await loadConfig(empty)).toEqual({ files: [], diagnostics: [] }); + } finally { + rmSync(empty, { recursive: true, force: true }); + } }); test("discovery walks upward: a parent directory's config is the chain from a bare subdirectory", async () => { @@ -437,7 +443,7 @@ describe("chain discovery", { timeout: 60_000 }, () => { expect(loaded.diagnostics[0]?.diagnostic.code).toBe( "CLI.CONFIG_PARENT_INVALID", ); - expect(loaded.diagnostics[0]?.diagnostic.summary).toContain("boolean"); + expect(loaded.diagnostics[0]?.diagnostic.summary).toContain("parent: true"); }); test("a broken file anywhere on the chain fails resolution, naming that file", async () => { @@ -499,6 +505,38 @@ describe("chain discovery", { timeout: 60_000 }, () => { diagnostics: [], }); }); + + test.skipIf(process.platform === "win32")( + "a symlinked --config file resolves to real paths and anchors at the real directory", + async () => { + const base = sandbox(); + const repo = join(base, "repo"); + markRepository(repo); + const rootFile = writeConfig(repo, `root: {}`); + const pkgFile = writeConfig(join(repo, "pkg"), `mine: {}`); + const link = join(base, "link.config.ts"); + symlinkSync(pkgFile, link, "file"); + + expect(await loadConfig(base, link)).toEqual({ + files: [ + { path: pkgFile, sections: { mine: {} } }, + { path: rootFile, sections: { root: {} } }, + ], + diagnostics: [], + }); + }, + ); + + test("a directory named prisma.config.ts is not a config file — discovery skips it", async () => { + const repo = join(sandbox(), "repo"); + markRepository(repo); + const rootFile = writeConfig(repo, `root: {}`); + mkdirSync(join(repo, "pkg", "prisma.config.ts"), { recursive: true }); + + expect( + (await loadConfig(join(repo, "pkg"))).files.map((file) => file.path), + ).toEqual([rootFile]); + }); }); describe("top-level keys that are not sections", { timeout: 60_000 }, () => { @@ -770,10 +808,14 @@ describe("--config", { timeout: 60_000 }, () => { }); test("without the flag, a missing prisma.config.ts stays an empty config", async () => { - expect(await loadConfig(join(FIXTURES, "named"))).toEqual({ - files: [], - diagnostics: [], - }); + const empty = realpathSync( + mkdtempSync(join(tmpdir(), "prisma-config-empty-")), + ); + try { + expect(await loadConfig(empty)).toEqual({ files: [], diagnostics: [] }); + } finally { + rmSync(empty, { recursive: true, force: true }); + } }); /** A diagnostic's summary is the line a user reads first, so it has @@ -946,7 +988,9 @@ const NAMED_CONFIG = `import { definePrismaConfig } from "@prisma/cli-engine"; const greeting: string = "from the file --config named"; -export default definePrismaConfig({ toy: { greeting } }); +// parent: false — the sandbox lives inside this repository, and the +// chain must not walk out of it. +export default definePrismaConfig({ toy: { greeting }, parent: false }); `; interface ProbeResult { @@ -967,9 +1011,6 @@ function runProbeOnPlainNode( ): ProbeResult & { readonly configPath: string; readonly namedPath: string } { mkdirSync(SANDBOX_ROOT, { recursive: true }); const root = realpathSync(mkdtempSync(join(SANDBOX_ROOT, "plain-node-"))); - // The sandbox is inside this repository, so it gets its own boundary - // marker: discovery from the project must never walk past it. - writeFileSync(join(root, ".git"), ""); const cwd = join(root, "project"); const elsewhere = join(root, "elsewhere"); mkdirSync(cwd); @@ -1002,7 +1043,6 @@ function runProbeOnPlainNode( afterAll(() => { rmSync(SANDBOX_ROOT, { recursive: true, force: true }); - rmSync(join(FIXTURES, ".git"), { force: true }); }); describe("loadConfig on a Node that cannot execute TypeScript", { @@ -1014,7 +1054,7 @@ describe("loadConfig on a Node that cannot execute TypeScript", { const greeting: string = "hello from plain node"; -export default definePrismaConfig({ toy: { greeting } }); +export default definePrismaConfig({ toy: { greeting }, parent: false }); `, ["--no-experimental-strip-types"], ); @@ -1051,7 +1091,10 @@ enum Level { Verbose = "verbose", } -export default definePrismaConfig({ toy: { greeting: Level.Verbose } }); +export default definePrismaConfig({ + toy: { greeting: Level.Verbose }, + parent: false, +}); `, [], ); diff --git a/packages/cli-engine/tests/fixtures/config/env-block/prisma.config.ts b/packages/cli-engine/tests/fixtures/config/env-block/prisma.config.ts index cffac66c..564dfb7e 100644 --- a/packages/cli-engine/tests/fixtures/config/env-block/prisma.config.ts +++ b/packages/cli-engine/tests/fixtures/config/env-block/prisma.config.ts @@ -1,6 +1,10 @@ import { definePrismaConfig } from "@prisma/cli-engine"; +// parent: false pins the chain to this file alone: these fixtures +// live inside a real repository, and a config file appearing in a +// directory above them must never join a test's chain. export default definePrismaConfig({ toy: { greeting: "plain" }, $env: { production: { toy: { greeting: "overlaid by $env" } } }, + parent: false, }); diff --git a/packages/cli-engine/tests/fixtures/config/env-overlay/prisma.config.ts b/packages/cli-engine/tests/fixtures/config/env-overlay/prisma.config.ts index 1859a131..cd013150 100644 --- a/packages/cli-engine/tests/fixtures/config/env-overlay/prisma.config.ts +++ b/packages/cli-engine/tests/fixtures/config/env-overlay/prisma.config.ts @@ -1,6 +1,10 @@ import { definePrismaConfig } from "@prisma/cli-engine"; +// parent: false pins the chain to this file alone: these fixtures +// live inside a real repository, and a config file appearing in a +// directory above them must never join a test's chain. export default definePrismaConfig({ toy: { greeting: "plain" }, $production: { toy: { greeting: "overlaid by $production" } }, + parent: false, }); diff --git a/packages/cli-engine/tests/fixtures/config/extends-key/prisma.config.ts b/packages/cli-engine/tests/fixtures/config/extends-key/prisma.config.ts index 5ff404a1..eb79d246 100644 --- a/packages/cli-engine/tests/fixtures/config/extends-key/prisma.config.ts +++ b/packages/cli-engine/tests/fixtures/config/extends-key/prisma.config.ts @@ -2,7 +2,11 @@ import { definePrismaConfig } from "@prisma/cli-engine"; // A string is the form c12 would act on if the loader left its merge // directive enabled: it would read another file in and delete the key. +// parent: false pins the chain to this file alone: these fixtures +// live inside a real repository, and a config file appearing in a +// directory above them must never join a test's chain. export default definePrismaConfig({ extends: "./base.config.ts", values: { list: [1, 2] }, + parent: false, }); diff --git a/packages/cli-engine/tests/fixtures/config/extends-remote/prisma.config.ts b/packages/cli-engine/tests/fixtures/config/extends-remote/prisma.config.ts index 2f9db476..512a9456 100644 --- a/packages/cli-engine/tests/fixtures/config/extends-remote/prisma.config.ts +++ b/packages/cli-engine/tests/fixtures/config/extends-remote/prisma.config.ts @@ -3,7 +3,11 @@ import { definePrismaConfig } from "@prisma/cli-engine"; // A dead local port: if the loader ever let c12 act on this key, the // download would be attempted and would fail here rather than reach a // real host. +// parent: false pins the chain to this file alone: these fixtures +// live inside a real repository, and a config file appearing in a +// directory above them must never join a test's chain. export default definePrismaConfig({ extends: "http://127.0.0.1:1/evil.tar.gz", values: { list: [1, 2] }, + parent: false, }); diff --git a/packages/cli-engine/tests/fixtures/config/marked/prisma.config.ts b/packages/cli-engine/tests/fixtures/config/marked/prisma.config.ts index ce9a2caf..028c2a46 100644 --- a/packages/cli-engine/tests/fixtures/config/marked/prisma.config.ts +++ b/packages/cli-engine/tests/fixtures/config/marked/prisma.config.ts @@ -1,6 +1,10 @@ import { definePrismaConfig } from "@prisma/cli-engine"; +// parent: false pins the chain to this file alone: these fixtures +// live inside a real repository, and a config file appearing in a +// directory above them must never join a test's chain. export default definePrismaConfig({ toy: { greeting: "hello" }, other: { level: 2 }, + parent: false, }); diff --git a/packages/cli-engine/tests/fixtures/config/named/elsewhere.config.ts b/packages/cli-engine/tests/fixtures/config/named/elsewhere.config.ts index a336e2c5..bdff9b67 100644 --- a/packages/cli-engine/tests/fixtures/config/named/elsewhere.config.ts +++ b/packages/cli-engine/tests/fixtures/config/named/elsewhere.config.ts @@ -1,5 +1,9 @@ import { definePrismaConfig } from "@prisma/cli-engine"; +// parent: false pins the chain to this file alone: these fixtures +// live inside a real repository, and a config file appearing in a +// directory above them must never join a test's chain. export default definePrismaConfig({ toy: { greeting: "from the named file" }, + parent: false, }); diff --git a/packages/cli-engine/tests/fixtures/config/passthrough/prisma.config.ts b/packages/cli-engine/tests/fixtures/config/passthrough/prisma.config.ts index fc4a9fc4..41f6a25c 100644 --- a/packages/cli-engine/tests/fixtures/config/passthrough/prisma.config.ts +++ b/packages/cli-engine/tests/fixtures/config/passthrough/prisma.config.ts @@ -1,5 +1,9 @@ import { definePrismaConfig } from "@prisma/cli-engine"; +// parent: false pins the chain to this file alone: these fixtures +// live inside a real repository, and a config file appearing in a +// directory above them must never join a test's chain. export default definePrismaConfig({ values: { list: [1, 2], when: new Date(0) }, + parent: false, }); diff --git a/packages/cli-engine/tests/fixtures/config/proto-key/prisma.config.ts b/packages/cli-engine/tests/fixtures/config/proto-key/prisma.config.ts index b945dff3..00f73a84 100644 --- a/packages/cli-engine/tests/fixtures/config/proto-key/prisma.config.ts +++ b/packages/cli-engine/tests/fixtures/config/proto-key/prisma.config.ts @@ -3,7 +3,11 @@ import { definePrismaConfig } from "@prisma/cli-engine"; // A computed key is the only way to write __proto__ as an ordinary // property. Copied to the loader's own object by assignment it would // run Object.prototype's setter instead, and vanish. +// parent: false pins the chain to this file alone: these fixtures +// live inside a real repository, and a config file appearing in a +// directory above them must never join a test's chain. export default definePrismaConfig({ ["__proto__"]: { injected: true }, toy: { greeting: "hello" }, + parent: false, }); diff --git a/packages/cli/src/commands/skills/config.ts b/packages/cli/src/commands/skills/config.ts index 42f4b537..dc799391 100644 --- a/packages/cli/src/commands/skills/config.ts +++ b/packages/cli/src/commands/skills/config.ts @@ -147,9 +147,11 @@ export function readSkillsConfig(loaded: { * config never pays the file's TypeScript transpile — or the section * does not validate. * - * Reads the nearest file of the loader's chain, which keeps this - * exactly the cwd-only behavior it always had; resolving through the - * whole chain comes with the engine-resolver consolidation. + * loadConfig walks and evaluates the whole ancestor chain; this + * adapter then reads only the nearest file, so the skills answer still + * comes from cwd's own config (ancestors are evaluated but ignored + * here). Resolving through the whole chain comes with the + * engine-resolver consolidation. */ export async function readProjectSkillsConfig( cwd: string, From f801b50bb8808f6d6905ab27230c726eeee56582 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 12:40:29 +0200 Subject: [PATCH 06/19] Sections merge per key over the config chain, with provenance Each needed section now resolves over the loaded chain nearest-first: the engine default merges per key at the section's top level and replaces below (arrays and non-plain objects replace whole), and a section can supply its own merge(parent, child). A section or key written as undefined contributes nothing, so it cannot silently shadow an ancestor's real value. Merging builds fresh objects with fromEntries, so frozen exports are never mutated and __proto__ hygiene carries over. Every resolved value carries provenance: which files declared the section and which file wrote each top-level key. Invalid-section errors now name the declaring file (the nearest one, listing the chain, when several merged), and a required section reports itself missing only when no file on the chain declares it. resolveSectionPath lets sections resolve relative paths against the file that declared them, never cwd. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/config-merge.ts | 168 +++++++++++ packages/cli-engine/src/config-section.ts | 16 +- packages/cli-engine/src/execution/needs.ts | 96 ++++-- packages/cli-engine/src/exports/index.ts | 7 + packages/cli-engine/tests/config.test.ts | 335 ++++++++++++++++++++- packages/cli-engine/tests/engine.test.ts | 3 + 6 files changed, 587 insertions(+), 38 deletions(-) create mode 100644 packages/cli-engine/src/config-merge.ts diff --git a/packages/cli-engine/src/config-merge.ts b/packages/cli-engine/src/config-merge.ts new file mode 100644 index 00000000..c4dc92c9 --- /dev/null +++ b/packages/cli-engine/src/config-merge.ts @@ -0,0 +1,168 @@ +/** + * Per-key resolution of one config section over the loaded chain, and + * the provenance that survives it: which files contributed the resolved + * value, and which file wrote each of its top-level keys, so + * diagnostics name the file to fix and relative paths resolve against + * the file that declared them. + */ +import { dirname, isAbsolute, resolve } from "node:path"; +import type { ConfigSection } from "./config-section"; +import type { LoadedConfigFile } from "./runtime"; + +/** Which files a resolved section value came from. */ +export interface SectionProvenance { + /** Paths of the files declaring the section, nearest first. */ + readonly files: readonly string[]; + /** + * The declaring file per top-level key of the resolved value: the + * nearest file whose raw section wrote that key. A key no file wrote + * (a custom merge produced it) is attributed to the nearest file. + */ + readonly keys: Readonly>; +} + +export interface ResolvedSection { + /** undefined when no file on the chain declares the section — the + * section validator owns absence, exactly as before. */ + readonly value: unknown; + /** The files declaring the section, nearest first. */ + readonly contributors: readonly LoadedConfigFile[]; +} + +/** A file declares a section by writing the key with a value. A key + * written as `undefined` is absent: it neither contributes nor + * shadows an ancestor's real section. */ +function declares(file: LoadedConfigFile, name: string): boolean { + return ( + Object.hasOwn(file.sections, name) && file.sections[name] !== undefined + ); +} + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * The engine's default merge: per key at the section's top level, the + * child's key winning, replacement below — arrays and anything else + * that is not a plain object replace atomically. A key written as + * `undefined` on either side contributes nothing, so it cannot shadow + * the other side's value. Built with fromEntries, never key-by-key + * assignment, for the same `__proto__` hygiene the loader applies; the + * result is always a fresh object, so frozen inputs are never touched. + */ +function mergePerKey(parent: unknown, child: unknown): unknown { + if (!isPlainObject(parent) || !isPlainObject(child)) { + return child; + } + return Object.fromEntries( + [...Object.entries(parent), ...Object.entries(child)].filter( + ([, value]) => value !== undefined, + ), + ); +} + +const PROVENANCE = new WeakMap(); + +function canCarryProvenance(value: unknown): value is object { + return ( + (typeof value === "object" && value !== null) || typeof value === "function" + ); +} + +/** The nearest contributor whose raw section value wrote `key`. */ +function fileDeclaringKey( + name: string, + key: string, + contributors: readonly LoadedConfigFile[], +): string | undefined { + return contributors.find((file) => { + const raw = file.sections[name]; + return ( + isPlainObject(raw) && Object.hasOwn(raw, key) && raw[key] !== undefined + ); + })?.path; +} + +function provenanceOf( + value: unknown, + name: string, + contributors: readonly LoadedConfigFile[], +): SectionProvenance { + const files = contributors.map((file) => file.path); + const keys = isPlainObject(value) + ? Object.fromEntries( + Object.keys(value).map((key) => [ + key, + fileDeclaringKey(name, key, contributors) ?? files[0], + ]), + ) + : {}; + return { files, keys }; +} + +/** + * Resolves `section` over the chain, nearest first: the farthest + * declaring file's raw value, folded under each nearer one with the + * section's own merge or the engine default. The resolved value is + * annotated with provenance, readable via sectionProvenance and + * resolveSectionPath. + */ +export function resolveSectionOverChain( + section: ConfigSection, + files: readonly LoadedConfigFile[], +): ResolvedSection { + const contributors = files.filter((file) => declares(file, section.name)); + if (contributors.length === 0) { + return { value: undefined, contributors }; + } + const merge = section.merge ?? mergePerKey; + const value = contributors + .map((file) => file.sections[section.name]) + .reduceRight((parent, child) => merge(parent, child)); + if (canCarryProvenance(value)) { + PROVENANCE.set(value, provenanceOf(value, section.name, contributors)); + } + return { value, contributors }; +} + +/** The provenance of a value resolveSectionOverChain produced, or + * undefined for any other value. */ +export function sectionProvenance( + value: unknown, +): SectionProvenance | undefined { + return canCarryProvenance(value) ? PROVENANCE.get(value) : undefined; +} + +/** + * Resolves a path found under `key` of a resolved section value against + * the file that declared that key — never against cwd or the nearest + * config file. Sections opt in by resolving their path-valued settings + * through this on the raw value their validator receives; an absolute + * path comes back unchanged. Throws when the value carries no + * provenance, which means it did not come from the engine's section + * resolution — an engine-boundary misuse, not a user error. + */ +export function resolveSectionPath( + sectionValue: unknown, + key: string, + path: string, +): string { + if (isAbsolute(path)) { + return path; + } + const provenance = sectionProvenance(sectionValue); + if (provenance === undefined) { + throw new Error( + "@prisma/cli-engine: resolveSectionPath needs a value the engine resolved from the config chain, and this one carries no provenance", + ); + } + const declaring = Object.hasOwn(provenance.keys, key) + ? provenance.keys[key] + : provenance.files[0]; + return resolve(dirname(declaring), path); +} diff --git a/packages/cli-engine/src/config-section.ts b/packages/cli-engine/src/config-section.ts index 12ee73da..92972951 100644 --- a/packages/cli-engine/src/config-section.ts +++ b/packages/cli-engine/src/config-section.ts @@ -10,6 +10,15 @@ import type { Diagnostic } from "./protocol"; export interface ConfigSection { readonly name: string; readonly validate: (raw: unknown | undefined) => SectionValidation; + /** + * How two files' raw values combine when more than one file on the + * config chain declares the section. `child` is the nearer file's + * value and wins conflicts. Both inputs may be frozen, so the result + * must be a fresh value, never a mutation of either. Absent, the + * engine merges per key at the section's top level and replaces + * below. + */ + readonly merge?: (parent: unknown, child: unknown) => unknown; } /** @@ -28,6 +37,11 @@ export type SectionValidation = export function defineConfigSection(spec: { readonly name: string; readonly validate: (raw: unknown | undefined) => SectionValidation; + readonly merge?: (parent: unknown, child: unknown) => unknown; }): ConfigSection { - return Object.freeze({ name: spec.name, validate: spec.validate }); + return Object.freeze({ + name: spec.name, + validate: spec.validate, + merge: spec.merge, + }); } diff --git a/packages/cli-engine/src/execution/needs.ts b/packages/cli-engine/src/execution/needs.ts index e3a7156d..1b130f7e 100644 --- a/packages/cli-engine/src/execution/needs.ts +++ b/packages/cli-engine/src/execution/needs.ts @@ -2,6 +2,7 @@ import { createRequire } from "node:module"; import { resolve } from "node:path"; import type { AnyCommand } from "../commands"; import { CONFIG_FILE_NAME } from "../config-loader"; +import { resolveSectionOverChain } from "../config-merge"; import type { ConfigSection, SectionValidation } from "../config-section"; import { credentialsRequiredError } from "../credential-errors"; import type { ActiveCredential } from "../credential-manager"; @@ -11,7 +12,7 @@ import { resolvePackageManager, } from "../package-manager"; import { CliStructuredError, type Diagnostic } from "../protocol"; -import type { LoadedConfig } from "../runtime"; +import type { LoadedConfig, LoadedConfigFile } from "../runtime"; import type { Invocation } from "./engine"; import { makePaint } from "./palette"; import { withDocsUrl, writeDiagnostic } from "./rendering"; @@ -268,33 +269,22 @@ async function checkConfiguration( fileLevel.slice(1), ); } - return validateConfigSection( - section, - loaded, - invocation, - configPath ?? CONFIG_FILE_NAME, - ); + return validateConfigSection(section, loaded, invocation); } -/** Validates the command's needed config section. The validator - * owns absence (it receives undefined when the section is missing) and - * never throws — a throw is an engine-boundary bug, settled as one. - * `configFile` is named in the error so a run under --config points at - * the file it actually read. */ +/** Validates the command's needed config section, resolved per key over + * the chain nearest-first. The validator owns absence (it receives + * undefined when no file declares the section) and never throws — a + * throw is an engine-boundary bug, settled as one. */ function validateConfigSection( section: ConfigSection, loaded: LoadedConfig, invocation: Invocation, - configFile: string, ): NeedsOutcome { - // The nearest file declaring the section supplies it whole; per-key - // merging over the chain arrives with ConfigSection.merge. - const raw = loaded.files.find((file) => - Object.hasOwn(file.sections, section.name), - )?.sections[section.name]; + const resolved = resolveSectionOverChain(section, loaded.files); let validation: SectionValidation; try { - validation = section.validate(raw); + validation = section.validate(resolved.value); } catch (cause) { return { kind: "bug", @@ -306,19 +296,7 @@ function validateConfigSection( } if (!validation.ok) { return needsErrored( - new CliStructuredError( - "CLI.CONFIG_SECTION_INVALID", - `The '${section.name}' section of ${configFile} is invalid.`, - { - nextActions: [ - { - kind: "user-choice", - label: - "Fix the reported problems in that section, then run the command again.", - }, - ], - }, - ), + sectionInvalidError(section.name, resolved.contributors, loaded.files), validation.diagnostics, ); } @@ -326,6 +304,60 @@ function validateConfigSection( return { kind: "ok", config: validation.value, spawnCredential: undefined }; } +/** Provenance decides which file the error names: the one declaring + * file, the nearest of several with the chain listed, or — when no + * file declares the section at all — the fact that it is missing. */ +function sectionInvalidError( + name: string, + contributors: readonly LoadedConfigFile[], + files: readonly LoadedConfigFile[], +): CliStructuredError { + const fix = { + kind: "user-choice" as const, + label: + "Fix the reported problems in that section, then run the command again.", + }; + if (contributors.length === 1) { + return new CliStructuredError( + "CLI.CONFIG_SECTION_INVALID", + `The '${name}' section of ${contributors[0].path} is invalid.`, + { nextActions: [fix] }, + ); + } + if (contributors.length > 1) { + const paths = contributors.map((file) => file.path); + return new CliStructuredError( + "CLI.CONFIG_SECTION_INVALID", + `The '${name}' section, merged from ${paths[0]} and its parent config files, is invalid.`, + { + why: `The resolved section combines these files, nearest first: ${paths.join(", ")}.`, + nextActions: [fix], + }, + ); + } + const target = files[0]?.path ?? CONFIG_FILE_NAME; + return new CliStructuredError( + "CLI.CONFIG_SECTION_INVALID", + `The '${name}' section is missing: ${ + files.length === 0 + ? "no config file was found" + : "no loaded config file declares it" + }.`, + { + why: + files.length === 0 + ? undefined + : `Config files loaded, nearest first: ${files.map((file) => file.path).join(", ")}.`, + nextActions: [ + { + kind: "user-choice", + label: `Declare the '${name}' section in ${target}, then run the command again.`, + }, + ], + }, + ); +} + /** Diagnostics on an OK validation are warnings: written to stderr as * commentary in both formats (stderr is free for commentary in json * mode), filtered by the active log level, never added to the stream diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index d72efcc6..cca0a51f 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -48,6 +48,13 @@ export { definePrismaConfig, loadConfig, } from "../config-loader"; +export { + type ResolvedSection, + resolveSectionOverChain, + resolveSectionPath, + type SectionProvenance, + sectionProvenance, +} from "../config-merge"; export { type ConfigSection, defineConfigSection, diff --git a/packages/cli-engine/tests/config.test.ts b/packages/cli-engine/tests/config.test.ts index 20abed5e..1c7ca124 100644 --- a/packages/cli-engine/tests/config.test.ts +++ b/packages/cli-engine/tests/config.test.ts @@ -15,7 +15,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join, relative } from "node:path"; +import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { type ConfigSection, @@ -30,10 +30,13 @@ import { PRISMA_CONFIG_VERSION, positional, type Runtime, + resolveSectionOverChain, + resolveSectionPath, type SectionValidation, + sectionProvenance, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; -import { createTestCli } from "@prisma/cli-engine/testing"; +import { createTestCli, type TestCli } from "@prisma/cli-engine/testing"; import { afterAll, describe, expect, test } from "vitest"; const TESTS_DIR = dirname(fileURLToPath(import.meta.url)); @@ -633,9 +636,8 @@ describe("top-level keys that are not sections", { timeout: 60_000 }, () => { expect(run.stderr).toContain("/repo/prisma.config.ts"); }); - /** Until sections merge per key, the nearest file declaring the - * section supplies it whole — and one declared nowhere near falls - * through to an ancestor. */ + /** Fall-through: a section only an ancestor declares resolves to + * that ancestor's value, whole. */ test("a section declared only by an ancestor file still reaches the handler", async () => { const cli = createTestCli({ commands: { show: showCommand(toySection()) }, @@ -1412,6 +1414,329 @@ describe("needs.config", { timeout: 60_000 }, () => { }); }); +/** + * Per-key resolution of a section over the chain: the nearest file's + * keys win, unwritten keys fall through to ancestors, values below the + * section's top level replace whole, and the resolved value carries + * provenance naming the file each key came from. + */ +describe("sections merge per key over the chain", { timeout: 60_000 }, () => { + const PKG = "/repo/pkg/prisma.config.ts"; + const ROOT = "/repo/prisma.config.ts"; + + function chainFiles( + pkg: Readonly>, + root: Readonly>, + ) { + return [ + { path: PKG, sections: pkg }, + { path: ROOT, sections: root }, + ]; + } + + function passthroughSection( + merge?: (parent: unknown, child: unknown) => unknown, + ): ConfigSection { + return defineConfigSection({ + name: "toy", + validate: (raw) => ({ ok: true, value: raw, diagnostics: [] }), + merge, + }); + } + + /** Fails on absence, the way the ORM section does. */ + function requiredSection(): ConfigSection { + return defineConfigSection({ + name: "toy", + validate: (raw) => + raw === undefined + ? { + ok: false, + diagnostics: [ + { + code: "TOY.MISSING", + severity: "error", + summary: "A toy section is required.", + nextActions: [], + }, + ], + } + : { ok: true, value: raw, diagnostics: [] }, + }); + } + + function presentCommand(section: ConfigSection) { + return defineCommand({ + help: { summary: "Present the resolved toy config" }, + needs: { config: section }, + handler: async (_args, ctx) => + ok( + ctx.present( + { data: ctx.config }, + { + human: () => [], + stdout: () => [], + json: () => ctx.config, + next: () => [], + }, + ), + ), + }); + } + + function chainCli( + files: ReturnType, + section: ConfigSection, + ) { + return createTestCli({ + commands: { show: presentCommand(section) }, + loadConfig: async () => ({ files, diagnostics: [] }), + }); + } + + function erroredEnvelope(run: Awaited>) { + const frame = run.json[run.json.length - 1]; + if (frame?.kind !== "result" || frame.envelope.ok) { + throw new Error(`expected an errored result frame, got ${run.stderr}`); + } + return frame.envelope; + } + + test("the nearest file's key shadows the ancestor's; unwritten keys fall through", async () => { + const cli = chainCli( + chainFiles( + { toy: { greeting: "pkg", agents: ["a"] } }, + { toy: { greeting: "root", check: false } }, + ), + passthroughSection(), + ); + const run = await cli.run(["show"], { isTty: { stdout: true } }); + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toEqual({ + greeting: "pkg", + agents: ["a"], + check: false, + }); + }); + + test("below the top level values replace whole: nested objects and arrays come from the nearer file", async () => { + const cli = chainCli( + chainFiles( + { toy: { nested: { a: 1 }, list: [1] } }, + { toy: { nested: { b: 2 }, list: [2, 3], only: "root" } }, + ), + passthroughSection(), + ); + const run = await cli.run(["show"], { isTty: { stdout: true } }); + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toEqual({ + nested: { a: 1 }, + list: [1], + only: "root", + }); + }); + + test("a section written as undefined does not shadow an ancestor's section", async () => { + const cli = chainCli( + chainFiles({ toy: undefined }, { toy: { greeting: "root" } }), + passthroughSection(), + ); + const run = await cli.run(["show"], { isTty: { stdout: true } }); + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toEqual({ greeting: "root" }); + }); + + test("a key written as undefined does not shadow the ancestor's key", async () => { + const cli = chainCli( + chainFiles( + { toy: { greeting: undefined, extra: 1 } }, + { toy: { greeting: "root" } }, + ), + passthroughSection(), + ); + const run = await cli.run(["show"], { isTty: { stdout: true } }); + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toEqual({ greeting: "root", extra: 1 }); + }); + + test("a section's own merge() replaces the default, folded from the farthest file to the nearest", async () => { + const concat = (parent: unknown, child: unknown): unknown => ({ + list: [ + ...(parent as { readonly list: readonly number[] }).list, + ...(child as { readonly list: readonly number[] }).list, + ], + }); + const cli = createTestCli({ + commands: { show: presentCommand(passthroughSection(concat)) }, + loadConfig: async () => ({ + files: [ + { + path: "/repo/a/b/prisma.config.ts", + sections: { toy: { list: [3] } }, + }, + { + path: "/repo/a/prisma.config.ts", + sections: { toy: { list: [2] } }, + }, + { path: ROOT, sections: { toy: { list: [1] } } }, + ], + diagnostics: [], + }), + }); + const run = await cli.run(["show"], { isTty: { stdout: true } }); + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toEqual({ list: [1, 2, 3] }); + }); + + test("merging constructs fresh objects and never mutates the frozen file exports", async () => { + const pkgToy = Object.freeze({ greeting: "pkg" }); + const rootToy = Object.freeze({ + greeting: "root", + check: Object.freeze({ deep: true }), + }); + const cli = chainCli( + chainFiles({ toy: pkgToy }, { toy: rootToy }), + passthroughSection(), + ); + const run = await cli.run(["show"], { isTty: { stdout: true } }); + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toEqual({ + greeting: "pkg", + check: { deep: true }, + }); + expect(pkgToy).toEqual({ greeting: "pkg" }); + expect(rootToy).toEqual({ greeting: "root", check: { deep: true } }); + }); + + test("a required section's absence error fires only when no file on the chain declares it", async () => { + const present = chainCli( + chainFiles({}, { toy: { greeting: "root" } }), + requiredSection(), + ); + const withAncestor = await present.run(["show"], { + isTty: { stdout: true }, + }); + expect(withAncestor.exitCode).toBe(0); + + const absent = chainCli(chainFiles({}, {}), requiredSection()); + const run = await absent.run(["show", "--json"]); + expect(run.exitCode).toBe(2); + const envelope = erroredEnvelope(run); + expect(envelope.error.code).toBe("CLI.CONFIG_SECTION_INVALID"); + expect(envelope.error.summary).toBe( + "The 'toy' section is missing: no loaded config file declares it.", + ); + expect(envelope.error.why).toBe( + `Config files loaded, nearest first: ${PKG}, ${ROOT}.`, + ); + expect(envelope.error.nextActions).toEqual([ + { + kind: "user-choice", + label: `Declare the 'toy' section in ${PKG}, then run the command again.`, + }, + ]); + expect(envelope.diagnostics[0]?.code).toBe("TOY.MISSING"); + }); + + test("an invalid merged section names the nearest contributing file and lists the chain", async () => { + const cli = chainCli( + chainFiles({ toy: { extra: true } }, { toy: { greeting: 5 } }), + toySection(), + ); + const run = await cli.run(["show", "--json"]); + expect(run.exitCode).toBe(2); + const envelope = erroredEnvelope(run); + expect(envelope.error.summary).toBe( + `The 'toy' section, merged from ${PKG} and its parent config files, is invalid.`, + ); + expect(envelope.error.why).toBe( + `The resolved section combines these files, nearest first: ${PKG}, ${ROOT}.`, + ); + }); + + test("an invalid section only an ancestor declares names that file, not the anchor", async () => { + const cli = chainCli( + chainFiles({}, { toy: { greeting: 5 } }), + toySection(), + ); + const run = await cli.run(["show", "--json"]); + expect(run.exitCode).toBe(2); + const envelope = erroredEnvelope(run); + expect(envelope.error.summary).toBe( + `The 'toy' section of ${ROOT} is invalid.`, + ); + expect(envelope.error.why).toBeUndefined(); + }); + + test("sectionProvenance records the contributors and the declaring file per key", () => { + const resolved = resolveSectionOverChain(passthroughSection(), [ + { path: PKG, sections: { toy: { out: "./dist", shared: 1 } } }, + { + path: ROOT, + sections: { toy: { migrations: "./migrations", shared: 2 } }, + }, + ]); + expect(sectionProvenance(resolved.value)).toEqual({ + files: [PKG, ROOT], + keys: { out: PKG, shared: PKG, migrations: ROOT }, + }); + }); + + test("resolveSectionPath resolves a relative path against the file that declared its key", () => { + const resolved = resolveSectionOverChain(passthroughSection(), [ + { path: PKG, sections: { toy: { out: "./dist" } } }, + { path: ROOT, sections: { toy: { migrations: "./migrations" } } }, + ]); + expect( + resolveSectionPath(resolved.value, "migrations", "./migrations"), + ).toBe(resolve("/repo", "migrations")); + expect(resolveSectionPath(resolved.value, "out", "./dist")).toBe( + resolve("/repo/pkg", "dist"), + ); + const absolute = resolve("/somewhere/else"); + expect(resolveSectionPath(resolved.value, "out", absolute)).toBe(absolute); + }); + + test("resolveSectionPath refuses a value the engine did not resolve", () => { + expect(() => resolveSectionPath({ out: "./x" }, "out", "./x")).toThrow( + "carries no provenance", + ); + }); + + /** The spec's primary layout, from disk: a root config and a package + * config, discovered and merged through the real loader. The temp + * tree carries its own .git marker, so the walk never reaches this + * repository's real ancestors. */ + test("the two-file monorepo layout resolves merged from disk, end to end", async () => { + const base = realpathSync( + mkdtempSync(join(tmpdir(), "prisma-config-merge-")), + ); + try { + const repo = join(base, "repo"); + mkdirSync(join(repo, ".git"), { recursive: true }); + writeFileSync( + join(repo, "prisma.config.ts"), + `export default { $prismaConfig: ${PRISMA_CONFIG_VERSION}, toy: { greeting: "root", check: false } };\n`, + ); + const pkg = join(repo, "packages", "db"); + mkdirSync(pkg, { recursive: true }); + writeFileSync( + join(pkg, "prisma.config.ts"), + `export default { $prismaConfig: ${PRISMA_CONFIG_VERSION}, toy: { greeting: "pkg" } };\n`, + ); + const cli = createTestCli({ + commands: { show: presentCommand(passthroughSection()) }, + loadConfig: (configPath) => loadConfig(pkg, configPath), + }); + const run = await cli.run(["show"], { isTty: { stdout: true } }); + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toEqual({ greeting: "pkg", check: false }); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); +}); + /** * `--config` is an ordinary engine-injected global flag, so the parser * owns its grammar. These pin what the parser does with the four diff --git a/packages/cli-engine/tests/engine.test.ts b/packages/cli-engine/tests/engine.test.ts index b663fd60..981238e6 100644 --- a/packages/cli-engine/tests/engine.test.ts +++ b/packages/cli-engine/tests/engine.test.ts @@ -41,6 +41,9 @@ describe("main export", () => { "noSessionForWorkspaceError", "positional", "readActiveAccessToken", + "resolveSectionOverChain", + "resolveSectionPath", + "sectionProvenance", "telemetryCommandGroup", ]); }); From 350d1c50f141b841975e07979c5752d403b41729 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 12:49:48 +0200 Subject: [PATCH 07/19] Harden section resolution: guarded reads, strict path lookup, frozen results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each contributor's section value is now read exactly once, inside a guard: a throwing property getter settles as a config error naming the file instead of an internal bug, and provenance is computed from the snapshot rather than re-reading values per key. resolveSectionOverChain returns an ok/error result to carry that classification. resolveSectionPath now throws on a key the resolved section does not carry at its top level — a silent nearest-file fallback could resolve a nested path against the wrong directory. Provenance is keyed per section name, so one object reused by two sections cannot collide, and sectionProvenance/resolveSectionPath take the section name. A single-file section passes through the same per-key normalization as a merged one, so a key written undefined is absent either way, and every plain-object result is a fresh, frozen object. The merged-section error copy counts its parents, and the merged object's __proto__ hygiene is pinned by a test. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/config-merge.ts | 207 +++++++++++++-------- packages/cli-engine/src/execution/needs.ts | 32 +++- packages/cli-engine/tests/config.test.ts | 92 +++++++-- 3 files changed, 241 insertions(+), 90 deletions(-) diff --git a/packages/cli-engine/src/config-merge.ts b/packages/cli-engine/src/config-merge.ts index c4dc92c9..8b2532b5 100644 --- a/packages/cli-engine/src/config-merge.ts +++ b/packages/cli-engine/src/config-merge.ts @@ -4,6 +4,10 @@ * value, and which file wrote each of its top-level keys, so * diagnostics name the file to fix and relative paths resolve against * the file that declared them. + * + * A section's value is user code — a property getter can throw or side + * effect — so each contributor's value is read exactly once, inside a + * guard that turns a throw into a config error naming that file. */ import { dirname, isAbsolute, resolve } from "node:path"; import type { ConfigSection } from "./config-section"; @@ -21,22 +25,23 @@ export interface SectionProvenance { readonly keys: Readonly>; } -export interface ResolvedSection { - /** undefined when no file on the chain declares the section — the - * section validator owns absence, exactly as before. */ - readonly value: unknown; - /** The files declaring the section, nearest first. */ - readonly contributors: readonly LoadedConfigFile[]; -} - -/** A file declares a section by writing the key with a value. A key - * written as `undefined` is absent: it neither contributes nor - * shadows an ancestor's real section. */ -function declares(file: LoadedConfigFile, name: string): boolean { - return ( - Object.hasOwn(file.sections, name) && file.sections[name] !== undefined - ); -} +export type ResolvedSection = + | { + readonly ok: true; + /** undefined when no file on the chain declares the section — the + * section validator owns absence, exactly as before. */ + readonly value: unknown; + /** The files declaring the section, nearest first. */ + readonly contributors: readonly LoadedConfigFile[]; + } + | { + /** Reading the section's value threw: a property getter in the + * config file is user code, so this is a config error naming + * the file, never an engine bug. */ + readonly ok: false; + readonly file: string; + readonly cause: unknown; + }; function isPlainObject(value: unknown): value is Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -47,26 +52,60 @@ function isPlainObject(value: unknown): value is Record { } /** - * The engine's default merge: per key at the section's top level, the - * child's key winning, replacement below — arrays and anything else - * that is not a plain object replace atomically. A key written as - * `undefined` on either side contributes nothing, so it cannot shadow - * the other side's value. Built with fromEntries, never key-by-key - * assignment, for the same `__proto__` hygiene the loader applies; the - * result is always a fresh object, so frozen inputs are never touched. + * One file's declared value, read exactly once. A plain object is + * snapshotted into a fresh object of its defined entries — a key + * written `undefined` is absent, on a single-file chain and a merged + * one alike, so it neither appears nor shadows an ancestor's key. + * Anything else is carried atomically. A section written `undefined` + * does not contribute at all. */ +interface Contribution { + readonly file: LoadedConfigFile; + readonly value: unknown; + /** The snapshot's top-level keys; null when the value is not a + * plain object. */ + readonly keys: ReadonlySet | null; +} + +function snapshotContribution( + file: LoadedConfigFile, + name: string, +): Contribution | null { + if (!Object.hasOwn(file.sections, name)) { + return null; + } + const raw = file.sections[name]; + if (raw === undefined) { + return null; + } + if (!isPlainObject(raw)) { + return { file, value: raw, keys: null }; + } + const entries = Object.entries(raw).filter( + ([, value]) => value !== undefined, + ); + return { + file, + value: Object.fromEntries(entries), + keys: new Set(entries.map(([key]) => key)), + }; +} + +/** The engine's default merge: per key at the section's top level, the + * child's key winning, replacement below. fromEntries rather than + * key-by-key assignment: assigning a key named `__proto__` would run + * the prototype setter instead of creating an own property. */ function mergePerKey(parent: unknown, child: unknown): unknown { if (!isPlainObject(parent) || !isPlainObject(child)) { return child; } - return Object.fromEntries( - [...Object.entries(parent), ...Object.entries(child)].filter( - ([, value]) => value !== undefined, - ), - ); + return Object.fromEntries([ + ...Object.entries(parent), + ...Object.entries(child), + ]); } -const PROVENANCE = new WeakMap(); +const PROVENANCE = new WeakMap>(); function canCarryProvenance(value: unknown): value is object { return ( @@ -74,80 +113,92 @@ function canCarryProvenance(value: unknown): value is object { ); } -/** The nearest contributor whose raw section value wrote `key`. */ -function fileDeclaringKey( +function registerProvenance( name: string, - key: string, - contributors: readonly LoadedConfigFile[], -): string | undefined { - return contributors.find((file) => { - const raw = file.sections[name]; - return ( - isPlainObject(raw) && Object.hasOwn(raw, key) && raw[key] !== undefined - ); - })?.path; -} - -function provenanceOf( value: unknown, - name: string, - contributors: readonly LoadedConfigFile[], -): SectionProvenance { - const files = contributors.map((file) => file.path); + contributions: readonly Contribution[], +): void { + if (!canCarryProvenance(value)) { + return; + } + const files = contributions.map((contribution) => contribution.file.path); const keys = isPlainObject(value) ? Object.fromEntries( Object.keys(value).map((key) => [ key, - fileDeclaringKey(name, key, contributors) ?? files[0], + contributions.find((contribution) => contribution.keys?.has(key)) + ?.file.path ?? files[0], ]), ) : {}; - return { files, keys }; + const perSection = PROVENANCE.get(value) ?? new Map(); + perSection.set(name, { files, keys }); + PROVENANCE.set(value, perSection); } /** * Resolves `section` over the chain, nearest first: the farthest - * declaring file's raw value, folded under each nearer one with the - * section's own merge or the engine default. The resolved value is - * annotated with provenance, readable via sectionProvenance and - * resolveSectionPath. + * declaring file's value, folded under each nearer one with the + * section's own merge or the engine default. A plain-object result is + * always a fresh, frozen object — the files' exports are never + * mutated — and it is annotated with provenance, readable via + * sectionProvenance and resolveSectionPath. */ export function resolveSectionOverChain( section: ConfigSection, files: readonly LoadedConfigFile[], ): ResolvedSection { - const contributors = files.filter((file) => declares(file, section.name)); - if (contributors.length === 0) { - return { value: undefined, contributors }; + const contributions: Contribution[] = []; + for (const file of files) { + let contribution: Contribution | null; + try { + contribution = snapshotContribution(file, section.name); + } catch (cause) { + return { ok: false, file: file.path, cause }; + } + if (contribution !== null) { + contributions.push(contribution); + } + } + const contributors = contributions.map((contribution) => contribution.file); + if (contributions.length === 0) { + return { ok: true, value: undefined, contributors }; } const merge = section.merge ?? mergePerKey; - const value = contributors - .map((file) => file.sections[section.name]) + const merged = contributions + .map((contribution) => contribution.value) .reduceRight((parent, child) => merge(parent, child)); - if (canCarryProvenance(value)) { - PROVENANCE.set(value, provenanceOf(value, section.name, contributors)); - } - return { value, contributors }; + // Freezing is safe here: every plain object in the fold is a fresh + // snapshot or built from one, never a file's own export. + const value = isPlainObject(merged) ? Object.freeze(merged) : merged; + registerProvenance(section.name, value, contributions); + return { ok: true, value, contributors }; } -/** The provenance of a value resolveSectionOverChain produced, or - * undefined for any other value. */ +/** The provenance of a value resolveSectionOverChain produced for the + * named section, or undefined for any other value. */ export function sectionProvenance( + section: string, value: unknown, ): SectionProvenance | undefined { - return canCarryProvenance(value) ? PROVENANCE.get(value) : undefined; + return canCarryProvenance(value) + ? PROVENANCE.get(value)?.get(section) + : undefined; } /** - * Resolves a path found under `key` of a resolved section value against - * the file that declared that key — never against cwd or the nearest - * config file. Sections opt in by resolving their path-valued settings - * through this on the raw value their validator receives; an absolute - * path comes back unchanged. Throws when the value carries no - * provenance, which means it did not come from the engine's section - * resolution — an engine-boundary misuse, not a user error. + * Resolves a path found under a TOP-LEVEL `key` of a resolved section + * value against the file that declared that key — never against cwd or + * the nearest config file. Sections opt in by resolving their + * path-valued settings through this on the raw value their validator + * receives; an absolute path comes back unchanged. Throws on a value + * that carries no provenance (it did not come from the engine's + * section resolution) and on a key the resolved value does not carry + * at its top level — a silent fallback could resolve against the wrong + * file, which is the mistake this helper exists to prevent. */ export function resolveSectionPath( + section: string, sectionValue: unknown, key: string, path: string, @@ -155,14 +206,16 @@ export function resolveSectionPath( if (isAbsolute(path)) { return path; } - const provenance = sectionProvenance(sectionValue); + const provenance = sectionProvenance(section, sectionValue); if (provenance === undefined) { throw new Error( "@prisma/cli-engine: resolveSectionPath needs a value the engine resolved from the config chain, and this one carries no provenance", ); } - const declaring = Object.hasOwn(provenance.keys, key) - ? provenance.keys[key] - : provenance.files[0]; - return resolve(dirname(declaring), path); + if (!Object.hasOwn(provenance.keys, key)) { + throw new Error( + `@prisma/cli-engine: resolveSectionPath resolves only top-level section keys, and '${key}' is not a top-level key of the resolved '${section}' section`, + ); + } + return resolve(dirname(provenance.keys[key]), path); } diff --git a/packages/cli-engine/src/execution/needs.ts b/packages/cli-engine/src/execution/needs.ts index 1b130f7e..782f1b18 100644 --- a/packages/cli-engine/src/execution/needs.ts +++ b/packages/cli-engine/src/execution/needs.ts @@ -282,6 +282,11 @@ function validateConfigSection( invocation: Invocation, ): NeedsOutcome { const resolved = resolveSectionOverChain(section, loaded.files); + if (!resolved.ok) { + return needsErrored( + sectionUnreadableError(section.name, resolved.file, resolved.cause), + ); + } let validation: SectionValidation; try { validation = section.validate(resolved.value); @@ -304,6 +309,29 @@ function validateConfigSection( return { kind: "ok", config: validation.value, spawnCredential: undefined }; } +/** A property getter in a section's value is user code; a throw while + * reading it is a config error naming the file, never an engine bug. */ +function sectionUnreadableError( + name: string, + file: string, + cause: unknown, +): CliStructuredError { + const message = cause instanceof Error ? cause.message : String(cause); + return new CliStructuredError( + "CLI.CONFIG_SECTION_INVALID", + `The '${name}' section of ${file} is invalid: reading its value threw '${message.split("\n", 1)[0].trim()}'.`, + { + nextActions: [ + { + kind: "user-choice", + label: + "Fix the reported problems in that section, then run the command again.", + }, + ], + }, + ); +} + /** Provenance decides which file the error names: the one declaring * file, the nearest of several with the chain listed, or — when no * file declares the section at all — the fact that it is missing. */ @@ -326,9 +354,11 @@ function sectionInvalidError( } if (contributors.length > 1) { const paths = contributors.map((file) => file.path); + const parents = + paths.length === 2 ? "its parent config file" : "its parent config files"; return new CliStructuredError( "CLI.CONFIG_SECTION_INVALID", - `The '${name}' section, merged from ${paths[0]} and its parent config files, is invalid.`, + `The '${name}' section, merged from ${paths[0]} and ${parents}, is invalid.`, { why: `The resolved section combines these files, nearest first: ${paths.join(", ")}.`, nextActions: [fix], diff --git a/packages/cli-engine/tests/config.test.ts b/packages/cli-engine/tests/config.test.ts index 1c7ca124..a8b773fa 100644 --- a/packages/cli-engine/tests/config.test.ts +++ b/packages/cli-engine/tests/config.test.ts @@ -1647,7 +1647,7 @@ describe("sections merge per key over the chain", { timeout: 60_000 }, () => { expect(run.exitCode).toBe(2); const envelope = erroredEnvelope(run); expect(envelope.error.summary).toBe( - `The 'toy' section, merged from ${PKG} and its parent config files, is invalid.`, + `The 'toy' section, merged from ${PKG} and its parent config file, is invalid.`, ); expect(envelope.error.why).toBe( `The resolved section combines these files, nearest first: ${PKG}, ${ROOT}.`, @@ -1668,39 +1668,107 @@ describe("sections merge per key over the chain", { timeout: 60_000 }, () => { expect(envelope.error.why).toBeUndefined(); }); + function resolvedValue(files: ReturnType): unknown { + const resolved = resolveSectionOverChain(passthroughSection(), files); + if (!resolved.ok) { + throw new Error(`expected resolution to succeed, got ${resolved.file}`); + } + return resolved.value; + } + test("sectionProvenance records the contributors and the declaring file per key", () => { - const resolved = resolveSectionOverChain(passthroughSection(), [ + const value = resolvedValue([ { path: PKG, sections: { toy: { out: "./dist", shared: 1 } } }, { path: ROOT, sections: { toy: { migrations: "./migrations", shared: 2 } }, }, ]); - expect(sectionProvenance(resolved.value)).toEqual({ + expect(sectionProvenance("toy", value)).toEqual({ files: [PKG, ROOT], keys: { out: PKG, shared: PKG, migrations: ROOT }, }); }); + test("a single file's section is normalized like a merged one: undefined keys are absent and the value is frozen", () => { + const value = resolvedValue([ + { path: PKG, sections: { toy: { greeting: undefined, extra: 1 } } }, + ]); + expect(value).toEqual({ extra: 1 }); + expect(Object.hasOwn(value as object, "greeting")).toBe(false); + expect(Object.isFrozen(value)).toBe(true); + }); + + test("an own __proto__ key on the merged section stays data and never reaches the prototype", () => { + const pkgToy = Object.fromEntries([ + ["__proto__", { polluted: true }], + ["greeting", "pkg"], + ]); + const value = resolvedValue( + chainFiles({ toy: pkgToy }, { toy: { greeting: "root", check: false } }), + ) as Record; + expect(Object.hasOwn(value, "__proto__")).toBe(true); + expect(value.greeting).toBe("pkg"); + expect(value.check).toBe(false); + expect(Object.getPrototypeOf(value)).toBe(Object.prototype); + expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); + expect(Object.isFrozen(value)).toBe(true); + }); + + test("a throwing property getter in a section is a config error naming the file, not an engine bug", async () => { + const cli = chainCli( + [ + { + path: PKG, + sections: { + toy: { + get greeting(): string { + throw new Error("getter boom"); + }, + }, + }, + }, + { path: ROOT, sections: {} }, + ], + passthroughSection(), + ); + const run = await cli.run(["show", "--json"]); + expect(run.exitCode).toBe(2); + const envelope = erroredEnvelope(run); + expect(envelope.error.code).toBe("CLI.CONFIG_SECTION_INVALID"); + expect(envelope.error.summary).toBe( + `The 'toy' section of ${PKG} is invalid: reading its value threw 'getter boom'.`, + ); + }); + test("resolveSectionPath resolves a relative path against the file that declared its key", () => { - const resolved = resolveSectionOverChain(passthroughSection(), [ + const value = resolvedValue([ { path: PKG, sections: { toy: { out: "./dist" } } }, { path: ROOT, sections: { toy: { migrations: "./migrations" } } }, ]); - expect( - resolveSectionPath(resolved.value, "migrations", "./migrations"), - ).toBe(resolve("/repo", "migrations")); - expect(resolveSectionPath(resolved.value, "out", "./dist")).toBe( + expect(resolveSectionPath("toy", value, "migrations", "./migrations")).toBe( + resolve("/repo", "migrations"), + ); + expect(resolveSectionPath("toy", value, "out", "./dist")).toBe( resolve("/repo/pkg", "dist"), ); const absolute = resolve("/somewhere/else"); - expect(resolveSectionPath(resolved.value, "out", absolute)).toBe(absolute); + expect(resolveSectionPath("toy", value, "out", absolute)).toBe(absolute); }); test("resolveSectionPath refuses a value the engine did not resolve", () => { - expect(() => resolveSectionPath({ out: "./x" }, "out", "./x")).toThrow( - "carries no provenance", - ); + expect(() => + resolveSectionPath("toy", { out: "./x" }, "out", "./x"), + ).toThrow("carries no provenance"); + }); + + test("resolveSectionPath refuses a key the resolved section does not carry at its top level", () => { + const value = resolvedValue([ + { path: PKG, sections: { toy: { out: "./dist" } } }, + ]); + expect(() => + resolveSectionPath("toy", value, "migrations", "./migrations"), + ).toThrow("only top-level section keys"); }); /** The spec's primary layout, from disk: a root config and a package From 5ba8a31977ab5cb8f0bcbe690ea5bf4f0ea27287 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 12:55:16 +0200 Subject: [PATCH 08/19] Skills reads resolve through the engine's chain resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readProjectSkillsConfig now loads the discovered config chain and resolves the skills section per key with resolveSectionOverChain, so the staleness notice, the post-login tip, and the skills commands agree on the governing config from any subdirectory. The hand-rolled existsSync check and the nearest-file-only adapter are gone; the no-config fast path survives because chain discovery is stat-only until a file exists. Out-of-handler callers keep their tolerant contract: a missing, broken, or invalid config still reads as null. The validator keeps its getter guard — the resolver snapshots only plain objects, and a test proves a class-instance section still needs it. readSkillsConfig, whose only caller was the deleted adapter, is removed. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/commands/skills/config.ts | 67 ++++------ packages/cli/tests/skills-config.test.ts | 138 +++++++++++++++++++++ 2 files changed, 164 insertions(+), 41 deletions(-) create mode 100644 packages/cli/tests/skills-config.test.ts diff --git a/packages/cli/src/commands/skills/config.ts b/packages/cli/src/commands/skills/config.ts index dc799391..ceacb445 100644 --- a/packages/cli/src/commands/skills/config.ts +++ b/packages/cli/src/commands/skills/config.ts @@ -1,6 +1,8 @@ -import { existsSync } from "node:fs"; -import path from "node:path"; -import { defineConfigSection, loadConfig } from "@prisma/cli-engine"; +import { + defineConfigSection, + loadConfig, + resolveSectionOverChain, +} from "@prisma/cli-engine"; import type { Diagnostic } from "@prisma/cli-engine/protocol"; import { type AgentName, @@ -122,50 +124,32 @@ function validateAgents( } /** - * The validated skills section of an already-loaded config, or null - * when the section does not validate. Used by code that runs outside a - * command handler (the staleness check, the post-login tip), which has - * no ctx.config. - * - * Null deliberately collapses "no config" and "config invalid": both - * callers fall back to the default agent set, so a broken config never - * silences the check. The commands that consume the config surface the - * validation error themselves. - */ -export function readSkillsConfig(loaded: { - readonly sections: Readonly>; -}): SkillsConfig | null { - const section = skillsConfigSection.validate( - loaded.sections[SKILLS_CONFIG_SECTION_NAME], - ); - return section.ok ? section.value : null; -} - -/** - * The project's skills settings from prisma.config.ts, or null when no - * config file exists — decided with one stat, so a project without a - * config never pays the file's TypeScript transpile — or the section - * does not validate. + * The project's skills settings, resolved per key over the discovered + * config chain exactly as the skills commands resolve them, so callers + * that run outside a command handler (the staleness check, the + * post-login tip) agree with the commands on the governing config from + * any directory. Chain discovery is stat-only until a file exists, so + * a project without a config never pays a TypeScript transpile. * - * loadConfig walks and evaluates the whole ancestor chain; this - * adapter then reads only the nearest file, so the skills answer still - * comes from cwd's own config (ancestors are evaluated but ignored - * here). Resolving through the whole chain comes with the - * engine-resolver consolidation. + * Null deliberately collapses "no config" and "config broken or + * invalid": both callers fall back to the default agent set, so a + * broken config never silences the check. The commands that consume + * the config surface the error themselves. */ export async function readProjectSkillsConfig( cwd: string, configPath?: string, ): Promise { - const file = - configPath === undefined - ? path.join(cwd, "prisma.config.ts") - : path.resolve(cwd, configPath); - if (!existsSync(file)) { + const loaded = await loadConfig(cwd, configPath); + if (loaded.diagnostics.length > 0 || loaded.files.length === 0) { return null; } - const loaded = await loadConfig(cwd, configPath); - return readSkillsConfig({ sections: loaded.files[0]?.sections ?? {} }); + const resolved = resolveSectionOverChain(skillsConfigSection, loaded.files); + if (!resolved.ok) { + return null; + } + const section = skillsConfigSection.validate(resolved.value); + return section.ok ? section.value : null; } export const skillsConfigSection = defineConfigSection({ @@ -177,8 +161,9 @@ export const skillsConfigSection = defineConfigSection({ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { return { ok: false, diagnostics: [invalidSection(raw)] }; } - // Reading a property can throw — a config file is user code, and - // may hand over an object whose getter does. + // The engine's chain resolver snapshots plain objects, but carries + // any other object (a class instance) atomically, so its getters — + // user code that can throw — first run here. let check: unknown; let rawAgents: unknown; try { diff --git a/packages/cli/tests/skills-config.test.ts b/packages/cli/tests/skills-config.test.ts new file mode 100644 index 00000000..4160bf06 --- /dev/null +++ b/packages/cli/tests/skills-config.test.ts @@ -0,0 +1,138 @@ +/** + * readProjectSkillsConfig resolves the skills section over the + * discovered config chain, so the out-of-handler callers (the + * staleness check, the post-login tip) agree with the skills commands + * on the governing config from any directory. Every fixture tree lives + * in its own temp directory with its own .git marker, so a walk never + * reaches this repository's checkout. + */ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { PRISMA_CONFIG_VERSION } from "@prisma/cli-engine"; +import { describe, expect, it } from "vitest"; + +import { readProjectSkillsConfig } from "../src/commands/skills/config"; +import { DEFAULT_AGENTS } from "../src/lib/skills/allowlist"; +import { makeProjectRoot } from "./helpers/skills-fixture"; + +async function makeRepoRoot(): Promise { + const root = await makeProjectRoot("config-"); + await mkdir(path.join(root, ".git"), { recursive: true }); + return root; +} + +/** The fixture files carry the version marker literally: a bare temp + * directory has no node_modules to resolve definePrismaConfig from. */ +async function writeConfig(dir: string, body: string): Promise { + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, "prisma.config.ts"), + `export default { $prismaConfig: ${PRISMA_CONFIG_VERSION}, ${body} };\n`, + "utf8", + ); +} + +describe("readProjectSkillsConfig over the config chain", () => { + it("reads a root config's skills section from a subdirectory", async () => { + const root = await makeRepoRoot(); + await writeConfig(root, `skills: { check: false }`); + const sub = path.join(root, "packages", "db"); + await mkdir(sub, { recursive: true }); + + expect(await readProjectSkillsConfig(sub)).toEqual({ + check: false, + agents: DEFAULT_AGENTS, + agentsConfigured: false, + }); + }); + + it("merges a subdirectory's override per key with the root", async () => { + const root = await makeRepoRoot(); + await writeConfig(root, `skills: { check: false, agents: ["claude"] }`); + const sub = path.join(root, "packages", "db"); + await writeConfig(sub, `skills: { agents: ["cursor"] }`); + + expect(await readProjectSkillsConfig(sub)).toEqual({ + check: false, + agents: ["cursor"], + agentsConfigured: true, + }); + }); + + it("reads a broken file anywhere on the chain as no config, even when the nearest file is fine", async () => { + const root = await makeRepoRoot(); + await mkdir(path.join(root, "packages", "db"), { recursive: true }); + await writeFile( + path.join(root, "prisma.config.ts"), + 'throw new Error("broken root config");\n', + "utf8", + ); + await writeConfig( + path.join(root, "packages", "db"), + `skills: { check: false }`, + ); + + expect( + await readProjectSkillsConfig(path.join(root, "packages", "db")), + ).toBeNull(); + }); + + it("reads a tree with no config anywhere on the chain as no config", async () => { + const root = await makeRepoRoot(); + const sub = path.join(root, "packages", "db"); + await mkdir(sub, { recursive: true }); + + expect(await readProjectSkillsConfig(sub)).toBeNull(); + }); + + it("reads a --config file as the chain's anchor, with the root still applying", async () => { + const root = await makeRepoRoot(); + await writeConfig(root, `skills: { check: false }`); + const sub = path.join(root, "packages", "db"); + await mkdir(sub, { recursive: true }); + await writeFile( + path.join(sub, "elsewhere.config.ts"), + `export default { $prismaConfig: ${PRISMA_CONFIG_VERSION}, skills: { agents: ["cursor"] } };\n`, + "utf8", + ); + + expect(await readProjectSkillsConfig(sub, "elsewhere.config.ts")).toEqual({ + check: false, + agents: ["cursor"], + agentsConfigured: true, + }); + }); + + it("reads a section whose plain-object getter throws as no config", async () => { + // The resolver's snapshot reads each key once and turns the throw + // into a config error; it never reaches the validator. + const root = await makeRepoRoot(); + await writeFile( + path.join(root, "prisma.config.ts"), + `export default { $prismaConfig: ${PRISMA_CONFIG_VERSION}, skills: { get check() { throw new Error("getter"); } } };\n`, + "utf8", + ); + + expect(await readProjectSkillsConfig(root)).toBeNull(); + }); + + it("reads a non-plain section value whose getter throws as no config", async () => { + // A class instance is carried atomically past the resolver's + // snapshot, so the validator's own guard is what catches this one. + const root = await makeRepoRoot(); + await writeFile( + path.join(root, "prisma.config.ts"), + `export default { $prismaConfig: ${PRISMA_CONFIG_VERSION}, skills: new (class { get check() { throw new Error("getter"); } })() };\n`, + "utf8", + ); + + expect(await readProjectSkillsConfig(root)).toBeNull(); + }); + + it("reads an invalid merged section as no config", async () => { + const root = await makeRepoRoot(); + await writeConfig(root, `skills: { check: "yes" }`); + + expect(await readProjectSkillsConfig(root)).toBeNull(); + }); +}); From 1331eaa9182c519e6b584d119b1163b7053ed547 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 13:03:31 +0200 Subject: [PATCH 09/19] Out-of-handler skills reads go through the Runtime loader seam readProjectSkillsConfig now takes the loader instead of a cwd: the staleness notice receives Runtime.loadConfig through SkillsCheckRuntime, so a host-supplied loader governs it, and the post-login tip binds the disk loader with projectConfigLoader, which carries the CLI version the direct import used to drop. Only severity-error diagnostics disqualify a chain, so a future loader warning cannot turn a good config into no config. The invalid-merge test now derives the invalidity from an actual merge, and a new skills-check test proves end to end that a repository root's skills.check reaches a nested directory. Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/src/commands/auth/agent-setup-tip.ts | 4 +-- packages/cli/src/commands/skills/config.ts | 28 ++++++++++++--- packages/cli/src/main.ts | 1 + packages/cli/src/skills-check.ts | 10 ++++-- packages/cli/tests/skills-check.test.ts | 33 ++++++++++++++++++ packages/cli/tests/skills-config.test.ts | 34 +++++++++++++------ 6 files changed, 91 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/commands/auth/agent-setup-tip.ts b/packages/cli/src/commands/auth/agent-setup-tip.ts index d1030d0a..37f0d84a 100644 --- a/packages/cli/src/commands/auth/agent-setup-tip.ts +++ b/packages/cli/src/commands/auth/agent-setup-tip.ts @@ -7,7 +7,7 @@ */ import { resolvePrismaCliPackageCommand } from "../../lib/agent/cli-command"; import { readSkillsStatus } from "../../lib/skills/status"; -import { readProjectSkillsConfig } from "../skills/config"; +import { projectConfigLoader, readProjectSkillsConfig } from "../skills/config"; const SKILLS_SYNC_ARGS = ["skills", "sync"] as const; @@ -28,7 +28,7 @@ export async function resolveAgentSetupTipCommand( // status scan or command resolver cannot read must not fail a login // that succeeded. try { - const config = await readProjectSkillsConfig(ctx.cwd); + const config = await readProjectSkillsConfig(projectConfigLoader(ctx.cwd)); if (config !== null && !config.check) { return null; } diff --git a/packages/cli/src/commands/skills/config.ts b/packages/cli/src/commands/skills/config.ts index ceacb445..f80bbb60 100644 --- a/packages/cli/src/commands/skills/config.ts +++ b/packages/cli/src/commands/skills/config.ts @@ -1,5 +1,6 @@ import { defineConfigSection, + type LoadedConfig, loadConfig, resolveSectionOverChain, } from "@prisma/cli-engine"; @@ -10,6 +11,7 @@ import { isKnownAgent, KNOWN_AGENTS, } from "../../lib/skills/allowlist"; +import { getCliVersion } from "../../lib/version"; export interface SkillsConfig { /** Whether other commands may report out-of-date agent skills. @@ -123,13 +125,26 @@ function validateAgents( return { ok: true, agents }; } +/** Runtime.loadConfig's shape: cwd and CLI version already bound. */ +export type ProjectConfigLoader = ( + configPath?: string, +) => Promise; + +/** The disk loader bound the way the bin's Runtime binds it, for + * callers with no Runtime in scope (the post-login tip). */ +export function projectConfigLoader(cwd: string): ProjectConfigLoader { + return (configPath) => loadConfig(cwd, configPath, getCliVersion()); +} + /** * The project's skills settings, resolved per key over the discovered * config chain exactly as the skills commands resolve them, so callers * that run outside a command handler (the staleness check, the * post-login tip) agree with the commands on the governing config from - * any directory. Chain discovery is stat-only until a file exists, so - * a project without a config never pays a TypeScript transpile. + * any directory. `load` is Runtime.loadConfig wherever a Runtime is in + * scope, so a host-supplied loader governs these reads too. Chain + * discovery is stat-only until a file exists, so a project without a + * config never pays a TypeScript transpile. * * Null deliberately collapses "no config" and "config broken or * invalid": both callers fall back to the default agent set, so a @@ -137,11 +152,14 @@ function validateAgents( * the config surface the error themselves. */ export async function readProjectSkillsConfig( - cwd: string, + load: ProjectConfigLoader, configPath?: string, ): Promise { - const loaded = await loadConfig(cwd, configPath); - if (loaded.diagnostics.length > 0 || loaded.files.length === 0) { + const loaded = await load(configPath); + const broken = loaded.diagnostics.some( + (entry) => entry.diagnostic.severity === "error", + ); + if (broken || loaded.files.length === 0) { return null; } const resolved = resolveSectionOverChain(skillsConfigSection, loaded.files); diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 4fa68c91..cb99eaca 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -43,6 +43,7 @@ export async function main( argv: proc.argv.slice(2), cwd: proc.cwd(), stderr: proc.stderr, + loadConfig: runtime.loadConfig, }); return exitCode; } diff --git a/packages/cli/src/skills-check.ts b/packages/cli/src/skills-check.ts index 2b57f497..5568a515 100644 --- a/packages/cli/src/skills-check.ts +++ b/packages/cli/src/skills-check.ts @@ -9,7 +9,10 @@ * conditioned on a TTY: agents run without one and are who this is for. */ import { detectCI } from "@prisma/cli-engine"; -import { readProjectSkillsConfig } from "./commands/skills/config"; +import { + type ProjectConfigLoader, + readProjectSkillsConfig, +} from "./commands/skills/config"; import { agentSkillDirs, DEFAULT_AGENTS } from "./lib/skills/allowlist"; import { readSkillsCheckDisabled } from "./lib/skills/opt-out"; import { @@ -24,6 +27,9 @@ export interface SkillsCheckRuntime { readonly argv: readonly string[]; readonly cwd: string; readonly stderr: { write(text: string): unknown }; + /** The Runtime's config loader, so the notice reads the same chain + * through the same seam as the commands. */ + readonly loadConfig: ProjectConfigLoader; } export const SKILLS_CHECK_ENV_VAR = "PRISMA_SKILLS_CHECK"; @@ -56,7 +62,7 @@ export async function maybeWriteSkillsStaleNotice( return; } const config = await readProjectSkillsConfig( - runtime.cwd, + runtime.loadConfig, configPathFromArgv(runtime.argv), ); if (config !== null && !config.check) { diff --git a/packages/cli/tests/skills-check.test.ts b/packages/cli/tests/skills-check.test.ts index 90d3917f..3bf5db0b 100644 --- a/packages/cli/tests/skills-check.test.ts +++ b/packages/cli/tests/skills-check.test.ts @@ -403,6 +403,39 @@ describe("the skills check off switches", () => { expect(proc.stderrText).toBe(""); }); + it("honors a repository root's skills.check from a nested directory", async () => { + // The stale install lives in packages/app; the config that turns + // the check off lives at the repository root. The first run, before + // the root config exists, proves the fixture is genuinely stale + // from the nested directory. + const root = await makeProjectRoot("check-"); + await mkdir(path.join(root, ".git"), { recursive: true }); + const member = path.join(root, "packages", "app"); + await installPackage(root, { + name: "@prisma/orm-postgres", + version: "8.1.0", + skills: ["prisma-8"], + member: "packages/app", + }); + await seedSyncedSkill(member, ".claude/skills", { + skill: "prisma-8", + library: "@prisma/orm-postgres", + version: "8.0.0", + }); + const before = makeProcess({ cwd: member }); + await main(before, stubCli()); + expect(before.stderrText).toContain(NOTICE); + + await writeFile( + path.join(root, "prisma.config.ts"), + configSource({ check: false }), + "utf8", + ); + const after = makeProcess({ cwd: member }); + await main(after, stubCli()); + expect(after.stderrText).toBe(""); + }); + it("still reports a stale copy inside the configured agents", async () => { const root = await makeStaleProject(); await writeFile( diff --git a/packages/cli/tests/skills-config.test.ts b/packages/cli/tests/skills-config.test.ts index 4160bf06..e8e43f4c 100644 --- a/packages/cli/tests/skills-config.test.ts +++ b/packages/cli/tests/skills-config.test.ts @@ -11,7 +11,10 @@ import path from "node:path"; import { PRISMA_CONFIG_VERSION } from "@prisma/cli-engine"; import { describe, expect, it } from "vitest"; -import { readProjectSkillsConfig } from "../src/commands/skills/config"; +import { + projectConfigLoader, + readProjectSkillsConfig, +} from "../src/commands/skills/config"; import { DEFAULT_AGENTS } from "../src/lib/skills/allowlist"; import { makeProjectRoot } from "./helpers/skills-fixture"; @@ -39,7 +42,7 @@ describe("readProjectSkillsConfig over the config chain", () => { const sub = path.join(root, "packages", "db"); await mkdir(sub, { recursive: true }); - expect(await readProjectSkillsConfig(sub)).toEqual({ + expect(await readProjectSkillsConfig(projectConfigLoader(sub))).toEqual({ check: false, agents: DEFAULT_AGENTS, agentsConfigured: false, @@ -52,7 +55,7 @@ describe("readProjectSkillsConfig over the config chain", () => { const sub = path.join(root, "packages", "db"); await writeConfig(sub, `skills: { agents: ["cursor"] }`); - expect(await readProjectSkillsConfig(sub)).toEqual({ + expect(await readProjectSkillsConfig(projectConfigLoader(sub))).toEqual({ check: false, agents: ["cursor"], agentsConfigured: true, @@ -73,7 +76,9 @@ describe("readProjectSkillsConfig over the config chain", () => { ); expect( - await readProjectSkillsConfig(path.join(root, "packages", "db")), + await readProjectSkillsConfig( + projectConfigLoader(path.join(root, "packages", "db")), + ), ).toBeNull(); }); @@ -82,7 +87,7 @@ describe("readProjectSkillsConfig over the config chain", () => { const sub = path.join(root, "packages", "db"); await mkdir(sub, { recursive: true }); - expect(await readProjectSkillsConfig(sub)).toBeNull(); + expect(await readProjectSkillsConfig(projectConfigLoader(sub))).toBeNull(); }); it("reads a --config file as the chain's anchor, with the root still applying", async () => { @@ -96,7 +101,12 @@ describe("readProjectSkillsConfig over the config chain", () => { "utf8", ); - expect(await readProjectSkillsConfig(sub, "elsewhere.config.ts")).toEqual({ + expect( + await readProjectSkillsConfig( + projectConfigLoader(sub), + "elsewhere.config.ts", + ), + ).toEqual({ check: false, agents: ["cursor"], agentsConfigured: true, @@ -113,7 +123,7 @@ describe("readProjectSkillsConfig over the config chain", () => { "utf8", ); - expect(await readProjectSkillsConfig(root)).toBeNull(); + expect(await readProjectSkillsConfig(projectConfigLoader(root))).toBeNull(); }); it("reads a non-plain section value whose getter throws as no config", async () => { @@ -126,13 +136,17 @@ describe("readProjectSkillsConfig over the config chain", () => { "utf8", ); - expect(await readProjectSkillsConfig(root)).toBeNull(); + expect(await readProjectSkillsConfig(projectConfigLoader(root))).toBeNull(); }); it("reads an invalid merged section as no config", async () => { + // The invalid check value comes from the child, so the null result + // is the merged view failing validation, not a single-file read. const root = await makeRepoRoot(); - await writeConfig(root, `skills: { check: "yes" }`); + await writeConfig(root, `skills: { check: true, agents: ["claude"] }`); + const sub = path.join(root, "packages", "db"); + await writeConfig(sub, `skills: { check: "yes" }`); - expect(await readProjectSkillsConfig(root)).toBeNull(); + expect(await readProjectSkillsConfig(projectConfigLoader(sub))).toBeNull(); }); }); From c99418a9b46d886c6abf5deb7988a300b3c6123f Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 13:12:45 +0200 Subject: [PATCH 10/19] init below an ancestor config scaffolds only, and the e2e rerun workaround comes out Run in a directory whose discovered config chain reaches a parent directory, prisma init now writes only the prisma.config.ts scaffold: the postinstall hook, the prisma dev dependency, and the skills sync belong at the repository root, and each reports itself skipped with that reason. Passing --postinstall restores the manifest edit and --skills restores the sync; init at a repository root is unchanged. The init e2e rerun no longer deletes the scaffold first: the engine imports c12 via its realpath, so the built binary evaluates configs in this repository's development layout, and the rerun now covers the config-present path directly. A subdirectory e2e case rides the same harness. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/e2e/init.e2e.ts | 60 +++++++++----- packages/cli/src/commands/init.ts | 95 ++++++++++++++++++--- packages/cli/tests/init.test.ts | 133 ++++++++++++++++++++++++++++++ 3 files changed, 258 insertions(+), 30 deletions(-) diff --git a/packages/cli/e2e/init.e2e.ts b/packages/cli/e2e/init.e2e.ts index f7d6a4a7..f8c4b4a3 100644 --- a/packages/cli/e2e/init.e2e.ts +++ b/packages/cli/e2e/init.e2e.ts @@ -186,34 +186,54 @@ describe("prisma init", () => { }); }); - // A rerun with the config still present cannot run against the built - // binary yet: the binary fails to evaluate ANY prisma.config.ts in - // this repository's development layout ("Cannot find package 'pathe' - // imported from .../cli-engine/node_modules/c12/dist/index.mjs" — - // c12 resolves through the pnpm symlink without reaching its store - // siblings). A verified one-line engine fix exists (import c12 via - // its realpath) but changing the engine forces a coordinated family - // release, so it ships with the next engine version. The - // config-exists rerun is covered by tests/init.test.ts; this rerun - // removes the config first so it exercises the binary's idempotency - // for the other steps and the scaffold's recreation. - it("reruns safely: the hook is kept and a removed config is recreated", async () => { - await rm(path.join(workdir, "prisma.config.ts")); + // The binary evaluates the scaffold it wrote in the first test: the + // engine imports c12 via its realpath, so the pnpm development + // layout that once broke this rerun ("Cannot find package 'pathe'") + // no longer does. + it("reruns safely over its own scaffold: every step reports already done", async () => { const envelope = await runInit(workdir); expect(envelope.ok).toBe(true); expect(envelope.result.postinstall.outcome).toBe("exists"); expect(envelope.result.postinstall.dependency).toBe("declared"); - expect(envelope.result.config.outcome).toBe("created"); + expect(envelope.result.config.outcome).toBe("exists"); expect(envelope.result.skills.outcome).toBe("no-packages"); - expect(envelope.diagnostics.map((d) => d.code)).not.toContain( - "INIT.CONFIG_KEPT", + expect(envelope.diagnostics).toEqual([]); + }); + + it("below an ancestor config, init scaffolds only and skips the root-level steps", async () => { + const repo = await mkdtemp(path.join(os.tmpdir(), "prisma-e2e-subinit-")); + await mkdir(path.join(repo, ".git")); + await writeFile( + path.join(repo, "prisma.config.ts"), + "export default { $prismaConfig: 1 };\n", + "utf8", + ); + const nested = path.join(repo, "packages", "db"); + await mkdir(nested, { recursive: true }); + await writeFile( + path.join(nested, "package.json"), + `${JSON.stringify({ name: "e2e-sub-fixture", version: "0.0.0" }, null, 2)}\n`, + "utf8", ); - const reloaded = await loadConfig(workdir); - expect(reloaded.diagnostics).toEqual([]); - expect(reloaded.files[0]?.sections.skills).toEqual({ - agents: ["claude", "cursor", "agents", "devin"], + const envelope = await runInit(nested); + + expect(envelope.ok).toBe(true); + expect(envelope.result.postinstall).toEqual({ + outcome: "skipped", + script: null, + dependency: "skipped", }); + expect(envelope.result.skills.outcome).toBe("skipped"); + expect(envelope.result.config.outcome).toBe("created"); + expect(envelope.diagnostics).toEqual([]); + const manifest = JSON.parse( + await readFile(path.join(nested, "package.json"), "utf8"), + ) as { scripts?: unknown; devDependencies?: unknown }; + expect(manifest.scripts).toBeUndefined(); + expect(manifest.devDependencies).toBeUndefined(); + expect(existsSync(path.join(nested, "prisma.config.ts"))).toBe(true); + await rm(repo, { recursive: true, force: true }); }); }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 5aa282f4..06081bbe 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,3 +1,4 @@ +import { realpathSync } from "node:fs"; import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import type { Block, Presentations } from "@prisma/cli-engine"; @@ -14,7 +15,7 @@ import { import { readSkillsStatus } from "../lib/skills/status"; import { syncSkills } from "../lib/skills/sync"; import { getCliVersion } from "../lib/version"; -import { skillsConfigSection } from "./skills/config"; +import { projectConfigLoader, skillsConfigSection } from "./skills/config"; import { syncPresentations } from "./skills/presentation"; import type { SkillsSyncResult } from "./skills/results"; import { @@ -674,6 +675,50 @@ async function syncSkillsStep( } } +function realpathOr(target: string): string { + try { + return realpathSync(target); + } catch { + return target; + } +} + +/** Whether the discovered config chain reaches outside cwd: a + * prisma.config.ts in a directory above, or one an explicit `parent` + * named elsewhere. A config in cwd itself is not an ancestor. Runs + * before the scaffold is written, so init's own output never counts. + * Realpath'd on both sides so symlinked layouts compare like with + * like. */ +async function hasAncestorConfig(cwd: string): Promise { + const loaded = await projectConfigLoader(cwd)(); + const here = realpathOr(cwd); + return loaded.files.some( + (file) => realpathOr(path.dirname(file.path)) !== here, + ); +} + +const ANCESTOR_SKIPPED_POSTINSTALL: Step = { + report: { outcome: "skipped", script: null, dependency: "skipped" }, + lines: [ + summary( + "info", + "Skipped the postinstall hook and the prisma dev dependency: a prisma.config.ts in a parent directory covers this one, so both belong at the repository root. Pass --postinstall to add them here anyway.", + ), + ], + diagnostics: [], +}; + +const ANCESTOR_SKIPPED_SKILLS: Step = { + report: { outcome: "skipped", sync: null }, + lines: [ + summary( + "info", + "Skipped the skills sync: a prisma.config.ts in a parent directory covers this one, so the skills belong at the repository root. Pass --skills with your agents to sync them here anyway.", + ), + ], + diagnostics: [], +}; + const SKIPPED_POSTINSTALL: Step = { report: { outcome: "skipped", script: null, dependency: "skipped" }, lines: [summary("info", "Skipped the postinstall hook (--no-postinstall).")], @@ -750,6 +795,20 @@ function parseSkillsFlag( return { kind: "agents", agents }; } +async function postinstallStep( + flag: boolean | undefined, + ancestor: boolean, + cwd: string, +): Promise> { + if (flag === false) { + return SKIPPED_POSTINSTALL; + } + if (ancestor && flag === undefined) { + return ANCESTOR_SKIPPED_POSTINSTALL; + } + return addPostinstallHook(cwd); +} + function initPresentations( result: InitResult, postinstall: Step, @@ -778,7 +837,7 @@ export const initCommand = defineCommand({ help: { summary: "Prepare this repository for Prisma development", description: - "Runs locally and calls no platform API. Adds a postinstall script to package.json that keeps the Prisma agent skills in sync on every install, adds prisma to devDependencies at this CLI's exact version when no dependency field declares it, scaffolds a prisma.config.ts recording which agents to install skills for, then syncs the skills once now. Everything lands in the current directory; a prisma.config.ts or postinstall script that already exists is never edited. Rerunning is safe: each step reports what is already done.", + "Runs locally and calls no platform API. Adds a postinstall script to package.json that keeps the Prisma agent skills in sync on every install, adds prisma to devDependencies at this CLI's exact version when no dependency field declares it, scaffolds a prisma.config.ts recording which agents to install skills for, then syncs the skills once now. Everything lands in the current directory; a prisma.config.ts or postinstall script that already exists is never edited. Rerunning is safe: each step reports what is already done. In a directory that a parent directory's prisma.config.ts already governs, init writes only the prisma.config.ts scaffold — the postinstall hook, the prisma dev dependency, and the skills sync belong at the repository root and are skipped unless --postinstall or --skills asks for them here.", examples: [ "init", "init --skills=claude,cursor", @@ -804,10 +863,18 @@ export const initCommand = defineCommand({ return notOk(skillsFlag.error); } - const postinstall = - args.flags.postinstall === false - ? SKIPPED_POSTINSTALL - : await addPostinstallHook(ctx.cwd); + // Detection is skipped when both steps are already decided by + // flags; discovery is stat-only until a config file exists, so a + // project without one pays nothing. + const stepsUndecided = + args.flags.postinstall === undefined || args.flags.skills === undefined; + const ancestor = stepsUndecided && (await hasAncestorConfig(ctx.cwd)); + + const postinstall = await postinstallStep( + args.flags.postinstall, + ancestor, + ctx.cwd, + ); // --skills=none still scaffolds: `agents: []` is the committed // record that no agent skills are wanted, so later syncs and the // staleness check stay quiet instead of falling back to the @@ -817,10 +884,18 @@ export const initCommand = defineCommand({ skillsFlag.kind === "skip" ? [] : skillsFlag.agents, ctx.config.agentsConfigured, ); - const skills = - skillsFlag.kind === "skip" - ? SKIPPED_SKILLS - : await syncSkillsStep(ctx.cwd, skillsFlag.agents, ctx.config.check); + let skills: Step; + if (skillsFlag.kind === "skip") { + skills = SKIPPED_SKILLS; + } else if (ancestor && args.flags.skills === undefined) { + skills = ANCESTOR_SKIPPED_SKILLS; + } else { + skills = await syncSkillsStep( + ctx.cwd, + skillsFlag.agents, + ctx.config.check, + ); + } const result: InitResult = { postinstall: postinstall.report, diff --git a/packages/cli/tests/init.test.ts b/packages/cli/tests/init.test.ts index 628da0dc..14d4d236 100644 --- a/packages/cli/tests/init.test.ts +++ b/packages/cli/tests/init.test.ts @@ -916,6 +916,18 @@ describe("init", () => { }); }); + it("runs every step at a repository root with no config", async () => { + const root = await makeProjectRoot("init-"); + await mkdir(path.join(root, ".git")); + + const { exitCode, result } = await runInit(root); + + expect(exitCode).toBe(0); + expect(result.postinstall.outcome).toBe("added"); + expect(result.config.outcome).toBe("created"); + expect(result.skills.outcome).toBe("no-packages"); + }); + it.skipIf(process.platform === "win32")( "turns a sync failure into a diagnostic on a successful init", async () => { @@ -943,3 +955,124 @@ describe("init", () => { }, ); }); + +/** + * Init below an ancestor config: real temp trees with their own `.git` + * marker, so chain discovery stops inside the fixture and the + * checkout's own configs never leak in. The ancestor file carries the + * version marker literally — nothing resolves from a bare temp + * directory. The handler's ancestor detection reads the disk through + * the engine's real loader, so the first evaluation loads c12/jiti. + */ +describe("init below an ancestor config", { timeout: 60_000 }, () => { + async function makeRepoWithAncestorConfig(): Promise<{ + root: string; + nested: string; + }> { + const root = await makeProjectRoot("init-repo-"); + await mkdir(path.join(root, ".git")); + await writeFile( + path.join(root, "prisma.config.ts"), + "export default { $prismaConfig: 1 };\n", + "utf8", + ); + const nested = path.join(root, "packages", "db"); + await mkdir(nested, { recursive: true }); + await writeFile( + path.join(nested, "package.json"), + `${JSON.stringify({ name: "db-package", version: "0.0.0" }, null, 2)}\n`, + "utf8", + ); + return { root, nested }; + } + + it("skips the postinstall hook, the dependency, and the skills sync, and says why", async () => { + const { nested } = await makeRepoWithAncestorConfig(); + + const run = await makeCli().run(["init"], { + cwd: nested, + isTty: { stdout: true, stderr: true }, + }); + const result = run.presented?.data as InitResult; + + expect(run.exitCode).toBe(0); + expect(result.postinstall).toEqual({ + outcome: "skipped", + script: null, + dependency: "skipped", + }); + expect(result.skills).toEqual({ outcome: "skipped", sync: null }); + expect(result.config).toEqual({ + outcome: "created", + agents: [...DEFAULT_AGENTS], + }); + expect(run.stderr).toContain( + "Skipped the postinstall hook and the prisma dev dependency", + ); + expect(run.stderr).toContain("Skipped the skills sync"); + expect(run.stderr).toContain("belong at the repository root"); + expect(await exists(path.join(nested, "prisma.config.ts"))).toBe(true); + const manifest = await readManifest(nested); + expect(manifest.scripts).toBeUndefined(); + expect(manifest.devDependencies).toBeUndefined(); + }); + + it("--postinstall opts the manifest edit back in, the sync stays skipped", async () => { + const { nested } = await makeRepoWithAncestorConfig(); + + const { exitCode, result } = await runInit(nested, ["--postinstall"]); + + expect(exitCode).toBe(0); + expect(result.postinstall).toEqual({ + outcome: "added", + script: POSTINSTALL_SCRIPT, + dependency: "added", + }); + expect(result.skills).toEqual({ outcome: "skipped", sync: null }); + const manifest = await readManifest(nested); + expect((manifest.scripts as Record).postinstall).toBe( + POSTINSTALL_SCRIPT, + ); + expect(manifest.devDependencies).toEqual({ prisma: getCliVersion() }); + }); + + it("--skills opts the sync back in, the manifest edit stays skipped", async () => { + const { nested } = await makeRepoWithAncestorConfig(); + await installPackage(nested, { + name: "@prisma/orm-postgres", + version: "8.1.0", + skills: ["prisma-8"], + }); + + const { exitCode, result } = await runInit(nested, ["--skills=claude"]); + + expect(exitCode).toBe(0); + expect(result.skills.outcome).toBe("synced"); + expect( + await exists(path.join(nested, ".claude/skills", "prisma-8", "SKILL.md")), + ).toBe(true); + expect(result.postinstall).toEqual({ + outcome: "skipped", + script: null, + dependency: "skipped", + }); + expect(result.config).toEqual({ outcome: "created", agents: ["claude"] }); + }); + + it("a config in cwd itself is not an ancestor and defers nothing", async () => { + const root = await makeProjectRoot("init-repo-"); + await mkdir(path.join(root, ".git")); + await writeFile( + path.join(root, "prisma.config.ts"), + "export default { $prismaConfig: 1 };\n", + "utf8", + ); + + const { exitCode, result } = await runInit(root); + + expect(exitCode).toBe(0); + expect(result.postinstall.outcome).toBe("added"); + expect(result.config.outcome).toBe("exists"); + expect(result.skills.outcome).toBe("no-packages"); + }); +}); From f381e5e6075da8234eea46198eaf62fe8c168b8b Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 13:26:37 +0200 Subject: [PATCH 11/19] init reads the config chain from the context, and machine output says why a step was skipped The engine now hands every handler the chain its needs check resolved: ctx.configFiles carries the LoadedConfigFile list behind ctx.config, empty for commands with no config need. Init's ancestor detection reads it instead of doing a second disk load, so every config file is evaluated once per run, --config anchors the decision exactly as it anchored ctx.config, and a host-supplied loader governs init too. The test harness now seeds its stub config file at the run's cwd, honoring the contract that LoadedConfigFile.path is absolute. The skipped-for-a-governing-config outcome is now visible to machine readers: the postinstall and skills reports carry reason: "governing-config" in the JSON envelope, distinct from a flag-driven skip. The human copy no longer claims the governing config is in a parent directory, which an explicit parent path can make false. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/context.ts | 10 +- .../src/execution/command-context.ts | 4 +- packages/cli-engine/src/execution/engine.ts | 1 + packages/cli-engine/src/execution/needs.ts | 11 ++- packages/cli-engine/src/testing.ts | 12 ++- packages/cli-engine/tests/config.test.ts | 3 +- packages/cli-engine/tests/engine.type-test.ts | 10 +- packages/cli-engine/tests/execution.test.ts | 69 ++++++++++++++ packages/cli/e2e/init.e2e.ts | 7 +- packages/cli/src/commands/init.ts | 65 +++++++------ packages/cli/tests/init.test.ts | 91 +++++++++++-------- 11 files changed, 209 insertions(+), 74 deletions(-) diff --git a/packages/cli-engine/src/context.ts b/packages/cli-engine/src/context.ts index 554dfdd6..8350b14e 100644 --- a/packages/cli-engine/src/context.ts +++ b/packages/cli-engine/src/context.ts @@ -3,7 +3,7 @@ import type { EngineEvent } from "./events"; import type { ManagementApiClient } from "./management-api"; import type { Outcome, Presentations, PresentedResult } from "./presentation"; import type { CliStructuredError, Result } from "./protocol"; -import type { Host } from "./runtime"; +import type { Host, LoadedConfigFile } from "./runtime"; import type { ChildResult, SpawnOptions } from "./spawn"; /** The handler context — the whole world arrives as one argument. */ @@ -18,6 +18,14 @@ export interface CommandContext< */ readonly config: TConfig; + /** + * The config chain the needs check resolved, nearest-first — the + * same load ctx.config was validated from, including files whose + * sections this command did not need. Empty when the command + * declares no config need, and when discovery found no file. + */ + readonly configFiles: ReadonlyArray; + /** * Builds the PresentedResult for the active format. The only * constructor of PresentedResult. diff --git a/packages/cli-engine/src/execution/command-context.ts b/packages/cli-engine/src/execution/command-context.ts index 1062b8d9..6d068d8b 100644 --- a/packages/cli-engine/src/execution/command-context.ts +++ b/packages/cli-engine/src/execution/command-context.ts @@ -14,7 +14,7 @@ import { type Ui, } from "../presentation"; import { type Diagnostic, notOk, okVoid } from "../protocol"; -import type { OutputStream } from "../runtime"; +import type { LoadedConfigFile, OutputStream } from "../runtime"; import { buildManagementApiClient } from "./api-client"; import { constructionError } from "./command-tree"; import type { Invocation, RunState } from "./engine"; @@ -105,6 +105,7 @@ export function makeContext( invocation: Invocation, def: AnyCommand, config: unknown, + configFiles: readonly LoadedConfigFile[], capabilities: CommandCapabilities, ): CommandContext { const state = invocation.state; @@ -143,6 +144,7 @@ export function makeContext( let api: ManagementApiClient | undefined; const context: CommandContext = { config, + configFiles, present: present as CommandContext["present"], activeCredential: (): Promise => invocation.runtime.credentialManager?.activeCredential() ?? diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 32a5a424..387459fd 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -647,6 +647,7 @@ export class EngineImpl implements Engine { invocation, entry.def, needsOutcome.config, + needsOutcome.configFiles, declaredCapabilities(entry.def), ); if (entry.def.kind === "session-command") { diff --git a/packages/cli-engine/src/execution/needs.ts b/packages/cli-engine/src/execution/needs.ts index 782f1b18..ec25b946 100644 --- a/packages/cli-engine/src/execution/needs.ts +++ b/packages/cli-engine/src/execution/needs.ts @@ -30,6 +30,9 @@ export type NeedsOutcome = | { readonly kind: "ok"; readonly config: unknown; + /** The chain the config check loaded, for ctx.configFiles; empty + * when the command has no config need. */ + readonly configFiles: readonly LoadedConfigFile[]; /** The credential resolved for a `credentials: "child"` command, * carried forward so the spawn path never re-resolves it. */ readonly spawnCredential: ActiveCredential | undefined; @@ -90,6 +93,7 @@ export async function checkNeeds( return { kind: "ok", config: undefined, + configFiles: [], spawnCredential: credentials.spawnCredential, }; } @@ -306,7 +310,12 @@ function validateConfigSection( ); } writeSectionWarnings(invocation, validation.diagnostics); - return { kind: "ok", config: validation.value, spawnCredential: undefined }; + return { + kind: "ok", + config: validation.value, + configFiles: loaded.files, + spawnCredential: undefined, + }; } /** A property getter in a section's value is user code; a throw while diff --git a/packages/cli-engine/src/testing.ts b/packages/cli-engine/src/testing.ts index 0cf96467..b5325753 100644 --- a/packages/cli-engine/src/testing.ts +++ b/packages/cli-engine/src/testing.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import type { CommandFamily, MountedTree } from "./command-family"; import { CONFIG_FILE_NAME } from "./config-loader"; import type { Credential } from "./credential-manager"; @@ -295,11 +296,16 @@ export function createTestCli(spec: { apiBaseUrl: managementApiBaseUrl, authBaseUrl: "https://auth.test.invalid", }; - const loadConfig: Runtime["loadConfig"] = + /** LoadedConfigFile.path is absolute by contract, so the seeded file + * lands in the run's cwd — where the real loader would discover it. */ + const loadConfigFor = (cwd: string): Runtime["loadConfig"] => spec.loadConfig ?? (async (configPath) => ({ files: [ - { path: configPath ?? CONFIG_FILE_NAME, sections: spec.config ?? {} }, + { + path: resolve(cwd, configPath ?? CONFIG_FILE_NAME), + sections: spec.config ?? {}, + }, ], diagnostics: [], })); @@ -374,7 +380,7 @@ export function createTestCli(spec: { signalListeners.delete(cb); }; }, - loadConfig, + loadConfig: loadConfigFor(opts?.cwd ?? "/"), credentialManager, managementApiClientConfig, spawn: recordingSpawn(spawnChild, spawns), diff --git a/packages/cli-engine/tests/config.test.ts b/packages/cli-engine/tests/config.test.ts index a8b773fa..2a9ec0fb 100644 --- a/packages/cli-engine/tests/config.test.ts +++ b/packages/cli-engine/tests/config.test.ts @@ -1240,7 +1240,8 @@ describe("needs.config", { timeout: 60_000 }, () => { error: { code: "CLI.CONFIG_SECTION_INVALID", severity: "error", - summary: "The 'toy' section of prisma.config.ts is invalid.", + // The harness seeds the config in the run's cwd ("/"). + summary: "The 'toy' section of /prisma.config.ts is invalid.", nextActions: [ { kind: "user-choice", diff --git a/packages/cli-engine/tests/engine.type-test.ts b/packages/cli-engine/tests/engine.type-test.ts index 62d733f7..9021cbf4 100644 --- a/packages/cli-engine/tests/engine.type-test.ts +++ b/packages/cli-engine/tests/engine.type-test.ts @@ -19,6 +19,7 @@ import type { FlagSpec, InputStream, LoadedConfig, + LoadedConfigFile, MountedTree, Presentations, PresentedResult, @@ -203,12 +204,15 @@ export const runCheck: CommandHandler = async ( const cfg: CheckCfg = ctx.config; const strictCfg: boolean = ctx.config.strict; + // The chain the config was resolved from rides the context + const chain: ReadonlyArray = ctx.configFiles; + // r5(1)/r4(b): documented exit codes compile at every return site const p4 = ctx.present( { data: { strict, filter, name, rest }, exitCode: 4 }, presentations, ); - const p5 = ctx.present({ data: cfg, exitCode: 5 }, presentations); + const p5 = ctx.present({ data: { cfg, chain }, exitCode: 5 }, presentations); const p0 = ctx.present({ data: strictCfg, exitCode: 0 }, presentations); // r5(1): diagnostics are optional alongside the exit code const pDiag = ctx.present( @@ -242,12 +246,14 @@ export const runPlain: CommandHandler = async ( ) => { // r5(3): with no needs.config, TConfig defaults to undefined const noConfig: undefined = ctx.config; + // ctx.configFiles exists on every context; it is empty here + const noChain: ReadonlyArray = ctx.configFiles; // r5(1): present({ data }) compiles without an exit code const bare = ctx.present({ data: noConfig }, presentations); // r5(1): diagnostics still accepted without a catalogue const withDiagnostics = ctx.present( - { data: 1, diagnostics: [] }, + { data: noChain, diagnostics: [] }, presentations, ); diff --git a/packages/cli-engine/tests/execution.test.ts b/packages/cli-engine/tests/execution.test.ts index ed9bf9b5..d8cc8e77 100644 --- a/packages/cli-engine/tests/execution.test.ts +++ b/packages/cli-engine/tests/execution.test.ts @@ -671,6 +671,75 @@ describe("needs preconditions", () => { }); }); +describe("ctx.configFiles", () => { + function chainReporter(withConfigNeed: boolean) { + return defineCommand({ + help: { summary: "Reports the chain the context carries" }, + ...(withConfigNeed + ? { + needs: { + config: defineConfigSection({ + name: "toy", + validate: () => ({ ok: true, value: null, diagnostics: [] }), + }), + }, + } + : {}), + handler: async (_args, ctx) => { + const paths = ctx.configFiles.map((file) => file.path); + return ok( + ctx.present( + { data: paths }, + { + human: () => [], + stdout: () => [], + json: () => paths, + next: () => [], + }, + ), + ); + }, + }); + } + + test("a command with a config need sees the loaded chain, nearest-first", async () => { + const cli = createTestCli({ + commands: { report: chainReporter(true) }, + loadConfig: async () => ({ + files: [ + { path: "/repo/pkg/prisma.config.ts", sections: {} }, + { path: "/repo/prisma.config.ts", sections: {} }, + ], + diagnostics: [], + }), + now: EPOCH, + }); + + const result = await cli.run(["report", "--json"]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual([ + "/repo/pkg/prisma.config.ts", + "/repo/prisma.config.ts", + ]); + }); + + test("a command with no config need carries an empty chain and never loads", async () => { + const cli = createTestCli({ + commands: { report: chainReporter(false) }, + loadConfig: async () => { + throw new Error("loadConfig must not be called without a config need"); + }, + now: EPOCH, + }); + + const result = await cli.run(["report", "--json"]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual([]); + }); +}); + describe("undocumented completion exit codes", () => { test("a completed exit code the command never documented settles as a bug", async () => { const rogue = defineCommand({ diff --git a/packages/cli/e2e/init.e2e.ts b/packages/cli/e2e/init.e2e.ts index f8c4b4a3..53079d42 100644 --- a/packages/cli/e2e/init.e2e.ts +++ b/packages/cli/e2e/init.e2e.ts @@ -35,6 +35,7 @@ interface InitEnvelope { readonly result: { readonly postinstall: { readonly outcome: string; + readonly reason?: string; readonly script: string | null; readonly dependency: string; }; @@ -44,6 +45,7 @@ interface InitEnvelope { }; readonly skills: { readonly outcome: string; + readonly reason?: string; readonly sync: { readonly packages: readonly unknown[] } | null; }; }; @@ -189,7 +191,8 @@ describe("prisma init", () => { // The binary evaluates the scaffold it wrote in the first test: the // engine imports c12 via its realpath, so the pnpm development // layout that once broke this rerun ("Cannot find package 'pathe'") - // no longer does. + // no longer does. The removed-config recreation the old rerun shape + // exercised instead is covered by tests/init.test.ts. it("reruns safely over its own scaffold: every step reports already done", async () => { const envelope = await runInit(workdir); @@ -222,10 +225,12 @@ describe("prisma init", () => { expect(envelope.ok).toBe(true); expect(envelope.result.postinstall).toEqual({ outcome: "skipped", + reason: "governing-config", script: null, dependency: "skipped", }); expect(envelope.result.skills.outcome).toBe("skipped"); + expect(envelope.result.skills.reason).toBe("governing-config"); expect(envelope.result.config.outcome).toBe("created"); expect(envelope.diagnostics).toEqual([]); const manifest = JSON.parse( diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 06081bbe..d137ed3c 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,7 +1,11 @@ import { realpathSync } from "node:fs"; import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import type { Block, Presentations } from "@prisma/cli-engine"; +import type { + Block, + LoadedConfigFile, + Presentations, +} from "@prisma/cli-engine"; import { defineCommand, flag } from "@prisma/cli-engine"; import type { Diagnostic, NextAction } from "@prisma/cli-engine/protocol"; import { CliStructuredError, notOk, ok } from "@prisma/cli-engine/protocol"; @@ -15,7 +19,7 @@ import { import { readSkillsStatus } from "../lib/skills/status"; import { syncSkills } from "../lib/skills/sync"; import { getCliVersion } from "../lib/version"; -import { projectConfigLoader, skillsConfigSection } from "./skills/config"; +import { skillsConfigSection } from "./skills/config"; import { syncPresentations } from "./skills/presentation"; import type { SkillsSyncResult } from "./skills/results"; import { @@ -30,8 +34,14 @@ export type InitPostinstallOutcome = "added" | "exists" | "kept" | "skipped"; export type InitDependencyOutcome = "added" | "declared" | "skipped"; +/** Present only on a "skipped" outcome that no flag and no diagnostic + * explains: another prisma.config.ts governs the directory, so the + * step belongs at the repository root. */ +export type InitSkipReason = "governing-config"; + export interface InitPostinstallReport { readonly outcome: InitPostinstallOutcome; + readonly reason?: InitSkipReason; /** The postinstall script package.json holds after init; null when * the step was skipped or nothing was written. */ readonly script: string | null; @@ -57,6 +67,7 @@ export type InitSkillsOutcome = export interface InitSkillsReport { readonly outcome: InitSkillsOutcome; + readonly reason?: InitSkipReason; readonly sync: SkillsSyncResult | null; } @@ -683,37 +694,42 @@ function realpathOr(target: string): string { } } -/** Whether the discovered config chain reaches outside cwd: a +/** Whether the resolved config chain reaches outside cwd: a * prisma.config.ts in a directory above, or one an explicit `parent` - * named elsewhere. A config in cwd itself is not an ancestor. Runs - * before the scaffold is written, so init's own output never counts. - * Realpath'd on both sides so symlinked layouts compare like with - * like. */ -async function hasAncestorConfig(cwd: string): Promise { - const loaded = await projectConfigLoader(cwd)(); + * named elsewhere. A config in cwd itself does not count. The chain + * is the one the needs check loaded before the handler ran, so + * init's own scaffold never counts either. Realpath'd on both sides + * so symlinked layouts compare like with like. */ +function governedByAncestorConfig( + files: readonly LoadedConfigFile[], + cwd: string, +): boolean { const here = realpathOr(cwd); - return loaded.files.some( - (file) => realpathOr(path.dirname(file.path)) !== here, - ); + return files.some((file) => realpathOr(path.dirname(file.path)) !== here); } const ANCESTOR_SKIPPED_POSTINSTALL: Step = { - report: { outcome: "skipped", script: null, dependency: "skipped" }, + report: { + outcome: "skipped", + reason: "governing-config", + script: null, + dependency: "skipped", + }, lines: [ summary( "info", - "Skipped the postinstall hook and the prisma dev dependency: a prisma.config.ts in a parent directory covers this one, so both belong at the repository root. Pass --postinstall to add them here anyway.", + "Skipped the postinstall hook and the prisma dev dependency: another prisma.config.ts already governs this directory, so both belong at the repository root. Pass --postinstall to add them here anyway.", ), ], diagnostics: [], }; const ANCESTOR_SKIPPED_SKILLS: Step = { - report: { outcome: "skipped", sync: null }, + report: { outcome: "skipped", reason: "governing-config", sync: null }, lines: [ summary( "info", - "Skipped the skills sync: a prisma.config.ts in a parent directory covers this one, so the skills belong at the repository root. Pass --skills with your agents to sync them here anyway.", + "Skipped the skills sync: another prisma.config.ts already governs this directory, so the skills belong at the repository root. Pass --skills with your agents to sync them here anyway.", ), ], diagnostics: [], @@ -797,13 +813,13 @@ function parseSkillsFlag( async function postinstallStep( flag: boolean | undefined, - ancestor: boolean, + deferToAncestor: boolean, cwd: string, ): Promise> { if (flag === false) { return SKIPPED_POSTINSTALL; } - if (ancestor && flag === undefined) { + if (deferToAncestor && flag === undefined) { return ANCESTOR_SKIPPED_POSTINSTALL; } return addPostinstallHook(cwd); @@ -837,7 +853,7 @@ export const initCommand = defineCommand({ help: { summary: "Prepare this repository for Prisma development", description: - "Runs locally and calls no platform API. Adds a postinstall script to package.json that keeps the Prisma agent skills in sync on every install, adds prisma to devDependencies at this CLI's exact version when no dependency field declares it, scaffolds a prisma.config.ts recording which agents to install skills for, then syncs the skills once now. Everything lands in the current directory; a prisma.config.ts or postinstall script that already exists is never edited. Rerunning is safe: each step reports what is already done. In a directory that a parent directory's prisma.config.ts already governs, init writes only the prisma.config.ts scaffold — the postinstall hook, the prisma dev dependency, and the skills sync belong at the repository root and are skipped unless --postinstall or --skills asks for them here.", + "Runs locally and calls no platform API. Adds a postinstall script to package.json that keeps the Prisma agent skills in sync on every install, adds prisma to devDependencies at this CLI's exact version when no dependency field declares it, scaffolds a prisma.config.ts recording which agents to install skills for, then syncs the skills once now. Everything lands in the current directory; a prisma.config.ts or postinstall script that already exists is never edited. Rerunning is safe: each step reports what is already done. In a directory another prisma.config.ts already governs, init writes only the prisma.config.ts scaffold — the postinstall hook, the prisma dev dependency, and the skills sync belong at the repository root and are skipped unless --postinstall or --skills asks for them here.", examples: [ "init", "init --skills=claude,cursor", @@ -863,16 +879,11 @@ export const initCommand = defineCommand({ return notOk(skillsFlag.error); } - // Detection is skipped when both steps are already decided by - // flags; discovery is stat-only until a config file exists, so a - // project without one pays nothing. - const stepsUndecided = - args.flags.postinstall === undefined || args.flags.skills === undefined; - const ancestor = stepsUndecided && (await hasAncestorConfig(ctx.cwd)); + const deferToAncestor = governedByAncestorConfig(ctx.configFiles, ctx.cwd); const postinstall = await postinstallStep( args.flags.postinstall, - ancestor, + deferToAncestor, ctx.cwd, ); // --skills=none still scaffolds: `agents: []` is the committed @@ -887,7 +898,7 @@ export const initCommand = defineCommand({ let skills: Step; if (skillsFlag.kind === "skip") { skills = SKIPPED_SKILLS; - } else if (ancestor && args.flags.skills === undefined) { + } else if (deferToAncestor && args.flags.skills === undefined) { skills = ANCESTOR_SKIPPED_SKILLS; } else { skills = await syncSkillsStep( diff --git a/packages/cli/tests/init.test.ts b/packages/cli/tests/init.test.ts index 14d4d236..037543d9 100644 --- a/packages/cli/tests/init.test.ts +++ b/packages/cli/tests/init.test.ts @@ -916,18 +916,6 @@ describe("init", () => { }); }); - it("runs every step at a repository root with no config", async () => { - const root = await makeProjectRoot("init-"); - await mkdir(path.join(root, ".git")); - - const { exitCode, result } = await runInit(root); - - expect(exitCode).toBe(0); - expect(result.postinstall.outcome).toBe("added"); - expect(result.config.outcome).toBe("created"); - expect(result.skills.outcome).toBe("no-packages"); - }); - it.skipIf(process.platform === "win32")( "turns a sync failure into a diagnostic on a successful init", async () => { @@ -957,25 +945,18 @@ describe("init", () => { }); /** - * Init below an ancestor config: real temp trees with their own `.git` - * marker, so chain discovery stops inside the fixture and the - * checkout's own configs never leak in. The ancestor file carries the - * version marker literally — nothing resolves from a bare temp - * directory. The handler's ancestor detection reads the disk through - * the engine's real loader, so the first evaluation loads c12/jiti. + * Init below an ancestor config. The handler reads the chain from + * ctx.configFiles — the load the engine's needs check already did — + * so these tests seed the chain through the test CLI's loadConfig, + * exactly as a real run's resolver would hand it over. The real-disk + * discovery path is the init e2e's. */ -describe("init below an ancestor config", { timeout: 60_000 }, () => { +describe("init below an ancestor config", () => { async function makeRepoWithAncestorConfig(): Promise<{ root: string; nested: string; }> { const root = await makeProjectRoot("init-repo-"); - await mkdir(path.join(root, ".git")); - await writeFile( - path.join(root, "prisma.config.ts"), - "export default { $prismaConfig: 1 };\n", - "utf8", - ); const nested = path.join(root, "packages", "db"); await mkdir(nested, { recursive: true }); await writeFile( @@ -986,10 +967,23 @@ describe("init below an ancestor config", { timeout: 60_000 }, () => { return { root, nested }; } + /** The chain a subdirectory run resolves: one file, at the fixture + * root, above the run's cwd. */ + function ancestorChainCli(root: string) { + return createTestCli({ + commands: { init: initCommand }, + loadConfig: async () => ({ + files: [{ path: path.join(root, "prisma.config.ts"), sections: {} }], + diagnostics: [], + }), + now: () => new Date(0), + }); + } + it("skips the postinstall hook, the dependency, and the skills sync, and says why", async () => { - const { nested } = await makeRepoWithAncestorConfig(); + const { root, nested } = await makeRepoWithAncestorConfig(); - const run = await makeCli().run(["init"], { + const run = await ancestorChainCli(root).run(["init"], { cwd: nested, isTty: { stdout: true, stderr: true }, }); @@ -998,10 +992,15 @@ describe("init below an ancestor config", { timeout: 60_000 }, () => { expect(run.exitCode).toBe(0); expect(result.postinstall).toEqual({ outcome: "skipped", + reason: "governing-config", script: null, dependency: "skipped", }); - expect(result.skills).toEqual({ outcome: "skipped", sync: null }); + expect(result.skills).toEqual({ + outcome: "skipped", + reason: "governing-config", + sync: null, + }); expect(result.config).toEqual({ outcome: "created", agents: [...DEFAULT_AGENTS], @@ -1010,6 +1009,9 @@ describe("init below an ancestor config", { timeout: 60_000 }, () => { "Skipped the postinstall hook and the prisma dev dependency", ); expect(run.stderr).toContain("Skipped the skills sync"); + expect(run.stderr).toContain( + "another prisma.config.ts already governs this directory", + ); expect(run.stderr).toContain("belong at the repository root"); expect(await exists(path.join(nested, "prisma.config.ts"))).toBe(true); const manifest = await readManifest(nested); @@ -1018,17 +1020,24 @@ describe("init below an ancestor config", { timeout: 60_000 }, () => { }); it("--postinstall opts the manifest edit back in, the sync stays skipped", async () => { - const { nested } = await makeRepoWithAncestorConfig(); + const { root, nested } = await makeRepoWithAncestorConfig(); - const { exitCode, result } = await runInit(nested, ["--postinstall"]); + const run = await ancestorChainCli(root).run(["init", "--postinstall"], { + cwd: nested, + }); + const result = run.presented?.data as InitResult; - expect(exitCode).toBe(0); + expect(run.exitCode).toBe(0); expect(result.postinstall).toEqual({ outcome: "added", script: POSTINSTALL_SCRIPT, dependency: "added", }); - expect(result.skills).toEqual({ outcome: "skipped", sync: null }); + expect(result.skills).toEqual({ + outcome: "skipped", + reason: "governing-config", + sync: null, + }); const manifest = await readManifest(nested); expect((manifest.scripts as Record).postinstall).toBe( POSTINSTALL_SCRIPT, @@ -1037,22 +1046,26 @@ describe("init below an ancestor config", { timeout: 60_000 }, () => { }); it("--skills opts the sync back in, the manifest edit stays skipped", async () => { - const { nested } = await makeRepoWithAncestorConfig(); + const { root, nested } = await makeRepoWithAncestorConfig(); await installPackage(nested, { name: "@prisma/orm-postgres", version: "8.1.0", skills: ["prisma-8"], }); - const { exitCode, result } = await runInit(nested, ["--skills=claude"]); + const run = await ancestorChainCli(root).run(["init", "--skills=claude"], { + cwd: nested, + }); + const result = run.presented?.data as InitResult; - expect(exitCode).toBe(0); + expect(run.exitCode).toBe(0); expect(result.skills.outcome).toBe("synced"); expect( await exists(path.join(nested, ".claude/skills", "prisma-8", "SKILL.md")), ).toBe(true); expect(result.postinstall).toEqual({ outcome: "skipped", + reason: "governing-config", script: null, dependency: "skipped", }); @@ -1061,17 +1074,21 @@ describe("init below an ancestor config", { timeout: 60_000 }, () => { it("a config in cwd itself is not an ancestor and defers nothing", async () => { const root = await makeProjectRoot("init-repo-"); - await mkdir(path.join(root, ".git")); await writeFile( path.join(root, "prisma.config.ts"), "export default { $prismaConfig: 1 };\n", "utf8", ); - const { exitCode, result } = await runInit(root); + // The default test-CLI loader places the seeded config in the + // run's cwd, the non-ancestor shape. + const { exitCode, result } = await runInit(root, [], { + skills: { agents: [...DEFAULT_AGENTS] }, + }); expect(exitCode).toBe(0); expect(result.postinstall.outcome).toBe("added"); + expect(result.postinstall.reason).toBeUndefined(); expect(result.config.outcome).toBe("exists"); expect(result.skills.outcome).toBe("no-packages"); }); From 721231c1107ef9ce6f593a897a81a2bf7f484d8b Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 13:38:27 +0200 Subject: [PATCH 12/19] Docs and records follow the config-chain truth, plus three review nits The engine requirements doc's R10 now describes what ships: chain discovery from the anchor upward to the repository boundary, the reserved parent key, --config anchoring the chain at the named file, per-key merging with the most-local value winning, provenance, declaring-file-relative paths, the two-config monorepo layout, and the three parent diagnostics. The output conventions describe the staleness notice reading the discovered chain rather than one file in cwd, and init's JSON reason field for governing-config skips; the command principles describe subdirectory init scaffolding only, with --postinstall and --skills opting back in. Records: the spec's status line carries the landed date, the pathe ledger entry now says the init e2e rerun workaround is out, and a new deferred entry records that the post-login skills tip does not see --config. Review nits: the section-invalid expectation builds its path with resolve and CONFIG_FILE_NAME instead of the hardcoded POSIX form; the empty-chain init case is back as a harness-level test; needs.ts freezes the loaded chain before handing it to handlers. Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 6 ++++- .../specs/config-file-resolution.md | 2 +- docs/architecture/cli-engine-requirements.md | 22 +++++++++++++------ docs/product/command-principles.md | 2 ++ docs/product/output-conventions.md | 6 ++--- packages/cli-engine/src/execution/needs.ts | 2 +- packages/cli-engine/tests/config.test.ts | 3 ++- packages/cli/src/skills-check.ts | 4 ++-- packages/cli/tests/init.test.ts | 17 ++++++++++++++ 9 files changed, 48 insertions(+), 16 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index 66a486e6..c5e4e50d 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -485,7 +485,7 @@ The cleanup PR removed the compute config and `init`, made service commands para ## From the agent-skills delivery (project closed 2026-08-22) -- ~~**Config evaluation fails through unrealpath'd pnpm symlinks.**~~ Closed (2026-08-25): the one-line realpath fix shipped — `packages/cli-engine/src/config-loader.ts` imports c12 via `realpathSync(import.meta.resolve("c12"))` on main, released with engine 0.2.2 (#224). The init e2e's rerun workaround (`e2e/init.e2e.ts:189-200`) is still in place; it comes out with the config-file-resolution slice, which tracks it in its plan. +- ~~**Config evaluation fails through unrealpath'd pnpm symlinks.**~~ Closed (2026-08-25): the one-line realpath fix shipped — `packages/cli-engine/src/config-loader.ts` imports c12 via `realpathSync(import.meta.resolve("c12"))` on main, released with engine 0.2.2 (#224). The init e2e's rerun workaround came out with the config-file-resolution slice (D4, 2026-08-25): the rerun no longer deletes the scaffold first and now covers the config-present path directly. The agent-skills project (skills sync/list, `prisma init`, the staleness notice; PR #219) closed with these items still open; details were in its own ledger, summarized here as the surviving record. @@ -511,3 +511,7 @@ The design in `specs/config-file-resolution.md` is decided (per-key merge with s - **`readProjectSkillsConfig`'s hand-rolled resolution must consolidate into the engine resolver** (ruled 2026-08-25) — lands as D3 of the slice; until then the staleness notice and the commands can disagree about which config governs when run from a subdirectory. - **Subdirectory `prisma init` skips the skills sync, postinstall script, and devDependency by default** (ruled 2026-08-25) — those steps belong to the repository root; lands as D4 of the slice, which needs D1's ancestor discovery to detect "subdirectory" at all. + +## Left open by the config-file-resolution slice (2026-08-25) + +- **`--config` does not reach the post-login skills tip.** `packages/cli/src/commands/auth/agent-setup-tip.ts` calls `readProjectSkillsConfig` via `projectConfigLoader(ctx.cwd)` — the disk loader anchored at cwd — because `CommandContext` does not expose the parsed `--config` flag to that path. A login run with `--config` shows the tip against the cwd-anchored chain instead of the named file's. Recorded from D3 review; pre-existing shape, low impact. diff --git a/.drive/projects/prisma-cli-v8/specs/config-file-resolution.md b/.drive/projects/prisma-cli-v8/specs/config-file-resolution.md index e6eaf2f0..2934fe7e 100644 --- a/.drive/projects/prisma-cli-v8/specs/config-file-resolution.md +++ b/.drive/projects/prisma-cli-v8/specs/config-file-resolution.md @@ -1,6 +1,6 @@ # Config-file resolution: ancestor discovery and per-key merging -Status: **decided** (operator rulings 2026-08-25). This file was the design discussion; it is now the slice contract. The discussion history survives condensed under "Design decisions"; the appendix's edge-case catalogue is carried forward as implementation requirements. +Status: **landed** (operator rulings 2026-08-25; implementation landed 2026-08-25). This file was the design discussion; it is now the slice contract. The discussion history survives condensed under "Design decisions"; the appendix's edge-case catalogue is carried forward as implementation requirements. ## At a glance diff --git a/docs/architecture/cli-engine-requirements.md b/docs/architecture/cli-engine-requirements.md index 21243f87..6d11b239 100644 --- a/docs/architecture/cli-engine-requirements.md +++ b/docs/architecture/cli-engine-requirements.md @@ -153,19 +153,27 @@ references, and nothing about the tree is discovered at run time. **Why:** a statically known tree is simpler to reason about, renders complete help without executing product code, and fails at build time when it is wrong. The expensive parts — driver stacks, Composer's dependency tree (which imports the user's own modules and has crashed at import in the past) — stay out of the startup path, so `prisma db migrate` can never be taken down by a product it isn't using. The split follows the existing design for isolating heavy dependency subtrees behind execution-time imports. -### R10 — One config file, validated by its products, never a crash +### R10 — One config chain, validated by its products, never a crash -The engine reads one config file, and it reads it only when the running command needs it. A command declares the section it needs; a run whose command declares none never opens the file, never pays for the loader, and cannot be failed by a broken config. Each product contributes a named section and a never-throwing validator; validation produces per-section diagnostics, and a command fails only if a section it needs is invalid. "Never a crash" covers loading too: a file that fails to import or evaluate — a syntax error, a throwing top-level statement — is caught by the engine and surfaced as a typed file-level diagnostic naming the config path, never a stack trace. +*(Amended 2026-08-25: resolution grew from one file in cwd to a discovered chain of files, merged per key. The lazy-read, per-section, never-crash rules below predate that change and survive it unchanged.)* -There are two ways to resolve the file, and they treat absence differently. By discovery: `prisma.config.ts` in the current directory, that directory only, never walking up — and no file there is not an error, because the section validators own absence and supply their defaults. By name: the engine-owned `--config ` flag, which reads the file it names instead — and a named file that is not there IS an error, `CLI.CONFIG_NOT_FOUND`, because the user said which file to run against and the CLI would otherwise run against different settings. +The engine resolves a chain of `prisma.config.ts` files, and it resolves it only when the running command needs it. A command declares the section it needs; a run whose command declares none never opens a file, never pays for the loader, and cannot be failed by a broken config. Each product contributes a named section and a never-throwing validator; validation produces per-section diagnostics, and a command fails only if a section it needs is invalid. "Never a crash" covers loading too: a file that fails to import or evaluate — a syntax error, a throwing top-level statement — is caught by the engine and surfaced as a typed file-level diagnostic naming that file, never a stack trace. -The version-marker contract is exact. The engine package owns both sides of it: its `defineConfig` stamps the exported config value with a `$prismaConfig` field carrying the config contract version, and its loader checks that field before interpreting anything else. Each failure mode has its own typed, file-level diagnostic carrying the config path: an evaluated file without the marker (in particular a classic Prisma 7 config, which uses the same filename) fails early with `CLI.CONFIG_MISSING_MARKER`; a marker declaring a version this CLI does not support — older or newer, future markers included — fails with `CLI.CONFIG_VERSION_UNSUPPORTED`; a file that cannot be evaluated at all fails with `CLI.CONFIG_UNREADABLE`, per the previous paragraph; a file named by `--config` that does not exist fails with `CLI.CONFIG_NOT_FOUND`; and a top-level key that is not a section name fails with `CLI.CONFIG_UNKNOWN_SECTION`, per the next paragraph. Those five are every file-level config diagnostic there is. `CLI.CONFIG_NOT_FOUND` covers the named file and nothing else: a `prisma.config.ts` that discovery does not find is deliberately not an error, per the previous paragraph, so that code never appears for a run without `--config`. One further code exists and is not file-level: a command's own section failing its validator is `CLI.CONFIG_SECTION_INVALID`, which names the section it came from. Each of the six means one thing, so a consumer branching on the code can tell a config written for another CLI from a bad field in one section. No best-effort reading of unmarked files. +Discovery collects every `prisma.config.ts` from the anchor directory upward, stopping at the repository boundary: the first directory containing a `.git` entry, that directory included. No `.git` at or above the anchor means the anchor directory alone — the filesystem root and the home directory are never reached implicitly, because every file on the chain is executed TypeScript and nothing outside the repository runs without explicit consent. The anchor is the current directory; with the engine-owned `--config ` flag it is the named file itself, which becomes the nearest link of the normal chain — its ancestors and `parent` declarations apply as usual, and the current directory's lineage never does. A file can steer the chain with the reserved top-level key `parent`: `parent: false` means "I am the last file, collect nothing above me"; `parent: "path"` names the next file explicitly, resolved against the declaring file's directory, allowed to cross the repository boundary (naming the file is the consent — the git-submodule case), and cycle-checked; an absent `parent` lets automatic discovery continue upward from the file's own directory. -The set of top-level keys is closed. Every top-level key names a config section, and the recognised names are exactly the sections the mounted commands and command families declare — so an unrecognised key is reported rather than ignored, and a settings block the user wrote in good faith never disappears in silence. The engine applies this check itself, in the same place it validates a command's own section, and not in the config loader: the loader is a member of the injected `Runtime`, so a check that lived there would hold only for as long as every host wired a loader that performed it. Two consequences are part of the requirement. First, an unrecognised key is a problem with the file's shape rather than with one section's content, so it is a file-level diagnostic and it fails every command that declares a config section, while commands that need no config keep working. That reach is deliberate: per-section isolation covers a section's content, which is where one product's mistake must not reach another product's commands, and a file whose shape the CLI cannot account for is not a problem any single section owns. Second, the recognised set is derived from the command families and commands a given binary mounts, so a config file's validity depends on the build as well as on the `$prismaConfig` marker — and of those two, only the marker is visible in the file. +Absence is treated per how the file entered the chain. Discovery finding no file anywhere is not an error — the section validators own absence and supply their defaults. A file named by `--config` that is not there IS an error, `CLI.CONFIG_NOT_FOUND`, because the user said which file to run against and the CLI would otherwise run against different settings; a file a `parent` key names that is not there is likewise an error, `CLI.CONFIG_PARENT_NOT_FOUND`. A broken file anywhere on the chain fails the command with a diagnostic naming that file; no skipping. -Section names are constrained, and the constraint is enforced when the CLI is constructed rather than when a user runs it. A section may not be named `extends`, which config loaders read as an instruction to merge another file in, nor anything beginning `$`, which the file format keeps for metadata (`$prismaConfig` is the version marker itself). A command or command family that declares such a section fails at construction, so the mistake is a build-time failure for a contributor and never a runtime surprise for a user. +Sections resolve per key over the chain, the most-local value winning — the layering model ESLint and tsconfig users already know. A nested file shadows only the keys it actually writes, so a repository-root `skills: { check: false }` reaches every subdirectory that does not override it, and a root-level section falls through to subdirectories that never mention it. Merge semantics belong to the section's owner: `ConfigSection` carries an optional `merge(parent, child)`, and the engine default — for sections that do not customise — merges per key at the section's top level and replaces below. Required-section validation runs after the merge, on the resolved view, so a nested partial and a root partial may complete each other; validators keep their existing contract and see one value. Merging never mutates a file's frozen export. Every resolved value carries provenance — which file contributed it, per top-level key — so post-merge diagnostics name the file to fix, and a relative path in a config resolves against the file that declared it (the engine's `resolveSectionPath`), never against cwd or the nearest config. There are no shadowing notices: overriding a parent's key is the mechanism working as intended. -**Why:** the unified CLI claims a filename Prisma 7 already owns; a silently misparsed v7 file is the worst launch bug available, so detection is a structural marker, not a heuristic. Per-section diagnostics exist because one product's config problem must not brick the other products' commands. Reading the file only for commands that need it follows the same logic one step further: a product's broken config cannot reach a command that never asked for config. And validators that throw turn a user's typo into a stack trace instead of a diagnostic with a fix. +The intended layout this serves is the two-config monorepo: one `prisma.config.ts` at the repository root carrying repository-wide settings (deploy target, `skills`), one in the database package carrying its `orm` section. From inside the package, ORM commands read the package's `orm` section; root-scoped sections fall through to the root file; a root `skills.check: false` reaches the package. Neither file names the other — discovery wires them together. + +The version-marker contract is exact, and it applies to every file on the chain. The engine package owns both sides of it: its `definePrismaConfig` stamps the exported config value with a `$prismaConfig` field carrying the config contract version, and its loader checks that field on each file before interpreting anything else. Each failure mode has its own typed, file-level diagnostic carrying the failing file's path: an evaluated file without the marker (in particular a classic Prisma 7 config, which uses the same filename) fails early with `CLI.CONFIG_MISSING_MARKER`; a marker declaring a version this CLI does not support — older or newer, future markers included — fails with `CLI.CONFIG_VERSION_UNSUPPORTED`; a file that cannot be evaluated at all fails with `CLI.CONFIG_UNREADABLE`; a file named by `--config` that does not exist fails with `CLI.CONFIG_NOT_FOUND`; a top-level key that is not a section name fails with `CLI.CONFIG_UNKNOWN_SECTION`, per the next paragraph; and the `parent` directive has its own three — `CLI.CONFIG_PARENT_INVALID` for a value that is neither `false` nor a path string, `CLI.CONFIG_PARENT_NOT_FOUND` for a named parent that is not there, `CLI.CONFIG_PARENT_CYCLE` for a link that closes a loop. Those eight are every file-level config diagnostic there is. `CLI.CONFIG_NOT_FOUND` covers the `--config` file and nothing else: a chain that discovery leaves empty is deliberately not an error, so that code never appears for a run without the flag. One further code exists and is not file-level: a command's own section failing its validator is `CLI.CONFIG_SECTION_INVALID`, which names the section and, through provenance, the contributing files. Each code means one thing, so a consumer branching on it can tell a config written for another CLI from a bad field in one section. No best-effort reading of unmarked files. + +The set of top-level keys is closed, and the check runs per file. Every top-level key names a config section, and the recognised names are exactly the sections the mounted commands and command families declare — so an unrecognised key is reported rather than ignored, and a settings block the user wrote in good faith never disappears in silence, even in a nested file whose sections the running command resolved elsewhere. The engine applies this check itself, in the same place it validates a command's own section, and not in the config loader: the loader is a member of the injected `Runtime`, so a check that lived there would hold only for as long as every host wired a loader that performed it. Two consequences are part of the requirement. First, an unrecognised key is a problem with a file's shape rather than with one section's content, so it is a file-level diagnostic and it fails every command that declares a config section, while commands that need no config keep working. That reach is deliberate: per-section isolation covers a section's content, which is where one product's mistake must not reach another product's commands, and a file whose shape the CLI cannot account for is not a problem any single section owns. Second, the recognised set is derived from the command families and commands a given binary mounts, so a config file's validity depends on the build as well as on the `$prismaConfig` marker — and of those two, only the marker is visible in the file. + +Section names are constrained, and the constraint is enforced when the CLI is constructed rather than when a user runs it. A section may not be named `extends`, which config loaders read as an instruction to merge another file in; nor `parent`, which is the engine's chain directive above; nor `__proto__`, which object merging drops rather than let a config file reach a prototype; nor anything beginning `$`, which the file format keeps for metadata (`$prismaConfig` is the version marker itself). A command or command family that declares such a section fails at construction, so the mistake is a build-time failure for a contributor and never a runtime surprise for a user. + +**Why:** the unified CLI claims a filename Prisma 7 already owns; a silently misparsed v7 file is the worst launch bug available, so detection is a structural marker, not a heuristic. Per-section diagnostics exist because one product's config problem must not brick the other products' commands. Reading the chain only for commands that need it follows the same logic one step further: a product's broken config cannot reach a command that never asked for config. Chain discovery with per-key merging exists for the monorepo layout above, which is already the mainstream pattern; the repository-boundary stop exists because chain files execute, so where they may come from must be bounded by something the user controls. And validators that throw turn a user's typo into a stack trace instead of a diagnostic with a fix. ### R11 — Pinned versions, tandem releases diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index d75f3723..f59a8821 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -101,6 +101,8 @@ The scaffolded config imports `definePrismaConfig` from `prisma/config` and spel `init` calls no platform API, never prompts, and never edits a file the user already owns: a `postinstall` script the user wrote and an existing `prisma.config.ts` are both left alone and reported as diagnostics — the config diagnostic shows the exact `skills: { agents: [...] }` snippet to add by hand. A `prisma` declaration in any dependency field, at any version or range, is likewise left alone; only a project that declares it nowhere gets the dev dependency added. Everything lands in the current directory: the hook in its `package.json`, the config beside it, the skill copies in the agent directories under it. Rerunning is safe; each step reports what is already done and the command exits 0. +`init` acts on the directory it runs in, with no special-casing of the scaffold: init at the repository root makes the root config, init in a package directory makes the package config, and config discovery wires them together. When the discovered config chain already holds a file in another directory — an ancestor `prisma.config.ts`, or one an explicit `parent` names — init writes only the config scaffold: the postinstall hook, the `prisma` dev dependency, and the skills sync belong at the repository root, so each is skipped and reported with that reason (`reason: "governing-config"` in the JSON reports). `--postinstall` opts the manifest edits back in and `--skills` opts the sync back in. Init in a directory with no governing config — the root case — is unchanged. + ### `logs` Resolve a service version and show or stream its logs. diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 8f4efd1e..616858de 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -112,15 +112,15 @@ It is silent when: - `--version` is being invoked, matching the update notification - `PRISMA_SKILLS_CHECK=0` is set - CI is detected -- `prisma.config.ts` sets `skills: { check: false }` +- a `prisma.config.ts` on the discovered config chain sets `skills: { check: false }` — a repository-root config silences it for every subdirectory that does not override the key - the project has run `skills sync --disable`, which records the opt-out in `.prisma/skills.json` at the project root - the command being run is itself a `skills` command This notice covers every project whose install does not resync the skills. `skills sync` itself never edits the user's `package.json` or root `.gitignore`. The synced copies are ordinary files that git tracks like any other file in the repository. Sync removes the `*` ignore file an older CLI wrote into its copies, but leaves a `.gitignore` the user authored in place. -Which agents get skill copies is configuration, never detection: `skills: { agents: [...] }` in `prisma.config.ts` names them, each agent name mapping to its directory — `claude` (`.claude/skills`), `cursor` (`.cursor/skills`), `agents` (`.agents/skills`), `devin` (`.devin/skills`). An unknown name is a config error naming the known agents. When the field or the whole config is absent, the default is every known agent, so a harness adopted later finds the skills already in place. An empty list (`agents: []`, what `prisma init --skills=none` scaffolds) is a recorded choice, not an omission: sync writes nothing and answers `No agents are configured to sync skills for.`, `skills list` reports the same, and the staleness notice never fires. `prisma init` writes the section into a fresh `prisma.config.ts`; a config that already exists is never edited — init reports the exact snippet to add instead. init also adds `prisma` to `devDependencies` at the CLI's exact version when no dependency field declares it, so the scaffolded config's `prisma/config` import resolves after the next install. Everything anchors at the directory the command runs in: sync, list, the staleness notice, and the `.prisma/skills.json` opt-out all read from cwd (the postinstall hook runs with cwd at the package root, so the mainline never guesses). The notice reads the config only when a `prisma.config.ts` exists in cwd and the full agent set already looks out of date, and evaluates it at most once; without a config it uses the default set and the postinstall hook remains the primary resync trigger. +Which agents get skill copies is configuration, never detection: `skills: { agents: [...] }` in `prisma.config.ts` names them, each agent name mapping to its directory — `claude` (`.claude/skills`), `cursor` (`.cursor/skills`), `agents` (`.agents/skills`), `devin` (`.devin/skills`). An unknown name is a config error naming the known agents. When the field or the whole config is absent, the default is every known agent, so a harness adopted later finds the skills already in place. An empty list (`agents: []`, what `prisma init --skills=none` scaffolds) is a recorded choice, not an omission: sync writes nothing and answers `No agents are configured to sync skills for.`, `skills list` reports the same, and the staleness notice never fires. `prisma init` writes the section into a fresh `prisma.config.ts`; a config that already exists is never edited — init reports the exact snippet to add instead. init also adds `prisma` to `devDependencies` at the CLI's exact version when no dependency field declares it, so the scaffolded config's `prisma/config` import resolves after the next install. Everything anchors at the directory the command runs in: sync, list, and the `.prisma/skills.json` opt-out read from cwd (the postinstall hook runs with cwd at the package root, so the mainline never guesses), and the config resolves over the chain discovered from that anchor — cwd, or the file `--config` names — upward to the repository boundary, so the staleness notice and the skills commands agree on the governing settings from any directory. The notice reads the config only when the discovered chain holds at least one file and the full agent set already looks out of date, and evaluates the chain at most once; discovery is stat-only until a file exists, so a project without a config never pays a TypeScript transpile, uses the default agent set, and leans on the postinstall hook as the primary resync trigger. -`Agent skills are up to date.` appears only when installed skills exist and are current — a project with nothing to sync never borrows that line. The three empty states each name themselves: `agents: []` answers `No agents are configured to sync skills for.`; a project with no allowlisted package installed answers `No Prisma packages with agent skills are installed.`; installed packages whose versions ship no skills at all (older releases without a `skills/` directory) answer `No Prisma dependencies in your project ship agent skills to sync.` from sync and `No Prisma dependencies in your project ship agent skills.` from list. The sync JSON result carries a `skills` array naming every skill the installed packages ship, so machine consumers can make the same distinction. `prisma init` reports it in its JSON `skills.outcome`, whose values are `synced`, `up-to-date`, `no-agents`, `no-packages`, `no-skills`, `failed`, and `skipped`. A project where at least one installed package ships skills keeps the ordinary summaries even when another installed package ships none. +`Agent skills are up to date.` appears only when installed skills exist and are current — a project with nothing to sync never borrows that line. The three empty states each name themselves: `agents: []` answers `No agents are configured to sync skills for.`; a project with no allowlisted package installed answers `No Prisma packages with agent skills are installed.`; installed packages whose versions ship no skills at all (older releases without a `skills/` directory) answer `No Prisma dependencies in your project ship agent skills to sync.` from sync and `No Prisma dependencies in your project ship agent skills.` from list. The sync JSON result carries a `skills` array naming every skill the installed packages ship, so machine consumers can make the same distinction. `prisma init` reports it in its JSON `skills.outcome`, whose values are `synced`, `up-to-date`, `no-agents`, `no-packages`, `no-skills`, `failed`, and `skipped`; a `skipped` outcome that no flag explains carries `reason: "governing-config"` — another `prisma.config.ts` on the discovered chain governs the directory, so the step belongs at the repository root — and the JSON `postinstall` report carries the same field for the same case. A project where at least one installed package ships skills keeps the ordinary summaries even when another installed package ships none. A target directory that already holds a `SKILL.md` this CLI did not write is `unmanaged`: sync refuses to replace it, reports each refusal as a `SKILLS.UNMANAGED_DIRECTORY` diagnostic and in the `refused` array of the JSON result, and `skills list` shows `unmanaged` in its State column. An unmanaged directory does not count as out of date — the staleness notice stays silent about it — but the human summary of `skills sync` and `skills list` names it instead of over-claiming: `Agent skills are up to date; 1 directory is not managed by this CLI.` A directory that merely exists without a `SKILL.md` is treated as absent and is written by the next sync. diff --git a/packages/cli-engine/src/execution/needs.ts b/packages/cli-engine/src/execution/needs.ts index ec25b946..332890f2 100644 --- a/packages/cli-engine/src/execution/needs.ts +++ b/packages/cli-engine/src/execution/needs.ts @@ -313,7 +313,7 @@ function validateConfigSection( return { kind: "ok", config: validation.value, - configFiles: loaded.files, + configFiles: Object.freeze(loaded.files), spawnCredential: undefined, }; } diff --git a/packages/cli-engine/tests/config.test.ts b/packages/cli-engine/tests/config.test.ts index 2a9ec0fb..bd4f4832 100644 --- a/packages/cli-engine/tests/config.test.ts +++ b/packages/cli-engine/tests/config.test.ts @@ -38,6 +38,7 @@ import { import { ok } from "@prisma/cli-engine/protocol"; import { createTestCli, type TestCli } from "@prisma/cli-engine/testing"; import { afterAll, describe, expect, test } from "vitest"; +import { CONFIG_FILE_NAME } from "../src/config-loader"; const TESTS_DIR = dirname(fileURLToPath(import.meta.url)); @@ -1241,7 +1242,7 @@ describe("needs.config", { timeout: 60_000 }, () => { code: "CLI.CONFIG_SECTION_INVALID", severity: "error", // The harness seeds the config in the run's cwd ("/"). - summary: "The 'toy' section of /prisma.config.ts is invalid.", + summary: `The 'toy' section of ${resolve("/", CONFIG_FILE_NAME)} is invalid.`, nextActions: [ { kind: "user-choice", diff --git a/packages/cli/src/skills-check.ts b/packages/cli/src/skills-check.ts index 5568a515..76cf7d68 100644 --- a/packages/cli/src/skills-check.ts +++ b/packages/cli/src/skills-check.ts @@ -189,8 +189,8 @@ function isSuppressedByInvocation(runtime: SkillsCheckRuntime): boolean { ); } -/** The file an explicit --config names, so the check reads the same - * config the command did. Discovery is otherwise cwd-only. */ +/** The file an explicit --config names, so the check anchors the same + * chain the command did. Discovery otherwise anchors at cwd. */ function configPathFromArgv(argv: readonly string[]): string | undefined { const tokens = flagTokens(argv); for (let index = 0; index < tokens.length; index += 1) { diff --git a/packages/cli/tests/init.test.ts b/packages/cli/tests/init.test.ts index 037543d9..2e087ce1 100644 --- a/packages/cli/tests/init.test.ts +++ b/packages/cli/tests/init.test.ts @@ -916,6 +916,23 @@ describe("init", () => { }); }); + it("runs every step when discovery finds no config file", async () => { + const root = await makeProjectRoot("init-"); + const cli = createTestCli({ + commands: { init: initCommand }, + loadConfig: async () => ({ files: [], diagnostics: [] }), + now: () => new Date(0), + }); + + const run = await cli.run(["init"], { cwd: root }); + const result = run.presented?.data as InitResult; + + expect(run.exitCode).toBe(0); + expect(result.postinstall.outcome).toBe("added"); + expect(result.config.outcome).toBe("created"); + expect(result.skills.outcome).toBe("no-packages"); + }); + it.skipIf(process.platform === "win32")( "turns a sync failure into a diagnostic on a successful init", async () => { From 3eb998acf432f25fec8dd260d74ca9b2cb0c579d Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 15:22:45 +0200 Subject: [PATCH 13/19] Config resolution review fixes: realpath parent links, user-code merge errors, explicit provenance, strict-ancestor init check, one config load per process, shared realpathOr Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/config-loader.ts | 10 ++- packages/cli-engine/src/config-merge.ts | 94 +++++++++------------- packages/cli-engine/src/execution/needs.ts | 7 +- packages/cli-engine/src/exports/index.ts | 2 +- packages/cli-engine/tests/config.test.ts | 78 ++++++++++++++---- packages/cli-engine/tests/engine.test.ts | 2 +- packages/cli/src/commands/init.ts | 33 ++++---- packages/cli/src/runtime.ts | 22 ++++- packages/cli/tests/bin.test.ts | 13 +++ packages/cli/tests/init.test.ts | 27 +++++++ 10 files changed, 190 insertions(+), 98 deletions(-) diff --git a/packages/cli-engine/src/config-loader.ts b/packages/cli-engine/src/config-loader.ts index e13e74d8..1c63b92a 100644 --- a/packages/cli-engine/src/config-loader.ts +++ b/packages/cli-engine/src/config-loader.ts @@ -371,7 +371,7 @@ function sectionsOf( /** realpath where the path exists; the path itself where it does not * (a cwd that is gone, a comparison candidate that was just checked). */ -function realpathOr(path: string): string { +export function realpathOr(path: string): string { try { return realpathSync(path); } catch { @@ -496,10 +496,14 @@ function followParent( if (!isFileAt(target)) { return { diagnostic: missingParentDiagnostic(path, target) }; } - if (collected.has(realpathOr(target))) { + // Realpath'd like a --config target: a symlinked parent traverses + // and reports the real file, so its own relative parent and the + // discovery above it anchor at the real directory. + const real = realpathOr(target); + if (collected.has(real)) { return { diagnostic: parentCycleDiagnostic(path, target) }; } - return { next: { kind: "file", path: target } }; + return { next: { kind: "file", path: real } }; } if (parent !== undefined) { return { diagnostic: invalidParentDiagnostic(path, parent) }; diff --git a/packages/cli-engine/src/config-merge.ts b/packages/cli-engine/src/config-merge.ts index 8b2532b5..6e8e42a8 100644 --- a/packages/cli-engine/src/config-merge.ts +++ b/packages/cli-engine/src/config-merge.ts @@ -33,11 +33,13 @@ export type ResolvedSection = readonly value: unknown; /** The files declaring the section, nearest first. */ readonly contributors: readonly LoadedConfigFile[]; + /** Where the value came from, for resolveSectionPath. */ + readonly provenance: SectionProvenance; } | { - /** Reading the section's value threw: a property getter in the - * config file is user code, so this is a config error naming - * the file, never an engine bug. */ + /** Reading or merging the section's value threw: the value and a + * custom merge are user code alike, so this is a config error + * naming the file, never an engine bug. */ readonly ok: false; readonly file: string; readonly cause: unknown; @@ -105,22 +107,10 @@ function mergePerKey(parent: unknown, child: unknown): unknown { ]); } -const PROVENANCE = new WeakMap>(); - -function canCarryProvenance(value: unknown): value is object { - return ( - (typeof value === "object" && value !== null) || typeof value === "function" - ); -} - -function registerProvenance( - name: string, +function provenanceOf( value: unknown, contributions: readonly Contribution[], -): void { - if (!canCarryProvenance(value)) { - return; - } +): SectionProvenance { const files = contributions.map((contribution) => contribution.file.path); const keys = isPlainObject(value) ? Object.fromEntries( @@ -131,9 +121,7 @@ function registerProvenance( ]), ) : {}; - const perSection = PROVENANCE.get(value) ?? new Map(); - perSection.set(name, { files, keys }); - PROVENANCE.set(value, perSection); + return { files, keys }; } /** @@ -141,8 +129,8 @@ function registerProvenance( * declaring file's value, folded under each nearer one with the * section's own merge or the engine default. A plain-object result is * always a fresh, frozen object — the files' exports are never - * mutated — and it is annotated with provenance, readable via - * sectionProvenance and resolveSectionPath. + * mutated — and the result carries the provenance resolveSectionPath + * consumes. */ export function resolveSectionOverChain( section: ConfigSection, @@ -162,59 +150,57 @@ export function resolveSectionOverChain( } const contributors = contributions.map((contribution) => contribution.file); if (contributions.length === 0) { - return { ok: true, value: undefined, contributors }; + return { + ok: true, + value: undefined, + contributors, + provenance: { files: [], keys: {} }, + }; } const merge = section.merge ?? mergePerKey; - const merged = contributions - .map((contribution) => contribution.value) - .reduceRight((parent, child) => merge(parent, child)); + let merged: unknown; + try { + merged = contributions + .map((contribution) => contribution.value) + .reduceRight((parent, child) => merge(parent, child)); + } catch (cause) { + // A custom merge is section-author code operating on config-file + // values; a throw names the nearest contributing file, like a + // throwing getter does. + return { ok: false, file: contributions[0].file.path, cause }; + } // Freezing is safe here: every plain object in the fold is a fresh // snapshot or built from one, never a file's own export. const value = isPlainObject(merged) ? Object.freeze(merged) : merged; - registerProvenance(section.name, value, contributions); - return { ok: true, value, contributors }; -} - -/** The provenance of a value resolveSectionOverChain produced for the - * named section, or undefined for any other value. */ -export function sectionProvenance( - section: string, - value: unknown, -): SectionProvenance | undefined { - return canCarryProvenance(value) - ? PROVENANCE.get(value)?.get(section) - : undefined; + return { + ok: true, + value, + contributors, + provenance: provenanceOf(value, contributions), + }; } /** * Resolves a path found under a TOP-LEVEL `key` of a resolved section * value against the file that declared that key — never against cwd or * the nearest config file. Sections opt in by resolving their - * path-valued settings through this on the raw value their validator - * receives; an absolute path comes back unchanged. Throws on a value - * that carries no provenance (it did not come from the engine's - * section resolution) and on a key the resolved value does not carry - * at its top level — a silent fallback could resolve against the wrong - * file, which is the mistake this helper exists to prevent. + * path-valued settings through this with the provenance + * resolveSectionOverChain returned; an absolute path comes back + * unchanged. Throws on a key the resolved value does not carry at its + * top level — a silent fallback could resolve against the wrong file, + * which is the mistake this helper exists to prevent. */ export function resolveSectionPath( - section: string, - sectionValue: unknown, + provenance: SectionProvenance, key: string, path: string, ): string { if (isAbsolute(path)) { return path; } - const provenance = sectionProvenance(section, sectionValue); - if (provenance === undefined) { - throw new Error( - "@prisma/cli-engine: resolveSectionPath needs a value the engine resolved from the config chain, and this one carries no provenance", - ); - } if (!Object.hasOwn(provenance.keys, key)) { throw new Error( - `@prisma/cli-engine: resolveSectionPath resolves only top-level section keys, and '${key}' is not a top-level key of the resolved '${section}' section`, + `@prisma/cli-engine: resolveSectionPath resolves only top-level section keys, and '${key}' is not a top-level key of the resolved section`, ); } return resolve(dirname(provenance.keys[key]), path); diff --git a/packages/cli-engine/src/execution/needs.ts b/packages/cli-engine/src/execution/needs.ts index 332890f2..f42ab33f 100644 --- a/packages/cli-engine/src/execution/needs.ts +++ b/packages/cli-engine/src/execution/needs.ts @@ -318,8 +318,9 @@ function validateConfigSection( }; } -/** A property getter in a section's value is user code; a throw while - * reading it is a config error naming the file, never an engine bug. */ +/** A section value's property getters and a section's custom merge are + * user code alike; a throw from either is a config error naming the + * file, never an engine bug. */ function sectionUnreadableError( name: string, file: string, @@ -328,7 +329,7 @@ function sectionUnreadableError( const message = cause instanceof Error ? cause.message : String(cause); return new CliStructuredError( "CLI.CONFIG_SECTION_INVALID", - `The '${name}' section of ${file} is invalid: reading its value threw '${message.split("\n", 1)[0].trim()}'.`, + `The '${name}' section of ${file} is invalid: resolving its value threw '${message.split("\n", 1)[0].trim()}'.`, { nextActions: [ { diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index cca0a51f..3e653e08 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -47,13 +47,13 @@ export { export { definePrismaConfig, loadConfig, + realpathOr, } from "../config-loader"; export { type ResolvedSection, resolveSectionOverChain, resolveSectionPath, type SectionProvenance, - sectionProvenance, } from "../config-merge"; export { type ConfigSection, diff --git a/packages/cli-engine/tests/config.test.ts b/packages/cli-engine/tests/config.test.ts index bd4f4832..deda0169 100644 --- a/packages/cli-engine/tests/config.test.ts +++ b/packages/cli-engine/tests/config.test.ts @@ -33,7 +33,6 @@ import { resolveSectionOverChain, resolveSectionPath, type SectionValidation, - sectionProvenance, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { createTestCli, type TestCli } from "@prisma/cli-engine/testing"; @@ -531,6 +530,31 @@ describe("chain discovery", { timeout: 60_000 }, () => { }, ); + test.skipIf(process.platform === "win32")( + "a symlinked parent resolves to real paths and discovery resumes from the real directory", + async () => { + const base = sandbox(); + const repo = join(base, "repo"); + markRepository(repo); + const rootFile = writeConfig(repo, `root: {}`); + const sharedFile = writeConfig(join(repo, "shared"), `theirs: {}`); + const anchorFile = writeConfig( + join(base, "anchor"), + `mine: {}, parent: "./link.config.ts"`, + ); + symlinkSync(sharedFile, join(base, "anchor", "link.config.ts"), "file"); + + expect(await loadConfig(join(base, "anchor"))).toEqual({ + files: [ + { path: anchorFile, sections: { mine: {} } }, + { path: sharedFile, sections: { theirs: {} } }, + { path: rootFile, sections: { root: {} } }, + ], + diagnostics: [], + }); + }, + ); + test("a directory named prisma.config.ts is not a config file — discovery skips it", async () => { const repo = join(sandbox(), "repo"); markRepository(repo); @@ -1670,23 +1694,27 @@ describe("sections merge per key over the chain", { timeout: 60_000 }, () => { expect(envelope.error.why).toBeUndefined(); }); - function resolvedValue(files: ReturnType): unknown { + function resolvedOk(files: ReturnType) { const resolved = resolveSectionOverChain(passthroughSection(), files); if (!resolved.ok) { throw new Error(`expected resolution to succeed, got ${resolved.file}`); } - return resolved.value; + return resolved; } - test("sectionProvenance records the contributors and the declaring file per key", () => { - const value = resolvedValue([ + function resolvedValue(files: ReturnType): unknown { + return resolvedOk(files).value; + } + + test("the resolved section carries provenance: the contributors and the declaring file per key", () => { + const resolved = resolvedOk([ { path: PKG, sections: { toy: { out: "./dist", shared: 1 } } }, { path: ROOT, sections: { toy: { migrations: "./migrations", shared: 2 } }, }, ]); - expect(sectionProvenance("toy", value)).toEqual({ + expect(resolved.provenance).toEqual({ files: [PKG, ROOT], keys: { out: PKG, shared: PKG, migrations: ROOT }, }); @@ -1739,37 +1767,53 @@ describe("sections merge per key over the chain", { timeout: 60_000 }, () => { const envelope = erroredEnvelope(run); expect(envelope.error.code).toBe("CLI.CONFIG_SECTION_INVALID"); expect(envelope.error.summary).toBe( - `The 'toy' section of ${PKG} is invalid: reading its value threw 'getter boom'.`, + `The 'toy' section of ${PKG} is invalid: resolving its value threw 'getter boom'.`, + ); + }); + + test("a throwing custom merge is a config error naming the nearest file, not an engine bug", async () => { + const cli = chainCli( + chainFiles({ toy: { greeting: "pkg" } }, { toy: { greeting: "root" } }), + passthroughSection(() => { + throw new Error("merge boom"); + }), + ); + const run = await cli.run(["show", "--json"]); + expect(run.exitCode).toBe(2); + const envelope = erroredEnvelope(run); + expect(envelope.error.code).toBe("CLI.CONFIG_SECTION_INVALID"); + expect(envelope.error.summary).toBe( + `The 'toy' section of ${PKG} is invalid: resolving its value threw 'merge boom'.`, ); }); test("resolveSectionPath resolves a relative path against the file that declared its key", () => { - const value = resolvedValue([ + const { provenance } = resolvedOk([ { path: PKG, sections: { toy: { out: "./dist" } } }, { path: ROOT, sections: { toy: { migrations: "./migrations" } } }, ]); - expect(resolveSectionPath("toy", value, "migrations", "./migrations")).toBe( + expect(resolveSectionPath(provenance, "migrations", "./migrations")).toBe( resolve("/repo", "migrations"), ); - expect(resolveSectionPath("toy", value, "out", "./dist")).toBe( + expect(resolveSectionPath(provenance, "out", "./dist")).toBe( resolve("/repo/pkg", "dist"), ); const absolute = resolve("/somewhere/else"); - expect(resolveSectionPath("toy", value, "out", absolute)).toBe(absolute); + expect(resolveSectionPath(provenance, "out", absolute)).toBe(absolute); }); - test("resolveSectionPath refuses a value the engine did not resolve", () => { - expect(() => - resolveSectionPath("toy", { out: "./x" }, "out", "./x"), - ).toThrow("carries no provenance"); + test("a primitive resolved value still carries provenance naming its contributors", () => { + const resolved = resolvedOk([{ path: PKG, sections: { toy: "compact" } }]); + expect(resolved.value).toBe("compact"); + expect(resolved.provenance).toEqual({ files: [PKG], keys: {} }); }); test("resolveSectionPath refuses a key the resolved section does not carry at its top level", () => { - const value = resolvedValue([ + const { provenance } = resolvedOk([ { path: PKG, sections: { toy: { out: "./dist" } } }, ]); expect(() => - resolveSectionPath("toy", value, "migrations", "./migrations"), + resolveSectionPath(provenance, "migrations", "./migrations"), ).toThrow("only top-level section keys"); }); diff --git a/packages/cli-engine/tests/engine.test.ts b/packages/cli-engine/tests/engine.test.ts index 981238e6..08755f70 100644 --- a/packages/cli-engine/tests/engine.test.ts +++ b/packages/cli-engine/tests/engine.test.ts @@ -41,9 +41,9 @@ describe("main export", () => { "noSessionForWorkspaceError", "positional", "readActiveAccessToken", + "realpathOr", "resolveSectionOverChain", "resolveSectionPath", - "sectionProvenance", "telemetryCommandGroup", ]); }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index d137ed3c..493c13c2 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,4 +1,3 @@ -import { realpathSync } from "node:fs"; import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import type { @@ -6,7 +5,7 @@ import type { LoadedConfigFile, Presentations, } from "@prisma/cli-engine"; -import { defineCommand, flag } from "@prisma/cli-engine"; +import { defineCommand, flag, realpathOr } from "@prisma/cli-engine"; import type { Diagnostic, NextAction } from "@prisma/cli-engine/protocol"; import { CliStructuredError, notOk, ok } from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../cli-name"; @@ -686,26 +685,26 @@ async function syncSkillsStep( } } -function realpathOr(target: string): string { - try { - return realpathSync(target); - } catch { - return target; - } -} - -/** Whether the resolved config chain reaches outside cwd: a - * prisma.config.ts in a directory above, or one an explicit `parent` - * named elsewhere. A config in cwd itself does not count. The chain - * is the one the needs check loaded before the handler ran, so - * init's own scaffold never counts either. Realpath'd on both sides - * so symlinked layouts compare like with like. */ +/** Whether some file on the resolved chain sits in a strict ancestor + * directory of cwd. A config in cwd itself does not count, and neither + * does one reached sideways (--config to a file elsewhere) — the spec + * defers only to an ancestor config. The chain is the one the needs + * check loaded before the handler ran, so init's own scaffold never + * counts either. Realpath'd on both sides so symlinked layouts compare + * like with like. */ function governedByAncestorConfig( files: readonly LoadedConfigFile[], cwd: string, ): boolean { const here = realpathOr(cwd); - return files.some((file) => realpathOr(path.dirname(file.path)) !== here); + return files.some((file) => { + const relative = path.relative(realpathOr(path.dirname(file.path)), here); + return ( + relative !== "" && + !relative.startsWith("..") && + !path.isAbsolute(relative) + ); + }); } const ANCESTOR_SKIPPED_POSTINSTALL: Step = { diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index 1c0b6b42..bfa588e6 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url"; import { type HostProcess, type InputStream, + type LoadedConfig, loadConfig, type Runtime, } from "@prisma/cli-engine"; @@ -96,6 +97,24 @@ function outputStreamsShareDevice(): boolean | undefined { } } +/** One chain discovery and transpile per config path per process: a + * command's needs check and the skills staleness check both load the + * config, and the process is single-shot, so nothing invalidates. The + * promise is cached, not the value, so concurrent calls coalesce. */ +function memoizedConfigLoader( + proc: HostProcess, +): (configPath?: string) => Promise { + const loads = new Map>(); + return (configPath) => { + let load = loads.get(configPath); + if (load === undefined) { + load = loadConfig(proc.cwd(), configPath, getCliVersion()); + loads.set(configPath, load); + } + return load; + }; +} + export async function assembleRuntime(proc: HostProcess): Promise { const stdin: InputStream = { setRawMode: @@ -134,8 +153,7 @@ export async function assembleRuntime(proc: HostProcess): Promise { outputStreamsShareDevice: outputStreamsShareDevice(), exit: (code) => proc.exit(code), onSignal: makeOnSignal(proc), - loadConfig: (configPath) => - loadConfig(proc.cwd(), configPath, getCliVersion()), + loadConfig: memoizedConfigLoader(proc), credentialManager: new FileCredentialManager({ env: proc.env, fetchWorkspaceName: fetchWorkspaceName(apiBaseUrl), diff --git a/packages/cli/tests/bin.test.ts b/packages/cli/tests/bin.test.ts index 7901ebaf..61e34041 100644 --- a/packages/cli/tests/bin.test.ts +++ b/packages/cli/tests/bin.test.ts @@ -297,6 +297,19 @@ describe("assembleRuntime", () => { ); }, 60_000); + it("loads the config once per config path: repeated and concurrent calls share one promise", async () => { + const runtime = await assembleRuntime(makeProcess()); + + const discovered = runtime.loadConfig(); + expect(runtime.loadConfig()).toBe(discovered); + + const named = runtime.loadConfig(`${NAMED_CONFIG_PATH}.gone`); + expect(named).not.toBe(discovered); + expect(runtime.loadConfig(`${NAMED_CONFIG_PATH}.gone`)).toBe(named); + + await Promise.all([discovered, named]); + }); + it("names this CLI's exact version in the install guidance for an unresolvable prisma package", async () => { const runtime = await assembleRuntime(makeProcess()); diff --git a/packages/cli/tests/init.test.ts b/packages/cli/tests/init.test.ts index 2e087ce1..a76b962f 100644 --- a/packages/cli/tests/init.test.ts +++ b/packages/cli/tests/init.test.ts @@ -1109,4 +1109,31 @@ describe("init below an ancestor config", () => { expect(result.config.outcome).toBe("exists"); expect(result.skills.outcome).toBe("no-packages"); }); + + it("a chain reached sideways (--config to a file elsewhere) is not an ancestor and defers nothing", async () => { + const { root, nested } = await makeRepoWithAncestorConfig(); + + // The chain a `--config ../shared/elsewhere.config.ts` run resolves: + // one file in a sibling directory, not on cwd's ancestor path. + const cli = createTestCli({ + commands: { init: initCommand }, + loadConfig: async () => ({ + files: [ + { + path: path.join(root, "shared", "elsewhere.config.ts"), + sections: {}, + }, + ], + diagnostics: [], + }), + now: () => new Date(0), + }); + const run = await cli.run(["init"], { cwd: nested }); + const result = run.presented?.data as InitResult; + + expect(run.exitCode).toBe(0); + expect(result.postinstall.outcome).toBe("added"); + expect(result.postinstall.reason).toBeUndefined(); + expect(result.skills.outcome).toBe("no-packages"); + }); }); From cb83ae575b730ca2eb1fe212076a1d75f59ceea4 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 15:23:57 +0200 Subject: [PATCH 14/19] Ledger: two config-chain review findings whose fixes live outside this repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The family dists resolve root-declared section paths against cwd until they adopt declaring-file resolution (briefs written for both repos), and a marker-less Prisma 7 root config blocks config-needing commands repo-wide under the ratified no-skipping rule — softening that needs an operator ruling. Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index c5e4e50d..c5e23060 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -515,3 +515,10 @@ The design in `specs/config-file-resolution.md` is decided (per-key merge with s ## Left open by the config-file-resolution slice (2026-08-25) - **`--config` does not reach the post-login skills tip.** `packages/cli/src/commands/auth/agent-setup-tip.ts` calls `readProjectSkillsConfig` via `projectConfigLoader(ctx.cwd)` — the disk loader anchored at cwd — because `CommandContext` does not expose the parsed `--config` flag to that path. A login run with `--config` shows the tip against the cwd-anchored chain instead of the named file's. Recorded from D3 review; pre-existing shape, low impact. + +## Config-chain review findings deferred out of the slice (2026-08-25) + +Post-merge review of the config-chain slice confirmed two issues whose fixes do not live in this repo: + +- **Root-declared family sections resolve their relative paths against cwd.** The chain now delivers a root config's `composer`/`orm` sections to subdirectory runs, but the pinned family dists resolve `configPath`, contract inputs/output, and `migrations.dir` against `ctx.cwd` — they predate `resolveSectionPath`. Until composer-cli and orm-toolchain adopt declaring-file resolution, a root-declared relative path mis-resolves from subdirectories (wrong-path missing-file error, or silently the wrong file). Briefs delivered: `wip/composer-declaring-file-paths-brief.md`, `wip/orm-declaring-file-paths-brief.md`. Companion engine work: expose section provenance to validators/handlers (today it stops inside `resolveSectionOverChain`'s result) — coordinate the seam when the first family adopts it. +- **A marker-less Prisma 7 `prisma.config.ts` at the repo root blocks every config-needing command — including `prisma init` — in every subdirectory.** Chain evaluation is deliberately no-skip (ratified: a broken file anywhere fails resolution), and the missing-marker error is chain-fatal, so a repo migrating from Prisma 7 cannot run the v8 migration entry point anywhere until the old root config is updated or removed. The error does name the file and the fix. Softening this (for example, treating a marker-less ANCESTOR file as a warning while keeping cwd's own file fatal) would change the ratified no-skipping rule, so it needs an operator ruling before anyone implements it. From 056d38fb911314f2b3d25e48e8936fb2490c9bc3 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 16:44:59 +0200 Subject: [PATCH 15/19] Config section validators receive the resolved value's provenance ConfigSection.validate gains a second parameter, the SectionProvenance that resolveSectionOverChain produced, so a validator can resolve its path-valued keys through resolveSectionPath against the declaring file and return absolute paths. One-argument validators remain assignable and keep working. The engine's needs check and the skills config reader pass the provenance; the conformance package's structural section type follows. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/architecture/cli-engine-requirements.md | 2 +- .../src/checks/validator-no-throw.ts | 2 +- packages/cli-conformance/src/subjects.ts | 8 ++- packages/cli-engine/src/config-merge.ts | 8 +-- packages/cli-engine/src/config-section.ts | 17 ++++-- packages/cli-engine/src/execution/needs.ts | 2 +- packages/cli-engine/tests/config.test.ts | 54 +++++++++++++++++++ packages/cli-engine/tests/engine.type-test.ts | 22 ++++++++ packages/cli/src/commands/skills/config.ts | 5 +- 9 files changed, 108 insertions(+), 12 deletions(-) diff --git a/docs/architecture/cli-engine-requirements.md b/docs/architecture/cli-engine-requirements.md index 6d11b239..582eb83a 100644 --- a/docs/architecture/cli-engine-requirements.md +++ b/docs/architecture/cli-engine-requirements.md @@ -163,7 +163,7 @@ Discovery collects every `prisma.config.ts` from the anchor directory upward, st Absence is treated per how the file entered the chain. Discovery finding no file anywhere is not an error — the section validators own absence and supply their defaults. A file named by `--config` that is not there IS an error, `CLI.CONFIG_NOT_FOUND`, because the user said which file to run against and the CLI would otherwise run against different settings; a file a `parent` key names that is not there is likewise an error, `CLI.CONFIG_PARENT_NOT_FOUND`. A broken file anywhere on the chain fails the command with a diagnostic naming that file; no skipping. -Sections resolve per key over the chain, the most-local value winning — the layering model ESLint and tsconfig users already know. A nested file shadows only the keys it actually writes, so a repository-root `skills: { check: false }` reaches every subdirectory that does not override it, and a root-level section falls through to subdirectories that never mention it. Merge semantics belong to the section's owner: `ConfigSection` carries an optional `merge(parent, child)`, and the engine default — for sections that do not customise — merges per key at the section's top level and replaces below. Required-section validation runs after the merge, on the resolved view, so a nested partial and a root partial may complete each other; validators keep their existing contract and see one value. Merging never mutates a file's frozen export. Every resolved value carries provenance — which file contributed it, per top-level key — so post-merge diagnostics name the file to fix, and a relative path in a config resolves against the file that declared it (the engine's `resolveSectionPath`), never against cwd or the nearest config. There are no shadowing notices: overriding a parent's key is the mechanism working as intended. +Sections resolve per key over the chain, the most-local value winning — the layering model ESLint and tsconfig users already know. A nested file shadows only the keys it actually writes, so a repository-root `skills: { check: false }` reaches every subdirectory that does not override it, and a root-level section falls through to subdirectories that never mention it. Merge semantics belong to the section's owner: `ConfigSection` carries an optional `merge(parent, child)`, and the engine default — for sections that do not customise — merges per key at the section's top level and replaces below. Required-section validation runs after the merge, on the resolved view, so a nested partial and a root partial may complete each other; a validator sees one value, plus that value's provenance as a second argument (`validate(raw, provenance)` — a one-argument validator remains a valid implementation). Merging never mutates a file's frozen export. Every resolved value carries provenance — which file contributed it, per top-level key — so post-merge diagnostics name the file to fix, and a relative path in a config resolves against the file that declared it, never against cwd or the nearest config: a section with path-valued keys resolves each in its validator through the engine's `resolveSectionPath(provenance, key, path)` and returns absolute paths, so downstream command code never resolves them again. There are no shadowing notices: overriding a parent's key is the mechanism working as intended. The intended layout this serves is the two-config monorepo: one `prisma.config.ts` at the repository root carrying repository-wide settings (deploy target, `skills`), one in the database package carrying its `orm` section. From inside the package, ORM commands read the package's `orm` section; root-scoped sections fall through to the root file; a root `skills.check: false` reaches the package. Neither file names the other — discovery wires them together. diff --git a/packages/cli-conformance/src/checks/validator-no-throw.ts b/packages/cli-conformance/src/checks/validator-no-throw.ts index e8577097..c72f4420 100644 --- a/packages/cli-conformance/src/checks/validator-no-throw.ts +++ b/packages/cli-conformance/src/checks/validator-no-throw.ts @@ -120,7 +120,7 @@ function checkOne( for (const hostile of corpus) { let returned: unknown; try { - returned = section.validate(hostile.make()); + returned = section.validate(hostile.make(), { files: [], keys: {} }); } catch (error) { threw.push(hostile.label); firstError ??= error instanceof Error ? error.message : String(error); diff --git a/packages/cli-conformance/src/subjects.ts b/packages/cli-conformance/src/subjects.ts index 1d78f7c5..3bf8bac5 100644 --- a/packages/cli-conformance/src/subjects.ts +++ b/packages/cli-conformance/src/subjects.ts @@ -7,7 +7,13 @@ */ export interface CheckableSection { readonly name: string; - readonly validate: (raw: unknown) => unknown; + readonly validate: (raw: unknown, provenance: CheckableProvenance) => unknown; +} + +/** The engine's `SectionProvenance`, structurally, for the same reason. */ +export interface CheckableProvenance { + readonly files: readonly string[]; + readonly keys: Readonly>; } /** diff --git a/packages/cli-engine/src/config-merge.ts b/packages/cli-engine/src/config-merge.ts index 6e8e42a8..3c7c6109 100644 --- a/packages/cli-engine/src/config-merge.ts +++ b/packages/cli-engine/src/config-merge.ts @@ -183,10 +183,10 @@ export function resolveSectionOverChain( /** * Resolves a path found under a TOP-LEVEL `key` of a resolved section * value against the file that declared that key — never against cwd or - * the nearest config file. Sections opt in by resolving their - * path-valued settings through this with the provenance - * resolveSectionOverChain returned; an absolute path comes back - * unchanged. Throws on a key the resolved value does not carry at its + * the nearest config file. The intended caller is a section validator, + * which receives the provenance as its second argument and returns the + * resolved absolute path in its validated value; an absolute path comes + * back unchanged. Throws on a key the resolved value does not carry at its * top level — a silent fallback could resolve against the wrong file, * which is the mistake this helper exists to prevent. */ diff --git a/packages/cli-engine/src/config-section.ts b/packages/cli-engine/src/config-section.ts index 92972951..7893a9f0 100644 --- a/packages/cli-engine/src/config-section.ts +++ b/packages/cli-engine/src/config-section.ts @@ -1,3 +1,4 @@ +import type { SectionProvenance } from "./config-merge"; import type { Diagnostic } from "./protocol"; /** @@ -5,11 +6,18 @@ import type { Diagnostic } from "./protocol"; * the section name, its validated type, and its total validator. The * validator owns absence: its input is the raw section value, or * undefined when the config file has no such section. It returns - * findings; it never throws. + * findings; it never throws. Its second argument is the resolved + * value's provenance: a validator with path-valued keys resolves each + * through resolveSectionPath(provenance, key, path) and returns + * absolute paths, so downstream code never resolves against cwd. A + * one-argument validator stays valid for sections with no paths. */ export interface ConfigSection { readonly name: string; - readonly validate: (raw: unknown | undefined) => SectionValidation; + readonly validate: ( + raw: unknown | undefined, + provenance: SectionProvenance, + ) => SectionValidation; /** * How two files' raw values combine when more than one file on the * config chain declares the section. `child` is the nearer file's @@ -36,7 +44,10 @@ export type SectionValidation = export function defineConfigSection(spec: { readonly name: string; - readonly validate: (raw: unknown | undefined) => SectionValidation; + readonly validate: ( + raw: unknown | undefined, + provenance: SectionProvenance, + ) => SectionValidation; readonly merge?: (parent: unknown, child: unknown) => unknown; }): ConfigSection { return Object.freeze({ diff --git a/packages/cli-engine/src/execution/needs.ts b/packages/cli-engine/src/execution/needs.ts index f42ab33f..b569f3ed 100644 --- a/packages/cli-engine/src/execution/needs.ts +++ b/packages/cli-engine/src/execution/needs.ts @@ -293,7 +293,7 @@ function validateConfigSection( } let validation: SectionValidation; try { - validation = section.validate(resolved.value); + validation = section.validate(resolved.value, resolved.provenance); } catch (cause) { return { kind: "bug", diff --git a/packages/cli-engine/tests/config.test.ts b/packages/cli-engine/tests/config.test.ts index deda0169..bfd1fc42 100644 --- a/packages/cli-engine/tests/config.test.ts +++ b/packages/cli-engine/tests/config.test.ts @@ -32,6 +32,7 @@ import { type Runtime, resolveSectionOverChain, resolveSectionPath, + type SectionProvenance, type SectionValidation, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; @@ -1802,6 +1803,59 @@ describe("sections merge per key over the chain", { timeout: 60_000 }, () => { expect(resolveSectionPath(provenance, "out", absolute)).toBe(absolute); }); + test("the validator receives the resolved section's provenance and can return declaring-file-absolute paths", async () => { + const seen: SectionProvenance[] = []; + const section = defineConfigSection({ + name: "toy", + validate: (raw, provenance) => { + seen.push(provenance); + const { out, migrations } = raw as { + out: string; + migrations: string; + }; + return { + ok: true, + value: { + out: resolveSectionPath(provenance, "out", out), + migrations: resolveSectionPath( + provenance, + "migrations", + migrations, + ), + }, + diagnostics: [], + }; + }, + }); + const cli = chainCli( + chainFiles( + { toy: { out: "./dist" } }, + { toy: { migrations: "./migrations" } }, + ), + section, + ); + const run = await cli.run(["show"], { isTty: { stdout: true } }); + expect(run.exitCode).toBe(0); + expect(seen).toEqual([ + { files: [PKG, ROOT], keys: { out: PKG, migrations: ROOT } }, + ]); + expect(run.presented?.data).toEqual({ + out: resolve("/repo/pkg", "dist"), + migrations: resolve("/repo", "migrations"), + }); + }); + + test("a one-argument validator keeps working unchanged", async () => { + const oneArg = defineConfigSection({ + name: "toy", + validate: (raw) => ({ ok: true, value: raw, diagnostics: [] }), + }); + const cli = chainCli(chainFiles({ toy: { greeting: "pkg" } }, {}), oneArg); + const run = await cli.run(["show"], { isTty: { stdout: true } }); + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toEqual({ greeting: "pkg" }); + }); + test("a primitive resolved value still carries provenance naming its contributors", () => { const resolved = resolvedOk([{ path: PKG, sections: { toy: "compact" } }]); expect(resolved.value).toBe("compact"); diff --git a/packages/cli-engine/tests/engine.type-test.ts b/packages/cli-engine/tests/engine.type-test.ts index 9021cbf4..d0b080c9 100644 --- a/packages/cli-engine/tests/engine.type-test.ts +++ b/packages/cli-engine/tests/engine.type-test.ts @@ -25,6 +25,7 @@ import type { PresentedResult, RedirectSpec, Runtime, + SectionProvenance, SectionValidation, Session, StreamEvent, @@ -38,6 +39,7 @@ import { defineSessionCommand, flag, positional, + resolveSectionPath, } from "@prisma/cli-engine"; import type { CliStructuredError, @@ -147,6 +149,8 @@ interface CheckCfg { readonly strict: boolean; } +// A one-argument validator stays assignable to the two-parameter +// declared type of ConfigSection.validate. export const checkSection: ConfigSection = defineConfigSection({ name: "check", validate: (raw): SectionValidation => @@ -155,6 +159,24 @@ export const checkSection: ConfigSection = defineConfigSection({ : { ok: true, value: { strict: true }, diagnostics: [] }, }); +// The validator's second parameter is the resolved value's provenance, +// typed for resolveSectionPath without annotation. +export const pathSection: ConfigSection<{ readonly out: string }> = + defineConfigSection({ + name: "paths", + validate: ( + raw, + provenance, + ): SectionValidation<{ readonly out: string }> => { + const typed: SectionProvenance = provenance; + return { + ok: true, + value: { out: resolveSectionPath(typed, "out", String(raw)) }, + diagnostics: [], + }; + }, + }); + export const checkCommand = defineCommand({ help: { summary: "Check the project" }, args: { diff --git a/packages/cli/src/commands/skills/config.ts b/packages/cli/src/commands/skills/config.ts index f80bbb60..670cab25 100644 --- a/packages/cli/src/commands/skills/config.ts +++ b/packages/cli/src/commands/skills/config.ts @@ -166,7 +166,10 @@ export async function readProjectSkillsConfig( if (!resolved.ok) { return null; } - const section = skillsConfigSection.validate(resolved.value); + const section = skillsConfigSection.validate( + resolved.value, + resolved.provenance, + ); return section.ok ? section.value : null; } From 1167cd377861842810c5ee7782c80544d215e385 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 17:30:00 +0200 Subject: [PATCH 16/19] Ledger: the family path fix shipped in tandem, not deferred The operator ruled the cwd-relative path resolution in the composer and orm sections was not acceptable to defer, so the engine grew the provenance seam and both families adopted it. Records the two upstream PRs, the one finding that is still open and needs a ruling, and the 307-file defineConfig rename the alias removal turned out to require in the orm repository. Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index c5e23060..34a7c4fc 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -516,9 +516,11 @@ The design in `specs/config-file-resolution.md` is decided (per-key merge with s - **`--config` does not reach the post-login skills tip.** `packages/cli/src/commands/auth/agent-setup-tip.ts` calls `readProjectSkillsConfig` via `projectConfigLoader(ctx.cwd)` — the disk loader anchored at cwd — because `CommandContext` does not expose the parsed `--config` flag to that path. A login run with `--config` shows the tip against the cwd-anchored chain instead of the named file's. Recorded from D3 review; pre-existing shape, low impact. -## Config-chain review findings deferred out of the slice (2026-08-25) +## Config-chain review findings: what shipped and what is still open (2026-08-25) -Post-merge review of the config-chain slice confirmed two issues whose fixes do not live in this repo: +Post-merge review of the config-chain slice confirmed two issues that could not be fixed inside prisma-cli alone. The operator ruled that the family path resolution is not acceptable to defer, so it was fixed in tandem across all three repositories: -- **Root-declared family sections resolve their relative paths against cwd.** The chain now delivers a root config's `composer`/`orm` sections to subdirectory runs, but the pinned family dists resolve `configPath`, contract inputs/output, and `migrations.dir` against `ctx.cwd` — they predate `resolveSectionPath`. Until composer-cli and orm-toolchain adopt declaring-file resolution, a root-declared relative path mis-resolves from subdirectories (wrong-path missing-file error, or silently the wrong file). Briefs delivered: `wip/composer-declaring-file-paths-brief.md`, `wip/orm-declaring-file-paths-brief.md`. Companion engine work: expose section provenance to validators/handlers (today it stops inside `resolveSectionOverChain`'s result) — coordinate the seam when the first family adopts it. -- **A marker-less Prisma 7 `prisma.config.ts` at the repo root blocks every config-needing command — including `prisma init` — in every subdirectory.** Chain evaluation is deliberately no-skip (ratified: a broken file anywhere fails resolution), and the missing-marker error is chain-fatal, so a repo migrating from Prisma 7 cannot run the v8 migration entry point anywhere until the old root config is updated or removed. The error does name the file and the fix. Softening this (for example, treating a marker-less ANCESTOR file as a warning while keeping cwd's own file fatal) would change the ratified no-skipping rule, so it needs an operator ruling before anyone implements it. +- **Root-declared family sections resolved their relative paths against cwd — FIXED, in tandem.** The chain delivers a root config's `composer`/`orm` sections to subdirectory runs, and the family packages resolved `configPath`, contract inputs/output, and `migrations.dir` against `ctx.cwd`. The engine gained the seam that makes declaring-file resolution possible: `ConfigSection.validate` now takes a second argument, `validate(raw, provenance)`, and a validator resolves its path-valued keys through `resolveSectionPath(provenance, key, path)`, returning absolute paths so downstream code never resolves against cwd. A one-argument validator stays assignable, so shipped sections keep working. Upstream adoption: composer https://github.com/prisma/composer/pull/262 and orm https://github.com/prisma/prisma/pull/30128, both waiting on engine 0.3.0 publishing before their CI can install. +- **A marker-less Prisma 7 `prisma.config.ts` at the repo root blocks every config-needing command — including `prisma init` — in every subdirectory. STILL OPEN, needs an operator ruling.** Chain evaluation is deliberately no-skip (ratified: a broken file anywhere fails resolution), and the missing-marker error is chain-fatal, so a repository migrating from Prisma 7 cannot run the v8 migration entry point anywhere until the old root config is updated or removed. The error does name the file and the fix. Softening it — for example, treating a marker-less *ancestor* as a warning while keeping cwd's own file fatal — would weaken the ratified no-skipping rule, because a genuinely broken v8 ancestor config would then be ignored with only a warning. Not to be implemented without a ruling. + +Discovered while doing the above: **engine 0.3.0's removal of the deprecated `defineConfig` alias breaks 307 files in the orm repository** that still import it from `@prisma/cli-engine` (mostly `prisma.config.ts` test fixtures). `definePrismaConfig` has been the name since engine 0.2.0, so the rename lands on the current 0.2.3 pin without waiting for anything; it is a prerequisite for the orm engine bump. Composer is unaffected. From 3153d0854c3f76ccde5f92edf4b210558b0f14c1 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 26 Aug 2026 14:51:26 +0200 Subject: [PATCH 17/19] Address review: --config reaches the post-login tip; docs match --config discovery - CommandContext exposes configPath (the file --config named), and the post-login skills tip passes it to readProjectSkillsConfig, so a login run with --config reads the chain the rest of the run read. Regression test added. - The conformance validator-no-throw check supplies the provenance the engine would: plain-object hostile inputs get a declaring file per top-level key, so a validator that resolves paths through resolveSectionPath no longer reports a false failure. - config-loader.ts and runtime.ts doc comments now state that with --config the named file is the first chain link and discovery resumes strictly above its directory. - The duplicated isolated-empty-directory test body is one helper. - deferred.md records the operator ruling: a marker-less Prisma 7 ancestor config stays chain-fatal; no warn-and-ignore softening. Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 4 +- .../src/checks/validator-no-throw.ts | 37 ++++++++++++++++++- packages/cli-engine/src/config-loader.ts | 10 +++-- packages/cli-engine/src/context.ts | 8 ++++ .../src/execution/command-context.ts | 1 + packages/cli-engine/src/runtime.ts | 6 ++- packages/cli-engine/tests/config.test.ts | 33 ++++++++--------- .../cli/src/commands/auth/agent-setup-tip.ts | 8 +++- packages/cli/tests/agent-setup-tip.test.ts | 34 ++++++++++++++++- 9 files changed, 110 insertions(+), 31 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index 34a7c4fc..27fcc992 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -514,13 +514,13 @@ The design in `specs/config-file-resolution.md` is decided (per-key merge with s ## Left open by the config-file-resolution slice (2026-08-25) -- **`--config` does not reach the post-login skills tip.** `packages/cli/src/commands/auth/agent-setup-tip.ts` calls `readProjectSkillsConfig` via `projectConfigLoader(ctx.cwd)` — the disk loader anchored at cwd — because `CommandContext` does not expose the parsed `--config` flag to that path. A login run with `--config` shows the tip against the cwd-anchored chain instead of the named file's. Recorded from D3 review; pre-existing shape, low impact. +- **`--config` does not reach the post-login skills tip — FIXED 2026-08-26.** `CommandContext` now exposes `configPath` (the file `--config` named, undefined otherwise), and `resolveAgentSetupTipCommand` passes it to `readProjectSkillsConfig`, so a login run with `--config` shows the tip against the named file's chain. Regression-tested in `packages/cli/tests/agent-setup-tip.test.ts`. ## Config-chain review findings: what shipped and what is still open (2026-08-25) Post-merge review of the config-chain slice confirmed two issues that could not be fixed inside prisma-cli alone. The operator ruled that the family path resolution is not acceptable to defer, so it was fixed in tandem across all three repositories: - **Root-declared family sections resolved their relative paths against cwd — FIXED, in tandem.** The chain delivers a root config's `composer`/`orm` sections to subdirectory runs, and the family packages resolved `configPath`, contract inputs/output, and `migrations.dir` against `ctx.cwd`. The engine gained the seam that makes declaring-file resolution possible: `ConfigSection.validate` now takes a second argument, `validate(raw, provenance)`, and a validator resolves its path-valued keys through `resolveSectionPath(provenance, key, path)`, returning absolute paths so downstream code never resolves against cwd. A one-argument validator stays assignable, so shipped sections keep working. Upstream adoption: composer https://github.com/prisma/composer/pull/262 and orm https://github.com/prisma/prisma/pull/30128, both waiting on engine 0.3.0 publishing before their CI can install. -- **A marker-less Prisma 7 `prisma.config.ts` at the repo root blocks every config-needing command — including `prisma init` — in every subdirectory. STILL OPEN, needs an operator ruling.** Chain evaluation is deliberately no-skip (ratified: a broken file anywhere fails resolution), and the missing-marker error is chain-fatal, so a repository migrating from Prisma 7 cannot run the v8 migration entry point anywhere until the old root config is updated or removed. The error does name the file and the fix. Softening it — for example, treating a marker-less *ancestor* as a warning while keeping cwd's own file fatal — would weaken the ratified no-skipping rule, because a genuinely broken v8 ancestor config would then be ignored with only a warning. Not to be implemented without a ruling. +- **A marker-less Prisma 7 `prisma.config.ts` at the repo root blocks every config-needing command — including `prisma init` — in every subdirectory. RULED 2026-08-26: keep it fatal.** Chain evaluation is deliberately no-skip (ratified: a broken file anywhere fails resolution), and the missing-marker error is chain-fatal, so a repository migrating from Prisma 7 cannot run the v8 migration entry point anywhere until the old root config is updated or removed. The error does name the file and the fix. The operator ruled the case is not worth an escape path: erroring out without doing anything untoward is safe, if inconvenient, and a Prisma 8 install inside a Prisma 7 repository needs far more plumbing than this anyway. No warn-and-ignore softening; the no-skip rule stands as ratified. Discovered while doing the above: **engine 0.3.0's removal of the deprecated `defineConfig` alias breaks 307 files in the orm repository** that still import it from `@prisma/cli-engine` (mostly `prisma.config.ts` test fixtures). `definePrismaConfig` has been the name since engine 0.2.0, so the rename lands on the current 0.2.3 pin without waiting for anything; it is a prerequisite for the orm engine bump. Composer is unaffected. diff --git a/packages/cli-conformance/src/checks/validator-no-throw.ts b/packages/cli-conformance/src/checks/validator-no-throw.ts index c72f4420..322a9dab 100644 --- a/packages/cli-conformance/src/checks/validator-no-throw.ts +++ b/packages/cli-conformance/src/checks/validator-no-throw.ts @@ -1,5 +1,5 @@ import type { Finding } from "../findings"; -import type { CheckableSection } from "../subjects"; +import type { CheckableProvenance, CheckableSection } from "../subjects"; /** One hostile value, with a label that names it in a finding. */ export interface HostileInput { @@ -119,8 +119,9 @@ function checkOne( let firstError: string | undefined; for (const hostile of corpus) { let returned: unknown; + const value = hostile.make(); try { - returned = section.validate(hostile.make(), { files: [], keys: {} }); + returned = section.validate(value, provenanceFor(value)); } catch (error) { threw.push(hostile.label); firstError ??= error instanceof Error ? error.message : String(error); @@ -152,6 +153,38 @@ function checkOne( ]; } +const SYNTHETIC_DECLARING_FILE = "/conformance-hostile/prisma.config.ts"; + +/** + * The provenance the engine would supply: every top-level key of a + * plain-object section value maps to a declaring file, so a validator + * that calls resolveSectionPath on a well-formed hostile input (a + * frozen `{ configPath: "x" }`) resolves instead of throwing. Any other + * value — and any object whose key enumeration itself throws — gets + * empty provenance, matching the engine's atomic-carry behaviour. + */ +function provenanceFor(value: unknown): CheckableProvenance { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { files: [], keys: {} }; + } + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) { + return { files: [], keys: {} }; + } + let names: string[]; + try { + names = Object.keys(value); + } catch { + return { files: [], keys: {} }; + } + return { + files: [SYNTHETIC_DECLARING_FILE], + keys: Object.fromEntries( + names.map((name) => [name, SYNTHETIC_DECLARING_FILE]), + ), + }; +} + function finding( kind: Finding["kind"], subject: string, diff --git a/packages/cli-engine/src/config-loader.ts b/packages/cli-engine/src/config-loader.ts index 1c63b92a..80a92300 100644 --- a/packages/cli-engine/src/config-loader.ts +++ b/packages/cli-engine/src/config-loader.ts @@ -3,10 +3,12 @@ * ordered chain of config files, evaluate each, check every one for the * definePrismaConfig version marker, and produce LoadedConfig. * - * Discovery starts at the anchor directory — cwd, or with `--config` - * the named file's own directory — and walks upward collecting every - * prisma.config.ts, stopping at the repository boundary: the first - * directory containing `.git`. No `.git` at or above the anchor means + * Without `--config`, discovery starts at cwd and walks upward + * collecting every prisma.config.ts, stopping at the repository + * boundary: the first directory containing `.git`. With `--config`, + * the named file is the first chain link and discovery resumes + * strictly above its directory — a prisma.config.ts beside the named + * file is never collected. No `.git` at or above the anchor means * the anchor directory only — every file on the chain is executed * TypeScript, and nothing outside the repository runs without explicit * consent. A file may end collection itself with `parent: false`, or diff --git a/packages/cli-engine/src/context.ts b/packages/cli-engine/src/context.ts index 8350b14e..1e33be70 100644 --- a/packages/cli-engine/src/context.ts +++ b/packages/cli-engine/src/context.ts @@ -26,6 +26,14 @@ export interface CommandContext< */ readonly configFiles: ReadonlyArray; + /** + * The file `--config` named, exactly as the user wrote it, or + * undefined when the flag was absent. Handlers that load config out + * of band (outside needs.config) pass it on, so the file the user + * named governs those reads too. + */ + readonly configPath: string | undefined; + /** * Builds the PresentedResult for the active format. The only * constructor of PresentedResult. diff --git a/packages/cli-engine/src/execution/command-context.ts b/packages/cli-engine/src/execution/command-context.ts index 6d068d8b..bea5e210 100644 --- a/packages/cli-engine/src/execution/command-context.ts +++ b/packages/cli-engine/src/execution/command-context.ts @@ -145,6 +145,7 @@ export function makeContext( const context: CommandContext = { config, configFiles, + configPath: state.configPath, present: present as CommandContext["present"], activeCredential: (): Promise => invocation.runtime.credentialManager?.activeCredential() ?? diff --git a/packages/cli-engine/src/runtime.ts b/packages/cli-engine/src/runtime.ts index b76700fc..7f4c3da2 100644 --- a/packages/cli-engine/src/runtime.ts +++ b/packages/cli-engine/src/runtime.ts @@ -74,8 +74,10 @@ export interface Runtime { * only when the command it is about to run declares a config section, * so a run that needs no config never touches a file. `configPath` is * the file `--config` named: the loader resolves it against the - * runtime's cwd, reports its absence, and anchors discovery at its - * directory. Absent means discover from cwd, where finding no file is + * runtime's cwd, reports its absence, makes it the first chain link, + * and resumes discovery strictly above its directory — a + * prisma.config.ts beside it is not collected. Absent means + * discover from cwd, where finding no file is * not an error. The bin wires the real disk loader; tests hand in * fixtures. */ diff --git a/packages/cli-engine/tests/config.test.ts b/packages/cli-engine/tests/config.test.ts index bfd1fc42..1b7fd61c 100644 --- a/packages/cli-engine/tests/config.test.ts +++ b/packages/cli-engine/tests/config.test.ts @@ -55,6 +55,19 @@ const FIXTURES = realpathSync(join(TESTS_DIR, "fixtures", "config")); const EPOCH = () => new Date(0); const T0 = "1970-01-01T00:00:00.000Z"; +/** In a temp directory outside the repository: an anchor with no + * config anywhere yields an empty chain and no error. */ +async function expectEmptyChainInIsolatedDirectory(): Promise { + const empty = realpathSync( + mkdtempSync(join(tmpdir(), "prisma-config-empty-")), + ); + try { + expect(await loadConfig(empty)).toEqual({ files: [], diagnostics: [] }); + } finally { + rmSync(empty, { recursive: true, force: true }); + } +} + describe("definePrismaConfig", () => { test("stamps the version marker on the config object", () => { expect(definePrismaConfig({ toy: { greeting: "hi" } })).toEqual({ @@ -77,17 +90,8 @@ describe("loadConfig", { timeout: 60_000 }, () => { }); }); - /** In a temp directory outside the repository: an anchor with no - * config anywhere is the one shape parent: false cannot pin. */ test("no file at all yields an empty chain and no error — validators own absence", async () => { - const empty = realpathSync( - mkdtempSync(join(tmpdir(), "prisma-config-empty-")), - ); - try { - expect(await loadConfig(empty)).toEqual({ files: [], diagnostics: [] }); - } finally { - rmSync(empty, { recursive: true, force: true }); - } + await expectEmptyChainInIsolatedDirectory(); }); test("discovery walks upward: a parent directory's config is the chain from a bare subdirectory", async () => { @@ -836,14 +840,7 @@ describe("--config", { timeout: 60_000 }, () => { }); test("without the flag, a missing prisma.config.ts stays an empty config", async () => { - const empty = realpathSync( - mkdtempSync(join(tmpdir(), "prisma-config-empty-")), - ); - try { - expect(await loadConfig(empty)).toEqual({ files: [], diagnostics: [] }); - } finally { - rmSync(empty, { recursive: true, force: true }); - } + await expectEmptyChainInIsolatedDirectory(); }); /** A diagnostic's summary is the line a user reads first, so it has diff --git a/packages/cli/src/commands/auth/agent-setup-tip.ts b/packages/cli/src/commands/auth/agent-setup-tip.ts index 37f0d84a..5e8a4451 100644 --- a/packages/cli/src/commands/auth/agent-setup-tip.ts +++ b/packages/cli/src/commands/auth/agent-setup-tip.ts @@ -13,6 +13,9 @@ const SKILLS_SYNC_ARGS = ["skills", "sync"] as const; export interface AgentSetupTipContext { readonly cwd: string; + /** The file `--config` named, so the tip reads the config the rest + * of the run read. */ + readonly configPath: string | undefined; readonly env: Readonly>; readonly signal: AbortSignal; } @@ -28,7 +31,10 @@ export async function resolveAgentSetupTipCommand( // status scan or command resolver cannot read must not fail a login // that succeeded. try { - const config = await readProjectSkillsConfig(projectConfigLoader(ctx.cwd)); + const config = await readProjectSkillsConfig( + projectConfigLoader(ctx.cwd), + ctx.configPath, + ); if (config !== null && !config.check) { return null; } diff --git a/packages/cli/tests/agent-setup-tip.test.ts b/packages/cli/tests/agent-setup-tip.test.ts index f4323e82..cc21d96b 100644 --- a/packages/cli/tests/agent-setup-tip.test.ts +++ b/packages/cli/tests/agent-setup-tip.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import { resolveAgentSetupTipCommand } from "../src/commands/auth/agent-setup-tip"; +import { readProjectSkillsConfig } from "../src/commands/skills/config"; import { resolvePrismaCliPackageCommand } from "../src/lib/agent/cli-command"; import { readSkillsStatus } from "../src/lib/skills/status"; @@ -14,12 +15,21 @@ vi.mock("../src/lib/skills/status", () => ({ vi.mock("../src/lib/agent/cli-command", () => ({ resolvePrismaCliPackageCommand: vi.fn(), })); +vi.mock("../src/commands/skills/config", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + readProjectSkillsConfig: vi.fn(actual.readProjectSkillsConfig), + }; +}); const mockedRead = vi.mocked(readSkillsStatus); const mockedResolve = vi.mocked(resolvePrismaCliPackageCommand); +const mockedConfig = vi.mocked(readProjectSkillsConfig); -function tipContext(cwd: string) { - return { cwd, env: {}, signal: new AbortController().signal }; +function tipContext(cwd: string, configPath?: string) { + return { cwd, configPath, env: {}, signal: new AbortController().signal }; } describe("resolveAgentSetupTipCommand", () => { @@ -74,4 +84,24 @@ describe("resolveAgentSetupTipCommand", () => { ).resolves.toBeNull(); expect(mockedRead).toHaveBeenCalledWith("/project", { orphans: false }); }); + + it("reads the project config through the file --config named", async () => { + mockedConfig.mockResolvedValueOnce({ + check: false, + agents: [], + agentsConfigured: false, + }); + const statusScansBefore = mockedRead.mock.calls.length; + + await expect( + resolveAgentSetupTipCommand( + tipContext("/project", "custom/prisma.config.ts"), + ), + ).resolves.toBeNull(); + expect(mockedConfig).toHaveBeenCalledWith( + expect.any(Function), + "custom/prisma.config.ts", + ); + expect(mockedRead.mock.calls.length).toBe(statusScansBefore); + }); }); From d081406f4565f098d7f757cf1357ba12058894dd Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 26 Aug 2026 15:19:24 +0200 Subject: [PATCH 18/19] Guard the prototype lookup in the conformance provenance builder A hostile proxy whose getPrototypeOf trap throws must yield empty provenance, not a false validator-threw finding. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-conformance/src/checks/validator-no-throw.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli-conformance/src/checks/validator-no-throw.ts b/packages/cli-conformance/src/checks/validator-no-throw.ts index 322a9dab..f1f3fdff 100644 --- a/packages/cli-conformance/src/checks/validator-no-throw.ts +++ b/packages/cli-conformance/src/checks/validator-no-throw.ts @@ -167,12 +167,12 @@ function provenanceFor(value: unknown): CheckableProvenance { if (typeof value !== "object" || value === null || Array.isArray(value)) { return { files: [], keys: {} }; } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) { - return { files: [], keys: {} }; - } let names: string[]; try { + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) { + return { files: [], keys: {} }; + } names = Object.keys(value); } catch { return { files: [], keys: {} }; From d0aca8abc41a118cf686ec7db5ea06d91a4733fe Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 26 Aug 2026 15:20:39 +0200 Subject: [PATCH 19/19] Register the three parent-directive diagnostics in the error reference Main gained the error-reference completeness check while this branch added CLI.CONFIG_PARENT_CYCLE, CLI.CONFIG_PARENT_INVALID and CLI.CONFIG_PARENT_NOT_FOUND; the merge left them unregistered. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 5ef29bbf..c0d7e802 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -116,6 +116,18 @@ The evaluated `prisma.config.ts` default export carries no `$prismaConfig` versi The file `--config` named does not exist. Only an explicitly named file is an error — an absent `prisma.config.ts` found by discovery is fine, because section validators own absence and supply defaults. The path is in `where.path`. Meta: none. +### CLI.CONFIG_PARENT_CYCLE + +A config file's explicit `parent` names a file that is already on the config chain, which would loop forever. Raised by the config loader while following the chain; the declaring file's absolute path is in `where.path` and the summary names both files. The fix is to point `parent` elsewhere or set it to `false`. Meta: none. + +### CLI.CONFIG_PARENT_INVALID + +A config file declares `parent` with a value that is neither `false` nor a path string. Raised by the config loader while following the chain; the declaring file's absolute path is in `where.path`. Meta: none. + +### CLI.CONFIG_PARENT_NOT_FOUND + +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_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.