@hyperfrontend/versioning 0.1.0 → 0.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.
Files changed (167) hide show
  1. package/ARCHITECTURE.md +50 -1
  2. package/CHANGELOG.md +37 -23
  3. package/README.md +19 -14
  4. package/changelog/index.cjs.js +38 -6
  5. package/changelog/index.cjs.js.map +1 -1
  6. package/changelog/index.esm.js +38 -6
  7. package/changelog/index.esm.js.map +1 -1
  8. package/changelog/models/entry.d.ts +5 -0
  9. package/changelog/models/entry.d.ts.map +1 -1
  10. package/changelog/models/index.cjs.js +2 -0
  11. package/changelog/models/index.cjs.js.map +1 -1
  12. package/changelog/models/index.esm.js +2 -0
  13. package/changelog/models/index.esm.js.map +1 -1
  14. package/changelog/operations/index.cjs.js.map +1 -1
  15. package/changelog/operations/index.esm.js.map +1 -1
  16. package/changelog/parse/index.cjs.js +85 -6
  17. package/changelog/parse/index.cjs.js.map +1 -1
  18. package/changelog/parse/index.esm.js +85 -6
  19. package/changelog/parse/index.esm.js.map +1 -1
  20. package/changelog/parse/line.d.ts.map +1 -1
  21. package/changelog/parse/parser.d.ts +0 -6
  22. package/changelog/parse/parser.d.ts.map +1 -1
  23. package/commits/classify/classifier.d.ts +73 -0
  24. package/commits/classify/classifier.d.ts.map +1 -0
  25. package/commits/classify/index.cjs.js +707 -0
  26. package/commits/classify/index.cjs.js.map +1 -0
  27. package/commits/classify/index.d.ts +8 -0
  28. package/commits/classify/index.d.ts.map +1 -0
  29. package/commits/classify/index.esm.js +679 -0
  30. package/commits/classify/index.esm.js.map +1 -0
  31. package/commits/classify/infrastructure.d.ts +205 -0
  32. package/commits/classify/infrastructure.d.ts.map +1 -0
  33. package/commits/classify/models.d.ts +108 -0
  34. package/commits/classify/models.d.ts.map +1 -0
  35. package/commits/classify/project-scopes.d.ts +69 -0
  36. package/commits/classify/project-scopes.d.ts.map +1 -0
  37. package/commits/index.cjs.js +704 -0
  38. package/commits/index.cjs.js.map +1 -1
  39. package/commits/index.d.ts +1 -0
  40. package/commits/index.d.ts.map +1 -1
  41. package/commits/index.esm.js +678 -1
  42. package/commits/index.esm.js.map +1 -1
  43. package/flow/executor/execute.d.ts +6 -0
  44. package/flow/executor/execute.d.ts.map +1 -1
  45. package/flow/executor/index.cjs.js +1617 -43
  46. package/flow/executor/index.cjs.js.map +1 -1
  47. package/flow/executor/index.esm.js +1623 -49
  48. package/flow/executor/index.esm.js.map +1 -1
  49. package/flow/index.cjs.js +6749 -2938
  50. package/flow/index.cjs.js.map +1 -1
  51. package/flow/index.esm.js +6751 -2944
  52. package/flow/index.esm.js.map +1 -1
  53. package/flow/models/index.cjs.js +138 -0
  54. package/flow/models/index.cjs.js.map +1 -1
  55. package/flow/models/index.d.ts +1 -1
  56. package/flow/models/index.d.ts.map +1 -1
  57. package/flow/models/index.esm.js +138 -1
  58. package/flow/models/index.esm.js.map +1 -1
  59. package/flow/models/types.d.ts +180 -3
  60. package/flow/models/types.d.ts.map +1 -1
  61. package/flow/presets/conventional.d.ts +9 -8
  62. package/flow/presets/conventional.d.ts.map +1 -1
  63. package/flow/presets/independent.d.ts.map +1 -1
  64. package/flow/presets/index.cjs.js +3641 -303
  65. package/flow/presets/index.cjs.js.map +1 -1
  66. package/flow/presets/index.esm.js +3641 -303
  67. package/flow/presets/index.esm.js.map +1 -1
  68. package/flow/presets/synced.d.ts.map +1 -1
  69. package/flow/steps/analyze-commits.d.ts +9 -6
  70. package/flow/steps/analyze-commits.d.ts.map +1 -1
  71. package/flow/steps/calculate-bump.d.ts.map +1 -1
  72. package/flow/steps/fetch-registry.d.ts.map +1 -1
  73. package/flow/steps/generate-changelog.d.ts +5 -0
  74. package/flow/steps/generate-changelog.d.ts.map +1 -1
  75. package/flow/steps/index.cjs.js +3663 -328
  76. package/flow/steps/index.cjs.js.map +1 -1
  77. package/flow/steps/index.d.ts +2 -1
  78. package/flow/steps/index.d.ts.map +1 -1
  79. package/flow/steps/index.esm.js +3661 -329
  80. package/flow/steps/index.esm.js.map +1 -1
  81. package/flow/steps/resolve-repository.d.ts +36 -0
  82. package/flow/steps/resolve-repository.d.ts.map +1 -0
  83. package/flow/steps/update-packages.d.ts.map +1 -1
  84. package/git/factory.d.ts +14 -0
  85. package/git/factory.d.ts.map +1 -1
  86. package/git/index.cjs.js +65 -0
  87. package/git/index.cjs.js.map +1 -1
  88. package/git/index.esm.js +66 -2
  89. package/git/index.esm.js.map +1 -1
  90. package/git/operations/index.cjs.js +40 -0
  91. package/git/operations/index.cjs.js.map +1 -1
  92. package/git/operations/index.d.ts +1 -1
  93. package/git/operations/index.d.ts.map +1 -1
  94. package/git/operations/index.esm.js +41 -2
  95. package/git/operations/index.esm.js.map +1 -1
  96. package/git/operations/log.d.ts +23 -0
  97. package/git/operations/log.d.ts.map +1 -1
  98. package/index.cjs.js +7547 -4947
  99. package/index.cjs.js.map +1 -1
  100. package/index.d.ts +3 -1
  101. package/index.d.ts.map +1 -1
  102. package/index.esm.js +7550 -4954
  103. package/index.esm.js.map +1 -1
  104. package/package.json +39 -1
  105. package/registry/index.cjs.js +3 -3
  106. package/registry/index.cjs.js.map +1 -1
  107. package/registry/index.esm.js +3 -3
  108. package/registry/index.esm.js.map +1 -1
  109. package/registry/models/index.cjs.js +2 -0
  110. package/registry/models/index.cjs.js.map +1 -1
  111. package/registry/models/index.esm.js +2 -0
  112. package/registry/models/index.esm.js.map +1 -1
  113. package/registry/models/version-info.d.ts +10 -0
  114. package/registry/models/version-info.d.ts.map +1 -1
  115. package/registry/npm/client.d.ts.map +1 -1
  116. package/registry/npm/index.cjs.js +1 -3
  117. package/registry/npm/index.cjs.js.map +1 -1
  118. package/registry/npm/index.esm.js +1 -3
  119. package/registry/npm/index.esm.js.map +1 -1
  120. package/repository/index.cjs.js +998 -0
  121. package/repository/index.cjs.js.map +1 -0
  122. package/repository/index.d.ts +4 -0
  123. package/repository/index.d.ts.map +1 -0
  124. package/repository/index.esm.js +981 -0
  125. package/repository/index.esm.js.map +1 -0
  126. package/repository/models/index.cjs.js +301 -0
  127. package/repository/models/index.cjs.js.map +1 -0
  128. package/repository/models/index.d.ts +7 -0
  129. package/repository/models/index.d.ts.map +1 -0
  130. package/repository/models/index.esm.js +290 -0
  131. package/repository/models/index.esm.js.map +1 -0
  132. package/repository/models/platform.d.ts +58 -0
  133. package/repository/models/platform.d.ts.map +1 -0
  134. package/repository/models/repository-config.d.ts +132 -0
  135. package/repository/models/repository-config.d.ts.map +1 -0
  136. package/repository/models/resolution.d.ts +121 -0
  137. package/repository/models/resolution.d.ts.map +1 -0
  138. package/repository/parse/index.cjs.js +755 -0
  139. package/repository/parse/index.cjs.js.map +1 -0
  140. package/repository/parse/index.d.ts +5 -0
  141. package/repository/parse/index.d.ts.map +1 -0
  142. package/repository/parse/index.esm.js +749 -0
  143. package/repository/parse/index.esm.js.map +1 -0
  144. package/repository/parse/package-json.d.ts +100 -0
  145. package/repository/parse/package-json.d.ts.map +1 -0
  146. package/repository/parse/url.d.ts +81 -0
  147. package/repository/parse/url.d.ts.map +1 -0
  148. package/repository/url/compare.d.ts +84 -0
  149. package/repository/url/compare.d.ts.map +1 -0
  150. package/repository/url/index.cjs.js +178 -0
  151. package/repository/url/index.cjs.js.map +1 -0
  152. package/repository/url/index.d.ts +3 -0
  153. package/repository/url/index.d.ts.map +1 -0
  154. package/repository/url/index.esm.js +176 -0
  155. package/repository/url/index.esm.js.map +1 -0
  156. package/workspace/discovery/changelog-path.d.ts +3 -7
  157. package/workspace/discovery/changelog-path.d.ts.map +1 -1
  158. package/workspace/discovery/index.cjs.js +408 -335
  159. package/workspace/discovery/index.cjs.js.map +1 -1
  160. package/workspace/discovery/index.esm.js +408 -335
  161. package/workspace/discovery/index.esm.js.map +1 -1
  162. package/workspace/discovery/packages.d.ts +0 -6
  163. package/workspace/discovery/packages.d.ts.map +1 -1
  164. package/workspace/index.cjs.js +84 -11
  165. package/workspace/index.cjs.js.map +1 -1
  166. package/workspace/index.esm.js +84 -11
  167. package/workspace/index.esm.js.map +1 -1
