diff --git a/AGENTS.md b/AGENTS.md index 5ffd560..4ae9e9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ These are produced by tooling and overwritten on the next run — change the sou ## Code style -The [DEVELOPMENT](DEVELOPMENT.md) doc is authoritative for the topics below — see "[How to name and locate your package's files](DEVELOPMENT.md#develop-file-structure)" and "[How to work with modules](DEVELOPMENT.md#develop-modules)". Summarized here because they apply to nearly every change; consult the [DEVELOPMENT](DEVELOPMENT.md) doc when this summary is not enough, and treat it as correct if the two disagree. +The [DEVELOPMENT](DEVELOPMENT.md) doc is authoritative for the topics below — see "[How to name and locate your package's files](DEVELOPMENT.md#develop-file-structure)", "[How to work with modules](DEVELOPMENT.md#develop-modules)" and "[How to format your code](DEVELOPMENT.md#develop-formatting)". Summarized here because they apply to nearly every change; consult the [DEVELOPMENT](DEVELOPMENT.md) doc when this summary is not enough, and treat it as correct if the two disagree. **File naming and placement.** All packages live in `packages/`, all local development scripts live in `bin/`. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e3eab32..88a7415 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -312,6 +312,29 @@ import { formatLabel } from './lib/format.js'; +

+ + +
+ + How to format your code + +

+ +The repo lints with `@bust/eslint-config`'s `strict` option, which covers blank lines between statements, declaration order, constant naming, and test suite structure. The full list is in that package's "[Strict conventions](packages/eslint-config/README.md#strict-conventions)" section. + +To check your code, and to fix everything but the naming and ordering rules: + +``` +npm run lint +npm run lint:fix +``` + +Two conventions are on you, since lint cannot check them: + +* code is ordered top-down: the highest-level constructs first, lower-level and utility functions below them +* `it()` names the behavior concisely - e.g. `'Loads values from a .env file before building the config'` +

