From d81df682959e074e837b01fc487c2ce72fe87082 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 30 Sep 2026 14:57:32 +0200 Subject: [PATCH 01/15] Resolve only project config in quarto check `quarto check` builds a full project context just to read `config.engines` for external engine registration. That walks every input file under the project root and opens a disk cache in `/.quarto`. With a stray `_quarto.yml` in a parent directory (e.g. the home dir), check walks that whole tree and crashes with a raw PermissionDenied on the first unreadable directory, and it creates a `.quarto` dir at that root, even though it is a diagnostic command that never uses input files. The config part of `projectContext()` (upward `_quarto.yml` search, extension detector pass, profiles, dotenv, vars, translations and `project:` normalization) is now `resolveProjectConfig()`, which also reports which `_quarto.yml` set the root. `projectContext()` calls it and then builds the context and walks the inputs as before. `initializeProjectContextAndEngines()` (check, create, create-project, call engine) uses it directly: none of these callers use input files, and `resolveEngines()` reads only `config.engines`. Project-type `config()` hooks are skipped on that path, and none of them touch `engines`. Adds a test helper that makes a directory unreadable (icacls on Windows, chmod elsewhere, unavailable as root), and llm-docs coverage of project context resolution. Refs #14960 --- .claude/rules/project/project-context.md | 9 + llm-docs/project-context-architecture.md | 137 +++++ src/command/command-utils.ts | 24 +- src/project/project-context.ts | 532 ++++++++++-------- .../check/check-project-config-only.test.ts | 82 +++ tests/utils.ts | 28 + 6 files changed, 560 insertions(+), 252 deletions(-) create mode 100644 .claude/rules/project/project-context.md create mode 100644 llm-docs/project-context-architecture.md create mode 100644 tests/smoke/check/check-project-config-only.test.ts diff --git a/.claude/rules/project/project-context.md b/.claude/rules/project/project-context.md new file mode 100644 index 00000000000..96cc1006e45 --- /dev/null +++ b/.claude/rules/project/project-context.md @@ -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`. diff --git a/llm-docs/project-context-architecture.md b/llm-docs/project-context-architecture.md new file mode 100644 index 00000000000..bb4aac9c6ec --- /dev/null +++ b/llm-docs/project-context-architecture.md @@ -0,0 +1,137 @@ +--- +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/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. + +## `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 `/.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()`. + +### 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 `/.quarto` (synthetic: under the temp +dir) and a temp context. `returnResult()` registers `context.cleanup` with +`onCleanup()`, which closes the cache at process exit. + +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 `/.quarto`. diff --git a/src/command/command-utils.ts b/src/command/command-utils.ts index f5fd6f06db4..5e97de5c760 100644 --- a/src/command/command-utils.ts +++ b/src/command/command-utils.ts @@ -5,9 +5,10 @@ */ import { initYamlIntelligenceResourcesFromFilesystem } from "../core/schema/utils.ts"; -import { projectContext } from "../project/project-context.ts"; -import { notebookContext } from "../render/notebook/notebook-context.ts"; +import { resolveProjectConfig } from "../project/project-context.ts"; +import { createExtensionContext } from "../extension/extension.ts"; import { resolveEngines } from "../execute/engine.ts"; +import { normalizePath } from "../core/path.ts"; import type { ProjectContext } from "../project/types.ts"; /** @@ -44,11 +45,11 @@ async function zeroFileProjectContext(dir?: string): Promise { } /** - * Initialize project context and register external engines from project config. + * Initialize project configuration and register external engines from it. * * This consolidates the common pattern of: * 1. Loading YAML intelligence resources - * 2. Creating project context + * 2. Resolving the project configuration (without walking project input files) * 3. Registering external engines via reorderEngines() * * If no project is found, a zero-file context is created to load bundled engine @@ -59,13 +60,18 @@ async function zeroFileProjectContext(dir?: string): Promise { export async function initializeProjectContextAndEngines( dir?: string, ): Promise { - // Initialize YAML intelligence resources (required for project context) + // Initialize YAML intelligence resources (required for project config) await initYamlIntelligenceResourcesFromFilesystem(); - // Load project context if we're in a project directory, or create a zero-file - // context to load bundled engines when no project exists - const context = await projectContext(dir || Deno.cwd(), notebookContext()) || - await zeroFileProjectContext(dir); + // Use the project config if we're in a project directory, or create a + // zero-file context to load bundled engines when no project exists + const resolved = await resolveProjectConfig( + normalizePath(dir || Deno.cwd()), + createExtensionContext(), + ); + const context = resolved + ? { dir: resolved.dir, config: resolved.config } as ProjectContext + : await zeroFileProjectContext(dir); // Register external engines from project config await resolveEngines(context); diff --git a/src/project/project-context.ts b/src/project/project-context.ts index f68be8bb46c..bbdb38694da 100644 --- a/src/project/project-context.ts +++ b/src/project/project-context.ts @@ -81,7 +81,11 @@ import { projectResolveFullMarkdownForFile, projectVarsFile, } from "./project-shared.ts"; -import { RenderOptions, RenderServices } from "../command/render/types.ts"; +import { + RenderFlags, + RenderOptions, + RenderServices, +} from "../command/render/types.ts"; import { kWebsite } from "./types/website/website-constants.ts"; import { readAndValidateYamlFromFile } from "../core/schema/validated-yaml.ts"; @@ -143,22 +147,14 @@ export async function projectContext( force = false, ): Promise { const flags = renderOptions?.flags; - let dir = normalizePath( + const originalDir = normalizePath( Deno.statSync(path).isDirectory ? path : dirname(path), ); - const originalDir = dir; // create an extension context if one doesn't exist const extensionContext = renderOptions?.services.extension || createExtensionContext(); - // first pass uses the config file resolve - const configSchema = await getProjectConfigSchema(); - const configResolvers = [ - quartoYamlProjectConfigResolver(configSchema), - await projectExtensionsConfigResolver(extensionContext, dir), - ]; - // Compute this on demand and only a single time per // project context let cachedEnv: ProjectEnvironment | undefined = undefined; @@ -183,6 +179,282 @@ export async function projectContext( return context; }; + const resolved = await resolveProjectConfig( + originalDir, + extensionContext, + flags, + ); + if (resolved) { + const dir = resolved.dir; + const projectConfig = resolved.config; + const configFiles = resolved.configFiles; + if (projectConfig?.project) { + const type = projectType(projectConfig.project?.[kProjectType]); + const temp = createTempContext({ + dir: join(dir, ".quarto"), + prefix: "quarto-session-temp", + }); + const fileInformationCache = new FileInformationCacheMap(); + const result: ProjectContext = { + clone: () => result, + resolveBrand: async (fileName?: string) => + projectResolveBrand(result, fileName), + resolveFullMarkdownForFile: ( + engine: ExecutionEngineInstance | undefined, + file: string, + markdown?: MappedString, + force?: boolean, + ) => { + return projectResolveFullMarkdownForFile( + result, + engine, + file, + markdown, + force, + ); + }, + dir, + engines: [], + fileInformationCache, + files: { + input: [], + }, + config: projectConfig, + // this is a relatively ugly hack to avoid a circular import chain + // that causes a deno bundler bug; + renderFormats, + environment: () => environment(result), + notebookContext, + fileExecutionEngineAndTarget: ( + file: string, + ) => { + return fileExecutionEngineAndTarget( + file, + flags, + result, + ); + }, + fileMetadata: async (file: string, force?: boolean) => { + return projectFileMetadata(result, file, force); + }, + isSingleFile: false, + previewServer: renderOptions?.previewServer, + diskCache: await createProjectCache(join(dir, ".quarto")), + temp, + cleanup: () => { + cleanupFileInformationCache(result); + result.diskCache.close(); + temp.cleanup(); + }, + }; + + // see if the project [kProjectType] wants to filter the project config + if (type.config) { + result.config = await type.config( + result, + projectConfig, + flags, + ); + } + const { files, engines } = await projectInputFiles( + result, + projectConfig, + ); + // if we are attemping to get the projectConext for a file and the + // file isn't in list of input files then return a single-file project + const fullPath = normalizePath(path); + if (Deno.statSync(fullPath).isFile && !files.includes(fullPath)) { + return undefined; + } + + if (type.formatExtras) { + result.formatExtras = async ( + source: string, + flags: PandocFlags, + format: Format, + services: RenderServices, + ) => type.formatExtras!(result, source, flags, format, services); + } + result.engines = engines; + result.files = { + input: files, + resources: projectResourceFiles(dir, projectConfig), + config: configFiles, + configResources: projectConfigResources(dir, projectConfig, type), + }; + return await returnResult(result); + } else { + const temp = createTempContext({ + dir: join(dir, ".quarto"), + prefix: "quarto-session-temp", + }); + const fileInformationCache = new FileInformationCacheMap(); + const result: ProjectContext = { + clone: () => result, + resolveBrand: async (fileName?: string) => + projectResolveBrand(result, fileName), + resolveFullMarkdownForFile: ( + engine: ExecutionEngineInstance | undefined, + file: string, + markdown?: MappedString, + force?: boolean, + ) => { + return projectResolveFullMarkdownForFile( + result, + engine, + file, + markdown, + force, + ); + }, + dir, + config: projectConfig, + engines: [], + fileInformationCache, + files: { + input: [], + }, + renderFormats, + environment: () => environment(result), + fileExecutionEngineAndTarget: ( + file: string, + ) => { + return fileExecutionEngineAndTarget( + file, + flags, + result, + ); + }, + fileMetadata: async (file: string, force?: boolean) => { + return projectFileMetadata(result, file, force); + }, + notebookContext, + isSingleFile: false, + previewServer: renderOptions?.previewServer, + diskCache: await createProjectCache(join(dir, ".quarto")), + temp, + cleanup: () => { + cleanupFileInformationCache(result); + result.diskCache.close(); + temp.cleanup(); + }, + }; + const { files, engines } = await projectInputFiles( + result, + projectConfig, + ); + result.engines = engines; + result.files = { + input: files, + resources: projectResourceFiles(dir, projectConfig), + config: configFiles, + configResources: projectConfigResources(dir, projectConfig), + }; + return await returnResult(result); + } + } else if (force) { + const temp = createTempContext({ + dir: join(originalDir, ".quarto"), + prefix: "quarto-session-temp", + }); + const fileInformationCache = new FileInformationCacheMap(); + const context: ProjectContext = { + clone: () => context, + resolveBrand: async (fileName?: string) => + projectResolveBrand(context, fileName), + resolveFullMarkdownForFile: ( + engine: ExecutionEngineInstance | undefined, + file: string, + markdown?: MappedString, + force?: boolean, + ) => { + return projectResolveFullMarkdownForFile( + context, + engine, + file, + markdown, + force, + ); + }, + dir: originalDir, + engines: [], + config: { + project: { + [kProjectOutputDir]: flags?.outputDir, + }, + }, + fileInformationCache, + files: { + input: [], + }, + renderFormats, + environment: () => environment(context), + notebookContext, + fileExecutionEngineAndTarget: ( + file: string, + ) => { + return fileExecutionEngineAndTarget( + file, + flags, + context, + ); + }, + fileMetadata: async (file: string, force?: boolean) => { + return projectFileMetadata(context, file, force); + }, + isSingleFile: false, + previewServer: renderOptions?.previewServer, + diskCache: await createProjectCache(join(temp.baseDir, ".quarto")), + temp, + cleanup: () => { + cleanupFileInformationCache(context); + context.diskCache.close(); + temp.cleanup(); + }, + }; + if (Deno.statSync(path).isDirectory) { + const { files, engines } = await projectInputFiles(context); + context.engines = engines; + context.files.input = files; + } else { + const input = normalizePath(path); + const engine = await fileExecutionEngine(input, undefined, context); + context.engines = [engine?.name ?? kMarkdownEngine]; + context.files.input = [input]; + } + return await returnResult(context); + } else { + return undefined; + } +} + +export interface ProjectConfigResolution { + dir: string; + config: ProjectConfig; + // the _quarto.yml that set the project root, or null when the root came + // from an extension project-type detector + configFile: string | null; + configFiles: string[]; +} + +// Finds the project enclosing `dir` (nearest _quarto.yml upward, else nearest +// dir matched by an extension project-type detector) and resolves its full +// configuration, without walking the project input files. +export async function resolveProjectConfig( + dir: string, + extensionContext: ExtensionContext, + flags?: RenderFlags, +): Promise { + const originalDir = dir; + + // first pass uses the config file resolve + const configSchema = await getProjectConfigSchema(); + const quartoYamlResolver = quartoYamlProjectConfigResolver(configSchema); + const configResolvers = [ + quartoYamlResolver, + await projectExtensionsConfigResolver(extensionContext, dir), + ]; + while (true) { // use the current resolver const resolver = configResolvers[0]; @@ -321,169 +593,14 @@ export async function projectContext( projOutputDir, ); } - - const temp = createTempContext({ - dir: join(dir, ".quarto"), - prefix: "quarto-session-temp", - }); - const fileInformationCache = new FileInformationCacheMap(); - const result: ProjectContext = { - clone: () => result, - resolveBrand: async (fileName?: string) => - projectResolveBrand(result, fileName), - resolveFullMarkdownForFile: ( - engine: ExecutionEngineInstance | undefined, - file: string, - markdown?: MappedString, - force?: boolean, - ) => { - return projectResolveFullMarkdownForFile( - result, - engine, - file, - markdown, - force, - ); - }, - dir, - engines: [], - fileInformationCache, - files: { - input: [], - }, - config: projectConfig, - // this is a relatively ugly hack to avoid a circular import chain - // that causes a deno bundler bug; - renderFormats, - environment: () => environment(result), - notebookContext, - fileExecutionEngineAndTarget: ( - file: string, - ) => { - return fileExecutionEngineAndTarget( - file, - flags, - result, - ); - }, - fileMetadata: async (file: string, force?: boolean) => { - return projectFileMetadata(result, file, force); - }, - isSingleFile: false, - previewServer: renderOptions?.previewServer, - diskCache: await createProjectCache(join(dir, ".quarto")), - temp, - cleanup: () => { - cleanupFileInformationCache(result); - result.diskCache.close(); - temp.cleanup(); - }, - }; - - // see if the project [kProjectType] wants to filter the project config - if (type.config) { - result.config = await type.config( - result, - projectConfig, - flags, - ); - } - const { files, engines } = await projectInputFiles( - result, - projectConfig, - ); - // if we are attemping to get the projectConext for a file and the - // file isn't in list of input files then return a single-file project - const fullPath = normalizePath(path); - if (Deno.statSync(fullPath).isFile && !files.includes(fullPath)) { - return undefined; - } - - if (type.formatExtras) { - result.formatExtras = async ( - source: string, - flags: PandocFlags, - format: Format, - services: RenderServices, - ) => type.formatExtras!(result, source, flags, format, services); - } - result.engines = engines; - result.files = { - input: files, - resources: projectResourceFiles(dir, projectConfig), - config: configFiles, - configResources: projectConfigResources(dir, projectConfig, type), - }; - return await returnResult(result); - } else { - const temp = createTempContext({ - dir: join(dir, ".quarto"), - prefix: "quarto-session-temp", - }); - const fileInformationCache = new FileInformationCacheMap(); - const result: ProjectContext = { - clone: () => result, - resolveBrand: async (fileName?: string) => - projectResolveBrand(result, fileName), - resolveFullMarkdownForFile: ( - engine: ExecutionEngineInstance | undefined, - file: string, - markdown?: MappedString, - force?: boolean, - ) => { - return projectResolveFullMarkdownForFile( - result, - engine, - file, - markdown, - force, - ); - }, - dir, - config: projectConfig, - engines: [], - fileInformationCache, - files: { - input: [], - }, - renderFormats, - environment: () => environment(result), - fileExecutionEngineAndTarget: ( - file: string, - ) => { - return fileExecutionEngineAndTarget( - file, - flags, - result, - ); - }, - fileMetadata: async (file: string, force?: boolean) => { - return projectFileMetadata(result, file, force); - }, - notebookContext, - isSingleFile: false, - previewServer: renderOptions?.previewServer, - diskCache: await createProjectCache(join(dir, ".quarto")), - temp, - cleanup: () => { - cleanupFileInformationCache(result); - result.diskCache.close(); - temp.cleanup(); - }, - }; - const { files, engines } = await projectInputFiles( - result, - projectConfig, - ); - result.engines = engines; - result.files = { - input: files, - resources: projectResourceFiles(dir, projectConfig), - config: configFiles, - configResources: projectConfigResources(dir, projectConfig), - }; - return await returnResult(result); } + + return { + dir, + config: projectConfig, + configFile: resolver === quartoYamlResolver ? configFiles[0] : null, + configFiles, + }; } else { const nextDir = dirname(dir); if (nextDir === dir) { @@ -491,77 +608,6 @@ export async function projectContext( // reset dir and proceed to next resolver dir = originalDir; configResolvers.shift(); - } else if (force) { - const temp = createTempContext({ - dir: join(originalDir, ".quarto"), - prefix: "quarto-session-temp", - }); - const fileInformationCache = new FileInformationCacheMap(); - const context: ProjectContext = { - clone: () => context, - resolveBrand: async (fileName?: string) => - projectResolveBrand(context, fileName), - resolveFullMarkdownForFile: ( - engine: ExecutionEngineInstance | undefined, - file: string, - markdown?: MappedString, - force?: boolean, - ) => { - return projectResolveFullMarkdownForFile( - context, - engine, - file, - markdown, - force, - ); - }, - dir: originalDir, - engines: [], - config: { - project: { - [kProjectOutputDir]: flags?.outputDir, - }, - }, - fileInformationCache, - files: { - input: [], - }, - renderFormats, - environment: () => environment(context), - notebookContext, - fileExecutionEngineAndTarget: ( - file: string, - ) => { - return fileExecutionEngineAndTarget( - file, - flags, - context, - ); - }, - fileMetadata: async (file: string, force?: boolean) => { - return projectFileMetadata(context, file, force); - }, - isSingleFile: false, - previewServer: renderOptions?.previewServer, - diskCache: await createProjectCache(join(temp.baseDir, ".quarto")), - temp, - cleanup: () => { - cleanupFileInformationCache(context); - context.diskCache.close(); - temp.cleanup(); - }, - }; - if (Deno.statSync(path).isDirectory) { - const { files, engines } = await projectInputFiles(context); - context.engines = engines; - context.files.input = files; - } else { - const input = normalizePath(path); - const engine = await fileExecutionEngine(input, undefined, context); - context.engines = [engine?.name ?? kMarkdownEngine]; - context.files.input = [input]; - } - return await returnResult(context); } else { return undefined; } diff --git a/tests/smoke/check/check-project-config-only.test.ts b/tests/smoke/check/check-project-config-only.test.ts new file mode 100644 index 00000000000..849968c3fc9 --- /dev/null +++ b/tests/smoke/check/check-project-config-only.test.ts @@ -0,0 +1,82 @@ +/* + * check-project-config-only.test.ts + * + * `quarto check` inside a project reads the project configuration only: it + * does not walk the project input files, nor create the project .quarto dir. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { existsSync } from "../../../src/deno_ral/fs.ts"; +import { dirname, join } from "../../../src/deno_ral/path.ts"; +import { testQuartoCmdJson } from "../../test.ts"; +import { canMakeUnreadableDir, makeUnreadableDir } from "../../utils.ts"; +import { assert, assertEquals } from "testing/asserts"; + +// The tree must exist at registration: the harness enters `cwd` before setup. +function tempProject(): string { + const dir = Deno.makeTempDirSync({ prefix: "quarto-check-project" }); + Deno.writeTextFileSync(join(dir, "_quarto.yml"), "project:\n type: website\n"); + Deno.mkdirSync(join(dir, "sub")); + Deno.writeTextFileSync(join(dir, "sub", "index.qmd"), "# Hello\n"); + return dir; +} + +// Teardown runs before the harness restores cwd, and Windows cannot remove +// the process cwd, so leave the project first. +function removeProject(dir: string) { + Deno.chdir(dirname(dir)); + Deno.removeSync(dir, { recursive: true }); + return Promise.resolve(); +} + +(() => { + const projectDir = tempProject(); + const locked = join(projectDir, "locked"); + Deno.mkdirSync(locked); + Deno.writeTextFileSync(join(locked, "doc.qmd"), "# Locked\n"); + const output = join(projectDir, "check-info.json"); + let restore: (() => void) | undefined; + testQuartoCmdJson( + "check", + ["info", "--output", output], + output, + "check-in-project-with-unreadable-dir", + (json) => { + assertEquals(json.strict, true); + }, + { + ignore: !canMakeUnreadableDir, + cwd: () => join(projectDir, "sub"), + setup: () => { + restore = makeUnreadableDir(locked); + return Promise.resolve(); + }, + teardown: () => { + restore?.(); + return removeProject(projectDir); + }, + }, + ); +})(); + +(() => { + const projectDir = tempProject(); + const output = join(projectDir, "check-info.json"); + testQuartoCmdJson( + "check", + ["info", "--output", output], + output, + "check-in-project-creates-no-project-cache", + (_json) => { + assert( + !existsSync(join(projectDir, ".quarto")), + "quarto check created the project .quarto dir", + ); + }, + { + cwd: () => join(projectDir, "sub"), + teardown: () => removeProject(projectDir), + }, + ); +})(); diff --git a/tests/utils.ts b/tests/utils.ts index aedc53a0850..d42426728f0 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -264,3 +264,31 @@ export function quartoDevCmd(): string { return isWindows ? "quarto.cmd" : "quarto"; } + +// Running as root bypasses chmod, so an unreadable dir cannot be made. +export const canMakeUnreadableDir = isWindows || Deno.uid() !== 0; + +// Denies listing `dir` to the current user (icacls on Windows, chmod 000 +// elsewhere). Returns a function restoring access, to call before removal. +export function makeUnreadableDir(dir: string): () => void { + if (isWindows) { + const user = Deno.env.get("USERNAME")!; + const icacls = (...args: string[]) => { + const result = new Deno.Command("icacls", { args: [dir, ...args] }) + .outputSync(); + if (!result.success) { + throw new Error( + `icacls ${args.join(" ")} failed: ${ + new TextDecoder().decode(result.stderr) + }`, + ); + } + }; + icacls("/deny", `${user}:(RD)`); + return () => icacls("/remove:d", user); + } else { + const mode = Deno.statSync(dir).mode!; + Deno.chmodSync(dir, 0o000); + return () => Deno.chmodSync(dir, mode & 0o7777); + } +} From d3d0cf5fc398034ca21b782a7bc0fd71e9320e38 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 30 Sep 2026 15:10:34 +0200 Subject: [PATCH 02/15] Default the extension loader in resolveProjectConfig Callers outside project resolution (check, create, call engine) should not have to build an extension loader to read project config. projectContext() still passes its render services' loader so render reuses the extension cache. --- llm-docs/project-context-architecture.md | 2 +- src/command/command-utils.ts | 6 +----- src/project/project-context.ts | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/llm-docs/project-context-architecture.md b/llm-docs/project-context-architecture.md index bb4aac9c6ec..732c8073f78 100644 --- a/llm-docs/project-context-architecture.md +++ b/llm-docs/project-context-architecture.md @@ -19,7 +19,7 @@ and `projectContext()` (config + `ProjectContext` + input walk), both in ## Root discovery -`resolveProjectConfig(dir, extensionContext, flags?)` runs two resolver +`resolveProjectConfig(dir, extensionContext?, flags?)` runs two resolver passes, each walking upward from `dir` (`dir = dirname(dir)`) until it matches or hits the filesystem root: diff --git a/src/command/command-utils.ts b/src/command/command-utils.ts index 5e97de5c760..afa7d308f9e 100644 --- a/src/command/command-utils.ts +++ b/src/command/command-utils.ts @@ -6,7 +6,6 @@ import { initYamlIntelligenceResourcesFromFilesystem } from "../core/schema/utils.ts"; import { resolveProjectConfig } from "../project/project-context.ts"; -import { createExtensionContext } from "../extension/extension.ts"; import { resolveEngines } from "../execute/engine.ts"; import { normalizePath } from "../core/path.ts"; import type { ProjectContext } from "../project/types.ts"; @@ -65,10 +64,7 @@ export async function initializeProjectContextAndEngines( // Use the project config if we're in a project directory, or create a // zero-file context to load bundled engines when no project exists - const resolved = await resolveProjectConfig( - normalizePath(dir || Deno.cwd()), - createExtensionContext(), - ); + const resolved = await resolveProjectConfig(normalizePath(dir || Deno.cwd())); const context = resolved ? { dir: resolved.dir, config: resolved.config } as ProjectContext : await zeroFileProjectContext(dir); diff --git a/src/project/project-context.ts b/src/project/project-context.ts index bbdb38694da..4834e3a11cd 100644 --- a/src/project/project-context.ts +++ b/src/project/project-context.ts @@ -442,7 +442,7 @@ export interface ProjectConfigResolution { // configuration, without walking the project input files. export async function resolveProjectConfig( dir: string, - extensionContext: ExtensionContext, + extensionContext: ExtensionContext = createExtensionContext(), flags?: RenderFlags, ): Promise { const originalDir = dir; From 56ee10cffc68a678f483218ad16ce797974126c8 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 30 Sep 2026 15:13:40 +0200 Subject: [PATCH 03/15] Don't register the unreadable-dir check test when running as root The fixture is built at registration, and an ignored test never runs its teardown, so running as root left the temp project behind. --- tests/smoke/check/check-project-config-only.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/smoke/check/check-project-config-only.test.ts b/tests/smoke/check/check-project-config-only.test.ts index 849968c3fc9..4be0c6010cc 100644 --- a/tests/smoke/check/check-project-config-only.test.ts +++ b/tests/smoke/check/check-project-config-only.test.ts @@ -30,7 +30,9 @@ function removeProject(dir: string) { return Promise.resolve(); } -(() => { +// Not registered when the dir can't be made unreadable: an ignored test never +// runs its teardown, so its fixture would be left behind. +if (canMakeUnreadableDir) { const projectDir = tempProject(); const locked = join(projectDir, "locked"); Deno.mkdirSync(locked); @@ -46,7 +48,6 @@ function removeProject(dir: string) { assertEquals(json.strict, true); }, { - ignore: !canMakeUnreadableDir, cwd: () => join(projectDir, "sub"), setup: () => { restore = makeUnreadableDir(locked); @@ -58,7 +59,7 @@ function removeProject(dir: string) { }, }, ); -})(); +} (() => { const projectDir = tempProject(); From ce81457a02b727f7f3e10d130d992e3c1d6421e5 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 30 Sep 2026 15:36:42 +0200 Subject: [PATCH 04/15] Report the project root and _quarto.yml in quarto check info A stray _quarto.yml in a parent directory silently turns everything below it into one project, and nothing told the user which file was in play. `quarto check info` now prints the project root and the _quarto.yml that set it, or that no project was found (single-file mode). With --output, the JSON gets info.project = { dir, configFile } (configFile null when an extension's project-type detector set the root), or null outside a project. A root at the user home directory or a filesystem root is almost always an accident, so check warns on stderr in that case; the JSON file stays data only. The resolution comes from resolveProjectConfig(), which initializeProjectContextAndEngines() now returns instead of discarding. The other callers (create, create-project, call engine) ignore it. The home-directory test spawns quarto as a subprocess with HOME and USERPROFILE pointing at a temp project: in dev mode the harness runs quarto in-process and applies TestContext.env through Deno.env.set, which would leak into concurrently running tests. Refs #14960 --- llm-docs/project-context-architecture.md | 7 +- src/command/check/check.ts | 60 ++++++- src/command/check/cmd.ts | 3 +- src/command/command-utils.ts | 10 +- tests/smoke/check/check-in-project.test.ts | 2 +- tests/smoke/check/check-info-project.test.ts | 157 +++++++++++++++++++ 6 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 tests/smoke/check/check-info-project.test.ts diff --git a/llm-docs/project-context-architecture.md b/llm-docs/project-context-architecture.md index 732c8073f78..c08d58affcc 100644 --- a/llm-docs/project-context-architecture.md +++ b/llm-docs/project-context-architecture.md @@ -5,6 +5,7 @@ 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 @@ -68,7 +69,11 @@ they need a `ProjectContext` and may read project files. None of them touch `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. + (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 diff --git a/src/command/check/check.ts b/src/command/check/check.ts index 6e10bad090a..814c1a9cde7 100644 --- a/src/command/check/check.ts +++ b/src/command/check/check.ts @@ -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"; @@ -24,7 +24,7 @@ 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"; @@ -33,6 +33,7 @@ import { isWindows } from "../../deno_ral/platform.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"; export function getTargets(): readonly string[] { const checkableEngineNames = executionEngines() @@ -58,6 +59,7 @@ export type CheckConfiguration = { output: string | undefined; services: RenderServiceWithLifetime; jsonResult: CheckJsonResult | undefined; + project: ProjectConfigResolution | undefined; }; function checkCompleteMessage(conf: CheckConfiguration, message: string) { @@ -76,6 +78,7 @@ export async function check( target: Target, strict?: boolean, output?: string, + project?: ProjectConfigResolution, ): Promise { const services = renderServices(notebookContext()); const conf: CheckConfiguration = { @@ -84,6 +87,7 @@ export async function check( output, services, jsonResult: undefined, + project, }; if (conf.output) { conf.jsonResult = { @@ -134,11 +138,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 Deno.realPathSync(home); + } catch { + return home; + } } async function checkVersions(conf: CheckConfiguration) { diff --git a/src/command/check/cmd.ts b/src/command/check/cmd.ts index b79ac51e3e0..6f237bab429 100644 --- a/src/command/check/cmd.ts +++ b/src/command/check/cmd.ts @@ -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); @@ -38,5 +38,6 @@ export const checkCommand = new Command() target, options.strict, options.output, + project, ); }); diff --git a/src/command/command-utils.ts b/src/command/command-utils.ts index afa7d308f9e..1d91e6fa544 100644 --- a/src/command/command-utils.ts +++ b/src/command/command-utils.ts @@ -5,7 +5,10 @@ */ import { initYamlIntelligenceResourcesFromFilesystem } from "../core/schema/utils.ts"; -import { resolveProjectConfig } from "../project/project-context.ts"; +import { + type ProjectConfigResolution, + resolveProjectConfig, +} from "../project/project-context.ts"; import { resolveEngines } from "../execute/engine.ts"; import { normalizePath } from "../core/path.ts"; import type { ProjectContext } from "../project/types.ts"; @@ -55,10 +58,11 @@ async function zeroFileProjectContext(dir?: string): Promise { * extensions (like Julia), ensuring they're available for commands like `quarto check julia`. * * @param dir - Optional directory path (defaults to current working directory) + * @returns The resolved project configuration, or undefined when no project is found */ export async function initializeProjectContextAndEngines( dir?: string, -): Promise { +): Promise { // Initialize YAML intelligence resources (required for project config) await initYamlIntelligenceResourcesFromFilesystem(); @@ -71,4 +75,6 @@ export async function initializeProjectContextAndEngines( // Register external engines from project config await resolveEngines(context); + + return resolved; } diff --git a/tests/smoke/check/check-in-project.test.ts b/tests/smoke/check/check-in-project.test.ts index 93486ea6d00..e85cc03cd85 100644 --- a/tests/smoke/check/check-in-project.test.ts +++ b/tests/smoke/check/check-in-project.test.ts @@ -25,7 +25,7 @@ const cwd = () => join(projectDir, "sub"); (json) => { assertEquals(Object.keys(json).sort(), ["info", "strict", "version"]); assertEquals(json.strict, true); - assertEquals(Object.keys(json.info), ["cacheDir"]); + assertEquals(Object.keys(json.info), ["cacheDir", "project"]); assert(typeof json.info.cacheDir === "string"); }, { cwd }, diff --git a/tests/smoke/check/check-info-project.test.ts b/tests/smoke/check/check-info-project.test.ts new file mode 100644 index 00000000000..c13fc81cac5 --- /dev/null +++ b/tests/smoke/check/check-info-project.test.ts @@ -0,0 +1,157 @@ +/* + * check-info-project.test.ts + * + * `quarto check info` reports the project in play: its root and the + * _quarto.yml that set it, or single-file mode outside any project. It warns + * when the project root is the user home directory. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { dirname, join, resolve } from "../../../src/deno_ral/path.ts"; +import { execProcess } from "../../../src/core/process.ts"; +import { testQuartoCmd, testQuartoCmdJson, unitTest } from "../../test.ts"; +import { noErrors, printsMessage } from "../../verify.ts"; +import { + isBinaryMode, + quartoDevBinCmd, + quartoSpawnEnvOptions, +} from "../../quarto-cmd.ts"; +import { assert, assertEquals } from "testing/asserts"; + +// Real path: the project root is resolved from the process cwd, which is a +// real path (macOS temp dirs live under the /var -> /private/var symlink). +function tempDir(prefix: string): string { + return Deno.realPathSync(Deno.makeTempDirSync({ prefix })); +} + +// The tree must exist at registration: the harness enters `cwd` before setup. +function tempProject(): string { + const dir = tempDir("quarto-check-info-project"); + Deno.writeTextFileSync(join(dir, "_quarto.yml"), "project:\n type: default\n"); + Deno.mkdirSync(join(dir, "sub")); + return dir; +} + +// Teardown runs before the harness restores cwd, and Windows cannot remove +// the process cwd, so leave the dir first. +function removeDir(dir: string) { + Deno.chdir(dirname(dir)); + Deno.removeSync(dir, { recursive: true }); + return Promise.resolve(); +} + +(() => { + const projectDir = tempProject(); + const output = join(projectDir, "check-info.json"); + testQuartoCmdJson( + "check", + ["info", "--output", output], + output, + "check-info-json-reports-project", + (json) => { + assertEquals(json.info.project, { + dir: projectDir, + configFile: join(projectDir, "_quarto.yml"), + }); + }, + { + cwd: () => join(projectDir, "sub"), + teardown: () => removeDir(projectDir), + }, + ); +})(); + +(() => { + const projectDir = tempProject(); + testQuartoCmd( + "check", + ["info"], + [ + noErrors, + printsMessage({ + level: "INFO", + regex: `Project root: ${RegExp.escape(projectDir)}$`, + }), + printsMessage({ + level: "INFO", + regex: `Project config: ${ + RegExp.escape(join(projectDir, "_quarto.yml")) + }$`, + }), + ], + { + cwd: () => join(projectDir, "sub"), + teardown: () => removeDir(projectDir), + }, + ); +})(); + +(() => { + const dir = tempDir("quarto-check-info-no-project"); + const output = join(dir, "check-info.json"); + testQuartoCmdJson( + "check", + ["info", "--output", output], + output, + "check-info-json-reports-no-project", + (json) => { + assertEquals(json.info.project, null); + }, + { + cwd: () => dir, + teardown: () => removeDir(dir), + }, + ); +})(); + +(() => { + const dir = tempDir("quarto-check-info-no-project"); + testQuartoCmd( + "check", + ["info"], + [ + noErrors, + printsMessage({ + level: "INFO", + regex: /Project: none found \(single-file mode\)$/, + }), + ], + { + cwd: () => dir, + teardown: () => removeDir(dir), + }, + ); +})(); + +// Runs quarto as a subprocess: the home dir comes from the environment, and +// changing it in-process would leak into concurrently running tests. +unitTest("check-info-warns-when-project-root-is-home", async () => { + const home = tempProject(); + try { + const output = join(home, "check-info.json"); + const result = await execProcess({ + // The dev launcher path is relative to tests/, the child runs elsewhere. + cmd: isBinaryMode() ? quartoDevBinCmd() : resolve(quartoDevBinCmd()), + args: ["check", "info", "--output", output], + cwd: join(home, "sub"), + stdout: "piped", + stderr: "piped", + ...quartoSpawnEnvOptions({ HOME: home, USERPROFILE: home }), + }); + assert(result.success, `quarto check failed: ${result.stderr}`); + const stderr = result.stderr ?? ""; + assert( + stderr.includes( + `Project root is your home directory (${home}), set by ${ + join(home, "_quarto.yml") + }.`, + ), + `Missing home directory warning in stderr:\n${stderr}`, + ); + const json = JSON.parse(Deno.readTextFileSync(output)); + assertEquals(json.info.project.dir, home); + } finally { + Deno.removeSync(home, { recursive: true }); + } +}); From 425e96911b06fa63fd4c4ec15bafd74f3e2e3d61 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 30 Sep 2026 16:24:20 +0200 Subject: [PATCH 05/15] Frame permission errors from the project input walk in render and preview A stray _quarto.yml (e.g. in the home directory) turns every path below it into a project, and render then walks the whole tree. When that walk hits a directory the user cannot list, render and preview printed the raw Deno readdir error with a stack trace, which says nothing about why Quarto was reading that directory. The walk now tags its PermissionDenied with the project root, and projectContext() adds the _quarto.yml that set it. The error object itself is unchanged, so inspect and other callers report exactly what they did before. render and preview turn only tagged errors into one framed error: the Deno message as is (it carries the unreadable path), the project root, the _quarto.yml, and a hint that the file may have been created by accident. Catching around projectContext() as a whole was avoided because config, profile and cache reads can raise PermissionDenied too. projectContext() also left the /.quarto disk cache open and the session temp dir behind when it threw after building the context: cleanup was only registered on success. Each branch now releases the context before rethrowing. On Windows the open cache made the project directory impossible to remove in the same process (os error 32). Refs #14960 --- llm-docs/project-context-architecture.md | 17 ++ src/command/command-utils.ts | 17 ++ src/command/preview/cmd.ts | 5 +- src/command/render/cmd.ts | 5 +- src/project/project-context.ts | 216 ++++++++++++------ .../check/check-project-config-only.test.ts | 30 +-- .../project/project-unreadable-dir.test.ts | 113 +++++++++ .../project-context-unreadable-dir.test.ts | 95 ++++++++ tests/utils.ts | 18 ++ 9 files changed, 420 insertions(+), 96 deletions(-) create mode 100644 tests/smoke/project/project-unreadable-dir.test.ts create mode 100644 tests/unit/project/project-context-unreadable-dir.test.ts diff --git a/llm-docs/project-context-architecture.md b/llm-docs/project-context-architecture.md index c08d58affcc..870d2a42c71 100644 --- a/llm-docs/project-context-architecture.md +++ b/llm-docs/project-context-architecture.md @@ -113,6 +113,18 @@ 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()`. +`addDir` tags 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 ''`, 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 @@ -127,6 +139,11 @@ Each built context opens a `Deno.Kv` disk cache via `createProjectCache()` 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 diff --git a/src/command/command-utils.ts b/src/command/command-utils.ts index 1d91e6fa544..2141db24217 100644 --- a/src/command/command-utils.ts +++ b/src/command/command-utils.ts @@ -6,6 +6,7 @@ import { initYamlIntelligenceResourcesFromFilesystem } from "../core/schema/utils.ts"; import { + frameInputWalkError, type ProjectConfigResolution, resolveProjectConfig, } from "../project/project-context.ts"; @@ -78,3 +79,19 @@ export async function initializeProjectContextAndEngines( return resolved; } + +/** + * Wraps a command action so a permission error from the project input walk + * is reported as a framed error naming the project and its _quarto.yml. + */ +export function withInputWalkErrorFraming( + action: (...args: A) => Promise, +): (...args: A) => Promise { + return async (...args: A) => { + try { + await action(...args); + } catch (e) { + throw frameInputWalkError(e); + } + }; +} diff --git a/src/command/preview/cmd.ts b/src/command/preview/cmd.ts index 8e34d5549c3..a5fc0eace98 100644 --- a/src/command/preview/cmd.ts +++ b/src/command/preview/cmd.ts @@ -54,6 +54,7 @@ import { fileExecutionEngine } from "../../execute/engine.ts"; import { notebookContext } from "../../render/notebook/notebook-context.ts"; import { singleFileProjectContext } from "../../project/types/single-file/single-file.ts"; import { exitWithCleanup } from "../../core/cleanup.ts"; +import { withInputWalkErrorFraming } from "../command-utils.ts"; export const previewCommand = new Command() .name("preview") @@ -138,7 +139,7 @@ export const previewCommand = new Command() "quarto preview --render html", ) // deno-lint-ignore no-explicit-any - .action(async (options: any, file?: string, ...args: string[]) => { + .action(withInputWalkErrorFraming(async (options: any, file?: string, ...args: string[]) => { // one-time initialization of yaml validation modules setInitializer(initYamlIntelligenceResourcesFromFilesystem); await initState(); @@ -434,4 +435,4 @@ export const previewCommand = new Command() presentation: options.presentation, }, project); } - }); + })); diff --git a/src/command/render/cmd.ts b/src/command/render/cmd.ts index 0c31b66a0ac..a78a0e463c4 100644 --- a/src/command/render/cmd.ts +++ b/src/command/render/cmd.ts @@ -19,6 +19,7 @@ import { RenderResult } from "./types.ts"; import { kCliffyImplicitCwd } from "../../config/constants.ts"; import { InternalError } from "../../core/lib/error.ts"; import { notebookContext } from "../../render/notebook/notebook-context.ts"; +import { withInputWalkErrorFraming } from "../command-utils.ts"; export const renderCommand = new Command() .name("render") @@ -126,7 +127,7 @@ export const renderCommand = new Command() "quarto render document.qmd --output -", ) // deno-lint-ignore no-explicit-any - .action(async (options: any, input?: string, ...args: string[]) => { + .action(withInputWalkErrorFraming(async (options: any, input?: string, ...args: string[]) => { // remove implicit clean argument (re-injected based on what the user // actually passes in flags.ts) if (options === undefined) { @@ -287,4 +288,4 @@ export const renderCommand = new Command() } else { throw new Error(`No valid input files passed to render`); } - }); + })); diff --git a/src/project/project-context.ts b/src/project/project-context.ts index 4834e3a11cd..a5ba6d5c56e 100644 --- a/src/project/project-context.ts +++ b/src/project/project-context.ts @@ -111,6 +111,8 @@ import { createProjectCache } from "../core/cache/cache.ts"; import { createTempContext } from "../core/temp.ts"; import { onCleanup } from "../core/cleanup.ts"; +import { ErrorEx } from "../core/lib/error.ts"; +import { tidyverseError, tidyverseInfo } from "../core/lib/errors.ts"; import { Zod } from "../resources/types/zod/schema-types.ts"; import { ExternalEngine } from "../resources/types/schema-types.ts"; @@ -248,41 +250,47 @@ export async function projectContext( }, }; - // see if the project [kProjectType] wants to filter the project config - if (type.config) { - result.config = await type.config( + try { + // see if the project [kProjectType] wants to filter the project config + if (type.config) { + result.config = await type.config( + result, + projectConfig, + flags, + ); + } + const { files, engines } = await projectInputFiles( result, projectConfig, - flags, ); - } - const { files, engines } = await projectInputFiles( - result, - projectConfig, - ); - // if we are attemping to get the projectConext for a file and the - // file isn't in list of input files then return a single-file project - const fullPath = normalizePath(path); - if (Deno.statSync(fullPath).isFile && !files.includes(fullPath)) { - return undefined; - } + // if we are attemping to get the projectConext for a file and the + // file isn't in list of input files then return a single-file project + const fullPath = normalizePath(path); + if (Deno.statSync(fullPath).isFile && !files.includes(fullPath)) { + return undefined; + } - if (type.formatExtras) { - result.formatExtras = async ( - source: string, - flags: PandocFlags, - format: Format, - services: RenderServices, - ) => type.formatExtras!(result, source, flags, format, services); + if (type.formatExtras) { + result.formatExtras = async ( + source: string, + flags: PandocFlags, + format: Format, + services: RenderServices, + ) => type.formatExtras!(result, source, flags, format, services); + } + result.engines = engines; + result.files = { + input: files, + resources: projectResourceFiles(dir, projectConfig), + config: configFiles, + configResources: projectConfigResources(dir, projectConfig, type), + }; + return await returnResult(result); + } catch (e) { + result.cleanup(); + tagInputWalkConfigFile(e, resolved.configFile); + throw e; } - result.engines = engines; - result.files = { - input: files, - resources: projectResourceFiles(dir, projectConfig), - config: configFiles, - configResources: projectConfigResources(dir, projectConfig, type), - }; - return await returnResult(result); } else { const temp = createTempContext({ dir: join(dir, ".quarto"), @@ -339,18 +347,24 @@ export async function projectContext( temp.cleanup(); }, }; - const { files, engines } = await projectInputFiles( - result, - projectConfig, - ); - result.engines = engines; - result.files = { - input: files, - resources: projectResourceFiles(dir, projectConfig), - config: configFiles, - configResources: projectConfigResources(dir, projectConfig), - }; - return await returnResult(result); + try { + const { files, engines } = await projectInputFiles( + result, + projectConfig, + ); + result.engines = engines; + result.files = { + input: files, + resources: projectResourceFiles(dir, projectConfig), + config: configFiles, + configResources: projectConfigResources(dir, projectConfig), + }; + return await returnResult(result); + } catch (e) { + result.cleanup(); + tagInputWalkConfigFile(e, resolved.configFile); + throw e; + } } } else if (force) { const temp = createTempContext({ @@ -412,17 +426,22 @@ export async function projectContext( temp.cleanup(); }, }; - if (Deno.statSync(path).isDirectory) { - const { files, engines } = await projectInputFiles(context); - context.engines = engines; - context.files.input = files; - } else { - const input = normalizePath(path); - const engine = await fileExecutionEngine(input, undefined, context); - context.engines = [engine?.name ?? kMarkdownEngine]; - context.files.input = [input]; + try { + if (Deno.statSync(path).isDirectory) { + const { files, engines } = await projectInputFiles(context); + context.engines = engines; + context.files.input = files; + } else { + const input = normalizePath(path); + const engine = await fileExecutionEngine(input, undefined, context); + context.engines = [engine?.name ?? kMarkdownEngine]; + context.files.input = [input]; + } + return await returnResult(context); + } catch (e) { + context.cleanup(); + throw e; } - return await returnResult(context); } else { return undefined; } @@ -931,6 +950,54 @@ function projectHiddenIgnoreGlob(dir: string) { .concat(["**/*.llms.md"]); // llms.txt companion markdown files } +// Set on the PermissionDenied thrown by the input walk: the project whose +// inputs were being listed, and the _quarto.yml that made it a project +// (null for an extension-detected root, unset outside a resolved project). +const kInputWalkProject = Symbol("input-walk-project"); +type InputWalkError = Deno.errors.PermissionDenied & { + [kInputWalkProject]?: { dir: string; configFile?: string | null }; +}; + +function tagInputWalkConfigFile(e: unknown, configFile: string | null) { + const project = (e as InputWalkError)?.[kInputWalkProject]; + if (project) { + project.configFile = configFile; + } +} + +// Returns a framed error for a PermissionDenied from a project input walk, +// or `e` unchanged for any other error. +export function frameInputWalkError(e: unknown): unknown { + const project = (e as InputWalkError)?.[kInputWalkProject]; + if (!project || project.configFile === undefined) { + return e; + } + const { dir, configFile } = project; + const lines = [ + "Could not read a directory while looking for project input files.", + tidyverseError((e as Error).message), + ]; + if (configFile) { + lines.push( + tidyverseInfo(`Project root: ${dir}`), + tidyverseInfo(`Set by: ${configFile}`), + tidyverseInfo( + "If this _quarto.yml was created by accident, remove it. Otherwise make the directory readable.", + ), + ); + } else { + lines.push( + tidyverseInfo( + `Project root: ${dir} (detected by an extension project type, no _quarto.yml)`, + ), + tidyverseInfo( + "If this directory is not meant to be a Quarto project, remove the files that mark it as one. Otherwise make the directory readable.", + ), + ); + } + return new ErrorEx("Error", lines.join("\n"), false, false); +} + export const projectInputFiles = makeTimedFunctionAsync( "projectInputFiles", projectInputFilesInternal, @@ -992,27 +1059,34 @@ async function projectInputFilesInternal( }; const addDir = async (dir: string): Promise => { const promises: Promise[] = []; - for await ( - const walkEntry of walk(dir, { - includeDirs: false, - // this was done b/c some directories e.g. renv/packrat and potentially python - // virtualenvs include symblinks to R or Python libraries that are in turn - // circular. much safer to not follow symlinks! - followSymlinks: false, - skip: [kSkipHidden].concat( - engineIgnoreDirs().map((ignore) => - globToRegExp(join(dir, ignore) + SEP) + try { + for await ( + const walkEntry of walk(dir, { + includeDirs: false, + // this was done b/c some directories e.g. renv/packrat and potentially python + // virtualenvs include symblinks to R or Python libraries that are in turn + // circular. much safer to not follow symlinks! + followSymlinks: false, + skip: [kSkipHidden].concat( + engineIgnoreDirs().map((ignore) => + globToRegExp(join(dir, ignore) + SEP) + ), ), - ), - }) - ) { - const pathRelative = pathWithForwardSlashes( - relative(dir, walkEntry.path), - ); - if (projectIgnores.some((regex) => regex.test(pathRelative))) { - continue; + }) + ) { + const pathRelative = pathWithForwardSlashes( + relative(dir, walkEntry.path), + ); + if (projectIgnores.some((regex) => regex.test(pathRelative))) { + continue; + } + promises.push(addFile(walkEntry.path)); + } + } catch (e) { + if (e instanceof Deno.errors.PermissionDenied) { + (e as InputWalkError)[kInputWalkProject] = { dir: project.dir }; } - promises.push(addFile(walkEntry.path)); + throw e; } const inclusions = await Promise.all(promises); return inclusions.flat(); diff --git a/tests/smoke/check/check-project-config-only.test.ts b/tests/smoke/check/check-project-config-only.test.ts index 4be0c6010cc..bcefe567d36 100644 --- a/tests/smoke/check/check-project-config-only.test.ts +++ b/tests/smoke/check/check-project-config-only.test.ts @@ -8,32 +8,20 @@ */ import { existsSync } from "../../../src/deno_ral/fs.ts"; -import { dirname, join } from "../../../src/deno_ral/path.ts"; +import { join } from "../../../src/deno_ral/path.ts"; import { testQuartoCmdJson } from "../../test.ts"; -import { canMakeUnreadableDir, makeUnreadableDir } from "../../utils.ts"; +import { + canMakeUnreadableDir, + makeUnreadableDir, + removeProject, + tempProject, +} from "../../utils.ts"; import { assert, assertEquals } from "testing/asserts"; -// The tree must exist at registration: the harness enters `cwd` before setup. -function tempProject(): string { - const dir = Deno.makeTempDirSync({ prefix: "quarto-check-project" }); - Deno.writeTextFileSync(join(dir, "_quarto.yml"), "project:\n type: website\n"); - Deno.mkdirSync(join(dir, "sub")); - Deno.writeTextFileSync(join(dir, "sub", "index.qmd"), "# Hello\n"); - return dir; -} - -// Teardown runs before the harness restores cwd, and Windows cannot remove -// the process cwd, so leave the project first. -function removeProject(dir: string) { - Deno.chdir(dirname(dir)); - Deno.removeSync(dir, { recursive: true }); - return Promise.resolve(); -} - // Not registered when the dir can't be made unreadable: an ignored test never // runs its teardown, so its fixture would be left behind. if (canMakeUnreadableDir) { - const projectDir = tempProject(); + const projectDir = tempProject("quarto-check-project"); const locked = join(projectDir, "locked"); Deno.mkdirSync(locked); Deno.writeTextFileSync(join(locked, "doc.qmd"), "# Locked\n"); @@ -62,7 +50,7 @@ if (canMakeUnreadableDir) { } (() => { - const projectDir = tempProject(); + const projectDir = tempProject("quarto-check-project"); const output = join(projectDir, "check-info.json"); testQuartoCmdJson( "check", diff --git a/tests/smoke/project/project-unreadable-dir.test.ts b/tests/smoke/project/project-unreadable-dir.test.ts new file mode 100644 index 00000000000..c091af5ce81 --- /dev/null +++ b/tests/smoke/project/project-unreadable-dir.test.ts @@ -0,0 +1,113 @@ +/* + * project-unreadable-dir.test.ts + * + * A project containing a directory the user cannot list: `quarto render` + * reports a framed error naming the directory, the project root and its + * _quarto.yml, while `quarto inspect` keeps reporting the raw error. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { join } from "../../../src/deno_ral/path.ts"; +import { normalizePath } from "../../../src/core/path.ts"; +import { ExecuteOutput, testQuartoCmd, Verify } from "../../test.ts"; +import { + canMakeUnreadableDir, + makeUnreadableDir, + removeProject, + tempProject, +} from "../../utils.ts"; +import { assert, assertEquals } from "testing/asserts"; + +function errorMessages(outputs: ExecuteOutput[]) { + return outputs.filter((output) => output.levelName === "ERROR") + .map((output) => output.msg); +} + +// Lines of the error message, without colors or tidyverse bullets. Dev builds +// run with QUARTO_DEBUG, which appends a stack trace to every error; the +// framed error's own printStack is pinned in the projectContext unit test. +function plainLines(msg: string) { + // deno-lint-ignore no-control-regex + return msg.replace(/\x1b\[[0-9;]*m/g, "").split("\n\nStack trace:")[0] + .split("\n") + .map((line) => line.replace(/^[✖xℹi] /, "")); +} + +function registerUnreadableDirTest( + cmd: string, + name: string, + verify: (root: string, locked: string) => Verify, +) { + const root = normalizePath(tempProject("quarto-unreadable-project")); + const locked = join(root, "locked"); + Deno.mkdirSync(locked); + Deno.writeTextFileSync(join(locked, "doc.qmd"), "# Locked\n"); + let restore: (() => void) | undefined; + testQuartoCmd(cmd, ["index.qmd"], [verify(root, locked)], { + cwd: () => join(root, "sub"), + setup: () => { + restore = makeUnreadableDir(locked); + return Promise.resolve(); + }, + teardown: () => { + restore?.(); + return removeProject(root); + }, + }, name); +} + +// Not registered when the dir can't be made unreadable: an ignored test never +// runs its teardown, so its fixture would be left behind. +if (canMakeUnreadableDir) { + registerUnreadableDirTest( + "render", + "render-in-project-with-unreadable-dir", + (root, locked) => ({ + name: "framed permission error", + verify: (outputs) => { + const errors = errorMessages(outputs); + assertEquals(errors.length, 1, `expected one error, got ${errors}`); + const lines = plainLines(errors[0]); + assertEquals(lines.length, 5, `unexpected message:\n${errors[0]}`); + assertEquals( + lines[0], + "Could not read a directory while looking for project input files.", + ); + assert( + lines[1].endsWith(`readdir '${locked}'`), + `expected the Deno readdir message, got: ${lines[1]}`, + ); + assertEquals(lines.slice(2), [ + `Project root: ${root}`, + `Set by: ${join(root, "_quarto.yml")}`, + "If this _quarto.yml was created by accident, remove it. Otherwise make the directory readable.", + ]); + return Promise.resolve(); + }, + }), + ); + + registerUnreadableDirTest( + "inspect", + "inspect-in-project-with-unreadable-dir", + (_root, locked) => ({ + name: "raw permission error", + verify: (outputs) => { + const errors = errorMessages(outputs); + assertEquals(errors.length, 1, `expected one error, got ${errors}`); + const [first] = errors[0].split("\n"); + assert( + first.startsWith("PermissionDenied: ") && + first.endsWith(`readdir '${locked}'`), + `expected the raw PermissionDenied error, got: ${first}`, + ); + assert( + errors[0].includes("Stack trace:"), + "expected the raw error with its stack trace", + ); + return Promise.resolve(); + }, + }), + ); +} diff --git a/tests/unit/project/project-context-unreadable-dir.test.ts b/tests/unit/project/project-context-unreadable-dir.test.ts new file mode 100644 index 00000000000..5e42b367b9f --- /dev/null +++ b/tests/unit/project/project-context-unreadable-dir.test.ts @@ -0,0 +1,95 @@ +/* + * project-context-unreadable-dir.test.ts + * + * projectContext() rejects with PermissionDenied when the input walk hits an + * unreadable directory, releases the context it built before throwing, and + * tags the error so render/preview can frame it. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../../test.ts"; +import { + assertEquals, + assertInstanceOf, + assertRejects, + assertStrictEquals, + assertStringIncludes, +} from "testing/asserts"; +import { join } from "../../../src/deno_ral/path.ts"; +import { existsSync } from "../../../src/deno_ral/fs.ts"; +import { normalizePath } from "../../../src/core/path.ts"; +import { ErrorEx } from "../../../src/core/lib/error.ts"; +import { + frameInputWalkError, + projectContext, +} from "../../../src/project/project-context.ts"; +import { notebookContext } from "../../../src/render/notebook/notebook-context.ts"; +import { initYamlIntelligenceResourcesFromFilesystem } from "../../../src/core/schema/utils.ts"; +import { + canMakeUnreadableDir, + makeUnreadableDir, + withTempDir, +} from "../../utils.ts"; + +function sessionTempDirs(root: string) { + const dotQuarto = join(root, ".quarto"); + if (!existsSync(dotQuarto)) { + return []; + } + return Array.from(Deno.readDirSync(dotQuarto)) + .map((entry) => entry.name) + .filter((name) => name.startsWith("quarto-session-temp")); +} + +// Not registered when the dir can't be made unreadable (root on Unix). +if (canMakeUnreadableDir) { + for ( + const [label, quartoYml, force] of [ + ["project key", "project:\n type: default\n", false], + ["no project key", "format: html\n", false], + ["no _quarto.yml, forced", undefined, true], + ] as const + ) { + unitTest( + `projectContext releases its context when the input walk is denied (${label})`, + async () => { + await initYamlIntelligenceResourcesFromFilesystem(); + // withTempDir removes the project afterwards: on Windows that fails + // with os error 32 while the project disk cache is still open. + await withTempDir(async (tmp) => { + const root = normalizePath(tmp); + if (quartoYml) { + Deno.writeTextFileSync(join(root, "_quarto.yml"), quartoYml); + } + Deno.writeTextFileSync(join(root, "index.qmd"), "# Hello\n"); + const locked = join(root, "locked"); + Deno.mkdirSync(locked); + Deno.writeTextFileSync(join(locked, "doc.qmd"), "# Locked\n"); + const restore = makeUnreadableDir(locked); + let walkError: unknown; + try { + walkError = await assertRejects( + () => projectContext(root, notebookContext(), undefined, force), + Deno.errors.PermissionDenied, + ); + } finally { + restore(); + } + assertEquals(sessionTempDirs(root), []); + + const framed = frameInputWalkError(walkError); + if (force) { + // no project root to report: the raw error is kept + assertStrictEquals(framed, walkError); + } else { + assertInstanceOf(framed, ErrorEx); + assertEquals(framed.printStack, false); + assertEquals(framed.printName, false); + assertStringIncludes(framed.message, `readdir '${locked}'`); + } + }, "quarto-unreadable-walk"); + }, + ); + } +} diff --git a/tests/utils.ts b/tests/utils.ts index d42426728f0..011af6aa234 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -292,3 +292,21 @@ export function makeUnreadableDir(dir: string): () => void { return () => Deno.chmodSync(dir, mode & 0o7777); } } + +// Temp project (website) with `sub/index.qmd`. The tree must exist at test +// registration: the harness enters `cwd` before setup. +export function tempProject(prefix: string): string { + const dir = Deno.makeTempDirSync({ prefix }); + Deno.writeTextFileSync(join(dir, "_quarto.yml"), "project:\n type: website\n"); + Deno.mkdirSync(join(dir, "sub")); + Deno.writeTextFileSync(join(dir, "sub", "index.qmd"), "# Hello\n"); + return dir; +} + +// Teardown runs before the harness restores cwd, and Windows cannot remove +// the process cwd, so leave the project first. +export function removeProject(dir: string) { + Deno.chdir(dirname(dir)); + Deno.removeSync(dir, { recursive: true }); + return Promise.resolve(); +} From 53c0ffb39fc567a41bd2027def3fc66974788c73 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 30 Sep 2026 16:44:13 +0200 Subject: [PATCH 06/15] Frame permission errors from project.render glob expansion too A project with `project: render` globs lists its inputs through resolvePathGlobs, whose expandGlob traversal is separate from the addDir walk. A recursive glob such as `**/*.qmd` reaching an unreadable directory still surfaced the raw readdir error with a stack trace in render and preview. Tag the PermissionDenied from that traversal the same way. File reads in addFile are left untagged on purpose: the framed message says a directory could not be read. --- llm-docs/project-context-architecture.md | 5 +- src/project/project-context.ts | 22 +++++-- .../project/project-unreadable-dir.test.ts | 60 ++++++++++++------- 3 files changed, 56 insertions(+), 31 deletions(-) diff --git a/llm-docs/project-context-architecture.md b/llm-docs/project-context-architecture.md index 870d2a42c71..8cc727f1bb2 100644 --- a/llm-docs/project-context-architecture.md +++ b/llm-docs/project-context-architecture.md @@ -113,8 +113,9 @@ 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()`. -`addDir` tags that `PermissionDenied` (the same error object, message and -stack untouched) with the project dir; `projectContext()` adds the +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 diff --git a/src/project/project-context.ts b/src/project/project-context.ts index a5ba6d5c56e..f4eff259371 100644 --- a/src/project/project-context.ts +++ b/src/project/project-context.ts @@ -1057,6 +1057,12 @@ async function projectInputFilesInternal( engineIntermediates: engineIntermediates, }]; }; + // directory traversals (walk, render globs) tag their PermissionDenied + const tagInputWalkError = (e: unknown) => { + if (e instanceof Deno.errors.PermissionDenied) { + (e as InputWalkError)[kInputWalkProject] = { dir: project.dir }; + } + }; const addDir = async (dir: string): Promise => { const promises: Promise[] = []; try { @@ -1083,9 +1089,7 @@ async function projectInputFilesInternal( promises.push(addFile(walkEntry.path)); } } catch (e) { - if (e instanceof Deno.errors.PermissionDenied) { - (e as InputWalkError)[kInputWalkProject] = { dir: project.dir }; - } + tagInputWalkError(e); throw e; } const inclusions = await Promise.all(promises); @@ -1103,9 +1107,15 @@ async function projectInputFilesInternal( let inclusions: FileInclusion[]; if (renderFiles) { const exclude = projIgnoreGlobs.concat(outputDir ? [outputDir] : []); - const resolved = resolvePathGlobs(dir, renderFiles, exclude, { - mode: "auto", - }); + let resolved; + try { + resolved = resolvePathGlobs(dir, renderFiles, exclude, { + mode: "auto", + }); + } catch (e) { + tagInputWalkError(e); + throw e; + } const toInclude = ld.difference( resolved.include, resolved.exclude, diff --git a/tests/smoke/project/project-unreadable-dir.test.ts b/tests/smoke/project/project-unreadable-dir.test.ts index c091af5ce81..7dcd9c6eacc 100644 --- a/tests/smoke/project/project-unreadable-dir.test.ts +++ b/tests/smoke/project/project-unreadable-dir.test.ts @@ -38,8 +38,12 @@ function registerUnreadableDirTest( cmd: string, name: string, verify: (root: string, locked: string) => Verify, + quartoYml?: string, ) { const root = normalizePath(tempProject("quarto-unreadable-project")); + if (quartoYml) { + Deno.writeTextFileSync(join(root, "_quarto.yml"), quartoYml); + } const locked = join(root, "locked"); Deno.mkdirSync(locked); Deno.writeTextFileSync(join(locked, "doc.qmd"), "# Locked\n"); @@ -57,35 +61,45 @@ function registerUnreadableDirTest( }, name); } +const framedError = (root: string, locked: string): Verify => ({ + name: "framed permission error", + verify: (outputs) => { + const errors = errorMessages(outputs); + assertEquals(errors.length, 1, `expected one error, got ${errors}`); + const lines = plainLines(errors[0]); + assertEquals(lines.length, 5, `unexpected message:\n${errors[0]}`); + assertEquals( + lines[0], + "Could not read a directory while looking for project input files.", + ); + assert( + lines[1].endsWith(`readdir '${locked}'`), + `expected the Deno readdir message, got: ${lines[1]}`, + ); + assertEquals(lines.slice(2), [ + `Project root: ${root}`, + `Set by: ${join(root, "_quarto.yml")}`, + "If this _quarto.yml was created by accident, remove it. Otherwise make the directory readable.", + ]); + return Promise.resolve(); + }, +}); + // Not registered when the dir can't be made unreadable: an ignored test never // runs its teardown, so its fixture would be left behind. if (canMakeUnreadableDir) { registerUnreadableDirTest( "render", "render-in-project-with-unreadable-dir", - (root, locked) => ({ - name: "framed permission error", - verify: (outputs) => { - const errors = errorMessages(outputs); - assertEquals(errors.length, 1, `expected one error, got ${errors}`); - const lines = plainLines(errors[0]); - assertEquals(lines.length, 5, `unexpected message:\n${errors[0]}`); - assertEquals( - lines[0], - "Could not read a directory while looking for project input files.", - ); - assert( - lines[1].endsWith(`readdir '${locked}'`), - `expected the Deno readdir message, got: ${lines[1]}`, - ); - assertEquals(lines.slice(2), [ - `Project root: ${root}`, - `Set by: ${join(root, "_quarto.yml")}`, - "If this _quarto.yml was created by accident, remove it. Otherwise make the directory readable.", - ]); - return Promise.resolve(); - }, - }), + framedError, + ); + + // project.render globs expand through their own directory traversal + registerUnreadableDirTest( + "render", + "render-glob-in-project-with-unreadable-dir", + framedError, + 'project:\n type: website\n render:\n - "**/*.qmd"\n', ); registerUnreadableDirTest( From 6e2bc8ea7a046f07bf52403608314e58360b9f5a Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 30 Sep 2026 17:54:01 +0200 Subject: [PATCH 07/15] Add changelog entry for #14960 --- news/changelog-1.11.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index e181367d4db..3622401d067 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -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) From 725aadb416cb610541d228c91e1a2790f9203205 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 1 Oct 2026 11:06:16 +0200 Subject: [PATCH 08/15] Pass project to CheckConfiguration in the Windows ARM R unit test CheckConfiguration now requires a project field, and this test builds the object inline, so it failed type-checking on CI. --- tests/unit/windows-arm-r-error.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/windows-arm-r-error.test.ts b/tests/unit/windows-arm-r-error.test.ts index 4ae1ff982de..d0371e23e55 100644 --- a/tests/unit/windows-arm-r-error.test.ts +++ b/tests/unit/windows-arm-r-error.test.ts @@ -97,6 +97,7 @@ unitTest("Windows ARM x64 R is preserved in check JSON", async () => { output: "check.json", services: undefined!, jsonResult, + project: undefined, }, { checkRBinary: async () => "Rscript", From 695cc05d4035c13e795074a9329b9e9d813308d1 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 1 Oct 2026 12:10:39 +0200 Subject: [PATCH 09/15] Resolve the temp project path in tempProject for macOS On macOS, Deno.makeTempDirSync returns a path under /var/folders, a symlink to /private/var/folders, while quarto derives the project root from the process cwd, which is a real path. Tests comparing the project root or the readdir path in the framed permission error against the temp dir would then fail on the scheduled macOS smoke runs. check-info-project.test.ts already resolves its temp dir the same way. --- tests/utils.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/utils.ts b/tests/utils.ts index 011af6aa234..94b76c9c29c 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -294,9 +294,11 @@ export function makeUnreadableDir(dir: string): () => void { } // Temp project (website) with `sub/index.qmd`. The tree must exist at test -// registration: the harness enters `cwd` before setup. +// registration: the harness enters `cwd` before setup. Real path: quarto +// resolves the project root from the process cwd, which is a real path (macOS +// temp dirs live under the /var -> /private/var symlink). export function tempProject(prefix: string): string { - const dir = Deno.makeTempDirSync({ prefix }); + const dir = Deno.realPathSync(Deno.makeTempDirSync({ prefix })); Deno.writeTextFileSync(join(dir, "_quarto.yml"), "project:\n type: website\n"); Deno.mkdirSync(join(dir, "sub")); Deno.writeTextFileSync(join(dir, "sub", "index.qmd"), "# Hello\n"); From a21726ce0d95759ebbd6ed31ef2a6741d98e026f Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 1 Oct 2026 12:24:02 +0200 Subject: [PATCH 10/15] Narrow resolveEngines to the project config resolveEngines only reads project.config, but its ProjectContext parameter forced initializeProjectContextAndEngines to fabricate a context with `as ProjectContext` casts, both for a resolved project and for the no-project bundled-engine case. Taking Pick lets it pass the config directly; full-context callers still type-check. The no-project helper's dynamic imports bought nothing, since project-context.ts already loads extension.ts statically, so they become static imports. --- src/command/command-utils.ts | 54 +++++++++++++----------------------- src/execute/engine.ts | 4 ++- 2 files changed, 23 insertions(+), 35 deletions(-) diff --git a/src/command/command-utils.ts b/src/command/command-utils.ts index 2141db24217..d56612a7941 100644 --- a/src/command/command-utils.ts +++ b/src/command/command-utils.ts @@ -8,43 +8,30 @@ import { initYamlIntelligenceResourcesFromFilesystem } from "../core/schema/util import { frameInputWalkError, type ProjectConfigResolution, + resolveEngineExtensions, resolveProjectConfig, } from "../project/project-context.ts"; +import { createExtensionContext } from "../extension/extension.ts"; import { resolveEngines } from "../execute/engine.ts"; import { normalizePath } from "../core/path.ts"; -import type { ProjectContext } from "../project/types.ts"; +import type { ProjectConfig } from "../project/types.ts"; /** - * Create a minimal "zero-file" project context for loading bundled engine extensions - * when no actual project or file exists. + * Resolve a config holding only the bundled engine extensions, for use when + * no project exists. * * This is needed for commands like `quarto check julia` that run outside any project - * but still need access to bundled engines. The context provides just enough structure - * to discover and register bundled engine extensions. + * but still need access to bundled engines. * - * @param dir - Directory to use as the base (defaults to current working directory) - * @returns A minimal ProjectContext with bundled engines loaded + * @param dir - Directory to use as the base + * @returns A project config with bundled engines loaded */ -async function zeroFileProjectContext(dir?: string): Promise { - const { createExtensionContext } = await import( - "../extension/extension.ts" - ); - const { resolveEngineExtensions } = await import( - "../project/project-context.ts" - ); - - const extensionContext = createExtensionContext(); - const config = await resolveEngineExtensions( - extensionContext, +function bundledEngineConfig(dir: string): Promise { + return resolveEngineExtensions( + createExtensionContext(), { project: {} }, - dir || Deno.cwd(), + dir, ); - - // Return a minimal project context with the resolved engine config - return { - dir: dir || Deno.cwd(), - config, - } as ProjectContext; } /** @@ -55,8 +42,8 @@ async function zeroFileProjectContext(dir?: string): Promise { * 2. Resolving the project configuration (without walking project input files) * 3. Registering external engines via reorderEngines() * - * If no project is found, a zero-file context is created to load bundled engine - * extensions (like Julia), ensuring they're available for commands like `quarto check julia`. + * If no project is found, a config with only the bundled engine extensions + * (like Julia) is used, ensuring they're available for commands like `quarto check julia`. * * @param dir - Optional directory path (defaults to current working directory) * @returns The resolved project configuration, or undefined when no project is found @@ -67,15 +54,14 @@ export async function initializeProjectContextAndEngines( // Initialize YAML intelligence resources (required for project config) await initYamlIntelligenceResourcesFromFilesystem(); - // Use the project config if we're in a project directory, or create a - // zero-file context to load bundled engines when no project exists - const resolved = await resolveProjectConfig(normalizePath(dir || Deno.cwd())); - const context = resolved - ? { dir: resolved.dir, config: resolved.config } as ProjectContext - : await zeroFileProjectContext(dir); + // Use the project config if we're in a project directory, or load only + // the bundled engines when no project exists + const baseDir = normalizePath(dir || Deno.cwd()); + const resolved = await resolveProjectConfig(baseDir); + const config = resolved?.config ?? await bundledEngineConfig(baseDir); // Register external engines from project config - await resolveEngines(context); + await resolveEngines({ config }); return resolved; } diff --git a/src/execute/engine.ts b/src/execute/engine.ts index 18a554921f7..4546a81dc4b 100644 --- a/src/execute/engine.ts +++ b/src/execute/engine.ts @@ -210,7 +210,9 @@ export function markdownExecutionEngine( return markdownEngineDiscovery.launch(engineProjectContext(project)); } -export async function resolveEngines(project: ProjectContext) { +export async function resolveEngines( + project: Pick, +) { // Register standard engines on first call if (!enginesRegistered) { enginesRegistered = true; From 1487237e53c0668e9e14d881eb473c50e9b95569 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 1 Oct 2026 12:39:34 +0200 Subject: [PATCH 11/15] Format file with `yamark` --- llm-docs/project-context-architecture.md | 148 ++++++++--------------- 1 file changed, 50 insertions(+), 98 deletions(-) diff --git a/llm-docs/project-context-architecture.md b/llm-docs/project-context-architecture.md index 8cc727f1bb2..668e0333422 100644 --- a/llm-docs/project-context-architecture.md +++ b/llm-docs/project-context-architecture.md @@ -13,148 +13,100 @@ key_files: # 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`. +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: +`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. +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. + `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). +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). +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 | +| 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`. +`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. +- `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: +`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 `/.quarto`), applies - `type.config()`, walks inputs, then applies the membership check below. +- **Project found, `config.project` set** — builds the `ProjectContext` (temp context and disk cache under `/.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()`. +- **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. +`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: +`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`. +- 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 ''`, 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. +| 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 ''`, 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. +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 `/.quarto` (synthetic: under the temp -dir) and a temp context. `returnResult()` registers `context.cleanup` with -`onCleanup()`, which closes the cache at process exit. +Each built context opens a `Deno.Kv` disk cache via `createProjectCache()` (`src/core/cache/cache.ts`) under `/.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. +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`. +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`) | -|---------------------------------|--------------------------------------| +| 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 `/.quarto`. From ca22ca15ca3a5b0eca4d7af866cc8a7931fa1426 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 1 Oct 2026 13:10:26 +0200 Subject: [PATCH 12/15] Resolve symlinks with originalRealPathSync in tempProject and userHomeDir src/quarto.ts monkey-patches Deno.realPathSync to normalizePath, which does not follow symlinks. The test harness imports src/quarto.ts through quarto-cmd.ts, so the macOS /var -> /private/var resolution added to the temp project helpers was a no-op, and quarto check's home-directory comparison missed a symlinked HOME. originalRealPathSync keeps the unpatched function. --- src/command/check/check.ts | 3 ++- tests/smoke/check/check-info-project.test.ts | 3 ++- tests/utils.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/command/check/check.ts b/src/command/check/check.ts index 814c1a9cde7..2d291ea8a7b 100644 --- a/src/command/check/check.ts +++ b/src/command/check/check.ts @@ -30,6 +30,7 @@ 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"; @@ -189,7 +190,7 @@ function userHomeDir(): string | undefined { } // The project root is a real path (resolved from the process cwd). try { - return Deno.realPathSync(home); + return originalRealPathSync(home); } catch { return home; } diff --git a/tests/smoke/check/check-info-project.test.ts b/tests/smoke/check/check-info-project.test.ts index c13fc81cac5..6c3b5246712 100644 --- a/tests/smoke/check/check-info-project.test.ts +++ b/tests/smoke/check/check-info-project.test.ts @@ -10,6 +10,7 @@ import { dirname, join, resolve } from "../../../src/deno_ral/path.ts"; import { execProcess } from "../../../src/core/process.ts"; +import { originalRealPathSync } from "../../../src/deno_ral/original-real-path.ts"; import { testQuartoCmd, testQuartoCmdJson, unitTest } from "../../test.ts"; import { noErrors, printsMessage } from "../../verify.ts"; import { @@ -22,7 +23,7 @@ import { assert, assertEquals } from "testing/asserts"; // Real path: the project root is resolved from the process cwd, which is a // real path (macOS temp dirs live under the /var -> /private/var symlink). function tempDir(prefix: string): string { - return Deno.realPathSync(Deno.makeTempDirSync({ prefix })); + return originalRealPathSync(Deno.makeTempDirSync({ prefix })); } // The tree must exist at registration: the harness enters `cwd` before setup. diff --git a/tests/utils.ts b/tests/utils.ts index 94b76c9c29c..a3a9473febb 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -11,6 +11,7 @@ import { kMetadataFormat, kOutputExt, kOutputFile } from "../src/config/constant import { pathWithForwardSlashes, safeExistsSync } from "../src/core/path.ts"; import { readYaml } from "../src/core/yaml.ts"; import { isWindows } from "../src/deno_ral/platform.ts"; +import { originalRealPathSync } from "../src/deno_ral/original-real-path.ts"; import { bookOutputStem } from "../src/project/types/book/book-shared.ts"; import { ProjectConfig } from "../src/project/types.ts"; @@ -298,7 +299,7 @@ export function makeUnreadableDir(dir: string): () => void { // resolves the project root from the process cwd, which is a real path (macOS // temp dirs live under the /var -> /private/var symlink). export function tempProject(prefix: string): string { - const dir = Deno.realPathSync(Deno.makeTempDirSync({ prefix })); + const dir = originalRealPathSync(Deno.makeTempDirSync({ prefix })); Deno.writeTextFileSync(join(dir, "_quarto.yml"), "project:\n type: website\n"); Deno.mkdirSync(join(dir, "sub")); Deno.writeTextFileSync(join(dir, "sub", "index.qmd"), "# Hello\n"); From fb0f75081d204a13dea52ae32c8abd9024b7f7b1 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 1 Oct 2026 13:16:43 +0200 Subject: [PATCH 13/15] Test the home-directory warning with a symlinked HOME The existing home-warning test passes an already resolved path as HOME, so it also passed with the monkey-patched Deno.realPathSync that never followed the symlink. Pointing HOME at a symlink to the project root fails without originalRealPathSync in userHomeDir. Skipped on Windows, where creating a directory symlink needs Developer Mode or admin rights. --- tests/smoke/check/check-info-project.test.ts | 67 +++++++++++++------- 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/tests/smoke/check/check-info-project.test.ts b/tests/smoke/check/check-info-project.test.ts index 6c3b5246712..2ddfe37b67a 100644 --- a/tests/smoke/check/check-info-project.test.ts +++ b/tests/smoke/check/check-info-project.test.ts @@ -11,6 +11,7 @@ import { dirname, join, resolve } from "../../../src/deno_ral/path.ts"; import { execProcess } from "../../../src/core/process.ts"; import { originalRealPathSync } from "../../../src/deno_ral/original-real-path.ts"; +import { isWindows } from "../../../src/deno_ral/platform.ts"; import { testQuartoCmd, testQuartoCmdJson, unitTest } from "../../test.ts"; import { noErrors, printsMessage } from "../../verify.ts"; import { @@ -127,32 +128,54 @@ function removeDir(dir: string) { // Runs quarto as a subprocess: the home dir comes from the environment, and // changing it in-process would leak into concurrently running tests. +async function assertHomeWarning(project: string, home: string) { + const output = join(project, "check-info.json"); + const result = await execProcess({ + // The dev launcher path is relative to tests/, the child runs elsewhere. + cmd: isBinaryMode() ? quartoDevBinCmd() : resolve(quartoDevBinCmd()), + args: ["check", "info", "--output", output], + cwd: join(project, "sub"), + stdout: "piped", + stderr: "piped", + ...quartoSpawnEnvOptions({ HOME: home, USERPROFILE: home }), + }); + assert(result.success, `quarto check failed: ${result.stderr}`); + const stderr = result.stderr ?? ""; + assert( + stderr.includes( + `Project root is your home directory (${project}), set by ${ + join(project, "_quarto.yml") + }.`, + ), + `Missing home directory warning in stderr:\n${stderr}`, + ); + const json = JSON.parse(Deno.readTextFileSync(output)); + assertEquals(json.info.project.dir, project); +} + unitTest("check-info-warns-when-project-root-is-home", async () => { const home = tempProject(); try { - const output = join(home, "check-info.json"); - const result = await execProcess({ - // The dev launcher path is relative to tests/, the child runs elsewhere. - cmd: isBinaryMode() ? quartoDevBinCmd() : resolve(quartoDevBinCmd()), - args: ["check", "info", "--output", output], - cwd: join(home, "sub"), - stdout: "piped", - stderr: "piped", - ...quartoSpawnEnvOptions({ HOME: home, USERPROFILE: home }), - }); - assert(result.success, `quarto check failed: ${result.stderr}`); - const stderr = result.stderr ?? ""; - assert( - stderr.includes( - `Project root is your home directory (${home}), set by ${ - join(home, "_quarto.yml") - }.`, - ), - `Missing home directory warning in stderr:\n${stderr}`, - ); - const json = JSON.parse(Deno.readTextFileSync(output)); - assertEquals(json.info.project.dir, home); + await assertHomeWarning(home, home); } finally { Deno.removeSync(home, { recursive: true }); } }); + +// HOME is a symlink to the project root, which quarto sees as a real path. +// Creating directory symlinks on Windows needs Developer Mode or admin rights. +unitTest( + "check-info-warns-when-symlinked-home-is-project-root", + async () => { + const project = tempProject(); + const alias = `${project}-home`; + Deno.symlinkSync(project, alias, { type: "dir" }); + try { + await assertHomeWarning(project, alias); + } finally { + Deno.removeSync(alias); + Deno.removeSync(project, { recursive: true }); + } + }, + { ignore: isWindows }, +); From aad589ac60cdc9c2927e02d1449cd818d1ba5e15 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 1 Oct 2026 13:25:30 +0200 Subject: [PATCH 14/15] Render quarto check test documents as single files The basic markdown render in checkInstall and the engine renders through checkRender write their document to the system temp dir. That dir can sit beneath a project root: on Windows %TEMP% is under the home directory, so a stray ~/_quarto.yml made render() resolve the home project for the temp file, create its .quarto dir and walk its inputs, failing on any unreadable directory even though `check info` no longer walks. A temp document has no relation to any project, so both renders now get an explicit single-file project context. --- src/command/check/check-render.ts | 14 +++-- src/command/check/check.ts | 11 ++-- .../check-render-temp-in-project.test.ts | 60 +++++++++++++++++++ 3 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 tests/smoke/check/check-render-temp-in-project.test.ts diff --git a/src/command/check/check-render.ts b/src/command/check/check-render.ts index 935cb45d690..78e8c495b3f 100644 --- a/src/command/check/check-render.ts +++ b/src/command/check/check-render.ts @@ -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 @@ -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 { diff --git a/src/command/check/check.ts b/src/command/check/check.ts index 2d291ea8a7b..b66ab7e5f06 100644 --- a/src/command/check/check.ts +++ b/src/command/check/check.ts @@ -35,6 +35,7 @@ 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() @@ -553,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; diff --git a/tests/smoke/check/check-render-temp-in-project.test.ts b/tests/smoke/check/check-render-temp-in-project.test.ts new file mode 100644 index 00000000000..a724bfba0eb --- /dev/null +++ b/tests/smoke/check/check-render-temp-in-project.test.ts @@ -0,0 +1,60 @@ +/* + * check-render-temp-in-project.test.ts + * + * The `quarto check` test renders use the system temp dir, which can sit + * beneath a project root (Windows %TEMP% is under the home dir). They must + * render as single files, ignoring that project. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { existsSync } from "../../../src/deno_ral/fs.ts"; +import { join, resolve } from "../../../src/deno_ral/path.ts"; +import { execProcess } from "../../../src/core/process.ts"; +import { unitTest } from "../../test.ts"; +import { + isBinaryMode, + quartoDevBinCmd, + quartoSpawnEnvOptions, +} from "../../quarto-cmd.ts"; +import { + canMakeUnreadableDir, + makeUnreadableDir, + tempProject, +} from "../../utils.ts"; +import { assert } from "testing/asserts"; + +// Runs quarto as a subprocess: the temp dir comes from the environment, and +// changing it in-process would leak into concurrently running tests. +unitTest( + "check-install-renders-with-temp-dir-in-project", + async () => { + const projectDir = tempProject("quarto-check-render-temp"); + const locked = join(projectDir, "locked"); + Deno.mkdirSync(locked); + Deno.writeTextFileSync(join(locked, "doc.qmd"), "# Locked\n"); + const tmp = join(projectDir, "tmp"); + Deno.mkdirSync(tmp); + const restore = makeUnreadableDir(locked); + try { + const result = await execProcess({ + // The dev launcher path is relative to tests/, the child runs elsewhere. + cmd: isBinaryMode() ? quartoDevBinCmd() : resolve(quartoDevBinCmd()), + args: ["check", "install"], + cwd: join(projectDir, "sub"), + stdout: "piped", + stderr: "piped", + ...quartoSpawnEnvOptions({ TMP: tmp, TEMP: tmp, TMPDIR: tmp }), + }); + assert(result.success, `quarto check failed: ${result.stderr}`); + assert( + !existsSync(join(projectDir, ".quarto")), + "quarto check created the project .quarto dir", + ); + } finally { + restore(); + Deno.removeSync(projectDir, { recursive: true }); + } + }, + { ignore: !canMakeUnreadableDir }, +); From 57f9016429b0d4d514af73eebb0bd08f69a58a27 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 1 Oct 2026 13:33:10 +0200 Subject: [PATCH 15/15] Test checkRender with its temp dir beneath a project `check install` never reaches checkRender(), so the existing subprocess test left the engine-check render path uncovered. Calling checkRender() in process with a temp context rooted inside the project exercises it without touching the process environment. --- .../check-render-temp-in-project.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/smoke/check/check-render-temp-in-project.test.ts b/tests/smoke/check/check-render-temp-in-project.test.ts index a724bfba0eb..bc6985b3c51 100644 --- a/tests/smoke/check/check-render-temp-in-project.test.ts +++ b/tests/smoke/check/check-render-temp-in-project.test.ts @@ -22,6 +22,11 @@ import { makeUnreadableDir, tempProject, } from "../../utils.ts"; +import { checkRender } from "../../../src/command/check/check-render.ts"; +import { renderServices } from "../../../src/command/render/render-services.ts"; +import { createTempContext } from "../../../src/core/temp.ts"; +import { initYamlIntelligenceResourcesFromFilesystem } from "../../../src/core/schema/utils.ts"; +import { notebookContext } from "../../../src/render/notebook/notebook-context.ts"; import { assert } from "testing/asserts"; // Runs quarto as a subprocess: the temp dir comes from the environment, and @@ -58,3 +63,39 @@ unitTest( }, { ignore: !canMakeUnreadableDir }, ); + +// The engine checks (`check jupyter`, `check knitr`) render through +// checkRender(), which `check install` never reaches. +unitTest( + "check-render-with-temp-dir-in-project", + async () => { + await initYamlIntelligenceResourcesFromFilesystem(); + const projectDir = tempProject("quarto-check-render-temp"); + const locked = join(projectDir, "locked"); + Deno.mkdirSync(locked); + Deno.writeTextFileSync(join(locked, "doc.qmd"), "# Locked\n"); + const restore = makeUnreadableDir(locked); + const services = { + ...renderServices(notebookContext()), + temp: createTempContext({ dir: join(projectDir, "tmp") }), + }; + try { + const result = await checkRender({ + content: "# Check\n", + language: "markdown", + services, + }); + assert(result.success, `checkRender failed: ${result.error}`); + assert( + !existsSync(join(projectDir, ".quarto")), + "checkRender created the project .quarto dir", + ); + } finally { + services.temp.cleanup(); + services.cleanup(); + restore(); + Deno.removeSync(projectDir, { recursive: true }); + } + }, + { ignore: !canMakeUnreadableDir }, +);