@naturalcycles/dev-lib 20.16.1 → 20.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cfg/biome.jsonc CHANGED
@@ -3,18 +3,12 @@
3
3
  "root": false,
4
4
  "files": {
5
5
  "includes": [
6
- "*",
7
- "src/**",
8
- "scripts/**",
9
- "!**/*.html",
10
- "!**/*.css",
11
- "!**/*.scss",
12
- "!**/tsconfig.json",
13
- "!**/tsconfig.*.json",
6
+ "**/*.ts",
7
+ "**/*.tsx",
8
+ "**/*.js",
9
+ "**/*.jsx",
14
10
  "!**/__exclude",
15
- "!**/try.ts",
16
- "!**/*.compact.json",
17
- "!**/*.mock.json"
11
+ "!**/try.ts"
18
12
  ]
19
13
  },
20
14
  "formatter": {
@@ -277,6 +277,7 @@
277
277
  "vitest/prefer-spy-on": 2,
278
278
  "vitest/prefer-to-be": 2,
279
279
  "vitest/prefer-to-contain": 2,
280
+ "vitest/consistent-test-filename": 2,
280
281
  "jest/consistent-test-it": [
281
282
  2,
282
283
  {
@@ -10,10 +10,15 @@ import type { InlineConfig } from 'vitest/node'
10
10
  // bail: 1,
11
11
  })
12
12
 
13
+ Pass `import.meta.dirname` as cwd if running from a monorepo.
14
+
13
15
  */
14
16
  export function defineVitestConfig(config?: Partial<ViteUserConfig>, cwd?: string): ViteUserConfig
15
17
 
16
- export const sharedConfig: InlineConfig
18
+ /**
19
+ * Pass `import.meta.dirname` as cwd if running from a monorepo.
20
+ */
21
+ export function getSharedConfig(cwd?: string): InlineConfig
17
22
 
18
23
  export const CollectReporter: any
19
24
  export const SummaryReporter: any
@@ -37,13 +37,10 @@ if (silent) {
37
37
  * })
38
38
  */
