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
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export function loadMyToolConfig(cwd: string): Promise<MyToolConfig | undefined>

Notes for the tool author:

- The name is both the key in the unified file and the base of the standalone file name, so it starts with a letter and contains only letters, digits, `-` and `_`. `extends` is reserved. `defineSection` throws a `TypeError` otherwise.
- The name is both the key in the unified file and the base of the standalone file name, so it starts with a letter and contains only letters (`A-Z`, `a-z`), digits, `-` and `_`. `extends` is reserved. A literal name that breaks the rule (`'1 bad name'`, `'Foo Bar'`, `'a_b.c'`, `''`) is a compile error at `defineSection` and at `withSections` whose message quotes the name and the rule, and the type accepts exactly the names the runtime check accepts, both derived from one pair of character-set constants. Two cases are left to the runtime check, which throws a `TypeError`: a name that is not a literal (`string`), and a literal longer than about a thousand characters, which TypeScript cannot check and reports as excessively deep.
- Under `isolatedDeclarations` an exported schema needs a written type. Write the interface by hand and annotate the schema `z.ZodType<Config, Config>` (output first, then input), as above, and use `z.exactOptional` for optional fields under `exactOptionalPropertyTypes`.
- Use `.strict()` schemas (`z.strictObject`) so a misspelt key in a config file fails at load time as well as in the type checker.
- To let `doctor` know which sections an installed package owns, declare them in the package's `package.json`:
Expand Down Expand Up @@ -129,15 +129,22 @@ import { doctor } from '@exadev/config';

