ts-repo-utils 10.2.0 → 10.3.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/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  [![npm downloads](https://img.shields.io/npm/dm/ts-repo-utils.svg)](https://www.npmjs.com/package/ts-repo-utils)
10
10
  [![License](https://img.shields.io/npm/l/ts-repo-utils.svg)](https://github.com/noshiro-pf/mono/blob/main/libs/ts-repo-utils/LICENSE)
11
- [![codecov](https://codecov.io/gh/noshiro-pf/mono/graph/badge.svg?token=S4688Q0CX3)](https://codecov.io/gh/noshiro-pf/mono)
11
+ [![codecov](https://codecov.io/gh/noshiro-pf/mono/graph/badge.svg?component=ts-repo-utils)](https://codecov.io/gh/noshiro-pf/mono)
12
12
 
13
13
  Utilities for TypeScript Repositories.
14
14
 
@@ -161,10 +161,56 @@ npm exec -- gen-index-ts ./src --target-ext .mts --index-ext .mts --export-ext .
161
161
  - `--min-depth` - Minimum depth to start generating index files (default: 0)
162
162
  - `--silent` - Suppress output messages (optional)
163
163
 
164
+ ### `check-should-run`
165
+
166
+ Checks whether a CI step should run, based on which files differ from the base branch. Reports `false` when every changed file matches one of the `--paths-ignore` patterns, so a step can be skipped when nothing it checks has changed.
167
+
168
+ Prefer this over a workflow-level `paths-ignore` trigger filter: a workflow that a path filter skips never reports a status check, so a pull request that changed only the filtered paths waits forever on a required check that never arrives. Gating the individual steps keeps the workflow running — and reporting — while doing no work.
169
+
170
+ ```bash
171
+ # Skip when the diff only touches a directory nothing checks
172
+ npm exec -- check-should-run --paths-ignore 'experimental/'
173
+
174
+ # Several patterns, and a custom base branch
175
+ npm exec -- check-should-run \
176
+ --paths-ignore 'experimental/' \
177
+ --paths-ignore 'docs/' \
178
+ --paths-ignore '**.md' \
179
+ --base-branch origin/develop
180
+ ```
181
+
182
+ ```yaml
183
+ # Example in GitHub Actions
184
+ - name: Check diff
185
+ id: check_diff
186
+ run: npm exec -- check-should-run --paths-ignore 'experimental/'
187
+
188
+ - name: Run the checks
189
+ if: steps.check_diff.outputs.should_run == 'true'
190
+ run: npm run check
191
+ ```
192
+
193
+ **Options:**
194
+
195
+ - `--paths-ignore` - Patterns whose files do not affect the gated step (optional, can be specified multiple times). Matched with [micromatch](https://github.com/micromatch/micromatch) (with `dot: true`) against paths relative to the repository root, with one shorthand: a pattern ending with `/` is a directory prefix.
196
+ - Exact file matches: `.editorconfig`
197
+ - Directory prefixes: `docs/` (the same as `docs/**`)
198
+ - Globs: `**.md`, `libs/*/samples/**`
199
+ - Default: `[]` — nothing is ignored, so the step always runs when anything changed
200
+ - `--base-branch` - Base branch to compare against for determining changed files (default: `origin/main`)
201
+
202
+ An empty diff reads as "nothing changed", so it reports `should_run=false`. On a push to the base branch itself the diff against that branch is empty for that reason; pass the commit the branch pointed at before the push as `--base-branch` there.
203
+
204
+ **GitHub Actions Integration:**
205
+
206
+ When running in GitHub Actions, the command appends `should_run=true` or `should_run=false` to the file named by the `GITHUB_OUTPUT` environment variable, which can be used in subsequent steps.
207
+
164
208
  ### `check-should-run-type-checks`
165
209
 
166
210
  Checks whether TypeScript type checks should run based on file changes from the base branch. Optimizes CI/CD pipelines by skipping type checks when only non-TypeScript files have changed. The determination of "non-TypeScript files" is based on configurable ignore patterns, which can be specified using the `--paths-ignore` option.
167
211
 
212
+ This is [`check-should-run`](#check-should-run) with a default ignore list of the files a TypeScript project typically has but never type-checks. For a step that is not a type check, use `check-should-run` and state its paths.
213
+
168
214
  ```bash
169
215
  # Basic usage (compares against origin/main)
170
216
  npm exec -- check-should-run-type-checks
@@ -193,11 +239,11 @@ npm exec -- check-should-run-type-checks \
193
239
 
194
240
  **Options:**
195
241
 
196
- - `--paths-ignore` - Patterns to ignore when checking if type checks should run (optional, can be specified multiple times)
242
+ - `--paths-ignore` - Patterns to ignore when checking if type checks should run (optional, can be specified multiple times). Matched as described for [`check-should-run`](#check-should-run); passing it replaces the default list rather than adding to it.
197
243
  - Supports exact file matches: `.cspell.config.yaml`
198
244
  - Directory prefixes: `docs/` (matches any file in docs directory)
199
245
  - File extensions: `**.md` (matches any markdown file)
200
- - Default: `['LICENSE', '.editorconfig', '.gitignore', '.cspell.config.yaml', '.markdownlint-cli2.mjs', '.npmignore', '.prettierignore', '.prettierrc', 'docs/', '**.md', '**.txt']`
246
+ - Default: `['LICENSE', '.editorconfig', '.gitignore', '.cspell.json', '.cspell.config.yaml', '.markdownlint-cli2.mjs', '.npmignore', '.prettierignore', '.prettierrc', 'docs/', '**.md', '**.txt']`
201
247
  - `--base-branch` - Base branch to compare against for determining changed files (default: `origin/main`)
202
248
 
203
249
  **GitHub Actions Integration:**
@@ -498,13 +544,27 @@ type Ret = Result<
498
544
 
499
545
  #### Build Optimization Utilities
500
546
 
547
+ ##### `checkShouldRun(options?): Promise<boolean>`
548
+
549
+ Checks whether a CI step should run, based on which files differ from the base branch. Reports `false` when every changed file matches one of the ignored patterns.
550
+ (Function version of the `check-should-run` command)
551
+
501
552
  ##### `checkShouldRunTypeChecks(options?): Promise<boolean>`
502
553
 
503
- Checks whether TypeScript type checks should run based on file changes from the base branch. Optimizes CI/CD pipelines by skipping type checks when only non-TypeScript files have changed.
554
+ `checkShouldRun` with a default ignore list of the files a TypeScript project typically has but never type-checks. Optimizes CI/CD pipelines by skipping type checks when only non-TypeScript files have changed.
504
555
  (Function version of the `check-should-run-type-checks` command)
505
556
 
506
557
  ```tsx
507
- import { $, checkShouldRunTypeChecks } from 'ts-repo-utils';
558
+ import { $, checkShouldRun, checkShouldRunTypeChecks } from 'ts-repo-utils';
559
+
560
+ // Gate any step on the paths it cares about
561
+ const shouldRunStyleChecks = await checkShouldRun({
562
+ pathsIgnore: ['experimental/'],
563
+ });
564
+
565
+ if (shouldRunStyleChecks) {
566
+ await $('npm run fmt:check');
567
+ }
508
568
 
509
569
  // Use default settings (compare against origin/main)
510
570
  const shouldRun = await checkShouldRunTypeChecks();
@@ -520,15 +580,17 @@ const shouldRun2 = await checkShouldRunTypeChecks({
520
580
  });
521
581
  ```
522
582
 
523
- **Options:**
583
+ **Options (both functions):**
524
584
 
525
- - `pathsIgnore?` - Patterns to ignore when checking if type checks should run:
585
+ - `pathsIgnore?` - Patterns whose files do not affect the gated step. Matched with [micromatch](https://github.com/micromatch/micromatch) (with `dot: true`) against paths relative to the repository root, with a trailing `/` as the shorthand for a directory:
526
586
  - Exact file matches: `.cspell.config.yaml`
527
- - Directory prefixes: `docs/` (matches any file in docs directory)
528
- - File extensions: `**.md` (matches any markdown file)
529
- - Default: `['LICENSE', '.editorconfig', '.gitignore', '.cspell.config.yaml', '.markdownlint-cli2.mjs', '.npmignore', '.prettierignore', '.prettierrc', 'docs/', '**.md', '**.txt']`
587
+ - Directory prefixes: `docs/` (the same as `docs/**`)
588
+ - Globs: `**.md` (matches any markdown file), `libs/*/samples/**`
589
+ - Default: `[]` for `checkShouldRun`; for `checkShouldRunTypeChecks`, `['LICENSE', '.editorconfig', '.gitignore', '.cspell.json', '.cspell.config.yaml', '.markdownlint-cli2.mjs', '.npmignore', '.prettierignore', '.prettierrc', 'docs/', '**.md', '**.txt']` — passing the option replaces that list rather than adding to it
530
590
  - `baseBranch?` - Base branch to compare against (default: `origin/main`)
531
591
 
592
+ Both return whether the step should run, and, in GitHub Actions, append `should_run=<result>` to the file named by `GITHUB_OUTPUT`. An empty diff reads as "nothing changed" and so returns `false`.
593
+
532
594
  ### Code Formatting Utilities
533
595
 
534
596
  #### `formatFilesGlob(pathGlob: string, options?): Promise<Result<undefined, unknown>>`
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=check-should-run.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"check-should-run.d.mts","sourceRoot":"","sources":["../../src/cmd/check-should-run.mts"],"names":[],"mappings":""}
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ import * as cmd from 'cmd-ts';
3
+ import { Arr } from 'ts-data-forge';
4
+ import 'node:path';
5
+ import 'node:fs/promises';
6
+ import 'fast-glob';
7
+ import 'node:child_process';
8
+ import 'prettier';
9
+ import 'micromatch';
10
+ import 'node:fs';
11
+ import 'node:url';
12
+ import { checkShouldRun } from '../functions/should-run.mjs';
13
+ import '@sindresorhus/is';
14
+
15
+ const cmdDef = cmd.command({
16
+ name: "check-should-run-cli",
17
+ version: "10.2.0",
18
+ args: {
19
+ pathsIgnore: cmd.multioption({
20
+ long: "paths-ignore",
21
+ type: cmd.optional(cmd.array(cmd.string)),
22
+ description: 'Patterns whose files do not affect the gated step. Matched with micromatch against repository-relative paths, with a trailing "/" as the shorthand for a directory (e.g. "experimental/", ".editorconfig", "**.md"). Defaults to ignoring nothing.'
23
+ }),
24
+ baseBranch: cmd.option({
25
+ long: "base-branch",
26
+ type: cmd.optional(cmd.string),
27
+ description: 'Base branch to compare against for determining changed files. Defaults to "origin/main"'
28
+ })
29
+ },
30
+ handler: (args) => {
31
+ main(args).catch((error) => {
32
+ console.error("An error occurred:", error);
33
+ process.exit(1);
34
+ });
35
+ }
36
+ });
37
+ const main = async (args) => {
38
+ await checkShouldRun({
39
+ pathsIgnore: args.pathsIgnore,
40
+ baseBranch: args.baseBranch
41
+ });
42
+ };
43
+ await cmd.run(cmdDef, Arr.skip(process.argv, 2));
44
+ //# sourceMappingURL=check-should-run.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"check-should-run.mjs","sources":["../../src/cmd/check-should-run.mts"],"sourcesContent":["#!/usr/bin/env node\n\nimport * as cmd from 'cmd-ts';\nimport { Arr } from 'ts-data-forge';\nimport { checkShouldRun } from '../functions/index.mjs';\n\nconst cmdDef = cmd.command({\n name: 'check-should-run-cli',\n version: '10.2.0',\n args: {\n pathsIgnore: cmd.multioption({\n long: 'paths-ignore',\n type: cmd.optional(cmd.array(cmd.string)),\n description:\n 'Patterns whose files do not affect the gated step. Matched with micromatch against repository-relative paths, with a trailing \"/\" as the shorthand for a directory (e.g. \"experimental/\", \".editorconfig\", \"**.md\"). Defaults to ignoring nothing.',\n }),\n baseBranch: cmd.option({\n long: 'base-branch',\n type: cmd.optional(cmd.string),\n description:\n 'Base branch to compare against for determining changed files. Defaults to \"origin/main\"',\n }),\n },\n handler: (args) => {\n main(args).catch((error: unknown) => {\n console.error('An error occurred:', error);\n\n process.exit(1);\n });\n },\n});\n\nconst main = async (\n args: Readonly<{\n pathsIgnore?: readonly string[];\n baseBranch?: string;\n }>,\n): Promise<void> => {\n await checkShouldRun({\n pathsIgnore: args.pathsIgnore,\n baseBranch: args.baseBranch,\n });\n};\n\nawait cmd.run(cmdDef, Arr.skip(process.argv, 2));\n"],"names":[],"mappings":";;;;;;;;;;;;;;AAMA,MAAM,MAAA,GAAS,IAAI,OAAA,CAAQ;AAAA,EACzB,IAAA,EAAM,sBAAA;AAAA,EACN,OAAA,EAAS,QAAA;AAAA,EACT,IAAA,EAAM;AAAA,IACJ,WAAA,EAAa,IAAI,WAAA,CAAY;AAAA,MAC3B,IAAA,EAAM,cAAA;AAAA,MACN,MAAM,GAAA,CAAI,QAAA,CAAS,IAAI,KAAA,CAAM,GAAA,CAAI,MAAM,CAAC,CAAA;AAAA,MACxC,WAAA,EACE;AAAA,KACH,CAAA;AAAA,IACD,UAAA,EAAY,IAAI,MAAA,CAAO;AAAA,MACrB,IAAA,EAAM,aAAA;AAAA,MACN,IAAA,EAAM,GAAA,CAAI,QAAA,CAAS,GAAA,CAAI,MAAM,CAAA;AAAA,MAC7B,WAAA,EACE;AAAA,KACH;AAAA,GACH;AAAA,EACA,OAAA,EAAS,CAAC,IAAA,KAAS;AACjB,IAAA,IAAA,CAAK,IAAI,CAAA,CAAE,KAAA,CAAM,CAAC,KAAA,KAAmB;AACnC,MAAA,OAAA,CAAQ,KAAA,CAAM,sBAAsB,KAAK,CAAA;AAEzC,MAAA,OAAA,CAAQ,KAAK,CAAC,CAAA;AAAA,IAChB,CAAC,CAAA;AAAA,EACH;AACF,CAAC,CAAA;AAED,MAAM,IAAA,GAAO,OACX,IAAA,KAIkB;AAClB,EAAA,MAAM,cAAA,CAAe;AAAA,IACnB,aAAa,IAAA,CAAK,WAAA;AAAA,IAClB,YAAY,IAAA,CAAK;AAAA,GAClB,CAAA;AACH,CAAA;AAEA,MAAM,GAAA,CAAI,IAAI,MAAA,EAAQ,GAAA,CAAI,KAAK,OAAA,CAAQ,IAAA,EAAM,CAAC,CAAC,CAAA"}
@@ -9,6 +9,7 @@ import 'prettier';
9
9
  import { genIndex } from '../functions/gen-index.mjs';
10
10
  import 'node:fs';
11
11
  import 'node:url';
12
+ import 'micromatch';
12
13
  import '@sindresorhus/is';
13
14
 
14
15
  const extensionType = cmd.extendType(cmd.string, {
@@ -1 +1 @@
1
- {"version":3,"file":"gen-index-ts.mjs","sources":["../../src/cmd/gen-index-ts.mts"],"sourcesContent":["#!/usr/bin/env node\n\n/* transformer-ignore convert-to-readonly */\n\nimport * as cmd from 'cmd-ts';\nimport { Arr, expectType } from 'ts-data-forge';\n// eslint-disable-next-line import-x/no-internal-modules, import-x/extensions\nimport { type InputOf, type OutputOf } from 'cmd-ts/dist/esm/from.js';\nimport { type NonEmptyArray } from 'ts-type-forge';\nimport { genIndex } from '../functions/index.mjs';\n\ntype Ext = `.${string}`;\n\nconst extensionType = cmd.extendType(cmd.string, {\n from: (s) => {\n if (!s.startsWith('.')) {\n throw new Error(`ext should start with '.'`);\n }\n\n // eslint-disable-next-line total-functions/no-unsafe-type-assertion\n return Promise.resolve(s as Ext);\n },\n});\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst nonEmptyArray = <T extends cmd.Type<any, any>>(\n t: T,\n commandName: string,\n): cmd.Type<readonly InputOf<T>[], NonEmptyArray<OutputOf<T>>> =>\n cmd.extendType(\n // eslint-disable-next-line total-functions/no-unsafe-type-assertion\n cmd.array(t) as cmd.Type<readonly InputOf<T>[], OutputOf<T>[]>,\n {\n from: (arr) => {\n if (Arr.isEmpty(arr)) {\n throw new Error(\n `No value provided for --${commandName}. At least one value is required.`,\n );\n }\n\n // eslint-disable-next-line total-functions/no-unsafe-type-assertion\n return Promise.resolve(arr as unknown as NonEmptyArray<OutputOf<T>>);\n },\n },\n );\n\nconst cmdDef = cmd.command({\n name: 'gen-index-ts-cli',\n version: '10.1.8',\n args: {\n // required args\n targetDirectory: cmd.positional({\n type: cmd.string,\n displayName: 'target-directory',\n description:\n 'Directory where the index file will be generated (Comma-separated list can be used)',\n }),\n\n targetExtensions: cmd.multioption({\n long: 'target-ext',\n type: nonEmptyArray(extensionType, 'target-ext'),\n description: 'File extensions to include in the index file',\n }),\n indexFileExtension: cmd.option({\n long: 'index-ext',\n type: extensionType,\n description: 'Extension of the index file to be generated',\n }),\n exportStatementExtension: cmd.option({\n long: 'export-ext',\n type: cmd.union([\n extensionType,\n cmd.extendType(cmd.string, {\n from: (s) => {\n if (s !== 'none') {\n throw new Error(\n `export-ext should be 'none' or a valid extension`,\n );\n }\n\n return Promise.resolve('none' as const);\n },\n }),\n ]),\n description: 'Extension of the export statements in the index file',\n }),\n\n // optional args\n exclude: cmd.multioption({\n long: 'exclude',\n type: cmd.optional(cmd.array(cmd.string)),\n description:\n 'Glob patterns of files to exclude from the index file (e.g., \"*.d.mts\", \"*.test.mts\")',\n }),\n formatCommand: cmd.option({\n long: 'fmt',\n type: cmd.optional(cmd.string),\n description:\n 'Command to format after generating the index file (e.g., \"npm run fmt\")',\n }),\n silent: cmd.flag({\n long: 'silent',\n type: cmd.optional(cmd.boolean),\n description: 'If true, suppresses output messages (default: false)',\n }),\n minDepth: cmd.option({\n long: 'min-depth',\n type: cmd.optional(cmd.number),\n description: 'Minimum depth to start generating index files (default: 0)',\n }),\n },\n handler: (args) => {\n expectType<typeof args.targetDirectory, string>('=');\n\n expectType<typeof args.targetExtensions, NonEmptyArray<Ext>>('=');\n\n expectType<typeof args.exportStatementExtension, Ext | 'none'>('=');\n\n expectType<typeof args.indexFileExtension, Ext>('=');\n\n expectType<typeof args.exclude, string[] | undefined>('=');\n\n expectType<typeof args.formatCommand, string | undefined>('=');\n\n expectType<typeof args.silent, boolean | undefined>('=');\n\n expectType<typeof args.minDepth, number | undefined>('=');\n\n main(args).catch((error: unknown) => {\n console.error('An error occurred:', error);\n\n process.exit(1);\n });\n },\n});\n\nconst main = async ({\n targetDirectory,\n targetExtensions,\n exportStatementExtension,\n indexFileExtension,\n exclude,\n formatCommand,\n silent,\n minDepth,\n}: Readonly<{\n targetDirectory: string;\n targetExtensions: readonly Ext[];\n exportStatementExtension: Ext | 'none';\n indexFileExtension: Ext;\n exclude?: readonly string[] | undefined;\n formatCommand?: string | undefined;\n silent?: boolean | undefined;\n minDepth?: number | undefined;\n}>): Promise<void> => {\n await genIndex({\n targetDirectory: targetDirectory.includes(',')\n ? targetDirectory.split(',').map((dir) => dir.trim())\n : targetDirectory,\n exportStatementExtension,\n targetExtensions,\n exclude,\n indexFileExtension,\n formatCommand,\n silent,\n minDepth,\n });\n};\n\nawait cmd.run(cmdDef, Arr.skip(process.argv, 2));\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"gen-index-ts.mjs","sources":["../../src/cmd/gen-index-ts.mts"],"sourcesContent":["#!/usr/bin/env node\n\n/* transformer-ignore convert-to-readonly */\n\nimport * as cmd from 'cmd-ts';\nimport { Arr, expectType } from 'ts-data-forge';\n// eslint-disable-next-line import-x/no-internal-modules, import-x/extensions\nimport { type InputOf, type OutputOf } from 'cmd-ts/dist/esm/from.js';\nimport { type NonEmptyArray } from 'ts-type-forge';\nimport { genIndex } from '../functions/index.mjs';\n\ntype Ext = `.${string}`;\n\nconst extensionType = cmd.extendType(cmd.string, {\n from: (s) => {\n if (!s.startsWith('.')) {\n throw new Error(`ext should start with '.'`);\n }\n\n // eslint-disable-next-line total-functions/no-unsafe-type-assertion\n return Promise.resolve(s as Ext);\n },\n});\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst nonEmptyArray = <T extends cmd.Type<any, any>>(\n t: T,\n commandName: string,\n): cmd.Type<readonly InputOf<T>[], NonEmptyArray<OutputOf<T>>> =>\n cmd.extendType(\n // eslint-disable-next-line total-functions/no-unsafe-type-assertion\n cmd.array(t) as cmd.Type<readonly InputOf<T>[], OutputOf<T>[]>,\n {\n from: (arr) => {\n if (Arr.isEmpty(arr)) {\n throw new Error(\n `No value provided for --${commandName}. At least one value is required.`,\n );\n }\n\n // eslint-disable-next-line total-functions/no-unsafe-type-assertion\n return Promise.resolve(arr as unknown as NonEmptyArray<OutputOf<T>>);\n },\n },\n );\n\nconst cmdDef = cmd.command({\n name: 'gen-index-ts-cli',\n version: '10.1.8',\n args: {\n // required args\n targetDirectory: cmd.positional({\n type: cmd.string,\n displayName: 'target-directory',\n description:\n 'Directory where the index file will be generated (Comma-separated list can be used)',\n }),\n\n targetExtensions: cmd.multioption({\n long: 'target-ext',\n type: nonEmptyArray(extensionType, 'target-ext'),\n description: 'File extensions to include in the index file',\n }),\n indexFileExtension: cmd.option({\n long: 'index-ext',\n type: extensionType,\n description: 'Extension of the index file to be generated',\n }),\n exportStatementExtension: cmd.option({\n long: 'export-ext',\n type: cmd.union([\n extensionType,\n cmd.extendType(cmd.string, {\n from: (s) => {\n if (s !== 'none') {\n throw new Error(\n `export-ext should be 'none' or a valid extension`,\n );\n }\n\n return Promise.resolve('none' as const);\n },\n }),\n ]),\n description: 'Extension of the export statements in the index file',\n }),\n\n // optional args\n exclude: cmd.multioption({\n long: 'exclude',\n type: cmd.optional(cmd.array(cmd.string)),\n description:\n 'Glob patterns of files to exclude from the index file (e.g., \"*.d.mts\", \"*.test.mts\")',\n }),\n formatCommand: cmd.option({\n long: 'fmt',\n type: cmd.optional(cmd.string),\n description:\n 'Command to format after generating the index file (e.g., \"npm run fmt\")',\n }),\n silent: cmd.flag({\n long: 'silent',\n type: cmd.optional(cmd.boolean),\n description: 'If true, suppresses output messages (default: false)',\n }),\n minDepth: cmd.option({\n long: 'min-depth',\n type: cmd.optional(cmd.number),\n description: 'Minimum depth to start generating index files (default: 0)',\n }),\n },\n handler: (args) => {\n expectType<typeof args.targetDirectory, string>('=');\n\n expectType<typeof args.targetExtensions, NonEmptyArray<Ext>>('=');\n\n expectType<typeof args.exportStatementExtension, Ext | 'none'>('=');\n\n expectType<typeof args.indexFileExtension, Ext>('=');\n\n expectType<typeof args.exclude, string[] | undefined>('=');\n\n expectType<typeof args.formatCommand, string | undefined>('=');\n\n expectType<typeof args.silent, boolean | undefined>('=');\n\n expectType<typeof args.minDepth, number | undefined>('=');\n\n main(args).catch((error: unknown) => {\n console.error('An error occurred:', error);\n\n process.exit(1);\n });\n },\n});\n\nconst main = async ({\n targetDirectory,\n targetExtensions,\n exportStatementExtension,\n indexFileExtension,\n exclude,\n formatCommand,\n silent,\n minDepth,\n}: Readonly<{\n targetDirectory: string;\n targetExtensions: readonly Ext[];\n exportStatementExtension: Ext | 'none';\n indexFileExtension: Ext;\n exclude?: readonly string[] | undefined;\n formatCommand?: string | undefined;\n silent?: boolean | undefined;\n minDepth?: number | undefined;\n}>): Promise<void> => {\n await genIndex({\n targetDirectory: targetDirectory.includes(',')\n ? targetDirectory.split(',').map((dir) => dir.trim())\n : targetDirectory,\n exportStatementExtension,\n targetExtensions,\n exclude,\n indexFileExtension,\n formatCommand,\n silent,\n minDepth,\n });\n};\n\nawait cmd.run(cmdDef, Arr.skip(process.argv, 2));\n"],"names":[],"mappings":"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"}
@@ -10,7 +10,7 @@ export { genIndex } from './functions/gen-index.mjs';
10
10
  export { glob } from './functions/glob.mjs';
11
11
  export { isDirectlyExecuted } from './functions/is-directly-executed.mjs';
12
12
  export { makeEmptyDir } from './functions/make-empty-dir.mjs';
13
- export { checkShouldRunTypeChecks } from './functions/should-run.mjs';
13
+ export { checkShouldRun, checkShouldRunTypeChecks } from './functions/should-run.mjs';
14
14
  export { executeParallel, executeStages } from './functions/workspace-utils/execute-parallel.mjs';
15
15
  export { getWorkspacePackages } from './functions/workspace-utils/get-workspace-packages.mjs';
16
16
  export { runCmdInParallelAcrossWorkspaces } from './functions/workspace-utils/run-cmd-in-parallel.mjs';
@@ -9,7 +9,7 @@ export { genIndex } from './gen-index.mjs';
9
9
  export { glob } from './glob.mjs';
10
10
  export { isDirectlyExecuted } from './is-directly-executed.mjs';
11
11
  export { makeEmptyDir } from './make-empty-dir.mjs';
12
- export { checkShouldRunTypeChecks } from './should-run.mjs';
12
+ export { checkShouldRun, checkShouldRunTypeChecks } from './should-run.mjs';
13
13
  export { executeParallel, executeStages } from './workspace-utils/execute-parallel.mjs';
14
14
  export { getWorkspacePackages } from './workspace-utils/get-workspace-packages.mjs';
15
15
  export { runCmdInParallelAcrossWorkspaces } from './workspace-utils/run-cmd-in-parallel.mjs';
@@ -1,7 +1,68 @@
1
+ /**
2
+ * Checks whether a CI step should run, based on which files differ from a base
3
+ * branch. Reports `false` when every changed file matches one of the ignored
4
+ * patterns.
5
+ *
6
+ * This is the general form of {@link checkShouldRunTypeChecks}: it carries no
7
+ * default ignore list, so the caller states exactly which paths are irrelevant
8
+ * to the step being gated.
9
+ *
10
+ * Prefer gating the individual steps of a workflow with this over a
11
+ * `paths-ignore` trigger filter: a workflow that a path filter skips never
12
+ * reports a status check, so a pull request that changed only the filtered
13
+ * paths waits forever on a required check that will never arrive.
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * // Skip when the diff only touches a directory nothing checks
18
+ * await checkShouldRun({ pathsIgnore: ['experimental/'] });
19
+ *
20
+ * // Custom base branch
21
+ * await checkShouldRun({
22
+ * pathsIgnore: ['docs/', '**.md'],
23
+ * baseBranch: 'origin/develop',
24
+ * });
25
+ * ```;
26
+ *
27
+ * @example
28
+ * GitHub Actions usage
29
+ * ```yaml
30
+ * - name: Check diff
31
+ * id: check_diff
32
+ * run: npm exec -- check-should-run --paths-ignore 'experimental/'
33
+ *
34
+ * - name: Run the checks
35
+ * if: steps.check_diff.outputs.should_run == 'true'
36
+ * run: npm run check
37
+ * ```
38
+ *
39
+ * @param options - Configuration options
40
+ * @param options.pathsIgnore - Patterns whose files do not affect the step.
41
+ * Matched with micromatch (with `dot: true`) against paths relative to the
42
+ * repository root, with one shorthand: a pattern ending with `/` is a
43
+ * directory prefix, so `docs/` is `docs/**`. Exact file names
44
+ * (`.editorconfig`) and ordinary globs (`**.md`, `docs/**`) both work.
45
+ * Defaults to `[]`, which ignores nothing.
46
+ * @param options.baseBranch - Base branch to compare against for determining
47
+ * changed files. Defaults to `'origin/main'`
48
+ * @returns Whether the step should run. An empty diff reads as "nothing
49
+ * changed" and so returns `false`. When running in GitHub Actions, also
50
+ * appends `should_run=true` or `should_run=false` to the file named by the
51
+ * `GITHUB_OUTPUT` environment variable.
52
+ */
53
+ export declare const checkShouldRun: (options?: Readonly<{
54
+ pathsIgnore?: readonly string[];
55
+ baseBranch?: string;
56
+ }>) => Promise<boolean>;
1
57
  /**
2
58
  * Checks if TypeScript type checks should run based on the diff from a base
3
59
  * branch. Skips type checks if all changed files match the ignored patterns.
4
60
  *
61
+ * This is {@link checkShouldRun} with an ignore list of the files that a
62
+ * TypeScript project typically has but never type-checks — documentation,
63
+ * tooling configuration, license text. Pass `pathsIgnore` to replace that list,
64
+ * or call {@link checkShouldRun} directly for a step that is not a type check.
65
+ *
5
66
  * This function is typically used in CI/CD pipelines to determine whether
6
67
  * expensive type checking operations should be performed. If all changed files
7
68
  * are documentation, configuration, or other non-TypeScript files, type checks
@@ -37,7 +98,7 @@
37
98
  *
38
99
  * @param options - Configuration options
39
100
  * @param options.pathsIgnore - Array of patterns to ignore when determining if
40
- * type checks should run. Supports three pattern types:
101
+ * type checks should run. Matched as described in {@link checkShouldRun}:
41
102
  *
42
103
  * - **Exact file matches**: e.g., `.cspell.config.yaml` matches only that file
43
104
  * - **Directory prefixes**: e.g., `docs/` matches any file in docs directory
@@ -1 +1 @@
1
- {"version":3,"file":"should-run.d.mts","sourceRoot":"","sources":["../../src/functions/should-run.mts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,eAAO,MAAM,wBAAwB,aACzB,QAAQ,CAAC;IACjB,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC,KACD,OAAO,CAAC,OAAO,CAyDjB,CAAC"}
1
+ {"version":3,"file":"should-run.d.mts","sourceRoot":"","sources":["../../src/functions/should-run.mts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,eAAO,MAAM,cAAc,aACf,QAAQ,CAAC;IACjB,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC,KACD,OAAO,CAAC,OAAO,CAyCjB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0DG;AACH,eAAO,MAAM,wBAAwB,aACzB,QAAQ,CAAC;IACjB,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC,KACD,OAAO,CAAC,OAAO,CAId,CAAC"}
@@ -1,51 +1,55 @@
1
+ import micromatch from 'micromatch';
1
2
  import * as fs from 'node:fs/promises';
2
3
  import * as path from 'node:path';
3
- import { Result } from 'ts-data-forge';
4
- import { getDiffFrom } from './diff.mjs';
4
+ import { Result, castMutable } from 'ts-data-forge';
5
+ import { getGitRoot, getDiffFrom } from './diff.mjs';
5
6
 
6
- const checkShouldRunTypeChecks = async (options) => {
7
- const pathsIgnore = options?.pathsIgnore ?? [
8
- "LICENSE",
9
- ".editorconfig",
10
- ".gitignore",
11
- ".cspell.json",
12
- ".cspell.config.yaml",
13
- ".markdownlint-cli2.mjs",
14
- ".npmignore",
15
- ".prettierignore",
16
- ".prettierrc",
17
- "docs/",
18
- "**.md",
19
- "**.txt"
20
- ];
7
+ const checkShouldRun = async (options) => {
8
+ const pathsIgnore = options?.pathsIgnore ?? [];
21
9
  const baseBranch = options?.baseBranch ?? "origin/main";
22
10
  const GITHUB_OUTPUT = process.env["GITHUB_OUTPUT"];
11
+ const gitRoot = await getGitRoot();
12
+ if (Result.isErr(gitRoot)) {
13
+ console.error("Error getting the git root:", gitRoot.value);
14
+ process.exit(1);
15
+ }
23
16
  const files = await getDiffFrom(baseBranch);
24
17
  if (Result.isErr(files)) {
25
18
  console.error("Error getting diff:", files.value);
26
19
  process.exit(1);
27
20
  }
28
- const shouldRunTsChecks = !files.value.every(
29
- (file) => pathsIgnore.some((pattern) => {
30
- if (pattern === file) {
31
- return true;
32
- }
33
- if (pattern.endsWith("/") && file.startsWith(pattern)) {
34
- return true;
35
- }
36
- if (pattern.startsWith("**.")) {
37
- const extension = pattern.slice(2);
38
- return path.basename(file).endsWith(extension);
39
- }
40
- return false;
41
- })
21
+ const changedFiles = files.value.map(
22
+ (file) => path.relative(gitRoot.value, file).split(path.sep).join("/")
23
+ );
24
+ const patterns = pathsIgnore.map(toMatchPattern);
25
+ const shouldRun = changedFiles.some(
26
+ (file) => !micromatch.isMatch(file, castMutable(patterns), { dot: true })
42
27
  );
43
28
  if (GITHUB_OUTPUT !== void 0) {
44
- await fs.appendFile(GITHUB_OUTPUT, `should_run=${shouldRunTsChecks}
29
+ await fs.appendFile(GITHUB_OUTPUT, `should_run=${shouldRun}
45
30
  `);
46
31
  }
47
- return shouldRunTsChecks;
32
+ return shouldRun;
48
33
  };
34
+ const checkShouldRunTypeChecks = async (options) => checkShouldRun({
35
+ pathsIgnore: options?.pathsIgnore ?? defaultTypeCheckPathsIgnore,
36
+ baseBranch: options?.baseBranch
37
+ });
38
+ const defaultTypeCheckPathsIgnore = [
39
+ "LICENSE",
40
+ ".editorconfig",
41
+ ".gitignore",
42
+ ".cspell.json",
43
+ ".cspell.config.yaml",
44
+ ".markdownlint-cli2.mjs",
45
+ ".npmignore",
46
+ ".prettierignore",
47
+ ".prettierrc",
48
+ "docs/",
49
+ "**.md",
50
+ "**.txt"
51
+ ];
52
+ const toMatchPattern = (pattern) => pattern.endsWith("/") ? `${pattern}**` : pattern;
49
53
 
50
- export { checkShouldRunTypeChecks };
54
+ export { checkShouldRun, checkShouldRunTypeChecks };
51
55
  //# sourceMappingURL=should-run.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"should-run.mjs","sources":["../../src/functions/should-run.mts"],"sourcesContent":["import * as fs from 'node:fs/promises';\nimport * as path from 'node:path';\nimport { Result } from 'ts-data-forge';\nimport { getDiffFrom } from './diff.mjs';\n\n/**\n * Checks if TypeScript type checks should run based on the diff from a base\n * branch. Skips type checks if all changed files match the ignored patterns.\n *\n * This function is typically used in CI/CD pipelines to determine whether\n * expensive type checking operations should be performed. If all changed files\n * are documentation, configuration, or other non-TypeScript files, type checks\n * can be safely skipped to improve build performance.\n *\n * @example\n * ```typescript\n * // Use default settings (compare against origin/main, ignore docs/md/txt files)\n * await checkShouldRunTypeChecks();\n *\n * // Custom ignore patterns\n * await checkShouldRunTypeChecks({\n * pathsIgnore: ['.eslintrc.json', 'docs/', '**.md', 'scripts/'],\n * });\n *\n * // Custom base branch\n * await checkShouldRunTypeChecks({\n * baseBranch: 'origin/develop',\n * });\n * ```;\n *\n * @example\n * GitHub Actions usage\n * ```yaml\n * - name: Check if type checks should run\n * id: check_diff\n * run: npm exec check-should-run-type-checks\n *\n * - name: Run type checks\n * if: steps.check_diff.outputs.should_run == 'true'\n * run: npm run type-check\n * ```\n *\n * @param options - Configuration options\n * @param options.pathsIgnore - Array of patterns to ignore when determining if\n * type checks should run. Supports three pattern types:\n *\n * - **Exact file matches**: e.g., `.cspell.config.yaml` matches only that file\n * - **Directory prefixes**: e.g., `docs/` matches any file in docs directory\n * - **File extensions**: e.g., `**.md` matches any markdown file Defaults to:\n * `['LICENSE', '.editorconfig', '.gitignore', '.cspell.json', '.cspell.config.yaml',\n * '.markdownlint-cli2.mjs', '.npmignore', '.prettierignore',\n * '.prettierrc', 'docs/', '**.md', '**.txt']`\n *\n * @param options.baseBranch - Base branch to compare against for determining\n * changed files. Defaults to `'origin/main'`\n * @returns A promise that resolves when the check is complete. The function\n * will set the GITHUB_OUTPUT environment variable with `should_run=true` or\n * `should_run=false` if running in GitHub Actions environment.\n */\nexport const checkShouldRunTypeChecks = async (\n options?: Readonly<{\n pathsIgnore?: readonly string[];\n baseBranch?: string;\n }>,\n): Promise<boolean> => {\n const pathsIgnore = options?.pathsIgnore ?? [\n 'LICENSE',\n '.editorconfig',\n '.gitignore',\n '.cspell.json',\n '.cspell.config.yaml',\n '.markdownlint-cli2.mjs',\n '.npmignore',\n '.prettierignore',\n '.prettierrc',\n 'docs/',\n '**.md',\n '**.txt',\n ];\n\n const baseBranch = options?.baseBranch ?? 'origin/main';\n\n const GITHUB_OUTPUT = process.env['GITHUB_OUTPUT'];\n\n const files = await getDiffFrom(baseBranch);\n\n if (Result.isErr(files)) {\n console.error('Error getting diff:', files.value);\n\n process.exit(1);\n }\n\n const shouldRunTsChecks: boolean = !files.value.every((file) =>\n pathsIgnore.some((pattern) => {\n // Exact file match\n if (pattern === file) {\n return true;\n }\n\n // Directory prefix match (pattern ends with '/')\n if (pattern.endsWith('/') && file.startsWith(pattern)) {\n return true;\n }\n\n // File extension pattern match (pattern starts with '**.')\n if (pattern.startsWith('**.')) {\n const extension = pattern.slice(2); // Remove '**'\n\n return path.basename(file).endsWith(extension);\n }\n\n return false;\n }),\n );\n\n if (GITHUB_OUTPUT !== undefined) {\n // eslint-disable-next-line security/detect-non-literal-fs-filename\n await fs.appendFile(GITHUB_OUTPUT, `should_run=${shouldRunTsChecks}\\n`);\n }\n\n return shouldRunTsChecks;\n};\n"],"names":[],"mappings":";;;;;AA2DO,MAAM,wBAAA,GAA2B,OACtC,OAAA,KAIqB;AACrB,EAAA,MAAM,WAAA,GAAc,SAAS,WAAA,IAAe;AAAA,IAC1C,SAAA;AAAA,IACA,eAAA;AAAA,IACA,YAAA;AAAA,IACA,cAAA;AAAA,IACA,qBAAA;AAAA,IACA,wBAAA;AAAA,IACA,YAAA;AAAA,IACA,iBAAA;AAAA,IACA,aAAA;AAAA,IACA,OAAA;AAAA,IACA,OAAA;AAAA,IACA;AAAA,GACF;AAEA,EAAA,MAAM,UAAA,GAAa,SAAS,UAAA,IAAc,aAAA;AAE1C,EAAA,MAAM,aAAA,GAAgB,OAAA,CAAQ,GAAA,CAAI,eAAe,CAAA;AAEjD,EAAA,MAAM,KAAA,GAAQ,MAAM,WAAA,CAAY,UAAU,CAAA;AAE1C,EAAA,IAAI,MAAA,CAAO,KAAA,CAAM,KAAK,CAAA,EAAG;AACvB,IAAA,OAAA,CAAQ,KAAA,CAAM,qBAAA,EAAuB,KAAA,CAAM,KAAK,CAAA;AAEhD,IAAA,OAAA,CAAQ,KAAK,CAAC,CAAA;AAAA,EAChB;AAEA,EAAA,MAAM,iBAAA,GAA6B,CAAC,KAAA,CAAM,KAAA,CAAM,KAAA;AAAA,IAAM,CAAC,IAAA,KACrD,WAAA,CAAY,IAAA,CAAK,CAAC,OAAA,KAAY;AAE5B,MAAA,IAAI,YAAY,IAAA,EAAM;AACpB,QAAA,OAAO,IAAA;AAAA,MACT;AAGA,MAAA,IAAI,QAAQ,QAAA,CAAS,GAAG,KAAK,IAAA,CAAK,UAAA,CAAW,OAAO,CAAA,EAAG;AACrD,QAAA,OAAO,IAAA;AAAA,MACT;AAGA,MAAA,IAAI,OAAA,CAAQ,UAAA,CAAW,KAAK,CAAA,EAAG;AAC7B,QAAA,MAAM,SAAA,GAAY,OAAA,CAAQ,KAAA,CAAM,CAAC,CAAA;AAEjC,QAAA,OAAO,IAAA,CAAK,QAAA,CAAS,IAAI,CAAA,CAAE,SAAS,SAAS,CAAA;AAAA,MAC/C;AAEA,MAAA,OAAO,KAAA;AAAA,IACT,CAAC;AAAA,GACH;AAEA,EAAA,IAAI,kBAAkB,MAAA,EAAW;AAE/B,IAAA,MAAM,EAAA,CAAG,UAAA,CAAW,aAAA,EAAe,CAAA,WAAA,EAAc,iBAAiB;AAAA,CAAI,CAAA;AAAA,EACxE;AAEA,EAAA,OAAO,iBAAA;AACT;;;;"}
1
+ {"version":3,"file":"should-run.mjs","sources":["../../src/functions/should-run.mts"],"sourcesContent":["import micromatch from 'micromatch';\nimport * as fs from 'node:fs/promises';\nimport * as path from 'node:path';\nimport { castMutable, Result } from 'ts-data-forge';\nimport { getDiffFrom, getGitRoot } from './diff.mjs';\n\n/**\n * Checks whether a CI step should run, based on which files differ from a base\n * branch. Reports `false` when every changed file matches one of the ignored\n * patterns.\n *\n * This is the general form of {@link checkShouldRunTypeChecks}: it carries no\n * default ignore list, so the caller states exactly which paths are irrelevant\n * to the step being gated.\n *\n * Prefer gating the individual steps of a workflow with this over a\n * `paths-ignore` trigger filter: a workflow that a path filter skips never\n * reports a status check, so a pull request that changed only the filtered\n * paths waits forever on a required check that will never arrive.\n *\n * @example\n * ```typescript\n * // Skip when the diff only touches a directory nothing checks\n * await checkShouldRun({ pathsIgnore: ['experimental/'] });\n *\n * // Custom base branch\n * await checkShouldRun({\n * pathsIgnore: ['docs/', '**.md'],\n * baseBranch: 'origin/develop',\n * });\n * ```;\n *\n * @example\n * GitHub Actions usage\n * ```yaml\n * - name: Check diff\n * id: check_diff\n * run: npm exec -- check-should-run --paths-ignore 'experimental/'\n *\n * - name: Run the checks\n * if: steps.check_diff.outputs.should_run == 'true'\n * run: npm run check\n * ```\n *\n * @param options - Configuration options\n * @param options.pathsIgnore - Patterns whose files do not affect the step.\n * Matched with micromatch (with `dot: true`) against paths relative to the\n * repository root, with one shorthand: a pattern ending with `/` is a\n * directory prefix, so `docs/` is `docs/**`. Exact file names\n * (`.editorconfig`) and ordinary globs (`**.md`, `docs/**`) both work.\n * Defaults to `[]`, which ignores nothing.\n * @param options.baseBranch - Base branch to compare against for determining\n * changed files. Defaults to `'origin/main'`\n * @returns Whether the step should run. An empty diff reads as \"nothing\n * changed\" and so returns `false`. When running in GitHub Actions, also\n * appends `should_run=true` or `should_run=false` to the file named by the\n * `GITHUB_OUTPUT` environment variable.\n */\nexport const checkShouldRun = async (\n options?: Readonly<{\n pathsIgnore?: readonly string[];\n baseBranch?: string;\n }>,\n): Promise<boolean> => {\n const pathsIgnore = options?.pathsIgnore ?? [];\n\n const baseBranch = options?.baseBranch ?? 'origin/main';\n\n const GITHUB_OUTPUT = process.env['GITHUB_OUTPUT'];\n\n const gitRoot = await getGitRoot();\n\n if (Result.isErr(gitRoot)) {\n console.error('Error getting the git root:', gitRoot.value);\n\n process.exit(1);\n }\n\n const files = await getDiffFrom(baseBranch);\n\n if (Result.isErr(files)) {\n console.error('Error getting diff:', files.value);\n\n process.exit(1);\n }\n\n // `getDiffFrom` returns absolute paths, while the patterns are written\n // relative to the repository root.\n const changedFiles = files.value.map((file) =>\n path.relative(gitRoot.value, file).split(path.sep).join('/'),\n );\n\n const patterns = pathsIgnore.map(toMatchPattern);\n\n const shouldRun: boolean = changedFiles.some(\n (file) => !micromatch.isMatch(file, castMutable(patterns), { dot: true }),\n );\n\n if (GITHUB_OUTPUT !== undefined) {\n // eslint-disable-next-line security/detect-non-literal-fs-filename\n await fs.appendFile(GITHUB_OUTPUT, `should_run=${shouldRun}\\n`);\n }\n\n return shouldRun;\n};\n\n/**\n * Checks if TypeScript type checks should run based on the diff from a base\n * branch. Skips type checks if all changed files match the ignored patterns.\n *\n * This is {@link checkShouldRun} with an ignore list of the files that a\n * TypeScript project typically has but never type-checks — documentation,\n * tooling configuration, license text. Pass `pathsIgnore` to replace that list,\n * or call {@link checkShouldRun} directly for a step that is not a type check.\n *\n * This function is typically used in CI/CD pipelines to determine whether\n * expensive type checking operations should be performed. If all changed files\n * are documentation, configuration, or other non-TypeScript files, type checks\n * can be safely skipped to improve build performance.\n *\n * @example\n * ```typescript\n * // Use default settings (compare against origin/main, ignore docs/md/txt files)\n * await checkShouldRunTypeChecks();\n *\n * // Custom ignore patterns\n * await checkShouldRunTypeChecks({\n * pathsIgnore: ['.eslintrc.json', 'docs/', '**.md', 'scripts/'],\n * });\n *\n * // Custom base branch\n * await checkShouldRunTypeChecks({\n * baseBranch: 'origin/develop',\n * });\n * ```;\n *\n * @example\n * GitHub Actions usage\n * ```yaml\n * - name: Check if type checks should run\n * id: check_diff\n * run: npm exec check-should-run-type-checks\n *\n * - name: Run type checks\n * if: steps.check_diff.outputs.should_run == 'true'\n * run: npm run type-check\n * ```\n *\n * @param options - Configuration options\n * @param options.pathsIgnore - Array of patterns to ignore when determining if\n * type checks should run. Matched as described in {@link checkShouldRun}:\n *\n * - **Exact file matches**: e.g., `.cspell.config.yaml` matches only that file\n * - **Directory prefixes**: e.g., `docs/` matches any file in docs directory\n * - **File extensions**: e.g., `**.md` matches any markdown file Defaults to:\n * `['LICENSE', '.editorconfig', '.gitignore', '.cspell.json', '.cspell.config.yaml',\n * '.markdownlint-cli2.mjs', '.npmignore', '.prettierignore',\n * '.prettierrc', 'docs/', '**.md', '**.txt']`\n *\n * @param options.baseBranch - Base branch to compare against for determining\n * changed files. Defaults to `'origin/main'`\n * @returns A promise that resolves when the check is complete. The function\n * will set the GITHUB_OUTPUT environment variable with `should_run=true` or\n * `should_run=false` if running in GitHub Actions environment.\n */\nexport const checkShouldRunTypeChecks = async (\n options?: Readonly<{\n pathsIgnore?: readonly string[];\n baseBranch?: string;\n }>,\n): Promise<boolean> =>\n checkShouldRun({\n pathsIgnore: options?.pathsIgnore ?? defaultTypeCheckPathsIgnore,\n baseBranch: options?.baseBranch,\n });\n\nconst defaultTypeCheckPathsIgnore = [\n 'LICENSE',\n '.editorconfig',\n '.gitignore',\n '.cspell.json',\n '.cspell.config.yaml',\n '.markdownlint-cli2.mjs',\n '.npmignore',\n '.prettierignore',\n '.prettierrc',\n 'docs/',\n '**.md',\n '**.txt',\n] as const;\n\n/**\n * Expands the trailing-slash shorthand for a directory into the glob\n * micromatch needs. Every other pattern is already one.\n */\nconst toMatchPattern = (pattern: string): string =>\n pattern.endsWith('/') ? (`${pattern}**` as const) : pattern;\n"],"names":[],"mappings":";;;;;;AA0DO,MAAM,cAAA,GAAiB,OAC5B,OAAA,KAIqB;AACrB,EAAA,MAAM,WAAA,GAAc,OAAA,EAAS,WAAA,IAAe,EAAC;AAE7C,EAAA,MAAM,UAAA,GAAa,SAAS,UAAA,IAAc,aAAA;AAE1C,EAAA,MAAM,aAAA,GAAgB,OAAA,CAAQ,GAAA,CAAI,eAAe,CAAA;AAEjD,EAAA,MAAM,OAAA,GAAU,MAAM,UAAA,EAAW;AAEjC,EAAA,IAAI,MAAA,CAAO,KAAA,CAAM,OAAO,CAAA,EAAG;AACzB,IAAA,OAAA,CAAQ,KAAA,CAAM,6BAAA,EAA+B,OAAA,CAAQ,KAAK,CAAA;AAE1D,IAAA,OAAA,CAAQ,KAAK,CAAC,CAAA;AAAA,EAChB;AAEA,EAAA,MAAM,KAAA,GAAQ,MAAM,WAAA,CAAY,UAAU,CAAA;AAE1C,EAAA,IAAI,MAAA,CAAO,KAAA,CAAM,KAAK,CAAA,EAAG;AACvB,IAAA,OAAA,CAAQ,KAAA,CAAM,qBAAA,EAAuB,KAAA,CAAM,KAAK,CAAA;AAEhD,IAAA,OAAA,CAAQ,KAAK,CAAC,CAAA;AAAA,EAChB;AAIA,EAAA,MAAM,YAAA,GAAe,MAAM,KAAA,CAAM,GAAA;AAAA,IAAI,CAAC,IAAA,KACpC,IAAA,CAAK,QAAA,CAAS,OAAA,CAAQ,KAAA,EAAO,IAAI,CAAA,CAAE,KAAA,CAAM,IAAA,CAAK,GAAG,CAAA,CAAE,KAAK,GAAG;AAAA,GAC7D;AAEA,EAAA,MAAM,QAAA,GAAW,WAAA,CAAY,GAAA,CAAI,cAAc,CAAA;AAE/C,EAAA,MAAM,YAAqB,YAAA,CAAa,IAAA;AAAA,IACtC,CAAC,IAAA,KAAS,CAAC,UAAA,CAAW,OAAA,CAAQ,IAAA,EAAM,WAAA,CAAY,QAAQ,CAAA,EAAG,EAAE,GAAA,EAAK,IAAA,EAAM;AAAA,GAC1E;AAEA,EAAA,IAAI,kBAAkB,MAAA,EAAW;AAE/B,IAAA,MAAM,EAAA,CAAG,UAAA,CAAW,aAAA,EAAe,CAAA,WAAA,EAAc,SAAS;AAAA,CAAI,CAAA;AAAA,EAChE;AAEA,EAAA,OAAO,SAAA;AACT;AA6DO,MAAM,wBAAA,GAA2B,OACtC,OAAA,KAKA,cAAA,CAAe;AAAA,EACb,WAAA,EAAa,SAAS,WAAA,IAAe,2BAAA;AAAA,EACrC,YAAY,OAAA,EAAS;AACvB,CAAC;AAEH,MAAM,2BAAA,GAA8B;AAAA,EAClC,SAAA;AAAA,EACA,eAAA;AAAA,EACA,YAAA;AAAA,EACA,cAAA;AAAA,EACA,qBAAA;AAAA,EACA,wBAAA;AAAA,EACA,YAAA;AAAA,EACA,iBAAA;AAAA,EACA,aAAA;AAAA,EACA,OAAA;AAAA,EACA,OAAA;AAAA,EACA;AACF,CAAA;AAMA,MAAM,cAAA,GAAiB,CAAC,OAAA,KACtB,OAAA,CAAQ,SAAS,GAAG,CAAA,GAAK,CAAA,EAAG,OAAO,CAAA,EAAA,CAAA,GAAiB,OAAA;;;;"}
@@ -1 +1 @@
1
- {"version":3,"file":"get-workspace-packages.d.mts","sourceRoot":"","sources":["../../../src/functions/workspace-utils/get-workspace-packages.mts"],"names":[],"mappings":"AAiBA,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,OAAO,EACb,MAAM,aAAa,CAAC;AAErB;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oBAAoB,uBACX,MAAM,qBACR,SAAS,eAAe,EAAE,KAC3C,OAAO,CAAC,SAAS,OAAO,EAAE,CAqE5B,CAAC"}
1
+ {"version":3,"file":"get-workspace-packages.d.mts","sourceRoot":"","sources":["../../../src/functions/workspace-utils/get-workspace-packages.mts"],"names":[],"mappings":"AAcA,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,OAAO,EACb,MAAM,aAAa,CAAC;AAErB;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oBAAoB,uBACX,MAAM,qBACR,SAAS,eAAe,EAAE,KAC3C,OAAO,CAAC,SAAS,OAAO,EAAE,CAqE5B,CAAC"}
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
- import { Result, Json, isNotUndefined, isRecord, hasKey, Arr, isString } from 'ts-data-forge';
3
+ import { Result, Json, isNotUndefined, isRecord, hasKey, Arr, isString, Obj } from 'ts-data-forge';
4
4
  import { glob } from '../glob.mjs';
5
5
  import { defaultDependencyFields } from './types.mjs';
6
6
 
@@ -63,10 +63,7 @@ const getKeyValueRecordFromJsonValue = (value, key) => {
63
63
  if (!isRecord(obj)) {
64
64
  return {};
65
65
  }
66
- const entries = Object.entries(obj).filter(
67
- (entry) => isString(entry[1])
68
- );
69
- return Object.fromEntries(entries);
66
+ return Obj.filter(obj, isString);
70
67
  };
71
68
 
72
69
  export { getWorkspacePackages };
@@ -1 +1 @@
1
- {"version":3,"file":"get-workspace-packages.mjs","sources":["../../../src/functions/workspace-utils/get-workspace-packages.mts"],"sourcesContent":["import * as fs from 'node:fs/promises';\nimport * as path from 'node:path';\nimport {\n Arr,\n hasKey,\n isNotUndefined,\n isRecord,\n isString,\n Json,\n Result,\n} from 'ts-data-forge';\nimport {\n type JsonValue,\n type MutableFixedLengthTuple,\n type ReadonlyRecord,\n} from 'ts-type-forge';\nimport { glob } from '../glob.mjs';\nimport {\n defaultDependencyFields,\n type DependencyField,\n type Package,\n} from './types.mjs';\n\n/**\n * Retrieves all workspace packages from a monorepo based on the workspace\n * patterns defined in the root package.json file.\n *\n * @param rootPackageJsonDir - The directory containing the root package.json\n * file\n * @param dependencyFields - Which `package.json` fields contribute to each\n * package's `dependencies` map. Defaults to\n * {@link defaultDependencyFields}.\n * @returns A promise that resolves to an array of Package objects containing\n * package metadata\n */\nexport const getWorkspacePackages = async (\n rootPackageJsonDir: string,\n dependencyFields: readonly DependencyField[] = defaultDependencyFields,\n): Promise<readonly Package[]> => {\n // Read root package.json\n // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment\n const rootPackageJson: JsonValue = JSON.parse(\n // eslint-disable-next-line security/detect-non-literal-fs-filename\n await fs.readFile(path.join(rootPackageJsonDir, 'package.json'), 'utf8'),\n );\n\n const workspacePatterns: readonly string[] = getStrArrayFromJsonValue(\n rootPackageJson,\n 'workspaces',\n );\n\n const packagePromises = workspacePatterns.map(async (pattern) => {\n const globResult = await glob(pattern, {\n cwd: rootPackageJsonDir,\n ignore: ['**/node_modules/**'],\n onlyDirectories: true,\n absolute: true,\n });\n\n if (Result.isErr(globResult)) {\n return [];\n }\n\n const matches = globResult.value;\n\n const packageJsonList: readonly (\n readonly [string, JsonValue] | undefined\n )[] = await Promise.all(\n matches.map(async (match) => {\n const maybePackagePath = path.join(match, 'package.json');\n\n const result = await Result.fromPromise(\n // eslint-disable-next-line security/detect-non-literal-fs-filename\n fs.readFile(maybePackagePath, 'utf8'),\n );\n\n if (Result.isErr(result)) return undefined;\n\n const parsed = Json.parse(result.value);\n\n if (Result.isErr(parsed)) return undefined;\n\n return [maybePackagePath, parsed.value] as const;\n }),\n );\n\n const packageInfos: readonly Package[] = packageJsonList\n .filter(isNotUndefined)\n .map(([packagePath, packageJson]) => ({\n name: getStrFromJsonValue(packageJson, 'name'),\n path: path.dirname(packagePath),\n packageJson,\n dependencies: Object.fromEntries(\n dependencyFields.flatMap((field) =>\n Object.entries(getKeyValueRecordFromJsonValue(packageJson, field)),\n ),\n ),\n }));\n\n return packageInfos;\n });\n\n const allPackageArrays = await Promise.all(packagePromises);\n\n const finalPackages = allPackageArrays.flat();\n\n return finalPackages;\n};\n\nconst getStrFromJsonValue = (value: JsonValue, key: string): string =>\n isRecord(value) && hasKey(value, key) && isString(value[key])\n ? value[key]\n : '';\n\nconst getStrArrayFromJsonValue = (\n value: JsonValue,\n key: string,\n): readonly string[] =>\n isRecord(value) &&\n hasKey(value, key) &&\n Arr.isArray(value[key]) &&\n value[key].every(isString)\n ? value[key]\n : ([] as const);\n\nconst getKeyValueRecordFromJsonValue = (\n value: JsonValue,\n key: string,\n): ReadonlyRecord<string, string> => {\n if (!isRecord(value) || !hasKey(value, key)) {\n return {};\n }\n\n const obj = value[key];\n\n if (!isRecord(obj)) {\n return {};\n }\n\n const entries = Object.entries(obj).filter(\n (entry): entry is MutableFixedLengthTuple<2, string> => isString(entry[1]),\n );\n\n return Object.fromEntries(entries);\n};\n"],"names":[],"mappings":";;;;;;AAmCO,MAAM,oBAAA,GAAuB,OAClC,kBAAA,EACA,gBAAA,GAA+C,uBAAA,KACf;AAGhC,EAAA,MAAM,kBAA6B,IAAA,CAAK,KAAA;AAAA;AAAA,IAEtC,MAAM,GAAG,QAAA,CAAS,IAAA,CAAK,KAAK,kBAAA,EAAoB,cAAc,GAAG,MAAM;AAAA,GACzE;AAEA,EAAA,MAAM,iBAAA,GAAuC,wBAAA;AAAA,IAC3C,eAAA;AAAA,IACA;AAAA,GACF;AAEA,EAAA,MAAM,eAAA,GAAkB,iBAAA,CAAkB,GAAA,CAAI,OAAO,OAAA,KAAY;AAC/D,IAAA,MAAM,UAAA,GAAa,MAAM,IAAA,CAAK,OAAA,EAAS;AAAA,MACrC,GAAA,EAAK,kBAAA;AAAA,MACL,MAAA,EAAQ,CAAC,oBAAoB,CAAA;AAAA,MAC7B,eAAA,EAAiB,IAAA;AAAA,MACjB,QAAA,EAAU;AAAA,KACX,CAAA;AAED,IAAA,IAAI,MAAA,CAAO,KAAA,CAAM,UAAU,CAAA,EAAG;AAC5B,MAAA,OAAO,EAAC;AAAA,IACV;AAEA,IAAA,MAAM,UAAU,UAAA,CAAW,KAAA;AAE3B,IAAA,MAAM,eAAA,GAEA,MAAM,OAAA,CAAQ,GAAA;AAAA,MAClB,OAAA,CAAQ,GAAA,CAAI,OAAO,KAAA,KAAU;AAC3B,QAAA,MAAM,gBAAA,GAAmB,IAAA,CAAK,IAAA,CAAK,KAAA,EAAO,cAAc,CAAA;AAExD,QAAA,MAAM,MAAA,GAAS,MAAM,MAAA,CAAO,WAAA;AAAA;AAAA,UAE1B,EAAA,CAAG,QAAA,CAAS,gBAAA,EAAkB,MAAM;AAAA,SACtC;AAEA,QAAA,IAAI,MAAA,CAAO,KAAA,CAAM,MAAM,CAAA,EAAG,OAAO,MAAA;AAEjC,QAAA,MAAM,MAAA,GAAS,IAAA,CAAK,KAAA,CAAM,MAAA,CAAO,KAAK,CAAA;AAEtC,QAAA,IAAI,MAAA,CAAO,KAAA,CAAM,MAAM,CAAA,EAAG,OAAO,MAAA;AAEjC,QAAA,OAAO,CAAC,gBAAA,EAAkB,MAAA,CAAO,KAAK,CAAA;AAAA,MACxC,CAAC;AAAA,KACH;AAEA,IAAA,MAAM,YAAA,GAAmC,eAAA,CACtC,MAAA,CAAO,cAAc,CAAA,CACrB,IAAI,CAAC,CAAC,WAAA,EAAa,WAAW,CAAA,MAAO;AAAA,MACpC,IAAA,EAAM,mBAAA,CAAoB,WAAA,EAAa,MAAM,CAAA;AAAA,MAC7C,IAAA,EAAM,IAAA,CAAK,OAAA,CAAQ,WAAW,CAAA;AAAA,MAC9B,WAAA;AAAA,MACA,cAAc,MAAA,CAAO,WAAA;AAAA,QACnB,gBAAA,CAAiB,OAAA;AAAA,UAAQ,CAAC,KAAA,KACxB,MAAA,CAAO,QAAQ,8BAAA,CAA+B,WAAA,EAAa,KAAK,CAAC;AAAA;AACnE;AACF,KACF,CAAE,CAAA;AAEJ,IAAA,OAAO,YAAA;AAAA,EACT,CAAC,CAAA;AAED,EAAA,MAAM,gBAAA,GAAmB,MAAM,OAAA,CAAQ,GAAA,CAAI,eAAe,CAAA;AAE1D,EAAA,MAAM,aAAA,GAAgB,iBAAiB,IAAA,EAAK;AAE5C,EAAA,OAAO,aAAA;AACT;AAEA,MAAM,sBAAsB,CAAC,KAAA,EAAkB,QAC7C,QAAA,CAAS,KAAK,KAAK,MAAA,CAAO,KAAA,EAAO,GAAG,CAAA,IAAK,SAAS,KAAA,CAAM,GAAG,CAAC,CAAA,GACxD,KAAA,CAAM,GAAG,CAAA,GACT,EAAA;AAEN,MAAM,wBAAA,GAA2B,CAC/B,KAAA,EACA,GAAA,KAEA,QAAA,CAAS,KAAK,CAAA,IACd,MAAA,CAAO,KAAA,EAAO,GAAG,CAAA,IACjB,GAAA,CAAI,OAAA,CAAQ,MAAM,GAAG,CAAC,CAAA,IACtB,KAAA,CAAM,GAAG,CAAA,CAAE,KAAA,CAAM,QAAQ,CAAA,GACrB,KAAA,CAAM,GAAG,CAAA,GACR,EAAC;AAER,MAAM,8BAAA,GAAiC,CACrC,KAAA,EACA,GAAA,KACmC;AACnC,EAAA,IAAI,CAAC,SAAS,KAAK,CAAA,IAAK,CAAC,MAAA,CAAO,KAAA,EAAO,GAAG,CAAA,EAAG;AAC3C,IAAA,OAAO,EAAC;AAAA,EACV;AAEA,EAAA,MAAM,GAAA,GAAM,MAAM,GAAG,CAAA;AAErB,EAAA,IAAI,CAAC,QAAA,CAAS,GAAG,CAAA,EAAG;AAClB,IAAA,OAAO,EAAC;AAAA,EACV;AAEA,EAAA,MAAM,OAAA,GAAU,MAAA,CAAO,OAAA,CAAQ,GAAG,CAAA,CAAE,MAAA;AAAA,IAClC,CAAC,KAAA,KAAuD,QAAA,CAAS,KAAA,CAAM,CAAC,CAAC;AAAA,GAC3E;AAEA,EAAA,OAAO,MAAA,CAAO,YAAY,OAAO,CAAA;AACnC,CAAA;;;;"}
1
+ {"version":3,"file":"get-workspace-packages.mjs","sources":["../../../src/functions/workspace-utils/get-workspace-packages.mts"],"sourcesContent":["import * as fs from 'node:fs/promises';\nimport * as path from 'node:path';\nimport {\n Arr,\n hasKey,\n isNotUndefined,\n isRecord,\n isString,\n Json,\n Obj,\n Result,\n} from 'ts-data-forge';\nimport { type JsonValue, type ReadonlyRecord } from 'ts-type-forge';\nimport { glob } from '../glob.mjs';\nimport {\n defaultDependencyFields,\n type DependencyField,\n type Package,\n} from './types.mjs';\n\n/**\n * Retrieves all workspace packages from a monorepo based on the workspace\n * patterns defined in the root package.json file.\n *\n * @param rootPackageJsonDir - The directory containing the root package.json\n * file\n * @param dependencyFields - Which `package.json` fields contribute to each\n * package's `dependencies` map. Defaults to\n * {@link defaultDependencyFields}.\n * @returns A promise that resolves to an array of Package objects containing\n * package metadata\n */\nexport const getWorkspacePackages = async (\n rootPackageJsonDir: string,\n dependencyFields: readonly DependencyField[] = defaultDependencyFields,\n): Promise<readonly Package[]> => {\n // Read root package.json\n // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment\n const rootPackageJson: JsonValue = JSON.parse(\n // eslint-disable-next-line security/detect-non-literal-fs-filename\n await fs.readFile(path.join(rootPackageJsonDir, 'package.json'), 'utf8'),\n );\n\n const workspacePatterns: readonly string[] = getStrArrayFromJsonValue(\n rootPackageJson,\n 'workspaces',\n );\n\n const packagePromises = workspacePatterns.map(async (pattern) => {\n const globResult = await glob(pattern, {\n cwd: rootPackageJsonDir,\n ignore: ['**/node_modules/**'],\n onlyDirectories: true,\n absolute: true,\n });\n\n if (Result.isErr(globResult)) {\n return [];\n }\n\n const matches = globResult.value;\n\n const packageJsonList: readonly (\n readonly [string, JsonValue] | undefined\n )[] = await Promise.all(\n matches.map(async (match) => {\n const maybePackagePath = path.join(match, 'package.json');\n\n const result = await Result.fromPromise(\n // eslint-disable-next-line security/detect-non-literal-fs-filename\n fs.readFile(maybePackagePath, 'utf8'),\n );\n\n if (Result.isErr(result)) return undefined;\n\n const parsed = Json.parse(result.value);\n\n if (Result.isErr(parsed)) return undefined;\n\n return [maybePackagePath, parsed.value] as const;\n }),\n );\n\n const packageInfos: readonly Package[] = packageJsonList\n .filter(isNotUndefined)\n .map(([packagePath, packageJson]) => ({\n name: getStrFromJsonValue(packageJson, 'name'),\n path: path.dirname(packagePath),\n packageJson,\n dependencies: Object.fromEntries(\n dependencyFields.flatMap((field) =>\n Object.entries(getKeyValueRecordFromJsonValue(packageJson, field)),\n ),\n ),\n }));\n\n return packageInfos;\n });\n\n const allPackageArrays = await Promise.all(packagePromises);\n\n const finalPackages = allPackageArrays.flat();\n\n return finalPackages;\n};\n\nconst getStrFromJsonValue = (value: JsonValue, key: string): string =>\n isRecord(value) && hasKey(value, key) && isString(value[key])\n ? value[key]\n : '';\n\nconst getStrArrayFromJsonValue = (\n value: JsonValue,\n key: string,\n): readonly string[] =>\n isRecord(value) &&\n hasKey(value, key) &&\n Arr.isArray(value[key]) &&\n value[key].every(isString)\n ? value[key]\n : ([] as const);\n\nconst getKeyValueRecordFromJsonValue = (\n value: JsonValue,\n key: string,\n): ReadonlyRecord<string, string> => {\n if (!isRecord(value) || !hasKey(value, key)) {\n return {};\n }\n\n const obj = value[key];\n\n if (!isRecord(obj)) {\n return {};\n }\n\n return Obj.filter(obj, isString);\n};\n"],"names":[],"mappings":"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"}
package/dist/index.mjs CHANGED
@@ -9,7 +9,7 @@ export { genIndex } from './functions/gen-index.mjs';
9
9
  export { glob } from './functions/glob.mjs';
10
10
  export { isDirectlyExecuted } from './functions/is-directly-executed.mjs';
11
11
  export { makeEmptyDir } from './functions/make-empty-dir.mjs';
12
- export { checkShouldRunTypeChecks } from './functions/should-run.mjs';
12
+ export { checkShouldRun, checkShouldRunTypeChecks } from './functions/should-run.mjs';
13
13
  export { executeParallel, executeStages } from './functions/workspace-utils/execute-parallel.mjs';
14
14
  export { getWorkspacePackages } from './functions/workspace-utils/get-workspace-packages.mjs';
15
15
  export { runCmdInParallelAcrossWorkspaces } from './functions/workspace-utils/run-cmd-in-parallel.mjs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-repo-utils",
3
- "version": "10.2.0",
3
+ "version": "10.3.0",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "typescript"
@@ -30,6 +30,7 @@
30
30
  "types": "./dist/types.d.mts",
31
31
  "bin": {
32
32
  "assert-repo-is-clean": "./dist/cmd/assert-repo-is-clean.mjs",
33
+ "check-should-run": "./dist/cmd/check-should-run.mjs",
33
34
  "check-should-run-type-checks": "./dist/cmd/check-should-run-type-checks.mjs",
34
35
  "format-diff-from": "./dist/cmd/format-diff-from.mjs",
35
36
  "format-uncommitted": "./dist/cmd/format-uncommitted.mjs",
@@ -48,37 +49,34 @@
48
49
  "cmd-ts": "^0.15.0",
49
50
  "fast-glob": "^3.3.3",
50
51
  "micromatch": "^4.0.8",
51
- "ts-data-forge": "^14.2.1",
52
+ "ts-data-forge": "^14.4.0",
52
53
  "ts-type-forge": "^9.2.1"
53
54
  },
54
55
  "devDependencies": {
55
- "@rollup/plugin-replace": "6.0.3",
56
- "@rollup/plugin-strip": "3.0.4",
57
56
  "@types/node": "26.2.0",
58
57
  "@vitest/coverage-v8": "4.1.10",
59
58
  "@vitest/ui": "4.1.10",
60
59
  "cspell": "10.0.1",
61
60
  "dedent": "1.7.2",
62
- "esbuild": "0.28.1",
63
- "eslint": "10.8.0",
61
+ "esbuild": "0.28.2",
62
+ "eslint": "10.8.1",
64
63
  "jiti": "2.7.0",
65
64
  "npm-run-all2": "9.0.3",
66
65
  "prettier": "3.9.6",
67
66
  "prettier-plugin-organize-imports": "4.3.0",
68
67
  "prettier-plugin-packagejson": "3.0.2",
69
68
  "rollup": "4.62.4",
70
- "rollup-plugin-esbuild": "6.2.1",
71
- "tsx": "4.23.4",
69
+ "tsx": "4.23.12",
72
70
  "typedoc": "0.28.20",
73
71
  "typedoc-github-theme": "0.4.0",
74
72
  "typescript": "6.0.3",
75
73
  "typescript-native": "npm:typescript@7.0.2",
76
- "vite": "8.2.0",
74
+ "vite": "8.2.1",
77
75
  "vitest": "4.1.10",
78
- "eslint-config-typed": "5.8.5",
79
- "eslint-plugin-ts-fortress": "1.0.1",
80
- "eslint-plugin-ts-data-forge": "0.4.4",
81
- "eslint-plugin-ts-type-forge": "0.6.3"
76
+ "eslint-plugin-ts-data-forge": "0.4.8",
77
+ "eslint-config-typed": "5.9.3",
78
+ "eslint-plugin-ts-fortress": "1.0.4",
79
+ "eslint-plugin-ts-type-forge": "0.6.6"
82
80
  },
83
81
  "peerDependencies": {
84
82
  "prettier": ">=3.0.0"
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+
3
+ import * as cmd from 'cmd-ts';
4
+ import { Arr } from 'ts-data-forge';
5
+ import { checkShouldRun } from '../functions/index.mjs';
6
+
7
+ const cmdDef = cmd.command({
8
+ name: 'check-should-run-cli',
9
+ version: '10.2.0',
10
+ args: {
11
+ pathsIgnore: cmd.multioption({
12
+ long: 'paths-ignore',
13
+ type: cmd.optional(cmd.array(cmd.string)),
14
+ description:
15
+ 'Patterns whose files do not affect the gated step. Matched with micromatch against repository-relative paths, with a trailing "/" as the shorthand for a directory (e.g. "experimental/", ".editorconfig", "**.md"). Defaults to ignoring nothing.',
16
+ }),
17
+ baseBranch: cmd.option({
18
+ long: 'base-branch',
19
+ type: cmd.optional(cmd.string),
20
+ description:
21
+ 'Base branch to compare against for determining changed files. Defaults to "origin/main"',
22
+ }),
23
+ },
24
+ handler: (args) => {
25
+ main(args).catch((error: unknown) => {
26
+ console.error('An error occurred:', error);
27
+
28
+ process.exit(1);
29
+ });
30
+ },
31
+ });
32
+
33
+ const main = async (
34
+ args: Readonly<{
35
+ pathsIgnore?: readonly string[];
36
+ baseBranch?: string;
37
+ }>,
38
+ ): Promise<void> => {
39
+ await checkShouldRun({
40
+ pathsIgnore: args.pathsIgnore,
41
+ baseBranch: args.baseBranch,
42
+ });
43
+ };
44
+
45
+ await cmd.run(cmdDef, Arr.skip(process.argv, 2));
@@ -1,12 +1,118 @@
1
+ import micromatch from 'micromatch';
1
2
  import * as fs from 'node:fs/promises';
2
3
  import * as path from 'node:path';
3
- import { Result } from 'ts-data-forge';
4
- import { getDiffFrom } from './diff.mjs';
4
+ import { castMutable, Result } from 'ts-data-forge';
5
+ import { getDiffFrom, getGitRoot } from './diff.mjs';
6
+
7
+ /**
8
+ * Checks whether a CI step should run, based on which files differ from a base
9
+ * branch. Reports `false` when every changed file matches one of the ignored
10
+ * patterns.
11
+ *
12
+ * This is the general form of {@link checkShouldRunTypeChecks}: it carries no
13
+ * default ignore list, so the caller states exactly which paths are irrelevant
14
+ * to the step being gated.
15
+ *
16
+ * Prefer gating the individual steps of a workflow with this over a
17
+ * `paths-ignore` trigger filter: a workflow that a path filter skips never
18
+ * reports a status check, so a pull request that changed only the filtered
19
+ * paths waits forever on a required check that will never arrive.
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * // Skip when the diff only touches a directory nothing checks
24
+ * await checkShouldRun({ pathsIgnore: ['experimental/'] });
25
+ *
26
+ * // Custom base branch
27
+ * await checkShouldRun({
28
+ * pathsIgnore: ['docs/', '**.md'],
29
+ * baseBranch: 'origin/develop',
30
+ * });
31
+ * ```;
32
+ *
33
+ * @example
34
+ * GitHub Actions usage
35
+ * ```yaml
36
+ * - name: Check diff
37
+ * id: check_diff
38
+ * run: npm exec -- check-should-run --paths-ignore 'experimental/'
39
+ *
40
+ * - name: Run the checks
41
+ * if: steps.check_diff.outputs.should_run == 'true'
42
+ * run: npm run check
43
+ * ```
44
+ *
45
+ * @param options - Configuration options
46
+ * @param options.pathsIgnore - Patterns whose files do not affect the step.
47
+ * Matched with micromatch (with `dot: true`) against paths relative to the
48
+ * repository root, with one shorthand: a pattern ending with `/` is a
49
+ * directory prefix, so `docs/` is `docs/**`. Exact file names
50
+ * (`.editorconfig`) and ordinary globs (`**.md`, `docs/**`) both work.
51
+ * Defaults to `[]`, which ignores nothing.
52
+ * @param options.baseBranch - Base branch to compare against for determining
53
+ * changed files. Defaults to `'origin/main'`
54
+ * @returns Whether the step should run. An empty diff reads as "nothing
55
+ * changed" and so returns `false`. When running in GitHub Actions, also
56
+ * appends `should_run=true` or `should_run=false` to the file named by the
57
+ * `GITHUB_OUTPUT` environment variable.
58
+ */
59
+ export const checkShouldRun = async (
60
+ options?: Readonly<{
61
+ pathsIgnore?: readonly string[];
62
+ baseBranch?: string;
63
+ }>,
64
+ ): Promise<boolean> => {
65
+ const pathsIgnore = options?.pathsIgnore ?? [];
66
+
67
+ const baseBranch = options?.baseBranch ?? 'origin/main';
68
+
69
+ const GITHUB_OUTPUT = process.env['GITHUB_OUTPUT'];
70
+
71
+ const gitRoot = await getGitRoot();
72
+
73
+ if (Result.isErr(gitRoot)) {
74
+ console.error('Error getting the git root:', gitRoot.value);
75
+
76
+ process.exit(1);
77
+ }
78
+
79
+ const files = await getDiffFrom(baseBranch);
80
+
81
+ if (Result.isErr(files)) {
82
+ console.error('Error getting diff:', files.value);
83
+
84
+ process.exit(1);
85
+ }
86
+
87
+ // `getDiffFrom` returns absolute paths, while the patterns are written
88
+ // relative to the repository root.
89
+ const changedFiles = files.value.map((file) =>
90
+ path.relative(gitRoot.value, file).split(path.sep).join('/'),
91
+ );
92
+
93
+ const patterns = pathsIgnore.map(toMatchPattern);
94
+
95
+ const shouldRun: boolean = changedFiles.some(
96
+ (file) => !micromatch.isMatch(file, castMutable(patterns), { dot: true }),
97
+ );
98
+
99
+ if (GITHUB_OUTPUT !== undefined) {
100
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
101
+ await fs.appendFile(GITHUB_OUTPUT, `should_run=${shouldRun}\n`);
102
+ }
103
+
104
+ return shouldRun;
105
+ };
5
106
 
6
107
  /**
7
108
  * Checks if TypeScript type checks should run based on the diff from a base
8
109
  * branch. Skips type checks if all changed files match the ignored patterns.
9
110
  *
111
+ * This is {@link checkShouldRun} with an ignore list of the files that a
112
+ * TypeScript project typically has but never type-checks — documentation,
113
+ * tooling configuration, license text. Pass `pathsIgnore` to replace that list,
114
+ * or call {@link checkShouldRun} directly for a step that is not a type check.
115
+ *
10
116
  * This function is typically used in CI/CD pipelines to determine whether
11
117
  * expensive type checking operations should be performed. If all changed files
12
118
  * are documentation, configuration, or other non-TypeScript files, type checks
@@ -42,7 +148,7 @@ import { getDiffFrom } from './diff.mjs';
42
148
  *
43
149
  * @param options - Configuration options
44
150
  * @param options.pathsIgnore - Array of patterns to ignore when determining if
45
- * type checks should run. Supports three pattern types:
151
+ * type checks should run. Matched as described in {@link checkShouldRun}:
46
152
  *
47
153
  * - **Exact file matches**: e.g., `.cspell.config.yaml` matches only that file
48
154
  * - **Directory prefixes**: e.g., `docs/` matches any file in docs directory
@@ -62,61 +168,30 @@ export const checkShouldRunTypeChecks = async (
62
168
  pathsIgnore?: readonly string[];
63
169
  baseBranch?: string;
64
170
  }>,
65
- ): Promise<boolean> => {
66
- const pathsIgnore = options?.pathsIgnore ?? [
67
- 'LICENSE',
68
- '.editorconfig',
69
- '.gitignore',
70
- '.cspell.json',
71
- '.cspell.config.yaml',
72
- '.markdownlint-cli2.mjs',
73
- '.npmignore',
74
- '.prettierignore',
75
- '.prettierrc',
76
- 'docs/',
77
- '**.md',
78
- '**.txt',
79
- ];
80
-
81
- const baseBranch = options?.baseBranch ?? 'origin/main';
82
-
83
- const GITHUB_OUTPUT = process.env['GITHUB_OUTPUT'];
84
-
85
- const files = await getDiffFrom(baseBranch);
86
-
87
- if (Result.isErr(files)) {
88
- console.error('Error getting diff:', files.value);
89
-
90
- process.exit(1);
91
- }
92
-
93
- const shouldRunTsChecks: boolean = !files.value.every((file) =>
94
- pathsIgnore.some((pattern) => {
95
- // Exact file match
96
- if (pattern === file) {
97
- return true;
98
- }
99
-
100
- // Directory prefix match (pattern ends with '/')
101
- if (pattern.endsWith('/') && file.startsWith(pattern)) {
102
- return true;
103
- }
171
+ ): Promise<boolean> =>
172
+ checkShouldRun({
173
+ pathsIgnore: options?.pathsIgnore ?? defaultTypeCheckPathsIgnore,
174
+ baseBranch: options?.baseBranch,
175
+ });
104
176
 
105
- // File extension pattern match (pattern starts with '**.')
106
- if (pattern.startsWith('**.')) {
107
- const extension = pattern.slice(2); // Remove '**'
177
+ const defaultTypeCheckPathsIgnore = [
178
+ 'LICENSE',
179
+ '.editorconfig',
180
+ '.gitignore',
181
+ '.cspell.json',
182
+ '.cspell.config.yaml',
183
+ '.markdownlint-cli2.mjs',
184
+ '.npmignore',
185
+ '.prettierignore',
186
+ '.prettierrc',
187
+ 'docs/',
188
+ '**.md',
189
+ '**.txt',
190
+ ] as const;
108
191
 
109
- return path.basename(file).endsWith(extension);
110
- }
111
-
112
- return false;
113
- }),
114
- );
115
-
116
- if (GITHUB_OUTPUT !== undefined) {
117
- // eslint-disable-next-line security/detect-non-literal-fs-filename
118
- await fs.appendFile(GITHUB_OUTPUT, `should_run=${shouldRunTsChecks}\n`);
119
- }
120
-
121
- return shouldRunTsChecks;
122
- };
192
+ /**
193
+ * Expands the trailing-slash shorthand for a directory into the glob
194
+ * micromatch needs. Every other pattern is already one.
195
+ */
196
+ const toMatchPattern = (pattern: string): string =>
197
+ pattern.endsWith('/') ? (`${pattern}**` as const) : pattern;
@@ -0,0 +1,196 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { $ } from './exec-async.mjs';
5
+ import { checkShouldRun, checkShouldRunTypeChecks } from './should-run.mjs';
6
+
7
+ describe('should-run', () => {
8
+ /**
9
+ * Creates a repository with one commit, applies `changedFiles` on top of it
10
+ * (staged, not committed, which `git diff <base>` reports all the same), and
11
+ * runs `check` from the repository root.
12
+ */
13
+ const withChangedFiles = async <T,>(
14
+ changedFiles: readonly string[],
15
+ check: (repoRoot: string) => Promise<T>,
16
+ ): Promise<T> => {
17
+ const repoPath = await fs.mkdtemp(path.join(tmpdir(), 'should-run-'));
18
+
19
+ const originalCwd = process.cwd();
20
+
21
+ try {
22
+ process.chdir(repoPath);
23
+
24
+ await $(
25
+ [
26
+ 'git init --initial-branch=main',
27
+ 'git config user.name "Test User"',
28
+ 'git config user.email "test@example.com"',
29
+ // The machine running the tests may sign commits by default, which
30
+ // needs a key this repository does not have.
31
+ 'git config commit.gpgsign false',
32
+ 'git commit --allow-empty -m "initial commit" --no-verify',
33
+ ].join(' && '),
34
+ { silent: true },
35
+ );
36
+
37
+ await Promise.all(
38
+ changedFiles.map(async (file) => {
39
+ const filePath = path.join(repoPath, file);
40
+
41
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
42
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
43
+
44
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
45
+ await fs.writeFile(filePath, 'content');
46
+ }),
47
+ );
48
+
49
+ await $('git add --all', { silent: true });
50
+
51
+ return await check(repoPath);
52
+ } finally {
53
+ process.chdir(originalCwd);
54
+
55
+ await fs.rm(repoPath, { recursive: true, force: true });
56
+ }
57
+ };
58
+
59
+ describe(checkShouldRun, () => {
60
+ test('should report false when every changed file is ignored', async () => {
61
+ const result = await withChangedFiles(
62
+ ['experimental/a/b.mts', 'experimental/c.md'],
63
+ async () =>
64
+ checkShouldRun({
65
+ pathsIgnore: ['experimental/'],
66
+ baseBranch: 'HEAD',
67
+ }),
68
+ );
69
+
70
+ assert.isFalse(result);
71
+ });
72
+
73
+ test('should report true when one changed file is not ignored', async () => {
74
+ const result = await withChangedFiles(
75
+ ['experimental/a/b.mts', 'libs/x/src/a.mts'],
76
+ async () =>
77
+ checkShouldRun({
78
+ pathsIgnore: ['experimental/'],
79
+ baseBranch: 'HEAD',
80
+ }),
81
+ );
82
+
83
+ assert.isTrue(result);
84
+ });
85
+
86
+ test('should report true when nothing is ignored', async () => {
87
+ const result = await withChangedFiles(['experimental/a.mts'], async () =>
88
+ checkShouldRun({ baseBranch: 'HEAD' }),
89
+ );
90
+
91
+ assert.isTrue(result);
92
+ });
93
+
94
+ test('should report false when nothing changed', async () => {
95
+ const result = await withChangedFiles([], async () =>
96
+ checkShouldRun({ pathsIgnore: ['experimental/'], baseBranch: 'HEAD' }),
97
+ );
98
+
99
+ assert.isFalse(result);
100
+ });
101
+
102
+ test('should match patterns against repository-relative paths', async () => {
103
+ // Every pattern kind at once: an exact file name, a directory prefix,
104
+ // an extension glob, and a glob with a wildcard segment. All four are
105
+ // relative to the repository root, so a file outside `docs/` is not
106
+ // ignored by the `docs/` pattern even though its own path contains it.
107
+ const result = await withChangedFiles(
108
+ ['LICENSE', 'docs/a.yml', 'a/b.md', 'libs/x/docs/note.yml'],
109
+ async () =>
110
+ checkShouldRun({
111
+ pathsIgnore: ['LICENSE', 'docs/', '**.md', 'libs/*/docs/**'],
112
+ baseBranch: 'HEAD',
113
+ }),
114
+ );
115
+
116
+ assert.isFalse(result);
117
+ });
118
+
119
+ test('should not ignore a file that only shares a prefix with a directory pattern', async () => {
120
+ const result = await withChangedFiles(['docs-site/a.yml'], async () =>
121
+ checkShouldRun({ pathsIgnore: ['docs/'], baseBranch: 'HEAD' }),
122
+ );
123
+
124
+ assert.isTrue(result);
125
+ });
126
+
127
+ test('should match dotfiles', async () => {
128
+ const result = await withChangedFiles(
129
+ ['.changeset/happy-pandas-sing.md', '.editorconfig'],
130
+ async () =>
131
+ checkShouldRun({
132
+ pathsIgnore: ['**.md', '.editorconfig'],
133
+ baseBranch: 'HEAD',
134
+ }),
135
+ );
136
+
137
+ assert.isFalse(result);
138
+ });
139
+
140
+ test('should write the result to GITHUB_OUTPUT', async () => {
141
+ const contents = await withChangedFiles(
142
+ ['experimental/a.mts'],
143
+ async (repoPath) => {
144
+ const outputPath = path.join(repoPath, 'github-output.txt');
145
+
146
+ vi.stubEnv('GITHUB_OUTPUT', outputPath);
147
+
148
+ try {
149
+ await checkShouldRun({
150
+ pathsIgnore: ['experimental/'],
151
+ baseBranch: 'HEAD',
152
+ });
153
+
154
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
155
+ return await fs.readFile(outputPath, 'utf8');
156
+ } finally {
157
+ vi.unstubAllEnvs();
158
+ }
159
+ },
160
+ );
161
+
162
+ assert.deepStrictEqual(contents, 'should_run=false\n');
163
+ });
164
+ });
165
+
166
+ describe(checkShouldRunTypeChecks, () => {
167
+ test('should ignore documentation and tooling configuration by default', async () => {
168
+ const result = await withChangedFiles(
169
+ ['README.md', 'LICENSE', 'docs/design.md', '.prettierrc', 'notes.txt'],
170
+ async () => checkShouldRunTypeChecks({ baseBranch: 'HEAD' }),
171
+ );
172
+
173
+ assert.isFalse(result);
174
+ });
175
+
176
+ test('should report true when a source file changed', async () => {
177
+ const result = await withChangedFiles(
178
+ ['README.md', 'libs/x/src/a.mts'],
179
+ async () => checkShouldRunTypeChecks({ baseBranch: 'HEAD' }),
180
+ );
181
+
182
+ assert.isTrue(result);
183
+ });
184
+
185
+ test('should replace the default ignore list when pathsIgnore is given', async () => {
186
+ const result = await withChangedFiles(['README.md'], async () =>
187
+ checkShouldRunTypeChecks({
188
+ pathsIgnore: ['experimental/'],
189
+ baseBranch: 'HEAD',
190
+ }),
191
+ );
192
+
193
+ assert.isTrue(result);
194
+ });
195
+ });
196
+ });
@@ -7,13 +7,10 @@ import {
7
7
  isRecord,
8
8
  isString,
9
9
  Json,
10
+ Obj,
10
11
  Result,
11
12
  } from 'ts-data-forge';
12
- import {
13
- type JsonValue,
14
- type MutableFixedLengthTuple,
15
- type ReadonlyRecord,
16
- } from 'ts-type-forge';
13
+ import { type JsonValue, type ReadonlyRecord } from 'ts-type-forge';
17
14
  import { glob } from '../glob.mjs';
18
15
  import {
19
16
  defaultDependencyFields,
@@ -137,9 +134,5 @@ const getKeyValueRecordFromJsonValue = (
137
134
  return {};
138
135
  }
139
136
 
140
- const entries = Object.entries(obj).filter(
141
- (entry): entry is MutableFixedLengthTuple<2, string> => isString(entry[1]),
142
- );
143
-
144
- return Object.fromEntries(entries);
137
+ return Obj.filter(obj, isString);
145
138
  };