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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
176 changes: 175 additions & 1 deletion src/explorer.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<PublicExplorer>, from: string): Promise<readonly string[]> {
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();
});
});
});
61 changes: 58 additions & 3 deletions src/explorer.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,6 +13,45 @@ export interface ExplorerOptions extends JitiLoaderOptions, Omit<ExtendsOptions,
* Passed to cosmiconfig unchanged, except that `transform` is owned by this package and `loaders` are merged over the jiti loader for `.ts`, `.mts` and `.cts`, so an entry here for one of those extensions replaces the jiti loader for it.
*/
readonly cosmiconfig?: Omit<Partial<Options>, '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<PublicExplorer>, from: string, stopDir: string): Promise<CosmiconfigResult> {
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;
}

/**
Expand All @@ -19,11 +60,25 @@ export interface ExplorerOptions extends JitiLoaderOptions, Omit<ExtendsOptions,
* The explorer never relies on cosmiconfig's own `.ts` handling, which needs the optional `typescript` peer in cosmiconfig 9 and native type stripping in cosmiconfig 10.
*/
export function createExplorer(moduleName: string, options: ExplorerOptions = {}): PublicExplorer {
const { loader, importer } = createJitiLoader(options);
const { searchUpTo } = options;
if (searchUpTo === '') {
throw new TypeError("createExplorer: searchUpTo must be 'project' or a directory, not an empty string");
}
if (searchUpTo !== undefined) {
assertNoRawSearchBounds(searchUpTo, options.cosmiconfig);
}

return cosmiconfig(moduleName, {
const { loader, importer } = createJitiLoader(options);
const explorer = cosmiconfig(moduleName, {
...options.cosmiconfig,
...(searchUpTo === undefined ? {} : { searchStrategy: searchUpTo === PROJECT_BOUND ? 'project' : 'none' }),
loaders: { '.ts': loader, '.mts': loader, '.cts': loader, ...options.cosmiconfig?.loaders },
transform: createExtendsTransform({ ...options, importer }),
});

if (searchUpTo === undefined || searchUpTo === PROJECT_BOUND) {
return explorer;
}

return { ...explorer, search: async (searchFrom = '') => searchThroughParents(explorer, searchFrom, searchUpTo) };
}
61 changes: 60 additions & 1 deletion src/interop.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<unknown>, from: string): Promise<readonly string[]> {
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([]);
});
});