@reelout/core 0.1.1 → 0.2.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.
Files changed (125) hide show
  1. package/dist/analyzer.js +38 -0
  2. package/dist/analyzer.js.map +1 -0
  3. package/dist/builder.js +52 -0
  4. package/dist/builder.js.map +1 -0
  5. package/dist/changelog.js +58 -0
  6. package/dist/changelog.js.map +1 -0
  7. package/dist/formatError-BXNdyRfu.js +23 -0
  8. package/dist/formatError-BXNdyRfu.js.map +1 -0
  9. package/dist/index.js +175 -1
  10. package/dist/index.js.map +1 -0
  11. package/dist/types-la_KkjCS.js +1 -0
  12. package/dist/types.js +2 -0
  13. package/dist/workflow-BoVmbvH6.js +2342 -0
  14. package/dist/workflow-BoVmbvH6.js.map +1 -0
  15. package/package.json +27 -3
  16. package/src/analyzer.ts +63 -0
  17. package/src/builder.ts +104 -0
  18. package/src/cache/TTLCache.ts +100 -0
  19. package/src/cache/index.ts +4 -0
  20. package/src/changelog.ts +97 -0
  21. package/src/config/const.ts +97 -0
  22. package/src/config/index.ts +11 -0
  23. package/src/config/isSemVerGitTag.ts +25 -0
  24. package/src/config/normalizeCommitFilter.ts +37 -0
  25. package/src/config/normalizeReleaseRule.ts +42 -0
  26. package/src/config/normalizeWorkspace.ts +34 -0
  27. package/src/config/resolvePlugin.ts +56 -0
  28. package/src/config/resolveReleaseTarget.ts +71 -0
  29. package/src/config/sanitizePrereleaseLabel.ts +18 -0
  30. package/src/git/branch/getCurrentGitBranch.ts +18 -0
  31. package/src/git/branch/index.ts +4 -0
  32. package/src/git/commit/createGitCommit.ts +29 -0
  33. package/src/git/commit/extractMentions.ts +34 -0
  34. package/src/git/commit/getCurrentGitCommit.ts +14 -0
  35. package/src/git/commit/getParsedCommit.ts +53 -0
  36. package/src/git/commit/index.ts +9 -0
  37. package/src/git/commit/parseCommit.ts +99 -0
  38. package/src/git/commit/parseGitPrettyFormat.ts +76 -0
  39. package/src/git/history/countGitHistory.ts +28 -0
  40. package/src/git/history/expandGitHistory.ts +108 -0
  41. package/src/git/history/index.ts +9 -0
  42. package/src/git/history/mergeGitHistory.ts +76 -0
  43. package/src/git/history/restoreFullGitHistory.ts +19 -0
  44. package/src/git/history/showGitHistory.ts +80 -0
  45. package/src/git/history/showParsedGitHistory.ts +30 -0
  46. package/src/git/index.ts +9 -0
  47. package/src/git/repository/ensureGitUserConfig.ts +31 -0
  48. package/src/git/repository/getGitConfigValue.ts +27 -0
  49. package/src/git/repository/getGitRemote.ts +20 -0
  50. package/src/git/repository/index.ts +10 -0
  51. package/src/git/repository/isGitShallowRepository.ts +21 -0
  52. package/src/git/repository/pushGitChanges.ts +25 -0
  53. package/src/git/repository/resolveGitRepository.ts +40 -0
  54. package/src/git/repository/withShallowLock.ts +62 -0
  55. package/src/git/revision/detectGitRelationship.ts +43 -0
  56. package/src/git/revision/index.ts +7 -0
  57. package/src/git/revision/isGitAncestor.ts +33 -0
  58. package/src/git/revision/readGitFile.ts +29 -0
  59. package/src/git/revision/resolveGitRevision.ts +23 -0
  60. package/src/git/tag/createGitTag.ts +25 -0
  61. package/src/git/tag/deleteGitTag.ts +17 -0
  62. package/src/git/tag/ensureGitTag.ts +63 -0
  63. package/src/git/tag/generateGitTagName.ts +43 -0
  64. package/src/git/tag/getCurrentGitTag.ts +23 -0
  65. package/src/git/tag/getGitTagMessage.ts +35 -0
  66. package/src/git/tag/getLatestGitTag.ts +22 -0
  67. package/src/git/tag/index.ts +13 -0
  68. package/src/git/tag/isGitTagConnected.ts +33 -0
  69. package/src/git/tag/isGitTagPointed.ts +38 -0
  70. package/src/git/tag/isGitTagPresent.ts +17 -0
  71. package/src/index.ts +8 -2
  72. package/src/types.ts +217 -0
  73. package/src/util/buildTrailerPattern.ts +20 -0
  74. package/src/util/compact.ts +14 -0
  75. package/src/util/execCommand.ts +130 -0
  76. package/src/util/execGit.ts +18 -0
  77. package/src/util/formatError.ts +21 -0
  78. package/src/util/formatFile.ts +41 -0
  79. package/src/util/index.ts +16 -0
  80. package/src/util/interpolate.ts +141 -0
  81. package/src/util/isCI.ts +23 -0
  82. package/src/util/isDefined.ts +12 -0
  83. package/src/util/isMatch.ts +43 -0
  84. package/src/util/readJson.ts +47 -0
  85. package/src/util/unique.ts +26 -0
  86. package/src/util/writeJson.ts +28 -0
  87. package/src/workflow/analysis/analyzePackageChanges.ts +54 -0
  88. package/src/workflow/analysis/index.ts +9 -0
  89. package/src/workflow/analysis/isCommitForPackage.ts +23 -0
  90. package/src/workflow/analysis/promotePrereleaseVersions.ts +50 -0
  91. package/src/workflow/analysis/propagateDependencyUpdates.ts +71 -0
  92. package/src/workflow/analysis/sliceUnreleased.ts +36 -0
  93. package/src/workflow/analysis/streamPackageCommits.ts +34 -0
  94. package/src/workflow/changelog/buildChangelog.ts +79 -0
  95. package/src/workflow/changelog/buildReleaseNotes.ts +50 -0
  96. package/src/workflow/changelog/extractLatestVersion.ts +22 -0
  97. package/src/workflow/changelog/extractPlainText.ts +38 -0
  98. package/src/workflow/changelog/extractPreviousVersion.ts +77 -0
  99. package/src/workflow/changelog/extractSemVer.ts +33 -0
  100. package/src/workflow/changelog/index.ts +12 -0
  101. package/src/workflow/changelog/iterateChangelogVersions.ts +70 -0
  102. package/src/workflow/changelog/prependChangelog.ts +59 -0
  103. package/src/workflow/changelog/writeChangelog.ts +27 -0
  104. package/src/workflow/committing/generateReleaseCommitMessage.ts +96 -0
  105. package/src/workflow/committing/index.ts +5 -0
  106. package/src/workflow/committing/isReleaseCommit.ts +52 -0
  107. package/src/workflow/discovery/buildDependencyGraph.ts +43 -0
  108. package/src/workflow/discovery/discoverPackageTag.ts +98 -0
  109. package/src/workflow/discovery/index.ts +9 -0
  110. package/src/workflow/discovery/resolvePackageStableTag.ts +88 -0
  111. package/src/workflow/discovery/resolvePackageTag.ts +45 -0
  112. package/src/workflow/discovery/sortPackages.ts +73 -0
  113. package/src/workflow/discovery/verifyPackageTag.ts +43 -0
  114. package/src/workflow/index.ts +10 -0
  115. package/src/workflow/report/generateReleaseReport.ts +76 -0
  116. package/src/workflow/report/index.ts +5 -0
  117. package/src/workflow/report/printReleaseReport.ts +97 -0
  118. package/src/workflow/runner/index.ts +4 -0
  119. package/src/workflow/runner/runWorkflow.ts +120 -0
  120. package/src/workflow/versioning/bumpVersion.ts +67 -0
  121. package/src/workflow/versioning/deriveReleaseType.ts +33 -0
  122. package/src/workflow/versioning/determineReleaseType.ts +35 -0
  123. package/src/workflow/versioning/index.ts +8 -0
  124. package/src/workflow/versioning/isStableVersion.ts +31 -0
  125. package/src/workflow/versioning/pickPriorityReleaseType.ts +27 -0
