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));
});
});
});
-