39
39
  export function defineVitestConfig(config, cwd) {
40
- const setupFiles = getSetupFiles(testType, cwd)
41
-
42
40
  const mergedConfig = defineConfig({
43
41
  ...config,
44
42
  test: {
45
- ...sharedConfig,
46
- setupFiles,
43
+ ...getSharedConfig(cwd),
47
44
  ...config?.test,
48
45
  },
49
46
  })
@@ -70,65 +67,67 @@ export function defineVitestConfig(config, cwd) {
70
67
  /**
71
68
  * Shared config for Vitest.
72
69
  */
73
- export const sharedConfig = {
74
- pool,
75
- maxWorkers,
76
- isolate: false,
77
- watch: false,
78
- // dir: 'src',
79
- restoreMocks: true,
80
- silent,
81
- setupFiles: getSetupFiles(testType),
82
- logHeapUsage: true,
83
- testTimeout: 60_000,
84
- slowTestThreshold: isCI ? 500 : 300, // higher threshold in CI
85
- sequence: {
86
- sequencer: VitestAlphabeticSequencer,
87
- // shuffle: {
88
- // files: true,
89
- // tests: false,
90
- // },
91
- // seed: 1, // this makes the order of tests deterministic (but still not alphabetic)
92
- },
93
- include,
94
- exclude,
95
- reporters: [
96
- 'default',
97
- new SummaryReporter(),
98
- junitReporterEnabled && [
99
- 'junit',
100
- {
101
- suiteName: `${testType} tests`,
102
- // classNameTemplate: '{filename} - {classname}',
103
- },
104
- ],
105
- ].filter(Boolean),
106
- // outputFile location is specified for compatibility with the previous jest config
107
- outputFile: junitReporterEnabled ? `./tmp/jest/${testType}.xml` : undefined,
108
- coverage: {
109
- enabled: coverageEnabled,
110
- reporter: ['html', 'lcov', 'json', 'json-summary', !isCI && 'text'].filter(Boolean),
111
- include: ['src/**/*.{ts,tsx}'],
112
- exclude: [
113
- '**/__exclude/**',
114
- 'scripts/**',
115
- 'public/**',
116
- 'src/index.{ts,tsx}',
117
- 'src/test/**',
118
- 'src/typings/**',
119
- 'src/{env,environment,environments}/**',
120
- 'src/bin/**',
121
- 'src/vendor/**',
122
- '**/*.test.*',
123
- '**/*.script.*',
124
- '**/*.module.*',
125
- '**/*.mock.*',
126
- '**/*.page.{ts,tsx}',
127
- '**/*.component.{ts,tsx}',
128
- '**/*.directive.{ts,tsx}',
129
- '**/*.modal.{ts,tsx}',
130
- ],
131
- },
70
+ export function getSharedConfig(cwd) {
71
+ return {
72
+ pool,
73
+ maxWorkers,
74
+ isolate: false,
75
+ watch: false,
76
+ // dir: 'src',
77
+ restoreMocks: true,
78
+ silent,
79
+ setupFiles: getSetupFiles(testType, cwd),
80
+ logHeapUsage: true,
81
+ testTimeout: 60_000,
82
+ slowTestThreshold: isCI ? 500 : 300, // higher threshold in CI
83
+ sequence: {
84
+ sequencer: VitestAlphabeticSequencer,
85
+ // shuffle: {
86
+ // files: true,
87
+ // tests: false,
88
+ // },
89
+ // seed: 1, // this makes the order of tests deterministic (but still not alphabetic)
90
+ },
91
+ include,
92
+ exclude,
93
+ reporters: [
94
+ 'default',
95
+ new SummaryReporter(),
96
+ junitReporterEnabled && [
97
+ 'junit',
98
+ {
99
+ suiteName: `${testType} tests`,
100
+ // classNameTemplate: '{filename} - {classname}',
101
+ },
102
+ ],
103
+ ].filter(Boolean),
104
+ // outputFile location is specified for compatibility with the previous jest config
105
+ outputFile: junitReporterEnabled ? `./tmp/jest/${testType}.xml` : undefined,
106
+ coverage: {
107
+ enabled: coverageEnabled,
108
+ reporter: ['html', 'lcov', 'json', 'json-summary', !isCI && 'text'].filter(Boolean),
109
+ include: ['src/**/*.{ts,tsx}'],
110
+ exclude: [
111
+ '**/__exclude/**',
112
+ 'scripts/**',
113
+ 'public/**',
114
+ 'src/index.{ts,tsx}',
115
+ 'src/test/**',
116
+ 'src/typings/**',
117
+ 'src/{env,environment,environments}/**',
118
+ 'src/bin/**',
119
+ 'src/vendor/**',
120
+ '**/*.test.*',
121
+ '**/*.script.*',
122
+ '**/*.module.*',
123
+ '**/*.mock.*',
124
+ '**/*.page.{ts,tsx}',
125
+ '**/*.component.{ts,tsx}',
126
+ '**/*.directive.{ts,tsx}',
127
+ '**/*.modal.{ts,tsx}',
128
+ ],
129
+ },
130
+ }
132
131
  }
133
132
 
134
133
  function doesItRunInIDE() {
@@ -184,14 +183,14 @@ function isRunningAllTests() {
184
183
  return !hasPositionalArgs
185
184
  }
186
185
 
187
- function getSetupFiles(testType, cwd = '.') {
186
+ function getSetupFiles(testType, cwd = process.cwd()) {
188
187
  // Set 'setupFiles' only if setup files exist
189
188
  const setupFiles = []
190
189
  if (fs.existsSync(`${cwd}/src/test/setupVitest.ts`)) {
191
- setupFiles.push('./src/test/setupVitest.ts')
190
+ setupFiles.push(`${cwd}/src/test/setupVitest.ts`)
192
191
  }
193
192
  if (fs.existsSync(`${cwd}/src/test/setupVitest.${testType}.ts`)) {
194
- setupFiles.push(`./src/test/setupVitest.${testType}.ts`)
193
+ setupFiles.push(`${cwd}/src/test/setupVitest.${testType}.ts`)
195
194
  }
196
195
  return setupFiles
197
196
  }
@@ -5,17 +5,23 @@ import { _assert } from '@naturalcycles/js-lib/error/assert.js';
5
5
  import { fs2 } from '@naturalcycles/nodejs-lib/fs2';
6
6
  import { runScript } from '@naturalcycles/nodejs-lib/runScript';
7
7
  import { buildCopy, buildProd, runTSCInFolders } from '../build.util.js';
8
- import { eslintAll, lintAllCommand, lintStagedCommand, runBiome, runCommitlintCommand, runOxlint, runPrettier, stylelintAll, } from '../lint.util.js';
8
+ import { runCommitlint } from '../commitlint.js';
9
+ import { eslintAll, lintAllCommand, lintStagedCommand, requireOxlintConfig, runBiome, runOxlint, runPrettier, stylelintAll, } from '../lint.util.js';
9
10
  import { runTest } from '../test.util.js';
10
11
  const commands = [
11
12
  new Separator(), // build
13
+ { name: 'check', fn: check, desc: '"Run all possible checks": lint, typecheck, then test.' },
14
+ { name: 'bt', fn: bt, desc: 'Build & Test: run "typecheck" (via oxlint) and then "test".' },
12
15
  {
13
16
  name: 'typecheck',
14
- fn: typecheck,
15
- desc: 'Run typecheck (tsc) in folders (src, scripts, e2e) if there is tsconfig.json present',
17
+ fn: typecheckWithOxlint,
18
+ desc: 'Run typecheck via oxlint --type-aware',
19
+ },
20
+ {
21
+ name: 'typecheck-with-tsc',
22
+ fn: typecheckWithTSC,
23
+ desc: 'Run typecheck (tsc) in folders (src, scripts, e2e) if there is tsconfig.json present. Deprecated, use oxlint type-checking instead.',
16
24
  },
17
- { name: 'bt', fn: bt, desc: 'Build & Test: run "typecheck" (tsc) and then "test".' },
18
- { name: 'check', fn: lbt, desc: '"Run all possible checks": lint, typecheck, then test.' },
19
25
  {
20
26
  name: 'build',
21
27
  fn: buildProd,
@@ -100,7 +106,7 @@ const commands = [
100
106
  desc: 'Run stylelint with auto-fix disabled.',
101
107
  },
102
108
  { name: 'stylelint-no-fix', cliOnly: true, fn: () => stylelintAll(false) },
103
- { name: 'commitlint', fn: runCommitlintCommand, desc: 'Run commitlint.', cliOnly: true },
109
+ { name: 'commitlint', fn: runCommitlint, desc: 'Run commitlint.', cliOnly: true },
104
110
  new Separator(), // interactive-only
105
111
  {
106
112
  name: 'exit',
@@ -145,15 +151,20 @@ runScript(async () => {
145
151
  }
146
152
  await commandMap[cmd].fn();
147
153
  });
148
- async function lbt() {
154
+ async function check() {
149
155
  await lintAllCommand();
150
- await bt();
156
+ runTest();
151
157
  }
152
158
  async function bt() {
153
- await typecheck();
159
+ await typecheckWithOxlint();
154
160
  runTest();
155
161
  }
156
- async function typecheck() {
162
+ async function typecheckWithOxlint() {
163
+ requireOxlintConfig();
164
+ const fix = !CI;
165
+ runOxlint(fix);
166
+ }
167
+ async function typecheckWithTSC() {
157
168
  await runTSCInFolders(['src', 'scripts', 'e2e'], ['--noEmit']);
158
169
  }
159
170
  async function cleanDist() {
@@ -1,11 +1,9 @@
1
1
  export declare function buildProd(): Promise<void>;
2
2
  /**
3
3
  * Use 'src' to indicate root.
4
+ *
5
+ * @deprecated - oxlint should be used for type-checking instead, it's faster.
4
6
  */
5
7
  export declare function runTSCInFolders(dirs: string[], args?: string[], parallel?: boolean): Promise<void>;
6
- /**
7
- * Pass 'src' to run in root.
8
- */
9
- export declare function runTSCInFolder(dir: string, args?: string[]): Promise<void>;
10
8
  export declare function runTSCProd(args?: string[]): Promise<void>;
11
9
  export declare function buildCopy(): void;
@@ -11,6 +11,8 @@ export async function buildProd() {
11
11
  }
12
12
  /**
13
13
  * Use 'src' to indicate root.
14
+ *
15
+ * @deprecated - oxlint should be used for type-checking instead, it's faster.
14
16
  */
15
17
  export async function runTSCInFolders(dirs, args = [], parallel = true) {
16
18
  if (parallel) {
@@ -25,7 +27,7 @@ export async function runTSCInFolders(dirs, args = [], parallel = true) {
25
27
  /**
26
28
  * Pass 'src' to run in root.
27
29
  */
28
- export async function runTSCInFolder(dir, args = []) {
30
+ async function runTSCInFolder(dir, args = []) {
29
31
  let configDir = dir;
30
32
  if (dir === 'src') {
31
33
  configDir = '';
@@ -0,0 +1,15 @@
1
+ import { type DevLibCommitlintConfig } from './config.js';
2
+ /**
3
+ * Validates the commit message,
4
+ * which is read from a file, passed as process.argv.at(-1)
5
+ */
6
+ export declare function runCommitlint(): void;
7
+ /**
8
+ * Commit message validator following Conventional Commits specification.
9
+ * https://www.conventionalcommits.org/
10
+ */
11
+ export declare function validateCommitMessage(input: string, cfg?: DevLibCommitlintConfig): CommitMessageValidationResponse;
12
+ export interface CommitMessageValidationResponse {
13
+ valid: boolean;
14
+ errors: string[];
15
+ }
@@ -0,0 +1,106 @@
1
+ import { _assert } from '@naturalcycles/js-lib/error';
2
+ import { fs2 } from '@naturalcycles/nodejs-lib/fs2';
3
+ import { readDevLibConfigIfPresent } from './config.js';
4
+ const ALLOWED_TYPES = new Set([
5
+ 'feat',
6
+ 'fix',
7
+ 'chore',
8
+ 'refactor',
9
+ 'docs',
10
+ 'style',
11
+ 'test',
12
+ 'perf',
13
+ 'ci',
14
+ 'build',
15
+ 'revert',
16
+ ]);
17
+ const SUBJECT_MAX_LENGTH = 120; // Only applies to subject line (first line)
18
+ /**
19
+ * Validates the commit message,
20
+ * which is read from a file, passed as process.argv.at(-1)
21
+ */
22
+ export function runCommitlint() {
23
+ // || '.git/COMMIT_EDITMSG' // fallback is unnecessary, first argument should be always present
24
+ const arg1 = process.argv.at(-1);
25
+ _assert(arg1, 'dev-lib commitlint2 is called with $1 (first argument) missing');
26
+ console.log({ arg1 });
27
+ fs2.requireFileToExist(arg1);
28
+ const msg = fs2.readText(arg1);
29
+ console.log({ msg });
30
+ const devLibCfg = readDevLibConfigIfPresent();
31
+ console.log({ devLibCfg });
32
+ const { valid, errors } = validateCommitMessage(msg, devLibCfg.commitlint);
33
+ if (valid) {
34
+ console.log('✓ Valid commit message');
35
+ return;
36
+ }
37
+ console.error('✗ Invalid commit message:');
38
+ for (const err of errors) {
39
+ console.error(` - ${err}`);
40
+ }
41
+ process.exit(1);
42
+ }
43
+ /**
44
+ * Commit message validator following Conventional Commits specification.
45
+ * https://www.conventionalcommits.org/
46
+ */
47
+ export function validateCommitMessage(input, cfg = {}) {
48
+ const errors = [];
49
+ const msg = input.trim();
50
+ if (!msg) {
51
+ return { valid: false, errors: ['Commit message is empty'] };
52
+ }
53
+ const lines = msg.split('\n');
54
+ const subjectLine = lines[0];
55
+ // Step 1: Validate subject line format
56
+ // Pattern: type(scope)!: description OR type!: description OR type(scope): description OR type: description
57
+ const subjectPattern = /^(\w+)(?:\(([^)]+)\))?(!)?\s*:\s*(.+)$/;
58
+ const match = subjectLine.match(subjectPattern);
59
+ if (!match) {
60
+ errors.push(`Subject line must match format: type(scope): description\n` +
61
+ ` Got: "${subjectLine}"\n` +
62
+ ` Examples: "feat(auth): add login", "fix: resolve crash"`);
63
+ return { valid: false, errors };
64
+ }
65
+ const [, type, scope, _breaking, description] = match;
66
+ // Step 2: Validate type
67
+ if (!ALLOWED_TYPES.has(type)) {
68
+ errors.push(`Invalid type "${type}". Allowed types: ${[...ALLOWED_TYPES].join(', ')}`);
69
+ }
70
+ // Step 3: Validate subject line length
71
+ if (subjectLine.length > SUBJECT_MAX_LENGTH) {
72
+ errors.push(`Subject line too long: ${subjectLine.length} chars (max ${SUBJECT_MAX_LENGTH})`);
73
+ }
74
+ // Step 4: Validate description is not empty
75
+ if (!description?.trim()) {
76
+ errors.push('Description after colon cannot be empty');
77
+ }
78
+ // Step 5: Validate description doesn't start with capital letter (conventional style)
79
+ // Disabled: many existing commits use capitals
80
+ // if (description && /^[A-Z]/.test(description.trim())) {
81
+ // errors.push('Description should start with lowercase letter')
82
+ // }
83
+ // Step 6: Validate blank line between subject and body (if body exists)
84
+ if (lines.length > 1 && lines[1].trim() !== '') {
85
+ errors.push('There must be a blank line between subject and body');
86
+ }
87
+ // Note: No line length validation for body lines - they can be any length
88
+ // Step 7: scope validation
89
+ if (cfg.requireScope && !scope) {
90
+ errors.push('Scope is required');
91
+ }
92
+ if (scope && cfg.allowedScopes && !cfg.allowedScopes.includes(scope)) {
93
+ errors.push(`Scope must be one of the allowed scopes:\n${cfg.allowedScopes.join('\n')}`);
94
+ }
95
+ return {
96
+ valid: errors.length === 0,
97
+ errors,
98
+ // parsed: {
99
+ // type,
100
+ // scope: scope || null,
101
+ // breaking: !!breaking,
102
+ // description: description?.trim(),
103
+ // body: lines.slice(2).join('\n').trim() || null,
104
+ // },
105
+ };
106
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Returns an empty config if the file is absent.
3
+ */
4
+ export declare function readDevLibConfigIfPresent(): DevLibConfig;
5
+ export interface DevLibConfig {
6
+ commitlint?: DevLibCommitlintConfig;
7
+ }
8
+ export interface DevLibCommitlintConfig {
9
+ /**
10
+ * Defaults to false.
11
+ * If set to true - commit scope becomes required.
12
+ */
13
+ requireScope?: boolean;
14
+ /**
15
+ * If defined - commitlint (which is run on git precommit hook) will validate that
16
+ * the scope is one of the allowedScopes.
17
+ * Empty (not present) scope will pass this rule, as it depends on the `requireScope` option instead.
18
+ */
19
+ allowedScopes?: string[];
20
+ }
package/dist/config.js ADDED
@@ -0,0 +1,20 @@
1
+ import { AjvSchema, j } from '@naturalcycles/nodejs-lib/ajv';
2
+ import { fs2 } from '@naturalcycles/nodejs-lib/fs2';
3
+ const devLibConfigPath = 'dev-lib.config.js';
4
+ /**
5
+ * Returns an empty config if the file is absent.
6
+ */
7
+ export function readDevLibConfigIfPresent() {
8
+ const cfg = fs2.pathExists(devLibConfigPath) ? fs2.readJson(devLibConfigPath) : {};
9
+ return devLibConfigSchema.validate(cfg);
10
+ }
11
+ const devLibConfigSchema = AjvSchema.create(j.object({
12
+ commitlint: j.object
13
+ .infer({
14
+ requireScope: j.boolean().optional(),
15
+ allowedScopes: j.array(j.string()).optional(),
16
+ })
17
+ .optional(),
18
+ }), {
19
+ inputName: 'dev-lib.config.js',
20
+ });
@@ -12,6 +12,8 @@ interface EslintAllOptions {
12
12
  */
13
13
  export declare function eslintAll(opt?: EslintAllOptions): void;
14
14
  export declare function runOxlint(fix?: boolean): void;
15
+ export declare function requireOxlintConfig(): void;
16
+ export declare function hasOxlintConfig(): boolean;
15
17
  interface RunPrettierOptions {
16
18
  experimentalCli?: boolean;
17
19
  fix?: boolean;
@@ -19,7 +21,6 @@ interface RunPrettierOptions {
19
21
  export declare function runPrettier(opt?: RunPrettierOptions): void;
20
22
  export declare function stylelintAll(fix?: boolean): void;
21
23
  export declare function lintStagedCommand(): Promise<void>;
22
- export declare function runCommitlintCommand(): void;
23
24
  export declare function requireActionlintVersion(): void;
24
25
  export declare function getActionLintVersion(): SemVerString | undefined;
25
26
  export declare function runBiome(fix?: boolean): void;
package/dist/lint.util.js CHANGED
@@ -7,7 +7,6 @@ import { _since } from '@naturalcycles/js-lib/datetime/time.util.js';
7
7
  import { _assert } from '@naturalcycles/js-lib/error/assert.js';
8
8
  import { _filterFalsyValues } from '@naturalcycles/js-lib/object/object.util.js';
9
9
  import { semver2 } from '@naturalcycles/js-lib/semver';
10
- import { git2 } from '@naturalcycles/nodejs-lib';
11
10
  import { dimGrey, white } from '@naturalcycles/nodejs-lib/colors';
12
11
  import { exec2 } from '@naturalcycles/nodejs-lib/exec2';
13
12
  import { fs2 } from '@naturalcycles/nodejs-lib/fs2';
@@ -132,8 +131,8 @@ function runESLint(extensions = eslintExtensions.split(','), fix = true) {
132
131
  });
133
132
  }
134
133
  export function runOxlint(fix = true) {
135
- const oxlintConfigPath = `.oxlintrc.json`;
136
- if (!existsSync(oxlintConfigPath)) {
134
+ if (!hasOxlintConfig()) {
135
+ console.log('.oxlintrc.json is not found, skipping to run oxlint');
137
136
  return;
138
137
  }
139
138
  const oxlintPath = findPackageBinPath('oxlint', 'oxlint');
@@ -151,6 +150,13 @@ export function runOxlint(fix = true) {
151
150
  shell: false,
152
151
  });
153
152
  }
153
+ export function requireOxlintConfig() {
154
+ _assert(hasOxlintConfig(), '.oxlintrc.json config is not found');
155
+ }
156
+ export function hasOxlintConfig() {
157
+ const oxlintConfigPath = `.oxlintrc.json`;
158
+ return existsSync(oxlintConfigPath);
159
+ }
154
160
  const prettierPaths = [
155
161
  // Everything inside these folders
156
162
  `./{${prettierDirs.join(',')}}/**/*.{${prettierExtensionsAll}}`,
@@ -224,24 +230,6 @@ export async function lintStagedCommand() {
224
230
  if (!success)
225
231
  process.exit(3);
226
232
  }
227
- export function runCommitlintCommand() {
228
- const editMsg = process.argv.at(-1) || '.git/COMMIT_EDITMSG';
229
- // console.log(editMsg)
230
- const cwd = process.cwd();
231
- const localConfig = `${cwd}/commitlint.config.js`;
232
- const sharedConfig = `${cfgDir}/commitlint.config.js`;
233
- const config = existsSync(localConfig) ? localConfig : sharedConfig;
234
- const env = {
235
- GIT_BRANCH: git2.getCurrentBranchName(),
236
- };
237
- const commitlintPath = findPackageBinPath('@commitlint/cli', 'commitlint');
238
- exec2.spawn(`${commitlintPath} --edit ${editMsg} --config ${config}`, {
239
- env,
240
- passProcessEnv: true, // important to pass it through, to preserve $PATH
241
- forceColor: false,
242
- log: false,
243
- });
244
- }
245
233
  async function runKTLint(fix = true) {
246
234
  if (!existsSync(`node_modules/@naturalcycles/ktlint`))
247
235
  return;
package/package.json CHANGED
@@ -1,11 +1,9 @@
1
1
  {
2
2
  "name": "@naturalcycles/dev-lib",
3
3
  "type": "module",
4
- "version": "20.16.1",
4
+ "version": "20.18.0",
5
5
  "dependencies": {
6
6
  "@biomejs/biome": "^2",
7
- "@commitlint/cli": "^20",
8
- "@commitlint/config-conventional": "^20",
9
7
  "@eslint/js": "^9",
10
8
  "@inquirer/prompts": "^8",
11
9
  "@naturalcycles/js-lib": "^15",