package/src/types.ts ADDED
@@ -0,0 +1,217 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import type { ConsolaInstance } from 'consola/core'
5
+ import type { CommitBase, CommitNote } from 'conventional-commits-parser'
6
+ import type { Hosts } from 'hosted-git-info'
7
+
8
+ export type CommitType =
9
+ | 'build'
10
+ | 'chore'
11
+ | 'ci'
12
+ | 'docs'
13
+ | 'feat'
14
+ | 'fix'
15
+ | 'perf'
16
+ | 'refactor'
17
+ | 'revert'
18
+ | 'style'
19
+ | 'test'
20
+ | (string & {})
21
+
22
+ export type { CommitNote }
23
+
24
+ export interface CommitTrailer {
25
+ key: string
26
+ value: string
27
+ }
28
+
29
+ export interface ParsedCommit extends CommitBase {
30
+ type: CommitType | null
31
+ scope: string | null
32
+ subject: string
33
+ header: string
34
+ body: string | null
35
+ footer: string | null
36
+ }
37
+
38
+ export interface Commit extends CommitBase {
39
+ hash: string
40
+ parent: string[]
41
+ authorDate: string
42
+ committerDate: string
43
+ type: CommitType | null
44
+ scope: string | null
45
+ subject: string | null
46
+ header: string
47
+ body: string | null
48
+ footer: string | null
49
+ trailers: CommitTrailer[]
50
+ files: readonly string[]
51
+ }
52
+
53
+ export interface GitRepository {
54
+ host: Hosts
55
+ owner: string
56
+ repo: string
57
+ url: string
58
+ }
59
+
60
+ export interface GitAuthor {
61
+ name: string
62
+ email: string
63
+ }
64
+
65
+ export type CommitClassifier = (
66
+ commit: Commit
67
+ ) => ReleaseType | null | undefined
68
+
69
+ export type ReleaseType = 'major' | 'minor' | 'patch'
70
+
71
+ export type ReleaseRuleType = 'branch'
72
+
73
+ export interface ReleaseRule {
74
+ type: ReleaseRuleType
75
+ pattern: string
76
+ prereleaseLabel: string | undefined
77
+ releaseChannel: string | undefined
78
+ }
79
+
80
+ export interface ReleaseConfig {
81
+ author?: Partial<GitAuthor>
82
+ classifier?: CommitClassifier | Partial<Record<ReleaseType, CommitType[]>>
83
+ rules?: Record<string, boolean | string | Partial<ReleaseRule>>
84
+ plugins?: (
85
+ [string, unknown?] | string | ReleasePlugin | false | null | undefined
86
+ )[]
87
+ }
88
+
89
+ export interface ResolvedReleaseConfig {
90
+ author: GitAuthor
91
+ classifier: CommitClassifier
92
+ rules: ReleaseRule[]
93
+ plugins: ReleasePlugin[]
94
+ }
95
+
96
+ export interface ReleaseContext extends ResolvedReleaseConfig {
97
+ cwd: string
98
+ dryRun: boolean
99
+ today: string
100
+ logger: ConsolaInstance
101
+ repository: GitRepository | null
102
+ commit: string
103
+ ref: { name: string; type: ReleaseRuleType }
104
+ promote: boolean | undefined
105
+ prereleaseLabel: string | undefined
106
+ releaseChannel: string | undefined
107
+ packages: Package[]
108
+ plans: ReleasePlan[]
109
+ }
110
+
111
+ export interface Workspace {
112
+ entry: string
113
+ members: readonly string[] | null
114
+ }
115
+
116
+ export interface Package {
117
+ root: boolean
118
+ entry: boolean
119
+ dir: string
120
+ files?: string | string[] | undefined
121
+ changelogFile?: string | undefined
122
+ tag?: Tag | null
123
+ name: string | undefined
124
+ version: string | undefined
125
+ private: boolean | undefined
126
+ dependencies: string[]
127
+ }
128
+
129
+ export interface Tag {
130
+ name: string
131
+ message?: string | undefined
132
+ }
133
+
134
+ export interface Release {
135
+ type: ReleaseType
136
+ version?: string | null
137
+ tag?: Tag | null
138
+ changelog?: string | null
139
+ }
140
+
141
+ export interface ReleasePlan {
142
+ package: Package
143
+ history: Commit[]
144
+ release: Release | null
145
+ }
146
+
147
+ export interface ReleasePlugin {
148
+ name: string
149
+ version: string
150
+ configure?: ReleasePluginHook
151
+ discover?: ReleasePluginHook
152
+ analyze?: ReleasePluginHook
153
+ build?: ReleasePluginHook
154
+ write?: ReleasePluginHook
155
+ commit?: ReleasePluginHook
156
+ publish?: ReleasePluginHook
157
+ finalize?: ReleasePluginHook
158
+ }
159
+
160
+ export type ReleasePluginHook =
161
+ ReleasePluginHookHandler | ReleasePluginHookObject
162
+
163
+ export interface ReleasePluginHookObject {
164
+ enforce?: ReleasePluginHookEnforce | null | undefined
165
+ handler: ReleasePluginHookHandler
166
+ }
167
+
168
+ export type ReleasePluginHookEnforce = 'pre' | 'post'
169
+
170
+ export type ReleasePluginHookHandler = (
171
+ context: ReleaseContext
172
+ ) =>
173
+ | undefined
174
+ | void
175
+ | ReleasePluginHookRollback
176
+ | Promise<undefined | void | ReleasePluginHookRollback>
177
+
178
+ export type ReleasePluginHookRollback = (
179
+ reason: unknown
180
+ ) => void | Promise<void>
181
+
182
+ export type ReleaseLifecycle = Exclude<keyof ReleasePlugin, 'name' | 'version'>
183
+
184
+ export type ReleaseStatus = 'success' | 'failure'
185
+
186
+ export interface ReleaseReport {
187
+ prereleaseLabel: string | undefined
188
+ releaseChannel: string | undefined
189
+ phase: { plugin: string; hook: ReleaseLifecycle } | undefined
190
+ status: ReleaseStatus
191
+ reason: string | null
192
+ plugins: {
193
+ name: string
194
+ version: string
195
+ hooks: string[]
196
+ }[]
197
+ packages: {
198
+ dir: string
199
+ name: string | undefined
200
+ version: string | undefined
201
+ }[]
202
+ plans: {
203
+ package: {
204
+ entry: boolean
205
+ dir: string
206
+ name: string | undefined
207
+ version: string | undefined
208
+ private: boolean
209
+ }
210
+ release: {
211
+ type: ReleaseType
212
+ version: string
213
+ tag: string | null | undefined
214
+ changelog: string | null | undefined
215
+ }
216
+ }[]
217
+ }
@@ -0,0 +1,20 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ /**
5
+ * Build a regular expression to match trailer lines using custom markers.
6
+ *
7
+ * Combines the provided marker strings with a standard alphanumeric pattern
8
+ * to construct a regular expression that matches trailer definitions.
9
+ *
10
+ * @param markers Optional array of custom trailer marker strings
11
+ * @returns A regular expression matching trailer lines
12
+ */
13
+ export function buildTrailerPattern(markers: readonly string[] = []) {
14
+ return new RegExp(
15
+ `^(${markers
16
+ .map(RegExp.escape)
17
+ .concat('[A-Za-z0-9_-]+')
18
+ .join('|')})[ \\t]*:[ \\t]*(.*)$`
19
+ )
20
+ }
@@ -0,0 +1,14 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import { isDefined } from './isDefined'
5
+
6
+ /**
7
+ * Remove `null` and `undefined` values from an array.
8
+ *
9
+ * @param array The array to compact
10
+ * @returns A new array that does not contain `null` or `undefined` values
11
+ */
12
+ export function compact<T>(array: readonly (T | null | undefined)[]): T[] {
13
+ return array.filter(isDefined)
14
+ }
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
5
+
6
+ export interface PromiseWithChild<T> extends Promise<T> {
7
+ child: ChildProcessWithoutNullStreams
8
+ }
9
+
10
+ let now = new Date('2026-01-01T00:00:00Z').valueOf()
11
+
12
+ const MUTATING_GIT_COMMANDS = new Set([
13
+ 'commit',
14
+ 'tag',
15
+ 'cherry-pick',
16
+ 'rebase',
17
+ 'merge',
18
+ 'revert',
19
+ 'commit-tree',
20
+ 'mktag',
21
+ ])
22
+
23
+ /**
24
+ * Execute a command asynchronously and return its standard output.
25
+ *
26
+ * @param cwd Working directory for the command
27
+ * @param command Binary to execute
28
+ * @param args Command-line arguments
29
+ * @returns Promise resolving to the stdout string
30
+ */
31
+ export function execCommand(
32
+ cwd: string,
33
+ command: string,
34
+ args: readonly string[]
35
+ ): PromiseWithChild<string> {
36
+ const env: NodeJS.ProcessEnv = {
37
+ ...process.env,
38
+ }
39
+
40
+ if (import.meta.env.MODE === 'test' && command === 'git') {
41
+ const primaryArg = args.find((arg) => !arg.startsWith('-'))
42
+
43
+ if (primaryArg && MUTATING_GIT_COMMANDS.has(primaryArg)) {
44
+ const date = new Date(now).toISOString()
45
+ env.GIT_AUTHOR_DATE = date
46
+ env.GIT_COMMITTER_DATE = date
47
+ now += 1000
48
+ }
49
+ }
50
+
51
+ let childProcess!: ChildProcessWithoutNullStreams
52
+
53
+ const promise = new Promise<string>((resolve, reject) => {
54
+ childProcess = spawn(command, args, {
55
+ cwd,
56
+ env,
57
+ })
58
+
59
+ childProcess.stdout.setEncoding('utf-8')
60
+ childProcess.stderr.setEncoding('utf-8')
61
+
62
+ let stdout = ''
63
+ let stderr = ''
64
+
65
+ childProcess.stdout.on('data', (data: string) => {
66
+ stdout += data
67
+ })
68
+
69
+ childProcess.stderr.on('data', (data: string) => {
70
+ stderr += data
71
+ })
72
+
73
+ childProcess.on('error', (err) => {
74
+ reject(err)
75
+ })
76
+
77
+ childProcess.on('close', (code, signal) => {
78
+ if (code === 0) {
79
+ return resolve(stdout)
80
+ }
81
+
82
+ const quotedCommand = quoteCommandForMessage([command, ...args].join(' '))
83
+
84
+ /* istanbul ignore next */
85
+ const exitStatus =
86
+ code !== null
87
+ ? `exit code ${code}`
88
+ : signal
89
+ ? `signal ${signal}`
90
+ : 'unknown status'
91
+
92
+ const errorMessage = `Command ${quotedCommand} failed (${exitStatus})`
93
+
94
+ const errorDiagnostics = (stderr || stdout).trim()
95
+
96
+ const err = new Error(
97
+ [errorMessage, errorDiagnostics]
98
+ .filter(Boolean)
99
+ .join(errorDiagnostics.includes('\n') ? '\n' : ': '),
100
+ {
101
+ cause: {
102
+ code,
103
+ signal,
104
+ diagnostics: errorDiagnostics,
105
+ },
106
+ }
107
+ )
108
+
109
+ Object.defineProperty(err, 'code', { value: code })
110
+
111
+ reject(err)
112
+ })
113
+ }) as PromiseWithChild<string>
114
+
115
+ promise.child = childProcess
116
+
117
+ return promise
118
+ }
119
+
120
+ function quoteCommandForMessage(command: string): string {
121
+ if (!command.includes("'")) {
122
+ return `'${command}'`
123
+ }
124
+
125
+ if (!command.includes('"')) {
126
+ return `"${command}"`
127
+ }
128
+
129
+ return JSON.stringify(command)
130
+ }
@@ -0,0 +1,18 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import { execCommand, type PromiseWithChild } from './execCommand'
5
+
6
+ /**
7
+ * Run a git command in a working directory and return its standard output.
8
+ *
9
+ * @param cwd Working directory for the git command
10
+ * @param args Git CLI arguments
11
+ * @returns Promise resolving to the stdout string
12
+ */
13
+ export function execGit(
14
+ cwd: string,
15
+ args: readonly string[]
16
+ ): PromiseWithChild<string> {
17
+ return execCommand(cwd, 'git', args)
18
+ }
@@ -0,0 +1,21 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ /**
5
+ * Normalize an unknown thrown value for logging or display.
6
+ *
7
+ * - If `err` is an `Error`, returns its `message` string.
8
+ * - Otherwise returns the original `err` value unchanged (may be a string,
9
+ * object, number, `null`, or `undefined`).
10
+ *
11
+ * This function intentionally does not coerce non-Error values to strings so
12
+ * callers can decide how to render structured values.
13
+ *
14
+ * @param err The thrown value to normalize
15
+ * @returns The error message when `err` is an `Error`, otherwise the original value
16
+ */
17
+ export function formatError<T>(err: T): T | string {
18
+ if (Error.isError(err)) return err.message
19
+ if (typeof err === 'string') return err
20
+ return err
21
+ }
@@ -0,0 +1,41 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import prettier from 'prettier'
5
+
6
+ /**
7
+ * Format `content` for `filePath` using Prettier when possible.
8
+ *
9
+ * Resolves project/editorconfig settings and formats the provided content.
10
+ * Returns the original content on error or when no parser is inferred.
11
+ *
12
+ * @param filePath Path used to infer parser and config
13
+ * @param content Source content to format
14
+ * @returns Formatted content or original content on failure
15
+ */
16
+ export async function formatFile(
17
+ filePath: string,
18
+ content: string
19
+ ): Promise<string> {
20
+ try {
21
+ const info = await prettier.getFileInfo(filePath, {
22
+ resolveConfig: true,
23
+ })
24
+
25
+ const config = await prettier.resolveConfig(filePath, {
26
+ editorconfig: true,
27
+ })
28
+
29
+ if (info.inferredParser == null) {
30
+ return content
31
+ }
32
+
33
+ return await prettier.format(content, {
34
+ ...config,
35
+ parser: info.inferredParser,
36
+ filepath: filePath,
37
+ })
38
+ } catch {
39
+ return content
40
+ }
41
+ }
@@ -0,0 +1,16 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ export * from './buildTrailerPattern'
5
+ export * from './compact'
6
+ export * from './execCommand'
7
+ export * from './execGit'
8
+ export * from './formatError'
9
+ export * from './formatFile'
10
+ export * from './interpolate'
11
+ export * from './isCI'
12
+ export * from './isDefined'
13
+ export * from './isMatch'
14
+ export * from './readJson'
15
+ export * from './unique'
16
+ export * from './writeJson'
@@ -0,0 +1,141 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ const BACKSLASH = '\\'
5
+ const LEFT_BRACE = '{'
6
+ const RIGHT_BRACE = '}'
7
+ const DOT = '.'
8
+ const ESCAPE_CHARS = [BACKSLASH, LEFT_BRACE, RIGHT_BRACE]
9
+ const BANNED_KEYS = ['__proto__', 'constructor', 'prototype']
10
+
11
+ /**
12
+ * Interpolate placeholders in `template` using values from `data`.
13
+ *
14
+ * - Placeholders use `{path.to.value}` (dot notation).
15
+ * - Backslash escapes are supported for `{`, `}`, `\`, and `.` inside tokens.
16
+ * - Tokens that reference banned keys (`__proto__`, `constructor`, `prototype`)
17
+ * are left unexpanded to avoid prototype pollution.
18
+ * - Only own properties are followed when resolving paths; if any step is
19
+ * missing, the original token text is preserved.
20
+ * - Resolved non-object, non-null values are stringified and inserted.
21
+ * - Unclosed tokens and a trailing backslash are preserved verbatim.
22
+ *
23
+ * @param template Template string containing placeholders like `{user.name}`
24
+ * @param data Object used to resolve placeholder paths
25
+ * @returns The template with placeholders replaced by corresponding values
26
+ */
27
+ export function interpolate(
28
+ template: string,
29
+ data: Record<string, unknown>
30
+ ): string {
31
+ let result = ''
32
+ let i = 0
33
+ let char: string | undefined
34
+ let nextChar: string | undefined
35
+
36
+ while ((char = template[i])) {
37
+ // Escape handling: if a backslash precedes an escapable char, emit the
38
+ // escaped character; otherwise emit the backslash itself.
39
+ if (char === BACKSLASH) {
40
+ nextChar = template[i + 1]
41
+
42
+ if (nextChar !== undefined && ESCAPE_CHARS.includes(nextChar)) {
43
+ result += nextChar
44
+ i += 2
45
+ } else {
46
+ result += char
47
+ i += 1
48
+ }
49
+ }
50
+
51
+ // Placeholder handling: parse a token starting at '{' until a matching '}'.
52
+ else if (char === LEFT_BRACE) {
53
+ const keys: string[] = []
54
+ let key = ''
55
+ let token = ''
56
+ let closed = false
57
+ i += 1
58
+
59
+ // Parse token body, supporting escaped characters and escaped dots.
60
+ while ((char = template[i])) {
61
+ if (char === RIGHT_BRACE) {
62
+ closed = true
63
+ keys.push(key)
64
+ i += 1
65
+ break
66
+ }
67
+
68
+ if (char === BACKSLASH) {
69
+ nextChar = template[i + 1]
70
+
71
+ // Allow escaping of brace, backslash, and dot inside tokens.
72
+ if (
73
+ nextChar !== undefined &&
74
+ [...ESCAPE_CHARS, DOT].includes(nextChar)
75
+ ) {
76
+ key += nextChar
77
+ token += nextChar
78
+ i += 2
79
+ } else {
80
+ // Treat an isolated backslash as a literal within the token.
81
+ key += char
82
+ token += char
83
+ i += 1
84
+ }
85
+ } else if (char === DOT) {
86
+ // Dot separates path segments; record the current segment.
87
+ keys.push(key)
88
+ key = ''
89
+ token += char
90
+ i += 1
91
+ } else {
92
+ key += char
93
+ token += char
94
+ i += 1
95
+ }
96
+ }
97
+
98
+ // If token closed with '}', attempt to resolve the path against `data`.
99
+ if (closed) {
100
+ let value: unknown = data
101
+
102
+ for (const key of keys) {
103
+ const isSafeKey = !BANNED_KEYS.includes(key)
104
+
105
+ // Only follow own properties on objects and skip banned keys.
106
+ if (
107
+ value !== null &&
108
+ typeof value === 'object' &&
109
+ Object.hasOwn(value, key) &&
110
+ isSafeKey
111
+ ) {
112
+ value = (value as Record<string, unknown>)[key]
113
+ } else {
114
+ value = undefined
115
+ break
116
+ }
117
+ }
118
+
119
+ // If resolution failed or produced an object/null, preserve the token.
120
+ if (value == null || typeof value === 'object') {
121
+ result += LEFT_BRACE + token + RIGHT_BRACE
122
+ } else {
123
+ result += String(value)
124
+ }
125
+ }
126
+
127
+ // Unclosed token: preserve the raw token text (including the opening brace).
128
+ else {
129
+ result += LEFT_BRACE + token
130
+ }
131
+ }
132
+
133
+ // Ordinary characters are appended directly.
134
+ else {
135
+ result += char
136
+ i += 1
137
+ }
138
+ }
139
+
140
+ return result
141
+ }
@@ -0,0 +1,23 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ /**
5
+ * Detect whether the current process is running in a CI environment.
6
+ *
7
+ * Returns true when the standard `CI` environment variable is set to a
8
+ * truthy value. Explicit string values `"false"` and `"0"` are treated as
9
+ * false to accommodate CI systems that expose `CI` but allow disabling it.
10
+ *
11
+ * This check is intentionally minimal and does not inspect provider-specific
12
+ * environment variables (e.g., `GITHUB_ACTIONS`, `GITLAB_CI`). Callers that
13
+ * need provider-level detail should check those variables separately.
14
+ *
15
+ * @returns `true` if running in CI; otherwise `false`
16
+ */
17
+ export function isCI(): boolean {
18
+ return !!(
19
+ process.env.CI &&
20
+ process.env.CI !== 'false' &&
21
+ process.env.CI !== '0'
22
+ )
23
+ }
@@ -0,0 +1,12 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ /**
5
+ * Type guard that asserts a value is neither `null` nor `undefined`.
6
+ *
7
+ * @param value Value to test
8
+ * @returns `true` when `value` is not `null` or `undefined`
9
+ */
10
+ export function isDefined<T>(value: T): value is NonNullable<T> {
11
+ return value != null
12
+ }
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import { minimatch } from 'minimatch'
5
+
6
+ /**
7
+ * Determine whether a target string matches glob patterns.
8
+ *
9
+ * Matches positive and negative glob patterns using dotfile support. Automatically
10
+ * expands a single negative pattern by prepending a global wildcard match.
11
+ *
12
+ * @param target Target string to evaluate against glob patterns
13
+ * @param patterns Single glob pattern or list of patterns to evaluate
14
+ * @returns True if target matches positive patterns and satisfies negative patterns
15
+ */
16
+ export function isMatch(
17
+ target: string,
18
+ patterns: string | readonly string[]
19
+ ): boolean {
20
+ if (typeof patterns === 'string') {
21
+ if (patterns.startsWith('!')) {
22
+ patterns = ['**/*', patterns]
23
+ } else {
24
+ patterns = [patterns]
25
+ }
26
+ }
27
+
28
+ const positives: string[] = []
29
+ const negatives: string[] = []
30
+
31
+ for (const pattern of patterns) {
32
+ if (pattern.startsWith('!')) {
33
+ negatives.push(pattern)
34
+ } else {
35
+ positives.push(pattern)
36
+ }
37
+ }
38
+
39
+ return (
40
+ positives.some((pattern) => minimatch(target, pattern, { dot: true })) &&
41
+ negatives.every((pattern) => minimatch(target, pattern, { dot: true }))
42
+ )
43
+ }