diff --git a/README.md b/README.md index 4725980..570cb78 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ export function loadMyToolConfig(cwd: string): Promise 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` (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`: @@ -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 ] [--section ]... +exadev-config doctor [--cwd ] [--section ]... [--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 diff --git a/src/doctor-command.integration.test.ts b/src/doctor-command.integration.test.ts index 65810e7..7955145 100644 --- a/src/doctor-command.integration.test.ts +++ b/src/doctor-command.integration.test.ts @@ -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 ] [--section ]...'); + expect(stdout).toContain('Usage: exadev-config doctor [--cwd ] [--section ]... [--require-config]'); + expect(stdout).toContain('--require-config'); expect(stderr).toBe(''); }); @@ -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 () => { diff --git a/src/doctor-command.ts b/src/doctor-command.ts index 6930145..5ee2fa5 100644 --- a/src/doctor-command.ts +++ b/src/doctor-command.ts @@ -16,15 +16,16 @@ export interface CommandOutput { readonly stderr: (text: string) => void; } -const USAGE = `Usage: exadev-config doctor [--cwd ] [--section ]... +const USAGE = `Usage: exadev-config doctor [--cwd ] [--section ]... [--require-config] Reports the sections in exadev.config.ts that no installed or listed tool owns. --cwd Directory that holds exadev.config.ts and package.json. Defaults to the current directory. --section Treat 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 { @@ -34,7 +35,7 @@ function messageOf(error: unknown): string { async function runDoctor(args: readonly string[], output: CommandOutput): Promise { 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) { @@ -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; } diff --git a/src/doctor.integration.test.ts b/src/doctor.integration.test.ts index 4ee50fd..d3a2988 100644 --- a/src/doctor.integration.test.ts +++ b/src/doctor.integration.test.ts @@ -18,8 +18,8 @@ function project(dependencies: Record, config = 'export default const declares = (...sections: readonly string[]): Record => ({ 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 () => { @@ -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 () => { diff --git a/src/doctor.ts b/src/doctor.ts index b8c79b4..f3458ad 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -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[]; /** @@ -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')); @@ -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`. * @@ -140,16 +160,15 @@ function installedSections(cwd: string): readonly string[] { export async function doctor(options: DoctorOptions): Promise { 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 }; } diff --git a/src/index.ts b/src/index.ts index 5814d0c..7d1df78 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/section.ts b/src/section.ts index 112e60d..ad894ad 100644 --- a/src/section.ts +++ b/src/section.ts @@ -57,9 +57,49 @@ export interface Rejected { export type EnvelopeKey = keyof Envelope; /** - * What a section name must be besides a string: `unknown` (no further constraint) unless it is an {@link Envelope} key, which a section may not take because the key already means something else. + * The characters a section name may start with, and (with {@link SECTION_NAME_TAIL_CHARACTERS}) the characters it may continue with. This is the single definition of the allowed character set: the runtime pattern is built from it, and the compile-time check of a literal name is derived from its type. */ -type Nameable = Name extends EnvelopeKey ? Rejected<'a section may not be named after a key of the config envelope'> : unknown; +export const SECTION_NAME_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + +/** + * The characters a section name may contain after its first letter, besides the letters in {@link SECTION_NAME_LETTERS}. The hyphen is last so that it is literal inside the character class of the runtime pattern. + */ +export const SECTION_NAME_TAIL_CHARACTERS = '0123456789_-'; + +/** + * The union of the single characters of `Text`, accumulated so that the recursion is a tail call and the length of the character set is not limited by the nesting depth. + */ +type CharactersOf = Text extends `${infer First}${infer Rest}` ? CharactersOf : Collected; + +/** + * `true` when every character of `Text` is in `Allowed`, including for the empty text. Tail-recursive, so TypeScript evaluates it iteratively up to its limit of 1000 steps; a literal name longer than that is a compile error ("excessively deep") rather than being accepted unchecked. + */ +type AllIn = Text extends `${infer First}${infer Rest}` ? (First extends Allowed ? AllIn : false) : true; + +/** + * Whether the literal `Name` matches the runtime rule exactly: a letter of {@link SECTION_NAME_LETTERS}, then any number of those letters or characters of {@link SECTION_NAME_TAIL_CHARACTERS}. A union distributes, so the result contains `false` when any member is invalid. The empty name is invalid because it has no first letter. + */ +type IsSectionName = Name extends `${infer First}${infer Rest}` + ? First extends CharactersOf + ? AllIn> + : false + : false; + +/** + * The members of the literal `Name` (a single name or a union of them) that break the naming rule, or `never` when all follow it. + */ +type BadNames = Name extends unknown ? (IsSectionName extends true ? never : Name) : never; + +/** + * What a section name must be besides a string: `unknown` (no further constraint) unless a literal breaks the naming rule, which is reported quoting the offending names, or is an {@link Envelope} key, which a section may not take because the key already means something else. A name that is not a literal (`string`) passes here; the runtime check and {@link Listable} deal with it. + */ +type Nameable = string extends Name + ? unknown + : [BadNames] extends [never] + ? [Extract] extends [never] + ? unknown + : Rejected<'a section may not be named after a key of the config envelope'> + : Rejected<`invalid section name '${BadNames}': it must start with a letter and contain only letters, digits, '-' and '_'`>; /** * The literal names of every descriptor in `Descriptors` except the one at `Index`. A name that is not a literal is left out, because `string` would otherwise be reported as a duplicate of every other name; it is rejected on its own. @@ -76,21 +116,23 @@ type NonEmpty = Descriptors extends read /** * What an argument of {@link withSections} must be besides a descriptor: `unknown` (no further constraint) when the descriptor can be listed, otherwise a {@link Rejected} that says why not. * - * A name that is not a literal (`string`, from a descriptor annotated with a wide {@link Section} type) would give {@link SectionsOf} a string index signature, which turns off the excess-property check for the whole config. A name that is an {@link Envelope} key, or is shared by two descriptors, would give one key two meanings. + * A name that breaks the naming rule of {@link defineSection}, which only a hand-written descriptor type can carry, is refused with the rule. A name that is not a literal (`string`, from a descriptor annotated with a wide {@link Section} type) would give {@link SectionsOf} a string index signature, which turns off the excess-property check for the whole config. A name that is an {@link Envelope} key, or is shared by two descriptors, would give one key two meanings. */ export type Listable = string extends Descriptor['name'] ? Rejected<'a listed section needs a literal name type: annotate the descriptor as Section<"name", Schema> or leave it inferred'> : Descriptor['name'] extends EnvelopeKey ? Rejected<'a section may not be named after a key of the config envelope'> - : Descriptor['name'] extends OtherNames - ? Rejected<'a section name may be listed only once'> - : unknown; + : [BadNames] extends [never] + ? Descriptor['name'] extends OtherNames + ? Rejected<'a section name may be listed only once'> + : unknown + : Rejected<`invalid section name '${BadNames}': it must start with a letter and contain only letters, digits, '-' and '_'`>; -const SECTION_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/; +const SECTION_NAME = new RegExp(`^[${SECTION_NAME_LETTERS}][${SECTION_NAME_LETTERS}${SECTION_NAME_TAIL_CHARACTERS}]*$`); const ENVELOPE_KEYS: readonly string[] = ['extends']; /** - * Describe a tool's section. `name` is both the key in the unified config and the base of the standalone file name `exadev..config.ts`, so it must start with a letter and contain only letters, digits, `-` and `_`, and it must not be a key of the {@link Envelope}. Throws a `TypeError` otherwise. + * Describe a tool's section. `name` is both the key in the unified config and the base of the standalone file name `exadev..config.ts`, so it must start with a letter and contain only letters, digits, `-` and `_`, and it must not be a key of the {@link Envelope}. A literal name that breaks the rule is a compile error quoting the name; a name that is not a literal, or is longer than TypeScript can check, is checked when the call runs. Throws a `TypeError` otherwise. */ export function defineSection( name: Name & Nameable, diff --git a/test/types/authoring-types.integration.test.ts b/test/types/authoring-types.integration.test.ts index 69f5215..7d0c3fb 100644 --- a/test/types/authoring-types.integration.test.ts +++ b/test/types/authoring-types.integration.test.ts @@ -83,7 +83,7 @@ function expectErrors(actual: readonly Finding[], expected: readonly Expected[]) } describe('an authoring file', () => { - it.each(['ok-two-tools', 'ok-layout', 'ok-sections-optional', 'ok-envelope-only', 'ok-isolated-declarations'])('%s type-checks', (name) => { + it.each(['ok-two-tools', 'ok-layout', 'ok-sections-optional', 'ok-envelope-only', 'ok-isolated-declarations', 'ok-name-shapes', 'name-long-valid'])('%s type-checks', (name) => { expect(findingsIn(plain, `cases/${name}`)).toEqual([]); }); @@ -132,6 +132,26 @@ describe('an authoring file', () => { expectErrors(findingsIn(plain, 'cases/envelope-named-section'), [{ line: 5, code: 2345, message: 'a section may not be named after a key of the config envelope' }]); }); + it.each([ + ['invalid-name-digit-first', '1 bad name'], + ['invalid-name-space', 'Foo Bar'], + ['invalid-name-underscore-first', '_a'], + ['invalid-name-empty', ''], + ['invalid-name-tail', 'a.b'], + ])('rejects the literal name in %s at defineSection, quoting the rule', (name, literal) => { + expectErrors(findingsIn(plain, `cases/${name}`), [ + { line: 4, code: 2345, message: `invalid section name '${literal}': it must start with a letter and contain only letters, digits, '-' and '_'` }, + ]); + }); + + it('rejects a union of names when any member is invalid', () => { + expectErrors(findingsIn(plain, 'cases/invalid-name-union'), [{ line: 6, code: 2345, message: "invalid section name 'not good'" }]); + }); + + it('rejects a descriptor with an invalid literal name at withSections', () => { + expectErrors(findingsIn(plain, 'cases/invalid-name-listed'), [{ line: 6, code: 2345, message: "invalid section name 'a b': it must start with a letter and contain only letters, digits, '-' and '_'" }]); + }); + it('rejects two descriptors that share a name', () => { expectErrors(findingsIn(plain, 'cases/duplicate-section-name'), [{ line: 6, code: 2345, message: 'a section name may be listed only once' }]); }); diff --git a/test/types/fixtures/cases/invalid-name-digit-first.ts b/test/types/fixtures/cases/invalid-name-digit-first.ts new file mode 100644 index 0000000..f6e2c28 --- /dev/null +++ b/test/types/fixtures/cases/invalid-name-digit-first.ts @@ -0,0 +1,4 @@ +import { defineSection } from '../../../../src/index'; +import { toolAConfigSchema } from '../tool-a'; + +export const bad = defineSection('1 bad name', toolAConfigSchema); diff --git a/test/types/fixtures/cases/invalid-name-empty.ts b/test/types/fixtures/cases/invalid-name-empty.ts new file mode 100644 index 0000000..198890a --- /dev/null +++ b/test/types/fixtures/cases/invalid-name-empty.ts @@ -0,0 +1,4 @@ +import { defineSection } from '../../../../src/index'; +import { toolAConfigSchema } from '../tool-a'; + +export const bad = defineSection('', toolAConfigSchema); diff --git a/test/types/fixtures/cases/invalid-name-listed.ts b/test/types/fixtures/cases/invalid-name-listed.ts new file mode 100644 index 0000000..38047f1 --- /dev/null +++ b/test/types/fixtures/cases/invalid-name-listed.ts @@ -0,0 +1,6 @@ +import { type Section, withSections } from '../../../../src/index'; +import { toolA, toolAConfigSchema } from '../tool-a'; + +declare const bad: Section<'a b', typeof toolAConfigSchema>; + +export const config = withSections(toolA, bad)({ toolA: { include: ['src'] } }); diff --git a/test/types/fixtures/cases/invalid-name-space.ts b/test/types/fixtures/cases/invalid-name-space.ts new file mode 100644 index 0000000..60ada55 --- /dev/null +++ b/test/types/fixtures/cases/invalid-name-space.ts @@ -0,0 +1,4 @@ +import { defineSection } from '../../../../src/index'; +import { toolAConfigSchema } from '../tool-a'; + +export const bad = defineSection('Foo Bar', toolAConfigSchema); diff --git a/test/types/fixtures/cases/invalid-name-tail.ts b/test/types/fixtures/cases/invalid-name-tail.ts new file mode 100644 index 0000000..2309909 --- /dev/null +++ b/test/types/fixtures/cases/invalid-name-tail.ts @@ -0,0 +1,4 @@ +import { defineSection } from '../../../../src/index'; +import { toolAConfigSchema } from '../tool-a'; + +export const bad = defineSection('a.b', toolAConfigSchema); diff --git a/test/types/fixtures/cases/invalid-name-underscore-first.ts b/test/types/fixtures/cases/invalid-name-underscore-first.ts new file mode 100644 index 0000000..cb69394 --- /dev/null +++ b/test/types/fixtures/cases/invalid-name-underscore-first.ts @@ -0,0 +1,4 @@ +import { defineSection } from '../../../../src/index'; +import { toolAConfigSchema } from '../tool-a'; + +export const bad = defineSection('_a', toolAConfigSchema); diff --git a/test/types/fixtures/cases/invalid-name-union.ts b/test/types/fixtures/cases/invalid-name-union.ts new file mode 100644 index 0000000..2d65704 --- /dev/null +++ b/test/types/fixtures/cases/invalid-name-union.ts @@ -0,0 +1,6 @@ +import { defineSection } from '../../../../src/index'; +import { toolAConfigSchema } from '../tool-a'; + +declare const either: 'good' | 'not good'; + +export const bad = defineSection(either, toolAConfigSchema); diff --git a/test/types/fixtures/cases/name-long-valid.ts b/test/types/fixtures/cases/name-long-valid.ts new file mode 100644 index 0000000..718b6b7 --- /dev/null +++ b/test/types/fixtures/cases/name-long-valid.ts @@ -0,0 +1,4 @@ +import { defineSection } from '../../../../src/index'; +import { toolAConfigSchema } from '../tool-a'; + +export const long = defineSection('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', toolAConfigSchema); diff --git a/test/types/fixtures/cases/ok-name-shapes.ts b/test/types/fixtures/cases/ok-name-shapes.ts new file mode 100644 index 0000000..78fc126 --- /dev/null +++ b/test/types/fixtures/cases/ok-name-shapes.ts @@ -0,0 +1,7 @@ +import { defineSection, withSections } from '../../../../src/index'; +import { toolAConfigSchema } from '../tool-a'; + +export const upper = defineSection('Z9_-', toolAConfigSchema); +export const hyphenated = defineSection('my-tool', toolAConfigSchema); +export const single = defineSection('a', toolAConfigSchema); +export const config = withSections(upper, hyphenated, single)({ 'my-tool': { include: ['src'] } });