@@ -1,3 +1,6 @@
1
+ import { join, basename, relative } from 'node:path';
2
+ import { existsSync, readFileSync, statSync, lstatSync, readdirSync } from 'node:fs';
3
+
1
4
  /**
2
5
  * Safe copies of JSON built-in methods.
3
6
  *
@@ -18,134 +21,2751 @@ const parse = _JSON.parse;
18
21
  const stringify = _JSON.stringify;
19
22
 
20
23
  /**
21
- * Creates a flow step.
24
+ * Creates a flow step.
25
+ *
26
+ * @param id - Unique step identifier
27
+ * @param name - Human-readable step name
28
+ * @param execute - Step executor function
29
+ * @param options - Optional step configuration
30
+ * @returns A FlowStep object
31
+ *
32
+ * @example
33
+ * ```typescript
34
+ * const fetchStep = createStep(
35
+ * 'fetch-registry',
36
+ * 'Fetch Registry Version',
37
+ * async (ctx) => {
38
+ * const version = await ctx.registry.getLatestVersion(ctx.packageName)
39
+ * return {
40
+ * status: 'success',
41
+ * stateUpdates: { publishedVersion: version },
42
+ * message: `Found published version: ${version}`
43
+ * }
44
+ * }
45
+ * )
46
+ * ```
47
+ */
48
+ function createStep(id, name, execute, options = {}) {
49
+ return {
50
+ id,
51
+ name,
52
+ execute,
53
+ description: options.description,
54
+ skipIf: options.skipIf,
55
+ continueOnError: options.continueOnError,
56
+ dependsOn: options.dependsOn,
57
+ };
58
+ }
59
+ /**
60
+ * Creates a skipped step result.
61
+ *
62
+ * @param message - Explanation for why the step was skipped
63
+ * @returns A FlowStepResult with 'skipped' status
64
+ */
65
+ function createSkippedResult(message) {
66
+ return {
67
+ status: 'skipped',
68
+ message,
69
+ };
70
+ }
71
+
72
+ const FETCH_REGISTRY_STEP_ID = 'fetch-registry';
73
+ /**
74
+ * Creates the fetch-registry step.
75
+ *
76
+ * This step:
77
+ * 1. Queries the registry for the latest published version
78
+ * 2. Reads the current version from package.json
79
+ * 3. Determines if this is a first release
80
+ *
81
+ * State updates:
82
+ * - publishedVersion: Latest version on registry (null if not published)
83
+ * - currentVersion: Version from local package.json
84
+ * - isFirstRelease: True if never published
85
+ *
86
+ * @returns A FlowStep that fetches registry information
87
+ */
88
+ function createFetchRegistryStep() {
89
+ return createStep(FETCH_REGISTRY_STEP_ID, 'Fetch Registry Version', async (ctx) => {
90
+ const { registry, tree, projectRoot, packageName, logger } = ctx;
91
+ // Read local package.json for current version
92
+ const packageJsonPath = `${projectRoot}/package.json`;
93
+ let currentVersion = '0.0.0';
94
+ try {
95
+ const content = tree.read(packageJsonPath, 'utf-8');
96
+ if (content) {
97
+ const pkg = parse(content);
98
+ currentVersion = pkg.version ?? '0.0.0';
99
+ }
100
+ }
101
+ catch (error) {
102
+ logger.warn(`Could not read package.json: ${error}`);
103
+ }
104
+ // Query registry for published version
105
+ let publishedVersion = null;
106
+ let publishedCommit = null;
107
+ let isFirstRelease = true;
108
+ try {
109
+ publishedVersion = await registry.getLatestVersion(packageName);
110
+ isFirstRelease = publishedVersion === null;
111
+ // When published version exists, get its commit hash from gitHead
112
+ if (publishedVersion) {
113
+ try {
114
+ const versionInfo = await registry.getVersionInfo(packageName, publishedVersion);
115
+ publishedCommit = versionInfo?.gitHead ?? null;
116
+ if (publishedCommit) {
117
+ logger.debug(`Published ${publishedVersion} at commit ${publishedCommit.slice(0, 7)}`);
118
+ }
119
+ else {
120
+ logger.debug(`Published ${publishedVersion} has no gitHead (older package or published without git)`);
121
+ }
122
+ }
123
+ catch (error) {
124
+ // Version info fetch failed, but we still have the version
125
+ logger.debug(`Could not fetch version info for ${publishedVersion}: ${error}`);
126
+ }
127
+ }
128
+ }
129
+ catch (error) {
130
+ // Package might not exist yet, which is fine
131
+ logger.debug(`Registry query failed (package may not exist): ${error}`);
132
+ isFirstRelease = true;
133
+ }
134
+ const message = isFirstRelease
135
+ ? `First release (local: ${currentVersion})`
136
+ : `Published: ${publishedVersion}${publishedCommit ? ` @ ${publishedCommit.slice(0, 7)}` : ''}, Local: ${currentVersion}`;
137
+ return {
138
+ status: 'success',
139
+ stateUpdates: {
140
+ publishedVersion,
141
+ publishedCommit,
142
+ currentVersion,
143
+ isFirstRelease,
144
+ },
145
+ message,
146
+ };
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Safe copies of Error built-ins via factory functions.
152
+ *
153
+ * Since constructors cannot be safely captured via Object.assign, this module
154
+ * provides factory functions that use Reflect.construct internally.
155
+ *
156
+ * These references are captured at module initialization time to protect against
157
+ * prototype pollution attacks. Import only what you need for tree-shaking.
158
+ *
159
+ * @module @hyperfrontend/immutable-api-utils/built-in-copy/error
160
+ */
161
+ // Capture references at module initialization time
162
+ const _Error = globalThis.Error;
163
+ const _Reflect$4 = globalThis.Reflect;
164
+ /**
165
+ * (Safe copy) Creates a new Error using the captured Error constructor.
166
+ * Use this instead of `new Error()`.
167
+ *
168
+ * @param message - Optional error message.
169
+ * @param options - Optional error options.
170
+ * @returns A new Error instance.
171
+ */
172
+ const createError = (message, options) => _Reflect$4.construct(_Error, [message, options]);
173
+
174
+ /**
175
+ * Creates a new RepositoryConfig.
176
+ *
177
+ * Normalizes the base URL by stripping trailing slashes and validating
178
+ * that custom platforms have a formatter function.
179
+ *
180
+ * @param options - Repository configuration options
181
+ * @returns A new RepositoryConfig object
182
+ * @throws {Error} if platform is 'custom' but no formatCompareUrl is provided
183
+ *
184
+ * @example
185
+ * ```typescript
186
+ * // GitHub repository
187
+ * const config = createRepositoryConfig({
188
+ * platform: 'github',
189
+ * baseUrl: 'https://github.com/owner/repo'
190
+ * })
191
+ *
192
+ * // Custom platform
193
+ * const customConfig = createRepositoryConfig({
194
+ * platform: 'custom',
195
+ * baseUrl: 'https://my-git.internal/repo',
196
+ * formatCompareUrl: (from, to) => `https://my-git.internal/diff/${from}/${to}`
197
+ * })
198
+ * ```
199
+ */
200
+ function createRepositoryConfig(options) {
201
+ const { platform, formatCompareUrl } = options;
202
+ // Validate custom platform has formatter
203
+ if (platform === 'custom' && !formatCompareUrl) {
204
+ throw createError("Repository config with platform 'custom' requires a formatCompareUrl function");
205
+ }
206
+ // Normalize base URL - strip trailing slashes
207
+ const baseUrl = normalizeBaseUrl(options.baseUrl);
208
+ return {
209
+ platform,
210
+ baseUrl,
211
+ formatCompareUrl,
212
+ };
213
+ }
214
+ /**
215
+ * Checks if a value is a RepositoryConfig object.
216
+ *
217
+ * @param value - Value to check
218
+ * @returns True if the value is a RepositoryConfig
219
+ *
220
+ * @example
221
+ * ```typescript
222
+ * const config = { platform: 'github', baseUrl: 'https://...' }
223
+ * if (isRepositoryConfig(config)) {
224
+ * // config is typed as RepositoryConfig
225
+ * }
226
+ * ```
227
+ */
228
+ function isRepositoryConfig(value) {
229
+ if (typeof value !== 'object' || value === null) {
230
+ return false;
231
+ }
232
+ const obj = value;
233
+ return (typeof obj['platform'] === 'string' &&
234
+ typeof obj['baseUrl'] === 'string' &&
235
+ (obj['formatCompareUrl'] === undefined || typeof obj['formatCompareUrl'] === 'function'));
236
+ }
237
+ /**
238
+ * Normalizes a base URL by stripping trailing slashes and .git suffix.
239
+ *
240
+ * @param url - URL to normalize
241
+ * @returns Normalized URL
242
+ *
243
+ * @internal
244
+ */
245
+ function normalizeBaseUrl(url) {
246
+ let normalized = url.trim();
247
+ // Remove trailing slashes
248
+ while (normalized.endsWith('/')) {
249
+ normalized = normalized.slice(0, -1);
250
+ }
251
+ // Remove .git suffix if present
252
+ if (normalized.endsWith('.git')) {
253
+ normalized = normalized.slice(0, -4);
254
+ }
255
+ return normalized;
256
+ }
257
+
258
+ /**
259
+ * Creates a disabled repository resolution configuration.
260
+ *
261
+ * No compare URLs will be generated.
262
+ *
263
+ * @returns A RepositoryResolution with mode 'disabled'
264
+ *
265
+ * @example
266
+ * ```typescript
267
+ * const config = createDisabledResolution()
268
+ * // { mode: 'disabled' }
269
+ * ```
270
+ */
271
+ /**
272
+ * Checks if a value is a RepositoryResolution object.
273
+ *
274
+ * @param value - Value to check
275
+ * @returns True if the value is a RepositoryResolution
276
+ */
277
+ function isRepositoryResolution(value) {
278
+ if (typeof value !== 'object' || value === null) {
279
+ return false;
280
+ }
281
+ const obj = value;
282
+ const mode = obj['mode'];
283
+ return mode === 'explicit' || mode === 'inferred' || mode === 'disabled';
284
+ }
285
+ /**
286
+ * Default inference order when mode is 'inferred'.
287
+ */
288
+ const DEFAULT_INFERENCE_ORDER = ['package-json', 'git-remote'];
289
+
290
+ /**
291
+ * Safe copies of Map built-in via factory function.
292
+ *
293
+ * Since constructors cannot be safely captured via Object.assign, this module
294
+ * provides a factory function that uses Reflect.construct internally.
295
+ *
296
+ * These references are captured at module initialization time to protect against
297
+ * prototype pollution attacks. Import only what you need for tree-shaking.
298
+ *
299
+ * @module @hyperfrontend/immutable-api-utils/built-in-copy/map
300
+ */
301
+ // Capture references at module initialization time
302
+ const _Map = globalThis.Map;
303
+ const _Reflect$3 = globalThis.Reflect;
304
+ /**
305
+ * (Safe copy) Creates a new Map using the captured Map constructor.
306
+ * Use this instead of `new Map()`.
307
+ *
308
+ * @param iterable - Optional iterable of key-value pairs.
309
+ * @returns A new Map instance.
310
+ */
311
+ const createMap = (iterable) => _Reflect$3.construct(_Map, iterable ? [iterable] : []);
312
+
313
+ /**
314
+ * Safe copies of Math built-in methods.
315
+ *
316
+ * These references are captured at module initialization time to protect against
317
+ * prototype pollution attacks. Import only what you need for tree-shaking.
318
+ *
319
+ * @module @hyperfrontend/immutable-api-utils/built-in-copy/math
320
+ */
321
+ // Capture references at module initialization time
322
+ const _Math = globalThis.Math;
323
+ // ============================================================================
324
+ // Min/Max
325
+ // ============================================================================
326
+ /**
327
+ * (Safe copy) Returns the larger of zero or more numbers.
328
+ */
329
+ const max = _Math.max;
330
+ /**
331
+ * (Safe copy) Returns the smaller of zero or more numbers.
332
+ */
333
+ const min = _Math.min;
334
+
335
+ /**
336
+ * Safe copies of URL built-ins via factory functions.
337
+ *
338
+ * Provides safe references to URL and URLSearchParams.
339
+ * These references are captured at module initialization time to protect against
340
+ * prototype pollution attacks. Import only what you need for tree-shaking.
341
+ *
342
+ * @module @hyperfrontend/immutable-api-utils/built-in-copy/url
343
+ */
344
+ // Capture references at module initialization time
345
+ const _URL = globalThis.URL;
346
+ const _Reflect$2 = globalThis.Reflect;
347
+ // ============================================================================
348
+ // URL
349
+ // ============================================================================
350
+ /**
351
+ * (Safe copy) Creates a new URL using the captured URL constructor.
352
+ * Use this instead of `new URL()`.
353
+ *
354
+ * @param url - The URL string to parse.
355
+ * @param base - Optional base URL for relative URLs.
356
+ * @returns A new URL instance.
357
+ */
358
+ const createURL = (url, base) => _Reflect$2.construct(_URL, [url, base]);
359
+ /**
360
+ * (Safe copy) Creates an object URL for the given object.
361
+ * Use this instead of `URL.createObjectURL()`.
362
+ *
363
+ * Note: This is a browser-only API. In Node.js environments, this will throw.
364
+ */
365
+ typeof _URL.createObjectURL === 'function'
366
+ ? _URL.createObjectURL.bind(_URL)
367
+ : () => {
368
+ throw new Error('URL.createObjectURL is not available in this environment');
369
+ };
370
+ /**
371
+ * (Safe copy) Revokes an object URL previously created with createObjectURL.
372
+ * Use this instead of `URL.revokeObjectURL()`.
373
+ *
374
+ * Note: This is a browser-only API. In Node.js environments, this will throw.
375
+ */
376
+ typeof _URL.revokeObjectURL === 'function'
377
+ ? _URL.revokeObjectURL.bind(_URL)
378
+ : () => {
379
+ throw new Error('URL.revokeObjectURL is not available in this environment');
380
+ };
381
+
382
+ /**
383
+ * Checks if a platform identifier is a known platform with built-in support.
384
+ *
385
+ * @param platform - Platform identifier to check
386
+ * @returns True if the platform is a known platform
387
+ *
388
+ * @example
389
+ * ```typescript
390
+ * isKnownPlatform('github') // true
391
+ * isKnownPlatform('gitlab') // true
392
+ * isKnownPlatform('custom') // false
393
+ * isKnownPlatform('unknown') // false
394
+ * ```
395
+ */
396
+ function isKnownPlatform(platform) {
397
+ return platform === 'github' || platform === 'gitlab' || platform === 'bitbucket' || platform === 'azure-devops';
398
+ }
399
+ /**
400
+ * Known platform hostnames mapped to their platform type.
401
+ * Used for automatic platform detection from repository URLs.
402
+ *
403
+ * Includes both standard SaaS domains and common patterns for self-hosted instances.
404
+ */
405
+ const PLATFORM_HOSTNAMES = createMap([
406
+ // GitHub
407
+ ['github.com', 'github'],
408
+ // GitLab
409
+ ['gitlab.com', 'gitlab'],
410
+ // Bitbucket
411
+ ['bitbucket.org', 'bitbucket'],
412
+ // Azure DevOps
413
+ ['dev.azure.com', 'azure-devops'],
414
+ ['visualstudio.com', 'azure-devops'],
415
+ ]);
416
+ /**
417
+ * Detects platform from a hostname.
418
+ *
419
+ * First checks for exact match in known platforms, then applies heuristics
420
+ * for self-hosted instances (e.g., `github.company.com` → `github`).
421
+ *
422
+ * @param hostname - Hostname to detect platform from (e.g., "github.com")
423
+ * @returns Detected platform or 'unknown' if not recognized
424
+ *
425
+ * @example
426
+ * ```typescript
427
+ * detectPlatformFromHostname('github.com') // 'github'
428
+ * detectPlatformFromHostname('gitlab.mycompany.com') // 'gitlab'
429
+ * detectPlatformFromHostname('custom-git.internal') // 'unknown'
430
+ * ```
431
+ */
432
+ function detectPlatformFromHostname(hostname) {
433
+ const normalized = hostname.toLowerCase();
434
+ // Check exact matches first
435
+ const exactMatch = PLATFORM_HOSTNAMES.get(normalized);
436
+ if (exactMatch) {
437
+ return exactMatch;
438
+ }
439
+ // Check for Azure DevOps legacy domain pattern
440
+ if (normalized.endsWith('.visualstudio.com')) {
441
+ return 'azure-devops';
442
+ }
443
+ // Check for Azure DevOps modern domain pattern (includes ssh.dev.azure.com)
444
+ if (normalized.endsWith('.azure.com')) {
445
+ return 'azure-devops';
446
+ }
447
+ // Heuristics for self-hosted instances
448
+ // GitHub Enterprise typically uses "github" in the hostname
449
+ if (normalized.includes('github')) {
450
+ return 'github';
451
+ }
452
+ // GitLab self-hosted typically uses "gitlab" in the hostname
453
+ if (normalized.includes('gitlab')) {
454
+ return 'gitlab';
455
+ }
456
+ // Bitbucket Data Center/Server might use "bitbucket" in hostname
457
+ if (normalized.includes('bitbucket')) {
458
+ return 'bitbucket';
459
+ }
460
+ return 'unknown';
461
+ }
462
+
463
+ /**
464
+ * Parses a git URL and extracts platform and base URL.
465
+ *
466
+ * Supports multiple URL formats:
467
+ * - `https://github.com/owner/repo`
468
+ * - `https://github.com/owner/repo.git`
469
+ * - `git+https://github.com/owner/repo.git`
470
+ * - `git://github.com/owner/repo.git`
471
+ * - `git@github.com:owner/repo.git` (SSH format)
472
+ *
473
+ * Handles self-hosted instances by detecting platform from hostname:
474
+ * - `github.mycompany.com` → `github`
475
+ * - `gitlab.internal.com` → `gitlab`
476
+ *
477
+ * Handles Azure DevOps URL formats:
478
+ * - `https://dev.azure.com/org/project/_git/repo`
479
+ * - `https://org.visualstudio.com/project/_git/repo`
480
+ *
481
+ * @param gitUrl - Git repository URL in any supported format
482
+ * @returns Parsed repository info with platform and base URL, or null if parsing fails
483
+ *
484
+ * @example
485
+ * ```typescript
486
+ * // GitHub HTTPS
487
+ * parseRepositoryUrl('https://github.com/owner/repo')
488
+ * // → { platform: 'github', baseUrl: 'https://github.com/owner/repo' }
489
+ *
490
+ * // SSH format
491
+ * parseRepositoryUrl('git@github.com:owner/repo.git')
492
+ * // → { platform: 'github', baseUrl: 'https://github.com/owner/repo' }
493
+ *
494
+ * // Azure DevOps
495
+ * parseRepositoryUrl('https://dev.azure.com/org/proj/_git/repo')
496
+ * // → { platform: 'azure-devops', baseUrl: 'https://dev.azure.com/org/proj/_git/repo' }
497
+ *
498
+ * // Self-hosted GitLab
499
+ * parseRepositoryUrl('https://gitlab.mycompany.com/team/project')
500
+ * // → { platform: 'gitlab', baseUrl: 'https://gitlab.mycompany.com/team/project' }
501
+ * ```
502
+ */
503
+ function parseRepositoryUrl(gitUrl) {
504
+ if (!gitUrl || typeof gitUrl !== 'string') {
505
+ return null;
506
+ }
507
+ const trimmed = gitUrl.trim();
508
+ if (!trimmed) {
509
+ return null;
510
+ }
511
+ // Try SSH format first: git@hostname:path
512
+ const sshParsed = parseSshUrl(trimmed);
513
+ if (sshParsed) {
514
+ return sshParsed;
515
+ }
516
+ // Try HTTP(S) formats
517
+ const httpParsed = parseHttpUrl(trimmed);
518
+ if (httpParsed) {
519
+ return httpParsed;
520
+ }
521
+ return null;
522
+ }
523
+ /**
524
+ * Parses an SSH-style git URL.
525
+ *
526
+ * @param url - URL to parse (e.g., "git@github.com:owner/repo.git")
527
+ * @returns Parsed repository or null
528
+ *
529
+ * @internal
530
+ */
531
+ function parseSshUrl(url) {
532
+ // Handle optional ssh:// prefix
533
+ let remaining = url;
534
+ if (remaining.startsWith('ssh://')) {
535
+ remaining = remaining.slice(6);
536
+ }
537
+ // Must start with git@
538
+ if (!remaining.startsWith('git@')) {
539
+ return null;
540
+ }
541
+ // Remove git@ prefix
542
+ remaining = remaining.slice(4);
543
+ // Find the separator (: or /)
544
+ const colonIndex = remaining.indexOf(':');
545
+ const slashIndex = remaining.indexOf('/');
546
+ let separatorIndex;
547
+ if (colonIndex === -1 && slashIndex === -1) {
548
+ return null;
549
+ }
550
+ else if (colonIndex === -1) {
551
+ separatorIndex = slashIndex;
552
+ }
553
+ else if (slashIndex === -1) {
554
+ separatorIndex = colonIndex;
555
+ }
556
+ else {
557
+ separatorIndex = min(colonIndex, slashIndex);
558
+ }
559
+ const hostname = remaining.slice(0, separatorIndex);
560
+ const pathPart = normalizePathPart(remaining.slice(separatorIndex + 1));
561
+ if (!hostname || !pathPart) {
562
+ return null;
563
+ }
564
+ const platform = detectPlatformFromHostname(hostname);
565
+ // For Azure DevOps, construct proper base URL
566
+ if (platform === 'azure-devops') {
567
+ const baseUrl = constructAzureDevOpsBaseUrl(hostname, pathPart);
568
+ if (baseUrl) {
569
+ return { platform, baseUrl };
570
+ }
571
+ return null;
572
+ }
573
+ // Standard platforms: https://hostname/path
574
+ const baseUrl = `https://${hostname}/${pathPart}`;
575
+ return { platform, baseUrl };
576
+ }
577
+ /**
578
+ * Parses an HTTP(S)-style git URL.
579
+ *
580
+ * @param url - URL to parse
581
+ * @returns Parsed repository or null
582
+ *
583
+ * @internal
584
+ */
585
+ function parseHttpUrl(url) {
586
+ // Normalize various git URL prefixes to https://
587
+ const normalized = url
588
+ .replace(/^git\+/, '') // git+https:// → https://
589
+ .replace(/^git:\/\//, 'https://'); // git:// → https://
590
+ let parsed;
591
+ try {
592
+ parsed = createURL(normalized);
593
+ }
594
+ catch {
595
+ return null;
596
+ }
597
+ // Only support http and https protocols
598
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
599
+ return null;
600
+ }
601
+ const hostname = parsed.hostname.toLowerCase();
602
+ const platform = detectPlatformFromHostname(hostname);
603
+ const pathPart = normalizePathPart(parsed.pathname);
604
+ if (!pathPart) {
605
+ return null;
606
+ }
607
+ // Handle Azure DevOps special URL structure
608
+ if (platform === 'azure-devops') {
609
+ const baseUrl = constructAzureDevOpsBaseUrl(hostname, pathPart);
610
+ if (baseUrl) {
611
+ return { platform, baseUrl };
612
+ }
613
+ // If Azure DevOps URL cannot be parsed properly, return null
614
+ return null;
615
+ }
616
+ // Standard platforms
617
+ const baseUrl = `${parsed.protocol}//${hostname}/${pathPart}`;
618
+ return { platform, baseUrl };
619
+ }
620
+ /**
621
+ * Normalizes a path part by removing leading slashes and .git suffix.
622
+ *
623
+ * @param path - Path to normalize
624
+ * @returns Normalized path or null if empty
625
+ *
626
+ * @internal
627
+ */
628
+ function normalizePathPart(path) {
629
+ let normalized = path.trim();
630
+ // Remove leading slashes
631
+ while (normalized.startsWith('/')) {
632
+ normalized = normalized.slice(1);
633
+ }
634
+ // Remove trailing slashes
635
+ while (normalized.endsWith('/')) {
636
+ normalized = normalized.slice(0, -1);
637
+ }
638
+ // Remove .git suffix
639
+ if (normalized.endsWith('.git')) {
640
+ normalized = normalized.slice(0, -4);
641
+ }
642
+ // Validate we have something
643
+ if (!normalized) {
644
+ return null;
645
+ }
646
+ return normalized;
647
+ }
648
+ /**
649
+ * Constructs the base URL for Azure DevOps repositories.
650
+ *
651
+ * Azure DevOps has special URL structures:
652
+ * - Modern: `https://dev.azure.com/{org}/{project}/_git/{repo}`
653
+ * - Legacy: `https://{org}.visualstudio.com/{project}/_git/{repo}`
654
+ * - SSH: `git@ssh.dev.azure.com:v3/{org}/{project}/{repo}`
655
+ *
656
+ * @param hostname - Hostname from the URL
657
+ * @param pathPart - Path portion after hostname
658
+ * @returns Constructed base URL or null if invalid
659
+ *
660
+ * @internal
661
+ */
662
+ function constructAzureDevOpsBaseUrl(hostname, pathPart) {
663
+ const pathParts = pathPart.split('/');
664
+ // dev.azure.com format: org/project/_git/repo
665
+ if (hostname === 'dev.azure.com' || hostname.endsWith('.azure.com')) {
666
+ // Need at least: org/project/_git/repo (4 parts)
667
+ // Or for SSH v3: v3/org/project/repo (4 parts)
668
+ if (pathParts.length >= 4) {
669
+ // Check for v3 SSH format
670
+ if (pathParts[0] === 'v3') {
671
+ // v3/org/project/repo → https://dev.azure.com/org/project/_git/repo
672
+ const org = pathParts[1];
673
+ const project = pathParts[2];
674
+ const repo = pathParts[3];
675
+ if (org && project && repo) {
676
+ return `https://dev.azure.com/${org}/${project}/_git/${repo}`;
677
+ }
678
+ }
679
+ // Standard format: org/project/_git/repo
680
+ const gitIndex = pathParts.indexOf('_git');
681
+ if (gitIndex >= 2 && pathParts[gitIndex + 1]) {
682
+ const org = pathParts.slice(0, gitIndex - 1).join('/');
683
+ const project = pathParts[gitIndex - 1];
684
+ const repo = pathParts[gitIndex + 1];
685
+ if (org && project && repo) {
686
+ return `https://dev.azure.com/${org}/${project}/_git/${repo}`;
687
+ }
688
+ }
689
+ }
690
+ return null;
691
+ }
692
+ // visualstudio.com format: {org}.visualstudio.com/project/_git/repo
693
+ if (hostname.endsWith('.visualstudio.com')) {
694
+ const org = hostname.replace('.visualstudio.com', '');
695
+ const gitIndex = pathParts.indexOf('_git');
696
+ if (gitIndex >= 1 && pathParts[gitIndex + 1]) {
697
+ const project = pathParts.slice(0, gitIndex).join('/');
698
+ const repo = pathParts[gitIndex + 1];
699
+ if (project && repo) {
700
+ // Normalize to dev.azure.com format
701
+ return `https://dev.azure.com/${org}/${project}/_git/${repo}`;
702
+ }
703
+ }
704
+ return null;
705
+ }
706
+ return null;
707
+ }
708
+ /**
709
+ * Creates a RepositoryConfig from a git URL.
710
+ *
711
+ * This is a convenience function that combines `parseRepositoryUrl` with
712
+ * `createRepositoryConfig` to produce a ready-to-use configuration.
713
+ *
714
+ * @param gitUrl - Git repository URL in any supported format
715
+ * @returns RepositoryConfig or null if URL cannot be parsed
716
+ *
717
+ * @example
718
+ * ```typescript
719
+ * const config = createRepositoryConfigFromUrl('https://github.com/owner/repo')
720
+ * // → { platform: 'github', baseUrl: 'https://github.com/owner/repo' }
721
+ *
722
+ * const config = createRepositoryConfigFromUrl('git@gitlab.com:group/project.git')
723
+ * // → { platform: 'gitlab', baseUrl: 'https://gitlab.com/group/project' }
724
+ * ```
725
+ */
726
+ function createRepositoryConfigFromUrl(gitUrl) {
727
+ const parsed = parseRepositoryUrl(gitUrl);
728
+ if (!parsed) {
729
+ return null;
730
+ }
731
+ // Don't create configs for unknown platforms as they can't generate URLs
732
+ if (parsed.platform === 'unknown') {
733
+ return null;
734
+ }
735
+ return createRepositoryConfig({
736
+ platform: parsed.platform,
737
+ baseUrl: parsed.baseUrl,
738
+ });
739
+ }
740
+
741
+ /**
742
+ * Shorthand platform prefixes supported in package.json repository field.
743
+ *
744
+ * Format: `"platform:owner/repo"` or `"owner/repo"` (defaults to GitHub)
745
+ *
746
+ * @see https://docs.npmjs.com/cli/v9/configuring-npm/package-json#repository
747
+ */
748
+ const SHORTHAND_PLATFORMS = createMap([
749
+ ['github', 'https://github.com'],
750
+ ['gitlab', 'https://gitlab.com'],
751
+ ['bitbucket', 'https://bitbucket.org'],
752
+ ['gist', 'https://gist.github.com'],
753
+ ]);
754
+ /**
755
+ * Infers repository configuration from package.json content.
756
+ *
757
+ * Handles multiple formats:
758
+ * - Shorthand: `"github:owner/repo"`, `"gitlab:group/project"`, `"bitbucket:team/repo"`
759
+ * - Bare shorthand: `"owner/repo"` (defaults to GitHub)
760
+ * - URL string: `"https://github.com/owner/repo"`
761
+ * - Object with URL: `{ "type": "git", "url": "https://..." }`
762
+ *
763
+ * @param packageJsonContent - Raw JSON string content of package.json
764
+ * @returns RepositoryConfig or null if repository cannot be inferred
765
+ *
766
+ * @example
767
+ * ```typescript
768
+ * // Shorthand format
769
+ * inferRepositoryFromPackageJson('{"repository": "github:owner/repo"}')
770
+ * // → { platform: 'github', baseUrl: 'https://github.com/owner/repo' }
771
+ *
772
+ * // URL string
773
+ * inferRepositoryFromPackageJson('{"repository": "https://github.com/owner/repo"}')
774
+ * // → { platform: 'github', baseUrl: 'https://github.com/owner/repo' }
775
+ *
776
+ * // Object format
777
+ * inferRepositoryFromPackageJson('{"repository": {"type": "git", "url": "https://github.com/owner/repo"}}')
778
+ * // → { platform: 'github', baseUrl: 'https://github.com/owner/repo' }
779
+ *
780
+ * // Bare shorthand (defaults to GitHub)
781
+ * inferRepositoryFromPackageJson('{"repository": "owner/repo"}')
782
+ * // → { platform: 'github', baseUrl: 'https://github.com/owner/repo' }
783
+ * ```
784
+ */
785
+ function inferRepositoryFromPackageJson(packageJsonContent) {
786
+ if (!packageJsonContent || typeof packageJsonContent !== 'string') {
787
+ return null;
788
+ }
789
+ let packageJson;
790
+ try {
791
+ packageJson = parse(packageJsonContent);
792
+ }
793
+ catch {
794
+ return null;
795
+ }
796
+ return inferRepositoryFromPackageJsonObject(packageJson);
797
+ }
798
+ /**
799
+ * Infers repository configuration from a parsed package.json object.
800
+ *
801
+ * This is useful when you already have the parsed object.
802
+ *
803
+ * @param packageJson - Parsed package.json object
804
+ * @returns RepositoryConfig or null if repository cannot be inferred
805
+ *
806
+ * @example
807
+ * ```typescript
808
+ * const pkg = { repository: 'github:owner/repo' }
809
+ * inferRepositoryFromPackageJsonObject(pkg)
810
+ * // → { platform: 'github', baseUrl: 'https://github.com/owner/repo' }
811
+ * ```
812
+ */
813
+ function inferRepositoryFromPackageJsonObject(packageJson) {
814
+ const { repository } = packageJson;
815
+ if (!repository) {
816
+ return null;
817
+ }
818
+ // Handle string format
819
+ if (typeof repository === 'string') {
820
+ return parseRepositoryString(repository);
821
+ }
822
+ // Handle object format
823
+ if (typeof repository === 'object' && repository.url) {
824
+ return createRepositoryConfigFromUrl(repository.url);
825
+ }
826
+ return null;
827
+ }
828
+ /**
829
+ * Parses a repository string (shorthand or URL).
830
+ *
831
+ * @param repoString - Repository string from package.json
832
+ * @returns RepositoryConfig or null
833
+ *
834
+ * @internal
835
+ */
836
+ function parseRepositoryString(repoString) {
837
+ const trimmed = repoString.trim();
838
+ if (!trimmed) {
839
+ return null;
840
+ }
841
+ // Check for shorthand format: platform:owner/repo
842
+ const colonIndex = trimmed.indexOf(':');
843
+ if (colonIndex > 0) {
844
+ const potentialPlatform = trimmed.slice(0, colonIndex);
845
+ // Platform must be only letters (a-z, case insensitive)
846
+ if (isOnlyLetters(potentialPlatform)) {
847
+ const platform = potentialPlatform.toLowerCase();
848
+ const path = trimmed.slice(colonIndex + 1);
849
+ if (path) {
850
+ const baseUrl = SHORTHAND_PLATFORMS.get(platform);
851
+ if (baseUrl) {
852
+ // Construct full URL and parse it
853
+ const fullUrl = `${baseUrl}/${path}`;
854
+ return createRepositoryConfigFromUrl(fullUrl);
855
+ }
856
+ // Unknown shorthand platform - try as URL
857
+ return createRepositoryConfigFromUrl(trimmed);
858
+ }
859
+ }
860
+ }
861
+ // Check for bare shorthand: owner/repo (no protocol, no platform prefix)
862
+ // Must match pattern like "owner/repo" but not "https://..." or "git@..."
863
+ if (!trimmed.includes('://') && !trimmed.startsWith('git@')) {
864
+ if (isBareShorthand(trimmed)) {
865
+ // Bare shorthand defaults to GitHub
866
+ const fullUrl = `https://github.com/${trimmed}`;
867
+ return createRepositoryConfigFromUrl(fullUrl);
868
+ }
869
+ }
870
+ // Try as a full URL
871
+ return createRepositoryConfigFromUrl(trimmed);
872
+ }
873
+ /**
874
+ * Checks if a string contains only ASCII letters (a-z, A-Z).
875
+ *
876
+ * @param str - String to check
877
+ * @returns True if string contains only letters
878
+ *
879
+ * @internal
880
+ */
881
+ function isOnlyLetters(str) {
882
+ for (let i = 0; i < str.length; i++) {
883
+ const char = str.charCodeAt(i);
884
+ const isLowercase = char >= 97 && char <= 122; // a-z
885
+ const isUppercase = char >= 65 && char <= 90; // A-Z
886
+ if (!isLowercase && !isUppercase) {
887
+ return false;
888
+ }
889
+ }
890
+ return str.length > 0;
891
+ }
892
+ /**
893
+ * Checks if a string is a bare shorthand format (owner/repo).
894
+ * Must have exactly one forward slash with content on both sides.
895
+ *
896
+ * @param str - String to check
897
+ * @returns True if string matches owner/repo format
898
+ *
899
+ * @internal
900
+ */
901
+ function isBareShorthand(str) {
902
+ const slashIndex = str.indexOf('/');
903
+ if (slashIndex <= 0 || slashIndex === str.length - 1) {
904
+ return false;
905
+ }
906
+ // Must not have another slash
907
+ return str.indexOf('/', slashIndex + 1) === -1;
908
+ }
909
+
910
+ const RESOLVE_REPOSITORY_STEP_ID = 'resolve-repository';
911
+ /**
912
+ * Creates the resolve-repository step.
913
+ *
914
+ * This step resolves repository configuration for compare URL generation.
915
+ * It supports multiple resolution modes:
916
+ *
917
+ * - `undefined` or `'disabled'`: No-op, backward compatible default
918
+ * - `'inferred'`: Auto-detect from package.json or git remote
919
+ * - `RepositoryConfig`: Direct repository configuration provided
920
+ * - `RepositoryResolution`: Fine-grained control with mode and options
921
+ *
922
+ * State updates:
923
+ * - repositoryConfig: Resolved repository configuration (if successful)
924
+ *
925
+ * @returns A FlowStep that resolves repository configuration
926
+ *
927
+ * @example
928
+ * ```typescript
929
+ * // Auto-detect repository
930
+ * const flow = createFlow({
931
+ * repository: 'inferred'
932
+ * })
933
+ *
934
+ * // Explicit repository
935
+ * const flow = createFlow({
936
+ * repository: {
937
+ * platform: 'github',
938
+ * baseUrl: 'https://github.com/owner/repo'
939
+ * }
940
+ * })
941
+ * ```
942
+ */
943
+ function createResolveRepositoryStep() {
944
+ return createStep(RESOLVE_REPOSITORY_STEP_ID, 'Resolve Repository', async (ctx) => {
945
+ const { config, logger, tree, git, projectRoot } = ctx;
946
+ const repoConfig = config.repository;
947
+ // Disabled or undefined - no-op for backward compatibility
948
+ if (repoConfig === undefined || repoConfig === 'disabled') {
949
+ logger.debug('Repository resolution disabled');
950
+ return {
951
+ status: 'skipped',
952
+ message: 'Repository resolution disabled',
953
+ };
954
+ }
955
+ // Direct RepositoryConfig provided
956
+ if (isRepositoryConfig(repoConfig)) {
957
+ logger.debug(`Using explicit repository config: ${repoConfig.platform}`);
958
+ return {
959
+ status: 'success',
960
+ stateUpdates: {
961
+ repositoryConfig: repoConfig,
962
+ },
963
+ message: `Using explicit ${repoConfig.platform} repository`,
964
+ };
965
+ }
966
+ // Shorthand 'inferred' mode
967
+ if (repoConfig === 'inferred') {
968
+ const resolved = await inferRepository(tree, git, projectRoot, DEFAULT_INFERENCE_ORDER, logger);
969
+ if (resolved) {
970
+ return {
971
+ status: 'success',
972
+ stateUpdates: {
973
+ repositoryConfig: resolved,
974
+ },
975
+ message: `Inferred ${resolved.platform} repository from ${resolved.baseUrl}`,
976
+ };
977
+ }
978
+ // Graceful degradation - no error, just no URLs
979
+ logger.debug('Could not infer repository from package.json or git remote');
980
+ return {
981
+ status: 'skipped',
982
+ message: 'Could not infer repository configuration',
983
+ };
984
+ }
985
+ // Full RepositoryResolution object
986
+ if (isRepositoryResolution(repoConfig)) {
987
+ return handleRepositoryResolution(repoConfig, tree, git, projectRoot, logger);
988
+ }
989
+ // Unknown configuration - should not happen with TypeScript
990
+ logger.warn('Unknown repository configuration format');
991
+ return {
992
+ status: 'skipped',
993
+ message: 'Unknown repository configuration format',
994
+ };
995
+ }, {
996
+ description: 'Resolves repository configuration for compare URL generation',
997
+ });
998
+ }
999
+ /**
1000
+ * Handles a full RepositoryResolution configuration.
1001
+ *
1002
+ * @param resolution - Repository resolution configuration
1003
+ * @param tree - Virtual file system tree
1004
+ * @param git - Git client instance
1005
+ * @param projectRoot - Path to the project root
1006
+ * @param logger - Logger instance
1007
+ * @returns Flow step result with repository config or skip/error status
1008
+ * @internal
1009
+ */
1010
+ async function handleRepositoryResolution(resolution, tree, git, projectRoot, logger) {
1011
+ const { mode, repository, inferenceOrder } = resolution;
1012
+ // Disabled mode
1013
+ if (mode === 'disabled') {
1014
+ logger.debug('Repository resolution explicitly disabled');
1015
+ return {
1016
+ status: 'skipped',
1017
+ message: 'Repository resolution disabled',
1018
+ };
1019
+ }
1020
+ // Explicit mode - must have repository
1021
+ if (mode === 'explicit') {
1022
+ if (!repository) {
1023
+ return {
1024
+ status: 'failed',
1025
+ message: 'Repository config required when mode is "explicit"',
1026
+ error: createError('Repository config required when mode is "explicit"'),
1027
+ };
1028
+ }
1029
+ logger.debug(`Using explicit repository config: ${repository.platform}`);
1030
+ return {
1031
+ status: 'success',
1032
+ stateUpdates: {
1033
+ repositoryConfig: repository,
1034
+ },
1035
+ message: `Using explicit ${repository.platform} repository`,
1036
+ };
1037
+ }
1038
+ // Inferred mode
1039
+ const order = inferenceOrder ?? DEFAULT_INFERENCE_ORDER;
1040
+ const resolved = await inferRepository(tree, git, projectRoot, order, logger);
1041
+ if (resolved) {
1042
+ return {
1043
+ status: 'success',
1044
+ stateUpdates: {
1045
+ repositoryConfig: resolved,
1046
+ },
1047
+ message: `Inferred ${resolved.platform} repository`,
1048
+ };
1049
+ }
1050
+ // Graceful degradation
1051
+ logger.debug('Could not infer repository configuration');
1052
+ return {
1053
+ status: 'skipped',
1054
+ message: 'Could not infer repository configuration',
1055
+ };
1056
+ }
1057
+ /**
1058
+ * Infers repository configuration from available sources.
1059
+ *
1060
+ * @param tree - Virtual file system tree
1061
+ * @param git - Git client instance
1062
+ * @param projectRoot - Path to the project root
1063
+ * @param order - Inference source order
1064
+ * @param logger - Logger instance
1065
+ * @returns Repository config or null if none found
1066
+ * @internal
1067
+ */
1068
+ async function inferRepository(tree, git, projectRoot, order, logger) {
1069
+ for (const source of order) {
1070
+ const config = await inferFromSource(tree, git, projectRoot, source, logger);
1071
+ if (config) {
1072
+ logger.debug(`Inferred repository from ${source}: ${config.platform}`);
1073
+ return config;
1074
+ }
1075
+ }
1076
+ return null;
1077
+ }
1078
+ /**
1079
+ * Infers repository from a single source.
1080
+ *
1081
+ * @param tree - Virtual file system tree
1082
+ * @param git - Git client instance
1083
+ * @param projectRoot - Path to the project root
1084
+ * @param source - Inference source type
1085
+ * @param logger - Logger instance
1086
+ * @returns Repository config or null if not found
1087
+ * @internal
1088
+ */
1089
+ async function inferFromSource(tree, git, projectRoot, source, logger) {
1090
+ if (source === 'package-json') {
1091
+ return inferFromPackageJson(tree, projectRoot, logger);
1092
+ }
1093
+ if (source === 'git-remote') {
1094
+ return inferFromGitRemote(git, logger);
1095
+ }
1096
+ logger.warn(`Unknown inference source: ${source}`);
1097
+ return null;
1098
+ }
1099
+ /**
1100
+ * Infers repository from package.json repository field.
1101
+ *
1102
+ * @param tree - Virtual file system tree
1103
+ * @param projectRoot - Path to the project root
1104
+ * @param logger - Logger instance
1105
+ * @returns Repository config or null if not found
1106
+ * @internal
1107
+ */
1108
+ function inferFromPackageJson(tree, projectRoot, logger) {
1109
+ const packageJsonPath = `${projectRoot}/package.json`;
1110
+ if (!tree.exists(packageJsonPath)) {
1111
+ logger.debug(`package.json not found at ${packageJsonPath}`);
1112
+ return null;
1113
+ }
1114
+ const content = tree.read(packageJsonPath, 'utf-8');
1115
+ if (!content) {
1116
+ logger.debug('Could not read package.json');
1117
+ return null;
1118
+ }
1119
+ const config = inferRepositoryFromPackageJson(content);
1120
+ if (config) {
1121
+ logger.debug(`Found repository in package.json: ${config.baseUrl}`);
1122
+ }
1123
+ return config;
1124
+ }
1125
+ /**
1126
+ * Infers repository from git remote URL.
1127
+ *
1128
+ * @param git - Git client instance
1129
+ * @param logger - Logger instance
1130
+ * @returns Repository config or null if not found
1131
+ * @internal
1132
+ */
1133
+ async function inferFromGitRemote(git, logger) {
1134
+ const remoteUrl = await git.getRemoteUrl('origin');
1135
+ if (!remoteUrl) {
1136
+ logger.debug('Could not get git remote URL');
1137
+ return null;
1138
+ }
1139
+ const config = createRepositoryConfigFromUrl(remoteUrl);
1140
+ if (config) {
1141
+ logger.debug(`Inferred repository from git remote: ${config.baseUrl}`);
1142
+ }
1143
+ return config;
1144
+ }
1145
+
1146
+ /**
1147
+ * Safe copies of Set built-in via factory function.
1148
+ *
1149
+ * Since constructors cannot be safely captured via Object.assign, this module
1150
+ * provides a factory function that uses Reflect.construct internally.
1151
+ *
1152
+ * These references are captured at module initialization time to protect against
1153
+ * prototype pollution attacks. Import only what you need for tree-shaking.
1154
+ *
1155
+ * @module @hyperfrontend/immutable-api-utils/built-in-copy/set
1156
+ */
1157
+ // Capture references at module initialization time
1158
+ const _Set = globalThis.Set;
1159
+ const _Reflect$1 = globalThis.Reflect;
1160
+ /**
1161
+ * (Safe copy) Creates a new Set using the captured Set constructor.
1162
+ * Use this instead of `new Set()`.
1163
+ *
1164
+ * @param iterable - Optional iterable of values.
1165
+ * @returns A new Set instance.
1166
+ */
1167
+ const createSet = (iterable) => _Reflect$1.construct(_Set, iterable ? [iterable] : []);
1168
+
1169
+ /**
1170
+ * Safe copies of Object built-in methods.
1171
+ *
1172
+ * These references are captured at module initialization time to protect against
1173
+ * prototype pollution attacks. Import only what you need for tree-shaking.
1174
+ *
1175
+ * @module @hyperfrontend/immutable-api-utils/built-in-copy/object
1176
+ */
1177
+ // Capture references at module initialization time
1178
+ const _Object = globalThis.Object;
1179
+ /**
1180
+ * (Safe copy) Prevents modification of existing property attributes and values,
1181
+ * and prevents the addition of new properties.
1182
+ */
1183
+ const freeze = _Object.freeze;
1184
+ /**
1185
+ * (Safe copy) Returns the names of the enumerable string properties and methods of an object.
1186
+ */
1187
+ const keys = _Object.keys;
1188
+ /**
1189
+ * (Safe copy) Returns an array of key/values of the enumerable own properties of an object.
1190
+ */
1191
+ const entries = _Object.entries;
1192
+ /**
1193
+ * (Safe copy) Returns an array of values of the enumerable own properties of an object.
1194
+ */
1195
+ const values = _Object.values;
1196
+ /**
1197
+ * (Safe copy) Adds one or more properties to an object, and/or modifies attributes of existing properties.
1198
+ */
1199
+ const defineProperties = _Object.defineProperties;
1200
+
1201
+ /**
1202
+ * Safe copies of Array built-in static methods.
1203
+ *
1204
+ * These references are captured at module initialization time to protect against
1205
+ * prototype pollution attacks. Import only what you need for tree-shaking.
1206
+ *
1207
+ * @module @hyperfrontend/immutable-api-utils/built-in-copy/array
1208
+ */
1209
+ // Capture references at module initialization time
1210
+ const _Array = globalThis.Array;
1211
+ /**
1212
+ * (Safe copy) Determines whether the passed value is an Array.
1213
+ */
1214
+ const isArray = _Array.isArray;
1215
+
1216
+ /**
1217
+ * Safe copies of Console built-in methods.
1218
+ *
1219
+ * These references are captured at module initialization time to protect against
1220
+ * prototype pollution attacks. Import only what you need for tree-shaking.
1221
+ *
1222
+ * @module @hyperfrontend/immutable-api-utils/built-in-copy/console
1223
+ */
1224
+ // Capture references at module initialization time
1225
+ const _console = globalThis.console;
1226
+ /**
1227
+ * (Safe copy) Outputs a message to the console.
1228
+ */
1229
+ const log = _console.log.bind(_console);
1230
+ /**
1231
+ * (Safe copy) Outputs a warning message to the console.
1232
+ */
1233
+ const warn = _console.warn.bind(_console);
1234
+ /**
1235
+ * (Safe copy) Outputs an error message to the console.
1236
+ */
1237
+ const error = _console.error.bind(_console);
1238
+ /**
1239
+ * (Safe copy) Outputs an informational message to the console.
1240
+ */
1241
+ const info = _console.info.bind(_console);
1242
+ /**
1243
+ * (Safe copy) Outputs a debug message to the console.
1244
+ */
1245
+ const debug = _console.debug.bind(_console);
1246
+ /**
1247
+ * (Safe copy) Outputs a stack trace to the console.
1248
+ */
1249
+ _console.trace.bind(_console);
1250
+ /**
1251
+ * (Safe copy) Displays an interactive listing of the properties of a specified object.
1252
+ */
1253
+ _console.dir.bind(_console);
1254
+ /**
1255
+ * (Safe copy) Displays tabular data as a table.
1256
+ */
1257
+ _console.table.bind(_console);
1258
+ /**
1259
+ * (Safe copy) Writes an error message to the console if the assertion is false.
1260
+ */
1261
+ _console.assert.bind(_console);
1262
+ /**
1263
+ * (Safe copy) Clears the console.
1264
+ */
1265
+ _console.clear.bind(_console);
1266
+ /**
1267
+ * (Safe copy) Logs the number of times that this particular call to count() has been called.
1268
+ */
1269
+ _console.count.bind(_console);
1270
+ /**
1271
+ * (Safe copy) Resets the counter used with console.count().
1272
+ */
1273
+ _console.countReset.bind(_console);
1274
+ /**
1275
+ * (Safe copy) Creates a new inline group in the console.
1276
+ */
1277
+ _console.group.bind(_console);
1278
+ /**
1279
+ * (Safe copy) Creates a new inline group in the console that is initially collapsed.
1280
+ */
1281
+ _console.groupCollapsed.bind(_console);
1282
+ /**
1283
+ * (Safe copy) Exits the current inline group.
1284
+ */
1285
+ _console.groupEnd.bind(_console);
1286
+ /**
1287
+ * (Safe copy) Starts a timer with a name specified as an input parameter.
1288
+ */
1289
+ _console.time.bind(_console);
1290
+ /**
1291
+ * (Safe copy) Stops a timer that was previously started.
1292
+ */
1293
+ _console.timeEnd.bind(_console);
1294
+ /**
1295
+ * (Safe copy) Logs the current value of a timer that was previously started.
1296
+ */
1297
+ _console.timeLog.bind(_console);
1298
+
1299
+ const registeredClasses = [];
1300
+
1301
+ /**
1302
+ * Returns the data type of the target.
1303
+ * Uses native `typeof` operator, however, makes distinction between `null`, `array`, and `object`.
1304
+ * Also, when classes are registered via `registerClass`, it checks if objects are instance of any known registered class.
1305
+ *
1306
+ * @param target - The target to get the data type of.
1307
+ * @returns The data type of the target.
1308
+ */
1309
+ const getType = (target) => {
1310
+ if (target === null)
1311
+ return 'null';
1312
+ const nativeDataType = typeof target;
1313
+ if (nativeDataType === 'object') {
1314
+ if (isArray(target))
1315
+ return 'array';
1316
+ for (const registeredClass of registeredClasses) {
1317
+ if (target instanceof registeredClass)
1318
+ return registeredClass.name;
1319
+ }
1320
+ }
1321
+ return nativeDataType;
1322
+ };
1323
+
1324
+ /**
1325
+ * Safe copies of Date built-in via factory function and static methods.
1326
+ *
1327
+ * Since constructors cannot be safely captured via Object.assign, this module
1328
+ * provides a factory function that uses Reflect.construct internally.
1329
+ *
1330
+ * These references are captured at module initialization time to protect against
1331
+ * prototype pollution attacks. Import only what you need for tree-shaking.
1332
+ *
1333
+ * @module @hyperfrontend/immutable-api-utils/built-in-copy/date
1334
+ */
1335
+ // Capture references at module initialization time
1336
+ const _Date = globalThis.Date;
1337
+ const _Reflect = globalThis.Reflect;
1338
+ function createDate(...args) {
1339
+ return _Reflect.construct(_Date, args);
1340
+ }
1341
+
1342
+ /* eslint-disable @typescript-eslint/no-explicit-any */
1343
+ /**
1344
+ * Creates a wrapper function that only executes the wrapped function if the condition function returns true.
1345
+ *
1346
+ * @param func - The function to be conditionally executed.
1347
+ * @param conditionFunc - A function that returns a boolean, determining if `func` should be executed.
1348
+ * @returns A wrapped version of `func` that executes conditionally.
1349
+ */
1350
+ function createConditionalExecutionFunction(func, conditionFunc) {
1351
+ return function (...args) {
1352
+ if (conditionFunc()) {
1353
+ return func(...args);
1354
+ }
1355
+ };
1356
+ }
1357
+
1358
+ /* eslint-disable @typescript-eslint/no-explicit-any */
1359
+ /**
1360
+ * Creates a wrapper function that silently ignores any errors thrown by the wrapped void function.
1361
+ * This function is specifically for wrapping functions that do not return a value (void functions).
1362
+ * Exceptions are swallowed without any logging or handling.
1363
+ *
1364
+ * @param func - The void function to be wrapped.
1365
+ * @returns A wrapped version of the input function that ignores errors.
1366
+ */
1367
+ function createErrorIgnoringFunction(func) {
1368
+ return function (...args) {
1369
+ try {
1370
+ func(...args);
1371
+ }
1372
+ catch {
1373
+ // Deliberately swallowing/ignoring the exception
1374
+ }
1375
+ };
1376
+ }
1377
+
1378
+ /* eslint-disable @typescript-eslint/no-unused-vars */
1379
+ /**
1380
+ * A no-operation function (noop) that does nothing regardless of the arguments passed.
1381
+ * It is designed to be as permissive as possible in its typing without using the `Function` keyword.
1382
+ *
1383
+ * @param args - Any arguments passed to the function (ignored)
1384
+ */
1385
+ const noop = (...args) => {
1386
+ // Intentionally does nothing
1387
+ };
1388
+
1389
+ const logLevels = ['none', 'error', 'warn', 'log', 'info', 'debug'];
1390
+ const priority = {
1391
+ error: 4,
1392
+ warn: 3,
1393
+ log: 2,
1394
+ info: 1,
1395
+ debug: 0,
1396
+ };
1397
+ /**
1398
+ * Validates whether a given string is a valid log level.
1399
+ *
1400
+ * @param level - The log level to validate
1401
+ * @returns True if the level is valid, false otherwise
1402
+ */
1403
+ function isValidLogLevel(level) {
1404
+ return logLevels.includes(level);
1405
+ }
1406
+ /**
1407
+ * Creates a log level configuration manager for controlling logging behavior.
1408
+ * Provides methods to get, set, and evaluate log levels based on priority.
1409
+ *
1410
+ * @param level - The initial log level (defaults to 'error')
1411
+ * @returns A configuration object with log level management methods
1412
+ * @throws {Error} When the provided level is not a valid log level
1413
+ */
1414
+ function createLogLevelConfig(level = 'error') {
1415
+ if (!isValidLogLevel(level)) {
1416
+ throw createError('Cannot create log level configuration with a valid default log level');
1417
+ }
1418
+ const state = { level };
1419
+ const getLogLevel = () => state.level;
1420
+ const setLogLevel = (level) => {
1421
+ if (!isValidLogLevel(level)) {
1422
+ throw createError(`Cannot set value '${level}' level. Expected levels are ${logLevels}.`);
1423
+ }
1424
+ state.level = level;
1425
+ };
1426
+ const shouldLog = (level) => {
1427
+ if (state.level === 'none' || level === 'none' || !isValidLogLevel(level)) {
1428
+ return false;
1429
+ }
1430
+ return priority[level] >= priority[state.level];
1431
+ };
1432
+ return freeze({
1433
+ getLogLevel,
1434
+ setLogLevel,
1435
+ shouldLog,
1436
+ });
1437
+ }
1438
+
1439
+ /**
1440
+ * Creates a logger instance with configurable log level filtering.
1441
+ * Each log function is wrapped to respect the current log level setting.
1442
+ *
1443
+ * @param error - Function to handle error-level logs (required)
1444
+ * @param warn - Function to handle warning-level logs (optional, defaults to noop)
1445
+ * @param log - Function to handle standard logs (optional, defaults to noop)
1446
+ * @param info - Function to handle info-level logs (optional, defaults to noop)
1447
+ * @param debug - Function to handle debug-level logs (optional, defaults to noop)
1448
+ * @returns A frozen logger object with log methods and level control
1449
+ * @throws {ErrorLevelFn} When any provided log function is invalid
1450
+ */
1451
+ function createLogger(error, warn = noop, log = noop, info = noop, debug = noop) {
1452
+ if (notValidLogFn(error)) {
1453
+ throw createError(notFnMsg('error'));
1454
+ }
1455
+ if (notValidLogFn(warn)) {
1456
+ throw createError(notFnMsg('warn'));
1457
+ }
1458
+ if (notValidLogFn(log)) {
1459
+ throw createError(notFnMsg('log'));
1460
+ }
1461
+ if (notValidLogFn(info)) {
1462
+ throw createError(notFnMsg('info'));
1463
+ }
1464
+ if (notValidLogFn(debug)) {
1465
+ throw createError(notFnMsg('debug'));
1466
+ }
1467
+ const { setLogLevel, getLogLevel, shouldLog } = createLogLevelConfig();
1468
+ const wrapLogFn = (fn, level) => {
1469
+ if (fn === noop)
1470
+ return fn;
1471
+ const condition = () => shouldLog(level);
1472
+ return createConditionalExecutionFunction(createErrorIgnoringFunction(fn), condition);
1473
+ };
1474
+ return freeze({
1475
+ error: wrapLogFn(error, 'error'),
1476
+ warn: wrapLogFn(warn, 'warn'),
1477
+ log: wrapLogFn(log, 'log'),
1478
+ info: wrapLogFn(info, 'info'),
1479
+ debug: wrapLogFn(debug, 'debug'),
1480
+ setLogLevel,
1481
+ getLogLevel,
1482
+ });
1483
+ }
1484
+ /**
1485
+ * Validates whether a given value is a valid log function.
1486
+ *
1487
+ * @param fn - The value to validate
1488
+ * @returns True if the value is not a function (invalid), false if it is valid
1489
+ */
1490
+ function notValidLogFn(fn) {
1491
+ return getType(fn) !== 'function' && fn !== noop;
1492
+ }
1493
+ /**
1494
+ * Generates an error message for invalid log function parameters.
1495
+ *
1496
+ * @param label - The name of the log function that failed validation
1497
+ * @returns A formatted error message string
1498
+ */
1499
+ function notFnMsg(label) {
1500
+ return `Cannot create a logger when ${label} is not a function`;
1501
+ }
1502
+
1503
+ createLogger(error, warn, log, info, debug);
1504
+
1505
+ /**
1506
+ * Global log level registry.
1507
+ * Tracks all created scoped loggers to allow global log level changes.
1508
+ */
1509
+ const loggerRegistry = createSet();
1510
+ /** Redacted placeholder for sensitive values */
1511
+ const REDACTED = '[REDACTED]';
1512
+ /**
1513
+ * Patterns that indicate a sensitive key name.
1514
+ * Keys containing these patterns will have their values sanitized.
1515
+ */
1516
+ const SENSITIVE_KEY_PATTERNS = [
1517
+ /token/i,
1518
+ /key/i,
1519
+ /password/i,
1520
+ /secret/i,
1521
+ /credential/i,
1522
+ /auth/i,
1523
+ /bearer/i,
1524
+ /api[_-]?key/i,
1525
+ /private/i,
1526
+ /passphrase/i,
1527
+ ];
1528
+ /**
1529
+ * Checks if a key name indicates sensitive data.
1530
+ *
1531
+ * @param key - Key name to check
1532
+ * @returns True if the key indicates sensitive data
1533
+ */
1534
+ function isSensitiveKey(key) {
1535
+ return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key));
1536
+ }
1537
+ /**
1538
+ * Sanitizes an object by replacing sensitive values with REDACTED.
1539
+ * This function recursively processes nested objects and arrays.
1540
+ *
1541
+ * @param obj - Object to sanitize
1542
+ * @returns New object with sensitive values redacted
1543
+ */
1544
+ function sanitize(obj) {
1545
+ if (obj === null || obj === undefined) {
1546
+ return obj;
1547
+ }
1548
+ if (isArray(obj)) {
1549
+ return obj.map((item) => sanitize(item));
1550
+ }
1551
+ if (typeof obj === 'object') {
1552
+ const result = {};
1553
+ for (const [key, value] of entries(obj)) {
1554
+ if (isSensitiveKey(key)) {
1555
+ result[key] = REDACTED;
1556
+ }
1557
+ else if (typeof value === 'object' && value !== null) {
1558
+ result[key] = sanitize(value);
1559
+ }
1560
+ else {
1561
+ result[key] = value;
1562
+ }
1563
+ }
1564
+ return result;
1565
+ }
1566
+ return obj;
1567
+ }
1568
+ /**
1569
+ * Formats a log message with optional metadata.
1570
+ *
1571
+ * @param namespace - Logger namespace prefix
1572
+ * @param message - Log message
1573
+ * @param meta - Optional metadata object
1574
+ * @returns Formatted log string
1575
+ */
1576
+ function formatMessage(namespace, message, meta) {
1577
+ const prefix = `[${namespace}]`;
1578
+ if (meta && keys(meta).length > 0) {
1579
+ const sanitizedMeta = sanitize(meta);
1580
+ return `${prefix} ${message} ${stringify(sanitizedMeta)}`;
1581
+ }
1582
+ return `${prefix} ${message}`;
1583
+ }
1584
+ /**
1585
+ * Creates a scoped logger with namespace prefix and optional secret sanitization.
1586
+ * All log messages will be prefixed with [namespace] and sensitive metadata
1587
+ * values will be automatically redacted.
1588
+ *
1589
+ * @param namespace - Logger namespace (e.g., 'project-scope', 'analyze')
1590
+ * @param options - Logger configuration options
1591
+ * @returns A configured scoped logger instance
1592
+ *
1593
+ * @example
1594
+ * ```typescript
1595
+ * const logger = createScopedLogger('project-scope')
1596
+ * logger.setLogLevel('debug')
1597
+ *
1598
+ * // Basic logging
1599
+ * logger.info('Starting analysis', { path: './project' })
1600
+ *
1601
+ * // Sensitive data is automatically redacted
1602
+ * logger.debug('Config loaded', { apiKey: 'secret123' })
1603
+ * // Output: [project-scope] Config loaded {"apiKey":"[REDACTED]"}
1604
+ * ```
1605
+ */
1606
+ function createScopedLogger(namespace, options = {}) {
1607
+ const { level = 'error', sanitizeSecrets = true } = options;
1608
+ // Create wrapper functions that add namespace prefix and sanitization
1609
+ const createLogFn = (baseFn) => (message, meta) => {
1610
+ const processedMeta = sanitizeSecrets && meta ? sanitize(meta) : meta;
1611
+ baseFn(formatMessage(namespace, message, processedMeta));
1612
+ };
1613
+ // Create base logger with wrapped functions
1614
+ const baseLogger = createLogger(createLogFn(error), createLogFn(warn), createLogFn(log), createLogFn(info), createLogFn(debug));
1615
+ // Set initial log level (use global override if set)
1616
+ baseLogger.setLogLevel(level);
1617
+ const scopedLogger = freeze({
1618
+ error: (message, meta) => baseLogger.error(message, meta),
1619
+ warn: (message, meta) => baseLogger.warn(message, meta),
1620
+ log: (message, meta) => baseLogger.log(message, meta),
1621
+ info: (message, meta) => baseLogger.info(message, meta),
1622
+ debug: (message, meta) => baseLogger.debug(message, meta),
1623
+ setLogLevel: baseLogger.setLogLevel,
1624
+ getLogLevel: baseLogger.getLogLevel,
1625
+ });
1626
+ // Register logger for global level management
1627
+ loggerRegistry.add(scopedLogger);
1628
+ return scopedLogger;
1629
+ }
1630
+ /**
1631
+ * Default logger instance for the project-scope library.
1632
+ * Use this for general logging within the library.
1633
+ *
1634
+ * @example
1635
+ * ```typescript
1636
+ * import { logger } from '@hyperfrontend/project-scope/core'
1637
+ *
1638
+ * logger.setLogLevel('debug')
1639
+ * logger.debug('Analyzing project', { path: './src' })
1640
+ * ```
1641
+ */
1642
+ createScopedLogger('project-scope');
1643
+
1644
+ createScopedLogger('project-scope:fs');
1645
+ /**
1646
+ * Create a file system error with code and context.
1647
+ *
1648
+ * @param message - The error message describing what went wrong
1649
+ * @param code - The category code for this type of filesystem failure
1650
+ * @param context - Additional context including path, operation, and cause
1651
+ * @returns A configured Error object with code and context properties
1652
+ */
1653
+ function createFileSystemError(message, code, context) {
1654
+ const error = createError(message);
1655
+ defineProperties(error, {
1656
+ code: { value: code, enumerable: true },
1657
+ context: { value: context, enumerable: true },
1658
+ });
1659
+ return error;
1660
+ }
1661
+ /**
1662
+ * Read file if exists, return null otherwise.
1663
+ *
1664
+ * @param filePath - Path to file
1665
+ * @param encoding - File encoding (default: utf-8)
1666
+ * @returns File contents or null if file doesn't exist
1667
+ */
1668
+ function readFileIfExists(filePath, encoding = 'utf-8') {
1669
+ if (!existsSync(filePath)) {
1670
+ return null;
1671
+ }
1672
+ try {
1673
+ return readFileSync(filePath, { encoding });
1674
+ }
1675
+ catch {
1676
+ return null;
1677
+ }
1678
+ }
1679
+ /**
1680
+ * Read and parse JSON file if exists, return null otherwise.
1681
+ *
1682
+ * @param filePath - Path to JSON file
1683
+ * @returns Parsed JSON object or null if file doesn't exist or is invalid
1684
+ */
1685
+ function readJsonFileIfExists(filePath) {
1686
+ if (!existsSync(filePath)) {
1687
+ return null;
1688
+ }
1689
+ try {
1690
+ const content = readFileSync(filePath, { encoding: 'utf-8' });
1691
+ return parse(content);
1692
+ }
1693
+ catch {
1694
+ return null;
1695
+ }
1696
+ }
1697
+
1698
+ createScopedLogger('project-scope:fs:write');
1699
+
1700
+ /**
1701
+ * Get file stats with error handling.
1702
+ *
1703
+ * @param filePath - Path to file
1704
+ * @param followSymlinks - Whether to follow symlinks (default: true)
1705
+ * @returns File stats or null if path doesn't exist
1706
+ */
1707
+ function getFileStat(filePath, followSymlinks = true) {
1708
+ if (!existsSync(filePath)) {
1709
+ return null;
1710
+ }
1711
+ try {
1712
+ const stat = followSymlinks ? statSync(filePath) : lstatSync(filePath);
1713
+ return {
1714
+ isFile: stat.isFile(),
1715
+ isDirectory: stat.isDirectory(),
1716
+ isSymlink: stat.isSymbolicLink(),
1717
+ size: stat.size,
1718
+ created: stat.birthtime,
1719
+ modified: stat.mtime,
1720
+ accessed: stat.atime,
1721
+ mode: stat.mode,
1722
+ };
1723
+ }
1724
+ catch {
1725
+ return null;
1726
+ }
1727
+ }
1728
+ /**
1729
+ * Check if path is a directory.
1730
+ *
1731
+ * @param dirPath - Path to check
1732
+ * @returns True if path is a directory
1733
+ */
1734
+ function isDirectory(dirPath) {
1735
+ const stats = getFileStat(dirPath);
1736
+ return stats?.isDirectory ?? false;
1737
+ }
1738
+ /**
1739
+ * Check if path exists.
1740
+ *
1741
+ * @param filePath - Path to check
1742
+ * @returns True if path exists
1743
+ */
1744
+ function exists(filePath) {
1745
+ return existsSync(filePath);
1746
+ }
1747
+
1748
+ const fsDirLogger = createScopedLogger('project-scope:fs:dir');
1749
+ /**
1750
+ * List immediate contents of a directory.
1751
+ *
1752
+ * @param dirPath - Absolute or relative path to the directory
1753
+ * @returns Array of entries with metadata for each file/directory
1754
+ * @throws {Error} If directory doesn't exist or isn't a directory
1755
+ *
1756
+ * @example
1757
+ * ```typescript
1758
+ * import { readDirectory } from '@hyperfrontend/project-scope'
1759
+ *
1760
+ * const entries = readDirectory('./src')
1761
+ * for (const entry of entries) {
1762
+ * console.log(entry.name, entry.isFile ? 'file' : 'directory')
1763
+ * }
1764
+ * ```
1765
+ */
1766
+ function readDirectory(dirPath) {
1767
+ fsDirLogger.debug('Reading directory', { path: dirPath });
1768
+ if (!existsSync(dirPath)) {
1769
+ fsDirLogger.debug('Directory not found', { path: dirPath });
1770
+ throw createFileSystemError(`Directory not found: ${dirPath}`, 'FS_NOT_FOUND', { path: dirPath, operation: 'readdir' });
1771
+ }
1772
+ if (!isDirectory(dirPath)) {
1773
+ fsDirLogger.debug('Path is not a directory', { path: dirPath });
1774
+ throw createFileSystemError(`Not a directory: ${dirPath}`, 'FS_NOT_A_DIRECTORY', { path: dirPath, operation: 'readdir' });
1775
+ }
1776
+ try {
1777
+ const entries = readdirSync(dirPath, { withFileTypes: true });
1778
+ fsDirLogger.debug('Directory read complete', { path: dirPath, entryCount: entries.length });
1779
+ return entries.map((entry) => ({
1780
+ name: entry.name,
1781
+ path: join(dirPath, entry.name),
1782
+ isFile: entry.isFile(),
1783
+ isDirectory: entry.isDirectory(),
1784
+ isSymlink: entry.isSymbolicLink(),
1785
+ }));
1786
+ }
1787
+ catch (error) {
1788
+ fsDirLogger.warn('Failed to read directory', { path: dirPath, error: error instanceof Error ? error.message : String(error) });
1789
+ throw createFileSystemError(`Failed to read directory: ${dirPath}`, 'FS_READ_ERROR', {
1790
+ path: dirPath,
1791
+ operation: 'readdir',
1792
+ cause: error,
1793
+ });
1794
+ }
1795
+ }
1796
+
1797
+ createScopedLogger('project-scope:fs:traversal');
1798
+
1799
+ const packageLogger = createScopedLogger('project-scope:project:package');
1800
+ /**
1801
+ * Verifies that a value is an object with only string values,
1802
+ * used for validating dependency maps and script definitions.
1803
+ *
1804
+ * @param value - Value to check
1805
+ * @returns True if value is a record of strings
1806
+ */
1807
+ function isStringRecord(value) {
1808
+ if (typeof value !== 'object' || value === null)
1809
+ return false;
1810
+ return values(value).every((v) => typeof v === 'string');
1811
+ }
1812
+ /**
1813
+ * Extracts and normalizes the workspaces field from package.json,
1814
+ * supporting both array format and object with packages array.
1815
+ *
1816
+ * @param value - Raw workspaces value from package.json
1817
+ * @returns Normalized workspace patterns or undefined if invalid
1818
+ */
1819
+ function parseWorkspaces(value) {
1820
+ if (isArray(value) && value.every((v) => typeof v === 'string')) {
1821
+ return value;
1822
+ }
1823
+ if (typeof value === 'object' && value !== null) {
1824
+ const obj = value;
1825
+ if (isArray(obj['packages'])) {
1826
+ return { packages: obj['packages'] };
1827
+ }
1828
+ }
1829
+ return undefined;
1830
+ }
1831
+ /**
1832
+ * Validate and normalize package.json data.
1833
+ *
1834
+ * @param data - Raw parsed data
1835
+ * @returns Validated package.json
1836
+ */
1837
+ function validatePackageJson(data) {
1838
+ if (typeof data !== 'object' || data === null) {
1839
+ throw createError('package.json must be an object');
1840
+ }
1841
+ const pkg = data;
1842
+ return {
1843
+ name: typeof pkg['name'] === 'string' ? pkg['name'] : undefined,
1844
+ version: typeof pkg['version'] === 'string' ? pkg['version'] : undefined,
1845
+ description: typeof pkg['description'] === 'string' ? pkg['description'] : undefined,
1846
+ main: typeof pkg['main'] === 'string' ? pkg['main'] : undefined,
1847
+ module: typeof pkg['module'] === 'string' ? pkg['module'] : undefined,
1848
+ browser: typeof pkg['browser'] === 'string' ? pkg['browser'] : undefined,
1849
+ types: typeof pkg['types'] === 'string' ? pkg['types'] : undefined,
1850
+ bin: typeof pkg['bin'] === 'string' || isStringRecord(pkg['bin']) ? pkg['bin'] : undefined,
1851
+ scripts: isStringRecord(pkg['scripts']) ? pkg['scripts'] : undefined,
1852
+ dependencies: isStringRecord(pkg['dependencies']) ? pkg['dependencies'] : undefined,
1853
+ devDependencies: isStringRecord(pkg['devDependencies']) ? pkg['devDependencies'] : undefined,
1854
+ peerDependencies: isStringRecord(pkg['peerDependencies']) ? pkg['peerDependencies'] : undefined,
1855
+ optionalDependencies: isStringRecord(pkg['optionalDependencies']) ? pkg['optionalDependencies'] : undefined,
1856
+ workspaces: parseWorkspaces(pkg['workspaces']),
1857
+ exports: typeof pkg['exports'] === 'object' ? pkg['exports'] : undefined,
1858
+ engines: isStringRecord(pkg['engines']) ? pkg['engines'] : undefined,
1859
+ ...pkg,
1860
+ };
1861
+ }
1862
+ /**
1863
+ * Attempts to read and parse package.json if it exists,
1864
+ * returning null on missing file or parse failure.
1865
+ *
1866
+ * @param projectPath - Project directory path or path to package.json
1867
+ * @returns Parsed package.json or null if not found
1868
+ */
1869
+ function readPackageJsonIfExists(projectPath) {
1870
+ const packageJsonPath = projectPath.endsWith('package.json') ? projectPath : join(projectPath, 'package.json');
1871
+ const content = readFileIfExists(packageJsonPath);
1872
+ if (!content) {
1873
+ packageLogger.debug('Package.json not found', { path: packageJsonPath });
1874
+ return null;
1875
+ }
1876
+ try {
1877
+ const validated = validatePackageJson(parse(content));
1878
+ packageLogger.debug('Package.json loaded', { path: packageJsonPath, name: validated.name });
1879
+ return validated;
1880
+ }
1881
+ catch {
1882
+ packageLogger.debug('Failed to parse package.json, returning null', { path: packageJsonPath });
1883
+ return null;
1884
+ }
1885
+ }
1886
+
1887
+ createScopedLogger('project-scope:root');
1888
+
1889
+ const nxLogger = createScopedLogger('project-scope:nx');
1890
+ /**
1891
+ * Files indicating NX workspace root.
1892
+ */
1893
+ const NX_CONFIG_FILES = ['nx.json', 'workspace.json'];
1894
+ /**
1895
+ * NX-specific project file.
1896
+ */
1897
+ const NX_PROJECT_FILE = 'project.json';
1898
+ /**
1899
+ * Check if directory is an NX workspace root.
1900
+ *
1901
+ * @param path - Directory path to check
1902
+ * @returns True if the directory contains nx.json or workspace.json
1903
+ *
1904
+ * @example
1905
+ * ```typescript
1906
+ * import { isNxWorkspace } from '@hyperfrontend/project-scope'
1907
+ *
1908
+ * if (isNxWorkspace('./my-project')) {
1909
+ * console.log('This is an NX monorepo')
1910
+ * }
1911
+ * ```
1912
+ */
1913
+ function isNxWorkspace(path) {
1914
+ for (const configFile of NX_CONFIG_FILES) {
1915
+ if (exists(join(path, configFile))) {
1916
+ nxLogger.debug('NX workspace detected', { path, configFile });
1917
+ return true;
1918
+ }
1919
+ }
1920
+ nxLogger.debug('Not an NX workspace', { path });
1921
+ return false;
1922
+ }
1923
+ /**
1924
+ * Check if directory is an NX project.
1925
+ *
1926
+ * @param path - Directory path to check
1927
+ * @returns True if the directory contains project.json
1928
+ */
1929
+ function isNxProject(path) {
1930
+ const isProject = exists(join(path, NX_PROJECT_FILE));
1931
+ nxLogger.debug('NX project check', { path, isProject });
1932
+ return isProject;
1933
+ }
1934
+ /**
1935
+ * Detect NX version from package.json dependencies.
1936
+ *
1937
+ * @param workspacePath - Workspace root path
1938
+ * @returns NX version string (without semver range) or null
1939
+ */
1940
+ function detectNxVersion(workspacePath) {
1941
+ const packageJson = readPackageJsonIfExists(workspacePath);
1942
+ if (packageJson) {
1943
+ const nxVersion = packageJson.devDependencies?.['nx'] ?? packageJson.dependencies?.['nx'];
1944
+ if (nxVersion) {
1945
+ // Strip semver range characters (^, ~, >=, etc.)
1946
+ return nxVersion.replace(/^[\^~>=<]+/, '');
1947
+ }
1948
+ }
1949
+ return null;
1950
+ }
1951
+ /**
1952
+ * Check if workspace is integrated (not standalone).
1953
+ * Integrated repos typically have workspaceLayout, namedInputs, or targetDefaults.
1954
+ *
1955
+ * @param nxJson - Parsed nx.json configuration
1956
+ * @returns True if the workspace is integrated
1957
+ */
1958
+ function isIntegratedRepo(nxJson) {
1959
+ return nxJson.workspaceLayout !== undefined || nxJson.namedInputs !== undefined || nxJson.targetDefaults !== undefined;
1960
+ }
1961
+ /**
1962
+ * Get comprehensive NX workspace information.
1963
+ *
1964
+ * @param workspacePath - Workspace root path
1965
+ * @returns Workspace info or null if not an NX workspace
1966
+ */
1967
+ function getNxWorkspaceInfo(workspacePath) {
1968
+ nxLogger.debug('Getting NX workspace info', { workspacePath });
1969
+ if (!isNxWorkspace(workspacePath)) {
1970
+ return null;
1971
+ }
1972
+ const nxJson = readJsonFileIfExists(join(workspacePath, 'nx.json'));
1973
+ if (!nxJson) {
1974
+ // Check for workspace.json as fallback (older NX)
1975
+ const workspaceJson = readJsonFileIfExists(join(workspacePath, 'workspace.json'));
1976
+ if (!workspaceJson) {
1977
+ nxLogger.debug('No nx.json or workspace.json found', { workspacePath });
1978
+ return null;
1979
+ }
1980
+ nxLogger.debug('Using legacy workspace.json', { workspacePath });
1981
+ // Create minimal nx.json from workspace.json
1982
+ return {
1983
+ root: workspacePath,
1984
+ version: detectNxVersion(workspacePath),
1985
+ nxJson: {},
1986
+ isIntegrated: true,
1987
+ workspaceLayout: {
1988
+ appsDir: 'apps',
1989
+ libsDir: 'libs',
1990
+ },
1991
+ };
1992
+ }
1993
+ const info = {
1994
+ root: workspacePath,
1995
+ version: detectNxVersion(workspacePath),
1996
+ nxJson,
1997
+ isIntegrated: isIntegratedRepo(nxJson),
1998
+ defaultProject: nxJson.defaultProject,
1999
+ workspaceLayout: {
2000
+ appsDir: nxJson.workspaceLayout?.appsDir ?? 'apps',
2001
+ libsDir: nxJson.workspaceLayout?.libsDir ?? 'libs',
2002
+ },
2003
+ };
2004
+ nxLogger.debug('NX workspace info retrieved', {
2005
+ workspacePath,
2006
+ version: info.version,
2007
+ isIntegrated: info.isIntegrated,
2008
+ defaultProject: info.defaultProject,
2009
+ });
2010
+ return info;
2011
+ }
2012
+
2013
+ createScopedLogger('project-scope:nx:devkit');
2014
+
2015
+ const nxConfigLogger = createScopedLogger('project-scope:nx:config');
2016
+ /**
2017
+ * Read project.json for an NX project.
2018
+ *
2019
+ * @param projectPath - Project directory path
2020
+ * @returns Parsed project.json or null if not found
2021
+ */
2022
+ function readProjectJson(projectPath) {
2023
+ const projectJsonPath = join(projectPath, NX_PROJECT_FILE);
2024
+ nxConfigLogger.debug('Reading project.json', { path: projectJsonPath });
2025
+ const result = readJsonFileIfExists(projectJsonPath);
2026
+ if (result) {
2027
+ nxConfigLogger.debug('Project.json loaded', { path: projectJsonPath, name: result.name });
2028
+ }
2029
+ else {
2030
+ nxConfigLogger.debug('Project.json not found', { path: projectJsonPath });
2031
+ }
2032
+ return result;
2033
+ }
2034
+ /**
2035
+ * Get project configuration from project.json or package.json nx field.
2036
+ *
2037
+ * @param projectPath - Project directory path
2038
+ * @param workspacePath - Workspace root path (for relative path calculation)
2039
+ * @returns Project configuration or null if not found
2040
+ */
2041
+ function getProjectConfig(projectPath, workspacePath) {
2042
+ nxConfigLogger.debug('Getting project config', { projectPath, workspacePath });
2043
+ // Try project.json first
2044
+ const projectJson = readProjectJson(projectPath);
2045
+ if (projectJson) {
2046
+ nxConfigLogger.debug('Using project.json config', { projectPath, name: projectJson.name });
2047
+ return {
2048
+ ...projectJson,
2049
+ root: projectJson.root ?? relative(workspacePath, projectPath),
2050
+ };
2051
+ }
2052
+ // Try to infer from package.json nx field
2053
+ const packageJson = readPackageJsonIfExists(projectPath);
2054
+ if (packageJson && typeof packageJson['nx'] === 'object') {
2055
+ nxConfigLogger.debug('Using package.json nx field', { projectPath, name: packageJson.name });
2056
+ const nxConfig = packageJson['nx'];
2057
+ return {
2058
+ name: packageJson.name,
2059
+ root: relative(workspacePath, projectPath),
2060
+ ...nxConfig,
2061
+ };
2062
+ }
2063
+ nxConfigLogger.debug('No project config found', { projectPath });
2064
+ return null;
2065
+ }
2066
+ /**
2067
+ * Recursively scan directory for project.json files.
2068
+ *
2069
+ * @param dirPath - Directory to scan
2070
+ * @param workspacePath - Workspace root path
2071
+ * @param projects - Map to add discovered projects to
2072
+ * @param maxDepth - Maximum recursion depth
2073
+ * @param currentDepth - Current recursion depth
2074
+ */
2075
+ function scanForProjects(dirPath, workspacePath, projects, maxDepth, currentDepth = 0) {
2076
+ if (currentDepth > maxDepth)
2077
+ return;
2078
+ try {
2079
+ const entries = readDirectory(dirPath);
2080
+ for (const entry of entries) {
2081
+ // Skip node_modules and hidden directories
2082
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist') {
2083
+ continue;
2084
+ }
2085
+ const fullPath = join(dirPath, entry.name);
2086
+ if (entry.isDirectory) {
2087
+ // Check if this directory is an NX project
2088
+ if (isNxProject(fullPath)) {
2089
+ const config = getProjectConfig(fullPath, workspacePath);
2090
+ if (config) {
2091
+ const name = config.name || relative(workspacePath, fullPath).replace(/[\\/]/g, '-');
2092
+ projects.set(name, {
2093
+ ...config,
2094
+ name,
2095
+ root: relative(workspacePath, fullPath),
2096
+ });
2097
+ }
2098
+ }
2099
+ // Recursively scan subdirectories
2100
+ scanForProjects(fullPath, workspacePath, projects, maxDepth, currentDepth + 1);
2101
+ }
2102
+ }
2103
+ }
2104
+ catch {
2105
+ // Directory not readable, skip
2106
+ }
2107
+ }
2108
+ /**
2109
+ * Discover all NX projects in workspace.
2110
+ * Supports both workspace.json (older format) and project.json (newer format).
2111
+ *
2112
+ * @param workspacePath - Workspace root path
2113
+ * @returns Map of project name to configuration
2114
+ */
2115
+ function discoverNxProjects(workspacePath) {
2116
+ const projects = createMap();
2117
+ // Check for workspace.json (older NX format)
2118
+ const workspaceJson = readJsonFileIfExists(join(workspacePath, 'workspace.json'));
2119
+ if (workspaceJson?.projects) {
2120
+ for (const [name, config] of entries(workspaceJson.projects)) {
2121
+ if (typeof config === 'string') {
2122
+ // Path reference to project directory
2123
+ const projectPath = join(workspacePath, config);
2124
+ const projectConfig = getProjectConfig(projectPath, workspacePath);
2125
+ if (projectConfig) {
2126
+ projects.set(name, { ...projectConfig, name });
2127
+ }
2128
+ }
2129
+ else if (typeof config === 'object' && config !== null) {
2130
+ // Inline config
2131
+ projects.set(name, { name, ...config });
2132
+ }
2133
+ }
2134
+ return projects;
2135
+ }
2136
+ // Scan for project.json files (newer NX format)
2137
+ const workspaceInfo = getNxWorkspaceInfo(workspacePath);
2138
+ const appsDir = workspaceInfo?.workspaceLayout.appsDir ?? 'apps';
2139
+ const libsDir = workspaceInfo?.workspaceLayout.libsDir ?? 'libs';
2140
+ const searchDirs = [appsDir, libsDir];
2141
+ // Also check packages directory (common in some setups)
2142
+ if (exists(join(workspacePath, 'packages'))) {
2143
+ searchDirs.push('packages');
2144
+ }
2145
+ for (const dir of searchDirs) {
2146
+ const dirPath = join(workspacePath, dir);
2147
+ if (exists(dirPath) && isDirectory(dirPath)) {
2148
+ try {
2149
+ scanForProjects(dirPath, workspacePath, projects, 3);
2150
+ }
2151
+ catch {
2152
+ // Directory not accessible
2153
+ }
2154
+ }
2155
+ }
2156
+ // Also check root-level projects (standalone projects in monorepo root)
2157
+ if (isNxProject(workspacePath)) {
2158
+ const config = readProjectJson(workspacePath);
2159
+ if (config) {
2160
+ const name = config.name || basename(workspacePath);
2161
+ projects.set(name, {
2162
+ ...config,
2163
+ name,
2164
+ root: '.',
2165
+ });
2166
+ }
2167
+ }
2168
+ return projects;
2169
+ }
2170
+ /**
2171
+ * Build a simple project graph from discovered projects.
2172
+ * For full graph capabilities, use `@nx/devkit`.
2173
+ *
2174
+ * @param workspacePath - Workspace root path
2175
+ * @param projects - Existing configuration map to skip auto-discovery
2176
+ * @returns NxProjectGraph with nodes and dependencies
2177
+ */
2178
+ function buildSimpleProjectGraph(workspacePath, projects) {
2179
+ const projectMap = projects ?? discoverNxProjects(workspacePath);
2180
+ const nodes = {};
2181
+ const dependencies = {};
2182
+ for (const [name, config] of projectMap) {
2183
+ nodes[name] = {
2184
+ name,
2185
+ type: config.projectType ?? 'library',
2186
+ data: config,
2187
+ };
2188
+ dependencies[name] = [];
2189
+ // Add implicit dependencies
2190
+ if (config.implicitDependencies) {
2191
+ for (const dep of config.implicitDependencies) {
2192
+ // Skip negative dependencies (those starting with !)
2193
+ if (!dep.startsWith('!')) {
2194
+ dependencies[name].push({
2195
+ target: dep,
2196
+ type: 'implicit',
2197
+ });
2198
+ }
2199
+ }
2200
+ }
2201
+ }
2202
+ return { nodes, dependencies };
2203
+ }
2204
+
2205
+ /**
2206
+ * Creates an empty classification summary.
2207
+ *
2208
+ * @returns A new ClassificationSummary with all counts at zero
2209
+ */
2210
+ function createEmptyClassificationSummary() {
2211
+ return {
2212
+ total: 0,
2213
+ included: 0,
2214
+ excluded: 0,
2215
+ bySource: {
2216
+ 'direct-scope': 0,
2217
+ 'direct-file': 0,
2218
+ 'unscoped-file': 0,
2219
+ 'indirect-dependency': 0,
2220
+ 'indirect-infra': 0,
2221
+ 'unscoped-global': 0,
2222
+ excluded: 0,
2223
+ },
2224
+ };
2225
+ }
2226
+ /**
2227
+ * Creates a classified commit.
2228
+ *
2229
+ * @param commit - The parsed conventional commit
2230
+ * @param raw - The raw git commit
2231
+ * @param source - How the commit relates to the project
2232
+ * @param options - Additional classification options
2233
+ * @param options.touchedFiles - Files in the project modified by this commit
2234
+ * @param options.dependencyPath - Chain of dependencies leading to indirect inclusion
2235
+ * @returns A new ClassifiedCommit object
2236
+ */
2237
+ function createClassifiedCommit(commit, raw, source, options) {
2238
+ const include = isIncludedSource(source);
2239
+ const preserveScope = shouldPreserveScope(source);
2240
+ return {
2241
+ commit,
2242
+ raw,
2243
+ source,
2244
+ include,
2245
+ preserveScope,
2246
+ touchedFiles: options?.touchedFiles,
2247
+ dependencyPath: options?.dependencyPath,
2248
+ };
2249
+ }
2250
+ /**
2251
+ * Determines if a source type should be included in changelog.
2252
+ *
2253
+ * @param source - The commit source type
2254
+ * @returns True if commits with this source should be included
2255
+ */
2256
+ function isIncludedSource(source) {
2257
+ switch (source) {
2258
+ case 'direct-scope':
2259
+ case 'direct-file':
2260
+ case 'unscoped-file':
2261
+ case 'indirect-dependency':
2262
+ case 'indirect-infra':
2263
+ return true;
2264
+ case 'unscoped-global':
2265
+ case 'excluded':
2266
+ return false;
2267
+ }
2268
+ }
2269
+ /**
2270
+ * Determines if scope should be preserved for a source type.
2271
+ *
2272
+ * Direct commits omit scope (redundant in project changelog).
2273
+ * Indirect commits preserve scope for context.
2274
+ *
2275
+ * @param source - The commit source type
2276
+ * @returns True if scope should be preserved in changelog
2277
+ */
2278
+ function shouldPreserveScope(source) {
2279
+ switch (source) {
2280
+ case 'direct-scope':
2281
+ case 'unscoped-file':
2282
+ return false; // Scope would be redundant
2283
+ case 'direct-file':
2284
+ case 'indirect-dependency':
2285
+ case 'indirect-infra':
2286
+ return true; // Scope provides context
2287
+ case 'unscoped-global':
2288
+ case 'excluded':
2289
+ return false; // Won't be shown
2290
+ }
2291
+ }
2292
+
2293
+ /**
2294
+ * Derives all scope variations that should match a project.
2295
+ *
2296
+ * Given a project named 'lib-versioning' with package '@hyperfrontend/versioning',
2297
+ * this generates variations like:
2298
+ * - 'lib-versioning' (full project name)
2299
+ * - 'versioning' (without lib- prefix)
2300
+ *
2301
+ * @param options - Project identification options
2302
+ * @returns Array of scope strings that match this project
2303
+ *
2304
+ * @example
2305
+ * deriveProjectScopes({ projectName: 'lib-versioning', packageName: '@hyperfrontend/versioning' })
2306
+ * // Returns: ['lib-versioning', 'versioning']
2307
+ *
2308
+ * @example
2309
+ * deriveProjectScopes({ projectName: 'app-demo', packageName: 'demo-app' })
2310
+ * // Returns: ['app-demo', 'demo']
2311
+ */
2312
+ function deriveProjectScopes(options) {
2313
+ const { projectName, packageName, additionalScopes = [], prefixes = DEFAULT_PROJECT_PREFIXES } = options;
2314
+ const scopes = createSet();
2315
+ // Always include the full project name
2316
+ scopes.add(projectName);
2317
+ // Add variations based on common prefixes
2318
+ const prefixVariations = extractPrefixVariations(projectName, prefixes);
2319
+ for (const variation of prefixVariations) {
2320
+ scopes.add(variation);
2321
+ }
2322
+ // Add package name variations if provided
2323
+ if (packageName) {
2324
+ const packageVariations = extractPackageNameVariations(packageName);
2325
+ for (const variation of packageVariations) {
2326
+ scopes.add(variation);
2327
+ }
2328
+ }
2329
+ // Add any additional scopes
2330
+ for (const scope of additionalScopes) {
2331
+ if (scope) {
2332
+ scopes.add(scope);
2333
+ }
2334
+ }
2335
+ return [...scopes];
2336
+ }
2337
+ /**
2338
+ * Default project name prefixes that can be stripped for scope matching.
2339
+ */
2340
+ const DEFAULT_PROJECT_PREFIXES = ['lib-', 'app-', 'e2e-', 'tool-', 'plugin-', 'feature-', 'package-'];
2341
+ /**
2342
+ * Generates scope variations by stripping recognized project prefixes.
2343
+ *
2344
+ * @param projectName - The project name to extract variations from
2345
+ * @param prefixes - Prefixes to check and strip
2346
+ * @returns Array of scope name variations
2347
+ */
2348
+ function extractPrefixVariations(projectName, prefixes) {
2349
+ const variations = [];
2350
+ for (const prefix of prefixes) {
2351
+ if (projectName.startsWith(prefix)) {
2352
+ const withoutPrefix = projectName.slice(prefix.length);
2353
+ if (withoutPrefix) {
2354
+ variations.push(withoutPrefix);
2355
+ }
2356
+ break; // Only remove one prefix
2357
+ }
2358
+ }
2359
+ return variations;
2360
+ }
2361
+ /**
2362
+ * Extracts scope variations from an npm package name.
2363
+ *
2364
+ * @param packageName - The npm package name (e.g., '@scope/name')
2365
+ * @returns Array of name variations
2366
+ */
2367
+ function extractPackageNameVariations(packageName) {
2368
+ const variations = [];
2369
+ // Handle scoped packages: @scope/name -> name
2370
+ if (packageName.startsWith('@')) {
2371
+ const slashIndex = packageName.indexOf('/');
2372
+ if (slashIndex !== -1) {
2373
+ const unscoped = packageName.slice(slashIndex + 1);
2374
+ if (unscoped) {
2375
+ variations.push(unscoped);
2376
+ }
2377
+ }
2378
+ }
2379
+ else {
2380
+ // Non-scoped package: just use the name
2381
+ variations.push(packageName);
2382
+ }
2383
+ return variations;
2384
+ }
2385
+ /**
2386
+ * Checks if a commit scope matches any of the project scopes.
2387
+ *
2388
+ * @param commitScope - The scope from a conventional commit
2389
+ * @param projectScopes - Array of scopes that match the project
2390
+ * @returns True if the commit scope matches the project
2391
+ *
2392
+ * @example
2393
+ * scopeMatchesProject('versioning', ['lib-versioning', 'versioning']) // true
2394
+ * scopeMatchesProject('logging', ['lib-versioning', 'versioning']) // false
2395
+ */
2396
+ function scopeMatchesProject(commitScope, projectScopes) {
2397
+ if (!commitScope) {
2398
+ return false;
2399
+ }
2400
+ // Case-insensitive comparison
2401
+ const normalizedScope = commitScope.toLowerCase();
2402
+ return projectScopes.some((scope) => scope.toLowerCase() === normalizedScope);
2403
+ }
2404
+ /**
2405
+ * Checks if a commit scope should be explicitly excluded.
2406
+ *
2407
+ * @param commitScope - The scope from a conventional commit
2408
+ * @param excludeScopes - Array of scopes to exclude
2409
+ * @returns True if the scope should be excluded
2410
+ */
2411
+ function scopeIsExcluded(commitScope, excludeScopes) {
2412
+ if (!commitScope) {
2413
+ return false;
2414
+ }
2415
+ const normalizedScope = commitScope.toLowerCase();
2416
+ return excludeScopes.some((scope) => scope.toLowerCase() === normalizedScope);
2417
+ }
2418
+ /**
2419
+ * Default scopes to exclude from changelogs.
2420
+ *
2421
+ * These represent repository-level or infrastructure changes
2422
+ * that typically don't belong in individual project changelogs.
2423
+ */
2424
+ const DEFAULT_EXCLUDE_SCOPES = ['release', 'deps', 'workspace', 'root', 'repo', 'ci', 'build'];
2425
+
2426
+ /**
2427
+ * Classifies a single commit against a project.
2428
+ *
2429
+ * Implements the hybrid classification strategy:
2430
+ * 1. Check scope match (fast path)
2431
+ * 2. Check file touch (validation/catch-all)
2432
+ * 3. Check dependency touch (indirect)
2433
+ * 4. Fallback to excluded
2434
+ *
2435
+ * @param input - The commit to classify
2436
+ * @param context - Classification context with project info
2437
+ * @returns Classified commit with source attribution
2438
+ *
2439
+ * @example
2440
+ * const classified = classifyCommit(
2441
+ * { commit: parsedCommit, raw: gitCommit },
2442
+ * { projectScopes: ['versioning'], fileCommitHashes: new Set(['abc123']) }
2443
+ * )
2444
+ */
2445
+ function classifyCommit(input, context) {
2446
+ const { commit, raw } = input;
2447
+ const { projectScopes, fileCommitHashes, dependencyCommitMap, infrastructureCommitHashes, excludeScopes = DEFAULT_EXCLUDE_SCOPES, includeScopes = [], } = context;
2448
+ const scope = commit.scope;
2449
+ const hasScope = !!scope;
2450
+ const allProjectScopes = [...projectScopes, ...includeScopes];
2451
+ // First check: Is this scope explicitly excluded?
2452
+ if (hasScope && scopeIsExcluded(scope, excludeScopes)) {
2453
+ return createClassifiedCommit(commit, raw, 'excluded');
2454
+ }
2455
+ // Priority 1: Scope-based direct match (fast path)
2456
+ if (hasScope && scopeMatchesProject(scope, allProjectScopes)) {
2457
+ return createClassifiedCommit(commit, raw, 'direct-scope');
2458
+ }
2459
+ // Priority 2: File-based direct match (validation/catch-all)
2460
+ if (fileCommitHashes.has(raw.hash)) {
2461
+ // Commit touched project files
2462
+ if (hasScope) {
2463
+ // Has a scope but it's different - likely a typo or cross-cutting change
2464
+ return createClassifiedCommit(commit, raw, 'direct-file');
2465
+ }
2466
+ // No scope but touched project files
2467
+ return createClassifiedCommit(commit, raw, 'unscoped-file');
2468
+ }
2469
+ // Priority 3: Indirect dependency match
2470
+ if (hasScope && dependencyCommitMap) {
2471
+ const dependencyPath = findDependencyPath(scope, raw.hash, dependencyCommitMap);
2472
+ if (dependencyPath) {
2473
+ return createClassifiedCommit(commit, raw, 'indirect-dependency', { dependencyPath });
2474
+ }
2475
+ }
2476
+ // File-based infrastructure match
2477
+ if (infrastructureCommitHashes?.has(raw.hash)) {
2478
+ return createClassifiedCommit(commit, raw, 'indirect-infra');
2479
+ }
2480
+ // Fallback: No match found
2481
+ if (!hasScope) {
2482
+ // Unscoped commit that didn't touch any project files
2483
+ return createClassifiedCommit(commit, raw, 'unscoped-global');
2484
+ }
2485
+ // Scoped commit that doesn't match anything
2486
+ return createClassifiedCommit(commit, raw, 'excluded');
2487
+ }
2488
+ /**
2489
+ * Classifies multiple commits against a project.
2490
+ *
2491
+ * @param commits - Array of commits to classify
2492
+ * @param context - Classification context with project info
2493
+ * @returns Classification result with all commits and summary
2494
+ */
2495
+ function classifyCommits(commits, context) {
2496
+ const classified = [];
2497
+ const included = [];
2498
+ const excluded = [];
2499
+ const summary = createEmptyClassificationSummary();
2500
+ const bySource = { ...summary.bySource };
2501
+ for (const input of commits) {
2502
+ const result = classifyCommit(input, context);
2503
+ classified.push(result);
2504
+ // Update summary
2505
+ bySource[result.source]++;
2506
+ if (result.include) {
2507
+ included.push(result);
2508
+ }
2509
+ else {
2510
+ excluded.push(result);
2511
+ }
2512
+ }
2513
+ return {
2514
+ commits: classified,
2515
+ included,
2516
+ excluded,
2517
+ summary: {
2518
+ total: classified.length,
2519
+ included: included.length,
2520
+ excluded: excluded.length,
2521
+ bySource,
2522
+ },
2523
+ };
2524
+ }
2525
+ /**
2526
+ * Finds a dependency path for a given scope and commit hash.
2527
+ *
2528
+ * Verifies both:
2529
+ * 1. The scope matches a dependency name (or variation)
2530
+ * 2. The commit hash is in that dependency's commit set
2531
+ *
2532
+ * This prevents false positives from mislabeled commits.
2533
+ *
2534
+ * @param scope - The commit scope
2535
+ * @param hash - The commit hash to verify
2536
+ * @param dependencyCommitMap - Map of dependencies to their commit hashes
2537
+ * @returns Dependency path if found and hash verified, undefined otherwise
2538
+ */
2539
+ function findDependencyPath(scope, hash, dependencyCommitMap) {
2540
+ const normalizedScope = scope.toLowerCase();
2541
+ for (const [depName, depHashes] of dependencyCommitMap) {
2542
+ // Check if scope matches dependency name or variations
2543
+ const depVariations = getDependencyVariations(depName);
2544
+ if (depVariations.some((v) => v.toLowerCase() === normalizedScope)) {
2545
+ // CRITICAL: Verify the commit actually touched this dependency's files
2546
+ // This prevents false positives from mislabeled commits
2547
+ if (depHashes.has(hash)) {
2548
+ return [depName];
2549
+ }
2550
+ }
2551
+ }
2552
+ return undefined;
2553
+ }
2554
+ /**
2555
+ * Generates name variations for a dependency to enable flexible scope matching.
2556
+ *
2557
+ * @param depName - The dependency project or package name
2558
+ * @returns Array of name variations including stripped prefixes
2559
+ */
2560
+ function getDependencyVariations(depName) {
2561
+ const variations = [depName];
2562
+ // Handle lib- prefix
2563
+ if (depName.startsWith('lib-')) {
2564
+ variations.push(depName.slice(4));
2565
+ }
2566
+ // Handle @scope/name
2567
+ if (depName.startsWith('@')) {
2568
+ const slashIndex = depName.indexOf('/');
2569
+ if (slashIndex !== -1) {
2570
+ variations.push(depName.slice(slashIndex + 1));
2571
+ }
2572
+ }
2573
+ return variations;
2574
+ }
2575
+ /**
2576
+ * Creates a classification context from common inputs.
2577
+ *
2578
+ * @param projectScopes - Scopes that match the project
2579
+ * @param fileCommitHashes - Set of commit hashes that touched project files
2580
+ * @param options - Additional context options
2581
+ * @param options.dependencyCommitMap - Map of dependency names to commit hashes touching them
2582
+ * @param options.infrastructureCommitHashes - Set of commit hashes touching infrastructure paths
2583
+ * @param options.excludeScopes - Scopes to explicitly exclude from classification
2584
+ * @param options.includeScopes - Additional scopes to include as direct matches
2585
+ * @returns A ClassificationContext object
2586
+ */
2587
+ function createClassificationContext(projectScopes, fileCommitHashes, options) {
2588
+ return {
2589
+ projectScopes,
2590
+ fileCommitHashes,
2591
+ dependencyCommitMap: options?.dependencyCommitMap,
2592
+ infrastructureCommitHashes: options?.infrastructureCommitHashes,
2593
+ excludeScopes: options?.excludeScopes ?? DEFAULT_EXCLUDE_SCOPES,
2594
+ includeScopes: options?.includeScopes,
2595
+ };
2596
+ }
2597
+ /**
2598
+ * Creates a modified conventional commit with scope handling based on classification.
2599
+ *
2600
+ * For direct commits, the scope is removed (redundant in project changelog).
2601
+ * For indirect commits, the scope is preserved (provides context).
2602
+ *
2603
+ * @param classified - Commit with classification metadata determining scope display
2604
+ * @returns A conventional commit with appropriate scope handling
2605
+ */
2606
+ function toChangelogCommit(classified) {
2607
+ const { commit, preserveScope } = classified;
2608
+ if (!preserveScope && commit.scope) {
2609
+ // Remove the scope for direct commits
2610
+ return {
2611
+ ...commit,
2612
+ scope: undefined,
2613
+ // Rebuild raw to reflect removed scope
2614
+ raw: rebuildRawWithoutScope(commit),
2615
+ };
2616
+ }
2617
+ return commit;
2618
+ }
2619
+ /**
2620
+ * Reconstructs a conventional commit message string without the scope portion.
2621
+ *
2622
+ * @param commit - The conventional commit to rebuild
2623
+ * @returns Reconstructed raw message with scope removed
2624
+ */
2625
+ function rebuildRawWithoutScope(commit) {
2626
+ const breaking = commit.breaking && !commit.breakingDescription ? '!' : '';
2627
+ const header = `${commit.type}${breaking}: ${commit.subject}`;
2628
+ if (!commit.body && commit.footers.length === 0) {
2629
+ return header;
2630
+ }
2631
+ let raw = header;
2632
+ if (commit.body) {
2633
+ raw += `\n\n${commit.body}`;
2634
+ }
2635
+ for (const footer of commit.footers) {
2636
+ raw += `\n${footer.key}${footer.separator}${footer.value}`;
2637
+ }
2638
+ return raw;
2639
+ }
2640
+
2641
+ /**
2642
+ * Creates a matcher that checks if commit scope matches any of the given scopes.
22
2643
  *
23
- * @param id - Unique step identifier
24
- * @param name - Human-readable step name
25
- * @param execute - Step executor function
26
- * @param options - Optional step configuration
27
- * @returns A FlowStep object
2644
+ * @param scopes - Scopes to match against (case-insensitive)
2645
+ * @returns Matcher that returns true if scope matches
28
2646
  *
29
2647
  * @example
30
- * ```typescript
31
- * const fetchStep = createStep(
32
- * 'fetch-registry',
33
- * 'Fetch Registry Version',
34
- * async (ctx) => {
35
- * const version = await ctx.registry.getLatestVersion(ctx.packageName)
36
- * return {
37
- * status: 'success',
38
- * stateUpdates: { publishedVersion: version },
39
- * message: `Found published version: ${version}`
40
- * }
41
- * }
42
- * )
43
- * ```
2648
+ * const matcher = scopeMatcher(['ci', 'build', 'tooling'])
2649
+ * matcher({ scope: 'CI', ... }) // true
2650
+ * matcher({ scope: 'feat', ... }) // false
44
2651
  */
