Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/rules/project/project-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
paths:
- "src/project/project-context.ts"
- "src/command/command-utils.ts"
---

# Project Context

For project root discovery, the config-only `resolveProjectConfig()` vs full `projectContext()` split, the input walk, and the `.quarto` disk cache lifecycle, see `llm-docs/project-context-architecture.md`.
112 changes: 112 additions & 0 deletions llm-docs/project-context-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
---
main_commit: 9b307f7db
analyzed_date: 2026-09-30
key_files:
- src/project/project-context.ts
- src/command/command-utils.ts
- src/command/check/cmd.ts
- src/command/check/check.ts
- src/execute/engine.ts
- src/core/cache/cache.ts
- src/project/types/single-file/single-file.ts
---

# Project Context Resolution

How Quarto finds the project for a path, resolves its configuration, and discovers its input files.
Two layers: `resolveProjectConfig()` (config only) and `projectContext()` (config + `ProjectContext` + input walk), both in `src/project/project-context.ts`.

## Root discovery

`resolveProjectConfig(dir, extensionContext?, flags?)` runs two resolver passes, each walking upward from `dir` (`dir = dirname(dir)`) until it matches or hits the filesystem root:

1. **`_quarto.yml` pass** — `quartoYamlProjectConfigResolver()` reads and validates `_quarto.yml` (plus `metadata-files` includes).
A `PermissionDenied` while probing a dir for `_quarto.yml` is swallowed (#5843), so an unreadable ancestor doesn't stop the upward search.
2. **Extension detector pass** — only if pass 1 found nothing.
`projectExtensionsConfigResolver()` collects `project.detect` file sets from extensions (loaded relative to the starting dir) and restarts the upward walk from the original dir looking for a dir containing one set.
The result is a synthesized `{ project: { type } }` config with no file.

The nearest `_quarto.yml` wins, so a stray `~/_quarto.yml` makes every path under `~` a project rooted at `~` (#14960).

## `resolveProjectConfig()` — config only

After a resolver matches, the rest of the config pipeline runs in order: legacy migration, project-type extension (+ its includes), engine extensions (`resolveEngineExtensions`), profiles, `.env` files (sets env vars), `_variables.yml`, language translations, `--to` format injection, then `project:` normalization (output-dir from flags, pre/post-render to arrays, type default `lib-dir`/`output-dir`, output-dir `.`/absolute normalization).

Returns `ProjectConfigResolution | undefined`:

| Field | Meaning |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `dir` | Project root |
| `config` | Fully resolved `ProjectConfig` |
| `configFile` | The `_quarto.yml` that set the root, or `null` when the root came from the extension detector pass |
| `configFiles` | Every config file read (`_quarto.yml` first, then includes, profiles, dotenv, vars, translations); becomes `files.config` on the context |

It does not walk input files, create `.quarto`, or open any handle.
`type.config()` hooks (book, website, manuscript) are **not** applied here: they need a `ProjectContext` and may read project files.
None of them touch `engines`.

### Callers

- `projectContext()` — always, then builds the full context.
- `initializeProjectContextAndEngines()` (`src/command/command-utils.ts`) — used by `quarto check`, `quarto create`, `quarto create-project` and `quarto call engine`.
Only needs `config.engines` for `resolveEngines()` (`src/execute/engine.ts`), so it wraps the result as a minimal `{ dir, config }` context, or falls back to `zeroFileProjectContext()` (bundled engine extensions only) when no project is found.
It returns the `ProjectConfigResolution` (or `undefined`); `quarto check` passes it into `check()`, and `check info` reports `dir`/`configFile` (JSON `info.project`, `null` outside a project) and warns when the root is the home dir or a filesystem root.
The other callers ignore it.

## `projectContext()` — full context and input walk

`projectContext(path, notebookContext, renderOptions?, force?)` calls `resolveProjectConfig()` from `path` (or its dir), then:

- **Project found, `config.project` set** — builds the `ProjectContext` (temp context and disk cache under `<dir>/.quarto`), applies `type.config()`, walks inputs, then applies the membership check below.
- **Project found, no `project` key** — same context without type hooks.
- **No project, `force`** — synthetic project at the original dir, see `llm-docs/synthetic-project-context.md`.
- **No project** — returns `undefined`; callers fall back to `singleFileProjectContext()`.

`mergeExtensionMetadata()` runs on the returned context only when `renderOptions` is passed.

### Input walk

`projectInputFiles()` → `projectInputFilesInternal()` first calls `resolveEngines(project)` (so external engines' ignore dirs apply), then:

- With `project.render` globs: resolves only those globs, excluding the hidden-ignore globs and output dir.
- Otherwise `addDir(dir)`: std `walk` over the whole root, `followSymlinks: false`.

Two filtering mechanisms with different cost:

| Mechanism | Applies to | Effect |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| `skip` (pruned, never read) | dot-dirs (`kSkipHidden`), `engineIgnoreDirs()`: `node_modules` plus each engine's `ignoreDirs()` (knitr: `renv`, `packrat`, `rsconnect`; jupyter: `venv`, `env`) | Walk does not descend |
| Post-filter on yielded files | `projectHiddenIgnoreGlob()`: `_*`, `.*`, README, CLAUDE/AGENTS md, `*.llms.md` | Dir is fully traversed, files discarded |

So `_site`, `_freeze`, `_extensions` and any `_dir` are read in full.
std `walk` has no error hook: an unreadable dir anywhere under the root throws `Deno.errors.PermissionDenied` out of `projectContext()`.

Both directory traversals, the `addDir` walk and the `project.render` glob expansion (`resolvePathGlobs`), tag that `PermissionDenied` (the same error object, message and stack untouched) with the project dir; `projectContext()` adds the `configFile` from `resolveProjectConfig()` on its throw path.
The forced synthetic branch leaves `configFile` unset.
`frameInputWalkError()` turns a tagged error with a `configFile` into one `ErrorEx` without stack: the Deno message (it carries the `readdir '<path>'`, reused verbatim), the project root, the `_quarto.yml` that set it, and a hint that the file may be accidental.
Any other error is returned unchanged.
Only `quarto render` and `quarto preview` frame, by wrapping their actions in `withInputWalkErrorFraming()` (`src/command/command-utils.ts`); `inspect` and other walkers still report the raw error.

### File-membership fallback

When `path` is a file and not in the walked inputs (e.g. `_partial.qmd`, or a file under an ignored dir), `projectContext()` returns `undefined` and the caller treats the file as single-file.
Membership is decided by the full walk, so rendering one file still pays for walking the whole project.

## `.quarto` disk cache lifecycle

Each built context opens a `Deno.Kv` disk cache via `createProjectCache()` (`src/core/cache/cache.ts`) under `<dir>/.quarto` (synthetic: under the temp dir) and a temp context.
`returnResult()` registers `context.cleanup` with `onCleanup()`, which closes the cache at process exit.

If anything throws after the context is built (`type.config()`, the input walk, `mergeExtensionMetadata()`), each branch calls `context.cleanup()` before rethrowing, so the cache is closed and the temp dir removed right away.

The membership fallback returns `undefined` **after** creating the cache, without calling cleanup: the handle stays open for the process lifetime (not fixed yet).
On Windows the project dir then cannot be removed in-process (os error 32).
Any new early return after the context is built must close `diskCache` and clean `temp`.

## Which commands pay for the walk

| Walks inputs (`projectContext`) | Config only (`resolveProjectConfig`) |
| ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------- |
| `render`, `preview`, `serve`, `inspect`, `publish`, `list`, `remove`, `use binder`, `use brand`, extension install | `check`, `create`, `create-project`, `call engine` |

Config-only commands neither walk inputs nor create `<root>/.quarto`.
4 changes: 4 additions & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ All changes included in 1.11:

- ([#14815](https://github.com/quarto-dev/quarto-cli/pull/14815)): Add `quarto call axe`, a hidden experimental command that scans a rendered site for accessibility violations with axe-core across a page × viewport × color-mode matrix, groups them by root-cause signature, reconciles a committed baseline, and can gate CI with `--fail-on`. See [dev-docs/axe-scan.md](https://github.com/quarto-dev/quarto-cli/blob/main/dev-docs/axe-scan.md).

### `check`

- ([#14960](https://github.com/quarto-dev/quarto-cli/issues/14960)): Fix `quarto check` failing when the project contains a directory it cannot read, for example when a stray `_quarto.yml` in the home directory makes the home directory a project. `quarto check` now reads only the project configuration, without scanning input files or creating a `.quarto` directory in the project root. `quarto check info` reports the project root and the `_quarto.yml` that set it (`info.project` in `--output` JSON), and warns when the project root is the home directory or a filesystem root. `quarto render` and `quarto preview` now report an unreadable directory in the project as one error naming the directory, the project root and its `_quarto.yml`, instead of a raw `readdir` error with a stack trace.

## Extensions

- ([#14936](https://github.com/quarto-dev/quarto-cli/pull/14936)): Accept and ignore static engine declaration keys (`name`, `claims`, `file-extensions`, `claims-files` — including an optional `processor` on `claims-files` entries) in the `external-engine` schema, so extensions can declare them for Quarto 2's engine resolution without breaking Quarto 1 validation. No Quarto 1 behavior change. (author: @gordonwoodhull)
Expand Down
14 changes: 10 additions & 4 deletions src/command/check/check-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import { render } from "../render/render-shared.ts";
import type { RenderServiceWithLifetime } from "../render/types.ts";
import { notebookContext } from "../../render/notebook/notebook-context.ts";
import { singleFileProjectContext } from "../../project/types/single-file/single-file.ts";

/**
* Options for test-rendering a document during check operations
Expand Down Expand Up @@ -48,10 +50,14 @@ export async function checkRender(
Deno.writeTextFileSync(tempFile, content);

// Render with appropriate flags
const result = await render(tempFile, {
services,
flags: { quiet: true, executeDaemon: 0 },
});
// Single-file context: the temp dir can sit beneath a project root, whose
// config and input files have no bearing on the check render.
const renderOptions = { services, flags: { quiet: true, executeDaemon: 0 } };
const result = await render(
tempFile,
renderOptions,
await singleFileProjectContext(tempFile, notebookContext(), renderOptions),
);

// Return simplified result
return {
Expand Down
72 changes: 65 additions & 7 deletions src/command/check/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Copyright (C) 2021-2022 Posit Software, PBC
*/

import { info } from "../../deno_ral/log.ts";
import { info, warning } from "../../deno_ral/log.ts";

import { render } from "../render/render-shared.ts";
import { renderServices } from "../render/render-services.ts";
Expand All @@ -24,15 +24,18 @@ import { satisfies } from "semver/mod.ts";
import { dartCommand } from "../../core/dart-sass.ts";
import { allTools, installableTool } from "../../tools/tools.ts";
import { texLiveContext, tlVersion } from "../render/latexmk/texlive.ts";
import { which } from "../../core/path.ts";
import { pathsEqual, which } from "../../core/path.ts";
import { dirname } from "../../deno_ral/path.ts";
import { notebookContext } from "../../render/notebook/notebook-context.ts";
import { typstBinaryPath } from "../../core/typst.ts";
import { quartoCacheDir } from "../../core/appdirs.ts";
import { isWindows } from "../../deno_ral/platform.ts";
import { originalRealPathSync } from "../../deno_ral/original-real-path.ts";
import { makeStringEnumTypeEnforcer } from "../../typing/dynamic.ts";
import { detectBrowser } from "../../core/puppeteer.ts";
import { executionEngines } from "../../execute/engine.ts";
import type { ProjectConfigResolution } from "../../project/project-context.ts";
import { singleFileProjectContext } from "../../project/types/single-file/single-file.ts";

export function getTargets(): readonly string[] {
const checkableEngineNames = executionEngines()
Expand All @@ -58,6 +61,7 @@ export type CheckConfiguration = {
output: string | undefined;
services: RenderServiceWithLifetime;
jsonResult: CheckJsonResult | undefined;
project: ProjectConfigResolution | undefined;
};

function checkCompleteMessage(conf: CheckConfiguration, message: string) {
Expand All @@ -76,6 +80,7 @@ export async function check(
target: Target,
strict?: boolean,
output?: string,
project?: ProjectConfigResolution,
): Promise<void> {
const services = renderServices(notebookContext());
const conf: CheckConfiguration = {
Expand All @@ -84,6 +89,7 @@ export async function check(
output,
services,
jsonResult: undefined,
project,
};
if (conf.output) {
conf.jsonResult = {
Expand Down Expand Up @@ -134,11 +140,61 @@ export async function check(
// and the message is useful for troubleshooting
async function checkInfo(conf: CheckConfiguration) {
const cacheDir = quartoCacheDir();
const project = conf.project
? { dir: conf.project.dir, configFile: conf.project.configFile }
: null;
if (conf.jsonResult) {
conf.jsonResult!.info = { cacheDir };
conf.jsonResult!.info = { cacheDir, project };
}
checkCompleteMessage(conf, "Checking environment information...");
checkInfoMsg(conf, kIndent + "Quarto cache location: " + cacheDir);
if (project) {
checkInfoMsg(conf, kIndent + "Project root: " + project.dir);
checkInfoMsg(
conf,
kIndent + "Project config: " +
(project.configFile ?? "none (project type detected by an extension)"),
);
warnOnBroadProjectRoot(project.dir, project.configFile);
} else {
checkInfoMsg(conf, kIndent + "Project: none found (single-file mode)");
}
}

// A project rooted at the home dir or a filesystem root (typically a stray
// _quarto.yml) makes every document below it part of that project.
function warnOnBroadProjectRoot(dir: string, configFile: string | null) {
let rootKind: string;
if (dirname(dir) === dir) {
rootKind = "the filesystem root";
} else {
const home = userHomeDir();
if (home === undefined || !pathsEqual(dir, home)) {
return;
}
rootKind = "your home directory";
}
const source = configFile ?? "a project type detected by an extension";
warning(`Project root is ${rootKind} (${dir}), set by ${source}.`);
warning(
"Every command run below this directory treats it as one project." +
(configFile
? " Remove or move this file if it was created by accident."
: ""),
);
}

function userHomeDir(): string | undefined {
const home = Deno.env.get(isWindows ? "USERPROFILE" : "HOME");
if (!home) {
return undefined;
}
// The project root is a real path (resolved from the process cwd).
try {
return originalRealPathSync(home);
} catch {
return home;
}
}

async function checkVersions(conf: CheckConfiguration) {
Expand Down Expand Up @@ -498,10 +554,12 @@ title: "Title"
## Header
`,
);
const result = await render(mdPath, {
services,
flags: { quiet: true },
});
const options = { services, flags: { quiet: true } };
const result = await render(
mdPath,
options,
await singleFileProjectContext(mdPath, notebookContext(), options),
);
if (result.error) {
if (!conf.jsonResult) {
throw result.error;
Expand Down
3 changes: 2 additions & 1 deletion src/command/check/cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const checkCommand = new Command()
targetStr = targetStr || "all";

// Initialize project context and register external engines
await initializeProjectContextAndEngines();
const project = await initializeProjectContextAndEngines();

// Validate target (now that all engines including external ones are loaded)
const target = enforceTargetType(targetStr);
Expand All @@ -38,5 +38,6 @@ export const checkCommand = new Command()
target,
options.strict,
options.output,
project,
);
});
Loading
Loading