From dba0fcbf8363e9b7927280041d89204fa93eee7d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 08:26:57 +0100 Subject: [PATCH 1/2] feat(explorer): add a searchUpTo option for bounded upward search A directory searches the start directory and each parent up to and including it, by calling a none-strategy explorer once per directory. cosmiconfig's global strategy cannot express this: it always ends by checking the OS config directory, whatever stopDir is. 'project' maps to the project strategy. Combining searchUpTo with cosmiconfig.searchStrategy or cosmiconfig.stopDir throws, naming both options. --- README.md | 18 ++++ src/explorer.integration.test.ts | 176 ++++++++++++++++++++++++++++++- src/explorer.ts | 61 ++++++++++- 3 files changed, 251 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7fc1519..92cc0c2 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,24 @@ export default { The package `shared-preset` is refused by default. See [the `extends` transform](#the-extends-transform) for how to allow it. +## Searching upwards + +By default `search(from)` checks only the directory it is given. `searchUpTo` states how far up a search may go: + +```ts +// Check the start directory and each parent, up to and including /repo. +createExplorer('my-tool', { searchUpTo: '/repo' }); + +// Stop at the first directory that holds a package.json or package.yaml. +createExplorer('my-tool', { searchUpTo: 'project' }); +``` + +The value `'project'` is the project boundary, so a directory with that name needs a path form such as `./project`. A directory is resolved against the working directory when `search` runs, and `search` throws if its start directory is not inside it. + +Neither form reads the user's global config directory. That is why a directory does not map to cosmiconfig's `global` strategy with `stopDir`: in cosmiconfig 9 and 10 that strategy always ends by checking the OS config directory (for example `~/.config/my-tool` or `~/Library/Preferences/my-tool`, as `config`, `config.json`, `config.ts` and similar), whatever `stopDir` is, and cosmiconfig has no option to switch that off. A config someone left there would be found by a search that was meant to stop at the repository, and the loader would evaluate it. A directory bound is therefore a walk over the parents with cosmiconfig's `none` strategy, which checks one directory per call, and `'project'` is cosmiconfig's `project` strategy, which never leaves the project. + +`searchUpTo` cannot be combined with `cosmiconfig.searchStrategy` or `cosmiconfig.stopDir`, and `createExplorer` throws naming both options. Leave `searchUpTo` out to pass those two options through to cosmiconfig unchanged, accepting the `global` strategy's lookup of the OS config directory. The bound is lexical: it compares resolved paths and does not follow symlinks. + ## Using the pieces with plain cosmiconfig Each part works on its own with a cosmiconfig explorer you build yourself: diff --git a/src/explorer.integration.test.ts b/src/explorer.integration.test.ts index 1776cbe..3300feb 100644 --- a/src/explorer.integration.test.ts +++ b/src/explorer.integration.test.ts @@ -1,7 +1,9 @@ +import fsPromises from 'node:fs/promises'; import { join } from 'node:path'; import * as v from 'valibot'; -import { describe, expect, it } from 'vitest'; +import type { PublicExplorer } from 'cosmiconfig'; +import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; import { makeProject, writeProjectFile } from '../test/support/project'; @@ -130,3 +132,175 @@ describe('createExplorer', () => { expect((await explorer.load(file))?.config).toEqual({ a: 'two' }); }); }); + +describe('createExplorer searchUpTo', () => { + const rc = (from: string): string => JSON.stringify({ from }); + + it('searches parents up to the directory, nearest first', async () => { + const root = makeProject({ + 'bound/.my-toolrc.json': rc('bound'), + 'bound/middle/.my-toolrc.json': rc('middle'), + 'bound/middle/start/placeholder.txt': '', + }); + + const result = await createExplorer('my-tool', { searchUpTo: join(root, 'bound') }).search(join(root, 'bound/middle/start')); + + expect(result?.config).toEqual({ from: 'middle' }); + }); + + it('includes the bounding directory itself', async () => { + const root = makeProject({ 'bound/.my-toolrc.json': rc('bound'), 'bound/start/placeholder.txt': '' }); + + const result = await createExplorer('my-tool', { searchUpTo: join(root, 'bound') }).search(join(root, 'bound/start')); + + expect(result?.filepath).toBe(join(root, 'bound/.my-toolrc.json')); + }); + + it('does not search above the bounding directory', async () => { + const root = makeProject({ '.my-toolrc.json': rc('above'), 'bound/start/placeholder.txt': '' }); + + expect(await createExplorer('my-tool', { searchUpTo: join(root, 'bound') }).search(join(root, 'bound/start'))).toBeNull(); + }); + + it('searches the start directory when it is the bounding directory', async () => { + const root = makeProject({ 'bound/.my-toolrc.json': rc('bound') }); + + const result = await createExplorer('my-tool', { searchUpTo: join(root, 'bound') }).search(join(root, 'bound')); + + expect(result?.config).toEqual({ from: 'bound' }); + }); + + it('applies extends to a config found in a parent', async () => { + const root = makeProject({ + 'bound/preset.ts': "export default { a: 'preset', b: 'preset' };\n", + 'bound/my-tool.config.ts': "export default { extends: './preset.ts', b: 'config' };\n", + 'bound/start/placeholder.txt': '', + }); + + const result = await createExplorer('my-tool', { searchUpTo: join(root, 'bound') }).search(join(root, 'bound/start')); + + expect(result?.config).toEqual({ a: 'preset', b: 'config' }); + }); + + it('throws when the search starts outside the bounding directory', async () => { + const root = makeProject({ 'bound/placeholder.txt': '', 'elsewhere/.my-toolrc.json': rc('elsewhere') }); + const explorer = createExplorer('my-tool', { searchUpTo: join(root, 'bound') }); + + await expect(explorer.search(join(root, 'elsewhere'))).rejects.toThrow(/does not contain it/); + }); + + it('is not fooled by a sibling directory sharing the bounding directory name as a prefix', async () => { + const root = makeProject({ 'bound/placeholder.txt': '', 'bound-sibling/.my-toolrc.json': rc('sibling') }); + const explorer = createExplorer('my-tool', { searchUpTo: join(root, 'bound') }); + + await expect(explorer.search(join(root, 'bound-sibling'))).rejects.toThrow(/does not contain it/); + }); + + it('keeps the other explorer methods', async () => { + const root = makeProject({ 'config.ts': "export default { a: 'x' };\n" }); + const explorer = createExplorer('my-tool', { searchUpTo: root }); + + expect((await explorer.load(join(root, 'config.ts')))?.config).toEqual({ a: 'x' }); + expect(() => { + explorer.clearCaches(); + }).not.toThrow(); + }); + + describe('against the user global config directory', () => { + /** + * Records every directory cosmiconfig checks during `search`, so the test does not depend on where the OS keeps the global config directory (cosmiconfig's path lookup caches the home directory at import time, so it cannot be redirected with environment variables). cosmiconfig stats a directory before reading anything from it, whether or not it exists. + */ + async function pathsCheckedBy(explorer: Readonly, from: string): Promise { + const stat = vi.spyOn(fsPromises, 'stat'); + try { + await explorer.search(from); + + return stat.mock.calls.flatMap(([path]) => (typeof path === 'string' ? [path] : [])); + } finally { + stat.mockRestore(); + } + } + + it('is never read for a directory bound, though the global strategy with stopDir reads it', async () => { + const root = makeProject({ 'bound/start/placeholder.txt': '' }); + const start = join(root, 'bound/start'); + const outsideRoot = (file: string): boolean => !file.startsWith(root); + + const raw = await pathsCheckedBy(createExplorer('my-tool', { cosmiconfig: { searchStrategy: 'global', stopDir: join(root, 'bound') } }), start); + const bounded = await pathsCheckedBy(createExplorer('my-tool', { searchUpTo: join(root, 'bound') }), start); + + expect(raw.some(outsideRoot)).toBe(true); + expect(bounded.length).toBeGreaterThan(0); + expect(bounded.filter(outsideRoot)).toEqual([]); + }); + + it('is never read for the project bound', async () => { + const root = makeProject({ 'package.json': '{}', 'start/placeholder.txt': '' }); + + const checked = await pathsCheckedBy(createExplorer('my-tool', { searchUpTo: 'project' }), join(root, 'start')); + + expect(checked.length).toBeGreaterThan(0); + expect(checked.filter((file) => !file.startsWith(root))).toEqual([]); + }); + }); + + describe('project', () => { + it('stops at the first directory containing a package.json', async () => { + const root = makeProject({ + '.my-toolrc.json': rc('above-package'), + 'package/package.json': '{}', + 'package/nested/start/placeholder.txt': '', + }); + + expect(await createExplorer('my-tool', { searchUpTo: 'project' }).search(join(root, 'package/nested/start'))).toBeNull(); + }); + + it('finds a config in a parent below the package root', async () => { + const root = makeProject({ + 'package.json': '{}', + 'nested/.my-toolrc.json': rc('nested'), + 'nested/start/placeholder.txt': '', + }); + + const result = await createExplorer('my-tool', { searchUpTo: 'project' }).search(join(root, 'nested/start')); + + expect(result?.config).toEqual({ from: 'nested' }); + }); + + it('finds a config beside the package.json', async () => { + const root = makeProject({ 'package.json': '{}', '.my-toolrc.json': rc('root'), 'start/placeholder.txt': '' }); + + const result = await createExplorer('my-tool', { searchUpTo: 'project' }).search(join(root, 'start')); + + expect(result?.config).toEqual({ from: 'root' }); + }); + }); + + describe('construction errors', () => { + it('rejects a raw searchStrategy, naming both options', () => { + expect(() => createExplorer('my-tool', { searchUpTo: 'project', cosmiconfig: { searchStrategy: 'global' } })).toThrow( + /searchUpTo 'project' cannot be combined with cosmiconfig\.searchStrategy$/, + ); + }); + + it('rejects a raw stopDir, naming both options', () => { + expect(() => createExplorer('my-tool', { searchUpTo: '/some/dir', cosmiconfig: { stopDir: '/other' } })).toThrow( + /searchUpTo '\/some\/dir' cannot be combined with cosmiconfig\.stopDir$/, + ); + }); + + it('names every raw option it conflicts with', () => { + expect(() => + createExplorer('my-tool', { searchUpTo: 'project', cosmiconfig: { searchStrategy: 'global', stopDir: '/other' } }), + ).toThrow(/cannot be combined with cosmiconfig\.searchStrategy and cosmiconfig\.stopDir$/); + }); + + it('rejects an empty string', () => { + expect(() => createExplorer('my-tool', { searchUpTo: '' })).toThrow(/not an empty string/); + }); + + it('still passes a raw searchStrategy and stopDir through without searchUpTo', () => { + expect(() => createExplorer('my-tool', { cosmiconfig: { searchStrategy: 'global', stopDir: '/some/dir' } })).not.toThrow(); + }); + }); +}); diff --git a/src/explorer.ts b/src/explorer.ts index 7299f3a..65213c8 100644 --- a/src/explorer.ts +++ b/src/explorer.ts @@ -1,4 +1,6 @@ -import { cosmiconfig, type Options, type PublicExplorer } from 'cosmiconfig'; +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; + +import { cosmiconfig, type CosmiconfigResult, type Options, type PublicExplorer } from 'cosmiconfig'; import { createExtendsTransform, type ExtendsOptions } from './extends'; import { createJitiLoader, type JitiLoaderOptions } from './loader'; @@ -11,6 +13,45 @@ export interface ExplorerOptions extends JitiLoaderOptions, Omit, 'transform'>; + /** + * Bounds an upward search. A directory makes `search` check the start directory and each parent in turn, up to and including that directory, and never reads the user's global config directory. `'project'` stops at the first directory containing a `package.json` or `package.yaml`, as cosmiconfig's `project` strategy does; pass `./project` to name a directory called `project`. + * + * Cannot be combined with `cosmiconfig.searchStrategy` or `cosmiconfig.stopDir`: `createExplorer` throws. A directory is resolved against the working directory when `search` runs, and `search` throws when its start directory is not inside it. + * + * Without this option, cosmiconfig's own default applies: only the start directory is searched. + */ + readonly searchUpTo?: string; +} + +const PROJECT_BOUND = 'project'; + +function assertNoRawSearchBounds(searchUpTo: string, raw: ExplorerOptions['cosmiconfig']): void { + const conflicting = (['searchStrategy', 'stopDir'] as const).filter((key) => raw?.[key] !== undefined); + if (conflicting.length > 0) { + throw new TypeError(`createExplorer: searchUpTo '${searchUpTo}' cannot be combined with ${conflicting.map((key) => `cosmiconfig.${key}`).join(' and ')}`); + } +} + +/** + * Searches `from` and each parent directory up to and including `stopDir`, returning the first result. + * + * `explorer` must use the `none` search strategy, which checks only the directory it is given. cosmiconfig's `global` strategy cannot express this bound, because it always ends by reading the OS config directory, whatever `stopDir` is. + */ +async function searchThroughParents(explorer: Readonly, from: string, stopDir: string): Promise { + const stop = resolve(stopDir); + let current = resolve(from); + const fromStop = relative(stop, current); + if (fromStop === '..' || fromStop.startsWith(`..${sep}`) || isAbsolute(fromStop)) { + throw new RangeError(`createExplorer: cannot search from '${current}' up to searchUpTo '${stop}', which does not contain it`); + } + + let result = await explorer.search(current); + while (result === null && current !== stop) { + current = dirname(current); + result = await explorer.search(current); + } + + return result; } /** @@ -19,11 +60,25 @@ export interface ExplorerOptions extends JitiLoaderOptions, Omit searchThroughParents(explorer, searchFrom, searchUpTo) }; } From e8a65599d9f9abc28d82df3b8113df34357e8333 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 08:27:05 +0100 Subject: [PATCH 2/2] test(interop): pin the cosmiconfig search strategy behaviour searchUpTo relies on Runs against cosmiconfig 9 and 10: none checks one directory, project stops at a package file, stopDir is inclusive and only valid with global, and global still checks the OS config directory after stopDir. --- src/interop.integration.test.ts | 61 ++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/src/interop.integration.test.ts b/src/interop.integration.test.ts index a6cd867..ff746a1 100644 --- a/src/interop.integration.test.ts +++ b/src/interop.integration.test.ts @@ -1,8 +1,10 @@ +import fsPromises from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { cosmiconfig as cosmiconfig10 } from 'cosmiconfig-10'; import { cosmiconfig as cosmiconfig9 } from 'cosmiconfig-9'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { makeProject, writeProjectFile } from '../test/support/project'; @@ -72,3 +74,60 @@ describe.each([ await expect(explorer.load(join(root, 'config.ts'))).rejects.toThrow(/refusing to load untrusted preset 'third-party-preset'/); }); }); + +/** + * Pins the cosmiconfig behaviour that the `searchUpTo` option of `createExplorer` is designed around, in both majors. + */ +describe.each([ + ['cosmiconfig 9', cosmiconfig9], + ['cosmiconfig 10', cosmiconfig10], +] as const)('%s search strategies', (_name, cosmiconfig) => { + const { loader } = createJitiLoader(); + const loaders = { '.ts': loader, '.mts': loader, '.cts': loader }; + + async function directoriesChecked(search: (from: string) => Promise, from: string): Promise { + const stat = vi.spyOn(fsPromises, 'stat'); + try { + await search(from); + + return stat.mock.calls.flatMap(([path]) => (typeof path === 'string' ? [path] : [])); + } finally { + stat.mockRestore(); + } + } + + it('searches only the start directory with the none strategy', async () => { + const root = makeProject({ '.my-toolrc.json': '{}', 'start/placeholder.txt': '' }); + + expect(await cosmiconfig('my-tool', { loaders, searchStrategy: 'none' }).search(join(root, 'start'))).toBeNull(); + }); + + it('stops at the first directory with a package.json with the project strategy', async () => { + const root = makeProject({ '.my-toolrc.json': '{}', 'package/package.json': '{}', 'package/start/placeholder.txt': '' }); + + expect(await cosmiconfig('my-tool', { loaders, searchStrategy: 'project' }).search(join(root, 'package/start'))).toBeNull(); + }); + + it('rejects stopDir with a strategy other than global', () => { + for (const searchStrategy of ['none', 'project'] as const) { + expect(() => cosmiconfig('my-tool', { loaders, searchStrategy, stopDir: tmpdir() })).toThrow(/stopDir/); + } + }); + + it('includes stopDir in the global strategy', async () => { + const root = makeProject({ 'bound/.my-toolrc.json': '{"from":"bound"}', 'bound/start/placeholder.txt': '' }); + + const result = await cosmiconfig('my-tool', { loaders, searchStrategy: 'global', stopDir: join(root, 'bound') }).search(join(root, 'bound/start')); + + expect(result?.config).toEqual({ from: 'bound' }); + }); + + it('still checks the OS config directory after stopDir with the global strategy', async () => { + const root = makeProject({ 'bound/start/placeholder.txt': '' }); + const explorer = cosmiconfig('my-tool', { loaders, searchStrategy: 'global', stopDir: join(root, 'bound') }); + + const checked = await directoriesChecked(async (from) => explorer.search(from), join(root, 'bound/start')); + + expect(checked.filter((directory) => !directory.startsWith(root))).not.toEqual([]); + }); +});