45
- function createStep(id, name, execute, options = {}) {
46
- return {
47
- id,
48
- name,
49
- execute,
50
- description: options.description,
51
- skipIf: options.skipIf,
52
- continueOnError: options.continueOnError,
53
- dependsOn: options.dependsOn,
2652
+ function scopeMatcher(scopes) {
2653
+ const normalizedScopes = createSet(scopes.map((s) => s.toLowerCase()));
2654
+ return (ctx) => {
2655
+ if (!ctx.scope)
2656
+ return false;
2657
+ return normalizedScopes.has(ctx.scope.toLowerCase());
54
2658
  };
55
2659
  }
56
2660
  /**
57
- * Creates a skipped step result.
2661
+ * Creates a matcher that checks if commit scope starts with any of the given prefixes.
58
2662
  *
59
- * @param message - Explanation for why the step was skipped
60
- * @returns A FlowStepResult with 'skipped' status
2663
+ * @param prefixes - Scope prefixes to match (case-insensitive)
2664
+ * @returns Matcher that returns true if scope starts with any prefix
2665
+ *
2666
+ * @example
2667
+ * const matcher = scopePrefixMatcher(['tool-', 'infra-'])
2668
+ * matcher({ scope: 'tool-package', ... }) // true
2669
+ * matcher({ scope: 'lib-utils', ... }) // false
61
2670
  */
62
- function createSkippedResult(message) {
63
- return {
64
- status: 'skipped',
65
- message,
2671
+ function scopePrefixMatcher(prefixes) {
2672
+ const normalizedPrefixes = prefixes.map((p) => p.toLowerCase());
2673
+ return (ctx) => {
2674
+ if (!ctx.scope)
2675
+ return false;
2676
+ const normalizedScope = ctx.scope.toLowerCase();
2677
+ return normalizedPrefixes.some((prefix) => normalizedScope.startsWith(prefix));
66
2678
  };
67
2679
  }
68
-
69
- const FETCH_REGISTRY_STEP_ID = 'fetch-registry';
70
2680
  /**
71
- * Creates the fetch-registry step.
72
- *
73
- * This step:
74
- * 1. Queries the registry for the latest published version
75
- * 2. Reads the current version from package.json
76
- * 3. Determines if this is a first release
2681
+ * Combines matchers with OR logic - returns true if ANY matcher matches.
77
2682
  *
78
- * State updates:
79
- * - publishedVersion: Latest version on registry (null if not published)
80
- * - currentVersion: Version from local package.json
81
- * - isFirstRelease: True if never published
2683
+ * @param matchers - Matchers to combine
2684
+ * @returns Combined matcher
82
2685
  *
83
- * @returns A FlowStep that fetches registry information
2686
+ * @example
2687
+ * const combined = anyOf(
2688
+ * scopeMatcher(['ci', 'build']),
2689
+ * messageMatcher(['[infra]']),
2690
+ * custom((ctx) => ctx.scope?.startsWith('tool-'))
2691
+ * )
84
2692
  */
85
- function createFetchRegistryStep() {
86
- return createStep(FETCH_REGISTRY_STEP_ID, 'Fetch Registry Version', async (ctx) => {
87
- const { registry, tree, projectRoot, packageName, logger } = ctx;
88
- // Read local package.json for current version
89
- const packageJsonPath = `${projectRoot}/package.json`;
90
- let currentVersion = '0.0.0';
91
- try {
92
- const content = tree.read(packageJsonPath, 'utf-8');
93
- if (content) {
94
- const pkg = parse(content);
95
- currentVersion = pkg.version ?? '0.0.0';
96
- }
97
- }
98
- catch (error) {
99
- logger.warn(`Could not read package.json: ${error}`);
100
- }
101
- // Query registry for published version
102
- let publishedVersion = null;
103
- let isFirstRelease = true;
104
- try {
105
- publishedVersion = await registry.getLatestVersion(packageName);
106
- isFirstRelease = publishedVersion === null;
107
- }
108
- catch (error) {
109
- // Package might not exist yet, which is fine
110
- logger.debug(`Registry query failed (package may not exist): ${error}`);
111
- isFirstRelease = true;
112
- }
113
- const message = isFirstRelease ? `First release (local: ${currentVersion})` : `Published: ${publishedVersion}, Local: ${currentVersion}`;
114
- return {
115
- status: 'success',
116
- stateUpdates: {
117
- publishedVersion,
118
- currentVersion,
119
- isFirstRelease,
120
- },
121
- message,
122
- };
123
- });
2693
+ function anyOf(...matchers) {
2694
+ return (ctx) => matchers.some((matcher) => matcher(ctx));
124
2695
  }
125
-
126
2696
  /**
127
- * Safe copies of Error built-ins via factory functions.
2697
+ * Matches common CI/CD scopes.
128
2698
  *
129
- * Since constructors cannot be safely captured via Object.assign, this module
130
- * provides factory functions that use Reflect.construct internally.
2699
+ * Matches: ci, cd, build, pipeline, workflow, actions
2700
+ */
2701
+ const CI_SCOPE_MATCHER = scopeMatcher(['ci', 'cd', 'build', 'pipeline', 'workflow', 'actions']);
2702
+ /**
2703
+ * Matches common tooling/workspace scopes.
131
2704
  *
132
- * These references are captured at module initialization time to protect against
133
- * prototype pollution attacks. Import only what you need for tree-shaking.
2705
+ * Matches: tooling, workspace, monorepo, nx, root
2706
+ */
2707
+ const TOOLING_SCOPE_MATCHER = scopeMatcher(['tooling', 'workspace', 'monorepo', 'nx', 'root']);
2708
+ /**
2709
+ * Matches tool-prefixed scopes (e.g., tool-package, tool-scripts).
2710
+ */
2711
+ const TOOL_PREFIX_MATCHER = scopePrefixMatcher(['tool-']);
2712
+ /**
2713
+ * Combined matcher for common infrastructure patterns.
134
2714
  *
135
- * @module @hyperfrontend/immutable-api-utils/built-in-copy/error
2715
+ * Combines CI, tooling, and tool-prefix matchers.
136
2716
  */
137
- // Capture references at module initialization time
138
- const _Error = globalThis.Error;
139
- const _Reflect$2 = globalThis.Reflect;
2717
+ anyOf(CI_SCOPE_MATCHER, TOOLING_SCOPE_MATCHER, TOOL_PREFIX_MATCHER);
140
2718
  /**
141
- * (Safe copy) Creates a new Error using the captured Error constructor.
142
- * Use this instead of `new Error()`.
2719
+ * Builds a combined matcher from infrastructure configuration.
143
2720
  *
144
- * @param message - Optional error message.
145
- * @param options - Optional error options.
146
- * @returns A new Error instance.
2721
+ * Combines scope-based matching with any custom matcher using OR logic.
2722
+ * Path-based matching is handled separately via git queries.
2723
+ *
2724
+ * @param config - Infrastructure configuration
2725
+ * @returns Combined matcher, or null if no matchers configured
2726
+ *
2727
+ * @example
2728
+ * const matcher = buildInfrastructureMatcher({
2729
+ * scopes: ['ci', 'build'],
2730
+ * matcher: (ctx) => ctx.scope?.startsWith('tool-')
2731
+ * })
2732
+ */
2733
+ function buildInfrastructureMatcher(config) {
2734
+ const matchers = [];
2735
+ // Add scope matcher if scopes configured
2736
+ if (config.scopes && config.scopes.length > 0) {
2737
+ matchers.push(scopeMatcher(config.scopes));
2738
+ }
2739
+ // Add custom matcher if provided
2740
+ if (config.matcher) {
2741
+ matchers.push(config.matcher);
2742
+ }
2743
+ // Return combined or null
2744
+ if (matchers.length === 0) {
2745
+ return null;
2746
+ }
2747
+ if (matchers.length === 1) {
2748
+ return matchers[0];
2749
+ }
2750
+ return anyOf(...matchers);
2751
+ }
2752
+ /**
2753
+ * Creates match context from a git commit.
2754
+ *
2755
+ * Extracts scope from conventional commit message if present.
2756
+ *
2757
+ * @param commit - Git commit to create context for
2758
+ * @param scope - Pre-parsed scope (optional, saves re-parsing)
2759
+ * @returns Match context for use with matchers
147
2760
  */
148
- const createError = (message, options) => _Reflect$2.construct(_Error, [message, options]);
2761
+ function createMatchContext(commit, scope) {
2762
+ return {
2763
+ commit,
2764
+ scope,
2765
+ subject: commit.subject,
2766
+ message: commit.message,
2767
+ };
2768
+ }
149
2769
 
150
2770
  /**
151
2771
  * Replaces all occurrences of a character in a string.
@@ -571,72 +3191,158 @@ function splitLines(message) {
571
3191
  return lines;
572
3192
  }
573
3193
 
3194
+ /**
3195
+ * Default changelog filename.
3196
+ */
3197
+ const DEFAULT_CHANGELOG_FILENAME = 'CHANGELOG.md';
3198
+ /**
3199
+ * Default scope filtering configuration.
3200
+ *
3201
+ * Uses DEFAULT_EXCLUDE_SCOPES from commits/classify to ensure consistency
3202
+ * between flow-level filtering and commit classification.
3203
+ */
3204
+ const DEFAULT_SCOPE_FILTERING_CONFIG = {
3205
+ strategy: 'hybrid',
3206
+ includeScopes: [],
3207
+ excludeScopes: DEFAULT_EXCLUDE_SCOPES,
3208
+ trackDependencyChanges: false,
3209
+ projectPrefixes: DEFAULT_PROJECT_PREFIXES,
3210
+ infrastructure: undefined,
3211
+ infrastructureMatcher: undefined,
3212
+ };
3213
+
574
3214
  const ANALYZE_COMMITS_STEP_ID = 'analyze-commits';
575
3215
  /**
576
3216
  * Creates the analyze-commits step.
577
3217
  *
578
3218
  * This step:
579
- * 1. Finds the last release tag for this package
580
- * 2. Gets all commits since that tag (or all commits if first release)
581
- * 3. Parses each commit using conventional commit format
582
- * 4. Filters to only release-worthy commits
3219
+ * 1. Uses publishedCommit from npm registry (set by fetch-registry step)
3220
+ * 2. Verifies the commit is reachable from current HEAD
3221
+ * 3. Gets all commits since that commit (or recent commits if first release/fallback)
3222
+ * 4. Parses each commit using conventional commit format
3223
+ * 5. Classifies commits based on scope filtering strategy
3224
+ * 6. Filters to only release-worthy commits that belong to this project
583
3225
  *
584
3226
  * State updates:
585
- * - lastReleaseTag: Tag name of last release (null if first release)
586
- * - commits: Array of parsed conventional commits
3227
+ * - effectiveBaseCommit: The verified base commit (null if fallback was used)
3228
+ * - commits: Array of parsed conventional commits (for backward compatibility)
3229
+ * - classificationResult: Full classification result with source attribution
587
3230
  *
588
3231
  * @returns A FlowStep that analyzes commits
589
3232
  */
590
3233
  function createAnalyzeCommitsStep() {
591
3234
  return createStep(ANALYZE_COMMITS_STEP_ID, 'Analyze Commits', async (ctx) => {
592
- const { git, projectName, packageName, config, logger, state } = ctx;
593
- // Find the last release tag for this package
594
- let lastReleaseTag = null;
595
- if (!state.isFirstRelease) {
596
- // Try to find a tag matching the package name pattern
597
- const tags = git.getTagsForPackage(packageName);
598
- if (tags.length > 0) {
599
- // Tags are returned in reverse chronological order
600
- lastReleaseTag = tags[0].name;
601
- logger.debug(`Found last release tag: ${lastReleaseTag}`);
3235
+ const { git, projectName, projectRoot, packageName, workspaceRoot, config, logger, state } = ctx;
3236
+ const maxFallback = config.maxCommitFallback ?? 500;
3237
+ // Use publishedCommit from registry (set by fetch-registry step)
3238
+ const { publishedCommit, isFirstRelease } = state;
3239
+ let rawCommits;
3240
+ let effectiveBaseCommit = null;
3241
+ if (publishedCommit && !isFirstRelease) {
3242
+ // CRITICAL: Verify the commit exists and is reachable from HEAD
3243
+ if (git.commitReachableFromHead(publishedCommit)) {
3244
+ rawCommits = git.getCommitsSince(publishedCommit);
3245
+ effectiveBaseCommit = publishedCommit;
3246
+ logger.debug(`Found ${rawCommits.length} commits since ${publishedCommit.slice(0, 7)}`);
602
3247
  }
603
3248
  else {
604
- // Try with project name format
605
- const projectTags = git.getTagsForPackage(projectName);
606
- if (projectTags.length > 0) {
607
- lastReleaseTag = projectTags[0].name;
608
- logger.debug(`Found last release tag (project format): ${lastReleaseTag}`);
609
- }
3249
+ // GRACEFUL DEGRADATION: Commit not in history (rebase/force push occurred)
3250
+ logger.warn(`Published commit ${publishedCommit.slice(0, 7)} not found in history. ` +
3251
+ `This may indicate a rebase or force push occurred after publishing v${state.publishedVersion}. ` +
3252
+ `Falling back to recent commit analysis.`);
3253
+ rawCommits = git.getCommitLog({ maxCount: maxFallback });
3254
+ // effectiveBaseCommit stays null - no compare URL will be generated
610
3255
  }
611
3256
  }
612
- // Get commits
613
- let rawCommits;
614
- if (lastReleaseTag) {
615
- rawCommits = git.getCommitsSince(lastReleaseTag);
616
- logger.debug(`Found ${rawCommits.length} commits since ${lastReleaseTag}`);
617
- }
618
3257
  else {
619
- // First release - get all commits (limit to recent for performance)
620
- rawCommits = git.getCommitLog({ maxCount: 100 });
3258
+ // First release or no published version
3259
+ rawCommits = git.getCommitLog({ maxCount: maxFallback });
621
3260
  logger.debug(`First release - analyzing up to ${rawCommits.length} commits`);
622
3261
  }
623
- // Parse commits using conventional commit format
624
- const commits = [];
3262
+ // Get scope filtering configuration
3263
+ const scopeFilteringConfig = {
3264
+ ...DEFAULT_SCOPE_FILTERING_CONFIG,
3265
+ ...config.scopeFiltering,
3266
+ };
3267
+ const strategy = resolveStrategy(scopeFilteringConfig.strategy ?? 'hybrid', rawCommits);
3268
+ // Parse commits with conventional commit format
625
3269
  const releaseTypes = config.releaseTypes ?? ['feat', 'fix', 'perf', 'revert'];
3270
+ const parsedCommits = [];
626
3271
  for (const rawCommit of rawCommits) {
627
3272
  const parsed = parseConventionalCommit(rawCommit.message);
628
3273
  if (parsed.type && releaseTypes.includes(parsed.type)) {
629
- commits.push(parsed);
3274
+ parsedCommits.push({
3275
+ commit: parsed,
3276
+ raw: {
3277
+ hash: rawCommit.hash,
3278
+ shortHash: rawCommit.hash.slice(0, 7),
3279
+ message: rawCommit.message,
3280
+ subject: parsed.subject ?? rawCommit.message.split('\n')[0],
3281
+ body: parsed.body ?? '',
3282
+ authorName: '',
3283
+ authorEmail: '',
3284
+ authorDate: '',
3285
+ committerName: '',
3286
+ committerEmail: '',
3287
+ commitDate: '',
3288
+ parents: [],
3289
+ refs: [],
3290
+ },
3291
+ });
630
3292
  }
631
3293
  }
632
- const message = commits.length > 0
633
- ? `Found ${commits.length} releasable commits (${rawCommits.length} total)`
634
- : `No releasable commits found (${rawCommits.length} total)`;
3294
+ // Build file commit hashes for hybrid/file-only strategies
3295
+ let fileCommitHashes = createSet();
3296
+ if (strategy === 'hybrid' || strategy === 'file-only') {
3297
+ // Get commits that touched project files using path filter
3298
+ const relativePath = getRelativePath(workspaceRoot, projectRoot);
3299
+ const pathFilteredCommits = effectiveBaseCommit
3300
+ ? git.getCommitsSince(effectiveBaseCommit, { path: relativePath })
3301
+ : git.getCommitLog({ maxCount: maxFallback, path: relativePath });
3302
+ fileCommitHashes = createSet(pathFilteredCommits.map((c) => c.hash));
3303
+ logger.debug(`Found ${fileCommitHashes.size} commits touching ${relativePath}`);
3304
+ }
3305
+ // Derive project scopes
3306
+ const projectScopes = deriveProjectScopes({
3307
+ projectName,
3308
+ packageName,
3309
+ additionalScopes: scopeFilteringConfig.includeScopes,
3310
+ prefixes: scopeFilteringConfig.projectPrefixes,
3311
+ });
3312
+ logger.debug(`Project scopes: ${projectScopes.join(', ')}`);
3313
+ // Build infrastructure commit hashes for file-based infrastructure detection
3314
+ const infrastructureCommitHashes = buildInfrastructureCommitHashes(git, effectiveBaseCommit, rawCommits, parsedCommits, scopeFilteringConfig, logger, maxFallback);
3315
+ // Build dependency commit map if tracking is enabled (Phase 4)
3316
+ let dependencyCommitMap;
3317
+ if (scopeFilteringConfig.trackDependencyChanges) {
3318
+ dependencyCommitMap = buildDependencyCommitMap(git, workspaceRoot, projectName, effectiveBaseCommit, logger, maxFallback);
3319
+ }
3320
+ // Create classification context
3321
+ const classificationContext = createClassificationContext(projectScopes, fileCommitHashes, {
3322
+ excludeScopes: scopeFilteringConfig.excludeScopes,
3323
+ includeScopes: scopeFilteringConfig.includeScopes,
3324
+ infrastructureCommitHashes,
3325
+ dependencyCommitMap,
3326
+ });
3327
+ // Classify commits
3328
+ const classificationResult = classifyCommits(parsedCommits, classificationContext);
3329
+ // Apply strategy-specific filtering
3330
+ const includedCommits = applyStrategyFilter(classificationResult.included, strategy);
3331
+ // Extract conventional commits for backward compatibility
3332
+ // Use toChangelogCommit to properly handle scope based on classification
3333
+ const commits = includedCommits.map((c) => toChangelogCommit(c));
3334
+ // Build message with classification summary
3335
+ const { summary } = classificationResult;
3336
+ const message = buildSummaryMessage(commits.length, rawCommits.length, summary, strategy);
3337
+ logger.debug(`Classification breakdown: direct-scope=${summary.bySource['direct-scope']}, ` +
3338
+ `direct-file=${summary.bySource['direct-file']}, unscoped-file=${summary.bySource['unscoped-file']}, ` +
3339
+ `excluded=${summary.bySource['excluded']}`);
635
3340
  return {
636
3341
  status: 'success',
637
3342
  stateUpdates: {
638
- lastReleaseTag,
3343
+ effectiveBaseCommit,
639
3344
  commits,
3345
+ classificationResult,
640
3346
  },
641
3347
  message,
642
3348
  };
@@ -644,6 +3350,376 @@ function createAnalyzeCommitsStep() {
644
3350
  dependsOn: ['fetch-registry'],
645
3351
  });
646
3352
  }
3353
+ /**
3354
+ * Resolves the filtering strategy, handling 'inferred' by analyzing commits.
3355
+ *
3356
+ * @param strategy - The configured scope filtering strategy
3357
+ * @param commits - The commits to analyze for strategy inference
3358
+ * @returns The resolved strategy (never 'inferred')
3359
+ */
3360
+ function resolveStrategy(strategy, commits) {
3361
+ if (strategy !== 'inferred') {
3362
+ return strategy;
3363
+ }
3364
+ // Infer strategy from commit history
3365
+ // Count commits with conventional scopes
3366
+ let scopedCount = 0;
3367
+ for (const commit of commits) {
3368
+ const parsed = parseConventionalCommit(commit.message);
3369
+ if (parsed.scope) {
3370
+ scopedCount++;
3371
+ }
3372
+ }
3373
+ const scopeRatio = commits.length > 0 ? scopedCount / commits.length : 0;
3374
+ // If >70% of commits have scopes, scope-only is viable
3375
+ // If <30% have scopes, file-only is better
3376
+ // Otherwise, use hybrid
3377
+ if (scopeRatio > 0.7) {
3378
+ return 'scope-only';
3379
+ }
3380
+ else if (scopeRatio < 0.3) {
3381
+ return 'file-only';
3382
+ }
3383
+ return 'hybrid';
3384
+ }
3385
+ /**
3386
+ * Applies strategy-specific filtering to classified commits.
3387
+ *
3388
+ * @param commits - The classified commits to filter
3389
+ * @param strategy - The resolved filtering strategy to apply
3390
+ * @returns Filtered commits based on the strategy
3391
+ */
3392
+ function applyStrategyFilter(commits, strategy) {
3393
+ switch (strategy) {
3394
+ case 'scope-only':
3395
+ // Only include direct-scope commits
3396
+ return commits.filter((c) => c.source === 'direct-scope');
3397
+ case 'file-only':
3398
+ // Only include file-based commits (direct-file, unscoped-file)
3399
+ return commits.filter((c) => c.source === 'direct-file' || c.source === 'unscoped-file');
3400
+ case 'hybrid':
3401
+ default:
3402
+ // Include all non-excluded commits (already filtered in classifyCommits)
3403
+ return commits;
3404
+ }
3405
+ }
3406
+ /**
3407
+ * Gets the relative path from workspace root to project root.
3408
+ *
3409
+ * @param workspaceRoot - The absolute path to the workspace root
3410
+ * @param projectRoot - The absolute path to the project root
3411
+ * @returns The relative path from workspace to project
3412
+ */
3413
+ function getRelativePath(workspaceRoot, projectRoot) {
3414
+ if (projectRoot.startsWith(workspaceRoot)) {
3415
+ return projectRoot.slice(workspaceRoot.length).replace(/^\//, '');
3416
+ }
3417
+ return projectRoot;
3418
+ }
3419
+ /**
3420
+ * Builds a summary message for the step result.
3421
+ *
3422
+ * @param includedCount - Number of commits included in the release
3423
+ * @param totalCount - Total number of commits analyzed
3424
+ * @param summary - Classification summary object
3425
+ * @param summary.bySource - Count of commits by source type
3426
+ * @param strategy - The filtering strategy used
3427
+ * @returns A human-readable summary message
3428
+ */
3429
+ function buildSummaryMessage(includedCount, totalCount, summary, strategy) {
3430
+ if (includedCount === 0) {
3431
+ return `No releasable commits found for this project (${totalCount} total, strategy: ${strategy})`;
3432
+ }
3433
+ const parts = [`Found ${includedCount} releasable commits`, `(${totalCount} total`, `strategy: ${strategy})`];
3434
+ return parts.join(' ');
3435
+ }
3436
+ /**
3437
+ * Builds a set of commit hashes that touched infrastructure paths or match infrastructure criteria.
3438
+ *
3439
+ * Supports multiple detection methods combined with OR logic:
3440
+ * 1. Path-based: Commits touching configured infrastructure paths (via git)
3441
+ * 2. Scope-based: Commits with scopes matching infrastructure.scopes
3442
+ * 3. Custom matcher: User-provided matching logic
3443
+ *
3444
+ * @param git - Git client for querying commits by path
3445
+ * @param baseCommit - Base commit hash for commit range (null for first release/fallback)
3446
+ * @param rawCommits - All raw commits being analyzed
3447
+ * @param parsedCommits - Parsed commits with conventional commit data
3448
+ * @param config - Scope filtering configuration
3449
+ * @param logger - Logger with debug method for output
3450
+ * @param logger.debug - Debug logging function
3451
+ * @param maxFallback - Maximum commits to query when baseCommit is null
3452
+ * @returns Set of commit hashes classified as infrastructure
3453
+ */
3454
+ function buildInfrastructureCommitHashes(git, baseCommit, rawCommits, parsedCommits, config, logger, maxFallback) {
3455
+ // Collect all infrastructure commit hashes
3456
+ let infraHashes = createSet();
3457
+ // Method 1: Path-based detection (query git for commits touching infra paths)
3458
+ const infraPaths = config.infrastructure?.paths ?? [];
3459
+ if (infraPaths.length > 0) {
3460
+ for (const infraPath of infraPaths) {
3461
+ const pathCommits = baseCommit
3462
+ ? git.getCommitsSince(baseCommit, { path: infraPath })
3463
+ : git.getCommitLog({ maxCount: maxFallback, path: infraPath });
3464
+ for (const commit of pathCommits) {
3465
+ infraHashes = infraHashes.add(commit.hash);
3466
+ }
3467
+ }
3468
+ logger.debug(`Found ${infraHashes.size} commits touching infrastructure paths: ${infraPaths.join(', ')}`);
3469
+ }
3470
+ // Method 2 & 3: Scope-based and custom matcher detection
3471
+ // Build a combined matcher from infrastructure config and/or custom matcher
3472
+ const configMatcher = config.infrastructure ? buildInfrastructureMatcher(config.infrastructure) : null;
3473
+ const customMatcher = config.infrastructureMatcher;
3474
+ const combinedMatcher = combineMatcher(configMatcher, customMatcher);
3475
+ if (combinedMatcher) {
3476
+ // Build a lookup for parsed commits by hash
3477
+ let parsedByHash = createMap();
3478
+ for (const parsed of parsedCommits) {
3479
+ parsedByHash = parsedByHash.set(parsed.raw.hash, parsed);
3480
+ }
3481
+ // Evaluate each raw commit against the matcher
3482
+ for (const rawCommit of rawCommits) {
3483
+ // Skip if already matched by path
3484
+ if (infraHashes.has(rawCommit.hash))
3485
+ continue;
3486
+ // Get parsed scope if available
3487
+ const parsed = parsedByHash.get(rawCommit.hash);
3488
+ const scope = parsed?.commit.scope;
3489
+ // Create match context and evaluate
3490
+ const context = createMatchContext(rawCommit, scope);
3491
+ if (combinedMatcher(context)) {
3492
+ infraHashes = infraHashes.add(rawCommit.hash);
3493
+ }
3494
+ }
3495
+ logger.debug(`Infrastructure matcher found ${infraHashes.size} total commits`);
3496
+ }
3497
+ // Return undefined if no infrastructure detection configured
3498
+ if (infraHashes.size === 0 && infraPaths.length === 0 && !combinedMatcher) {
3499
+ return undefined;
3500
+ }
3501
+ return infraHashes;
3502
+ }
3503
+ /**
3504
+ * Combines two optional matchers into one using OR logic.
3505
+ *
3506
+ * @param a - First matcher (may be null)
3507
+ * @param b - Second matcher (may be undefined)
3508
+ * @returns Combined matcher or null if neither provided
3509
+ */
3510
+ function combineMatcher(a, b) {
3511
+ if (a && b) {
3512
+ return (ctx) => a(ctx) || b(ctx);
3513
+ }
3514
+ return a ?? b ?? null;
3515
+ }
3516
+ /**
3517
+ * Builds a map of dependency project names to the commit hashes that touched them.
3518
+ *
3519
+ * This enables accurate indirect-dependency classification by verifying that:
3520
+ * 1. A commit's scope matches a dependency name
3521
+ * 2. The commit actually touched that dependency's files (hash in set)
3522
+ *
3523
+ * Uses lib-project-scope for dependency discovery, avoiding hard NX dependency.
3524
+ *
3525
+ * @param git - Git client for querying commits by path
3526
+ * @param workspaceRoot - Absolute path to workspace root
3527
+ * @param projectName - Name of the project being versioned
3528
+ * @param baseCommit - Base commit hash for commit range (null for first release/fallback)
3529
+ * @param logger - Logger with debug method for output
3530
+ * @param logger.debug - Debug logging function
3531
+ * @param maxFallback - Maximum commits to query when baseCommit is null
3532
+ * @returns Map of dependency names to commit hashes touching that dependency
3533
+ */
3534
+ function buildDependencyCommitMap(git, workspaceRoot, projectName, baseCommit, logger, maxFallback) {
3535
+ let dependencyMap = createMap();
3536
+ try {
3537
+ // Discover all projects in workspace using lib-project-scope
3538
+ // This gracefully handles NX and non-NX workspaces
3539
+ const projects = discoverNxProjects(workspaceRoot);
3540
+ const projectGraph = buildSimpleProjectGraph(workspaceRoot, projects);
3541
+ // Get dependencies for the current project
3542
+ const projectDeps = projectGraph.dependencies[projectName] ?? [];
3543
+ if (projectDeps.length === 0) {
3544
+ logger.debug(`No dependencies found for project: ${projectName}`);
3545
+ return dependencyMap;
3546
+ }
3547
+ logger.debug(`Found ${projectDeps.length} dependencies for ${projectName}: ${projectDeps.map((d) => d.target).join(', ')}`);
3548
+ // For each dependency, find commits that touched its files
3549
+ for (const dep of projectDeps) {
3550
+ const depNode = projectGraph.nodes[dep.target];
3551
+ if (!depNode?.data?.root) {
3552
+ logger.debug(`Skipping dependency ${dep.target}: no root path found`);
3553
+ continue;
3554
+ }
3555
+ const depRoot = depNode.data.root;
3556
+ // Query git for commits touching this dependency's path
3557
+ const depCommits = baseCommit
3558
+ ? git.getCommitsSince(baseCommit, { path: depRoot })
3559
+ : git.getCommitLog({ maxCount: maxFallback, path: depRoot });
3560
+ if (depCommits.length > 0) {
3561
+ const hashSet = createSet(depCommits.map((c) => c.hash));
3562
+ dependencyMap = dependencyMap.set(dep.target, hashSet);
3563
+ logger.debug(`Dependency ${dep.target}: ${depCommits.length} commits at ${depRoot}`);
3564
+ }
3565
+ }
3566
+ }
3567
+ catch (error) {
3568
+ // Graceful degradation: if project discovery fails, return empty map
3569
+ // This allows versioning to proceed without dependency tracking
3570
+ const message = error instanceof Error ? error.message : String(error);
3571
+ logger.debug(`Failed to build dependency map: ${message}`);
3572
+ }
3573
+ return dependencyMap;
3574
+ }
3575
+
3576
+ /**
3577
+ * Safe copies of Number built-in methods and constants.
3578
+ *
3579
+ * These references are captured at module initialization time to protect against
3580
+ * prototype pollution attacks. Import only what you need for tree-shaking.
3581
+ *
3582
+ * @module @hyperfrontend/immutable-api-utils/built-in-copy/number
3583
+ */
3584
+ // Capture references at module initialization time
3585
+ const _parseInt = globalThis.parseInt;
3586
+ const _isNaN = globalThis.isNaN;
3587
+ // ============================================================================
3588
+ // Parsing
3589
+ // ============================================================================
3590
+ /**
3591
+ * (Safe copy) Parses a string and returns an integer.
3592
+ */
3593
+ const parseInt = _parseInt;
3594
+ // ============================================================================
3595
+ // Global Type Checking (legacy, less strict)
3596
+ // ============================================================================
3597
+ /**
3598
+ * (Safe copy) Global isNaN function (coerces to number first, less strict than Number.isNaN).
3599
+ */
3600
+ const globalIsNaN = _isNaN;
3601
+
3602
+ /**
3603
+ * Compares two semantic versions.
3604
+ *
3605
+ * @param a - First version
3606
+ * @param b - Second version
3607
+ * @returns -1 if a < b, 0 if a == b, 1 if a > b
3608
+ *
3609
+ * @example
3610
+ * compare(parseVersion('1.0.0'), parseVersion('2.0.0')) // -1
3611
+ * compare(parseVersion('1.0.0'), parseVersion('1.0.0')) // 0
3612
+ * compare(parseVersion('2.0.0'), parseVersion('1.0.0')) // 1
3613
+ */
3614
+ function compare(a, b) {
3615
+ // Compare major, minor, patch
3616
+ if (a.major !== b.major) {
3617
+ return a.major < b.major ? -1 : 1;
3618
+ }
3619
+ if (a.minor !== b.minor) {
3620
+ return a.minor < b.minor ? -1 : 1;
3621
+ }
3622
+ if (a.patch !== b.patch) {
3623
+ return a.patch < b.patch ? -1 : 1;
3624
+ }
3625
+ // Compare prerelease
3626
+ // Version with prerelease has lower precedence than release
3627
+ if (a.prerelease.length === 0 && b.prerelease.length > 0) {
3628
+ return 1; // a is release, b is prerelease -> a > b
3629
+ }
3630
+ if (a.prerelease.length > 0 && b.prerelease.length === 0) {
3631
+ return -1; // a is prerelease, b is release -> a < b
3632
+ }
3633
+ // Both have prerelease - compare identifiers
3634
+ const maxLen = max(a.prerelease.length, b.prerelease.length);
3635
+ for (let i = 0; i < maxLen; i++) {
3636
+ const aId = a.prerelease[i];
3637
+ const bId = b.prerelease[i];
3638
+ // Shorter prerelease array has lower precedence
3639
+ if (aId === undefined && bId !== undefined) {
3640
+ return -1;
3641
+ }
3642
+ if (aId !== undefined && bId === undefined) {
3643
+ return 1;
3644
+ }
3645
+ if (aId === undefined || bId === undefined) {
3646
+ continue;
3647
+ }
3648
+ // Compare identifiers
3649
+ const cmp = compareIdentifiers(aId, bId);
3650
+ if (cmp !== 0) {
3651
+ return cmp;
3652
+ }
3653
+ }
3654
+ return 0;
3655
+ }
3656
+ /**
3657
+ * Checks if a > b.
3658
+ *
3659
+ * @param a - First version to compare
3660
+ * @param b - Second version to compare
3661
+ * @returns True if a is greater than b
3662
+ */
3663
+ function gt(a, b) {
3664
+ return compare(a, b) === 1;
3665
+ }
3666
+ // ============================================================================
3667
+ // Internal helpers
3668
+ // ============================================================================
3669
+ /**
3670
+ * Compares two prerelease identifiers.
3671
+ * Numeric identifiers have lower precedence than alphanumeric.
3672
+ * Numeric identifiers are compared numerically.
3673
+ * Alphanumeric identifiers are compared lexically.
3674
+ *
3675
+ * @param a - First prerelease identifier
3676
+ * @param b - Second prerelease identifier
3677
+ * @returns -1 if a < b, 0 if equal, 1 if a > b
3678
+ */
3679
+ function compareIdentifiers(a, b) {
3680
+ const aIsNumeric = isNumeric(a);
3681
+ const bIsNumeric = isNumeric(b);
3682
+ // Numeric identifiers have lower precedence
3683
+ if (aIsNumeric && !bIsNumeric) {
3684
+ return -1;
3685
+ }
3686
+ if (!aIsNumeric && bIsNumeric) {
3687
+ return 1;
3688
+ }
3689
+ // Both numeric - compare as numbers
3690
+ if (aIsNumeric && bIsNumeric) {
3691
+ const aNum = parseInt(a, 10);
3692
+ const bNum = parseInt(b, 10);
3693
+ if (aNum < bNum)
3694
+ return -1;
3695
+ if (aNum > bNum)
3696
+ return 1;
3697
+ return 0;
3698
+ }
3699
+ // Both alphanumeric - compare lexically
3700
+ if (a < b)
3701
+ return -1;
3702
+ if (a > b)
3703
+ return 1;
3704
+ return 0;
3705
+ }
3706
+ /**
3707
+ * Checks if a string consists only of digits.
3708
+ *
3709
+ * @param str - String to check for numeric content
3710
+ * @returns True if string contains only digits
3711
+ */
3712
+ function isNumeric(str) {
3713
+ if (str.length === 0)
3714
+ return false;
3715
+ for (let i = 0; i < str.length; i++) {
3716
+ const code = str.charCodeAt(i);
3717
+ if (code < 48 || code > 57) {
3718
+ return false;
3719
+ }
3720
+ }
3721
+ return true;
3722
+ }
647
3723
 
648
3724
  /**
649
3725
  * Converts a SemVer to its canonical string representation.
@@ -662,32 +3738,6 @@ function format(version) {
662
3738
  return result;
663
3739
  }
664
3740
 
665
- /**
666
- * Safe copies of Number built-in methods and constants.
667
- *
668
- * These references are captured at module initialization time to protect against
669
- * prototype pollution attacks. Import only what you need for tree-shaking.
670
- *
671
- * @module @hyperfrontend/immutable-api-utils/built-in-copy/number
672
- */
673
- // Capture references at module initialization time
674
- const _parseInt = globalThis.parseInt;
675
- const _isNaN = globalThis.isNaN;
676
- // ============================================================================
677
- // Parsing
678
- // ============================================================================
679
- /**
680
- * (Safe copy) Parses a string and returns an integer.
681
- */
682
- const parseInt = _parseInt;
683
- // ============================================================================
684
- // Global Type Checking (legacy, less strict)
685
- // ============================================================================
686
- /**
687
- * (Safe copy) Global isNaN function (coerces to number first, less strict than Number.isNaN).
688
- */
689
- const globalIsNaN = _isNaN;
690
-
691
3741
  /**
692
3742
  * Creates a new SemVer object.
693
3743
  *
@@ -1165,7 +4215,7 @@ function createCalculateBumpStep() {
1165
4215
  message: 'No version bump needed',
1166
4216
  };
1167
4217
  }
1168
- // Calculate next version
4218
+ // Parse versions for comparison
1169
4219
  const current = parseVersion(currentVersion ?? '0.0.0');
1170
4220
  if (!current.success || !current.version) {
1171
4221
  return {
@@ -1174,6 +4224,27 @@ function createCalculateBumpStep() {
1174
4224
  message: `Could not parse current version: ${currentVersion}`,
1175
4225
  };
1176
4226
  }
4227
+ const { publishedVersion } = state;
4228
+ const published = parseVersion(publishedVersion ?? '0.0.0');
4229
+ // Detect pending publication state: currentVersion > publishedVersion
4230
+ // This means a previous bump happened but was never published
4231
+ const isPendingPublication = published.success && published.version && publishedVersion != null && gt(current.version, published.version);
4232
+ if (isPendingPublication && published.version) {
4233
+ // ALWAYS calculate from publishedVersion - commits may have changed
4234
+ const next = increment(published.version, bumpType);
4235
+ const nextVersion = format(next);
4236
+ logger.info(`Pending publication detected: recalculating from ${publishedVersion} → ${nextVersion}`);
4237
+ return {
4238
+ status: 'success',
4239
+ stateUpdates: {
4240
+ bumpType,
4241
+ nextVersion,
4242
+ isPendingPublication: true,
4243
+ },
4244
+ message: `${bumpType} bump (pending): ${publishedVersion} → ${nextVersion}`,
4245
+ };
4246
+ }
4247
+ // Normal path: increment from currentVersion
1177
4248
  const next = increment(current.version, bumpType);
1178
4249
  const nextVersion = format(next);
1179
4250
  return {
@@ -1228,24 +4299,6 @@ function createCheckIdempotencyStep() {
1228
4299
  });
1229
4300
  }
1230
4301
 
1231
- /**
1232
- * Safe copies of Date built-in via factory function and static methods.
1233
- *
1234
- * Since constructors cannot be safely captured via Object.assign, this module
1235
- * provides a factory function that uses Reflect.construct internally.
1236
- *
1237
- * These references are captured at module initialization time to protect against
1238
- * prototype pollution attacks. Import only what you need for tree-shaking.
1239
- *
1240
- * @module @hyperfrontend/immutable-api-utils/built-in-copy/date
1241
- */
1242
- // Capture references at module initialization time
1243
- const _Date = globalThis.Date;
1244
- const _Reflect$1 = globalThis.Reflect;
1245
- function createDate(...args) {
1246
- return _Reflect$1.construct(_Date, args);
1247
- }
1248
-
1249
4302
  /**
1250
4303
  * Creates a new changelog item.
1251
4304
  *
@@ -1260,6 +4313,8 @@ function createChangelogItem(description, options) {
1260
4313
  commits: options?.commits ?? [],
1261
4314
  references: options?.references ?? [],
1262
4315
  breaking: options?.breaking ?? false,
4316
+ source: options?.source,
4317
+ indirect: options?.indirect,
1263
4318
  };
1264
4319
  }
1265
4320
  /**
@@ -1383,96 +4438,6 @@ function getSectionType(heading) {
1383
4438
  return SECTION_TYPE_MAP[normalized] ?? 'other';
1384
4439
  }
1385
4440
 
1386
- /**
1387
- * Safe copies of Map built-in via factory function.
1388
- *
1389
- * Since constructors cannot be safely captured via Object.assign, this module
1390
- * provides a factory function that uses Reflect.construct internally.
1391
- *
1392
- * These references are captured at module initialization time to protect against
1393
- * prototype pollution attacks. Import only what you need for tree-shaking.
1394
- *
1395
- * @module @hyperfrontend/immutable-api-utils/built-in-copy/map
1396
- */
1397
- // Capture references at module initialization time
1398
- const _Map = globalThis.Map;
1399
- const _Reflect = globalThis.Reflect;
1400
- /**
1401
- * (Safe copy) Creates a new Map using the captured Map constructor.
1402
- * Use this instead of `new Map()`.
1403
- *
1404
- * @param iterable - Optional iterable of key-value pairs.
1405
- * @returns A new Map instance.
1406
- */
1407
- const createMap = (iterable) => _Reflect.construct(_Map, iterable ? [iterable] : []);
1408
-
1409
- /**
1410
- * Safe copies of Object built-in methods.
1411
- *
1412
- * These references are captured at module initialization time to protect against
1413
- * prototype pollution attacks. Import only what you need for tree-shaking.
1414
- *
1415
- * @module @hyperfrontend/immutable-api-utils/built-in-copy/object
1416
- */
1417
- // Capture references at module initialization time
1418
- const _Object = globalThis.Object;
1419
- /**
1420
- * (Safe copy) Returns an array of key/values of the enumerable own properties of an object.
1421
- */
1422
- const entries = _Object.entries;
1423
-
1424
- /**
1425
- * Safe copies of URL built-ins via factory functions.
1426
- *
1427
- * Provides safe references to URL and URLSearchParams.
1428
- * These references are captured at module initialization time to protect against
1429
- * prototype pollution attacks. Import only what you need for tree-shaking.
1430
- *
1431
- * @module @hyperfrontend/immutable-api-utils/built-in-copy/url
1432
- */
1433
- // Capture references at module initialization time
1434
- const _URL = globalThis.URL;
1435
- /**
1436
- * (Safe copy) Creates an object URL for the given object.
1437
- * Use this instead of `URL.createObjectURL()`.
1438
- *
1439
- * Note: This is a browser-only API. In Node.js environments, this will throw.
1440
- */
1441
- typeof _URL.createObjectURL === 'function'
1442
- ? _URL.createObjectURL.bind(_URL)
1443
- : () => {
1444
- throw new Error('URL.createObjectURL is not available in this environment');
1445
- };
1446
- /**
1447
- * (Safe copy) Revokes an object URL previously created with createObjectURL.
1448
- * Use this instead of `URL.revokeObjectURL()`.
1449
- *
1450
- * Note: This is a browser-only API. In Node.js environments, this will throw.
1451
- */
1452
- typeof _URL.revokeObjectURL === 'function'
1453
- ? _URL.revokeObjectURL.bind(_URL)
1454
- : () => {
1455
- throw new Error('URL.revokeObjectURL is not available in this environment');
1456
- };
1457
-
1458
- /**
1459
- * Safe copies of Math built-in methods.
1460
- *
1461
- * These references are captured at module initialization time to protect against
1462
- * prototype pollution attacks. Import only what you need for tree-shaking.
1463
- *
1464
- * @module @hyperfrontend/immutable-api-utils/built-in-copy/math
1465
- */
1466
- // Capture references at module initialization time
1467
- const _Math = globalThis.Math;
1468
- // ============================================================================
1469
- // Min/Max
1470
- // ============================================================================
1471
- /**
1472
- * (Safe copy) Returns the larger of zero or more numbers.
1473
- */
1474
- const max = _Math.max;
1475
-
1476
4441
  /**
1477
4442
  * Line Parser
1478
4443
  *
@@ -1528,6 +4493,25 @@ function parseVersionFromHeading(heading) {
1528
4493
  if (trimmed[pos] === ']') {
1529
4494
  pos++;
1530
4495
  }
4496
+ // Handle markdown link format [version](url) - jscutlery/semver style
4497
+ // This extracts the compare URL from patterns like [0.0.4](https://github.com/.../compare/...)
4498
+ if (trimmed[pos] === '(') {
4499
+ const urlStart = pos + 1;
4500
+ let depth = 1;
4501
+ pos++;
4502
+ // Find matching closing parenthesis (handles nested parens in URLs)
4503
+ while (pos < trimmed.length && depth > 0) {
4504
+ if (trimmed[pos] === '(')
4505
+ depth++;
4506
+ else if (trimmed[pos] === ')')
4507
+ depth--;
4508
+ pos++;
4509
+ }
4510
+ // Extract URL if we found the closing paren
4511
+ if (depth === 0) {
4512
+ compareUrl = trimmed.slice(urlStart, pos - 1);
4513
+ }
4514
+ }
1531
4515
  // Skip whitespace and separator
1532
4516
  while (pos < trimmed.length && (trimmed[pos] === ' ' || trimmed[pos] === '-' || trimmed[pos] === '–')) {
1533
4517
  pos++;
@@ -1544,8 +4528,8 @@ function parseVersionFromHeading(heading) {
1544
4528
  while (pos < trimmed.length && trimmed[pos] === ' ') {
1545
4529
  pos++;
1546
4530
  }
1547
- // Check for link at end: [compare](url)
1548
- if (pos < trimmed.length) {
4531
+ // Check for link at end: [compare](url) - only if no URL was already extracted
4532
+ if (pos < trimmed.length && !compareUrl) {
1549
4533
  const linkMatch = extractLink(trimmed.slice(pos));
1550
4534
  if (linkMatch?.url) {
1551
4535
  compareUrl = linkMatch.url;
@@ -2239,11 +5223,22 @@ function isWhitespace(char) {
2239
5223
  }
2240
5224
 
2241
5225
  /**
2242
- * Changelog Parser
5226
+ * Validates that a URL is actually a GitHub URL by parsing it properly.
5227
+ * This prevents SSRF attacks where 'github.com' could appear in path/query.
2243
5228
  *
2244
- * Parses a changelog markdown string into a structured Changelog object.
2245
- * Uses a state machine tokenizer for ReDoS-safe parsing.
5229
+ * @param url - The URL string to validate
5230
+ * @returns True if the URL host is github.com or a subdomain
2246
5231
  */
5232
+ function isGitHubUrl(url) {
5233
+ try {
5234
+ const parsed = createURL(url);
5235
+ // Check that the host is exactly github.com or ends with .github.com
5236
+ return parsed.host === 'github.com' || parsed.host.endsWith('.github.com');
5237
+ }
5238
+ catch {
5239
+ return false;
5240
+ }
5241
+ }
2247
5242
  /**
2248
5243
  * Parses a changelog markdown string into a Changelog object.
2249
5244
  *
@@ -2311,7 +5306,7 @@ function parseHeader(state) {
2311
5306
  description.push(`[${token.value}](${nextToken.value})`);
2312
5307
  links.push({ label: token.value, url: nextToken.value });
2313
5308
  // Try to detect repository URL
2314
- if (!state.repositoryUrl && nextToken.value.includes('github.com')) {
5309
+ if (!state.repositoryUrl && isGitHubUrl(nextToken.value)) {
2315
5310
  state.repositoryUrl = extractRepoUrl(nextToken.value);
2316
5311
  }
2317
5312
  advance(state); // skip link-text
@@ -2942,20 +5937,28 @@ function serializeIssueRef(ref) {
2942
5937
  * ```
2943
5938
  */
2944
5939
  function addEntry(changelog, entry, options) {
5940
+ const position = options?.position ?? 'start';
5941
+ const replaceExisting = options?.replaceExisting ?? false;
5942
+ const updateMetadata = options?.updateMetadata ?? false;
2945
5943
  // Check for existing entry
2946
5944
  const existingIndex = changelog.entries.findIndex((e) => e.version === entry.version);
2947
- if (existingIndex !== -1 && true) {
5945
+ if (existingIndex !== -1 && !replaceExisting) {
2948
5946
  throw createError(`Entry with version "${entry.version}" already exists. Use replaceExisting: true to replace.`);
2949
5947
  }
2950
5948
  let newEntries;
2951
- {
5949
+ if (existingIndex !== -1 && replaceExisting) {
5950
+ // Replace existing entry
5951
+ newEntries = [...changelog.entries];
5952
+ newEntries[existingIndex] = entry;
5953
+ }
5954
+ else {
2952
5955
  // Add new entry
2953
- const insertIndex = 0 ;
5956
+ const insertIndex = position === 'start' ? 0 : position === 'end' ? changelog.entries.length : position;
2954
5957
  newEntries = [...changelog.entries];
2955
5958
  newEntries.splice(insertIndex, 0, entry);
2956
5959
  }
2957
5960
  // Build new metadata if requested
2958
- const metadata = changelog.metadata;
5961
+ const metadata = updateMetadata ? { ...changelog.metadata, warnings: [] } : changelog.metadata;
2959
5962
  return {
2960
5963
  ...changelog,
2961
5964
  entries: newEntries,
@@ -2963,11 +5966,149 @@ function addEntry(changelog, entry, options) {
2963
5966
  };
2964
5967
  }
2965
5968
 
5969
+ /**
5970
+ * Changelog Entry Removal
5971
+ *
5972
+ * Functions for removing entries from a changelog.
5973
+ */
5974
+ /**
5975
+ * Removes multiple entries from a changelog.
5976
+ *
5977
+ * @param changelog - The changelog to remove from
5978
+ * @param versions - The versions to remove
5979
+ * @param options - Optional removal options
5980
+ * @returns A new changelog without the specified entries
5981
+ */
5982
+ function removeEntries(changelog, versions, options) {
5983
+ const versionsSet = createSet(versions);
5984
+ const newEntries = changelog.entries.filter((e) => !versionsSet.has(e.version));
5985
+ return {
5986
+ ...changelog,
5987
+ entries: newEntries,
5988
+ };
5989
+ }
5990
+
5991
+ /**
5992
+ * Creates a platform-specific compare URL for viewing changes between two commits.
5993
+ *
5994
+ * Each platform has a different URL format:
5995
+ * - **GitHub**: `{baseUrl}/compare/{fromCommit}...{toCommit}` (three dots)
5996
+ * - **GitLab**: `{baseUrl}/-/compare/{fromCommit}...{toCommit}` (three dots, `/-/` prefix)
5997
+ * - **Bitbucket**: `{baseUrl}/compare/{toCommit}..{fromCommit}` (two dots, reversed order)
5998
+ * - **Azure DevOps**: `{baseUrl}/compare?version=GT{toCommit}&compareVersion=GT{fromCommit}` (query params)
5999
+ *
6000
+ * For `custom` platforms, a `formatCompareUrl` function must be provided in the repository config.
6001
+ * For `unknown` platforms, returns `null`.
6002
+ *
6003
+ * @param options - Compare URL options including repository, fromCommit, and toCommit
6004
+ * @returns The compare URL string, or null if URL cannot be generated
6005
+ *
6006
+ * @example
6007
+ * ```typescript
6008
+ * // GitHub
6009
+ * createCompareUrl({
6010
+ * repository: { platform: 'github', baseUrl: 'https://github.com/owner/repo' },
6011
+ * fromCommit: 'abc1234',
6012
+ * toCommit: 'def5678'
6013
+ * })
6014
+ * // → 'https://github.com/owner/repo/compare/abc1234...def5678'
6015
+ *
6016
+ * // GitLab
6017
+ * createCompareUrl({
6018
+ * repository: { platform: 'gitlab', baseUrl: 'https://gitlab.com/group/project' },
6019
+ * fromCommit: 'abc1234',
6020
+ * toCommit: 'def5678'
6021
+ * })
6022
+ * // → 'https://gitlab.com/group/project/-/compare/abc1234...def5678'
6023
+ *
6024
+ * // Bitbucket (reversed order)
6025
+ * createCompareUrl({
6026
+ * repository: { platform: 'bitbucket', baseUrl: 'https://bitbucket.org/owner/repo' },
6027
+ * fromCommit: 'abc1234',
6028
+ * toCommit: 'def5678'
6029
+ * })
6030
+ * // → 'https://bitbucket.org/owner/repo/compare/def5678..abc1234'
6031
+ *
6032
+ * // Azure DevOps
6033
+ * createCompareUrl({
6034
+ * repository: { platform: 'azure-devops', baseUrl: 'https://dev.azure.com/org/proj/_git/repo' },
6035
+ * fromCommit: 'abc1234',
6036
+ * toCommit: 'def5678'
6037
+ * })
6038
+ * // → 'https://dev.azure.com/org/proj/_git/repo/compare?version=GTdef5678&compareVersion=GTabc1234'
6039
+ *
6040
+ * // Custom formatter
6041
+ * createCompareUrl({
6042
+ * repository: {
6043
+ * platform: 'custom',
6044
+ * baseUrl: 'https://my-git.internal/repo',
6045
+ * formatCompareUrl: (from, to) => `https://my-git.internal/diff/${from}/${to}`
6046
+ * },
6047
+ * fromCommit: 'abc1234',
6048
+ * toCommit: 'def5678'
6049
+ * })
6050
+ * // → 'https://my-git.internal/diff/abc1234/def5678'
6051
+ * ```
6052
+ */
6053
+ function createCompareUrl(options) {
6054
+ const { repository, fromCommit, toCommit } = options;
6055
+ // Validate inputs
6056
+ if (!repository || !fromCommit || !toCommit) {
6057
+ return null;
6058
+ }
6059
+ // If custom formatter is provided, use it (works for any platform including overrides)
6060
+ if (repository.formatCompareUrl) {
6061
+ return repository.formatCompareUrl(fromCommit, toCommit);
6062
+ }
6063
+ const { platform, baseUrl } = repository;
6064
+ // Cannot generate URL for unknown platforms without a formatter
6065
+ if (platform === 'unknown') {
6066
+ return null;
6067
+ }
6068
+ // Custom platform requires a formatter
6069
+ if (platform === 'custom') {
6070
+ return null;
6071
+ }
6072
+ // Generate URL for known platforms
6073
+ if (isKnownPlatform(platform)) {
6074
+ return formatKnownPlatformCompareUrl(platform, baseUrl, fromCommit, toCommit);
6075
+ }
6076
+ return null;
6077
+ }
6078
+ /**
6079
+ * Formats a compare URL for known platforms.
6080
+ *
6081
+ * @param platform - Known platform type
6082
+ * @param baseUrl - Repository base URL
6083
+ * @param fromCommit - Source commit hash (older version)
6084
+ * @param toCommit - Target commit hash (newer version)
6085
+ * @returns Formatted compare URL
6086
+ *
6087
+ * @internal
6088
+ */
6089
+ function formatKnownPlatformCompareUrl(platform, baseUrl, fromCommit, toCommit) {
6090
+ switch (platform) {
6091
+ case 'github':
6092
+ // GitHub: {baseUrl}/compare/{fromCommit}...{toCommit}
6093
+ return `${baseUrl}/compare/${fromCommit}...${toCommit}`;
6094
+ case 'gitlab':
6095
+ // GitLab: {baseUrl}/-/compare/{fromCommit}...{toCommit}
6096
+ return `${baseUrl}/-/compare/${fromCommit}...${toCommit}`;
6097
+ case 'bitbucket':
6098
+ // Bitbucket: {baseUrl}/compare/{toCommit}..{fromCommit} (reversed order, two dots)
6099
+ return `${baseUrl}/compare/${toCommit}..${fromCommit}`;
6100
+ case 'azure-devops':
6101
+ // Azure DevOps: {baseUrl}/compare?version=GT{toCommit}&compareVersion=GT{fromCommit}
6102
+ // Use encodeURIComponent for query parameter values
6103
+ return `${baseUrl}/compare?version=GT${encodeURIComponent(toCommit)}&compareVersion=GT${encodeURIComponent(fromCommit)}`;
6104
+ }
6105
+ }
6106
+
2966
6107
  const GENERATE_CHANGELOG_STEP_ID = 'generate-changelog';
2967
6108
  /**
2968
6109
  * Maps conventional commit types to changelog section types.
2969
6110
  */
2970
- const COMMIT_TYPE_TO_SECTION = {
6111
+ const DEFAULT_COMMIT_TYPE_TO_SECTION = {
2971
6112
  feat: 'features',
2972
6113
  fix: 'fixes',
2973
6114
  perf: 'performance',
@@ -2980,23 +6121,102 @@ const COMMIT_TYPE_TO_SECTION = {
2980
6121
  chore: 'chores',
2981
6122
  style: 'other',
2982
6123
  };
6124
+ /**
6125
+ * Resolves the commit type to section mapping by merging config with defaults.
6126
+ *
6127
+ * @param configMapping - User-provided partial mapping from FlowConfig
6128
+ * @returns Resolved mapping with user overrides applied
6129
+ */
6130
+ function resolveCommitTypeMapping(configMapping) {
6131
+ if (!configMapping) {
6132
+ return DEFAULT_COMMIT_TYPE_TO_SECTION;
6133
+ }
6134
+ return { ...DEFAULT_COMMIT_TYPE_TO_SECTION, ...configMapping };
6135
+ }
6136
+ /**
6137
+ * Checks if a commit source represents an indirect change.
6138
+ *
6139
+ * @param source - The commit source type
6140
+ * @returns True if the commit is indirect (dependency or infrastructure)
6141
+ */
6142
+ function isIndirectSource(source) {
6143
+ return source === 'indirect-dependency' || source === 'indirect-infra';
6144
+ }
6145
+ /**
6146
+ * Groups classified commits by their section type.
6147
+ *
6148
+ * @param commits - Array of classified commits
6149
+ * @param mapping - Commit type to section mapping
6150
+ * @returns Record of section type to classified commits
6151
+ */
6152
+ function groupClassifiedCommitsBySection(commits, mapping) {
6153
+ const groups = {};
6154
+ for (const classified of commits) {
6155
+ const sectionType = mapping[classified.commit.type ?? 'chore'];
6156
+ // Skip if explicitly excluded (null)
6157
+ if (sectionType === null)
6158
+ continue;
6159
+ // Fallback to 'chores' for unmapped types
6160
+ const resolvedSection = sectionType ?? 'chores';
6161
+ if (!groups[resolvedSection]) {
6162
+ groups[resolvedSection] = [];
6163
+ }
6164
+ groups[resolvedSection].push(classified);
6165
+ }
6166
+ return groups;
6167
+ }
2983
6168
  /**
2984
6169
  * Groups commits by their section type.
2985
6170
  *
2986
6171
  * @param commits - Array of conventional commits
6172
+ * @param mapping - Commit type to section mapping
2987
6173
  * @returns Record of section type to commits
2988
6174
  */
2989
- function groupCommitsBySection(commits) {
6175
+ function groupCommitsBySection(commits, mapping) {
2990
6176
  const groups = {};
2991
6177
  for (const commit of commits) {
2992
- const sectionType = COMMIT_TYPE_TO_SECTION[commit.type ?? 'chore'] ?? 'chores';
2993
- if (!groups[sectionType]) {
2994
- groups[sectionType] = [];
6178
+ const sectionType = mapping[commit.type ?? 'chore'];
6179
+ // Skip if explicitly excluded (null)
6180
+ if (sectionType === null)
6181
+ continue;
6182
+ // Fallback to 'chores' for unmapped types
6183
+ const resolvedSection = sectionType ?? 'chores';
6184
+ if (!groups[resolvedSection]) {
6185
+ groups[resolvedSection] = [];
2995
6186
  }
2996
- groups[sectionType].push(commit);
6187
+ groups[resolvedSection].push(commit);
2997
6188
  }
2998
6189
  return groups;
2999
6190
  }
6191
+ /**
6192
+ * Creates a changelog item from a classified commit.
6193
+ *
6194
+ * Applies scope display rules:
6195
+ * - Direct commits: scope omitted (redundant in project changelog)
6196
+ * - Indirect commits: scope preserved (provides context)
6197
+ *
6198
+ * @param classified - The classified commit with source metadata
6199
+ * @returns A changelog item with proper scope handling
6200
+ */
6201
+ function classifiedCommitToItem(classified) {
6202
+ // Apply scope transformation based on classification
6203
+ const commit = toChangelogCommit(classified);
6204
+ const indirect = isIndirectSource(classified.source);
6205
+ let text = commit.subject;
6206
+ // Add scope prefix if preserved (indirect commits)
6207
+ if (commit.scope) {
6208
+ text = `**${commit.scope}:** ${text}`;
6209
+ }
6210
+ // Add breaking change indicator
6211
+ if (commit.breaking) {
6212
+ text = `⚠️ BREAKING: ${text}`;
6213
+ }
6214
+ return createChangelogItem(text, {
6215
+ source: classified.source,
6216
+ indirect,
6217
+ breaking: commit.breaking,
6218
+ });
6219
+ }
3000
6220
  /**
3001
6221
  * Creates a changelog item from a conventional commit.
3002
6222
  *
@@ -3032,6 +6252,8 @@ function createGenerateChangelogStep() {
3032
6252
  return createStep(GENERATE_CHANGELOG_STEP_ID, 'Generate Changelog Entry', async (ctx) => {
3033
6253
  const { config, state } = ctx;
3034
6254
  const { commits, nextVersion, bumpType } = state;
6255
+ // Resolve commit type to section mapping
6256
+ const commitTypeMapping = resolveCommitTypeMapping(config.commitTypeToSection);
3035
6257
  // Skip if no bump needed
3036
6258
  if (!nextVersion || bumpType === 'none') {
3037
6259
  return createSkippedResult('No version bump, skipping changelog generation');
@@ -3042,9 +6264,26 @@ function createGenerateChangelogStep() {
3042
6264
  }
3043
6265
  // Handle case with no commits (e.g., first release)
3044
6266
  if (!commits || commits.length === 0) {
6267
+ // Generate compare URL using commit hashes ONLY
6268
+ // Only generate if we have a valid base commit (effectiveBaseCommit will be null if fallback was used)
6269
+ let compareUrl;
6270
+ if (state.repositoryConfig && state.effectiveBaseCommit) {
6271
+ const currentCommit = ctx.git.getHeadHash();
6272
+ compareUrl =
6273
+ createCompareUrl({
6274
+ repository: state.repositoryConfig,
6275
+ fromCommit: state.effectiveBaseCommit,
6276
+ toCommit: currentCommit,
6277
+ }) ?? undefined;
6278
+ }
6279
+ else if (state.publishedCommit && !state.effectiveBaseCommit) {
6280
+ // Log why we're not generating a compare URL
6281
+ ctx.logger.info('Compare URL omitted: published commit not in current history');
6282
+ }
3045
6283
  const entry = createChangelogEntry(nextVersion, {
3046
6284
  date: createDate().toISOString().split('T')[0],
3047
6285
  sections: [createChangelogSection('features', 'Features', [createChangelogItem('Initial release')])],
6286
+ compareUrl,
3048
6287
  });
3049
6288
  return {
3050
6289
  status: 'success',
@@ -3052,41 +6291,109 @@ function createGenerateChangelogStep() {
3052
6291
  message: 'Generated initial release changelog entry',
3053
6292
  };
3054
6293
  }
3055
- // Group commits by section
3056
- const grouped = groupCommitsBySection(commits);
3057
- // Create sections
6294
+ // Use classification result when available for proper scope handling
6295
+ const { classificationResult } = state;
3058
6296
  const sections = [];
3059
- // Add breaking changes section first if any
3060
- const breakingCommits = commits.filter((c) => c.breaking);
3061
- if (breakingCommits.length > 0) {
3062
- sections.push(createChangelogSection('breaking', 'Breaking Changes', breakingCommits.map((c) => {
3063
- const text = c.breakingDescription ?? c.subject;
3064
- return createChangelogItem(c.scope ? `**${c.scope}:** ${text}` : text);
3065
- })));
3066
- }
3067
- // Add other sections in conventional order
3068
- const sectionOrder = [
3069
- { type: 'features', heading: 'Features' },
3070
- { type: 'fixes', heading: 'Bug Fixes' },
3071
- { type: 'performance', heading: 'Performance' },
3072
- { type: 'documentation', heading: 'Documentation' },
3073
- { type: 'refactoring', heading: 'Code Refactoring' },
3074
- { type: 'build', heading: 'Build' },
3075
- { type: 'ci', heading: 'Continuous Integration' },
3076
- { type: 'tests', heading: 'Tests' },
3077
- { type: 'chores', heading: 'Chores' },
3078
- { type: 'other', heading: 'Other' },
3079
- ];
3080
- for (const { type: sectionType, heading } of sectionOrder) {
3081
- const sectionCommits = grouped[sectionType];
3082
- if (sectionCommits && sectionCommits.length > 0) {
3083
- sections.push(createChangelogSection(sectionType, heading, sectionCommits.map(commitToItem)));
6297
+ if (classificationResult && classificationResult.included.length > 0) {
6298
+ // Use classified commits for proper scope display rules
6299
+ const classifiedCommits = classificationResult.included;
6300
+ // Separate direct and indirect commits
6301
+ const directCommits = classifiedCommits.filter((c) => !isIndirectSource(c.source));
6302
+ const indirectCommits = classifiedCommits.filter((c) => isIndirectSource(c.source));
6303
+ // Add breaking changes section first if any
6304
+ const breakingCommits = classifiedCommits.filter((c) => c.commit.breaking);
6305
+ if (breakingCommits.length > 0) {
6306
+ sections.push(createChangelogSection('breaking', 'Breaking Changes', breakingCommits.map((c) => {
6307
+ const commit = toChangelogCommit(c);
6308
+ const text = commit.breakingDescription ?? commit.subject;
6309
+ const indirect = isIndirectSource(c.source);
6310
+ return createChangelogItem(commit.scope ? `**${commit.scope}:** ${text}` : text, {
6311
+ source: c.source,
6312
+ indirect,
6313
+ breaking: true,
6314
+ });
6315
+ })));
6316
+ }
6317
+ // Group direct commits by section
6318
+ const groupedDirect = groupClassifiedCommitsBySection(directCommits, commitTypeMapping);
6319
+ // Add other sections in conventional order (direct commits only)
6320
+ const sectionOrder = [
6321
+ { type: 'features', heading: 'Features' },
6322
+ { type: 'fixes', heading: 'Bug Fixes' },
6323
+ { type: 'performance', heading: 'Performance' },
6324
+ { type: 'documentation', heading: 'Documentation' },
6325
+ { type: 'refactoring', heading: 'Code Refactoring' },
6326
+ { type: 'build', heading: 'Build' },
6327
+ { type: 'ci', heading: 'Continuous Integration' },
6328
+ { type: 'tests', heading: 'Tests' },
6329
+ { type: 'chores', heading: 'Chores' },
6330
+ { type: 'other', heading: 'Other' },
6331
+ ];
6332
+ for (const { type: sectionType, heading } of sectionOrder) {
6333
+ const sectionCommits = groupedDirect[sectionType];
6334
+ if (sectionCommits && sectionCommits.length > 0) {
6335
+ sections.push(createChangelogSection(sectionType, heading, sectionCommits.map(classifiedCommitToItem)));
6336
+ }
6337
+ }
6338
+ // Add Dependency Updates section for indirect commits if any
6339
+ if (indirectCommits.length > 0) {
6340
+ sections.push(createChangelogSection('other', // Use 'other' as section type for dependency updates
6341
+ 'Dependency Updates', indirectCommits.map((c) => classifiedCommitToItem(c))));
6342
+ }
6343
+ }
6344
+ else {
6345
+ // Fallback: use commits without classification (backward compatibility)
6346
+ const grouped = groupCommitsBySection(commits, commitTypeMapping);
6347
+ // Add breaking changes section first if any
6348
+ const breakingCommits = commits.filter((c) => c.breaking);
6349
+ if (breakingCommits.length > 0) {
6350
+ sections.push(createChangelogSection('breaking', 'Breaking Changes', breakingCommits.map((c) => {
6351
+ const text = c.breakingDescription ?? c.subject;
6352
+ return createChangelogItem(c.scope ? `**${c.scope}:** ${text}` : text);
6353
+ })));
6354
+ }
6355
+ // Add other sections in conventional order
6356
+ const sectionOrder = [
6357
+ { type: 'features', heading: 'Features' },
6358
+ { type: 'fixes', heading: 'Bug Fixes' },
6359
+ { type: 'performance', heading: 'Performance' },
6360
+ { type: 'documentation', heading: 'Documentation' },
6361
+ { type: 'refactoring', heading: 'Code Refactoring' },
6362
+ { type: 'build', heading: 'Build' },
6363
+ { type: 'ci', heading: 'Continuous Integration' },
6364
+ { type: 'tests', heading: 'Tests' },
6365
+ { type: 'chores', heading: 'Chores' },
6366
+ { type: 'other', heading: 'Other' },
6367
+ ];
6368
+ for (const { type: sectionType, heading } of sectionOrder) {
6369
+ const sectionCommits = grouped[sectionType];
6370
+ if (sectionCommits && sectionCommits.length > 0) {
6371
+ sections.push(createChangelogSection(sectionType, heading, sectionCommits.map(commitToItem)));
6372
+ }
3084
6373
  }
3085
6374
  }
6375
+ // Generate compare URL using commit hashes ONLY
6376
+ // Only generate if we have a valid base commit (effectiveBaseCommit will be null if fallback was used)
6377
+ let compareUrl;
6378
+ if (state.repositoryConfig && state.effectiveBaseCommit) {
6379
+ const currentCommit = ctx.git.getHeadHash();
6380
+ compareUrl =
6381
+ createCompareUrl({
6382
+ repository: state.repositoryConfig,
6383
+ fromCommit: state.effectiveBaseCommit,
6384
+ toCommit: currentCommit,
6385
+ }) ?? undefined;
6386
+ ctx.logger.debug(`Compare URL: ${state.effectiveBaseCommit.slice(0, 7)}...${currentCommit.slice(0, 7)}`);
6387
+ }
6388
+ else if (state.publishedCommit && !state.effectiveBaseCommit) {
6389
+ // Log why we're not generating a compare URL
6390
+ ctx.logger.info('Compare URL omitted: published commit not in current history');
6391
+ }
3086
6392
  // Create the entry
3087
6393
  const entry = createChangelogEntry(nextVersion, {
3088
6394
  date: createDate().toISOString().split('T')[0],
3089
6395
  sections,
6396
+ compareUrl,
3090
6397
  });
3091
6398
  return {
3092
6399
  status: 'success',
@@ -3112,14 +6419,15 @@ function createWriteChangelogStep() {
3112
6419
  if (!nextVersion || bumpType === 'none' || !changelogEntry || config.skipChangelog) {
3113
6420
  return createSkippedResult('No changelog to write');
3114
6421
  }
3115
- const changelogPath = `${projectRoot}/CHANGELOG.md`;
6422
+ const changelogFileName = config.changelogFileName ?? DEFAULT_CHANGELOG_FILENAME;
6423
+ const changelogPath = `${projectRoot}/${changelogFileName}`;
3116
6424
  let existingContent = '';
3117
6425
  // Read existing changelog
3118
6426
  try {
3119
6427
  existingContent = tree.read(changelogPath, 'utf-8') ?? '';
3120
6428
  }
3121
6429
  catch {
3122
- logger.debug('No existing CHANGELOG.md found');
6430
+ logger.debug(`No existing ${changelogFileName} found`);
3123
6431
  }
3124
6432
  // If no existing content, create new changelog
3125
6433
  if (!existingContent.trim()) {
@@ -3137,12 +6445,33 @@ function createWriteChangelogStep() {
3137
6445
  stateUpdates: {
3138
6446
  modifiedFiles: [...(state.modifiedFiles ?? []), changelogPath],
3139
6447
  },
3140
- message: `Created CHANGELOG.md with version ${nextVersion}`,
6448
+ message: `Created ${changelogFileName} with version ${nextVersion}`,
3141
6449
  };
3142
6450
  }
3143
6451
  // Parse existing and add entry
3144
6452
  const existing = parseChangelog(existingContent);
3145
- const updated = addEntry(existing, changelogEntry);
6453
+ const isPendingPublication = state.isPendingPublication === true;
6454
+ let changelog = existing;
6455
+ // Clean up stacked entries when in pending publication state
6456
+ if (isPendingPublication && state.publishedVersion) {
6457
+ const publishedVer = parseVersion(state.publishedVersion);
6458
+ if (publishedVer.success && publishedVer.version) {
6459
+ const pubVer = publishedVer.version;
6460
+ const toRemove = changelog.entries
6461
+ .filter((e) => !e.unreleased)
6462
+ .filter((e) => {
6463
+ const ver = parseVersion(e.version);
6464
+ return ver.success && ver.version && gt(ver.version, pubVer);
6465
+ })
6466
+ .map((e) => e.version);
6467
+ if (toRemove.length > 0) {
6468
+ logger.info(`Removing stacked entries: ${toRemove.join(', ')}`);
6469
+ changelog = removeEntries(changelog, toRemove);
6470
+ }
6471
+ }
6472
+ }
6473
+ // Add entry (replaceExisting handles case where nextVersion entry already exists)
6474
+ const updated = addEntry(changelog, changelogEntry, { replaceExisting: isPendingPublication });
3146
6475
  const serialized = serializeChangelog(updated);
3147
6476
  tree.write(changelogPath, serialized);
3148
6477
  return {
@@ -3150,7 +6479,7 @@ function createWriteChangelogStep() {
3150
6479
  stateUpdates: {
3151
6480
  modifiedFiles: [...(state.modifiedFiles ?? []), changelogPath],
3152
6481
  },
3153
- message: `Updated CHANGELOG.md with version ${nextVersion}`,
6482
+ message: `Updated ${changelogFileName} with version ${nextVersion}`,
3154
6483
  };
3155
6484
  }, {
3156
6485
  dependsOn: ['generate-changelog'],
@@ -3179,23 +6508,26 @@ function createUpdatePackageStep() {
3179
6508
  return createSkippedResult('No version bump needed');
3180
6509
  }
3181
6510
  const packageJsonPath = `${projectRoot}/package.json`;
6511
+ logger.debug(`Reading package.json from: ${packageJsonPath}`);
3182
6512
  // Read package.json
3183
6513
  let content;
3184
6514
  try {
3185
6515
  content = tree.read(packageJsonPath, 'utf-8') ?? '';
3186
6516
  if (!content) {
6517
+ logger.error(`package.json not found at ${packageJsonPath}`);
3187
6518
  return {
3188
6519
  status: 'failed',
3189
- error: createError('package.json not found'),
3190
- message: 'Could not read package.json',
6520
+ error: createError(`package.json not found at ${packageJsonPath}`),
6521
+ message: `Could not read package.json at ${packageJsonPath}`,
3191
6522
  };
3192
6523
  }
3193
6524
  }
3194
6525
  catch (error) {
6526
+ logger.error(`Failed to read package.json at ${packageJsonPath}: ${error}`);
3195
6527
  return {
3196
6528
  status: 'failed',
3197
6529
  error: error instanceof Error ? error : createError(String(error)),
3198
- message: 'Failed to read package.json',
6530
+ message: `Failed to read package.json at ${packageJsonPath}`,
3199
6531
  };
3200
6532
  }
3201
6533
  // Parse and update version
@@ -3500,5 +6832,5 @@ function createPushTagStep() {
3500
6832
  });
3501
6833
  }
3502
6834
 
3503
- export { ANALYZE_COMMITS_STEP_ID, CALCULATE_BUMP_STEP_ID, CREATE_COMMIT_STEP_ID, CREATE_TAG_STEP_ID, FETCH_REGISTRY_STEP_ID, GENERATE_CHANGELOG_STEP_ID, UPDATE_PACKAGES_STEP_ID, createAnalyzeCommitsStep, createCalculateBumpStep, createCascadeDependenciesStep, createCheckIdempotencyStep, createFetchRegistryStep, createGenerateChangelogStep, createGitCommitStep, createPushTagStep, createTagStep, createUpdatePackageStep, createWriteChangelogStep };
6835
+ export { ANALYZE_COMMITS_STEP_ID, CALCULATE_BUMP_STEP_ID, CREATE_COMMIT_STEP_ID, CREATE_TAG_STEP_ID, DEFAULT_COMMIT_TYPE_TO_SECTION, FETCH_REGISTRY_STEP_ID, GENERATE_CHANGELOG_STEP_ID, RESOLVE_REPOSITORY_STEP_ID, UPDATE_PACKAGES_STEP_ID, createAnalyzeCommitsStep, createCalculateBumpStep, createCascadeDependenciesStep, createCheckIdempotencyStep, createFetchRegistryStep, createGenerateChangelogStep, createGitCommitStep, createPushTagStep, createResolveRepositoryStep, createTagStep, createUpdatePackageStep, createWriteChangelogStep };
3504
6836
  //# sourceMappingURL=index.esm.js.map