diff --git a/bin/create-package.mjs b/bin/create-package.mjs index c4c8a96..d491c3d 100755 --- a/bin/create-package.mjs +++ b/bin/create-package.mjs @@ -43,7 +43,6 @@ try { await git.add(['README.md', 'npm-shrinkwrap.json', pkg.path]); await git.commit(`[${pkg.basename}] create package`); await git.tag(['-a', pkg.tag, '-m', pkg.tag]); - logTitle('Success!'); logTitle(`Your new ${pkg.name} package is located here: ${pkg.path}`); } catch (error){ @@ -72,6 +71,7 @@ async function promptForPackageInfo(cli){ if (await pkg.exists()){ return `Directory "${pkg.path}" already exists! please choose a different name.`; } + return true; }, }, @@ -85,4 +85,3 @@ async function promptForPackageInfo(cli){ return new Pkg({ name, description }); } - diff --git a/bin/lib/constants.mjs b/bin/lib/constants.mjs index 0e1765a..bbf0d00 100644 --- a/bin/lib/constants.mjs +++ b/bin/lib/constants.mjs @@ -8,4 +8,3 @@ export const REPO_DIR = path.join(__dirname, '..', '..'); export const TEMP_DIR = path.join(REPO_DIR, 'tmp'); export const PACKAGES_DIR = path.join(REPO_DIR, 'packages'); export const TEMPLATES_DIR = path.join(__dirname, '..', 'templates'); - diff --git a/bin/lib/create-substitution-pattern.mjs b/bin/lib/create-substitution-pattern.mjs index 5300a6a..970be90 100644 --- a/bin/lib/create-substitution-pattern.mjs +++ b/bin/lib/create-substitution-pattern.mjs @@ -1,4 +1,3 @@ export const createSubstitutionPtn = (start, end) => { return new RegExp(`${start}[\\s\\S]*?${end}`, 'gmi'); }; - diff --git a/bin/lib/git.mjs b/bin/lib/git.mjs index 81c9178..5e2c9ac 100644 --- a/bin/lib/git.mjs +++ b/bin/lib/git.mjs @@ -35,4 +35,3 @@ export class Git { } } } - diff --git a/bin/lib/load-templates.mjs b/bin/lib/load-templates.mjs index 2605720..157b7b0 100644 --- a/bin/lib/load-templates.mjs +++ b/bin/lib/load-templates.mjs @@ -16,7 +16,6 @@ export const loadTemplates = async () => { const filename = path.join(TEMPLATES_DIR, f.name); const tmplData = await fs.readFile(filename, 'utf8'); const key = formatKey(f); - templates[key] = _.template(tmplData, { imports: { camelCase: _.camelCase, @@ -41,4 +40,3 @@ function formatKey(file){ const ext = parts.pop(); return _.camelCase(parts.join('-')) + ext; } - diff --git a/bin/lib/log.mjs b/bin/lib/log.mjs index bd02283..9ab9e33 100644 --- a/bin/lib/log.mjs +++ b/bin/lib/log.mjs @@ -24,4 +24,3 @@ export const logErrorAndExit = (error, code = 1) => { console.error(error); return process.exit(code); }; - diff --git a/bin/lib/pkg.mjs b/bin/lib/pkg.mjs index 0ba945d..567976e 100644 --- a/bin/lib/pkg.mjs +++ b/bin/lib/pkg.mjs @@ -73,9 +73,7 @@ export class Pkg { } const tag = `${this.name}@${version}`; - this.publishedTag = await this.git.hasTag(tag) ? tag : null; - return this.publishedTag; } @@ -88,9 +86,7 @@ export class Pkg { const query = `--grep=\\[${basename}\\]`; const fmt = '--pretty=format:%B'; const prefix = `[${basename}]`; - const { stdout: history } = await this.git.log([range, query, fmt]); - return history .split('\n') .filter((x) => !!x) @@ -123,7 +119,6 @@ export class Pkg { } lines.push('\n'); - return this.writeChangelog( content.replace(ptn, lines.join('')), ); diff --git a/bin/show-unpublished-changes.mjs b/bin/show-unpublished-changes.mjs index df56572..c821b9a 100755 --- a/bin/show-unpublished-changes.mjs +++ b/bin/show-unpublished-changes.mjs @@ -9,7 +9,6 @@ try { const pkg = await Pkg.createFromDisk(args[0].trim()); const tag = await pkg.getPublishedTag(); const changes = await pkg.getUnpublishedChanges(); - logTitle(`${pkg.name} - Unpublished Changes`); log(tag ? `:::: since ${tag}` @@ -27,4 +26,3 @@ try { } logTitle('All Done!'); - diff --git a/bin/templates/index.test.ts.tmpl b/bin/templates/index.test.ts.tmpl index 18d3dd3..6fafb2d 100644 --- a/bin/templates/index.test.ts.tmpl +++ b/bin/templates/index.test.ts.tmpl @@ -9,4 +9,3 @@ describe('<%= name %>', () => { assert.equal(<%= exportName %>.greet(), 'hello world!'); }); }); - diff --git a/bin/update-changelog.mjs b/bin/update-changelog.mjs index 82ce4c1..2054a82 100755 --- a/bin/update-changelog.mjs +++ b/bin/update-changelog.mjs @@ -18,4 +18,3 @@ try { } logTitle('All Done!'); - diff --git a/bin/update-package-readme.mjs b/bin/update-package-readme.mjs index c640ee1..6787b57 100755 --- a/bin/update-package-readme.mjs +++ b/bin/update-package-readme.mjs @@ -17,4 +17,3 @@ try { } logTitle('All Done!'); - diff --git a/bin/update-readme.mjs b/bin/update-readme.mjs index 81157f5..8c6d256 100755 --- a/bin/update-readme.mjs +++ b/bin/update-readme.mjs @@ -62,4 +62,3 @@ function writeDocs(readme, docs){ const fragment = `${PKGLIST_MARKER_START}\n${docs}\n${PKGLIST_MARKER_END}`; return readme.replace(ptn, fragment); } - diff --git a/eslint.config.mjs b/eslint.config.mjs index 0e40f33..0681fb8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,7 +5,7 @@ import bust from '@bust/eslint-config'; export default [ includeIgnoreFile(path.join(import.meta.dirname, '.gitignore')), - ...bust({ nodeTest: true, ignores: ['**/docs/**'] }), + ...bust({ nodeTest: true, strict: true, ignores: ['**/docs/**'] }), { name: 'jsville/tests', diff --git a/packages/config/docs/config/classes/Config.md b/packages/config/docs/config/classes/Config.md index 6b0d0a8..9588bf8 100644 --- a/packages/config/docs/config/classes/Config.md +++ b/packages/config/docs/config/classes/Config.md @@ -4,7 +4,7 @@ # Class: Config\ -Defined in: [config.ts:54](/packages/config/src/config.ts#L54) +Defined in: [config.ts:113](/packages/config/src/config.ts#L113) Schema-driven configuration store. Given a [SettingsSchemaTree](../../node/interfaces/SettingsSchemaTree.md) and a map of environment variables, hydrates each leaf setting's value from the @@ -48,7 +48,7 @@ SettingsValue union. > **new Config**\<`S`\>(`__namedParameters?`): `Config`\<`S`\> -Defined in: [config.ts:57](/packages/config/src/config.ts#L57) +Defined in: [config.ts:116](/packages/config/src/config.ts#L116) #### Parameters @@ -66,7 +66,7 @@ Defined in: [config.ts:57](/packages/config/src/config.ts#L57) > **settings**: `Settings` -Defined in: [config.ts:55](/packages/config/src/config.ts#L55) +Defined in: [config.ts:114](/packages/config/src/config.ts#L114) ## Methods @@ -74,7 +74,7 @@ Defined in: [config.ts:55](/packages/config/src/config.ts#L55) > **get**\<`K`\>(`key`): `SettingsValueAt`\<`S`, `K`\> -Defined in: [config.ts:66](/packages/config/src/config.ts#L66) +Defined in: [config.ts:125](/packages/config/src/config.ts#L125) Looks up a single setting's hydrated value by its dot-delimited key. @@ -104,7 +104,7 @@ if `key` isn't present in the hydrated schema > **getPublicEnvVars**(): [`ConfigEnvVars`](../../node/interfaces/ConfigEnvVars.md) -Defined in: [config.ts:95](/packages/config/src/config.ts#L95) +Defined in: [config.ts:154](/packages/config/src/config.ts#L154) Returns every public setting's value keyed by its *environment variable name* rather than its schema path. @@ -119,7 +119,7 @@ name* rather than its schema path. > **getPublicSettings**(): `PublicSettings` -Defined in: [config.ts:77](/packages/config/src/config.ts#L77) +Defined in: [config.ts:136](/packages/config/src/config.ts#L136) Returns every hydrated setting marked `public: true`, keyed by dot-delimited path. @@ -133,7 +133,7 @@ Returns every hydrated setting marked `public: true`, keyed by dot-delimited pat > **hydrate**(`data`, `env?`): `Settings` -Defined in: [config.ts:118](/packages/config/src/config.ts#L118) +Defined in: [config.ts:177](/packages/config/src/config.ts#L177) Walks a [SettingsSchemaTree](../../node/interfaces/SettingsSchemaTree.md), resolving each leaf's format, coercing/validating its value from `env` (or its default), and diff --git a/packages/config/docs/node/interfaces/ConfigEnvVars.md b/packages/config/docs/node/interfaces/ConfigEnvVars.md index d9a2758..0b5c306 100644 --- a/packages/config/docs/node/interfaces/ConfigEnvVars.md +++ b/packages/config/docs/node/interfaces/ConfigEnvVars.md @@ -4,7 +4,7 @@ # Interface: ConfigEnvVars -Defined in: [types.ts:241](/packages/config/src/types.ts#L241) +Defined in: [types.ts:242](/packages/config/src/types.ts#L242) A map of environment variable names to values. Values are typically raw strings (as they'd come from `process.env`), but pre-coerced values are diff --git a/packages/config/src/browser.test.mts b/packages/config/src/browser.test.mts index 162f9dc..d0eade8 100644 --- a/packages/config/src/browser.test.mts +++ b/packages/config/src/browser.test.mts @@ -17,7 +17,7 @@ describe('@bust/config/browser', () => { delete (globalThis as GlobalWithBustConfig).__BUST_CONFIG__; }); - describe('createConfig', () => { + describe('createConfig()', () => { it('Builds a config sourced from its schema defaults when nothing was baked in', () => { const config = createConfig(schema); assert.strictEqual(config.get('app.name'), 'My App'); @@ -25,13 +25,12 @@ describe('@bust/config/browser', () => { it('Builds a config sourced from a `getBrowserDefine()`-baked settings blob', () => { (globalThis as GlobalWithBustConfig).__BUST_CONFIG__ = { MY_APP_NAME: 'Baked In By Vite' }; - const config = createConfig(schema); assert.strictEqual(config.get('app.name'), 'Baked In By Vite'); }); }); - describe('Schema-derived types', () => { + describe('createConfig() schema-derived types', () => { it('Types keys and values from the schema, same as the Node entry point', () => { const config = createConfig({ app: { @@ -41,7 +40,6 @@ describe('@bust/config/browser', () => { }); const name: string = config.get('app.name'); const mode: 'light' | 'dark' = config.get('app.mode'); - // @ts-expect-error - 'app.nmae' is a typo, not a declared key assert.throws(() => config.get('app.nmae')); assert.strictEqual(name, 'My App'); @@ -49,14 +47,13 @@ describe('@bust/config/browser', () => { }); }); - describe('getBrowserEnv', () => { + describe('getBrowserEnv()', () => { it('Falls back to an empty object when nothing was baked in', () => { assert.deepEqual(getBrowserEnv(), {}); }); it('Returns the settings blob a bundler baked in via `define`', () => { (globalThis as GlobalWithBustConfig).__BUST_CONFIG__ = { MY_APP_NAME: 'Baked In By Vite' }; - assert.deepEqual(getBrowserEnv(), { MY_APP_NAME: 'Baked In By Vite' }); }); }); diff --git a/packages/config/src/config.test.ts b/packages/config/src/config.test.ts index d7f26bd..9529fb3 100644 --- a/packages/config/src/config.test.ts +++ b/packages/config/src/config.test.ts @@ -40,7 +40,7 @@ describe('@bust/config', () => { config = new Config({ schema }); }); - describe('Getting configuration settings', () => { + describe('get()', () => { it('Gets a setting', () => { const value = config.get('five.a.b'); assert.strictEqual(value, 'ok'); @@ -56,7 +56,7 @@ describe('@bust/config', () => { }); }); - describe('Getting `public` configuration settings', () => { + describe('getPublicSettings()', () => { it('Gets all publicly available settings', () => { const settings = config.getPublicSettings(); assert.deepEqual(settings, { @@ -91,7 +91,7 @@ describe('@bust/config', () => { }); }); - describe('Getting `public` environment variables', () => { + describe('getPublicEnvVars()', () => { it('Gets all publicly available environment variables', () => { const env = config.getPublicEnvVars(); assert.deepEqual(env, { @@ -105,7 +105,7 @@ describe('@bust/config', () => { }); }); - describe('Hydrating schema', () => { + describe('hydrate()', () => { let fakeEnv!: ConfigEnvVars; beforeEach(() => { @@ -119,7 +119,6 @@ describe('@bust/config', () => { it('Establishes settings', () => { const settings = config.hydrate(schema); const keys = [...settings.keys()]; - assert.deepEqual(keys, [ 'one', 'two', @@ -158,7 +157,6 @@ describe('@bust/config', () => { it('Establishes settings when environment variables are provided', () => { const settings = config.hydrate(schema, fakeEnv); const keys = [...settings.keys()]; - assert.deepEqual(keys, [ 'one', 'two', @@ -223,7 +221,6 @@ describe('@bust/config', () => { ); schema.test.default = 'a'; - assert.throws( () => config.hydrate(schema, env), { @@ -233,7 +230,6 @@ describe('@bust/config', () => { schema.test.default = 'a'; env.TEST = 'b'; - assert.doesNotThrow( () => config.hydrate(schema, env), ); @@ -253,7 +249,6 @@ describe('@bust/config', () => { ); schema.test.default = 1.1; - assert.throws( () => config.hydrate(schema), { @@ -262,7 +257,6 @@ describe('@bust/config', () => { ); schema.test.default = 1; - assert.throws( () => config.hydrate(schema, env), { @@ -272,7 +266,6 @@ describe('@bust/config', () => { schema.test.default = 1; env.TEST = 2; - assert.doesNotThrow( () => config.hydrate(schema, env), ); @@ -292,7 +285,6 @@ describe('@bust/config', () => { ); schema.test.default = -1; - assert.throws( () => config.hydrate(schema), { @@ -301,7 +293,6 @@ describe('@bust/config', () => { ); schema.test.default = 1.0; - assert.throws( () => config.hydrate(schema, env), { @@ -311,7 +302,6 @@ describe('@bust/config', () => { schema.test.default = 1.0; env.TEST = 2.5; - assert.doesNotThrow( () => config.hydrate(schema, env), ); @@ -331,7 +321,6 @@ describe('@bust/config', () => { ); schema.test.default = -1.5; - assert.throws( () => config.hydrate(schema, env), { @@ -341,7 +330,6 @@ describe('@bust/config', () => { schema.test.default = -1.5; env.TEST = -2.5; - assert.doesNotThrow( () => config.hydrate(schema, env), ); @@ -361,7 +349,6 @@ describe('@bust/config', () => { ); schema.test.default = 'http://example.com'; - assert.throws( () => config.hydrate(schema, env), { @@ -371,7 +358,6 @@ describe('@bust/config', () => { schema.test.default = 'http://example.com'; env.TEST = 'http://example.com/updated'; - assert.doesNotThrow( () => config.hydrate(schema, env), ); @@ -391,7 +377,6 @@ describe('@bust/config', () => { ); schema.test.default = false; - assert.doesNotThrow( () => config.hydrate(schema, env), ); @@ -411,7 +396,6 @@ describe('@bust/config', () => { ); schema.test.default = 'ok'; - assert.doesNotThrow( () => config.hydrate(schema, env), ); @@ -431,7 +415,6 @@ describe('@bust/config', () => { ); schema.test.default = /ok/; - assert.doesNotThrow( () => config.hydrate(schema, env), ); @@ -451,7 +434,6 @@ describe('@bust/config', () => { ); schema.test.default = {}; - assert.throws( () => config.hydrate(schema, env), { @@ -461,7 +443,6 @@ describe('@bust/config', () => { schema.test.default = {}; env.TEST = '{}'; - assert.doesNotThrow( () => config.hydrate(schema, env), ); @@ -480,7 +461,6 @@ describe('@bust/config', () => { ); schema.test.default = 'one,two,three'; - assert.throws( () => config.hydrate(schema), { @@ -490,7 +470,6 @@ describe('@bust/config', () => { const env = { TEST: false }; schema.test.default = []; - assert.throws( () => config.hydrate(schema, env), { @@ -500,14 +479,12 @@ describe('@bust/config', () => { schema.test.default = []; env.TEST = 'one,two,three'; - assert.doesNotThrow( () => config.hydrate(schema, env), ); schema.test.default = []; env.TEST = ['one', 'two', 'three']; - assert.doesNotThrow( () => config.hydrate(schema, env), ); diff --git a/packages/config/src/config.ts b/packages/config/src/config.ts index 6b2d9e0..153d961 100644 --- a/packages/config/src/config.ts +++ b/packages/config/src/config.ts @@ -21,6 +21,65 @@ import type { */ export const CONFIG_GLOBAL_NAME = '__BUST_CONFIG__'; +const OBJECT_TYPE_PATTERN = /\[.* |]/g; + +const validators: Record = { + enum: function(key, spec) { + const allowed = spec.format as readonly string[]; + + if (!allowed.includes(spec.value as string)) { + throw new Error(`'${key}': must be one of ${allowed.join('|')}`); + } + }, + int: function(key, spec) { + if (!Number.isInteger(spec.value)) { + throw new Error(`'${key}': must be an integer`); + } + }, + nat: function(key, spec) { + if (!Number.isInteger(spec.value) || (spec.value as number) < 0) { + throw new Error(`'${key}': must be a positive integer`); + } + }, + url: function(key, spec) { + try { + new URL(spec.value as string); + } catch { + throw new Error(`'${key}': must be a valid url`); + } + }, + boolean: function(key, spec) { + if (typeof spec.value !== 'boolean') { + throw new Error(`'${key}': must be a boolean`); + } + }, + string: function(key, spec) { + if (typeof spec.value !== 'string') { + throw new Error(`'${key}': must be a string`); + } + }, + number: function(key, spec) { + if (typeof spec.value !== 'number' || isNaN(spec.value)) { + throw new Error(`'${key}': must be a number`); + } + }, + regexp: function(key, spec) { + if (spec?.value?.constructor?.name !== 'RegExp') { + throw new Error(`'${key}': must be a regular expression (RegExp)`); + } + }, + object: function(key, spec) { + if (!isObject(spec.value)) { + throw new Error(`'${key}': must be an object`); + } + }, + array: function(key, spec) { + if (!Array.isArray(spec.value)) { + throw new Error(`'${key}': must be an array`); + } + }, +}; + /** * Schema-driven configuration store. Given a {@link SettingsSchemaTree} and a * map of environment variables, hydrates each leaf setting's value from the @@ -145,7 +204,6 @@ export class Config { } traverse(data, ''); - return settings; } } @@ -206,63 +264,6 @@ function format(x: SettingsValue | undefined, key: SettingsKey, spec: SettingsSp } } -const validators: Record = { - enum: function(key, spec) { - const allowed = spec.format as readonly string[]; - - if (!allowed.includes(spec.value as string)) { - throw new Error(`'${key}': must be one of ${allowed.join('|')}`); - } - }, - int: function(key, spec) { - if (!Number.isInteger(spec.value)) { - throw new Error(`'${key}': must be an integer`); - } - }, - nat: function(key, spec) { - if (!Number.isInteger(spec.value) || (spec.value as number) < 0) { - throw new Error(`'${key}': must be a positive integer`); - } - }, - url: function(key, spec) { - try { - new URL(spec.value as string); - } catch { - throw new Error(`'${key}': must be a valid url`); - } - }, - boolean: function(key, spec) { - if (typeof spec.value !== 'boolean') { - throw new Error(`'${key}': must be a boolean`); - } - }, - string: function(key, spec) { - if (typeof spec.value !== 'string') { - throw new Error(`'${key}': must be a string`); - } - }, - number: function(key, spec) { - if (typeof spec.value !== 'number' || isNaN(spec.value)) { - throw new Error(`'${key}': must be a number`); - } - }, - regexp: function(key, spec) { - if (spec?.value?.constructor?.name !== 'RegExp') { - throw new Error(`'${key}': must be a regular expression (RegExp)`); - } - }, - object: function(key, spec) { - if (!isObject(spec.value)) { - throw new Error(`'${key}': must be an object`); - } - }, - array: function(key, spec) { - if (!Array.isArray(spec.value)) { - throw new Error(`'${key}': must be an array`); - } - }, -}; - function validate(key: SettingsKey, spec: SettingsSpec): void { const validator = Array.isArray(spec.format) ? validators.enum @@ -279,9 +280,7 @@ function isObject(x: unknown): x is Record { return !!x && typeof x === 'object' && !Array.isArray(x); } -const objTypePtn = /\[.* |]/g; - function getDefaultFormatter(x: SettingsValue): SettingsFormatName { const type = Object.prototype.toString.call(x); - return type.replace(objTypePtn, '').toLowerCase() as SettingsFormatName; + return type.replace(OBJECT_TYPE_PATTERN, '').toLowerCase() as SettingsFormatName; } diff --git a/packages/config/src/lib.e2e.mts b/packages/config/src/lib.e2e.mts index 40725f6..c89d2ad 100644 --- a/packages/config/src/lib.e2e.mts +++ b/packages/config/src/lib.e2e.mts @@ -12,10 +12,9 @@ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ' const tmpRoot = path.join(packageRoot, 'tmp'); -describe('@bust/config end-to-end usage', () => { - describe('Node.js', () => { +describe('@bust/config', () => { + describe('createConfig() in Node.js', () => { const dir = path.join(tmpRoot, 'e2e-node'); - before(() => { writeFixture(dir, { '.env': 'MY_APP_NAME=E2E Node Value\n', @@ -37,15 +36,13 @@ describe('@bust/config end-to-end usage', () => { }); }); - describe('Browser (via Vite)', () => { + describe('createConfig() in the browser via Vite', () => { const MY_APP_NAME = 'E2E Browser Test'; const MY_APP_SECRET = 'fake-secret-value'; const dir = path.join(tmpRoot, 'e2e-browser'); const bundlePath = path.join(dir, 'dist', 'bundle.js'); - before(async () => { const cwd = process.cwd(); - writeFixture(dir, { 'package.json': JSON.stringify({ name: 'e2e-browser-fixture', private: true, type: 'module' }), '.env': `MY_APP_NAME=${MY_APP_NAME}\nMY_APP_SECRET=${MY_APP_SECRET}\n`, diff --git a/packages/config/src/node.test.ts b/packages/config/src/node.test.ts index f57a333..dbfc9f5 100644 --- a/packages/config/src/node.test.ts +++ b/packages/config/src/node.test.ts @@ -22,7 +22,7 @@ describe('@bust/config/node', () => { }); }); - describe('Schema-derived types', () => { + describe('createConfig() schema-derived types', () => { // an inline schema keeps its literal types - a `const` type parameter // only preserves them for the expression at the call site, so a schema // hoisted into its own variable needs `as const` to type its enums @@ -50,7 +50,6 @@ describe('@bust/config/node', () => { const owners: string[] = typed.get('app.owners'); const pattern: RegExp = typed.get('app.pattern'); const limits: Record = typed.get('app.limits'); - assert.strictEqual(name, 'My App'); assert.strictEqual(env, 'development'); assert.strictEqual(url, 'http://example.com'); @@ -72,7 +71,6 @@ describe('@bust/config/node', () => { it('Falls back to loose keys and values when the schema shape is unknown', () => { const loose: Config = new Config({ schema, env: {} }); const value: SettingsValue = loose.get('app.name'); - assert.strictEqual(value, 'My App'); }); }); @@ -86,10 +84,9 @@ describe('@bust/config/node', () => { }); }); - describe('.env file loading', () => { + describe('createConfig() with a .env file', () => { let cwd: string; let dir: string; - before(() => { cwd = process.cwd(); // TODO (busticated): retarget to local `./tmp` directory diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index dcc5060..df68718 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -108,8 +108,14 @@ export type SettingsKeyOf = string extends keyof S ? SettingsKey : SettingsPath; +/** Resolves the final, undotted segment of a path on behalf of {@link SettingsSpecAt}. */ +export type SettingsSpecNamed = K extends keyof S + ? S[K] + : never; + /** The {@link SettingsSpecInput} a dot-delimited key resolves to within a schema. */ export type SettingsSpecAt = P extends `${infer Head}.${infer Rest}` + // eslint-disable-next-line @typescript-eslint/no-use-before-define -- mutually recursive with SettingsSpecBelow ? SettingsSpecBelow : SettingsSpecNamed; @@ -118,11 +124,6 @@ export type SettingsSpecBelow = Head extends keyof ? SettingsSpecAt : never; -/** Resolves the final, undotted segment of a path on behalf of {@link SettingsSpecAt}. */ -export type SettingsSpecNamed = K extends keyof S - ? S[K] - : never; - /** The literal types a `default` can declare that widen back to a base primitive. */ export type SettingsWidenable = string | number | boolean; @@ -141,14 +142,6 @@ export type SettingsWidened = | (T extends boolean ? boolean : never) | (T extends SettingsWidenable ? never : T); -/** - * The element type behind an `array` setting, inferred from `default` where - * it says something. - */ -export type SettingsArrayValue = Spec extends { default: readonly (infer Element)[] } - ? SettingsArrayOf - : string[]; - /** * Falls back to `string[]` for an empty default, which says nothing about its * elements - the value can always arrive as a comma-delimited environment @@ -158,10 +151,13 @@ export type SettingsArrayOf = [Element] extends [never] ? string[] : SettingsWidened[]; -/** The value type a spec's `default` implies when it declares no explicit format. */ -export type SettingsDefaultValue = Spec extends { default: infer Default } - ? SettingsInferredValue - : SettingsValue; +/** + * The element type behind an `array` setting, inferred from `default` where + * it says something. + */ +export type SettingsArrayValue = Spec extends { default: readonly (infer Element)[] } + ? SettingsArrayOf + : string[]; /** * Reads a value type off a `default` on behalf of {@link SettingsDefaultValue}. @@ -175,6 +171,11 @@ export type SettingsInferredValue = | (Default extends Record ? Record : never) | (Default extends SettingsWidenable | null ? SettingsWidened : never); +/** The value type a spec's `default` implies when it declares no explicit format. */ +export type SettingsDefaultValue = Spec extends { default: infer Default } + ? SettingsInferredValue + : SettingsValue; + /** The type each built-in format name resolves to. */ export interface SettingsFormatValues { int: number; @@ -187,14 +188,6 @@ export interface SettingsFormatValues { regexp: RegExp; } -/** - * The type a single setting holds once hydrated, derived from its declared - * `format` and falling back to its `default` when it declares none. - */ -export type SettingsValueOf = [SettingsFormatOf] extends [never] - ? SettingsDefaultValue - : SettingsFormatValue>; - /** A spec's declared format, or `never` when it declares none. */ export type SettingsFormatOf = Spec extends { format: infer Format } ? Exclude @@ -216,6 +209,14 @@ export type SettingsFormatValue = | (Format extends 'array' ? SettingsArrayValue : never) | (Format extends SettingsFormatName | readonly string[] ? never : SettingsValue); +/** + * The type a single setting holds once hydrated, derived from its declared + * `format` and falling back to its `default` when it declares none. + */ +export type SettingsValueOf = [SettingsFormatOf] extends [never] + ? SettingsDefaultValue + : SettingsFormatValue>; + /** * The type `Config.get()` returns for a given key. Mirrors * {@link SettingsKeyOf}: a schema with a string index signature has no key to diff --git a/packages/eslint-config/README.md b/packages/eslint-config/README.md index 4131087..0ef61ac 100644 --- a/packages/eslint-config/README.md +++ b/packages/eslint-config/README.md @@ -45,6 +45,30 @@ export default bust({ typescript: false }); `vitest: true` adds Vitest's recommended rules. `nodeTest: true` is for projects on `node:test`, where `describe()` and `it()` return promises nobody awaits - it relaxes `no-floating-promises` across the spec, test, e2e, and integration suites, and does nothing when the type-aware rules are off. +### Strict conventions + +`strict: true` is opt-in and adds the stricter conventions. Every rule below can be fixed by `eslint --fix` except the naming and ordering ones. + +```js +export default bust({ react: true, vitest: true, strict: true }); +``` + +* the file ends with a single newline +* module-level constants holding a literal - string, number, boolean, regex or plain template - are `SCREAMING_SNAKE_CASE` +* variables, types and interfaces are declared above the code that uses them, and variables above functions and classes in the same scope +* a `.forEach()` that assigns a variable or changes a collection is a `for...of` instead +* inside a block, a single-line statement is never followed by an empty line, and a `return` after a multi-line statement always is +* `if`, `for`, `while`, `switch`, `try`, function declarations, and multi-line `.forEach()` and `useX()` hook calls are preceded by an empty line unless they open the block +* a logger call is preceded and followed by an empty line, except when it opens or ends the block, sits beside another logger call, or is a single line right before `return`, `throw` or `process.exit()` + +With `vitest` or `nodeTest` on, test files also follow these: + +* the top-level `describe()` names the module in Title Case (`'Admin User Queries'`) or the package (`'@bust/config'`) +* a nested `describe()` names a function, component or constant - `'createThing()'`, `''` or `'MODES'` - with an optional suffix +* variables sit above the `describe()`, `it()` and hook calls in the same scope, and those calls are preceded by an empty line +* logger calls are left unpadded, since the logged line is usually the act under test +* with `vitest` only, a file has one top-level `describe()` and uses `it()` rather than `test()` + ### Type-aware rules These are on by default and need your `tsconfig.json` to cover every file being linted. A file the tsconfig lists but excludes - an `eslint.config.js` in a project without `allowJs`, say - needs naming explicitly: diff --git a/packages/eslint-config/docs/functions/bust.md b/packages/eslint-config/docs/functions/bust.md index c365ca4..a1e0cca 100644 --- a/packages/eslint-config/docs/functions/bust.md +++ b/packages/eslint-config/docs/functions/bust.md @@ -6,7 +6,7 @@ > **bust**(`options?`): `Config`\<`RulesConfig`\>[] -Defined in: [index.ts:130](/packages/eslint-config/src/index.ts#L130) +Defined in: [index.ts:209](/packages/eslint-config/src/index.ts#L209) Builds the shared ESLint configuration. diff --git a/packages/eslint-config/docs/interfaces/BustConfigOptions.md b/packages/eslint-config/docs/interfaces/BustConfigOptions.md index 89c9e3c..8b6709a 100644 --- a/packages/eslint-config/docs/interfaces/BustConfigOptions.md +++ b/packages/eslint-config/docs/interfaces/BustConfigOptions.md @@ -4,7 +4,7 @@ # Interface: BustConfigOptions -Defined in: [index.ts:31](/packages/eslint-config/src/index.ts#L31) +Defined in: [index.ts:101](/packages/eslint-config/src/index.ts#L101) Options accepted by [bust](../functions/bust.md). @@ -14,7 +14,7 @@ Options accepted by [bust](../functions/bust.md). > `optional` **allowDefaultProject?**: `string`[] -Defined in: [index.ts:80](/packages/eslint-config/src/index.ts#L80) +Defined in: [index.ts:158](/packages/eslint-config/src/index.ts#L158) Files to type-check outside the project's `tsconfig.json` - an `eslint.config.js` that the tsconfig lists but `allowJs` excludes, for @@ -26,7 +26,7 @@ instance. > `optional` **ignores?**: `string`[] -Defined in: [index.ts:82](/packages/eslint-config/src/index.ts#L82) +Defined in: [index.ts:160](/packages/eslint-config/src/index.ts#L160) Paths to ignore, added to [DEFAULT\_IGNORES](../variables/DEFAULT_IGNORES.md). @@ -36,7 +36,7 @@ Paths to ignore, added to [DEFAULT\_IGNORES](../variables/DEFAULT_IGNORES.md). > `optional` **nodeTest?**: `boolean` -Defined in: [index.ts:68](/packages/eslint-config/src/index.ts#L68) +Defined in: [index.ts:138](/packages/eslint-config/src/index.ts#L138) Relax the type-aware rules that Node's test runner trips over. Has no effect without `typeAware`, which is what turns those rules on. @@ -53,7 +53,7 @@ false > `optional` **react?**: `boolean` -Defined in: [index.ts:55](/packages/eslint-config/src/index.ts#L55) +Defined in: [index.ts:125](/packages/eslint-config/src/index.ts#L125) Add React rules: `@eslint-react` plus the hooks plugin, and the JSX half of the stylistic rules. @@ -66,11 +66,29 @@ false *** +### strict? + +> `optional` **strict?**: `boolean` + +Defined in: [index.ts:146](/packages/eslint-config/src/index.ts#L146) + +Enforce the stricter conventions: blank lines between statements, +declaration order, constant naming, and - with a test runner on - test +suite structure. See the README for the full list. + +#### Default Value + +```ts +false +``` + +*** + ### tsconfigRootDir? > `optional` **tsconfigRootDir?**: `string` -Defined in: [index.ts:74](/packages/eslint-config/src/index.ts#L74) +Defined in: [index.ts:152](/packages/eslint-config/src/index.ts#L152) Where the type-aware project service looks for `tsconfig.json`. @@ -84,7 +102,7 @@ Where the type-aware project service looks for `tsconfig.json`. > `optional` **typeAware?**: `boolean` -Defined in: [index.ts:48](/packages/eslint-config/src/index.ts#L48) +Defined in: [index.ts:118](/packages/eslint-config/src/index.ts#L118) Add the rules that need type information - the ones that catch a promise nobody awaited. Requires `typescript`, and requires the @@ -102,7 +120,7 @@ true > `optional` **typescript?**: `boolean` -Defined in: [index.ts:40](/packages/eslint-config/src/index.ts#L40) +Defined in: [index.ts:110](/packages/eslint-config/src/index.ts#L110) Lint TypeScript. Adds `typescript-eslint`'s recommended rules and the TS-aware variants of `no-unused-vars` and `no-use-before-define`. @@ -121,7 +139,7 @@ true > `optional` **vitest?**: `boolean` -Defined in: [index.ts:61](/packages/eslint-config/src/index.ts#L61) +Defined in: [index.ts:131](/packages/eslint-config/src/index.ts#L131) Add Vitest's recommended rules, scoped to `*.test.*` files. diff --git a/packages/eslint-config/docs/variables/DEFAULT_IGNORES.md b/packages/eslint-config/docs/variables/DEFAULT_IGNORES.md index 3a72775..d0bbbd5 100644 --- a/packages/eslint-config/docs/variables/DEFAULT_IGNORES.md +++ b/packages/eslint-config/docs/variables/DEFAULT_IGNORES.md @@ -6,6 +6,6 @@ > `const` **DEFAULT\_IGNORES**: `string`[] -Defined in: [index.ts:14](/packages/eslint-config/src/index.ts#L14) +Defined in: [index.ts:17](/packages/eslint-config/src/index.ts#L17) Build output and scratch directories every project here ignores. diff --git a/packages/eslint-config/src/index.test.ts b/packages/eslint-config/src/index.test.ts index 1e21968..367a56c 100644 --- a/packages/eslint-config/src/index.test.ts +++ b/packages/eslint-config/src/index.test.ts @@ -6,7 +6,7 @@ import { bust, DEFAULT_IGNORES } from './index.js'; describe('@bust/eslint-config', () => { - describe('Default options', () => { + describe('bust() default options', () => { it('Targets TypeScript, with type-aware rules and no framework blocks', () => { const names = namesOf(bust()); assert.deepEqual(names, [ @@ -34,7 +34,7 @@ describe('@bust/eslint-config', () => { }); }); - describe('Ignores', () => { + describe('bust() ignores', () => { it('Ignores build output by default', () => { const block = blockNamed(bust(), 'bust/ignores'); assert.deepEqual(block.ignores, DEFAULT_IGNORES); @@ -46,7 +46,7 @@ describe('@bust/eslint-config', () => { }); }); - describe('JavaScript-only projects', () => { + describe('bust() for JavaScript-only projects', () => { it('Drops the TypeScript blocks and keeps the core rules', () => { const names = namesOf(bust({ typescript: false })); assert.deepEqual(names, ['bust/ignores', 'bust/base', 'bust/javascript']); @@ -72,7 +72,7 @@ describe('@bust/eslint-config', () => { }); }); - describe('Type-aware rules', () => { + describe('bust() type-aware rules', () => { it('Can be turned off on their own', () => { const names = namesOf(bust({ typeAware: false })); assert.deepEqual(names, ['bust/ignores', 'bust/base', 'bust/typescript']); @@ -99,7 +99,7 @@ describe('@bust/eslint-config', () => { }); }); - describe('React', () => { + describe('bust() React', () => { it('Adds the react, jsx-stylistic, and hooks blocks', () => { const names = namesOf(bust({ react: true })); assert.equal(names.includes('bust/react'), true); @@ -119,7 +119,7 @@ describe('@bust/eslint-config', () => { }); }); - describe('Node test runner', () => { + describe('bust() Node test runner', () => { it('Relaxes the type-aware rule its `describe()` and `it()` trip', () => { const block = blockNamed(bust({ nodeTest: true }), 'bust/node-test'); assert.equal(rulesOf(block)['@typescript-eslint/no-floating-promises'], 'off'); @@ -140,7 +140,7 @@ describe('@bust/eslint-config', () => { }); }); - describe('Vitest', () => { + describe('bust() Vitest', () => { it('Adds a block scoped to test files', () => { const block = blockNamed(bust({ vitest: true }), 'bust/vitest'); assert.deepEqual(block.files, ['**/*.test.{js,jsx,ts,tsx,mjs,mts}']); @@ -152,6 +152,83 @@ describe('@bust/eslint-config', () => { }); }); + describe('bust() strict', () => { + it('Is absent unless asked for', () => { + assert.equal(namesOf(bust()).some((name) => name.startsWith('bust/strict')), false); + }); + + it('Adds no test blocks without a test runner', () => { + const names = namesOf(bust({ strict: true })); + assert.equal(names.at(-1), 'bust/strict'); + assert.equal(names.includes('bust/strict-tests'), false); + }); + + it('Puts variables and types above their users in TypeScript', () => { + const rules = rulesOf(blockNamed(bust({ strict: true }), 'bust/strict')); + assert.deepEqual(rules['@typescript-eslint/no-use-before-define'], ['error', { + functions: false, + classes: false, + variables: true, + typedefs: true, + ignoreTypeReferences: false, + }]); + }); + + it('Uses the core `no-use-before-define` without TypeScript', () => { + const rules = rulesOf(blockNamed(bust({ typescript: false, strict: true }), 'bust/strict')); + assert.deepEqual(rules['no-use-before-define'], ['error', { + functions: false, + classes: false, + variables: true, + }]); + }); + + it('Scopes the test rules to Vitest files and adds the Vitest-only rules', () => { + const configs = bust({ strict: true, vitest: true }); + assert.deepEqual(blockNamed(configs, 'bust/strict-tests').files, ['**/*.test.{js,jsx,ts,tsx,mjs,mts}']); + assert.ok(rulesOf(blockNamed(configs, 'bust/strict-vitest'))['vitest/require-top-level-describe']); + }); + + it('Scopes the test rules to the Node test runner suites without the Vitest-only rules', () => { + const names = namesOf(bust({ strict: true, nodeTest: true })); + const block = blockNamed(bust({ strict: true, nodeTest: true }), 'bust/strict-tests'); + assert.deepEqual(block.files, ['**/*.{spec,test,e2e,integration}.{js,jsx,ts,tsx,mjs,mts}']); + assert.equal(names.includes('bust/strict-vitest'), false); + }); + + it('Reports a blank line after a single-line statement', () => { + const ruleIds = verify('export function f() {\n\tconst a = 1;\n\n\treturn a;\n}\n', 'example.js'); + assert.deepEqual(ruleIds, ['@stylistic/padding-line-between-statements']); + }); + + it('Reports a primitive constant that is not SCREAMING_SNAKE_CASE', () => { + const ruleIds = verify('export const maxTries = 3;\nexport const MAX_LENGTH = 10;\n', 'example.js'); + assert.deepEqual(ruleIds, ['no-restricted-syntax']); + }); + + it('Reports a describe() title that names neither the module nor a function', () => { + const source = [ + "describe('@bust/example', () => {", + "\tdescribe('example()', () => {});", + '', + "\tdescribe('guards', () => {});", + '});', + '', + ].join('\n'); + const messages = verifyMessages(source, 'example.test.js'); + assert.deepEqual(messages.map((message) => message.line), [4]); + }); + + function verify(source: string, filename: string): string[] { + return verifyMessages(source, filename).map((message) => String(message.ruleId)).sort(); + } + + function verifyMessages(source: string, filename: string): LinterTypes.LintMessage[] { + const configs = bust({ typescript: false, strict: true, nodeTest: true }); + return new Linter().verify(source, [...configs, { languageOptions: { globals: { describe: 'readonly' } } }] as LinterTypes.Config[], filename); + } + }); + // the plugin configs we compose carry their own names; these assertions are // about the blocks this package contributes function namesOf(configs: LinterTypes.Config[]): string[] { diff --git a/packages/eslint-config/src/index.ts b/packages/eslint-config/src/index.ts index 8a8fbf5..d064a30 100644 --- a/packages/eslint-config/src/index.ts +++ b/packages/eslint-config/src/index.ts @@ -10,6 +10,9 @@ import tseslint from 'typescript-eslint'; /** Files Node's test runner drives - it owns the e2e and integration suites too. */ const NODE_TEST_FILES = ['**/*.{spec,test,e2e,integration}.{js,jsx,ts,tsx,mjs,mts}']; +/** Files Vitest drives. */ +const VITEST_FILES = ['**/*.test.{js,jsx,ts,tsx,mjs,mts}']; + /** Build output and scratch directories every project here ignores. */ export const DEFAULT_IGNORES = ['**/dist/', '**/coverage/', '**/tmp/']; @@ -27,6 +30,73 @@ const USE_BEFORE_DEFINE = { variables: false, }; +/** `no-use-before-define` under `strict`: variables and types sit above their users too. */ +const STRICT_USE_BEFORE_DEFINE = { + ...USE_BEFORE_DEFINE, + variables: true, +}; + +/** Module-level constants holding a literal - a string, number, boolean, regex or plain template. */ +const CONSTANT_NAMES = ['Program >', 'Program > ExportNamedDeclaration >'].map((parent) => ({ + selector: `${parent} VariableDeclaration[kind="const"] > VariableDeclarator[id.name!=/^[A-Z][A-Z0-9_]*$/]:matches([init.type="Literal"], [init.type="TemplateLiteral"][init.expressions.length=0], [init.type="UnaryExpression"][init.argument.type="Literal"])`, + message: 'Module-level primitive constants are SCREAMING_SNAKE_CASE.', +})); + +const VARIABLE = ':matches(VariableDeclaration, ExportNamedDeclaration[declaration.type="VariableDeclaration"])'; +const FUNCTION_OR_CLASS = ':matches(FunctionDeclaration, ClassDeclaration, ExportNamedDeclaration[declaration.type=/^(FunctionDeclaration|ClassDeclaration)$/], ExportDefaultDeclaration)'; +const TEST_CALL = ':matches(ExpressionStatement[expression.callee.name=/^(describe|it|test|beforeEach|afterEach|beforeAll|afterAll)$/], ExpressionStatement[expression.callee.object.name=/^(describe|it|test)$/][expression.callee.property.name=/^(describe|only|each|beforeEach|afterEach|beforeAll|afterAll)$/])'; + +const ORDERING = [ + { selector: `${FUNCTION_OR_CLASS} ~ ${VARIABLE}`, message: 'Declare variables above functions and classes in the same scope.' }, + { + selector: 'CallExpression[callee.property.name="forEach"] > :function :matches(AssignmentExpression, UpdateExpression, CallExpression[callee.property.name=/^(push|unshift|splice|pop|shift|set|add|delete|clear)$/])', + message: 'Use `for...of` rather than `.forEach()` to assign or change a collection.', + }, +]; + +const TEST_ORDERING = [ + { selector: `${TEST_CALL} ~ ${VARIABLE}`, message: 'Declare variables above describe(), it() and hooks in the same scope.' }, +]; + +const DESCRIBE_TITLES = [ + { + selector: 'Program > ExpressionStatement > CallExpression[callee.name="describe"][arguments.0.value!=/^([A-Z][A-Za-z0-9]*( [A-Z][A-Za-z0-9]*)*|@[a-z0-9-]+\\/[a-z0-9-]+(\\/[a-z0-9-]+)*)$/]', + message: 'A top-level describe() names the module in Title Case, or the package as `@scope/name`.', + }, + { + selector: 'CallExpression[callee.name="describe"] CallExpression[callee.name="describe"][arguments.0.value!=/^([A-Za-z_$][A-Za-z0-9_$.]*\\(\\)|<[A-Z][A-Za-z0-9.]* \\/>|[A-Z][A-Z0-9_]*)( .+)?$/]', + message: 'A nested describe() names a function, component or constant - `fn()`, `` or `CONSTANT` - with an optional suffix.', + }, +]; + +// module level is left alone: a one-line `vi.mock()` above `describe()` keeps its blank line +const SINGLE_LINE_IN_BLOCK = { selector: 'BlockStatement > *', lineMode: 'singleline' }; +const LOGGER = 'ExpressionStatement[expression.callee.property.name=/^(debug|info|warn|error|print|printError)$/][expression.callee.object.name!=/^(console|controller)$/]'; +// the last statement in a block falls back to the rules every other statement follows +const LOGGER_CALL = { selector: `${LOGGER}:not(:last-child)` }; +const PROCESS_EXIT = { selector: 'ExpressionStatement[expression.callee.object.name="process"][expression.callee.property.name="exit"]' }; + +const STATEMENT_PADDING = [ + { blankLine: 'never', prev: SINGLE_LINE_IN_BLOCK, next: '*' }, + { blankLine: 'always', prev: '*', next: ['if', 'for', 'while', 'do', 'switch', 'try', 'function'] }, + { blankLine: 'always', prev: '*', next: { selector: TEST_CALL } }, + { blankLine: 'always', prev: '*', next: { selector: 'ExpressionStatement[expression.callee.name=/^use[A-Z]/]', lineMode: 'multiline' } }, + { blankLine: 'always', prev: '*', next: { selector: 'ExpressionStatement[expression.callee.property.name="forEach"]', lineMode: 'multiline' } }, + { blankLine: 'always', prev: { selector: 'BlockStatement > *', lineMode: 'multiline' }, next: 'return' }, +]; + +const LOGGER_PADDING = [ + { blankLine: 'always', prev: '*', next: LOGGER_CALL }, + { blankLine: 'always', prev: LOGGER_CALL, next: '*' }, + { blankLine: 'never', prev: LOGGER_CALL, next: { selector: LOGGER } }, + { blankLine: 'never', prev: { ...LOGGER_CALL, lineMode: 'singleline' }, next: ['return', 'throw', PROCESS_EXIT] }, +]; + +const IMPORT_PADDING = [ + { blankLine: 'always', prev: 'import', next: '*' }, + { blankLine: 'any', prev: 'import', next: 'import' }, +]; + /** Options accepted by {@link bust}. */ export interface BustConfigOptions { /** @@ -66,6 +136,14 @@ export interface BustConfigOptions { * @defaultValue false */ nodeTest?: boolean; + /** + * Enforce the stricter conventions: blank lines between statements, + * declaration order, constant naming, and - with a test runner on - test + * suite structure. See the README for the full list. + * + * @defaultValue false + */ + strict?: boolean; /** * Where the type-aware project service looks for `tsconfig.json`. * @@ -91,6 +169,7 @@ function resolveOptions(options: BustConfigOptions): Required { it('removes padding from zero-padded ids', () => { const hash = base52.encode('0000001'); - assert.strictEqual(hash, '3'); - const id = base52.decode(hash); - assert.strictEqual(id, '1'); }); @@ -52,4 +49,3 @@ describe('@bust/numeric-id-hasher', () => { ]; } }); - diff --git a/packages/numeric-id-hasher/src/index.ts b/packages/numeric-id-hasher/src/index.ts index 01b3d8b..2b8c2ed 100644 --- a/packages/numeric-id-hasher/src/index.ts +++ b/packages/numeric-id-hasher/src/index.ts @@ -54,7 +54,6 @@ export const encode = (id: string) => { export const decode = (hash: string) => { let result = BigInt(0); let charIndex, i; - hash = hash.split('').reverse().join(''); for (i = 0; i < hash.length; i++){ @@ -64,4 +63,3 @@ export const decode = (hash: string) => { return result.toString(); }; - diff --git a/packages/periodical/src/index.test.ts b/packages/periodical/src/index.test.ts index 83770e0..191b4fe 100644 --- a/packages/periodical/src/index.test.ts +++ b/packages/periodical/src/index.test.ts @@ -28,11 +28,8 @@ describe('@bust/periodically', () => { }; ctx.mock.timers.enable({ apis: ['setTimeout'] }); - p.start(myObj.run, myObj); - ctx.mock.timers.tick(interval); - assert.equal(myObj.value, 1); assert.notEqual(p.timeoutId, undefined); assert.equal(p.isRunning(), true); @@ -41,13 +38,9 @@ describe('@bust/periodically', () => { it('starts with function as callback', (ctx) => { const p = new Periodical(interval); const run = ctx.mock.fn(); - ctx.mock.timers.enable({ apis: ['setTimeout'] }); - p.start(run); - ctx.mock.timers.tick(interval); - assert.equal(run.mock.callCount(), 1); assert.notEqual(p.timeoutId, undefined); assert.equal(p.isRunning(), true); @@ -56,12 +49,9 @@ describe('@bust/periodically', () => { it('stops', (ctx) => { const p = new Periodical(interval); const myObj = { run: ctx.mock.fn() }; - ctx.mock.timers.enable({ apis: ['setTimeout'] }); - p.start(myObj.run, myObj); p.stop(); - assert.equal(myObj.run.mock.callCount(), 0); assert.equal(p.timeoutId, undefined); assert.equal(p.isRunning(), false); @@ -70,12 +60,9 @@ describe('@bust/periodically', () => { it('executes', (ctx) => { const p = new Periodical(interval); const run = ctx.mock.fn(); - ctx.mock.timers.enable({ apis: ['setTimeout'] }); - p.start(run); p.exec(); - assert.equal(run.mock.callCount(), 1); assert.notEqual(p.fn, noop); assert.notEqual(p.timeoutId, undefined); @@ -85,9 +72,7 @@ describe('@bust/periodically', () => { it('does not execute when stopped', (ctx) => { const p = new Periodical(interval); const myObj = { run: ctx.mock.fn() }; - p.exec(); - assert.equal(myObj.run.mock.callCount(), 0); assert.equal(p.fn, noop); assert.equal(p.timeoutId, undefined); @@ -97,21 +82,13 @@ describe('@bust/periodically', () => { it('executes periodically', (ctx) => { const p = new Periodical(interval); const myObj = { run: ctx.mock.fn() }; - ctx.mock.timers.enable({ apis: ['setTimeout'] }); - p.start(myObj.run, myObj); - ctx.mock.timers.tick(interval); - assert.equal(myObj.run.mock.callCount(), 1); - ctx.mock.timers.tick(interval); - assert.equal(myObj.run.mock.callCount(), 2); - ctx.mock.timers.tick(interval); - assert.equal(myObj.run.mock.callCount(), 3); }); @@ -125,26 +102,19 @@ describe('@bust/periodically', () => { let date = new Date('1983-04-01T00:00:05.000Z'); // steven m. newman takes a stroll let interval = 10 * 1000; // every 10s on the dot let p = new Periodical(interval); - assert.equal(p.msUntilNextRun(date), 5000); assert.equal(getTimeStampForNextRun(date, p), 'Fri, 01 Apr 1983 00:00:10 GMT'); - date = new Date('1983-04-01T00:00:07.000Z'); p = new Periodical(interval); - assert.equal(p.msUntilNextRun(date), 3000); assert.equal(getTimeStampForNextRun(date, p), 'Fri, 01 Apr 1983 00:00:10 GMT'); - interval = 60 * 1000; // every 1m on the dot date = new Date('1983-04-01T00:00:37.000Z'); p = new Periodical(interval); - assert.equal(p.msUntilNextRun(date), 23000); assert.equal(getTimeStampForNextRun(date, p), 'Fri, 01 Apr 1983 00:01:00 GMT'); - date = new Date('1983-04-01T00:00:45.000Z'); p = new Periodical(interval); - assert.equal(p.msUntilNextRun(date), 15000); assert.equal(getTimeStampForNextRun(date, p), 'Fri, 01 Apr 1983 00:01:00 GMT'); }); @@ -153,7 +123,6 @@ describe('@bust/periodically', () => { const date = new Date('1983-04-01T00:00:00.000Z'); const interval = 60 * 1000; // every 1m on the dot const p = new Periodical(interval); - assert.equal(p.msUntilNextRun(date), 60000); assert.equal(getTimeStampForNextRun(date, p), 'Fri, 01 Apr 1983 00:01:00 GMT'); }); @@ -162,4 +131,3 @@ describe('@bust/periodically', () => { return new Date(d.getTime() + p.msUntilNextRun(d)).toUTCString(); } }); - diff --git a/packages/periodical/src/index.ts b/packages/periodical/src/index.ts index d7f9e19..f056342 100644 --- a/packages/periodical/src/index.ts +++ b/packages/periodical/src/index.ts @@ -148,4 +148,3 @@ export class Periodical { return now.getTime() - original.getTime(); } } - diff --git a/packages/timer/src/index.test.ts b/packages/timer/src/index.test.ts index 630a9ca..21c19e7 100644 --- a/packages/timer/src/index.test.ts +++ b/packages/timer/src/index.test.ts @@ -10,7 +10,6 @@ const DELTA = 5; // b/c js timers (O_o) describe('@bust/timer', () => { it('Initializes', () => { const timer = new Timer(); - assert(timer.hasOwnProperty('started')); assert(timer.hasOwnProperty('ended')); assert.equal(typeof timer.isRunning, 'function'); @@ -31,9 +30,7 @@ describe('@bust/timer', () => { it('Captures start time', () => { const timer = new Timer(); - timer.start(); - assert(timer.started); assert(timer.now() - timer.started < DELTA); assert.equal(timer.ended, 0); @@ -43,9 +40,7 @@ describe('@bust/timer', () => { it('Accepts a start time if provided', () => { const timer = new Timer(); - timer.start(STARTED); - assert(timer.started); assert.equal(timer.started, STARTED); assert.equal(timer.ended, 0); @@ -72,10 +67,8 @@ describe('@bust/timer', () => { it('Accepts an end time if provided', () => { const timer = new Timer(); - timer.start(STARTED); timer.end(ENDED); - assert.equal(timer.started, STARTED); assert.equal(timer.ended, ENDED); assert.equal(timer.isRunning(), false); @@ -85,9 +78,7 @@ describe('@bust/timer', () => { it('Marks start & end time', () => { const timer = new Timer(); const now = timer.now(); - timer.mark(); - assert(timer.started > 0); assert(now - timer.started < DELTA); assert(timer.ended > 0); @@ -98,9 +89,7 @@ describe('@bust/timer', () => { it('Accepts a mark time if provided', () => { const timer = new Timer(); - timer.mark(ENDED); - assert.equal(timer.started, ENDED); assert.equal(timer.ended, ENDED); assert.equal(timer.isRunning(), false); @@ -109,10 +98,8 @@ describe('@bust/timer', () => { it('Calculates elapsed time', () => { const timer = new Timer(); - timer.start(STARTED); timer.end(ENDED); - assert.equal(timer.elapsed(), 32); }); @@ -133,40 +120,28 @@ describe('@bust/timer', () => { it('Determines if the timer is running', () => { const timer = new Timer(); - assert.equal(timer.isRunning(), false); - timer.start(); - assert.equal(timer.isRunning(), true); - timer.end(); - assert.equal(timer.isRunning(), false); }); it('Determine if the timer is finished', () => { const timer = new Timer(); - assert.equal(timer.isFinished(), false); - timer.start(); - assert.equal(timer.isFinished(), false); - timer.end(); - assert.equal(timer.isFinished(), true); }); it('Should not error if called derpily', () => { const timer = new Timer(); const input = [undefined, null, '', false, 0]; - assert.doesNotThrow(() => { // @ts-expect-error - testing error cases input.forEach((x) => timer.start(x).end(x)); }); }); }); -