const report = await doctor({ cwd: process.cwd(), listed: ['myTool'] });
// report.unowned lists the keys of exadev.config.ts that no installed or listed tool owns.
// report.outcome is 'checked', 'empty' (the file exists but exports undefined) or 'no-config-file'; the last two mean nothing was checked, so an empty report.unowned is not a clean result.
```

A key is owned when it is `layout`, when a dependency of the project's `package.json` declares it in its `exadevConfig.sections`, or when the project's own `package.json` declares it (a tool repository dogfooding its section), or when it appears in `listed`. Keys of `exadevConfig` other than `sections` are ignored, so a later manifest version does not break an older `doctor`. A dependency is looked for in the `node_modules` directories from `cwd` upwards. The command wraps the same function:

```sh
exadev-config doctor [--cwd <directory>] [--section <name>]...
exadev-config doctor [--cwd <directory>] [--section <name>]... [--require-config]
```

It exits 0 when every section is owned, 1 when some is not, and 2 when it could not run, which includes a missing command, a `--cwd` that is not a directory and a config that fails to load. A directory with no `exadev.config.ts` (or an empty one) is not searched upward: the command says there is nothing to check and exits 0, so run it where the file is. `exadev-config --help` and `exadev-config doctor --help` print the usage.
| Exit code | Meaning |
| --- | --- |
| 0 | Every section is owned. Also the result when nothing was checked: the command then says so, either that no exadev config file was found in the directory or that the file is empty. |
| 1 | Some section is not owned. |
| 2 | The command could not run: a missing command, a `--cwd` that is not a directory, a config that fails to load, or, with `--require-config`, a directory with no exadev config file. |

The directory is not searched upward, so run the command where the file is. `--require-config` turns the no-file case into a failure for a script that must not pass without a config; an empty file still exits 0 with its message. `exadev-config --help` and `exadev-config doctor --help` print the usage.

## Development

Expand Down
40 changes: 33 additions & 7 deletions src/doctor-command.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ describe('runCommand', () => {
const { code, stdout, stderr } = await run(...args);

expect(code).toBe(EXIT_CODES.clean);
expect(stdout).toContain('Usage: exadev-config doctor [--cwd <directory>] [--section <name>]...');
expect(stdout).toContain('Usage: exadev-config doctor [--cwd <directory>] [--section <name>]... [--require-config]');
expect(stdout).toContain('--require-config');
expect(stderr).toBe('');
});

Expand All @@ -59,13 +60,38 @@ describe('runCommand', () => {
expect(await run('doctor', '--cwd', cwd)).toEqual({ code: EXIT_CODES.clean, stdout: '', stderr: '' });
});

it.each([[{}], [{ 'exadev.config.ts': 'export default undefined;\n' }]])('says there is nothing to check, and succeeds, when there is no unified config: %j', async (files) => {
const cwd = makeProject(files);
const { code, stdout, stderr } = await run('doctor', '--cwd', cwd);
it('says no exadev config file was found and nothing was checked, and still succeeds, when the directory has none', async () => {
const cwd = makeProject({});

expect(code).toBe(EXIT_CODES.clean);
expect(stdout).toBe(`${cwd}: nothing to check, since exadev.config.ts is absent or empty there. The command does not search parent directories.\n`);
expect(stderr).toBe('');
expect(await run('doctor', '--cwd', cwd)).toEqual({
code: EXIT_CODES.clean,
stdout: `${cwd}: no exadev config file was found in this directory, so nothing was checked. The command does not search parent directories.\n`,
stderr: '',
});
});

it('says the config file is empty and nothing was checked, and succeeds, even with --require-config', async () => {
const cwd = makeProject({ 'exadev.config.ts': 'export default undefined;\n' });
const stdout = `${cwd}: the exadev config file in this directory is empty, so nothing was checked.\n`;

expect(await run('doctor', '--cwd', cwd)).toEqual({ code: EXIT_CODES.clean, stdout, stderr: '' });
expect(await run('doctor', '--cwd', cwd, '--require-config')).toEqual({ code: EXIT_CODES.clean, stdout, stderr: '' });
});

it('exits with the failure code and writes the same message to stderr under --require-config when the directory has no config file', async () => {
const cwd = makeProject({});

expect(await run('doctor', '--cwd', cwd, '--require-config')).toEqual({
code: EXIT_CODES.failed,
stdout: '',
stderr: `${cwd}: no exadev config file was found in this directory, so nothing was checked. The command does not search parent directories.\n`,
});
});

it('does not fail under --require-config when a config file exists', async () => {
const cwd = makeProject({ 'exadev.config.ts': 'export default { layout: {} };\n' });

expect(await run('doctor', '--cwd', cwd, '--require-config')).toEqual({ code: EXIT_CODES.clean, stdout: '', stderr: '' });
});

it('reports each unowned section on stderr with the known ones, and exits 1', async () => {
Expand Down
22 changes: 17 additions & 5 deletions src/doctor-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,16 @@ export interface CommandOutput {
readonly stderr: (text: string) => void;
}

const USAGE = `Usage: exadev-config doctor [--cwd <directory>] [--section <name>]...
const USAGE = `Usage: exadev-config doctor [--cwd <directory>] [--section <name>]... [--require-config]

Reports the sections in exadev.config.ts that no installed or listed tool owns.

--cwd <directory> Directory that holds exadev.config.ts and package.json. Defaults to the current directory.
--section <name> Treat <name> as owned. Repeatable.
--require-config Fail (exit status ${String(EXIT_CODES.failed)}) when the directory has no exadev.config.ts, instead of reporting that nothing was checked and succeeding.
--help Show this message.

Exit status: ${String(EXIT_CODES.clean)} when every section is owned (or there is no exadev.config.ts to check), ${String(EXIT_CODES.unowned)} when some is not, ${String(EXIT_CODES.failed)} when the command could not run.
Exit status: ${String(EXIT_CODES.clean)} when every section is owned (or there is no exadev.config.ts to check, unless --require-config is given), ${String(EXIT_CODES.unowned)} when some is not, ${String(EXIT_CODES.failed)} when the command could not run.
`;

function messageOf(error: unknown): string {
Expand All @@ -34,7 +35,7 @@ function messageOf(error: unknown): string {
async function runDoctor(args: readonly string[], output: CommandOutput): Promise<number> {
const { values } = parseArgs({
args: [...args],
options: { cwd: { type: 'string' }, section: { type: 'string', multiple: true }, help: { type: 'boolean' } },
options: { cwd: { type: 'string' }, section: { type: 'string', multiple: true }, 'require-config': { type: 'boolean' }, help: { type: 'boolean' } },
allowPositionals: false,
});
if (values.help === true) {
Expand All @@ -44,8 +45,19 @@ async function runDoctor(args: readonly string[], output: CommandOutput): Promis
}
const cwd = resolve(values.cwd ?? process.cwd());
const report = await doctor({ cwd, listed: values.section ?? [] });
if (report.file === undefined) {
output.stdout(`${cwd}: nothing to check, since exadev.config.ts is absent or empty there. The command does not search parent directories.\n`);
if (report.outcome !== 'checked') {
if (report.outcome === 'empty') {
output.stdout(`${cwd}: the exadev config file in this directory is empty, so nothing was checked.\n`);

return EXIT_CODES.clean;
}
const message = `${cwd}: no exadev config file was found in this directory, so nothing was checked. The command does not search parent directories.\n`;
if (values['require-config'] === true) {
output.stderr(message);

return EXIT_CODES.failed;
}
output.stdout(message);

return EXIT_CODES.clean;
}
Expand Down
10 changes: 5 additions & 5 deletions src/doctor.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ function project(dependencies: Record<string, unknown>, config = 'export default
const declares = (...sections: readonly string[]): Record<string, unknown> => ({ exadevConfig: { sections } });

describe('doctor', () => {
it('reports nothing for a directory with no unified config, and still lists the built-in section', async () => {
expect(await doctor({ cwd: makeProject() })).toEqual({ file: undefined, defined: [], unowned: [], known: ['layout'] });
it('reports no config file, not a clean check, for a directory with no unified config, and still lists the built-in section', async () => {
expect(await doctor({ cwd: makeProject() })).toEqual({ outcome: 'no-config-file', file: undefined, defined: [], unowned: [], known: ['layout'] });
});

it('fails when cwd is not an existing directory, instead of reporting a clean one', async () => {
Expand All @@ -28,16 +28,16 @@ describe('doctor', () => {
await expect(doctor({ cwd: missing })).rejects.toThrow(new Error(`${missing} is not an existing directory`));
});

it('reports nothing for an empty unified config', async () => {
it('reports an empty config, not a clean check, for an empty unified config', async () => {
const cwd = makeProject({ 'exadev.config.ts': 'export default undefined;\n' });

expect(await doctor({ cwd })).toEqual({ file: undefined, defined: [], unowned: [], known: ['layout'] });
expect(await doctor({ cwd })).toEqual({ outcome: 'empty', file: undefined, defined: [], unowned: [], known: ['layout'] });
});

it('owns the layout section without any package', async () => {
const cwd = makeProject({ 'exadev.config.ts': 'export default { layout: {} };\n' });

expect(await doctor({ cwd })).toEqual({ file: join(cwd, 'exadev.config.ts'), defined: ['layout'], unowned: [], known: ['layout'] });
expect(await doctor({ cwd })).toEqual({ outcome: 'checked', file: join(cwd, 'exadev.config.ts'), defined: ['layout'], unowned: [], known: ['layout'] });
});

it('reports a section no installed or listed tool owns, keeping file order', async () => {
Expand Down
45 changes: 32 additions & 13 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,15 @@ export interface DoctorOptions extends ConfigFileOptions {
}

/**
* What {@link doctor} found.
* The fields of a {@link DoctorReport} that do not depend on whether a config was checked.
*/
export interface DoctorReport {
interface DoctorReportFields {
/**
* The unified config file that was read (`exadev.config.ts`, or the same with the extension `.mts` or `.cts`), or `undefined` when the directory has none or the file is empty.
*/
readonly file: string | undefined;
/**
* The top-level keys the file defines after `extends` is applied, in merge order: the keys of a preset come before the keys only the file itself defines.
* The top-level keys the file defines after `extends` is applied, in merge order: the keys of a preset come before the keys only the file itself defines. Empty when nothing was checked.
*/
readonly defined: readonly string[];
/**
* The keys in {@link DoctorReport.defined} that no installed or listed tool owns. Every tool ignores such a key, so it is a misspelt name or a tool that is not installed.
* The keys in {@link DoctorReportFields.defined} that no installed or listed tool owns. Every tool ignores such a key, so it is a misspelt name or a tool that is not installed. Empty when nothing was checked, which is not the same as every section being owned: test {@link DoctorReport.outcome} first.
*/
readonly unowned: readonly string[];
/**
Expand All @@ -84,6 +80,30 @@ export interface DoctorReport {
readonly known: readonly string[];
}

/**
* The report for a unified config file that was read and defines its keys.
*/
export interface CheckedDoctorReport extends DoctorReportFields {
readonly outcome: 'checked';
/**
* The unified config file that was read: `exadev.config.ts`, or the same with the extension `.mts` or `.cts`.
*/
readonly file: string;
}

/**
* The report when nothing was checked: `'no-config-file'` when the directory has no unified config file, `'empty'` when the file exists but its default export is `undefined`.
*/
export interface UncheckedDoctorReport extends DoctorReportFields {
readonly outcome: 'no-config-file' | 'empty';
readonly file: undefined;
}

/**
* What {@link doctor} found. Discriminated by `outcome`: a checked config carries its `file`, and a directory with no config file, or with an empty one, is reported as such instead of as a config in which every section is owned.
*/
export type DoctorReport = CheckedDoctorReport | UncheckedDoctorReport;

function readJson(file: string): unknown {
const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'));

Expand Down Expand Up @@ -131,7 +151,7 @@ function installedSections(cwd: string): readonly string[] {
}

/**
* Report the sections in the unified config file of `cwd` that no installed or listed tool owns.
* Report the sections in the unified config file of `cwd` that no installed or listed tool owns. A directory with no such file is not an error and is not searched upward: the report's {@link DoctorReport.outcome} is `'no-config-file'`, so a caller can tell that from a checked config with nothing unowned.
*
* Typing cannot catch these: a section for a tool that is not installed, or under a misspelt name, is simply ignored by every tool. A section is owned when it is `layout`, when a dependency of the project in `cwd` declares it through {@link MANIFEST_FIELD}, or when it is in `options.listed`.
*
Expand All @@ -140,16 +160,15 @@ function installedSections(cwd: string): readonly string[] {
export async function doctor(options: DoctorOptions): Promise<DoctorReport> {
assertDirectory(options.cwd);
const known = [...new Set([layoutSection.name, ...installedSections(options.cwd), ...(options.listed ?? [])])].sort();
const nothingDefined: DoctorReport = { file: undefined, defined: [], unowned: [], known };
const file = findConfigFile(options.cwd, UNIFIED_BASE);
if (file === undefined) {
return nothingDefined;
return { outcome: 'no-config-file', file: undefined, defined: [], unowned: [], known };
}
const config = await readUnifiedFile(file, options);
if (config === undefined) {
return nothingDefined;
return { outcome: 'empty', file: undefined, defined: [], unowned: [], known };
}
const defined = Object.keys(config);

return { file, defined, unowned: defined.filter((name) => !known.includes(name)), known };
return { outcome: 'checked', file, defined, unowned: defined.filter((name) => !known.includes(name)), known };
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export { ConfigValidationError, type ConfigValidationIssue } from 'cosmiconfig-extends';
export type { ConfigFileOptions } from './config-file';
export { doctor, type DoctorOptions, type DoctorReport, MANIFEST_FIELD } from './doctor';
export { type CheckedDoctorReport, doctor, type DoctorOptions, type DoctorReport, MANIFEST_FIELD, type UncheckedDoctorReport } from './doctor';
export {
type GroupSpec,
type LayoutConfig,
Expand Down
Loading