@hyperfrontend/versioning 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +593 -0
- package/CHANGELOG.md +35 -0
- package/FUNDING.md +141 -0
- package/LICENSE.md +21 -0
- package/README.md +195 -0
- package/SECURITY.md +82 -0
- package/changelog/compare/diff.d.ts +128 -0
- package/changelog/compare/diff.d.ts.map +1 -0
- package/changelog/compare/index.cjs.js +628 -0
- package/changelog/compare/index.cjs.js.map +1 -0
- package/changelog/compare/index.d.ts +4 -0
- package/changelog/compare/index.d.ts.map +1 -0
- package/changelog/compare/index.esm.js +612 -0
- package/changelog/compare/index.esm.js.map +1 -0
- package/changelog/compare/is-equal.d.ts +114 -0
- package/changelog/compare/is-equal.d.ts.map +1 -0
- package/changelog/index.cjs.js +6448 -0
- package/changelog/index.cjs.js.map +1 -0
- package/changelog/index.d.ts +6 -0
- package/changelog/index.d.ts.map +1 -0
- package/changelog/index.esm.js +6358 -0
- package/changelog/index.esm.js.map +1 -0
- package/changelog/models/changelog.d.ts +86 -0
- package/changelog/models/changelog.d.ts.map +1 -0
- package/changelog/models/commit-ref.d.ts +51 -0
- package/changelog/models/commit-ref.d.ts.map +1 -0
- package/changelog/models/entry.d.ts +84 -0
- package/changelog/models/entry.d.ts.map +1 -0
- package/changelog/models/index.cjs.js +2043 -0
- package/changelog/models/index.cjs.js.map +1 -0
- package/changelog/models/index.d.ts +11 -0
- package/changelog/models/index.d.ts.map +1 -0
- package/changelog/models/index.esm.js +2026 -0
- package/changelog/models/index.esm.js.map +1 -0
- package/changelog/models/schema.d.ts +68 -0
- package/changelog/models/schema.d.ts.map +1 -0
- package/changelog/models/section.d.ts +25 -0
- package/changelog/models/section.d.ts.map +1 -0
- package/changelog/operations/add-entry.d.ts +56 -0
- package/changelog/operations/add-entry.d.ts.map +1 -0
- package/changelog/operations/add-item.d.ts +18 -0
- package/changelog/operations/add-item.d.ts.map +1 -0
- package/changelog/operations/filter-by-predicate.d.ts +81 -0
- package/changelog/operations/filter-by-predicate.d.ts.map +1 -0
- package/changelog/operations/filter-by-range.d.ts +63 -0
- package/changelog/operations/filter-by-range.d.ts.map +1 -0
- package/changelog/operations/filter-entries.d.ts +9 -0
- package/changelog/operations/filter-entries.d.ts.map +1 -0
- package/changelog/operations/index.cjs.js +2455 -0
- package/changelog/operations/index.cjs.js.map +1 -0
- package/changelog/operations/index.d.ts +15 -0
- package/changelog/operations/index.d.ts.map +1 -0
- package/changelog/operations/index.esm.js +2411 -0
- package/changelog/operations/index.esm.js.map +1 -0
- package/changelog/operations/merge.d.ts +88 -0
- package/changelog/operations/merge.d.ts.map +1 -0
- package/changelog/operations/remove-entry.d.ts +45 -0
- package/changelog/operations/remove-entry.d.ts.map +1 -0
- package/changelog/operations/remove-section.d.ts +50 -0
- package/changelog/operations/remove-section.d.ts.map +1 -0
- package/changelog/operations/transform.d.ts +143 -0
- package/changelog/operations/transform.d.ts.map +1 -0
- package/changelog/parse/index.cjs.js +1282 -0
- package/changelog/parse/index.cjs.js.map +1 -0
- package/changelog/parse/index.d.ts +5 -0
- package/changelog/parse/index.d.ts.map +1 -0
- package/changelog/parse/index.esm.js +1275 -0
- package/changelog/parse/index.esm.js.map +1 -0
- package/changelog/parse/line.d.ts +48 -0
- package/changelog/parse/line.d.ts.map +1 -0
- package/changelog/parse/parser.d.ts +16 -0
- package/changelog/parse/parser.d.ts.map +1 -0
- package/changelog/parse/tokenizer.d.ts +49 -0
- package/changelog/parse/tokenizer.d.ts.map +1 -0
- package/changelog/serialize/index.cjs.js +574 -0
- package/changelog/serialize/index.cjs.js.map +1 -0
- package/changelog/serialize/index.d.ts +6 -0
- package/changelog/serialize/index.d.ts.map +1 -0
- package/changelog/serialize/index.esm.js +564 -0
- package/changelog/serialize/index.esm.js.map +1 -0
- package/changelog/serialize/templates.d.ts +81 -0
- package/changelog/serialize/templates.d.ts.map +1 -0
- package/changelog/serialize/to-json.d.ts +57 -0
- package/changelog/serialize/to-json.d.ts.map +1 -0
- package/changelog/serialize/to-string.d.ts +30 -0
- package/changelog/serialize/to-string.d.ts.map +1 -0
- package/commits/index.cjs.js +648 -0
- package/commits/index.cjs.js.map +1 -0
- package/commits/index.d.ts +3 -0
- package/commits/index.d.ts.map +1 -0
- package/commits/index.esm.js +629 -0
- package/commits/index.esm.js.map +1 -0
- package/commits/models/breaking.d.ts +39 -0
- package/commits/models/breaking.d.ts.map +1 -0
- package/commits/models/commit-type.d.ts +32 -0
- package/commits/models/commit-type.d.ts.map +1 -0
- package/commits/models/conventional.d.ts +49 -0
- package/commits/models/conventional.d.ts.map +1 -0
- package/commits/models/index.cjs.js +207 -0
- package/commits/models/index.cjs.js.map +1 -0
- package/commits/models/index.d.ts +7 -0
- package/commits/models/index.d.ts.map +1 -0
- package/commits/models/index.esm.js +193 -0
- package/commits/models/index.esm.js.map +1 -0
- package/commits/parse/body.d.ts +18 -0
- package/commits/parse/body.d.ts.map +1 -0
- package/commits/parse/footer.d.ts +16 -0
- package/commits/parse/footer.d.ts.map +1 -0
- package/commits/parse/header.d.ts +15 -0
- package/commits/parse/header.d.ts.map +1 -0
- package/commits/parse/index.cjs.js +505 -0
- package/commits/parse/index.cjs.js.map +1 -0
- package/commits/parse/index.d.ts +5 -0
- package/commits/parse/index.d.ts.map +1 -0
- package/commits/parse/index.esm.js +499 -0
- package/commits/parse/index.esm.js.map +1 -0
- package/commits/parse/message.d.ts +17 -0
- package/commits/parse/message.d.ts.map +1 -0
- package/commits/utils/replace-char.d.ts +19 -0
- package/commits/utils/replace-char.d.ts.map +1 -0
- package/flow/executor/execute.d.ts +72 -0
- package/flow/executor/execute.d.ts.map +1 -0
- package/flow/executor/index.cjs.js +4402 -0
- package/flow/executor/index.cjs.js.map +1 -0
- package/flow/executor/index.d.ts +3 -0
- package/flow/executor/index.d.ts.map +1 -0
- package/flow/executor/index.esm.js +4398 -0
- package/flow/executor/index.esm.js.map +1 -0
- package/flow/factory.d.ts +58 -0
- package/flow/factory.d.ts.map +1 -0
- package/flow/index.cjs.js +8506 -0
- package/flow/index.cjs.js.map +1 -0
- package/flow/index.d.ts +7 -0
- package/flow/index.d.ts.map +1 -0
- package/flow/index.esm.js +8451 -0
- package/flow/index.esm.js.map +1 -0
- package/flow/models/flow.d.ts +130 -0
- package/flow/models/flow.d.ts.map +1 -0
- package/flow/models/index.cjs.js +285 -0
- package/flow/models/index.cjs.js.map +1 -0
- package/flow/models/index.d.ts +7 -0
- package/flow/models/index.d.ts.map +1 -0
- package/flow/models/index.esm.js +268 -0
- package/flow/models/index.esm.js.map +1 -0
- package/flow/models/step.d.ts +108 -0
- package/flow/models/step.d.ts.map +1 -0
- package/flow/models/types.d.ts +150 -0
- package/flow/models/types.d.ts.map +1 -0
- package/flow/presets/conventional.d.ts +59 -0
- package/flow/presets/conventional.d.ts.map +1 -0
- package/flow/presets/independent.d.ts +61 -0
- package/flow/presets/independent.d.ts.map +1 -0
- package/flow/presets/index.cjs.js +3903 -0
- package/flow/presets/index.cjs.js.map +1 -0
- package/flow/presets/index.d.ts +4 -0
- package/flow/presets/index.d.ts.map +1 -0
- package/flow/presets/index.esm.js +3889 -0
- package/flow/presets/index.esm.js.map +1 -0
- package/flow/presets/synced.d.ts +65 -0
- package/flow/presets/synced.d.ts.map +1 -0
- package/flow/steps/analyze-commits.d.ts +19 -0
- package/flow/steps/analyze-commits.d.ts.map +1 -0
- package/flow/steps/calculate-bump.d.ts +27 -0
- package/flow/steps/calculate-bump.d.ts.map +1 -0
- package/flow/steps/create-commit.d.ts +16 -0
- package/flow/steps/create-commit.d.ts.map +1 -0
- package/flow/steps/create-tag.d.ts +22 -0
- package/flow/steps/create-tag.d.ts.map +1 -0
- package/flow/steps/fetch-registry.d.ts +19 -0
- package/flow/steps/fetch-registry.d.ts.map +1 -0
- package/flow/steps/generate-changelog.d.ts +25 -0
- package/flow/steps/generate-changelog.d.ts.map +1 -0
- package/flow/steps/index.cjs.js +3523 -0
- package/flow/steps/index.cjs.js.map +1 -0
- package/flow/steps/index.d.ts +8 -0
- package/flow/steps/index.d.ts.map +1 -0
- package/flow/steps/index.esm.js +3504 -0
- package/flow/steps/index.esm.js.map +1 -0
- package/flow/steps/update-packages.d.ts +25 -0
- package/flow/steps/update-packages.d.ts.map +1 -0
- package/flow/utils/interpolate.d.ts +11 -0
- package/flow/utils/interpolate.d.ts.map +1 -0
- package/git/factory.d.ts +233 -0
- package/git/factory.d.ts.map +1 -0
- package/git/index.cjs.js +2863 -0
- package/git/index.cjs.js.map +1 -0
- package/git/index.d.ts +5 -0
- package/git/index.d.ts.map +1 -0
- package/git/index.esm.js +2785 -0
- package/git/index.esm.js.map +1 -0
- package/git/models/commit.d.ts +129 -0
- package/git/models/commit.d.ts.map +1 -0
- package/git/models/index.cjs.js +755 -0
- package/git/models/index.cjs.js.map +1 -0
- package/git/models/index.d.ts +7 -0
- package/git/models/index.d.ts.map +1 -0
- package/git/models/index.esm.js +729 -0
- package/git/models/index.esm.js.map +1 -0
- package/git/models/ref.d.ts +120 -0
- package/git/models/ref.d.ts.map +1 -0
- package/git/models/tag.d.ts +141 -0
- package/git/models/tag.d.ts.map +1 -0
- package/git/operations/commit.d.ts +97 -0
- package/git/operations/commit.d.ts.map +1 -0
- package/git/operations/head-info.d.ts +29 -0
- package/git/operations/head-info.d.ts.map +1 -0
- package/git/operations/index.cjs.js +1954 -0
- package/git/operations/index.cjs.js.map +1 -0
- package/git/operations/index.d.ts +14 -0
- package/git/operations/index.d.ts.map +1 -0
- package/git/operations/index.esm.js +1903 -0
- package/git/operations/index.esm.js.map +1 -0
- package/git/operations/log.d.ts +104 -0
- package/git/operations/log.d.ts.map +1 -0
- package/git/operations/manage-tags.d.ts +60 -0
- package/git/operations/manage-tags.d.ts.map +1 -0
- package/git/operations/query-tags.d.ts +88 -0
- package/git/operations/query-tags.d.ts.map +1 -0
- package/git/operations/stage.d.ts +66 -0
- package/git/operations/stage.d.ts.map +1 -0
- package/git/operations/status.d.ts +173 -0
- package/git/operations/status.d.ts.map +1 -0
- package/index.cjs.js +16761 -0
- package/index.cjs.js.map +1 -0
- package/index.d.ts +102 -0
- package/index.d.ts.map +1 -0
- package/index.esm.js +16427 -0
- package/index.esm.js.map +1 -0
- package/package.json +200 -0
- package/registry/factory.d.ts +18 -0
- package/registry/factory.d.ts.map +1 -0
- package/registry/index.cjs.js +543 -0
- package/registry/index.cjs.js.map +1 -0
- package/registry/index.d.ts +5 -0
- package/registry/index.d.ts.map +1 -0
- package/registry/index.esm.js +535 -0
- package/registry/index.esm.js.map +1 -0
- package/registry/models/index.cjs.js +69 -0
- package/registry/models/index.cjs.js.map +1 -0
- package/registry/models/index.d.ts +6 -0
- package/registry/models/index.d.ts.map +1 -0
- package/registry/models/index.esm.js +66 -0
- package/registry/models/index.esm.js.map +1 -0
- package/registry/models/package-info.d.ts +55 -0
- package/registry/models/package-info.d.ts.map +1 -0
- package/registry/models/registry.d.ts +62 -0
- package/registry/models/registry.d.ts.map +1 -0
- package/registry/models/version-info.d.ts +67 -0
- package/registry/models/version-info.d.ts.map +1 -0
- package/registry/npm/cache.d.ts +50 -0
- package/registry/npm/cache.d.ts.map +1 -0
- package/registry/npm/client.d.ts +30 -0
- package/registry/npm/client.d.ts.map +1 -0
- package/registry/npm/index.cjs.js +456 -0
- package/registry/npm/index.cjs.js.map +1 -0
- package/registry/npm/index.d.ts +4 -0
- package/registry/npm/index.d.ts.map +1 -0
- package/registry/npm/index.esm.js +451 -0
- package/registry/npm/index.esm.js.map +1 -0
- package/semver/compare/compare.d.ts +100 -0
- package/semver/compare/compare.d.ts.map +1 -0
- package/semver/compare/index.cjs.js +386 -0
- package/semver/compare/index.cjs.js.map +1 -0
- package/semver/compare/index.d.ts +3 -0
- package/semver/compare/index.d.ts.map +1 -0
- package/semver/compare/index.esm.js +370 -0
- package/semver/compare/index.esm.js.map +1 -0
- package/semver/compare/sort.d.ts +36 -0
- package/semver/compare/sort.d.ts.map +1 -0
- package/semver/format/index.cjs.js +58 -0
- package/semver/format/index.cjs.js.map +1 -0
- package/semver/format/index.d.ts +2 -0
- package/semver/format/index.d.ts.map +1 -0
- package/semver/format/index.esm.js +53 -0
- package/semver/format/index.esm.js.map +1 -0
- package/semver/format/to-string.d.ts +31 -0
- package/semver/format/to-string.d.ts.map +1 -0
- package/semver/increment/bump.d.ts +37 -0
- package/semver/increment/bump.d.ts.map +1 -0
- package/semver/increment/index.cjs.js +223 -0
- package/semver/increment/index.cjs.js.map +1 -0
- package/semver/increment/index.d.ts +2 -0
- package/semver/increment/index.d.ts.map +1 -0
- package/semver/increment/index.esm.js +219 -0
- package/semver/increment/index.esm.js.map +1 -0
- package/semver/index.cjs.js +1499 -0
- package/semver/index.cjs.js.map +1 -0
- package/semver/index.d.ts +6 -0
- package/semver/index.d.ts.map +1 -0
- package/semver/index.esm.js +1458 -0
- package/semver/index.esm.js.map +1 -0
- package/semver/models/index.cjs.js +153 -0
- package/semver/models/index.cjs.js.map +1 -0
- package/semver/models/index.d.ts +5 -0
- package/semver/models/index.d.ts.map +1 -0
- package/semver/models/index.esm.js +139 -0
- package/semver/models/index.esm.js.map +1 -0
- package/semver/models/range.d.ts +83 -0
- package/semver/models/range.d.ts.map +1 -0
- package/semver/models/version.d.ts +78 -0
- package/semver/models/version.d.ts.map +1 -0
- package/semver/parse/index.cjs.js +799 -0
- package/semver/parse/index.cjs.js.map +1 -0
- package/semver/parse/index.d.ts +5 -0
- package/semver/parse/index.d.ts.map +1 -0
- package/semver/parse/index.esm.js +793 -0
- package/semver/parse/index.esm.js.map +1 -0
- package/semver/parse/range.d.ts +38 -0
- package/semver/parse/range.d.ts.map +1 -0
- package/semver/parse/version.d.ts +49 -0
- package/semver/parse/version.d.ts.map +1 -0
- package/workspace/discovery/changelog-path.d.ts +21 -0
- package/workspace/discovery/changelog-path.d.ts.map +1 -0
- package/workspace/discovery/dependencies.d.ts +145 -0
- package/workspace/discovery/dependencies.d.ts.map +1 -0
- package/workspace/discovery/discover-changelogs.d.ts +76 -0
- package/workspace/discovery/discover-changelogs.d.ts.map +1 -0
- package/workspace/discovery/index.cjs.js +2300 -0
- package/workspace/discovery/index.cjs.js.map +1 -0
- package/workspace/discovery/index.d.ts +13 -0
- package/workspace/discovery/index.d.ts.map +1 -0
- package/workspace/discovery/index.esm.js +2283 -0
- package/workspace/discovery/index.esm.js.map +1 -0
- package/workspace/discovery/packages.d.ts +83 -0
- package/workspace/discovery/packages.d.ts.map +1 -0
- package/workspace/index.cjs.js +4445 -0
- package/workspace/index.cjs.js.map +1 -0
- package/workspace/index.d.ts +52 -0
- package/workspace/index.d.ts.map +1 -0
- package/workspace/index.esm.js +4394 -0
- package/workspace/index.esm.js.map +1 -0
- package/workspace/models/index.cjs.js +284 -0
- package/workspace/models/index.cjs.js.map +1 -0
- package/workspace/models/index.d.ts +10 -0
- package/workspace/models/index.d.ts.map +1 -0
- package/workspace/models/index.esm.js +261 -0
- package/workspace/models/index.esm.js.map +1 -0
- package/workspace/models/project.d.ts +118 -0
- package/workspace/models/project.d.ts.map +1 -0
- package/workspace/models/workspace.d.ts +139 -0
- package/workspace/models/workspace.d.ts.map +1 -0
- package/workspace/operations/batch-update.d.ts +99 -0
- package/workspace/operations/batch-update.d.ts.map +1 -0
- package/workspace/operations/cascade-bump.d.ts +125 -0
- package/workspace/operations/cascade-bump.d.ts.map +1 -0
- package/workspace/operations/index.cjs.js +2675 -0
- package/workspace/operations/index.cjs.js.map +1 -0
- package/workspace/operations/index.d.ts +12 -0
- package/workspace/operations/index.d.ts.map +1 -0
- package/workspace/operations/index.esm.js +2663 -0
- package/workspace/operations/index.esm.js.map +1 -0
- package/workspace/operations/validate.d.ts +85 -0
- package/workspace/operations/validate.d.ts.map +1 -0
|
@@ -0,0 +1,3889 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a version flow.
|
|
3
|
+
*
|
|
4
|
+
* @param id - Flow identifier
|
|
5
|
+
* @param name - Human-readable flow name
|
|
6
|
+
* @param steps - Ordered steps to execute
|
|
7
|
+
* @param options - Optional flow configuration
|
|
8
|
+
* @returns A VersionFlow object
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* const myFlow = createFlow(
|
|
13
|
+
* 'custom',
|
|
14
|
+
* 'Custom Release Flow',
|
|
15
|
+
* [fetchStep, analyzeStep, bumpStep],
|
|
16
|
+
* { description: 'My custom versioning workflow' }
|
|
17
|
+
* )
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
function createFlow(id, name, steps, options = {}) {
|
|
21
|
+
return {
|
|
22
|
+
id,
|
|
23
|
+
name,
|
|
24
|
+
steps,
|
|
25
|
+
description: options.description,
|
|
26
|
+
config: options.config ?? {},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Safe copies of JSON built-in methods.
|
|
32
|
+
*
|
|
33
|
+
* These references are captured at module initialization time to protect against
|
|
34
|
+
* prototype pollution attacks. Import only what you need for tree-shaking.
|
|
35
|
+
*
|
|
36
|
+
* @module @hyperfrontend/immutable-api-utils/built-in-copy/json
|
|
37
|
+
*/
|
|
38
|
+
// Capture references at module initialization time
|
|
39
|
+
const _JSON = globalThis.JSON;
|
|
40
|
+
/**
|
|
41
|
+
* (Safe copy) Converts a JavaScript Object Notation (JSON) string into an object.
|
|
42
|
+
*/
|
|
43
|
+
const parse = _JSON.parse;
|
|
44
|
+
/**
|
|
45
|
+
* (Safe copy) Converts a JavaScript value to a JavaScript Object Notation (JSON) string.
|
|
46
|
+
*/
|
|
47
|
+
const stringify = _JSON.stringify;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Creates a flow step.
|
|
51
|
+
*
|
|
52
|
+
* @param id - Unique step identifier
|
|
53
|
+
* @param name - Human-readable step name
|
|
54
|
+
* @param execute - Step executor function
|
|
55
|
+
* @param options - Optional step configuration
|
|
56
|
+
* @returns A FlowStep object
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* const fetchStep = createStep(
|
|
61
|
+
* 'fetch-registry',
|
|
62
|
+
* 'Fetch Registry Version',
|
|
63
|
+
* async (ctx) => {
|
|
64
|
+
* const version = await ctx.registry.getLatestVersion(ctx.packageName)
|
|
65
|
+
* return {
|
|
66
|
+
* status: 'success',
|
|
67
|
+
* stateUpdates: { publishedVersion: version },
|
|
68
|
+
* message: `Found published version: ${version}`
|
|
69
|
+
* }
|
|
70
|
+
* }
|
|
71
|
+
* )
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
function createStep(id, name, execute, options = {}) {
|
|
75
|
+
return {
|
|
76
|
+
id,
|
|
77
|
+
name,
|
|
78
|
+
execute,
|
|
79
|
+
description: options.description,
|
|
80
|
+
skipIf: options.skipIf,
|
|
81
|
+
continueOnError: options.continueOnError,
|
|
82
|
+
dependsOn: options.dependsOn,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Creates a skipped step result.
|
|
87
|
+
*
|
|
88
|
+
* @param message - Explanation for why the step was skipped
|
|
89
|
+
* @returns A FlowStepResult with 'skipped' status
|
|
90
|
+
*/
|
|
91
|
+
function createSkippedResult(message) {
|
|
92
|
+
return {
|
|
93
|
+
status: 'skipped',
|
|
94
|
+
message,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const FETCH_REGISTRY_STEP_ID = 'fetch-registry';
|
|
99
|
+
/**
|
|
100
|
+
* Creates the fetch-registry step.
|
|
101
|
+
*
|
|
102
|
+
* This step:
|
|
103
|
+
* 1. Queries the registry for the latest published version
|
|
104
|
+
* 2. Reads the current version from package.json
|
|
105
|
+
* 3. Determines if this is a first release
|
|
106
|
+
*
|
|
107
|
+
* State updates:
|
|
108
|
+
* - publishedVersion: Latest version on registry (null if not published)
|
|
109
|
+
* - currentVersion: Version from local package.json
|
|
110
|
+
* - isFirstRelease: True if never published
|
|
111
|
+
*
|
|
112
|
+
* @returns A FlowStep that fetches registry information
|
|
113
|
+
*/
|
|
114
|
+
function createFetchRegistryStep() {
|
|
115
|
+
return createStep(FETCH_REGISTRY_STEP_ID, 'Fetch Registry Version', async (ctx) => {
|
|
116
|
+
const { registry, tree, projectRoot, packageName, logger } = ctx;
|
|
117
|
+
// Read local package.json for current version
|
|
118
|
+
const packageJsonPath = `${projectRoot}/package.json`;
|
|
119
|
+
let currentVersion = '0.0.0';
|
|
120
|
+
try {
|
|
121
|
+
const content = tree.read(packageJsonPath, 'utf-8');
|
|
122
|
+
if (content) {
|
|
123
|
+
const pkg = parse(content);
|
|
124
|
+
currentVersion = pkg.version ?? '0.0.0';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
logger.warn(`Could not read package.json: ${error}`);
|
|
129
|
+
}
|
|
130
|
+
// Query registry for published version
|
|
131
|
+
let publishedVersion = null;
|
|
132
|
+
let isFirstRelease = true;
|
|
133
|
+
try {
|
|
134
|
+
publishedVersion = await registry.getLatestVersion(packageName);
|
|
135
|
+
isFirstRelease = publishedVersion === null;
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
// Package might not exist yet, which is fine
|
|
139
|
+
logger.debug(`Registry query failed (package may not exist): ${error}`);
|
|
140
|
+
isFirstRelease = true;
|
|
141
|
+
}
|
|
142
|
+
const message = isFirstRelease ? `First release (local: ${currentVersion})` : `Published: ${publishedVersion}, Local: ${currentVersion}`;
|
|
143
|
+
return {
|
|
144
|
+
status: 'success',
|
|
145
|
+
stateUpdates: {
|
|
146
|
+
publishedVersion,
|
|
147
|
+
currentVersion,
|
|
148
|
+
isFirstRelease,
|
|
149
|
+
},
|
|
150
|
+
message,
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Safe copies of Error built-ins via factory functions.
|
|
157
|
+
*
|
|
158
|
+
* Since constructors cannot be safely captured via Object.assign, this module
|
|
159
|
+
* provides factory functions that use Reflect.construct internally.
|
|
160
|
+
*
|
|
161
|
+
* These references are captured at module initialization time to protect against
|
|
162
|
+
* prototype pollution attacks. Import only what you need for tree-shaking.
|
|
163
|
+
*
|
|
164
|
+
* @module @hyperfrontend/immutable-api-utils/built-in-copy/error
|
|
165
|
+
*/
|
|
166
|
+
// Capture references at module initialization time
|
|
167
|
+
const _Error = globalThis.Error;
|
|
168
|
+
const _Reflect$2 = globalThis.Reflect;
|
|
169
|
+
/**
|
|
170
|
+
* (Safe copy) Creates a new Error using the captured Error constructor.
|
|
171
|
+
* Use this instead of `new Error()`.
|
|
172
|
+
*
|
|
173
|
+
* @param message - Optional error message.
|
|
174
|
+
* @param options - Optional error options.
|
|
175
|
+
* @returns A new Error instance.
|
|
176
|
+
*/
|
|
177
|
+
const createError = (message, options) => _Reflect$2.construct(_Error, [message, options]);
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Replaces all occurrences of a character in a string.
|
|
181
|
+
* Uses character-by-character iteration to avoid regex (ReDoS-safe).
|
|
182
|
+
*
|
|
183
|
+
* @param input - The input string
|
|
184
|
+
* @param target - The character to replace
|
|
185
|
+
* @param replacement - The replacement character
|
|
186
|
+
* @returns String with all occurrences replaced
|
|
187
|
+
*/
|
|
188
|
+
function replaceChar(input, target, replacement) {
|
|
189
|
+
const result = [];
|
|
190
|
+
for (let i = 0; i < input.length; i++) {
|
|
191
|
+
result.push(input[i] === target ? replacement : input[i]);
|
|
192
|
+
}
|
|
193
|
+
return result.join('');
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Replaces all hyphens with spaces.
|
|
197
|
+
* Convenience wrapper for normalizing footer keys.
|
|
198
|
+
*
|
|
199
|
+
* @param input - The input string
|
|
200
|
+
* @returns String with hyphens replaced by spaces
|
|
201
|
+
*/
|
|
202
|
+
function hyphenToSpace(input) {
|
|
203
|
+
return replaceChar(input, '-', ' ');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Parses the body section of a commit message.
|
|
208
|
+
*
|
|
209
|
+
* The body starts after the first blank line and continues until
|
|
210
|
+
* we encounter a footer (key: value or key #value pattern) or end of message.
|
|
211
|
+
*
|
|
212
|
+
* @param lines - All lines of the commit message
|
|
213
|
+
* @param startIndex - Index to start looking for body (after header)
|
|
214
|
+
* @returns Parsed body or undefined if no body
|
|
215
|
+
*/
|
|
216
|
+
function parseBody(lines, startIndex) {
|
|
217
|
+
// Skip blank lines to find body start
|
|
218
|
+
let pos = startIndex;
|
|
219
|
+
while (pos < lines.length && lines[pos].trim() === '') {
|
|
220
|
+
pos++;
|
|
221
|
+
}
|
|
222
|
+
if (pos >= lines.length) {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
// If the first non-blank line is a footer, there's no body
|
|
226
|
+
if (isFooterLine(lines[pos])) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
const bodyLines = [];
|
|
230
|
+
// Collect body lines until we hit a footer or end
|
|
231
|
+
while (pos < lines.length) {
|
|
232
|
+
const line = lines[pos];
|
|
233
|
+
// Check if this line looks like a footer
|
|
234
|
+
if (isFooterLine(line)) {
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
// Check for blank line followed by a footer
|
|
238
|
+
if (line.trim() === '' && pos + 1 < lines.length && isFooterLine(lines[pos + 1])) {
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
bodyLines.push(line);
|
|
242
|
+
pos++;
|
|
243
|
+
}
|
|
244
|
+
// Trim trailing blank lines from body
|
|
245
|
+
while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === '') {
|
|
246
|
+
bodyLines.pop();
|
|
247
|
+
}
|
|
248
|
+
if (bodyLines.length === 0) {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
body: bodyLines.join('\n'),
|
|
253
|
+
endIndex: pos,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Checks if a line looks like a footer.
|
|
258
|
+
*
|
|
259
|
+
* Footer format:
|
|
260
|
+
* - key: value
|
|
261
|
+
* - key #value (for issue references)
|
|
262
|
+
* - BREAKING CHANGE: description
|
|
263
|
+
* - BREAKING-CHANGE: description
|
|
264
|
+
*
|
|
265
|
+
* @param line - The line to check
|
|
266
|
+
* @returns True if the line looks like a footer
|
|
267
|
+
*/
|
|
268
|
+
function isFooterLine(line) {
|
|
269
|
+
if (!line)
|
|
270
|
+
return false;
|
|
271
|
+
const trimmed = line.trim();
|
|
272
|
+
if (trimmed === '')
|
|
273
|
+
return false;
|
|
274
|
+
// Check for BREAKING CHANGE or BREAKING-CHANGE
|
|
275
|
+
if (trimmed.startsWith('BREAKING CHANGE:') || trimmed.startsWith('BREAKING-CHANGE:')) {
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
// Check for token: value or token #value pattern
|
|
279
|
+
let pos = 0;
|
|
280
|
+
// Skip leading whitespace
|
|
281
|
+
while (pos < trimmed.length && trimmed[pos] === ' ') {
|
|
282
|
+
pos++;
|
|
283
|
+
}
|
|
284
|
+
// Read token (alphanumeric and hyphens)
|
|
285
|
+
const tokenStart = pos;
|
|
286
|
+
while (pos < trimmed.length) {
|
|
287
|
+
const char = trimmed[pos];
|
|
288
|
+
const code = char.charCodeAt(0);
|
|
289
|
+
if ((code >= 97 && code <= 122) || // a-z
|
|
290
|
+
(code >= 65 && code <= 90) || // A-Z
|
|
291
|
+
(code >= 48 && code <= 57) || // 0-9
|
|
292
|
+
code === 45 // -
|
|
293
|
+
) {
|
|
294
|
+
pos++;
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Must have at least one character in token
|
|
301
|
+
if (pos === tokenStart)
|
|
302
|
+
return false;
|
|
303
|
+
// Must be followed by : or space-#
|
|
304
|
+
if (trimmed[pos] === ':') {
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
if (trimmed[pos] === ' ' && trimmed[pos + 1] === '#') {
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Checks if a footer key indicates a breaking change.
|
|
315
|
+
*
|
|
316
|
+
* @param key - The footer key to check
|
|
317
|
+
* @returns True if the key indicates a breaking change
|
|
318
|
+
*/
|
|
319
|
+
function isBreakingFooterKey(key) {
|
|
320
|
+
const normalized = hyphenToSpace(key.toUpperCase());
|
|
321
|
+
return normalized === 'BREAKING CHANGE';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Parses the footer section of a commit message.
|
|
326
|
+
*
|
|
327
|
+
* @param lines - All lines of the commit message
|
|
328
|
+
* @param startIndex - Index where footers start
|
|
329
|
+
* @returns Parsed footers
|
|
330
|
+
*/
|
|
331
|
+
function parseFooters(lines, startIndex) {
|
|
332
|
+
const footers = [];
|
|
333
|
+
let breakingDescription;
|
|
334
|
+
let pos = startIndex;
|
|
335
|
+
// Skip blank lines
|
|
336
|
+
while (pos < lines.length && lines[pos].trim() === '') {
|
|
337
|
+
pos++;
|
|
338
|
+
}
|
|
339
|
+
while (pos < lines.length) {
|
|
340
|
+
const line = lines[pos];
|
|
341
|
+
const footer = parseFooterLine(line);
|
|
342
|
+
if (footer) {
|
|
343
|
+
footers.push(footer);
|
|
344
|
+
// Check for breaking change
|
|
345
|
+
if (isBreakingFooterKey(footer.key)) {
|
|
346
|
+
breakingDescription = footer.value;
|
|
347
|
+
// Breaking description may span multiple lines
|
|
348
|
+
pos++;
|
|
349
|
+
while (pos < lines.length && !isNewFooter(lines[pos])) {
|
|
350
|
+
const nextLine = lines[pos];
|
|
351
|
+
if (nextLine.trim() !== '') {
|
|
352
|
+
breakingDescription += '\n' + nextLine;
|
|
353
|
+
}
|
|
354
|
+
pos++;
|
|
355
|
+
}
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
pos++;
|
|
360
|
+
}
|
|
361
|
+
return { footers, breakingDescription };
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Parses a single footer line.
|
|
365
|
+
*
|
|
366
|
+
* @param line - The line to parse
|
|
367
|
+
* @returns The parsed CommitFooter or null if not a valid footer
|
|
368
|
+
*/
|
|
369
|
+
function parseFooterLine(line) {
|
|
370
|
+
if (!line)
|
|
371
|
+
return null;
|
|
372
|
+
const trimmed = line.trim();
|
|
373
|
+
if (trimmed === '')
|
|
374
|
+
return null;
|
|
375
|
+
// Check for BREAKING CHANGE or BREAKING-CHANGE
|
|
376
|
+
if (trimmed.startsWith('BREAKING CHANGE:')) {
|
|
377
|
+
return {
|
|
378
|
+
key: 'BREAKING CHANGE',
|
|
379
|
+
value: trimmed.slice(16).trim(),
|
|
380
|
+
separator: ':',
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
if (trimmed.startsWith('BREAKING-CHANGE:')) {
|
|
384
|
+
return {
|
|
385
|
+
key: 'BREAKING-CHANGE',
|
|
386
|
+
value: trimmed.slice(16).trim(),
|
|
387
|
+
separator: ':',
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
// Parse token: value or token #value
|
|
391
|
+
let pos = 0;
|
|
392
|
+
// Read token
|
|
393
|
+
const tokenStart = pos;
|
|
394
|
+
while (pos < trimmed.length) {
|
|
395
|
+
const char = trimmed[pos];
|
|
396
|
+
const code = char.charCodeAt(0);
|
|
397
|
+
if ((code >= 97 && code <= 122) || // a-z
|
|
398
|
+
(code >= 65 && code <= 90) || // A-Z
|
|
399
|
+
(code >= 48 && code <= 57) || // 0-9
|
|
400
|
+
code === 45 // -
|
|
401
|
+
) {
|
|
402
|
+
pos++;
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (pos === tokenStart)
|
|
409
|
+
return null;
|
|
410
|
+
const key = trimmed.slice(tokenStart, pos);
|
|
411
|
+
// Check separator
|
|
412
|
+
let separator;
|
|
413
|
+
let valueStart;
|
|
414
|
+
if (trimmed[pos] === ':') {
|
|
415
|
+
separator = ':';
|
|
416
|
+
valueStart = pos + 1;
|
|
417
|
+
}
|
|
418
|
+
else if (trimmed[pos] === ' ' && trimmed[pos + 1] === '#') {
|
|
419
|
+
separator = ' #';
|
|
420
|
+
valueStart = pos + 2;
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
// Skip whitespace after separator (for : case)
|
|
426
|
+
if (separator === ':') {
|
|
427
|
+
while (valueStart < trimmed.length && trimmed[valueStart] === ' ') {
|
|
428
|
+
valueStart++;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const value = trimmed.slice(valueStart);
|
|
432
|
+
return { key, value, separator };
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Checks if a line starts a new footer.
|
|
436
|
+
*
|
|
437
|
+
* @param line - The line to check
|
|
438
|
+
* @returns True if the line starts a new footer
|
|
439
|
+
*/
|
|
440
|
+
function isNewFooter(line) {
|
|
441
|
+
if (!line)
|
|
442
|
+
return false;
|
|
443
|
+
return parseFooterLine(line) !== null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Parses a conventional commit header line.
|
|
448
|
+
*
|
|
449
|
+
* @param line - The first line of the commit message
|
|
450
|
+
* @returns Parsed header with type, scope, subject, and breaking flag
|
|
451
|
+
*/
|
|
452
|
+
function parseHeader$1(line) {
|
|
453
|
+
let pos = 0;
|
|
454
|
+
const len = line.length;
|
|
455
|
+
// Extract type (alphanumeric characters until ( or : or !)
|
|
456
|
+
const typeStart = pos;
|
|
457
|
+
while (pos < len) {
|
|
458
|
+
const char = line[pos];
|
|
459
|
+
const code = char.charCodeAt(0);
|
|
460
|
+
// a-z, A-Z, 0-9
|
|
461
|
+
if ((code >= 97 && code <= 122) || (code >= 65 && code <= 90) || (code >= 48 && code <= 57)) {
|
|
462
|
+
pos++;
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const type = line.slice(typeStart, pos).toLowerCase();
|
|
469
|
+
// Check for scope in parentheses
|
|
470
|
+
let scope;
|
|
471
|
+
if (line[pos] === '(') {
|
|
472
|
+
pos++; // skip (
|
|
473
|
+
const scopeStart = pos;
|
|
474
|
+
while (pos < len && line[pos] !== ')') {
|
|
475
|
+
pos++;
|
|
476
|
+
}
|
|
477
|
+
scope = line.slice(scopeStart, pos);
|
|
478
|
+
if (line[pos] === ')') {
|
|
479
|
+
pos++; // skip )
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// Check for breaking change indicator (!)
|
|
483
|
+
const breaking = line[pos] === '!';
|
|
484
|
+
if (breaking) {
|
|
485
|
+
pos++;
|
|
486
|
+
}
|
|
487
|
+
// Expect colon
|
|
488
|
+
if (line[pos] === ':') {
|
|
489
|
+
pos++;
|
|
490
|
+
}
|
|
491
|
+
// Skip whitespace after colon
|
|
492
|
+
while (pos < len && line[pos] === ' ') {
|
|
493
|
+
pos++;
|
|
494
|
+
}
|
|
495
|
+
// Rest is subject
|
|
496
|
+
const subject = line.slice(pos).trim();
|
|
497
|
+
return {
|
|
498
|
+
type,
|
|
499
|
+
scope,
|
|
500
|
+
subject,
|
|
501
|
+
breaking,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Maximum commit message length (10KB)
|
|
507
|
+
*/
|
|
508
|
+
const MAX_MESSAGE_LENGTH = 10 * 1024;
|
|
509
|
+
/**
|
|
510
|
+
* Parses a conventional commit message.
|
|
511
|
+
*
|
|
512
|
+
* @param message - The complete commit message
|
|
513
|
+
* @returns Parsed ConventionalCommit object
|
|
514
|
+
* @throws {Error} If message exceeds maximum length
|
|
515
|
+
*/
|
|
516
|
+
function parseConventionalCommit(message) {
|
|
517
|
+
if (message.length > MAX_MESSAGE_LENGTH) {
|
|
518
|
+
throw createError(`Commit message exceeds maximum length of ${MAX_MESSAGE_LENGTH} characters`);
|
|
519
|
+
}
|
|
520
|
+
const lines = splitLines(message);
|
|
521
|
+
if (lines.length === 0) {
|
|
522
|
+
return {
|
|
523
|
+
type: '',
|
|
524
|
+
subject: '',
|
|
525
|
+
footers: [],
|
|
526
|
+
breaking: false,
|
|
527
|
+
raw: message,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
// Parse header (first line)
|
|
531
|
+
const header = parseHeader$1(lines[0]);
|
|
532
|
+
// Parse body and footers
|
|
533
|
+
let body;
|
|
534
|
+
let footersStartIndex = 1;
|
|
535
|
+
if (lines.length > 1) {
|
|
536
|
+
const bodyResult = parseBody(lines, 1);
|
|
537
|
+
if (bodyResult) {
|
|
538
|
+
body = bodyResult.body;
|
|
539
|
+
footersStartIndex = bodyResult.endIndex;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// Parse footers
|
|
543
|
+
const footersResult = parseFooters(lines, footersStartIndex);
|
|
544
|
+
const breakingDescriptionFromFooter = footersResult.breakingDescription;
|
|
545
|
+
// Determine breaking status
|
|
546
|
+
const breaking = header.breaking || footersResult.footers.some((f) => hyphenToSpace(f.key.toUpperCase()) === 'BREAKING CHANGE');
|
|
547
|
+
// Determine breaking description
|
|
548
|
+
let breakingDescription;
|
|
549
|
+
if (header.breaking && header.subject) {
|
|
550
|
+
// If breaking via !, the subject may describe the breaking change
|
|
551
|
+
breakingDescription = header.subject;
|
|
552
|
+
}
|
|
553
|
+
if (breakingDescriptionFromFooter) {
|
|
554
|
+
breakingDescription = breakingDescriptionFromFooter;
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
type: header.type,
|
|
558
|
+
scope: header.scope,
|
|
559
|
+
subject: header.subject,
|
|
560
|
+
body,
|
|
561
|
+
footers: footersResult.footers,
|
|
562
|
+
breaking,
|
|
563
|
+
breakingDescription,
|
|
564
|
+
raw: message,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Splits a message into lines, handling different line endings.
|
|
569
|
+
*
|
|
570
|
+
* @param message - The message to split into lines
|
|
571
|
+
* @returns An array of lines from the message
|
|
572
|
+
*/
|
|
573
|
+
function splitLines(message) {
|
|
574
|
+
const lines = [];
|
|
575
|
+
let currentLine = '';
|
|
576
|
+
let i = 0;
|
|
577
|
+
while (i < message.length) {
|
|
578
|
+
const char = message[i];
|
|
579
|
+
if (char === '\r') {
|
|
580
|
+
lines.push(currentLine);
|
|
581
|
+
currentLine = '';
|
|
582
|
+
// Skip \n if this is \r\n
|
|
583
|
+
if (message[i + 1] === '\n') {
|
|
584
|
+
i++;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
else if (char === '\n') {
|
|
588
|
+
lines.push(currentLine);
|
|
589
|
+
currentLine = '';
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
currentLine += char;
|
|
593
|
+
}
|
|
594
|
+
i++;
|
|
595
|
+
}
|
|
596
|
+
// Add final line if not empty
|
|
597
|
+
if (currentLine || message.endsWith('\n') || message.endsWith('\r')) {
|
|
598
|
+
lines.push(currentLine);
|
|
599
|
+
}
|
|
600
|
+
return lines;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const ANALYZE_COMMITS_STEP_ID = 'analyze-commits';
|
|
604
|
+
/**
|
|
605
|
+
* Creates the analyze-commits step.
|
|
606
|
+
*
|
|
607
|
+
* This step:
|
|
608
|
+
* 1. Finds the last release tag for this package
|
|
609
|
+
* 2. Gets all commits since that tag (or all commits if first release)
|
|
610
|
+
* 3. Parses each commit using conventional commit format
|
|
611
|
+
* 4. Filters to only release-worthy commits
|
|
612
|
+
*
|
|
613
|
+
* State updates:
|
|
614
|
+
* - lastReleaseTag: Tag name of last release (null if first release)
|
|
615
|
+
* - commits: Array of parsed conventional commits
|
|
616
|
+
*
|
|
617
|
+
* @returns A FlowStep that analyzes commits
|
|
618
|
+
*/
|
|
619
|
+
function createAnalyzeCommitsStep() {
|
|
620
|
+
return createStep(ANALYZE_COMMITS_STEP_ID, 'Analyze Commits', async (ctx) => {
|
|
621
|
+
const { git, projectName, packageName, config, logger, state } = ctx;
|
|
622
|
+
// Find the last release tag for this package
|
|
623
|
+
let lastReleaseTag = null;
|
|
624
|
+
if (!state.isFirstRelease) {
|
|
625
|
+
// Try to find a tag matching the package name pattern
|
|
626
|
+
const tags = git.getTagsForPackage(packageName);
|
|
627
|
+
if (tags.length > 0) {
|
|
628
|
+
// Tags are returned in reverse chronological order
|
|
629
|
+
lastReleaseTag = tags[0].name;
|
|
630
|
+
logger.debug(`Found last release tag: ${lastReleaseTag}`);
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
// Try with project name format
|
|
634
|
+
const projectTags = git.getTagsForPackage(projectName);
|
|
635
|
+
if (projectTags.length > 0) {
|
|
636
|
+
lastReleaseTag = projectTags[0].name;
|
|
637
|
+
logger.debug(`Found last release tag (project format): ${lastReleaseTag}`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
// Get commits
|
|
642
|
+
let rawCommits;
|
|
643
|
+
if (lastReleaseTag) {
|
|
644
|
+
rawCommits = git.getCommitsSince(lastReleaseTag);
|
|
645
|
+
logger.debug(`Found ${rawCommits.length} commits since ${lastReleaseTag}`);
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
// First release - get all commits (limit to recent for performance)
|
|
649
|
+
rawCommits = git.getCommitLog({ maxCount: 100 });
|
|
650
|
+
logger.debug(`First release - analyzing up to ${rawCommits.length} commits`);
|
|
651
|
+
}
|
|
652
|
+
// Parse commits using conventional commit format
|
|
653
|
+
const commits = [];
|
|
654
|
+
const releaseTypes = config.releaseTypes ?? ['feat', 'fix', 'perf', 'revert'];
|
|
655
|
+
for (const rawCommit of rawCommits) {
|
|
656
|
+
const parsed = parseConventionalCommit(rawCommit.message);
|
|
657
|
+
if (parsed.type && releaseTypes.includes(parsed.type)) {
|
|
658
|
+
commits.push(parsed);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const message = commits.length > 0
|
|
662
|
+
? `Found ${commits.length} releasable commits (${rawCommits.length} total)`
|
|
663
|
+
: `No releasable commits found (${rawCommits.length} total)`;
|
|
664
|
+
return {
|
|
665
|
+
status: 'success',
|
|
666
|
+
stateUpdates: {
|
|
667
|
+
lastReleaseTag,
|
|
668
|
+
commits,
|
|
669
|
+
},
|
|
670
|
+
message,
|
|
671
|
+
};
|
|
672
|
+
}, {
|
|
673
|
+
dependsOn: ['fetch-registry'],
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Converts a SemVer to its canonical string representation.
|
|
679
|
+
*
|
|
680
|
+
* @param version - The version to format
|
|
681
|
+
* @returns The version string (e.g., "1.2.3-alpha.1+build.123")
|
|
682
|
+
*/
|
|
683
|
+
function format(version) {
|
|
684
|
+
let result = `${version.major}.${version.minor}.${version.patch}`;
|
|
685
|
+
if (version.prerelease.length > 0) {
|
|
686
|
+
result += '-' + version.prerelease.join('.');
|
|
687
|
+
}
|
|
688
|
+
if (version.build.length > 0) {
|
|
689
|
+
result += '+' + version.build.join('.');
|
|
690
|
+
}
|
|
691
|
+
return result;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Safe copies of Number built-in methods and constants.
|
|
696
|
+
*
|
|
697
|
+
* These references are captured at module initialization time to protect against
|
|
698
|
+
* prototype pollution attacks. Import only what you need for tree-shaking.
|
|
699
|
+
*
|
|
700
|
+
* @module @hyperfrontend/immutable-api-utils/built-in-copy/number
|
|
701
|
+
*/
|
|
702
|
+
// Capture references at module initialization time
|
|
703
|
+
const _parseInt = globalThis.parseInt;
|
|
704
|
+
const _isNaN = globalThis.isNaN;
|
|
705
|
+
// ============================================================================
|
|
706
|
+
// Parsing
|
|
707
|
+
// ============================================================================
|
|
708
|
+
/**
|
|
709
|
+
* (Safe copy) Parses a string and returns an integer.
|
|
710
|
+
*/
|
|
711
|
+
const parseInt = _parseInt;
|
|
712
|
+
// ============================================================================
|
|
713
|
+
// Global Type Checking (legacy, less strict)
|
|
714
|
+
// ============================================================================
|
|
715
|
+
/**
|
|
716
|
+
* (Safe copy) Global isNaN function (coerces to number first, less strict than Number.isNaN).
|
|
717
|
+
*/
|
|
718
|
+
const globalIsNaN = _isNaN;
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Creates a new SemVer object.
|
|
722
|
+
*
|
|
723
|
+
* @param options - Version components
|
|
724
|
+
* @returns A new SemVer object
|
|
725
|
+
*/
|
|
726
|
+
function createSemVer(options) {
|
|
727
|
+
return {
|
|
728
|
+
major: options.major,
|
|
729
|
+
minor: options.minor,
|
|
730
|
+
patch: options.patch,
|
|
731
|
+
prerelease: options.prerelease ?? [],
|
|
732
|
+
build: options.build ?? [],
|
|
733
|
+
raw: options.raw,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Increments a version based on the bump type.
|
|
739
|
+
*
|
|
740
|
+
* @param version - The version to increment
|
|
741
|
+
* @param type - The type of bump (major, minor, patch, etc.)
|
|
742
|
+
* @param prereleaseId - Optional prerelease identifier for prerelease bumps
|
|
743
|
+
* @returns A new incremented SemVer
|
|
744
|
+
*
|
|
745
|
+
* @example
|
|
746
|
+
* increment(parseVersion('1.2.3'), 'minor') // 1.3.0
|
|
747
|
+
* increment(parseVersion('1.2.3'), 'major') // 2.0.0
|
|
748
|
+
* increment(parseVersion('1.2.3'), 'prerelease', 'alpha') // 1.2.4-alpha.0
|
|
749
|
+
*/
|
|
750
|
+
function increment(version, type, prereleaseId) {
|
|
751
|
+
switch (type) {
|
|
752
|
+
case 'major':
|
|
753
|
+
return createSemVer({
|
|
754
|
+
major: version.major + 1,
|
|
755
|
+
minor: 0,
|
|
756
|
+
patch: 0,
|
|
757
|
+
prerelease: [],
|
|
758
|
+
build: [],
|
|
759
|
+
});
|
|
760
|
+
case 'minor':
|
|
761
|
+
return createSemVer({
|
|
762
|
+
major: version.major,
|
|
763
|
+
minor: version.minor + 1,
|
|
764
|
+
patch: 0,
|
|
765
|
+
prerelease: [],
|
|
766
|
+
build: [],
|
|
767
|
+
});
|
|
768
|
+
case 'patch':
|
|
769
|
+
// If version has prerelease, just remove it (1.2.3-alpha -> 1.2.3)
|
|
770
|
+
if (version.prerelease.length > 0) {
|
|
771
|
+
return createSemVer({
|
|
772
|
+
major: version.major,
|
|
773
|
+
minor: version.minor,
|
|
774
|
+
patch: version.patch,
|
|
775
|
+
prerelease: [],
|
|
776
|
+
build: [],
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
return createSemVer({
|
|
780
|
+
major: version.major,
|
|
781
|
+
minor: version.minor,
|
|
782
|
+
patch: version.patch + 1,
|
|
783
|
+
prerelease: [],
|
|
784
|
+
build: [],
|
|
785
|
+
});
|
|
786
|
+
case 'premajor':
|
|
787
|
+
return createSemVer({
|
|
788
|
+
major: version.major + 1,
|
|
789
|
+
minor: 0,
|
|
790
|
+
patch: 0,
|
|
791
|
+
prerelease: ['alpha', '0'],
|
|
792
|
+
build: [],
|
|
793
|
+
});
|
|
794
|
+
case 'preminor':
|
|
795
|
+
return createSemVer({
|
|
796
|
+
major: version.major,
|
|
797
|
+
minor: version.minor + 1,
|
|
798
|
+
patch: 0,
|
|
799
|
+
prerelease: ['alpha', '0'],
|
|
800
|
+
build: [],
|
|
801
|
+
});
|
|
802
|
+
case 'prepatch':
|
|
803
|
+
return createSemVer({
|
|
804
|
+
major: version.major,
|
|
805
|
+
minor: version.minor,
|
|
806
|
+
patch: version.patch + 1,
|
|
807
|
+
prerelease: ['alpha', '0'],
|
|
808
|
+
build: [],
|
|
809
|
+
});
|
|
810
|
+
case 'prerelease':
|
|
811
|
+
return incrementPrerelease(version);
|
|
812
|
+
case 'none':
|
|
813
|
+
default:
|
|
814
|
+
return version;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Increments the prerelease portion of a version.
|
|
819
|
+
*
|
|
820
|
+
* @param version - The version to increment
|
|
821
|
+
* @param id - Optional prerelease identifier
|
|
822
|
+
* @returns A new version with incremented prerelease
|
|
823
|
+
*/
|
|
824
|
+
function incrementPrerelease(version, id) {
|
|
825
|
+
const prerelease = [...version.prerelease];
|
|
826
|
+
if (prerelease.length === 0) {
|
|
827
|
+
// No existing prerelease - start at patch+1 with id.0
|
|
828
|
+
return createSemVer({
|
|
829
|
+
major: version.major,
|
|
830
|
+
minor: version.minor,
|
|
831
|
+
patch: version.patch + 1,
|
|
832
|
+
prerelease: ['alpha', '0'],
|
|
833
|
+
build: [],
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
// Check if the last identifier is numeric
|
|
837
|
+
const lastIdx = prerelease.length - 1;
|
|
838
|
+
const last = prerelease[lastIdx];
|
|
839
|
+
const lastNum = parseInt(last, 10);
|
|
840
|
+
if (!globalIsNaN(lastNum)) {
|
|
841
|
+
// Increment the numeric part
|
|
842
|
+
prerelease[lastIdx] = String(lastNum + 1);
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
// Append .0
|
|
846
|
+
prerelease.push('0');
|
|
847
|
+
}
|
|
848
|
+
return createSemVer({
|
|
849
|
+
major: version.major,
|
|
850
|
+
minor: version.minor,
|
|
851
|
+
patch: version.patch,
|
|
852
|
+
prerelease,
|
|
853
|
+
build: [],
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Maximum version string length to prevent memory exhaustion.
|
|
859
|
+
*/
|
|
860
|
+
const MAX_VERSION_LENGTH = 256;
|
|
861
|
+
/**
|
|
862
|
+
* Parses a semantic version string.
|
|
863
|
+
*
|
|
864
|
+
* Accepts versions in the format: MAJOR.MINOR.PATCH[-prerelease][+build]
|
|
865
|
+
* Optional leading 'v' or '=' prefixes are stripped.
|
|
866
|
+
*
|
|
867
|
+
* @param input - The version string to parse
|
|
868
|
+
* @returns A ParseVersionResult with the parsed version or error
|
|
869
|
+
*
|
|
870
|
+
* @example
|
|
871
|
+
* parseVersion('1.2.3') // { success: true, version: { major: 1, minor: 2, patch: 3, ... } }
|
|
872
|
+
* parseVersion('v1.0.0-alpha.1+build.123') // { success: true, ... }
|
|
873
|
+
* parseVersion('invalid') // { success: false, error: '...' }
|
|
874
|
+
*/
|
|
875
|
+
function parseVersion(input) {
|
|
876
|
+
// Input validation
|
|
877
|
+
if (!input || typeof input !== 'string') {
|
|
878
|
+
return { success: false, error: 'Version string is required' };
|
|
879
|
+
}
|
|
880
|
+
if (input.length > MAX_VERSION_LENGTH) {
|
|
881
|
+
return { success: false, error: `Version string exceeds maximum length of ${MAX_VERSION_LENGTH}` };
|
|
882
|
+
}
|
|
883
|
+
// Strip leading whitespace
|
|
884
|
+
let pos = 0;
|
|
885
|
+
while (pos < input.length && isWhitespace$1(input.charCodeAt(pos))) {
|
|
886
|
+
pos++;
|
|
887
|
+
}
|
|
888
|
+
// Strip trailing whitespace
|
|
889
|
+
let end = input.length;
|
|
890
|
+
while (end > pos && isWhitespace$1(input.charCodeAt(end - 1))) {
|
|
891
|
+
end--;
|
|
892
|
+
}
|
|
893
|
+
// Strip optional leading 'v' or '='
|
|
894
|
+
if (pos < end) {
|
|
895
|
+
const code = input.charCodeAt(pos);
|
|
896
|
+
if (code === 118 || code === 86) {
|
|
897
|
+
// 'v' or 'V'
|
|
898
|
+
pos++;
|
|
899
|
+
}
|
|
900
|
+
else if (code === 61) {
|
|
901
|
+
// '='
|
|
902
|
+
pos++;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
// Parse major version
|
|
906
|
+
const majorResult = parseNumericIdentifier(input, pos, end);
|
|
907
|
+
if (!majorResult.success) {
|
|
908
|
+
return { success: false, error: majorResult.error ?? 'Invalid major version' };
|
|
909
|
+
}
|
|
910
|
+
pos = majorResult.endPos;
|
|
911
|
+
// Expect dot
|
|
912
|
+
if (pos >= end || input.charCodeAt(pos) !== 46) {
|
|
913
|
+
// '.'
|
|
914
|
+
return { success: false, error: 'Expected "." after major version' };
|
|
915
|
+
}
|
|
916
|
+
pos++;
|
|
917
|
+
// Parse minor version
|
|
918
|
+
const minorResult = parseNumericIdentifier(input, pos, end);
|
|
919
|
+
if (!minorResult.success) {
|
|
920
|
+
return { success: false, error: minorResult.error ?? 'Invalid minor version' };
|
|
921
|
+
}
|
|
922
|
+
pos = minorResult.endPos;
|
|
923
|
+
// Expect dot
|
|
924
|
+
if (pos >= end || input.charCodeAt(pos) !== 46) {
|
|
925
|
+
// '.'
|
|
926
|
+
return { success: false, error: 'Expected "." after minor version' };
|
|
927
|
+
}
|
|
928
|
+
pos++;
|
|
929
|
+
// Parse patch version
|
|
930
|
+
const patchResult = parseNumericIdentifier(input, pos, end);
|
|
931
|
+
if (!patchResult.success) {
|
|
932
|
+
return { success: false, error: patchResult.error ?? 'Invalid patch version' };
|
|
933
|
+
}
|
|
934
|
+
pos = patchResult.endPos;
|
|
935
|
+
// Parse optional prerelease
|
|
936
|
+
const prerelease = [];
|
|
937
|
+
if (pos < end && input.charCodeAt(pos) === 45) {
|
|
938
|
+
// '-'
|
|
939
|
+
pos++;
|
|
940
|
+
const prereleaseResult = parseIdentifiers(input, pos, end, [43]); // Stop at '+'
|
|
941
|
+
if (!prereleaseResult.success) {
|
|
942
|
+
return { success: false, error: prereleaseResult.error ?? 'Invalid prerelease' };
|
|
943
|
+
}
|
|
944
|
+
prerelease.push(...prereleaseResult.identifiers);
|
|
945
|
+
pos = prereleaseResult.endPos;
|
|
946
|
+
}
|
|
947
|
+
// Parse optional build metadata
|
|
948
|
+
const build = [];
|
|
949
|
+
if (pos < end && input.charCodeAt(pos) === 43) {
|
|
950
|
+
// '+'
|
|
951
|
+
pos++;
|
|
952
|
+
const buildResult = parseIdentifiers(input, pos, end, []);
|
|
953
|
+
if (!buildResult.success) {
|
|
954
|
+
return { success: false, error: buildResult.error ?? 'Invalid build metadata' };
|
|
955
|
+
}
|
|
956
|
+
build.push(...buildResult.identifiers);
|
|
957
|
+
pos = buildResult.endPos;
|
|
958
|
+
}
|
|
959
|
+
// Check for trailing characters
|
|
960
|
+
if (pos < end) {
|
|
961
|
+
return { success: false, error: `Unexpected character at position ${pos}: "${input[pos]}"` };
|
|
962
|
+
}
|
|
963
|
+
return {
|
|
964
|
+
success: true,
|
|
965
|
+
version: createSemVer({
|
|
966
|
+
major: majorResult.value,
|
|
967
|
+
minor: minorResult.value,
|
|
968
|
+
patch: patchResult.value,
|
|
969
|
+
prerelease,
|
|
970
|
+
build,
|
|
971
|
+
raw: input,
|
|
972
|
+
}),
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Parses a numeric identifier (non-negative integer, no leading zeros except for "0").
|
|
977
|
+
*
|
|
978
|
+
* @param input - Input string to parse
|
|
979
|
+
* @param start - Start position in the input
|
|
980
|
+
* @param end - End position in the input
|
|
981
|
+
* @returns Numeric parsing result
|
|
982
|
+
*/
|
|
983
|
+
function parseNumericIdentifier(input, start, end) {
|
|
984
|
+
if (start >= end) {
|
|
985
|
+
return { success: false, value: 0, endPos: start, error: 'Expected numeric identifier' };
|
|
986
|
+
}
|
|
987
|
+
let pos = start;
|
|
988
|
+
const firstCode = input.charCodeAt(pos);
|
|
989
|
+
// Must start with a digit
|
|
990
|
+
if (!isDigit(firstCode)) {
|
|
991
|
+
return { success: false, value: 0, endPos: pos, error: 'Expected digit' };
|
|
992
|
+
}
|
|
993
|
+
// Check for leading zero (only "0" is valid, not "01", "007", etc.)
|
|
994
|
+
if (firstCode === 48 && pos + 1 < end && isDigit(input.charCodeAt(pos + 1))) {
|
|
995
|
+
return { success: false, value: 0, endPos: pos, error: 'Numeric identifier cannot have leading zeros' };
|
|
996
|
+
}
|
|
997
|
+
// Consume digits
|
|
998
|
+
let value = 0;
|
|
999
|
+
while (pos < end && isDigit(input.charCodeAt(pos))) {
|
|
1000
|
+
value = value * 10 + (input.charCodeAt(pos) - 48);
|
|
1001
|
+
pos++;
|
|
1002
|
+
// Prevent overflow
|
|
1003
|
+
if (value > Number.MAX_SAFE_INTEGER) {
|
|
1004
|
+
return { success: false, value: 0, endPos: pos, error: 'Numeric identifier is too large' };
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return { success: true, value, endPos: pos };
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Parses dot-separated identifiers (for prerelease/build).
|
|
1011
|
+
*
|
|
1012
|
+
* @param input - Input string to parse
|
|
1013
|
+
* @param start - Start position in the input
|
|
1014
|
+
* @param end - End position in the input
|
|
1015
|
+
* @param stopCodes - Character codes that signal end of identifiers
|
|
1016
|
+
* @returns Identifiers parsing result
|
|
1017
|
+
*/
|
|
1018
|
+
function parseIdentifiers(input, start, end, stopCodes) {
|
|
1019
|
+
const identifiers = [];
|
|
1020
|
+
let pos = start;
|
|
1021
|
+
while (pos < end) {
|
|
1022
|
+
// Check for stop characters
|
|
1023
|
+
if (stopCodes.includes(input.charCodeAt(pos))) {
|
|
1024
|
+
break;
|
|
1025
|
+
}
|
|
1026
|
+
// Parse one identifier
|
|
1027
|
+
const identStart = pos;
|
|
1028
|
+
while (pos < end) {
|
|
1029
|
+
const code = input.charCodeAt(pos);
|
|
1030
|
+
// Stop at dot or stop characters
|
|
1031
|
+
if (code === 46 || stopCodes.includes(code)) {
|
|
1032
|
+
break;
|
|
1033
|
+
}
|
|
1034
|
+
// Must be alphanumeric or hyphen
|
|
1035
|
+
if (!isAlphanumeric(code) && code !== 45) {
|
|
1036
|
+
return { success: false, identifiers: [], endPos: pos, error: `Invalid character in identifier: "${input[pos]}"` };
|
|
1037
|
+
}
|
|
1038
|
+
pos++;
|
|
1039
|
+
}
|
|
1040
|
+
// Empty identifier is not allowed
|
|
1041
|
+
if (pos === identStart) {
|
|
1042
|
+
return { success: false, identifiers: [], endPos: pos, error: 'Empty identifier' };
|
|
1043
|
+
}
|
|
1044
|
+
identifiers.push(input.slice(identStart, pos));
|
|
1045
|
+
// Consume dot separator
|
|
1046
|
+
if (pos < end && input.charCodeAt(pos) === 46) {
|
|
1047
|
+
pos++;
|
|
1048
|
+
// Dot at end is invalid
|
|
1049
|
+
if (pos >= end || stopCodes.includes(input.charCodeAt(pos))) {
|
|
1050
|
+
return { success: false, identifiers: [], endPos: pos, error: 'Identifier expected after dot' };
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return { success: true, identifiers, endPos: pos };
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Checks if a character code is a digit (0-9).
|
|
1058
|
+
*
|
|
1059
|
+
* @param code - Character code to check
|
|
1060
|
+
* @returns True if the code represents a digit
|
|
1061
|
+
*/
|
|
1062
|
+
function isDigit(code) {
|
|
1063
|
+
return code >= 48 && code <= 57;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Checks if a character code is alphanumeric or hyphen.
|
|
1067
|
+
*
|
|
1068
|
+
* @param code - Character code to check
|
|
1069
|
+
* @returns True if the code represents an alphanumeric character
|
|
1070
|
+
*/
|
|
1071
|
+
function isAlphanumeric(code) {
|
|
1072
|
+
return ((code >= 48 && code <= 57) || // 0-9
|
|
1073
|
+
(code >= 65 && code <= 90) || // A-Z
|
|
1074
|
+
(code >= 97 && code <= 122) // a-z
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Checks if a character code is whitespace.
|
|
1079
|
+
*
|
|
1080
|
+
* @param code - Character code to check
|
|
1081
|
+
* @returns True if the code represents whitespace
|
|
1082
|
+
*/
|
|
1083
|
+
function isWhitespace$1(code) {
|
|
1084
|
+
return code === 32 || code === 9 || code === 10 || code === 13;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const CALCULATE_BUMP_STEP_ID = 'calculate-bump';
|
|
1088
|
+
/**
|
|
1089
|
+
* Determines the highest bump type from analyzed commits.
|
|
1090
|
+
*
|
|
1091
|
+
* Priority: major > minor > patch > none
|
|
1092
|
+
* Breaking changes always result in major (post-1.0.0) or minor (pre-1.0.0).
|
|
1093
|
+
*
|
|
1094
|
+
* @param commits - Parsed conventional commits
|
|
1095
|
+
* @param minorTypes - Types that trigger minor bumps
|
|
1096
|
+
* @param patchTypes - Types that trigger patch bumps
|
|
1097
|
+
* @param currentVersion - Current version string (for pre-1.0.0 handling)
|
|
1098
|
+
* @returns The highest bump type needed
|
|
1099
|
+
*/
|
|
1100
|
+
function calculateBumpFromCommits(commits, minorTypes, patchTypes, currentVersion) {
|
|
1101
|
+
if (commits.length === 0) {
|
|
1102
|
+
return 'none';
|
|
1103
|
+
}
|
|
1104
|
+
// Check for breaking changes
|
|
1105
|
+
const hasBreaking = commits.some((c) => c.breaking);
|
|
1106
|
+
if (hasBreaking) {
|
|
1107
|
+
// Pre-1.0.0: breaking changes are minor, not major
|
|
1108
|
+
const parsed = parseVersion(currentVersion);
|
|
1109
|
+
if (parsed.success && parsed.version && parsed.version.major === 0) {
|
|
1110
|
+
return 'minor';
|
|
1111
|
+
}
|
|
1112
|
+
return 'major';
|
|
1113
|
+
}
|
|
1114
|
+
// Check for minor-triggering types
|
|
1115
|
+
const hasMinor = commits.some((c) => c.type && minorTypes.includes(c.type));
|
|
1116
|
+
if (hasMinor) {
|
|
1117
|
+
return 'minor';
|
|
1118
|
+
}
|
|
1119
|
+
// Check for patch-triggering types
|
|
1120
|
+
const hasPatch = commits.some((c) => c.type && patchTypes.includes(c.type));
|
|
1121
|
+
if (hasPatch) {
|
|
1122
|
+
return 'patch';
|
|
1123
|
+
}
|
|
1124
|
+
return 'none';
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Creates the calculate-bump step.
|
|
1128
|
+
*
|
|
1129
|
+
* This step:
|
|
1130
|
+
* 1. Analyzes commit types and breaking changes
|
|
1131
|
+
* 2. Determines the appropriate bump level
|
|
1132
|
+
* 3. Calculates the next version
|
|
1133
|
+
*
|
|
1134
|
+
* State updates:
|
|
1135
|
+
* - bumpType: 'major' | 'minor' | 'patch' | 'none'
|
|
1136
|
+
* - nextVersion: Calculated next version string
|
|
1137
|
+
*
|
|
1138
|
+
* @returns A FlowStep that calculates version bump
|
|
1139
|
+
*/
|
|
1140
|
+
function createCalculateBumpStep() {
|
|
1141
|
+
return createStep(CALCULATE_BUMP_STEP_ID, 'Calculate Version Bump', async (ctx) => {
|
|
1142
|
+
const { config, state, logger } = ctx;
|
|
1143
|
+
const { commits, currentVersion, isFirstRelease } = state;
|
|
1144
|
+
// Handle first release
|
|
1145
|
+
if (isFirstRelease) {
|
|
1146
|
+
const firstVersion = config.firstReleaseVersion ?? '0.1.0';
|
|
1147
|
+
logger.info(`First release: using version ${firstVersion}`);
|
|
1148
|
+
return {
|
|
1149
|
+
status: 'success',
|
|
1150
|
+
stateUpdates: {
|
|
1151
|
+
bumpType: 'minor',
|
|
1152
|
+
nextVersion: firstVersion,
|
|
1153
|
+
},
|
|
1154
|
+
message: `First release: ${firstVersion}`,
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
// Check for forced bump type (releaseAs)
|
|
1158
|
+
if (config.releaseAs) {
|
|
1159
|
+
const forcedBumpType = config.releaseAs;
|
|
1160
|
+
const current = parseVersion(currentVersion ?? '0.0.0');
|
|
1161
|
+
if (!current.success || !current.version) {
|
|
1162
|
+
return {
|
|
1163
|
+
status: 'failed',
|
|
1164
|
+
error: createError(`Invalid current version: ${currentVersion}`),
|
|
1165
|
+
message: `Could not parse current version: ${currentVersion}`,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
const next = increment(current.version, forcedBumpType);
|
|
1169
|
+
const nextVersion = format(next);
|
|
1170
|
+
logger.info(`Forced ${forcedBumpType} bump via releaseAs: ${currentVersion} → ${nextVersion}`);
|
|
1171
|
+
return {
|
|
1172
|
+
status: 'success',
|
|
1173
|
+
stateUpdates: {
|
|
1174
|
+
bumpType: forcedBumpType,
|
|
1175
|
+
nextVersion,
|
|
1176
|
+
},
|
|
1177
|
+
message: `${forcedBumpType} bump (forced): ${currentVersion} → ${nextVersion}`,
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
// No commits = no bump needed
|
|
1181
|
+
if (!commits || commits.length === 0) {
|
|
1182
|
+
return createSkippedResult('No releasable commits found');
|
|
1183
|
+
}
|
|
1184
|
+
const minorTypes = config.minorTypes ?? ['feat'];
|
|
1185
|
+
const patchTypes = config.patchTypes ?? ['fix', 'perf', 'revert'];
|
|
1186
|
+
const bumpType = calculateBumpFromCommits(commits, minorTypes, patchTypes, currentVersion ?? '0.0.0');
|
|
1187
|
+
if (bumpType === 'none') {
|
|
1188
|
+
return {
|
|
1189
|
+
status: 'success',
|
|
1190
|
+
stateUpdates: {
|
|
1191
|
+
bumpType: 'none',
|
|
1192
|
+
nextVersion: undefined,
|
|
1193
|
+
},
|
|
1194
|
+
message: 'No version bump needed',
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
// Calculate next version
|
|
1198
|
+
const current = parseVersion(currentVersion ?? '0.0.0');
|
|
1199
|
+
if (!current.success || !current.version) {
|
|
1200
|
+
return {
|
|
1201
|
+
status: 'failed',
|
|
1202
|
+
error: createError(`Invalid current version: ${currentVersion}`),
|
|
1203
|
+
message: `Could not parse current version: ${currentVersion}`,
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
const next = increment(current.version, bumpType);
|
|
1207
|
+
const nextVersion = format(next);
|
|
1208
|
+
return {
|
|
1209
|
+
status: 'success',
|
|
1210
|
+
stateUpdates: {
|
|
1211
|
+
bumpType,
|
|
1212
|
+
nextVersion,
|
|
1213
|
+
},
|
|
1214
|
+
message: `${bumpType} bump: ${currentVersion} → ${nextVersion}`,
|
|
1215
|
+
};
|
|
1216
|
+
}, {
|
|
1217
|
+
dependsOn: ['analyze-commits'],
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Creates a step that checks for idempotency.
|
|
1222
|
+
*
|
|
1223
|
+
* This step prevents redundant releases by checking if the
|
|
1224
|
+
* calculated version is already published.
|
|
1225
|
+
*
|
|
1226
|
+
* @returns A FlowStep that checks idempotency
|
|
1227
|
+
*/
|
|
1228
|
+
function createCheckIdempotencyStep() {
|
|
1229
|
+
return createStep('check-idempotency', 'Check Idempotency', async (ctx) => {
|
|
1230
|
+
const { registry, packageName, state } = ctx;
|
|
1231
|
+
const { nextVersion, bumpType } = state;
|
|
1232
|
+
// No bump = nothing to check
|
|
1233
|
+
if (!nextVersion || bumpType === 'none') {
|
|
1234
|
+
return {
|
|
1235
|
+
status: 'success',
|
|
1236
|
+
message: 'No version to check (no bump needed)',
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
// Check if version is already published
|
|
1240
|
+
const isPublished = await registry.isVersionPublished(packageName, nextVersion);
|
|
1241
|
+
if (isPublished) {
|
|
1242
|
+
return {
|
|
1243
|
+
status: 'skipped',
|
|
1244
|
+
stateUpdates: {
|
|
1245
|
+
bumpType: 'none',
|
|
1246
|
+
nextVersion: undefined,
|
|
1247
|
+
},
|
|
1248
|
+
message: `Version ${nextVersion} is already published`,
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
return {
|
|
1252
|
+
status: 'success',
|
|
1253
|
+
message: `Version ${nextVersion} is not yet published`,
|
|
1254
|
+
};
|
|
1255
|
+
}, {
|
|
1256
|
+
dependsOn: ['calculate-bump'],
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* Safe copies of Date built-in via factory function and static methods.
|
|
1262
|
+
*
|
|
1263
|
+
* Since constructors cannot be safely captured via Object.assign, this module
|
|
1264
|
+
* provides a factory function that uses Reflect.construct internally.
|
|
1265
|
+
*
|
|
1266
|
+
* These references are captured at module initialization time to protect against
|
|
1267
|
+
* prototype pollution attacks. Import only what you need for tree-shaking.
|
|
1268
|
+
*
|
|
1269
|
+
* @module @hyperfrontend/immutable-api-utils/built-in-copy/date
|
|
1270
|
+
*/
|
|
1271
|
+
// Capture references at module initialization time
|
|
1272
|
+
const _Date = globalThis.Date;
|
|
1273
|
+
const _Reflect$1 = globalThis.Reflect;
|
|
1274
|
+
function createDate(...args) {
|
|
1275
|
+
return _Reflect$1.construct(_Date, args);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Creates a new changelog item.
|
|
1280
|
+
*
|
|
1281
|
+
* @param description - The description text of the change
|
|
1282
|
+
* @param options - Optional configuration for scope, commits, references, and breaking flag
|
|
1283
|
+
* @returns A new ChangelogItem object
|
|
1284
|
+
*/
|
|
1285
|
+
function createChangelogItem(description, options) {
|
|
1286
|
+
return {
|
|
1287
|
+
description,
|
|
1288
|
+
scope: options?.scope,
|
|
1289
|
+
commits: options?.commits ?? [],
|
|
1290
|
+
references: options?.references ?? [],
|
|
1291
|
+
breaking: options?.breaking ?? false,
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Creates a new changelog section.
|
|
1296
|
+
*
|
|
1297
|
+
* @param type - The type of section (features, fixes, breaking, etc.)
|
|
1298
|
+
* @param heading - The display heading for the section
|
|
1299
|
+
* @param items - Optional array of changelog items in this section
|
|
1300
|
+
* @returns A new ChangelogSection object
|
|
1301
|
+
*/
|
|
1302
|
+
function createChangelogSection(type, heading, items = []) {
|
|
1303
|
+
return {
|
|
1304
|
+
type,
|
|
1305
|
+
heading,
|
|
1306
|
+
items,
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Creates a new changelog entry.
|
|
1311
|
+
*
|
|
1312
|
+
* @param version - The version string (e.g., '1.0.0')
|
|
1313
|
+
* @param options - Optional configuration for date, sections, and other properties
|
|
1314
|
+
* @returns A new ChangelogEntry object
|
|
1315
|
+
*/
|
|
1316
|
+
function createChangelogEntry(version, options) {
|
|
1317
|
+
return {
|
|
1318
|
+
version,
|
|
1319
|
+
date: options?.date ?? null,
|
|
1320
|
+
unreleased: options?.unreleased ?? false,
|
|
1321
|
+
compareUrl: options?.compareUrl,
|
|
1322
|
+
sections: options?.sections ?? [],
|
|
1323
|
+
rawContent: options?.rawContent,
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Maps section headings to their canonical types.
|
|
1329
|
+
* Used during parsing to normalize different heading styles.
|
|
1330
|
+
*/
|
|
1331
|
+
const SECTION_TYPE_MAP = {
|
|
1332
|
+
// Breaking changes
|
|
1333
|
+
'breaking changes': 'breaking',
|
|
1334
|
+
breaking: 'breaking',
|
|
1335
|
+
'breaking change': 'breaking',
|
|
1336
|
+
// Features
|
|
1337
|
+
features: 'features',
|
|
1338
|
+
feature: 'features',
|
|
1339
|
+
added: 'features',
|
|
1340
|
+
new: 'features',
|
|
1341
|
+
// Fixes
|
|
1342
|
+
fixes: 'fixes',
|
|
1343
|
+
fix: 'fixes',
|
|
1344
|
+
'bug fixes': 'fixes',
|
|
1345
|
+
bugfixes: 'fixes',
|
|
1346
|
+
fixed: 'fixes',
|
|
1347
|
+
// Performance
|
|
1348
|
+
performance: 'performance',
|
|
1349
|
+
'performance improvements': 'performance',
|
|
1350
|
+
perf: 'performance',
|
|
1351
|
+
// Documentation
|
|
1352
|
+
documentation: 'documentation',
|
|
1353
|
+
docs: 'documentation',
|
|
1354
|
+
// Deprecations
|
|
1355
|
+
deprecations: 'deprecations',
|
|
1356
|
+
deprecated: 'deprecations',
|
|
1357
|
+
// Refactoring
|
|
1358
|
+
refactoring: 'refactoring',
|
|
1359
|
+
refactor: 'refactoring',
|
|
1360
|
+
'code refactoring': 'refactoring',
|
|
1361
|
+
// Tests
|
|
1362
|
+
tests: 'tests',
|
|
1363
|
+
test: 'tests',
|
|
1364
|
+
testing: 'tests',
|
|
1365
|
+
// Build
|
|
1366
|
+
build: 'build',
|
|
1367
|
+
'build system': 'build',
|
|
1368
|
+
dependencies: 'build',
|
|
1369
|
+
// CI
|
|
1370
|
+
ci: 'ci',
|
|
1371
|
+
'continuous integration': 'ci',
|
|
1372
|
+
// Chores
|
|
1373
|
+
chores: 'chores',
|
|
1374
|
+
chore: 'chores',
|
|
1375
|
+
maintenance: 'chores',
|
|
1376
|
+
// Other
|
|
1377
|
+
other: 'other',
|
|
1378
|
+
miscellaneous: 'other',
|
|
1379
|
+
misc: 'other',
|
|
1380
|
+
changed: 'other',
|
|
1381
|
+
changes: 'other',
|
|
1382
|
+
removed: 'other',
|
|
1383
|
+
security: 'other',
|
|
1384
|
+
};
|
|
1385
|
+
/**
|
|
1386
|
+
* Standard section headings for serialization.
|
|
1387
|
+
* Maps section types to their preferred heading text.
|
|
1388
|
+
*/
|
|
1389
|
+
const SECTION_HEADINGS = {
|
|
1390
|
+
breaking: 'Breaking Changes',
|
|
1391
|
+
features: 'Features',
|
|
1392
|
+
fixes: 'Bug Fixes',
|
|
1393
|
+
performance: 'Performance',
|
|
1394
|
+
documentation: 'Documentation',
|
|
1395
|
+
deprecations: 'Deprecations',
|
|
1396
|
+
refactoring: 'Refactoring',
|
|
1397
|
+
tests: 'Tests',
|
|
1398
|
+
build: 'Build',
|
|
1399
|
+
ci: 'CI',
|
|
1400
|
+
chores: 'Chores',
|
|
1401
|
+
other: 'Other',
|
|
1402
|
+
};
|
|
1403
|
+
/**
|
|
1404
|
+
* Determines the section type from a heading string.
|
|
1405
|
+
* Returns 'other' if the heading is not recognized.
|
|
1406
|
+
*
|
|
1407
|
+
* @param heading - The heading string to parse
|
|
1408
|
+
* @returns The corresponding ChangelogSectionType
|
|
1409
|
+
*/
|
|
1410
|
+
function getSectionType(heading) {
|
|
1411
|
+
const normalized = heading.toLowerCase().trim();
|
|
1412
|
+
return SECTION_TYPE_MAP[normalized] ?? 'other';
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/**
|
|
1416
|
+
* Safe copies of Map built-in via factory function.
|
|
1417
|
+
*
|
|
1418
|
+
* Since constructors cannot be safely captured via Object.assign, this module
|
|
1419
|
+
* provides a factory function that uses Reflect.construct internally.
|
|
1420
|
+
*
|
|
1421
|
+
* These references are captured at module initialization time to protect against
|
|
1422
|
+
* prototype pollution attacks. Import only what you need for tree-shaking.
|
|
1423
|
+
*
|
|
1424
|
+
* @module @hyperfrontend/immutable-api-utils/built-in-copy/map
|
|
1425
|
+
*/
|
|
1426
|
+
// Capture references at module initialization time
|
|
1427
|
+
const _Map = globalThis.Map;
|
|
1428
|
+
const _Reflect = globalThis.Reflect;
|
|
1429
|
+
/**
|
|
1430
|
+
* (Safe copy) Creates a new Map using the captured Map constructor.
|
|
1431
|
+
* Use this instead of `new Map()`.
|
|
1432
|
+
*
|
|
1433
|
+
* @param iterable - Optional iterable of key-value pairs.
|
|
1434
|
+
* @returns A new Map instance.
|
|
1435
|
+
*/
|
|
1436
|
+
const createMap = (iterable) => _Reflect.construct(_Map, iterable ? [iterable] : []);
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* Safe copies of Object built-in methods.
|
|
1440
|
+
*
|
|
1441
|
+
* These references are captured at module initialization time to protect against
|
|
1442
|
+
* prototype pollution attacks. Import only what you need for tree-shaking.
|
|
1443
|
+
*
|
|
1444
|
+
* @module @hyperfrontend/immutable-api-utils/built-in-copy/object
|
|
1445
|
+
*/
|
|
1446
|
+
// Capture references at module initialization time
|
|
1447
|
+
const _Object = globalThis.Object;
|
|
1448
|
+
/**
|
|
1449
|
+
* (Safe copy) Returns an array of key/values of the enumerable own properties of an object.
|
|
1450
|
+
*/
|
|
1451
|
+
const entries = _Object.entries;
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* Safe copies of URL built-ins via factory functions.
|
|
1455
|
+
*
|
|
1456
|
+
* Provides safe references to URL and URLSearchParams.
|
|
1457
|
+
* These references are captured at module initialization time to protect against
|
|
1458
|
+
* prototype pollution attacks. Import only what you need for tree-shaking.
|
|
1459
|
+
*
|
|
1460
|
+
* @module @hyperfrontend/immutable-api-utils/built-in-copy/url
|
|
1461
|
+
*/
|
|
1462
|
+
// Capture references at module initialization time
|
|
1463
|
+
const _URL = globalThis.URL;
|
|
1464
|
+
/**
|
|
1465
|
+
* (Safe copy) Creates an object URL for the given object.
|
|
1466
|
+
* Use this instead of `URL.createObjectURL()`.
|
|
1467
|
+
*
|
|
1468
|
+
* Note: This is a browser-only API. In Node.js environments, this will throw.
|
|
1469
|
+
*/
|
|
1470
|
+
typeof _URL.createObjectURL === 'function'
|
|
1471
|
+
? _URL.createObjectURL.bind(_URL)
|
|
1472
|
+
: () => {
|
|
1473
|
+
throw new Error('URL.createObjectURL is not available in this environment');
|
|
1474
|
+
};
|
|
1475
|
+
/**
|
|
1476
|
+
* (Safe copy) Revokes an object URL previously created with createObjectURL.
|
|
1477
|
+
* Use this instead of `URL.revokeObjectURL()`.
|
|
1478
|
+
*
|
|
1479
|
+
* Note: This is a browser-only API. In Node.js environments, this will throw.
|
|
1480
|
+
*/
|
|
1481
|
+
typeof _URL.revokeObjectURL === 'function'
|
|
1482
|
+
? _URL.revokeObjectURL.bind(_URL)
|
|
1483
|
+
: () => {
|
|
1484
|
+
throw new Error('URL.revokeObjectURL is not available in this environment');
|
|
1485
|
+
};
|
|
1486
|
+
|
|
1487
|
+
/**
|
|
1488
|
+
* Safe copies of Math built-in methods.
|
|
1489
|
+
*
|
|
1490
|
+
* These references are captured at module initialization time to protect against
|
|
1491
|
+
* prototype pollution attacks. Import only what you need for tree-shaking.
|
|
1492
|
+
*
|
|
1493
|
+
* @module @hyperfrontend/immutable-api-utils/built-in-copy/math
|
|
1494
|
+
*/
|
|
1495
|
+
// Capture references at module initialization time
|
|
1496
|
+
const _Math = globalThis.Math;
|
|
1497
|
+
// ============================================================================
|
|
1498
|
+
// Min/Max
|
|
1499
|
+
// ============================================================================
|
|
1500
|
+
/**
|
|
1501
|
+
* (Safe copy) Returns the larger of zero or more numbers.
|
|
1502
|
+
*/
|
|
1503
|
+
const max = _Math.max;
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* Line Parser
|
|
1507
|
+
*
|
|
1508
|
+
* Utilities for parsing individual changelog lines without regex.
|
|
1509
|
+
*/
|
|
1510
|
+
/**
|
|
1511
|
+
* Parses a version string from a heading.
|
|
1512
|
+
* Examples: "1.2.3", "v1.2.3", "[1.2.3]", "1.2.3 - 2024-01-01"
|
|
1513
|
+
*
|
|
1514
|
+
* @param heading - The heading string to parse
|
|
1515
|
+
* @returns An object containing the parsed version, date, and optional compareUrl
|
|
1516
|
+
*/
|
|
1517
|
+
function parseVersionFromHeading(heading) {
|
|
1518
|
+
const trimmed = heading.trim();
|
|
1519
|
+
// Check for unreleased
|
|
1520
|
+
const lowerHeading = trimmed.toLowerCase();
|
|
1521
|
+
if (lowerHeading === 'unreleased' || lowerHeading === '[unreleased]') {
|
|
1522
|
+
return { version: 'Unreleased', date: null };
|
|
1523
|
+
}
|
|
1524
|
+
let pos = 0;
|
|
1525
|
+
let version = '';
|
|
1526
|
+
let date = null;
|
|
1527
|
+
let compareUrl;
|
|
1528
|
+
// Skip leading [ if present
|
|
1529
|
+
if (trimmed[pos] === '[') {
|
|
1530
|
+
pos++;
|
|
1531
|
+
}
|
|
1532
|
+
// Skip leading 'v' if present
|
|
1533
|
+
if (trimmed[pos] === 'v' || trimmed[pos] === 'V') {
|
|
1534
|
+
pos++;
|
|
1535
|
+
}
|
|
1536
|
+
// Parse version number (digits and dots)
|
|
1537
|
+
const versionStart = pos;
|
|
1538
|
+
while (pos < trimmed.length) {
|
|
1539
|
+
const char = trimmed[pos];
|
|
1540
|
+
const code = char.charCodeAt(0);
|
|
1541
|
+
// Allow digits, dots, hyphens (for prerelease), plus signs
|
|
1542
|
+
if ((code >= 48 && code <= 57) || // 0-9
|
|
1543
|
+
char === '.' ||
|
|
1544
|
+
char === '-' ||
|
|
1545
|
+
char === '+' ||
|
|
1546
|
+
(code >= 97 && code <= 122) || // a-z (for prerelease tags like alpha, beta, rc)
|
|
1547
|
+
(code >= 65 && code <= 90) // A-Z
|
|
1548
|
+
) {
|
|
1549
|
+
pos++;
|
|
1550
|
+
}
|
|
1551
|
+
else {
|
|
1552
|
+
break;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
version = trimmed.slice(versionStart, pos);
|
|
1556
|
+
// Skip trailing ] if present
|
|
1557
|
+
if (trimmed[pos] === ']') {
|
|
1558
|
+
pos++;
|
|
1559
|
+
}
|
|
1560
|
+
// Skip whitespace and separator
|
|
1561
|
+
while (pos < trimmed.length && (trimmed[pos] === ' ' || trimmed[pos] === '-' || trimmed[pos] === '–')) {
|
|
1562
|
+
pos++;
|
|
1563
|
+
}
|
|
1564
|
+
// Try to parse date (YYYY-MM-DD format)
|
|
1565
|
+
if (pos < trimmed.length) {
|
|
1566
|
+
const dateMatch = extractDate(trimmed.slice(pos));
|
|
1567
|
+
if (dateMatch) {
|
|
1568
|
+
date = dateMatch.date;
|
|
1569
|
+
pos += dateMatch.length;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
// Skip to check for compare URL (in parentheses or link)
|
|
1573
|
+
while (pos < trimmed.length && trimmed[pos] === ' ') {
|
|
1574
|
+
pos++;
|
|
1575
|
+
}
|
|
1576
|
+
// Check for link at end: [compare](url)
|
|
1577
|
+
if (pos < trimmed.length) {
|
|
1578
|
+
const linkMatch = extractLink(trimmed.slice(pos));
|
|
1579
|
+
if (linkMatch?.url) {
|
|
1580
|
+
compareUrl = linkMatch.url;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
return { version, date, compareUrl };
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Extracts a date in YYYY-MM-DD format from a string.
|
|
1587
|
+
*
|
|
1588
|
+
* @param str - The string to extract a date from
|
|
1589
|
+
* @returns The extracted date and its length, or null if no date found
|
|
1590
|
+
*/
|
|
1591
|
+
function extractDate(str) {
|
|
1592
|
+
let pos = 0;
|
|
1593
|
+
// Skip optional parentheses
|
|
1594
|
+
if (str[pos] === '(')
|
|
1595
|
+
pos++;
|
|
1596
|
+
// Parse year (4 digits)
|
|
1597
|
+
const yearStart = pos;
|
|
1598
|
+
while (pos < str.length && pos - yearStart < 4) {
|
|
1599
|
+
const code = str.charCodeAt(pos);
|
|
1600
|
+
if (code >= 48 && code <= 57) {
|
|
1601
|
+
pos++;
|
|
1602
|
+
}
|
|
1603
|
+
else {
|
|
1604
|
+
break;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
if (pos - yearStart !== 4)
|
|
1608
|
+
return null;
|
|
1609
|
+
// Expect - or /
|
|
1610
|
+
if (str[pos] !== '-' && str[pos] !== '/')
|
|
1611
|
+
return null;
|
|
1612
|
+
const separator = str[pos];
|
|
1613
|
+
pos++;
|
|
1614
|
+
// Parse month (2 digits)
|
|
1615
|
+
const monthStart = pos;
|
|
1616
|
+
while (pos < str.length && pos - monthStart < 2) {
|
|
1617
|
+
const code = str.charCodeAt(pos);
|
|
1618
|
+
if (code >= 48 && code <= 57) {
|
|
1619
|
+
pos++;
|
|
1620
|
+
}
|
|
1621
|
+
else {
|
|
1622
|
+
break;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
if (pos - monthStart !== 2)
|
|
1626
|
+
return null;
|
|
1627
|
+
// Expect same separator
|
|
1628
|
+
if (str[pos] !== separator)
|
|
1629
|
+
return null;
|
|
1630
|
+
pos++;
|
|
1631
|
+
// Parse day (2 digits)
|
|
1632
|
+
const dayStart = pos;
|
|
1633
|
+
while (pos < str.length && pos - dayStart < 2) {
|
|
1634
|
+
const code = str.charCodeAt(pos);
|
|
1635
|
+
if (code >= 48 && code <= 57) {
|
|
1636
|
+
pos++;
|
|
1637
|
+
}
|
|
1638
|
+
else {
|
|
1639
|
+
break;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
if (pos - dayStart !== 2)
|
|
1643
|
+
return null;
|
|
1644
|
+
// Skip optional closing parenthesis
|
|
1645
|
+
if (str[pos] === ')')
|
|
1646
|
+
pos++;
|
|
1647
|
+
const dateStr = str.slice(yearStart, dayStart + 2);
|
|
1648
|
+
const date = slashToHyphen(dateStr);
|
|
1649
|
+
return { date, length: pos };
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Replaces forward slashes with hyphens (ReDoS-safe).
|
|
1653
|
+
*
|
|
1654
|
+
* @param input - The input string
|
|
1655
|
+
* @returns String with forward slashes replaced by hyphens
|
|
1656
|
+
*/
|
|
1657
|
+
function slashToHyphen(input) {
|
|
1658
|
+
const result = [];
|
|
1659
|
+
for (let i = 0; i < input.length; i++) {
|
|
1660
|
+
result.push(input[i] === '/' ? '-' : input[i]);
|
|
1661
|
+
}
|
|
1662
|
+
return result.join('');
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Extracts a markdown link from a string.
|
|
1666
|
+
*
|
|
1667
|
+
* @param str - The string to extract a link from
|
|
1668
|
+
* @returns The extracted link text, url, and length, or null if no link found
|
|
1669
|
+
*/
|
|
1670
|
+
function extractLink(str) {
|
|
1671
|
+
if (str[0] !== '[')
|
|
1672
|
+
return null;
|
|
1673
|
+
let pos = 1;
|
|
1674
|
+
let depth = 1;
|
|
1675
|
+
// Find closing ]
|
|
1676
|
+
while (pos < str.length && depth > 0) {
|
|
1677
|
+
if (str[pos] === '[')
|
|
1678
|
+
depth++;
|
|
1679
|
+
else if (str[pos] === ']')
|
|
1680
|
+
depth--;
|
|
1681
|
+
pos++;
|
|
1682
|
+
}
|
|
1683
|
+
if (depth !== 0)
|
|
1684
|
+
return null;
|
|
1685
|
+
const text = str.slice(1, pos - 1);
|
|
1686
|
+
// Expect (
|
|
1687
|
+
if (str[pos] !== '(')
|
|
1688
|
+
return null;
|
|
1689
|
+
pos++;
|
|
1690
|
+
const urlStart = pos;
|
|
1691
|
+
depth = 1;
|
|
1692
|
+
while (pos < str.length && depth > 0) {
|
|
1693
|
+
if (str[pos] === '(')
|
|
1694
|
+
depth++;
|
|
1695
|
+
else if (str[pos] === ')')
|
|
1696
|
+
depth--;
|
|
1697
|
+
pos++;
|
|
1698
|
+
}
|
|
1699
|
+
if (depth !== 0)
|
|
1700
|
+
return null;
|
|
1701
|
+
const url = str.slice(urlStart, pos - 1);
|
|
1702
|
+
return { text, url, length: pos };
|
|
1703
|
+
}
|
|
1704
|
+
/**
|
|
1705
|
+
* Parses commit references from a line.
|
|
1706
|
+
* Examples: (abc1234), [abc1234], commit abc1234
|
|
1707
|
+
*
|
|
1708
|
+
* @param text - The text to parse for commit references
|
|
1709
|
+
* @param baseUrl - Optional base URL for constructing commit links
|
|
1710
|
+
* @returns An array of parsed CommitRef objects
|
|
1711
|
+
*/
|
|
1712
|
+
function parseCommitRefs(text, baseUrl) {
|
|
1713
|
+
const refs = [];
|
|
1714
|
+
let pos = 0;
|
|
1715
|
+
while (pos < text.length) {
|
|
1716
|
+
// Look for potential hash patterns
|
|
1717
|
+
// Common formats: (abc1234), [abc1234], abc1234fabcdef
|
|
1718
|
+
// Check for parenthetical hash
|
|
1719
|
+
if (text[pos] === '(' || text[pos] === '[') {
|
|
1720
|
+
const closeChar = text[pos] === '(' ? ')' : ']';
|
|
1721
|
+
const start = pos + 1;
|
|
1722
|
+
pos++;
|
|
1723
|
+
// Read potential hash
|
|
1724
|
+
while (pos < text.length && isHexDigit(text[pos])) {
|
|
1725
|
+
pos++;
|
|
1726
|
+
}
|
|
1727
|
+
// Check if valid hash (7-40 hex chars)
|
|
1728
|
+
const hash = text.slice(start, pos);
|
|
1729
|
+
if (hash.length >= 7 && hash.length <= 40 && text[pos] === closeChar) {
|
|
1730
|
+
refs.push({
|
|
1731
|
+
hash,
|
|
1732
|
+
shortHash: hash.slice(0, 7),
|
|
1733
|
+
url: baseUrl ? `${baseUrl}/commit/${hash}` : undefined,
|
|
1734
|
+
});
|
|
1735
|
+
pos++; // skip closing bracket
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
pos++;
|
|
1740
|
+
}
|
|
1741
|
+
return refs;
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* Parses issue/PR references from a line.
|
|
1745
|
+
* Examples: #123, GH-123, closes #123
|
|
1746
|
+
*
|
|
1747
|
+
* @param text - The text to parse for issue references
|
|
1748
|
+
* @param baseUrl - Optional base URL for constructing issue links
|
|
1749
|
+
* @returns An array of parsed IssueRef objects
|
|
1750
|
+
*/
|
|
1751
|
+
function parseIssueRefs(text, baseUrl) {
|
|
1752
|
+
const refs = [];
|
|
1753
|
+
let pos = 0;
|
|
1754
|
+
while (pos < text.length) {
|
|
1755
|
+
// Look for # followed by digits
|
|
1756
|
+
if (text[pos] === '#') {
|
|
1757
|
+
pos++;
|
|
1758
|
+
const numStart = pos;
|
|
1759
|
+
while (pos < text.length && isDigitChar(text[pos])) {
|
|
1760
|
+
pos++;
|
|
1761
|
+
}
|
|
1762
|
+
if (pos > numStart) {
|
|
1763
|
+
const number = parseInt(text.slice(numStart, pos), 10);
|
|
1764
|
+
// Check context for PR vs issue
|
|
1765
|
+
const beforeHash = text.slice(max(0, numStart - 10), numStart - 1).toLowerCase();
|
|
1766
|
+
const type = beforeHash.includes('pr') || beforeHash.includes('pull') ? 'pull-request' : 'issue';
|
|
1767
|
+
refs.push({
|
|
1768
|
+
number,
|
|
1769
|
+
type,
|
|
1770
|
+
url: baseUrl ? `${baseUrl}/issues/${number}` : undefined,
|
|
1771
|
+
});
|
|
1772
|
+
continue;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
pos++;
|
|
1776
|
+
}
|
|
1777
|
+
return refs;
|
|
1778
|
+
}
|
|
1779
|
+
/**
|
|
1780
|
+
* Parses the scope from a changelog item.
|
|
1781
|
+
* Example: "**scope:** description" -> { scope: "scope", description: "description" }
|
|
1782
|
+
*
|
|
1783
|
+
* @param text - The text to parse for scope
|
|
1784
|
+
* @returns An object with optional scope and the description
|
|
1785
|
+
*/
|
|
1786
|
+
function parseScopeFromItem(text) {
|
|
1787
|
+
const trimmed = text.trim();
|
|
1788
|
+
// Check for **scope:** pattern (colon inside or outside bold)
|
|
1789
|
+
if (trimmed.startsWith('**')) {
|
|
1790
|
+
let pos = 2;
|
|
1791
|
+
const scopeStart = pos;
|
|
1792
|
+
// Read until ** or :
|
|
1793
|
+
while (pos < trimmed.length && trimmed[pos] !== '*' && trimmed[pos] !== ':') {
|
|
1794
|
+
pos++;
|
|
1795
|
+
}
|
|
1796
|
+
// Handle **scope:** pattern (colon before closing **)
|
|
1797
|
+
if (trimmed[pos] === ':' && trimmed[pos + 1] === '*' && trimmed[pos + 2] === '*') {
|
|
1798
|
+
const scope = trimmed.slice(scopeStart, pos);
|
|
1799
|
+
pos += 3; // skip :**
|
|
1800
|
+
// Skip whitespace
|
|
1801
|
+
while (trimmed[pos] === ' ')
|
|
1802
|
+
pos++;
|
|
1803
|
+
return { scope, description: trimmed.slice(pos) };
|
|
1804
|
+
}
|
|
1805
|
+
// Handle **scope**: pattern (colon after closing **)
|
|
1806
|
+
if (trimmed[pos] === '*' && trimmed[pos + 1] === '*') {
|
|
1807
|
+
const scope = trimmed.slice(scopeStart, pos);
|
|
1808
|
+
pos += 2; // skip **
|
|
1809
|
+
// Skip : if present
|
|
1810
|
+
if (trimmed[pos] === ':')
|
|
1811
|
+
pos++;
|
|
1812
|
+
// Skip whitespace
|
|
1813
|
+
while (trimmed[pos] === ' ')
|
|
1814
|
+
pos++;
|
|
1815
|
+
return { scope, description: trimmed.slice(pos) };
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
// Check for scope: pattern (without bold)
|
|
1819
|
+
const colonPos = trimmed.indexOf(':');
|
|
1820
|
+
if (colonPos > 0 && colonPos < 30) {
|
|
1821
|
+
// scope shouldn't be too long
|
|
1822
|
+
const potentialScope = trimmed.slice(0, colonPos);
|
|
1823
|
+
// Scope should be a simple identifier (letters, numbers, hyphens)
|
|
1824
|
+
if (isValidScope(potentialScope)) {
|
|
1825
|
+
const description = trimmed.slice(colonPos + 1).trim();
|
|
1826
|
+
return { scope: potentialScope, description };
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
return { description: trimmed };
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Checks if a string is a valid scope (alphanumeric with hyphens).
|
|
1833
|
+
*
|
|
1834
|
+
* @param str - The string to check
|
|
1835
|
+
* @returns True if the string is a valid scope identifier
|
|
1836
|
+
*/
|
|
1837
|
+
function isValidScope(str) {
|
|
1838
|
+
if (!str || str.length === 0)
|
|
1839
|
+
return false;
|
|
1840
|
+
for (let i = 0; i < str.length; i++) {
|
|
1841
|
+
const code = str.charCodeAt(i);
|
|
1842
|
+
if (!(code >= 48 && code <= 57) && // 0-9
|
|
1843
|
+
!(code >= 65 && code <= 90) && // A-Z
|
|
1844
|
+
!(code >= 97 && code <= 122) && // a-z
|
|
1845
|
+
code !== 45 // -
|
|
1846
|
+
) {
|
|
1847
|
+
return false;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
return true;
|
|
1851
|
+
}
|
|
1852
|
+
/**
|
|
1853
|
+
* Checks if a character is a hex digit.
|
|
1854
|
+
*
|
|
1855
|
+
* @param char - The character to check
|
|
1856
|
+
* @returns True if the character is a hex digit (0-9, A-F, a-f)
|
|
1857
|
+
*/
|
|
1858
|
+
function isHexDigit(char) {
|
|
1859
|
+
const code = char.charCodeAt(0);
|
|
1860
|
+
return ((code >= 48 && code <= 57) || // 0-9
|
|
1861
|
+
(code >= 65 && code <= 70) || // A-F
|
|
1862
|
+
(code >= 97 && code <= 102) // a-f
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
/**
|
|
1866
|
+
* Checks if a character is a digit.
|
|
1867
|
+
*
|
|
1868
|
+
* @param char - The character to check
|
|
1869
|
+
* @returns True if the character is a digit (0-9)
|
|
1870
|
+
*/
|
|
1871
|
+
function isDigitChar(char) {
|
|
1872
|
+
const code = char.charCodeAt(0);
|
|
1873
|
+
return code >= 48 && code <= 57;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
/**
|
|
1877
|
+
* Changelog Tokenizer
|
|
1878
|
+
*
|
|
1879
|
+
* A state machine tokenizer that processes markdown character-by-character.
|
|
1880
|
+
* No regex is used to ensure ReDoS safety.
|
|
1881
|
+
*/
|
|
1882
|
+
/**
|
|
1883
|
+
* Maximum input length to prevent memory exhaustion (1MB)
|
|
1884
|
+
*/
|
|
1885
|
+
const MAX_INPUT_LENGTH = 1024 * 1024;
|
|
1886
|
+
/**
|
|
1887
|
+
* Tokenizes a changelog markdown string into tokens.
|
|
1888
|
+
*
|
|
1889
|
+
* @param input - The markdown content to tokenize
|
|
1890
|
+
* @returns Array of tokens
|
|
1891
|
+
* @throws {Error} If input exceeds maximum length
|
|
1892
|
+
*/
|
|
1893
|
+
function tokenize(input) {
|
|
1894
|
+
if (input.length > MAX_INPUT_LENGTH) {
|
|
1895
|
+
throw createError(`Input exceeds maximum length of ${MAX_INPUT_LENGTH} characters`);
|
|
1896
|
+
}
|
|
1897
|
+
const state = {
|
|
1898
|
+
pos: 0,
|
|
1899
|
+
line: 1,
|
|
1900
|
+
column: 1,
|
|
1901
|
+
input,
|
|
1902
|
+
tokens: [],
|
|
1903
|
+
};
|
|
1904
|
+
while (state.pos < state.input.length) {
|
|
1905
|
+
const char = state.input[state.pos];
|
|
1906
|
+
// Check for newline
|
|
1907
|
+
if (char === '\n') {
|
|
1908
|
+
consumeNewline(state);
|
|
1909
|
+
continue;
|
|
1910
|
+
}
|
|
1911
|
+
// Check for carriage return (handle \r\n)
|
|
1912
|
+
if (char === '\r') {
|
|
1913
|
+
state.pos++;
|
|
1914
|
+
if (state.input[state.pos] === '\n') {
|
|
1915
|
+
consumeNewline(state);
|
|
1916
|
+
}
|
|
1917
|
+
continue;
|
|
1918
|
+
}
|
|
1919
|
+
// At start of line, check for special markers
|
|
1920
|
+
if (state.column === 1) {
|
|
1921
|
+
// Check for heading
|
|
1922
|
+
if (char === '#') {
|
|
1923
|
+
consumeHeading(state);
|
|
1924
|
+
continue;
|
|
1925
|
+
}
|
|
1926
|
+
// Check for list item
|
|
1927
|
+
if ((char === '-' || char === '*') && isWhitespace(state.input[state.pos + 1])) {
|
|
1928
|
+
consumeListItem(state);
|
|
1929
|
+
continue;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
// Check for inline markdown elements
|
|
1933
|
+
if (char === '[') {
|
|
1934
|
+
consumeLink(state);
|
|
1935
|
+
continue;
|
|
1936
|
+
}
|
|
1937
|
+
if (char === '`') {
|
|
1938
|
+
consumeCode(state);
|
|
1939
|
+
continue;
|
|
1940
|
+
}
|
|
1941
|
+
if (char === '*' && state.input[state.pos + 1] === '*') {
|
|
1942
|
+
consumeBold(state);
|
|
1943
|
+
continue;
|
|
1944
|
+
}
|
|
1945
|
+
// Default: consume as text
|
|
1946
|
+
consumeText(state);
|
|
1947
|
+
}
|
|
1948
|
+
// Add EOF token
|
|
1949
|
+
pushToken(state, 'eof', '');
|
|
1950
|
+
return state.tokens;
|
|
1951
|
+
}
|
|
1952
|
+
/**
|
|
1953
|
+
* Consumes a newline character.
|
|
1954
|
+
*
|
|
1955
|
+
* @param state - The tokenizer state to update
|
|
1956
|
+
*/
|
|
1957
|
+
function consumeNewline(state) {
|
|
1958
|
+
// Check if this is a blank line (previous token was also newline or at start)
|
|
1959
|
+
const prevToken = state.tokens[state.tokens.length - 1];
|
|
1960
|
+
const isBlank = prevToken?.type === 'newline' || prevToken?.type === 'blank-line' || state.tokens.length === 0;
|
|
1961
|
+
pushToken(state, isBlank ? 'blank-line' : 'newline', '\n');
|
|
1962
|
+
state.pos++;
|
|
1963
|
+
state.line++;
|
|
1964
|
+
state.column = 1;
|
|
1965
|
+
}
|
|
1966
|
+
/**
|
|
1967
|
+
* Consumes a heading (# through ####).
|
|
1968
|
+
*
|
|
1969
|
+
* @param state - The tokenizer state to update
|
|
1970
|
+
*/
|
|
1971
|
+
function consumeHeading(state) {
|
|
1972
|
+
const startColumn = state.column;
|
|
1973
|
+
let level = 0;
|
|
1974
|
+
// Count # characters
|
|
1975
|
+
while (state.input[state.pos] === '#' && level < 4) {
|
|
1976
|
+
state.pos++;
|
|
1977
|
+
state.column++;
|
|
1978
|
+
level++;
|
|
1979
|
+
}
|
|
1980
|
+
// Skip whitespace after #
|
|
1981
|
+
while (isWhitespace(state.input[state.pos]) && state.input[state.pos] !== '\n') {
|
|
1982
|
+
state.pos++;
|
|
1983
|
+
state.column++;
|
|
1984
|
+
}
|
|
1985
|
+
// Consume the rest of the line as heading content
|
|
1986
|
+
const contentStart = state.pos;
|
|
1987
|
+
while (state.pos < state.input.length && state.input[state.pos] !== '\n') {
|
|
1988
|
+
state.pos++;
|
|
1989
|
+
state.column++;
|
|
1990
|
+
}
|
|
1991
|
+
const content = state.input.slice(contentStart, state.pos);
|
|
1992
|
+
const type = `heading-${level}`;
|
|
1993
|
+
state.tokens.push({
|
|
1994
|
+
type,
|
|
1995
|
+
value: content.trim(),
|
|
1996
|
+
line: state.line,
|
|
1997
|
+
column: startColumn,
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
/**
|
|
2001
|
+
* Consumes a list item (- or *).
|
|
2002
|
+
*
|
|
2003
|
+
* @param state - The tokenizer state to update
|
|
2004
|
+
*/
|
|
2005
|
+
function consumeListItem(state) {
|
|
2006
|
+
const startColumn = state.column;
|
|
2007
|
+
// Skip the marker and whitespace
|
|
2008
|
+
state.pos++; // skip - or *
|
|
2009
|
+
state.column++;
|
|
2010
|
+
while (isWhitespace(state.input[state.pos]) && state.input[state.pos] !== '\n') {
|
|
2011
|
+
state.pos++;
|
|
2012
|
+
state.column++;
|
|
2013
|
+
}
|
|
2014
|
+
// Consume the rest of the line as list item content
|
|
2015
|
+
const contentStart = state.pos;
|
|
2016
|
+
while (state.pos < state.input.length && state.input[state.pos] !== '\n') {
|
|
2017
|
+
state.pos++;
|
|
2018
|
+
state.column++;
|
|
2019
|
+
}
|
|
2020
|
+
const content = state.input.slice(contentStart, state.pos);
|
|
2021
|
+
state.tokens.push({
|
|
2022
|
+
type: 'list-item',
|
|
2023
|
+
value: content,
|
|
2024
|
+
line: state.line,
|
|
2025
|
+
column: startColumn,
|
|
2026
|
+
});
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Consumes a markdown link [text](url).
|
|
2030
|
+
*
|
|
2031
|
+
* @param state - The tokenizer state to update
|
|
2032
|
+
*/
|
|
2033
|
+
function consumeLink(state) {
|
|
2034
|
+
const startColumn = state.column;
|
|
2035
|
+
const startLine = state.line;
|
|
2036
|
+
// Skip opening [
|
|
2037
|
+
state.pos++;
|
|
2038
|
+
state.column++;
|
|
2039
|
+
// Find closing ]
|
|
2040
|
+
const textStart = state.pos;
|
|
2041
|
+
let depth = 1;
|
|
2042
|
+
while (state.pos < state.input.length && depth > 0) {
|
|
2043
|
+
const char = state.input[state.pos];
|
|
2044
|
+
if (char === '[')
|
|
2045
|
+
depth++;
|
|
2046
|
+
else if (char === ']')
|
|
2047
|
+
depth--;
|
|
2048
|
+
else if (char === '\n') {
|
|
2049
|
+
// Link text shouldn't span lines; emit '[' as text and reset
|
|
2050
|
+
state.tokens.push({
|
|
2051
|
+
type: 'text',
|
|
2052
|
+
value: '[',
|
|
2053
|
+
line: startLine,
|
|
2054
|
+
column: startColumn,
|
|
2055
|
+
});
|
|
2056
|
+
state.pos = textStart;
|
|
2057
|
+
state.column = startColumn + 1;
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
if (depth > 0) {
|
|
2061
|
+
state.pos++;
|
|
2062
|
+
state.column++;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
if (depth !== 0) {
|
|
2066
|
+
// No closing ], emit '[' as text and reset
|
|
2067
|
+
state.tokens.push({
|
|
2068
|
+
type: 'text',
|
|
2069
|
+
value: '[',
|
|
2070
|
+
line: startLine,
|
|
2071
|
+
column: startColumn,
|
|
2072
|
+
});
|
|
2073
|
+
state.pos = textStart;
|
|
2074
|
+
state.column = startColumn + 1;
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
const linkText = state.input.slice(textStart, state.pos);
|
|
2078
|
+
state.pos++; // skip ]
|
|
2079
|
+
state.column++;
|
|
2080
|
+
// Check for (url)
|
|
2081
|
+
if (state.input[state.pos] === '(') {
|
|
2082
|
+
state.pos++; // skip (
|
|
2083
|
+
state.column++;
|
|
2084
|
+
const urlStart = state.pos;
|
|
2085
|
+
depth = 1;
|
|
2086
|
+
while (state.pos < state.input.length && depth > 0) {
|
|
2087
|
+
const char = state.input[state.pos];
|
|
2088
|
+
if (char === '(')
|
|
2089
|
+
depth++;
|
|
2090
|
+
else if (char === ')')
|
|
2091
|
+
depth--;
|
|
2092
|
+
else if (char === '\n') {
|
|
2093
|
+
// URL shouldn't span lines
|
|
2094
|
+
break;
|
|
2095
|
+
}
|
|
2096
|
+
if (depth > 0) {
|
|
2097
|
+
state.pos++;
|
|
2098
|
+
state.column++;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
if (depth === 0) {
|
|
2102
|
+
const linkUrl = state.input.slice(urlStart, state.pos);
|
|
2103
|
+
state.pos++; // skip )
|
|
2104
|
+
state.column++;
|
|
2105
|
+
// Emit link-text token
|
|
2106
|
+
state.tokens.push({
|
|
2107
|
+
type: 'link-text',
|
|
2108
|
+
value: linkText,
|
|
2109
|
+
line: startLine,
|
|
2110
|
+
column: startColumn,
|
|
2111
|
+
});
|
|
2112
|
+
// Emit link-url token
|
|
2113
|
+
state.tokens.push({
|
|
2114
|
+
type: 'link-url',
|
|
2115
|
+
value: linkUrl,
|
|
2116
|
+
line: startLine,
|
|
2117
|
+
column: startColumn + linkText.length + 3,
|
|
2118
|
+
});
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
// No URL part, emit as text
|
|
2123
|
+
state.tokens.push({
|
|
2124
|
+
type: 'text',
|
|
2125
|
+
value: `[${linkText}]`,
|
|
2126
|
+
line: startLine,
|
|
2127
|
+
column: startColumn,
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
/**
|
|
2131
|
+
* Consumes a code span `code`.
|
|
2132
|
+
*
|
|
2133
|
+
* @param state - The tokenizer state to update
|
|
2134
|
+
*/
|
|
2135
|
+
function consumeCode(state) {
|
|
2136
|
+
const startColumn = state.column;
|
|
2137
|
+
const startLine = state.line;
|
|
2138
|
+
state.pos++; // skip opening `
|
|
2139
|
+
state.column++;
|
|
2140
|
+
const contentStart = state.pos;
|
|
2141
|
+
while (state.pos < state.input.length && state.input[state.pos] !== '`' && state.input[state.pos] !== '\n') {
|
|
2142
|
+
state.pos++;
|
|
2143
|
+
state.column++;
|
|
2144
|
+
}
|
|
2145
|
+
if (state.input[state.pos] === '`') {
|
|
2146
|
+
const content = state.input.slice(contentStart, state.pos);
|
|
2147
|
+
state.pos++; // skip closing `
|
|
2148
|
+
state.column++;
|
|
2149
|
+
state.tokens.push({
|
|
2150
|
+
type: 'code',
|
|
2151
|
+
value: content,
|
|
2152
|
+
line: startLine,
|
|
2153
|
+
column: startColumn,
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
else {
|
|
2157
|
+
// No closing `, emit as text
|
|
2158
|
+
state.tokens.push({
|
|
2159
|
+
type: 'text',
|
|
2160
|
+
value: '`' + state.input.slice(contentStart, state.pos),
|
|
2161
|
+
line: startLine,
|
|
2162
|
+
column: startColumn,
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2167
|
+
* Consumes bold text **text**.
|
|
2168
|
+
*
|
|
2169
|
+
* @param state - The tokenizer state to update
|
|
2170
|
+
*/
|
|
2171
|
+
function consumeBold(state) {
|
|
2172
|
+
const startColumn = state.column;
|
|
2173
|
+
const startLine = state.line;
|
|
2174
|
+
state.pos += 2; // skip opening **
|
|
2175
|
+
state.column += 2;
|
|
2176
|
+
const contentStart = state.pos;
|
|
2177
|
+
while (state.pos < state.input.length) {
|
|
2178
|
+
// Check for closing **
|
|
2179
|
+
if (state.input[state.pos] === '*' && state.input[state.pos + 1] === '*') {
|
|
2180
|
+
break;
|
|
2181
|
+
}
|
|
2182
|
+
if (state.input[state.pos] === '\n') {
|
|
2183
|
+
state.line++;
|
|
2184
|
+
state.column = 0;
|
|
2185
|
+
}
|
|
2186
|
+
state.pos++;
|
|
2187
|
+
state.column++;
|
|
2188
|
+
}
|
|
2189
|
+
if (state.input[state.pos] === '*' && state.input[state.pos + 1] === '*') {
|
|
2190
|
+
const content = state.input.slice(contentStart, state.pos);
|
|
2191
|
+
state.pos += 2; // skip closing **
|
|
2192
|
+
state.column += 2;
|
|
2193
|
+
state.tokens.push({
|
|
2194
|
+
type: 'bold',
|
|
2195
|
+
value: content,
|
|
2196
|
+
line: startLine,
|
|
2197
|
+
column: startColumn,
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
else {
|
|
2201
|
+
// No closing **, emit as text
|
|
2202
|
+
state.tokens.push({
|
|
2203
|
+
type: 'text',
|
|
2204
|
+
value: '**' + state.input.slice(contentStart, state.pos),
|
|
2205
|
+
line: startLine,
|
|
2206
|
+
column: startColumn,
|
|
2207
|
+
});
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
/**
|
|
2211
|
+
* Consumes plain text until a special character or end of line.
|
|
2212
|
+
*
|
|
2213
|
+
* @param state - The tokenizer state to update
|
|
2214
|
+
*/
|
|
2215
|
+
function consumeText(state) {
|
|
2216
|
+
const startColumn = state.column;
|
|
2217
|
+
const startLine = state.line;
|
|
2218
|
+
const startPos = state.pos;
|
|
2219
|
+
while (state.pos < state.input.length) {
|
|
2220
|
+
const char = state.input[state.pos];
|
|
2221
|
+
// Stop at newline
|
|
2222
|
+
if (char === '\n' || char === '\r') {
|
|
2223
|
+
break;
|
|
2224
|
+
}
|
|
2225
|
+
// Stop at special characters (but only if they start a pattern)
|
|
2226
|
+
if (char === '[')
|
|
2227
|
+
break;
|
|
2228
|
+
if (char === '`')
|
|
2229
|
+
break;
|
|
2230
|
+
if (char === '*' && state.input[state.pos + 1] === '*')
|
|
2231
|
+
break;
|
|
2232
|
+
state.pos++;
|
|
2233
|
+
state.column++;
|
|
2234
|
+
}
|
|
2235
|
+
const content = state.input.slice(startPos, state.pos);
|
|
2236
|
+
if (content.length > 0) {
|
|
2237
|
+
state.tokens.push({
|
|
2238
|
+
type: 'text',
|
|
2239
|
+
value: content,
|
|
2240
|
+
line: startLine,
|
|
2241
|
+
column: startColumn,
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* Pushes a token to the state.
|
|
2247
|
+
*
|
|
2248
|
+
* @param state - The tokenizer state to update
|
|
2249
|
+
* @param type - Token classification (e.g., 'heading-1', 'text', 'link-url')
|
|
2250
|
+
* @param value - Text content associated with the token
|
|
2251
|
+
*/
|
|
2252
|
+
function pushToken(state, type, value) {
|
|
2253
|
+
state.tokens.push({
|
|
2254
|
+
type,
|
|
2255
|
+
value,
|
|
2256
|
+
line: state.line,
|
|
2257
|
+
column: state.column,
|
|
2258
|
+
});
|
|
2259
|
+
}
|
|
2260
|
+
/**
|
|
2261
|
+
* Checks if a character is whitespace (but not newline).
|
|
2262
|
+
*
|
|
2263
|
+
* @param char - The character to check
|
|
2264
|
+
* @returns True if the character is whitespace (space or tab)
|
|
2265
|
+
*/
|
|
2266
|
+
function isWhitespace(char) {
|
|
2267
|
+
return char === ' ' || char === '\t';
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
/**
|
|
2271
|
+
* Changelog Parser
|
|
2272
|
+
*
|
|
2273
|
+
* Parses a changelog markdown string into a structured Changelog object.
|
|
2274
|
+
* Uses a state machine tokenizer for ReDoS-safe parsing.
|
|
2275
|
+
*/
|
|
2276
|
+
/**
|
|
2277
|
+
* Parses a changelog markdown string into a Changelog object.
|
|
2278
|
+
*
|
|
2279
|
+
* @param content - The markdown content to parse
|
|
2280
|
+
* @param source - Optional source file path
|
|
2281
|
+
* @returns Parsed Changelog object
|
|
2282
|
+
*/
|
|
2283
|
+
function parseChangelog(content, source) {
|
|
2284
|
+
const tokens = tokenize(content);
|
|
2285
|
+
const state = {
|
|
2286
|
+
tokens,
|
|
2287
|
+
pos: 0,
|
|
2288
|
+
warnings: [],
|
|
2289
|
+
};
|
|
2290
|
+
// Parse header
|
|
2291
|
+
const header = parseHeader(state);
|
|
2292
|
+
// Parse entries
|
|
2293
|
+
const entries = parseEntries(state);
|
|
2294
|
+
// Detect format
|
|
2295
|
+
const format = detectFormat(header, entries);
|
|
2296
|
+
// Build metadata
|
|
2297
|
+
const metadata = {
|
|
2298
|
+
format,
|
|
2299
|
+
isConventional: format === 'conventional',
|
|
2300
|
+
repositoryUrl: state.repositoryUrl,
|
|
2301
|
+
warnings: state.warnings,
|
|
2302
|
+
};
|
|
2303
|
+
return {
|
|
2304
|
+
source,
|
|
2305
|
+
header,
|
|
2306
|
+
entries,
|
|
2307
|
+
metadata,
|
|
2308
|
+
};
|
|
2309
|
+
}
|
|
2310
|
+
/**
|
|
2311
|
+
* Parses the changelog header section.
|
|
2312
|
+
*
|
|
2313
|
+
* @param state - The parser state containing tokens and position
|
|
2314
|
+
* @returns The parsed ChangelogHeader with title, description, and links
|
|
2315
|
+
*/
|
|
2316
|
+
function parseHeader(state) {
|
|
2317
|
+
let title = '# Changelog';
|
|
2318
|
+
const description = [];
|
|
2319
|
+
const links = [];
|
|
2320
|
+
// Look for h1 title
|
|
2321
|
+
const headingToken = currentToken(state);
|
|
2322
|
+
if (headingToken?.type === 'heading-1') {
|
|
2323
|
+
title = `# ${headingToken.value}`;
|
|
2324
|
+
advance(state);
|
|
2325
|
+
}
|
|
2326
|
+
// Skip newlines
|
|
2327
|
+
skipNewlines(state);
|
|
2328
|
+
// Collect description lines until we hit h2 (version entry)
|
|
2329
|
+
while (!isEOF(state) && currentToken(state)?.type !== 'heading-2') {
|
|
2330
|
+
const token = currentToken(state);
|
|
2331
|
+
if (!token)
|
|
2332
|
+
break;
|
|
2333
|
+
if (token.type === 'text') {
|
|
2334
|
+
description.push(token.value);
|
|
2335
|
+
}
|
|
2336
|
+
else if (token.type === 'link-text') {
|
|
2337
|
+
// Check for link definition
|
|
2338
|
+
const nextToken = peek(state, 1);
|
|
2339
|
+
if (nextToken?.type === 'link-url') {
|
|
2340
|
+
description.push(`[${token.value}](${nextToken.value})`);
|
|
2341
|
+
links.push({ label: token.value, url: nextToken.value });
|
|
2342
|
+
// Try to detect repository URL
|
|
2343
|
+
if (!state.repositoryUrl && nextToken.value.includes('github.com')) {
|
|
2344
|
+
state.repositoryUrl = extractRepoUrl(nextToken.value);
|
|
2345
|
+
}
|
|
2346
|
+
advance(state); // skip link-text
|
|
2347
|
+
advance(state); // skip link-url
|
|
2348
|
+
continue;
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
else if (token.type === 'newline' || token.type === 'blank-line') {
|
|
2352
|
+
if (description.length > 0 && description[description.length - 1] !== '') {
|
|
2353
|
+
description.push('');
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
advance(state);
|
|
2357
|
+
}
|
|
2358
|
+
// Trim trailing empty lines
|
|
2359
|
+
while (description.length > 0 && description[description.length - 1] === '') {
|
|
2360
|
+
description.pop();
|
|
2361
|
+
}
|
|
2362
|
+
return { title, description, links };
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* Parses all changelog entries.
|
|
2366
|
+
*
|
|
2367
|
+
* @param state - The parser state containing tokens and position
|
|
2368
|
+
* @returns An array of parsed ChangelogEntry objects
|
|
2369
|
+
*/
|
|
2370
|
+
function parseEntries(state) {
|
|
2371
|
+
const entries = [];
|
|
2372
|
+
while (!isEOF(state)) {
|
|
2373
|
+
// Look for h2 heading (version entry)
|
|
2374
|
+
if (currentToken(state)?.type === 'heading-2') {
|
|
2375
|
+
const entry = parseEntry(state);
|
|
2376
|
+
if (entry) {
|
|
2377
|
+
entries.push(entry);
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
else {
|
|
2381
|
+
advance(state);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
return entries;
|
|
2385
|
+
}
|
|
2386
|
+
/**
|
|
2387
|
+
* Parses a single changelog entry.
|
|
2388
|
+
*
|
|
2389
|
+
* @param state - The parser state containing tokens and position
|
|
2390
|
+
* @returns The parsed ChangelogEntry or null if parsing fails
|
|
2391
|
+
*/
|
|
2392
|
+
function parseEntry(state) {
|
|
2393
|
+
const headingToken = currentToken(state);
|
|
2394
|
+
if (headingToken?.type !== 'heading-2') {
|
|
2395
|
+
return null;
|
|
2396
|
+
}
|
|
2397
|
+
const { version, date, compareUrl } = parseVersionFromHeading(headingToken.value);
|
|
2398
|
+
const unreleased = version.toLowerCase() === 'unreleased';
|
|
2399
|
+
advance(state); // skip h2
|
|
2400
|
+
skipNewlines(state);
|
|
2401
|
+
// Parse sections
|
|
2402
|
+
const sections = parseSections(state);
|
|
2403
|
+
return {
|
|
2404
|
+
version,
|
|
2405
|
+
date,
|
|
2406
|
+
unreleased,
|
|
2407
|
+
compareUrl,
|
|
2408
|
+
sections,
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
/**
|
|
2412
|
+
* Parses sections within an entry.
|
|
2413
|
+
*
|
|
2414
|
+
* @param state - The parser state containing tokens and position
|
|
2415
|
+
* @returns An array of parsed ChangelogSection objects
|
|
2416
|
+
*/
|
|
2417
|
+
function parseSections(state) {
|
|
2418
|
+
const sections = [];
|
|
2419
|
+
while (!isEOF(state)) {
|
|
2420
|
+
const token = currentToken(state);
|
|
2421
|
+
// Stop at next version entry (h2)
|
|
2422
|
+
if (token?.type === 'heading-2') {
|
|
2423
|
+
break;
|
|
2424
|
+
}
|
|
2425
|
+
// Parse section (h3)
|
|
2426
|
+
if (token?.type === 'heading-3') {
|
|
2427
|
+
const section = parseSection(state);
|
|
2428
|
+
if (section) {
|
|
2429
|
+
sections.push(section);
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
else if (token?.type === 'list-item') {
|
|
2433
|
+
// Items without section heading - create "other" section
|
|
2434
|
+
const items = parseItems(state);
|
|
2435
|
+
if (items.length > 0) {
|
|
2436
|
+
sections.push({
|
|
2437
|
+
type: 'other',
|
|
2438
|
+
heading: 'Changes',
|
|
2439
|
+
items,
|
|
2440
|
+
});
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
else {
|
|
2444
|
+
advance(state);
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
return sections;
|
|
2448
|
+
}
|
|
2449
|
+
/**
|
|
2450
|
+
* Parses a single section.
|
|
2451
|
+
*
|
|
2452
|
+
* @param state - The parser state containing tokens and position
|
|
2453
|
+
* @returns The parsed ChangelogSection or null if parsing fails
|
|
2454
|
+
*/
|
|
2455
|
+
function parseSection(state) {
|
|
2456
|
+
const headingToken = currentToken(state);
|
|
2457
|
+
if (headingToken?.type !== 'heading-3') {
|
|
2458
|
+
return null;
|
|
2459
|
+
}
|
|
2460
|
+
const heading = headingToken.value;
|
|
2461
|
+
const type = getSectionType(heading);
|
|
2462
|
+
advance(state); // skip h3
|
|
2463
|
+
skipNewlines(state);
|
|
2464
|
+
// Parse items
|
|
2465
|
+
const items = parseItems(state);
|
|
2466
|
+
return {
|
|
2467
|
+
type,
|
|
2468
|
+
heading,
|
|
2469
|
+
items,
|
|
2470
|
+
};
|
|
2471
|
+
}
|
|
2472
|
+
/**
|
|
2473
|
+
* Parses list items.
|
|
2474
|
+
*
|
|
2475
|
+
* @param state - The parser state containing tokens and position
|
|
2476
|
+
* @returns An array of parsed ChangelogItem objects
|
|
2477
|
+
*/
|
|
2478
|
+
function parseItems(state) {
|
|
2479
|
+
const items = [];
|
|
2480
|
+
while (!isEOF(state)) {
|
|
2481
|
+
const token = currentToken(state);
|
|
2482
|
+
// Stop at headings
|
|
2483
|
+
if (token?.type === 'heading-2' || token?.type === 'heading-3') {
|
|
2484
|
+
break;
|
|
2485
|
+
}
|
|
2486
|
+
// Parse list item
|
|
2487
|
+
if (token?.type === 'list-item') {
|
|
2488
|
+
const item = parseItem(state);
|
|
2489
|
+
if (item) {
|
|
2490
|
+
items.push(item);
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
else {
|
|
2494
|
+
advance(state);
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
return items;
|
|
2498
|
+
}
|
|
2499
|
+
/**
|
|
2500
|
+
* Parses a single list item.
|
|
2501
|
+
*
|
|
2502
|
+
* @param state - The parser state containing tokens and position
|
|
2503
|
+
* @returns The parsed ChangelogItem or null if parsing fails
|
|
2504
|
+
*/
|
|
2505
|
+
function parseItem(state) {
|
|
2506
|
+
const token = currentToken(state);
|
|
2507
|
+
if (token?.type !== 'list-item') {
|
|
2508
|
+
return null;
|
|
2509
|
+
}
|
|
2510
|
+
const text = token.value;
|
|
2511
|
+
const { scope, description } = parseScopeFromItem(text);
|
|
2512
|
+
const commits = parseCommitRefs(text, state.repositoryUrl);
|
|
2513
|
+
const references = parseIssueRefs(text, state.repositoryUrl);
|
|
2514
|
+
// Check for breaking change indicators
|
|
2515
|
+
const breaking = isBreakingItem(text);
|
|
2516
|
+
advance(state);
|
|
2517
|
+
return createChangelogItem(description, {
|
|
2518
|
+
scope,
|
|
2519
|
+
commits,
|
|
2520
|
+
references,
|
|
2521
|
+
breaking,
|
|
2522
|
+
});
|
|
2523
|
+
}
|
|
2524
|
+
/**
|
|
2525
|
+
* Checks if an item indicates a breaking change.
|
|
2526
|
+
*
|
|
2527
|
+
* @param text - The text content of the item
|
|
2528
|
+
* @returns True if the item indicates a breaking change
|
|
2529
|
+
*/
|
|
2530
|
+
function isBreakingItem(text) {
|
|
2531
|
+
const lower = text.toLowerCase();
|
|
2532
|
+
return lower.includes('breaking change') || lower.includes('breaking:') || lower.startsWith('!') || lower.includes('[breaking]');
|
|
2533
|
+
}
|
|
2534
|
+
/**
|
|
2535
|
+
* Detects the changelog format.
|
|
2536
|
+
*
|
|
2537
|
+
* @param header - The parsed header of the changelog
|
|
2538
|
+
* @param entries - The parsed changelog entries
|
|
2539
|
+
* @returns The detected ChangelogFormat
|
|
2540
|
+
*/
|
|
2541
|
+
function detectFormat(header, entries) {
|
|
2542
|
+
const descriptionText = header.description.join(' ').toLowerCase();
|
|
2543
|
+
// Check for Keep a Changelog
|
|
2544
|
+
if (descriptionText.includes('keep a changelog') || descriptionText.includes('keepachangelog')) {
|
|
2545
|
+
return 'keep-a-changelog';
|
|
2546
|
+
}
|
|
2547
|
+
// Check for conventional changelog patterns
|
|
2548
|
+
const hasConventionalSections = entries.some((entry) => entry.sections.some((section) => ['features', 'fixes', 'performance'].includes(section.type)));
|
|
2549
|
+
if (hasConventionalSections) {
|
|
2550
|
+
return 'conventional';
|
|
2551
|
+
}
|
|
2552
|
+
// Check if we have entries with structured sections
|
|
2553
|
+
if (entries.some((entry) => entry.sections.length > 0)) {
|
|
2554
|
+
return 'custom';
|
|
2555
|
+
}
|
|
2556
|
+
return 'unknown';
|
|
2557
|
+
}
|
|
2558
|
+
/**
|
|
2559
|
+
* Extracts repository URL from a GitHub URL.
|
|
2560
|
+
*
|
|
2561
|
+
* @param url - The URL to extract the repository from
|
|
2562
|
+
* @returns The repository URL or undefined if not found
|
|
2563
|
+
*/
|
|
2564
|
+
function extractRepoUrl(url) {
|
|
2565
|
+
// Try to extract base repo URL from various GitHub URL patterns
|
|
2566
|
+
const githubIndex = url.indexOf('github.com/');
|
|
2567
|
+
if (githubIndex !== -1) {
|
|
2568
|
+
const afterGithub = url.slice(githubIndex + 11);
|
|
2569
|
+
const parts = afterGithub.split('/');
|
|
2570
|
+
if (parts.length >= 2) {
|
|
2571
|
+
return `https://github.com/${parts[0]}/${parts[1]}`;
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
return undefined;
|
|
2575
|
+
}
|
|
2576
|
+
// ============================================================================
|
|
2577
|
+
// Parser utilities
|
|
2578
|
+
// ============================================================================
|
|
2579
|
+
/**
|
|
2580
|
+
* Gets the current token at the parser position.
|
|
2581
|
+
*
|
|
2582
|
+
* @param state - The parser state
|
|
2583
|
+
* @returns The current token or undefined if at end
|
|
2584
|
+
*/
|
|
2585
|
+
function currentToken(state) {
|
|
2586
|
+
return state.tokens[state.pos];
|
|
2587
|
+
}
|
|
2588
|
+
/**
|
|
2589
|
+
* Peeks at a token at an offset from the current position.
|
|
2590
|
+
*
|
|
2591
|
+
* @param state - The parser state
|
|
2592
|
+
* @param offset - The offset from current position
|
|
2593
|
+
* @returns The token at the offset or undefined if out of bounds
|
|
2594
|
+
*/
|
|
2595
|
+
function peek(state, offset) {
|
|
2596
|
+
return state.tokens[state.pos + offset];
|
|
2597
|
+
}
|
|
2598
|
+
/**
|
|
2599
|
+
* Advances the parser position by one token.
|
|
2600
|
+
*
|
|
2601
|
+
* @param state - The parser state to advance
|
|
2602
|
+
*/
|
|
2603
|
+
function advance(state) {
|
|
2604
|
+
state.pos++;
|
|
2605
|
+
}
|
|
2606
|
+
/**
|
|
2607
|
+
* Checks if the parser has reached the end of the token stream.
|
|
2608
|
+
*
|
|
2609
|
+
* @param state - The parser state
|
|
2610
|
+
* @returns True if at end of file
|
|
2611
|
+
*/
|
|
2612
|
+
function isEOF(state) {
|
|
2613
|
+
const token = currentToken(state);
|
|
2614
|
+
return !token || token.type === 'eof';
|
|
2615
|
+
}
|
|
2616
|
+
/**
|
|
2617
|
+
* Skips newline tokens until a non-newline token is found.
|
|
2618
|
+
*
|
|
2619
|
+
* @param state - The parser state
|
|
2620
|
+
*/
|
|
2621
|
+
function skipNewlines(state) {
|
|
2622
|
+
while (!isEOF(state)) {
|
|
2623
|
+
const token = currentToken(state);
|
|
2624
|
+
if (token?.type !== 'newline' && token?.type !== 'blank-line') {
|
|
2625
|
+
break;
|
|
2626
|
+
}
|
|
2627
|
+
advance(state);
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
/**
|
|
2632
|
+
* Changelog Serialization Templates
|
|
2633
|
+
*
|
|
2634
|
+
* Output templates and formatting helpers for changelog serialization.
|
|
2635
|
+
* Provides configurable options for different changelog styles.
|
|
2636
|
+
*/
|
|
2637
|
+
/**
|
|
2638
|
+
* Default serialization options.
|
|
2639
|
+
*/
|
|
2640
|
+
const DEFAULT_SERIALIZE_OPTIONS = {
|
|
2641
|
+
includeDescription: true,
|
|
2642
|
+
includeLinks: true,
|
|
2643
|
+
includeCompareUrls: true,
|
|
2644
|
+
includeCommits: true,
|
|
2645
|
+
includeReferences: true,
|
|
2646
|
+
includeScope: true,
|
|
2647
|
+
includeRawContent: false,
|
|
2648
|
+
sectionOrder: [
|
|
2649
|
+
'breaking',
|
|
2650
|
+
'features',
|
|
2651
|
+
'fixes',
|
|
2652
|
+
'performance',
|
|
2653
|
+
'documentation',
|
|
2654
|
+
'deprecations',
|
|
2655
|
+
'refactoring',
|
|
2656
|
+
'tests',
|
|
2657
|
+
'build',
|
|
2658
|
+
'ci',
|
|
2659
|
+
'chores',
|
|
2660
|
+
'other',
|
|
2661
|
+
],
|
|
2662
|
+
sectionHeadings: {},
|
|
2663
|
+
lineEnding: '\n',
|
|
2664
|
+
entrySpacing: 1,
|
|
2665
|
+
sectionSpacing: 1,
|
|
2666
|
+
useAsterisks: false,
|
|
2667
|
+
};
|
|
2668
|
+
/**
|
|
2669
|
+
* Merges user options with defaults.
|
|
2670
|
+
*
|
|
2671
|
+
* @param options - User-provided options
|
|
2672
|
+
* @returns Complete options with defaults applied
|
|
2673
|
+
*/
|
|
2674
|
+
function resolveOptions(options) {
|
|
2675
|
+
{
|
|
2676
|
+
return DEFAULT_SERIALIZE_OPTIONS;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
/**
|
|
2680
|
+
* Gets the heading for a section type, respecting custom headings.
|
|
2681
|
+
*
|
|
2682
|
+
* @param type - The changelog section type to get the heading for
|
|
2683
|
+
* @param customHeadings - Optional map of custom section type to heading overrides
|
|
2684
|
+
* @returns The heading string to use
|
|
2685
|
+
*/
|
|
2686
|
+
function getSectionHeading(type, customHeadings) {
|
|
2687
|
+
return customHeadings?.[type] ?? SECTION_HEADINGS[type];
|
|
2688
|
+
}
|
|
2689
|
+
/**
|
|
2690
|
+
* Creates a markdown link.
|
|
2691
|
+
*
|
|
2692
|
+
* @param text - The display text for the link
|
|
2693
|
+
* @param url - The destination URL for the link
|
|
2694
|
+
* @returns Formatted markdown link
|
|
2695
|
+
*/
|
|
2696
|
+
function formatLink(text, url) {
|
|
2697
|
+
return `[${text}](${url})`;
|
|
2698
|
+
}
|
|
2699
|
+
/**
|
|
2700
|
+
* Creates a list item marker.
|
|
2701
|
+
*
|
|
2702
|
+
* @param useAsterisks - Whether to use * instead of -
|
|
2703
|
+
* @returns The list item marker ('- ' or '* ')
|
|
2704
|
+
*/
|
|
2705
|
+
function getListMarker(useAsterisks) {
|
|
2706
|
+
return useAsterisks ? '* ' : '- ';
|
|
2707
|
+
}
|
|
2708
|
+
/**
|
|
2709
|
+
* Creates blank lines for spacing.
|
|
2710
|
+
*
|
|
2711
|
+
* @param count - Number of blank lines
|
|
2712
|
+
* @param lineEnding - Line ending style
|
|
2713
|
+
* @returns String with specified number of blank lines
|
|
2714
|
+
*/
|
|
2715
|
+
function createSpacing(count, lineEnding) {
|
|
2716
|
+
if (count <= 0)
|
|
2717
|
+
return '';
|
|
2718
|
+
return lineEnding.repeat(count);
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
/**
|
|
2722
|
+
* Changelog Serialization to String
|
|
2723
|
+
*
|
|
2724
|
+
* Converts a Changelog object back to markdown format.
|
|
2725
|
+
* Supports configurable output formatting for different changelog styles.
|
|
2726
|
+
*/
|
|
2727
|
+
/**
|
|
2728
|
+
* Serializes a Changelog object to markdown string.
|
|
2729
|
+
*
|
|
2730
|
+
* @param changelog - The changelog to serialize
|
|
2731
|
+
* @param options - Optional serialization options
|
|
2732
|
+
* @returns The markdown string representation
|
|
2733
|
+
*
|
|
2734
|
+
* @example
|
|
2735
|
+
* ```ts
|
|
2736
|
+
* const markdown = serializeChangelog(changelog)
|
|
2737
|
+
* ```
|
|
2738
|
+
*
|
|
2739
|
+
* @example
|
|
2740
|
+
* ```ts
|
|
2741
|
+
* const markdown = serializeChangelog(changelog, {
|
|
2742
|
+
* includeCommits: false,
|
|
2743
|
+
* useAsterisks: true,
|
|
2744
|
+
* })
|
|
2745
|
+
* ```
|
|
2746
|
+
*/
|
|
2747
|
+
function serializeChangelog(changelog, options) {
|
|
2748
|
+
const opts = resolveOptions();
|
|
2749
|
+
const parts = [];
|
|
2750
|
+
// Serialize header
|
|
2751
|
+
parts.push(serializeHeader(changelog.header, opts));
|
|
2752
|
+
// Serialize entries
|
|
2753
|
+
for (let i = 0; i < changelog.entries.length; i++) {
|
|
2754
|
+
const entry = changelog.entries[i];
|
|
2755
|
+
parts.push(serializeEntry(entry, opts));
|
|
2756
|
+
// Add spacing between entries (except after the last one)
|
|
2757
|
+
if (i < changelog.entries.length - 1) {
|
|
2758
|
+
parts.push(createSpacing(opts.entrySpacing, opts.lineEnding));
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
return parts.join('');
|
|
2762
|
+
}
|
|
2763
|
+
/**
|
|
2764
|
+
* Serializes the changelog header.
|
|
2765
|
+
*
|
|
2766
|
+
* @param header - The header to serialize
|
|
2767
|
+
* @param opts - Resolved options
|
|
2768
|
+
* @returns The serialized header string
|
|
2769
|
+
*/
|
|
2770
|
+
function serializeHeader(header, opts) {
|
|
2771
|
+
const parts = [];
|
|
2772
|
+
const nl = opts.lineEnding;
|
|
2773
|
+
// Title
|
|
2774
|
+
parts.push(header.title + nl);
|
|
2775
|
+
parts.push(nl);
|
|
2776
|
+
// Description
|
|
2777
|
+
if (opts.includeDescription && header.description.length > 0) {
|
|
2778
|
+
for (const line of header.description) {
|
|
2779
|
+
parts.push(line + nl);
|
|
2780
|
+
}
|
|
2781
|
+
parts.push(nl);
|
|
2782
|
+
}
|
|
2783
|
+
// Links section
|
|
2784
|
+
if (opts.includeLinks && header.links.length > 0) {
|
|
2785
|
+
for (const link of header.links) {
|
|
2786
|
+
parts.push(serializeLink(link) + nl);
|
|
2787
|
+
}
|
|
2788
|
+
parts.push(nl);
|
|
2789
|
+
}
|
|
2790
|
+
return parts.join('');
|
|
2791
|
+
}
|
|
2792
|
+
/**
|
|
2793
|
+
* Serializes a changelog link.
|
|
2794
|
+
*
|
|
2795
|
+
* @param link - The link to serialize
|
|
2796
|
+
* @returns The serialized link
|
|
2797
|
+
*/
|
|
2798
|
+
function serializeLink(link) {
|
|
2799
|
+
return `[${link.label}]: ${link.url}`;
|
|
2800
|
+
}
|
|
2801
|
+
/**
|
|
2802
|
+
* Serializes a changelog entry.
|
|
2803
|
+
*
|
|
2804
|
+
* @param entry - The entry to serialize
|
|
2805
|
+
* @param opts - Resolved options
|
|
2806
|
+
* @returns The serialized entry string
|
|
2807
|
+
*/
|
|
2808
|
+
function serializeEntry(entry, opts) {
|
|
2809
|
+
const parts = [];
|
|
2810
|
+
const nl = opts.lineEnding;
|
|
2811
|
+
// Entry heading
|
|
2812
|
+
parts.push(serializeEntryHeading(entry, opts) + nl);
|
|
2813
|
+
parts.push(nl);
|
|
2814
|
+
// Raw content fallback
|
|
2815
|
+
if (opts.includeRawContent && entry.rawContent) {
|
|
2816
|
+
parts.push(entry.rawContent + nl);
|
|
2817
|
+
return parts.join('');
|
|
2818
|
+
}
|
|
2819
|
+
// Sort sections by specified order
|
|
2820
|
+
const sortedSections = sortSections(entry.sections, opts.sectionOrder);
|
|
2821
|
+
// Serialize sections
|
|
2822
|
+
for (let i = 0; i < sortedSections.length; i++) {
|
|
2823
|
+
const section = sortedSections[i];
|
|
2824
|
+
parts.push(serializeSection(section, opts));
|
|
2825
|
+
// Add spacing between sections (except after the last one)
|
|
2826
|
+
if (i < sortedSections.length - 1) {
|
|
2827
|
+
parts.push(createSpacing(opts.sectionSpacing, nl));
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
return parts.join('');
|
|
2831
|
+
}
|
|
2832
|
+
/**
|
|
2833
|
+
* Serializes an entry heading.
|
|
2834
|
+
*
|
|
2835
|
+
* @param entry - The changelog entry to serialize the heading for
|
|
2836
|
+
* @param opts - Resolved serialization options
|
|
2837
|
+
* @returns The heading line
|
|
2838
|
+
*/
|
|
2839
|
+
function serializeEntryHeading(entry, opts) {
|
|
2840
|
+
const parts = ['## '];
|
|
2841
|
+
// Version with optional compare URL
|
|
2842
|
+
if (opts.includeCompareUrls && entry.compareUrl) {
|
|
2843
|
+
parts.push(formatLink(entry.version, entry.compareUrl));
|
|
2844
|
+
}
|
|
2845
|
+
else {
|
|
2846
|
+
parts.push(entry.version);
|
|
2847
|
+
}
|
|
2848
|
+
// Date
|
|
2849
|
+
if (entry.date) {
|
|
2850
|
+
parts.push(` - ${entry.date}`);
|
|
2851
|
+
}
|
|
2852
|
+
return parts.join('');
|
|
2853
|
+
}
|
|
2854
|
+
/**
|
|
2855
|
+
* Sorts sections by the specified order.
|
|
2856
|
+
*
|
|
2857
|
+
* @param sections - The sections to sort
|
|
2858
|
+
* @param order - The desired section order
|
|
2859
|
+
* @returns Sorted sections
|
|
2860
|
+
*/
|
|
2861
|
+
function sortSections(sections, order) {
|
|
2862
|
+
const orderMap = createMap();
|
|
2863
|
+
order.forEach((type, index) => orderMap.set(type, index));
|
|
2864
|
+
return [...sections].sort((a, b) => {
|
|
2865
|
+
const orderA = orderMap.get(a.type) ?? Number.MAX_SAFE_INTEGER;
|
|
2866
|
+
const orderB = orderMap.get(b.type) ?? Number.MAX_SAFE_INTEGER;
|
|
2867
|
+
return orderA - orderB;
|
|
2868
|
+
});
|
|
2869
|
+
}
|
|
2870
|
+
/**
|
|
2871
|
+
* Serializes a changelog section.
|
|
2872
|
+
*
|
|
2873
|
+
* @param section - The section to serialize
|
|
2874
|
+
* @param opts - Resolved options
|
|
2875
|
+
* @returns The serialized section string
|
|
2876
|
+
*/
|
|
2877
|
+
function serializeSection(section, opts) {
|
|
2878
|
+
const parts = [];
|
|
2879
|
+
const nl = opts.lineEnding;
|
|
2880
|
+
// Section heading - use original heading if available, otherwise generate
|
|
2881
|
+
const heading = section.heading || getSectionHeading(section.type, opts.sectionHeadings);
|
|
2882
|
+
parts.push(`### ${heading}${nl}`);
|
|
2883
|
+
parts.push(nl);
|
|
2884
|
+
// Items
|
|
2885
|
+
for (const item of section.items) {
|
|
2886
|
+
parts.push(serializeItem(item, opts) + nl);
|
|
2887
|
+
}
|
|
2888
|
+
return parts.join('');
|
|
2889
|
+
}
|
|
2890
|
+
/**
|
|
2891
|
+
* Serializes a changelog item.
|
|
2892
|
+
*
|
|
2893
|
+
* @param item - The item to serialize
|
|
2894
|
+
* @param opts - Resolved options
|
|
2895
|
+
* @returns The serialized item string
|
|
2896
|
+
*/
|
|
2897
|
+
function serializeItem(item, opts) {
|
|
2898
|
+
const parts = [];
|
|
2899
|
+
const marker = getListMarker(opts.useAsterisks);
|
|
2900
|
+
parts.push(marker);
|
|
2901
|
+
// Breaking change indicator
|
|
2902
|
+
if (item.breaking) {
|
|
2903
|
+
parts.push('**BREAKING** ');
|
|
2904
|
+
}
|
|
2905
|
+
// Scope
|
|
2906
|
+
if (opts.includeScope && item.scope) {
|
|
2907
|
+
parts.push(`**${item.scope}:** `);
|
|
2908
|
+
}
|
|
2909
|
+
// Description
|
|
2910
|
+
parts.push(item.description);
|
|
2911
|
+
// Commit references
|
|
2912
|
+
if (opts.includeCommits && item.commits.length > 0) {
|
|
2913
|
+
parts.push(' (');
|
|
2914
|
+
parts.push(item.commits.map(serializeCommitRef).join(', '));
|
|
2915
|
+
parts.push(')');
|
|
2916
|
+
}
|
|
2917
|
+
// Issue/PR references
|
|
2918
|
+
if (opts.includeReferences && item.references.length > 0) {
|
|
2919
|
+
const refs = item.references.map(serializeIssueRef).join(', ');
|
|
2920
|
+
parts.push(` ${refs}`);
|
|
2921
|
+
}
|
|
2922
|
+
return parts.join('');
|
|
2923
|
+
}
|
|
2924
|
+
/**
|
|
2925
|
+
* Serializes a commit reference.
|
|
2926
|
+
*
|
|
2927
|
+
* @param ref - The commit reference
|
|
2928
|
+
* @returns The serialized reference
|
|
2929
|
+
*/
|
|
2930
|
+
function serializeCommitRef(ref) {
|
|
2931
|
+
if (ref.url) {
|
|
2932
|
+
return formatLink(ref.shortHash, ref.url);
|
|
2933
|
+
}
|
|
2934
|
+
return ref.shortHash;
|
|
2935
|
+
}
|
|
2936
|
+
/**
|
|
2937
|
+
* Serializes an issue/PR reference.
|
|
2938
|
+
*
|
|
2939
|
+
* @param ref - The issue reference
|
|
2940
|
+
* @returns The serialized reference
|
|
2941
|
+
*/
|
|
2942
|
+
function serializeIssueRef(ref) {
|
|
2943
|
+
const text = `#${ref.number}`;
|
|
2944
|
+
if (ref.url) {
|
|
2945
|
+
return formatLink(text, ref.url);
|
|
2946
|
+
}
|
|
2947
|
+
return text;
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
/**
|
|
2951
|
+
* Changelog Entry Addition
|
|
2952
|
+
*
|
|
2953
|
+
* Functions for adding new entries to a changelog.
|
|
2954
|
+
*/
|
|
2955
|
+
/**
|
|
2956
|
+
* Adds a new entry to a changelog.
|
|
2957
|
+
*
|
|
2958
|
+
* @param changelog - The changelog to add to
|
|
2959
|
+
* @param entry - The entry to add
|
|
2960
|
+
* @param options - Optional add options
|
|
2961
|
+
* @returns A new changelog with the entry added
|
|
2962
|
+
*
|
|
2963
|
+
* @example
|
|
2964
|
+
* ```ts
|
|
2965
|
+
* const newChangelog = addEntry(changelog, {
|
|
2966
|
+
* version: '1.2.0',
|
|
2967
|
+
* date: '2024-01-15',
|
|
2968
|
+
* unreleased: false,
|
|
2969
|
+
* sections: [...]
|
|
2970
|
+
* })
|
|
2971
|
+
* ```
|
|
2972
|
+
*/
|
|
2973
|
+
function addEntry(changelog, entry, options) {
|
|
2974
|
+
// Check for existing entry
|
|
2975
|
+
const existingIndex = changelog.entries.findIndex((e) => e.version === entry.version);
|
|
2976
|
+
if (existingIndex !== -1 && true) {
|
|
2977
|
+
throw createError(`Entry with version "${entry.version}" already exists. Use replaceExisting: true to replace.`);
|
|
2978
|
+
}
|
|
2979
|
+
let newEntries;
|
|
2980
|
+
{
|
|
2981
|
+
// Add new entry
|
|
2982
|
+
const insertIndex = 0 ;
|
|
2983
|
+
newEntries = [...changelog.entries];
|
|
2984
|
+
newEntries.splice(insertIndex, 0, entry);
|
|
2985
|
+
}
|
|
2986
|
+
// Build new metadata if requested
|
|
2987
|
+
const metadata = changelog.metadata;
|
|
2988
|
+
return {
|
|
2989
|
+
...changelog,
|
|
2990
|
+
entries: newEntries,
|
|
2991
|
+
metadata,
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
const GENERATE_CHANGELOG_STEP_ID = 'generate-changelog';
|
|
2996
|
+
/**
|
|
2997
|
+
* Maps conventional commit types to changelog section types.
|
|
2998
|
+
*/
|
|
2999
|
+
const COMMIT_TYPE_TO_SECTION = {
|
|
3000
|
+
feat: 'features',
|
|
3001
|
+
fix: 'fixes',
|
|
3002
|
+
perf: 'performance',
|
|
3003
|
+
docs: 'documentation',
|
|
3004
|
+
refactor: 'refactoring',
|
|
3005
|
+
revert: 'other',
|
|
3006
|
+
build: 'build',
|
|
3007
|
+
ci: 'ci',
|
|
3008
|
+
test: 'tests',
|
|
3009
|
+
chore: 'chores',
|
|
3010
|
+
style: 'other',
|
|
3011
|
+
};
|
|
3012
|
+
/**
|
|
3013
|
+
* Groups commits by their section type.
|
|
3014
|
+
*
|
|
3015
|
+
* @param commits - Array of conventional commits
|
|
3016
|
+
* @returns Record of section type to commits
|
|
3017
|
+
*/
|
|
3018
|
+
function groupCommitsBySection(commits) {
|
|
3019
|
+
const groups = {};
|
|
3020
|
+
for (const commit of commits) {
|
|
3021
|
+
const sectionType = COMMIT_TYPE_TO_SECTION[commit.type ?? 'chore'] ?? 'chores';
|
|
3022
|
+
if (!groups[sectionType]) {
|
|
3023
|
+
groups[sectionType] = [];
|
|
3024
|
+
}
|
|
3025
|
+
groups[sectionType].push(commit);
|
|
3026
|
+
}
|
|
3027
|
+
return groups;
|
|
3028
|
+
}
|
|
3029
|
+
/**
|
|
3030
|
+
* Creates a changelog item from a conventional commit.
|
|
3031
|
+
*
|
|
3032
|
+
* @param commit - The conventional commit
|
|
3033
|
+
* @returns A changelog item
|
|
3034
|
+
*/
|
|
3035
|
+
function commitToItem(commit) {
|
|
3036
|
+
let text = commit.subject;
|
|
3037
|
+
// Add scope prefix if present
|
|
3038
|
+
if (commit.scope) {
|
|
3039
|
+
text = `**${commit.scope}:** ${text}`;
|
|
3040
|
+
}
|
|
3041
|
+
// Add breaking change indicator
|
|
3042
|
+
if (commit.breaking) {
|
|
3043
|
+
text = `⚠️ BREAKING: ${text}`;
|
|
3044
|
+
}
|
|
3045
|
+
return createChangelogItem(text);
|
|
3046
|
+
}
|
|
3047
|
+
/**
|
|
3048
|
+
* Creates the generate-changelog step.
|
|
3049
|
+
*
|
|
3050
|
+
* This step:
|
|
3051
|
+
* 1. Groups commits by type/section
|
|
3052
|
+
* 2. Creates changelog items from commits
|
|
3053
|
+
* 3. Assembles a complete changelog entry
|
|
3054
|
+
*
|
|
3055
|
+
* State updates:
|
|
3056
|
+
* - changelogEntry: The generated ChangelogEntry
|
|
3057
|
+
*
|
|
3058
|
+
* @returns A FlowStep that generates changelog
|
|
3059
|
+
*/
|
|
3060
|
+
function createGenerateChangelogStep() {
|
|
3061
|
+
return createStep(GENERATE_CHANGELOG_STEP_ID, 'Generate Changelog Entry', async (ctx) => {
|
|
3062
|
+
const { config, state } = ctx;
|
|
3063
|
+
const { commits, nextVersion, bumpType } = state;
|
|
3064
|
+
// Skip if no bump needed
|
|
3065
|
+
if (!nextVersion || bumpType === 'none') {
|
|
3066
|
+
return createSkippedResult('No version bump, skipping changelog generation');
|
|
3067
|
+
}
|
|
3068
|
+
// Skip if changelog disabled
|
|
3069
|
+
if (config.skipChangelog) {
|
|
3070
|
+
return createSkippedResult('Changelog generation disabled');
|
|
3071
|
+
}
|
|
3072
|
+
// Handle case with no commits (e.g., first release)
|
|
3073
|
+
if (!commits || commits.length === 0) {
|
|
3074
|
+
const entry = createChangelogEntry(nextVersion, {
|
|
3075
|
+
date: createDate().toISOString().split('T')[0],
|
|
3076
|
+
sections: [createChangelogSection('features', 'Features', [createChangelogItem('Initial release')])],
|
|
3077
|
+
});
|
|
3078
|
+
return {
|
|
3079
|
+
status: 'success',
|
|
3080
|
+
stateUpdates: { changelogEntry: entry },
|
|
3081
|
+
message: 'Generated initial release changelog entry',
|
|
3082
|
+
};
|
|
3083
|
+
}
|
|
3084
|
+
// Group commits by section
|
|
3085
|
+
const grouped = groupCommitsBySection(commits);
|
|
3086
|
+
// Create sections
|
|
3087
|
+
const sections = [];
|
|
3088
|
+
// Add breaking changes section first if any
|
|
3089
|
+
const breakingCommits = commits.filter((c) => c.breaking);
|
|
3090
|
+
if (breakingCommits.length > 0) {
|
|
3091
|
+
sections.push(createChangelogSection('breaking', 'Breaking Changes', breakingCommits.map((c) => {
|
|
3092
|
+
const text = c.breakingDescription ?? c.subject;
|
|
3093
|
+
return createChangelogItem(c.scope ? `**${c.scope}:** ${text}` : text);
|
|
3094
|
+
})));
|
|
3095
|
+
}
|
|
3096
|
+
// Add other sections in conventional order
|
|
3097
|
+
const sectionOrder = [
|
|
3098
|
+
{ type: 'features', heading: 'Features' },
|
|
3099
|
+
{ type: 'fixes', heading: 'Bug Fixes' },
|
|
3100
|
+
{ type: 'performance', heading: 'Performance' },
|
|
3101
|
+
{ type: 'documentation', heading: 'Documentation' },
|
|
3102
|
+
{ type: 'refactoring', heading: 'Code Refactoring' },
|
|
3103
|
+
{ type: 'build', heading: 'Build' },
|
|
3104
|
+
{ type: 'ci', heading: 'Continuous Integration' },
|
|
3105
|
+
{ type: 'tests', heading: 'Tests' },
|
|
3106
|
+
{ type: 'chores', heading: 'Chores' },
|
|
3107
|
+
{ type: 'other', heading: 'Other' },
|
|
3108
|
+
];
|
|
3109
|
+
for (const { type: sectionType, heading } of sectionOrder) {
|
|
3110
|
+
const sectionCommits = grouped[sectionType];
|
|
3111
|
+
if (sectionCommits && sectionCommits.length > 0) {
|
|
3112
|
+
sections.push(createChangelogSection(sectionType, heading, sectionCommits.map(commitToItem)));
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
// Create the entry
|
|
3116
|
+
const entry = createChangelogEntry(nextVersion, {
|
|
3117
|
+
date: createDate().toISOString().split('T')[0],
|
|
3118
|
+
sections,
|
|
3119
|
+
});
|
|
3120
|
+
return {
|
|
3121
|
+
status: 'success',
|
|
3122
|
+
stateUpdates: { changelogEntry: entry },
|
|
3123
|
+
message: `Generated changelog with ${sections.length} section(s), ${commits.length} commit(s)`,
|
|
3124
|
+
};
|
|
3125
|
+
}, {
|
|
3126
|
+
dependsOn: ['check-idempotency'],
|
|
3127
|
+
});
|
|
3128
|
+
}
|
|
3129
|
+
/**
|
|
3130
|
+
* Creates the write-changelog step.
|
|
3131
|
+
*
|
|
3132
|
+
* This step writes the generated changelog entry to CHANGELOG.md.
|
|
3133
|
+
*
|
|
3134
|
+
* @returns A FlowStep that writes changelog to file
|
|
3135
|
+
*/
|
|
3136
|
+
function createWriteChangelogStep() {
|
|
3137
|
+
return createStep('write-changelog', 'Write Changelog', async (ctx) => {
|
|
3138
|
+
const { tree, projectRoot, config, state, logger } = ctx;
|
|
3139
|
+
const { changelogEntry, nextVersion, bumpType } = state;
|
|
3140
|
+
// Skip if no bump or no changelog
|
|
3141
|
+
if (!nextVersion || bumpType === 'none' || !changelogEntry || config.skipChangelog) {
|
|
3142
|
+
return createSkippedResult('No changelog to write');
|
|
3143
|
+
}
|
|
3144
|
+
const changelogPath = `${projectRoot}/CHANGELOG.md`;
|
|
3145
|
+
let existingContent = '';
|
|
3146
|
+
// Read existing changelog
|
|
3147
|
+
try {
|
|
3148
|
+
existingContent = tree.read(changelogPath, 'utf-8') ?? '';
|
|
3149
|
+
}
|
|
3150
|
+
catch {
|
|
3151
|
+
logger.debug('No existing CHANGELOG.md found');
|
|
3152
|
+
}
|
|
3153
|
+
// If no existing content, create new changelog
|
|
3154
|
+
if (!existingContent.trim()) {
|
|
3155
|
+
const newChangelog = {
|
|
3156
|
+
header: {
|
|
3157
|
+
title: '# Changelog',
|
|
3158
|
+
description: ['All notable changes to this project will be documented in this file.'],
|
|
3159
|
+
links: [],
|
|
3160
|
+
},
|
|
3161
|
+
entries: [changelogEntry]};
|
|
3162
|
+
const serialized = serializeChangelog(newChangelog);
|
|
3163
|
+
tree.write(changelogPath, serialized);
|
|
3164
|
+
return {
|
|
3165
|
+
status: 'success',
|
|
3166
|
+
stateUpdates: {
|
|
3167
|
+
modifiedFiles: [...(state.modifiedFiles ?? []), changelogPath],
|
|
3168
|
+
},
|
|
3169
|
+
message: `Created CHANGELOG.md with version ${nextVersion}`,
|
|
3170
|
+
};
|
|
3171
|
+
}
|
|
3172
|
+
// Parse existing and add entry
|
|
3173
|
+
const existing = parseChangelog(existingContent);
|
|
3174
|
+
const updated = addEntry(existing, changelogEntry);
|
|
3175
|
+
const serialized = serializeChangelog(updated);
|
|
3176
|
+
tree.write(changelogPath, serialized);
|
|
3177
|
+
return {
|
|
3178
|
+
status: 'success',
|
|
3179
|
+
stateUpdates: {
|
|
3180
|
+
modifiedFiles: [...(state.modifiedFiles ?? []), changelogPath],
|
|
3181
|
+
},
|
|
3182
|
+
message: `Updated CHANGELOG.md with version ${nextVersion}`,
|
|
3183
|
+
};
|
|
3184
|
+
}, {
|
|
3185
|
+
dependsOn: ['generate-changelog'],
|
|
3186
|
+
});
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
const UPDATE_PACKAGES_STEP_ID = 'update-packages';
|
|
3190
|
+
/**
|
|
3191
|
+
* Creates the update-packages step.
|
|
3192
|
+
*
|
|
3193
|
+
* This step:
|
|
3194
|
+
* 1. Updates the version field in package.json
|
|
3195
|
+
* 2. Tracks the modified files
|
|
3196
|
+
*
|
|
3197
|
+
* State updates:
|
|
3198
|
+
* - modifiedFiles: Adds package.json to list
|
|
3199
|
+
*
|
|
3200
|
+
* @returns A FlowStep that updates package.json
|
|
3201
|
+
*/
|
|
3202
|
+
function createUpdatePackageStep() {
|
|
3203
|
+
return createStep(UPDATE_PACKAGES_STEP_ID, 'Update Package Version', async (ctx) => {
|
|
3204
|
+
const { tree, projectRoot, state, logger } = ctx;
|
|
3205
|
+
const { nextVersion, bumpType, currentVersion } = state;
|
|
3206
|
+
// Skip if no bump needed
|
|
3207
|
+
if (!nextVersion || bumpType === 'none') {
|
|
3208
|
+
return createSkippedResult('No version bump needed');
|
|
3209
|
+
}
|
|
3210
|
+
const packageJsonPath = `${projectRoot}/package.json`;
|
|
3211
|
+
// Read package.json
|
|
3212
|
+
let content;
|
|
3213
|
+
try {
|
|
3214
|
+
content = tree.read(packageJsonPath, 'utf-8') ?? '';
|
|
3215
|
+
if (!content) {
|
|
3216
|
+
return {
|
|
3217
|
+
status: 'failed',
|
|
3218
|
+
error: createError('package.json not found'),
|
|
3219
|
+
message: 'Could not read package.json',
|
|
3220
|
+
};
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3223
|
+
catch (error) {
|
|
3224
|
+
return {
|
|
3225
|
+
status: 'failed',
|
|
3226
|
+
error: error instanceof Error ? error : createError(String(error)),
|
|
3227
|
+
message: 'Failed to read package.json',
|
|
3228
|
+
};
|
|
3229
|
+
}
|
|
3230
|
+
// Parse and update version
|
|
3231
|
+
let pkg;
|
|
3232
|
+
try {
|
|
3233
|
+
pkg = parse(content);
|
|
3234
|
+
}
|
|
3235
|
+
catch (error) {
|
|
3236
|
+
return {
|
|
3237
|
+
status: 'failed',
|
|
3238
|
+
error: error instanceof Error ? error : createError(String(error)),
|
|
3239
|
+
message: 'Failed to parse package.json',
|
|
3240
|
+
};
|
|
3241
|
+
}
|
|
3242
|
+
pkg['version'] = nextVersion;
|
|
3243
|
+
// Write back with preserved formatting
|
|
3244
|
+
const updated = stringify(pkg, null, 2) + '\n';
|
|
3245
|
+
tree.write(packageJsonPath, updated);
|
|
3246
|
+
logger.info(`Updated package.json: ${currentVersion} → ${nextVersion}`);
|
|
3247
|
+
return {
|
|
3248
|
+
status: 'success',
|
|
3249
|
+
stateUpdates: {
|
|
3250
|
+
modifiedFiles: [...(state.modifiedFiles ?? []), packageJsonPath],
|
|
3251
|
+
},
|
|
3252
|
+
message: `Updated version to ${nextVersion}`,
|
|
3253
|
+
};
|
|
3254
|
+
}, {
|
|
3255
|
+
dependsOn: ['calculate-bump'],
|
|
3256
|
+
});
|
|
3257
|
+
}
|
|
3258
|
+
/**
|
|
3259
|
+
* Creates a step that updates dependent packages in a monorepo.
|
|
3260
|
+
*
|
|
3261
|
+
* This step cascades version updates to packages that depend
|
|
3262
|
+
* on the updated package.
|
|
3263
|
+
*
|
|
3264
|
+
* @returns A FlowStep that cascades dependency updates
|
|
3265
|
+
*/
|
|
3266
|
+
function createCascadeDependenciesStep() {
|
|
3267
|
+
return createStep('cascade-dependencies', 'Cascade Dependency Updates', async (ctx) => {
|
|
3268
|
+
const { config, state, logger } = ctx;
|
|
3269
|
+
const { nextVersion, bumpType } = state;
|
|
3270
|
+
// Skip if dependency tracking not enabled
|
|
3271
|
+
if (!config.trackDeps) {
|
|
3272
|
+
return createSkippedResult('Dependency tracking not enabled');
|
|
3273
|
+
}
|
|
3274
|
+
// Skip if no bump needed
|
|
3275
|
+
if (!nextVersion || bumpType === 'none') {
|
|
3276
|
+
return createSkippedResult('No version bump to cascade');
|
|
3277
|
+
}
|
|
3278
|
+
// In a full implementation, this would:
|
|
3279
|
+
// 1. Use workspace discovery to find dependent packages
|
|
3280
|
+
// 2. Update their dependency references
|
|
3281
|
+
// 3. Track the modified files
|
|
3282
|
+
logger.warn('Cascade dependencies step is not fully implemented yet');
|
|
3283
|
+
return {
|
|
3284
|
+
status: 'success',
|
|
3285
|
+
message: 'Dependency cascade skipped (not fully implemented)',
|
|
3286
|
+
};
|
|
3287
|
+
}, {
|
|
3288
|
+
dependsOn: ['update-packages'],
|
|
3289
|
+
});
|
|
3290
|
+
}
|
|
3291
|
+
|
|
3292
|
+
/**
|
|
3293
|
+
* Interpolates template variables in a string.
|
|
3294
|
+
*
|
|
3295
|
+
* Supports: ${projectName}, ${packageName}, ${version}
|
|
3296
|
+
*
|
|
3297
|
+
* @param template - Template string with ${var} placeholders
|
|
3298
|
+
* @param vars - Variable values
|
|
3299
|
+
* @returns Interpolated string
|
|
3300
|
+
*/
|
|
3301
|
+
function interpolate(template, vars) {
|
|
3302
|
+
let result = template;
|
|
3303
|
+
for (const [key, value] of entries(vars)) {
|
|
3304
|
+
const placeholder = '${' + key + '}';
|
|
3305
|
+
let index = result.indexOf(placeholder);
|
|
3306
|
+
while (index !== -1) {
|
|
3307
|
+
result = result.slice(0, index) + value + result.slice(index + placeholder.length);
|
|
3308
|
+
index = result.indexOf(placeholder, index + value.length);
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
return result;
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
const CREATE_COMMIT_STEP_ID = 'create-commit';
|
|
3315
|
+
/**
|
|
3316
|
+
* Creates the create-commit step.
|
|
3317
|
+
*
|
|
3318
|
+
* This step:
|
|
3319
|
+
* 1. Stages modified files
|
|
3320
|
+
* 2. Creates a commit with the configured message
|
|
3321
|
+
*
|
|
3322
|
+
* State updates:
|
|
3323
|
+
* - commitHash: Hash of the created commit
|
|
3324
|
+
*
|
|
3325
|
+
* @returns A FlowStep that creates a git commit
|
|
3326
|
+
*/
|
|
3327
|
+
function createGitCommitStep() {
|
|
3328
|
+
return createStep(CREATE_COMMIT_STEP_ID, 'Create Version Commit', async (ctx) => {
|
|
3329
|
+
const { git, config, state, projectName, packageName, logger } = ctx;
|
|
3330
|
+
const { nextVersion, bumpType, modifiedFiles } = state;
|
|
3331
|
+
// Skip if git operations disabled
|
|
3332
|
+
if (config.skipGit) {
|
|
3333
|
+
return createSkippedResult('Git operations disabled');
|
|
3334
|
+
}
|
|
3335
|
+
// Skip if no bump needed
|
|
3336
|
+
if (!nextVersion || bumpType === 'none') {
|
|
3337
|
+
return createSkippedResult('No version bump, no commit needed');
|
|
3338
|
+
}
|
|
3339
|
+
// Skip if dry run
|
|
3340
|
+
if (config.dryRun) {
|
|
3341
|
+
const message = interpolate(config.commitMessage ?? 'chore(${projectName}): release version ${version}', {
|
|
3342
|
+
projectName,
|
|
3343
|
+
packageName,
|
|
3344
|
+
version: nextVersion,
|
|
3345
|
+
});
|
|
3346
|
+
return {
|
|
3347
|
+
status: 'success',
|
|
3348
|
+
message: `[DRY RUN] Would commit: "${message}"`,
|
|
3349
|
+
};
|
|
3350
|
+
}
|
|
3351
|
+
// Stage files
|
|
3352
|
+
if (modifiedFiles && modifiedFiles.length > 0) {
|
|
3353
|
+
try {
|
|
3354
|
+
git.stage(modifiedFiles);
|
|
3355
|
+
logger.debug(`Staged ${modifiedFiles.length} file(s)`);
|
|
3356
|
+
}
|
|
3357
|
+
catch (error) {
|
|
3358
|
+
return {
|
|
3359
|
+
status: 'failed',
|
|
3360
|
+
error: error instanceof Error ? error : createError(String(error)),
|
|
3361
|
+
message: 'Failed to stage files',
|
|
3362
|
+
};
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
else {
|
|
3366
|
+
// Stage all changes
|
|
3367
|
+
try {
|
|
3368
|
+
git.stageAll();
|
|
3369
|
+
logger.debug('Staged all changes');
|
|
3370
|
+
}
|
|
3371
|
+
catch (error) {
|
|
3372
|
+
return {
|
|
3373
|
+
status: 'failed',
|
|
3374
|
+
error: error instanceof Error ? error : createError(String(error)),
|
|
3375
|
+
message: 'Failed to stage files',
|
|
3376
|
+
};
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
// Create commit message
|
|
3380
|
+
const message = interpolate(config.commitMessage ?? 'chore(${projectName}): release version ${version}', {
|
|
3381
|
+
projectName,
|
|
3382
|
+
packageName,
|
|
3383
|
+
version: nextVersion,
|
|
3384
|
+
});
|
|
3385
|
+
// Create commit
|
|
3386
|
+
try {
|
|
3387
|
+
const commit = git.createCommit(message);
|
|
3388
|
+
logger.info(`Created commit: ${commit.hash.slice(0, 7)}`);
|
|
3389
|
+
return {
|
|
3390
|
+
status: 'success',
|
|
3391
|
+
stateUpdates: {
|
|
3392
|
+
commitHash: commit.hash,
|
|
3393
|
+
},
|
|
3394
|
+
message: `Created commit ${commit.hash.slice(0, 7)}: ${message}`,
|
|
3395
|
+
};
|
|
3396
|
+
}
|
|
3397
|
+
catch (error) {
|
|
3398
|
+
return {
|
|
3399
|
+
status: 'failed',
|
|
3400
|
+
error: error instanceof Error ? error : createError(String(error)),
|
|
3401
|
+
message: 'Failed to create commit',
|
|
3402
|
+
};
|
|
3403
|
+
}
|
|
3404
|
+
}, {
|
|
3405
|
+
dependsOn: ['update-packages', 'write-changelog'],
|
|
3406
|
+
});
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
const CREATE_TAG_STEP_ID = 'create-tag';
|
|
3410
|
+
/**
|
|
3411
|
+
* Creates the create-tag step.
|
|
3412
|
+
*
|
|
3413
|
+
* This step:
|
|
3414
|
+
* 1. Creates an annotated git tag
|
|
3415
|
+
* 2. Uses the configured tag format
|
|
3416
|
+
*
|
|
3417
|
+
* State updates:
|
|
3418
|
+
* - tagName: Name of the created tag
|
|
3419
|
+
*
|
|
3420
|
+
* @returns A FlowStep that creates a git tag
|
|
3421
|
+
*/
|
|
3422
|
+
function createTagStep() {
|
|
3423
|
+
return createStep(CREATE_TAG_STEP_ID, 'Create Git Tag', async (ctx) => {
|
|
3424
|
+
const { git, config, state, projectName, packageName, logger } = ctx;
|
|
3425
|
+
const { nextVersion, bumpType, changelogEntry } = state;
|
|
3426
|
+
// Skip if git operations disabled
|
|
3427
|
+
if (config.skipGit) {
|
|
3428
|
+
return createSkippedResult('Git operations disabled');
|
|
3429
|
+
}
|
|
3430
|
+
// Skip if tags disabled
|
|
3431
|
+
if (config.skipTag) {
|
|
3432
|
+
return createSkippedResult('Tag creation disabled');
|
|
3433
|
+
}
|
|
3434
|
+
// Skip if no bump needed
|
|
3435
|
+
if (!nextVersion || bumpType === 'none') {
|
|
3436
|
+
return createSkippedResult('No version bump, no tag needed');
|
|
3437
|
+
}
|
|
3438
|
+
// Generate tag name
|
|
3439
|
+
const tagName = interpolate(config.tagFormat ?? '${projectName}@${version}', {
|
|
3440
|
+
projectName,
|
|
3441
|
+
packageName,
|
|
3442
|
+
version: nextVersion,
|
|
3443
|
+
});
|
|
3444
|
+
// Skip if dry run
|
|
3445
|
+
if (config.dryRun) {
|
|
3446
|
+
return {
|
|
3447
|
+
status: 'success',
|
|
3448
|
+
stateUpdates: { tagName },
|
|
3449
|
+
message: `[DRY RUN] Would create tag: ${tagName}`,
|
|
3450
|
+
};
|
|
3451
|
+
}
|
|
3452
|
+
// Create tag message from changelog entry if available
|
|
3453
|
+
let tagMessage = `Release ${nextVersion}`;
|
|
3454
|
+
if (changelogEntry && changelogEntry.sections.length > 0) {
|
|
3455
|
+
const highlights = [];
|
|
3456
|
+
for (const section of changelogEntry.sections.slice(0, 3)) {
|
|
3457
|
+
const itemCount = section.items.length;
|
|
3458
|
+
highlights.push(`${section.type}: ${itemCount} change${itemCount !== 1 ? 's' : ''}`);
|
|
3459
|
+
}
|
|
3460
|
+
tagMessage = `Release ${nextVersion}\n\n${highlights.join('\n')}`;
|
|
3461
|
+
}
|
|
3462
|
+
// Create tag
|
|
3463
|
+
try {
|
|
3464
|
+
const tag = git.createTag(tagName, {
|
|
3465
|
+
message: tagMessage,
|
|
3466
|
+
});
|
|
3467
|
+
logger.info(`Created tag: ${tag.name}`);
|
|
3468
|
+
return {
|
|
3469
|
+
status: 'success',
|
|
3470
|
+
stateUpdates: {
|
|
3471
|
+
tagName: tag.name,
|
|
3472
|
+
},
|
|
3473
|
+
message: `Created tag: ${tagName}`,
|
|
3474
|
+
};
|
|
3475
|
+
}
|
|
3476
|
+
catch (error) {
|
|
3477
|
+
return {
|
|
3478
|
+
status: 'failed',
|
|
3479
|
+
error: error instanceof Error ? error : createError(String(error)),
|
|
3480
|
+
message: `Failed to create tag: ${tagName}`,
|
|
3481
|
+
};
|
|
3482
|
+
}
|
|
3483
|
+
}, {
|
|
3484
|
+
dependsOn: ['create-commit'],
|
|
3485
|
+
});
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
/**
|
|
3489
|
+
* Default configuration for conventional flow.
|
|
3490
|
+
*/
|
|
3491
|
+
const CONVENTIONAL_FLOW_CONFIG = {
|
|
3492
|
+
preset: 'conventional',
|
|
3493
|
+
releaseTypes: ['feat', 'fix', 'perf', 'revert'],
|
|
3494
|
+
minorTypes: ['feat'],
|
|
3495
|
+
patchTypes: ['fix', 'perf', 'revert'],
|
|
3496
|
+
skipGit: false,
|
|
3497
|
+
skipTag: true, // Tags typically created after publish
|
|
3498
|
+
skipChangelog: false,
|
|
3499
|
+
dryRun: false,
|
|
3500
|
+
commitMessage: 'chore(${projectName}): release version ${version}',
|
|
3501
|
+
tagFormat: '${projectName}@${version}',
|
|
3502
|
+
trackDeps: false,
|
|
3503
|
+
firstReleaseVersion: '0.1.0',
|
|
3504
|
+
};
|
|
3505
|
+
/**
|
|
3506
|
+
* Creates a conventional flow.
|
|
3507
|
+
*
|
|
3508
|
+
* This flow follows the standard conventional commits workflow:
|
|
3509
|
+
* 1. Fetch published version from registry
|
|
3510
|
+
* 2. Analyze commits since last release
|
|
3511
|
+
* 3. Calculate version bump based on commit types
|
|
3512
|
+
* 4. Check if version already published (idempotency)
|
|
3513
|
+
* 5. Generate changelog entry
|
|
3514
|
+
* 6. Update package.json version
|
|
3515
|
+
* 7. Write changelog to file
|
|
3516
|
+
* 8. Create git commit (optional)
|
|
3517
|
+
* 9. Create git tag (optional, typically after publish)
|
|
3518
|
+
*
|
|
3519
|
+
* @param config - Optional configuration overrides
|
|
3520
|
+
* @returns A VersionFlow configured for conventional commits
|
|
3521
|
+
*
|
|
3522
|
+
* @example
|
|
3523
|
+
* ```typescript
|
|
3524
|
+
* import { createConventionalFlow, executeFlow } from '@hyperfrontend/versioning'
|
|
3525
|
+
*
|
|
3526
|
+
* // Use defaults
|
|
3527
|
+
* const flow = createConventionalFlow()
|
|
3528
|
+
*
|
|
3529
|
+
* // With overrides
|
|
3530
|
+
* const customFlow = createConventionalFlow({
|
|
3531
|
+
* skipTag: false,
|
|
3532
|
+
* releaseTypes: ['feat', 'fix'],
|
|
3533
|
+
* })
|
|
3534
|
+
*
|
|
3535
|
+
* const result = await executeFlow(flow, 'lib-utils', '/workspace')
|
|
3536
|
+
* ```
|
|
3537
|
+
*/
|
|
3538
|
+
function createConventionalFlow(config) {
|
|
3539
|
+
const mergedConfig = { ...CONVENTIONAL_FLOW_CONFIG, ...config };
|
|
3540
|
+
return createFlow('conventional', 'Conventional Commits Flow', [
|
|
3541
|
+
createFetchRegistryStep(),
|
|
3542
|
+
createAnalyzeCommitsStep(),
|
|
3543
|
+
createCalculateBumpStep(),
|
|
3544
|
+
createCheckIdempotencyStep(),
|
|
3545
|
+
createGenerateChangelogStep(),
|
|
3546
|
+
createUpdatePackageStep(),
|
|
3547
|
+
createWriteChangelogStep(),
|
|
3548
|
+
createGitCommitStep(),
|
|
3549
|
+
createTagStep(),
|
|
3550
|
+
], {
|
|
3551
|
+
description: 'Standard versioning using conventional commits specification',
|
|
3552
|
+
config: mergedConfig,
|
|
3553
|
+
});
|
|
3554
|
+
}
|
|
3555
|
+
/**
|
|
3556
|
+
* Creates a minimal flow for quick releases.
|
|
3557
|
+
*
|
|
3558
|
+
* Skips changelog and tag creation.
|
|
3559
|
+
*
|
|
3560
|
+
* @param config - Optional configuration overrides
|
|
3561
|
+
* @returns A minimal VersionFlow
|
|
3562
|
+
*/
|
|
3563
|
+
function createMinimalFlow(config) {
|
|
3564
|
+
return createConventionalFlow({
|
|
3565
|
+
skipChangelog: true,
|
|
3566
|
+
skipTag: true,
|
|
3567
|
+
...config,
|
|
3568
|
+
});
|
|
3569
|
+
}
|
|
3570
|
+
/**
|
|
3571
|
+
* Creates a changelog-only flow.
|
|
3572
|
+
*
|
|
3573
|
+
* Only generates changelog, no version bumps or git operations.
|
|
3574
|
+
*
|
|
3575
|
+
* @param config - Optional configuration overrides
|
|
3576
|
+
* @returns A VersionFlow that only updates changelog
|
|
3577
|
+
*/
|
|
3578
|
+
function createChangelogOnlyFlow(config) {
|
|
3579
|
+
return createFlow('changelog-only', 'Changelog Only Flow', [
|
|
3580
|
+
createFetchRegistryStep(),
|
|
3581
|
+
createAnalyzeCommitsStep(),
|
|
3582
|
+
createCalculateBumpStep(),
|
|
3583
|
+
createGenerateChangelogStep(),
|
|
3584
|
+
createWriteChangelogStep(),
|
|
3585
|
+
], {
|
|
3586
|
+
description: 'Generate changelog without version bump or git operations',
|
|
3587
|
+
config: {
|
|
3588
|
+
...CONVENTIONAL_FLOW_CONFIG,
|
|
3589
|
+
skipGit: true,
|
|
3590
|
+
skipTag: true,
|
|
3591
|
+
...config,
|
|
3592
|
+
},
|
|
3593
|
+
});
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3596
|
+
/**
|
|
3597
|
+
* Default configuration for independent flow.
|
|
3598
|
+
*/
|
|
3599
|
+
const INDEPENDENT_FLOW_CONFIG = {
|
|
3600
|
+
...CONVENTIONAL_FLOW_CONFIG,
|
|
3601
|
+
preset: 'independent',
|
|
3602
|
+
trackDeps: true,
|
|
3603
|
+
};
|
|
3604
|
+
/**
|
|
3605
|
+
* Creates a step that checks for dependent package bumps.
|
|
3606
|
+
*
|
|
3607
|
+
* In independent versioning, when a dependency is bumped,
|
|
3608
|
+
* dependents may also need version bumps.
|
|
3609
|
+
*
|
|
3610
|
+
* @returns A FlowStep that checks dependent bumps
|
|
3611
|
+
*/
|
|
3612
|
+
function createCheckDependentBumpsStep() {
|
|
3613
|
+
return createStep('check-dependent-bumps', 'Check Dependent Bumps', async (ctx) => {
|
|
3614
|
+
const { config, state, logger } = ctx;
|
|
3615
|
+
const { bumpType, nextVersion } = state;
|
|
3616
|
+
// Skip if dependency tracking not enabled
|
|
3617
|
+
if (!config.trackDeps) {
|
|
3618
|
+
return createSkippedResult('Dependency tracking not enabled');
|
|
3619
|
+
}
|
|
3620
|
+
// Skip if no bump needed for this package
|
|
3621
|
+
if (!nextVersion || bumpType === 'none') {
|
|
3622
|
+
return createSkippedResult('No bump to propagate');
|
|
3623
|
+
}
|
|
3624
|
+
// In a full implementation, this would:
|
|
3625
|
+
// 1. Load workspace dependency graph
|
|
3626
|
+
// 2. Find packages that depend on this one
|
|
3627
|
+
// 3. Mark them for potential bumps
|
|
3628
|
+
logger.debug('Dependent bump checking not fully implemented');
|
|
3629
|
+
return {
|
|
3630
|
+
status: 'success',
|
|
3631
|
+
message: 'Dependent bump check complete (basic implementation)',
|
|
3632
|
+
};
|
|
3633
|
+
}, {
|
|
3634
|
+
dependsOn: ['calculate-bump'],
|
|
3635
|
+
});
|
|
3636
|
+
}
|
|
3637
|
+
/**
|
|
3638
|
+
* Creates an independent versioning flow.
|
|
3639
|
+
*
|
|
3640
|
+
* This flow is designed for monorepos where each package
|
|
3641
|
+
* is versioned independently:
|
|
3642
|
+
*
|
|
3643
|
+
* 1. Fetch published version from registry
|
|
3644
|
+
* 2. Analyze commits since last release
|
|
3645
|
+
* 3. Calculate version bump based on commit types
|
|
3646
|
+
* 4. Check for dependent package bumps (cascade)
|
|
3647
|
+
* 5. Check if version already published (idempotency)
|
|
3648
|
+
* 6. Generate changelog entry
|
|
3649
|
+
* 7. Update package.json version
|
|
3650
|
+
* 8. Cascade dependency updates
|
|
3651
|
+
* 9. Write changelog to file
|
|
3652
|
+
* 10. Create git commit
|
|
3653
|
+
* 11. Create git tag
|
|
3654
|
+
*
|
|
3655
|
+
* @param config - Optional configuration overrides
|
|
3656
|
+
* @returns A VersionFlow configured for independent versioning
|
|
3657
|
+
*
|
|
3658
|
+
* @example
|
|
3659
|
+
* ```typescript
|
|
3660
|
+
* import { createIndependentFlow, executeFlow } from '@hyperfrontend/versioning'
|
|
3661
|
+
*
|
|
3662
|
+
* const flow = createIndependentFlow()
|
|
3663
|
+
* const result = await executeFlow(flow, 'lib-utils', '/workspace')
|
|
3664
|
+
*
|
|
3665
|
+
* // Check which dependents were bumped
|
|
3666
|
+
* console.log(result.state.cascadedBumps)
|
|
3667
|
+
* ```
|
|
3668
|
+
*/
|
|
3669
|
+
function createIndependentFlow(config) {
|
|
3670
|
+
const mergedConfig = { ...INDEPENDENT_FLOW_CONFIG, ...config };
|
|
3671
|
+
return createFlow('independent', 'Independent Versioning Flow', [
|
|
3672
|
+
createFetchRegistryStep(),
|
|
3673
|
+
createAnalyzeCommitsStep(),
|
|
3674
|
+
createCalculateBumpStep(),
|
|
3675
|
+
createCheckDependentBumpsStep(),
|
|
3676
|
+
createCheckIdempotencyStep(),
|
|
3677
|
+
createGenerateChangelogStep(),
|
|
3678
|
+
createUpdatePackageStep(),
|
|
3679
|
+
createCascadeDependenciesStep(),
|
|
3680
|
+
createWriteChangelogStep(),
|
|
3681
|
+
createGitCommitStep(),
|
|
3682
|
+
createTagStep(),
|
|
3683
|
+
], {
|
|
3684
|
+
description: 'Version packages independently with dependency tracking',
|
|
3685
|
+
config: mergedConfig,
|
|
3686
|
+
});
|
|
3687
|
+
}
|
|
3688
|
+
/**
|
|
3689
|
+
* Creates a flow for releasing multiple packages independently.
|
|
3690
|
+
*
|
|
3691
|
+
* This is a variant that skips commit/tag creation, intended
|
|
3692
|
+
* to be used when releasing multiple packages in sequence
|
|
3693
|
+
* with a single commit at the end.
|
|
3694
|
+
*
|
|
3695
|
+
* @param config - Optional configuration overrides
|
|
3696
|
+
* @returns A VersionFlow for batch independent releases
|
|
3697
|
+
*/
|
|
3698
|
+
function createBatchReleaseFlow(config) {
|
|
3699
|
+
return createFlow('batch-release', 'Batch Release Flow', [
|
|
3700
|
+
createFetchRegistryStep(),
|
|
3701
|
+
createAnalyzeCommitsStep(),
|
|
3702
|
+
createCalculateBumpStep(),
|
|
3703
|
+
createCheckIdempotencyStep(),
|
|
3704
|
+
createGenerateChangelogStep(),
|
|
3705
|
+
createUpdatePackageStep(),
|
|
3706
|
+
createWriteChangelogStep(),
|
|
3707
|
+
], {
|
|
3708
|
+
description: 'Prepare release without committing (for batch releases)',
|
|
3709
|
+
config: {
|
|
3710
|
+
...INDEPENDENT_FLOW_CONFIG,
|
|
3711
|
+
skipGit: true,
|
|
3712
|
+
skipTag: true,
|
|
3713
|
+
...config,
|
|
3714
|
+
},
|
|
3715
|
+
});
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
/**
|
|
3719
|
+
* Default configuration for synced flow.
|
|
3720
|
+
*/
|
|
3721
|
+
const SYNCED_FLOW_CONFIG = {
|
|
3722
|
+
...CONVENTIONAL_FLOW_CONFIG,
|
|
3723
|
+
preset: 'synced',
|
|
3724
|
+
tagFormat: 'v${version}', // Single tag for all packages
|
|
3725
|
+
commitMessage: 'chore: release version ${version}',
|
|
3726
|
+
};
|
|
3727
|
+
/**
|
|
3728
|
+
* Creates a step that updates all workspace packages to the same version.
|
|
3729
|
+
*
|
|
3730
|
+
* @returns A FlowStep that syncs all package versions
|
|
3731
|
+
*/
|
|
3732
|
+
function createSyncAllPackagesStep() {
|
|
3733
|
+
return createStep('sync-all-packages', 'Sync All Package Versions', async (ctx) => {
|
|
3734
|
+
const { tree, workspaceRoot, state, logger } = ctx;
|
|
3735
|
+
const { nextVersion, bumpType } = state;
|
|
3736
|
+
// Skip if no bump needed
|
|
3737
|
+
if (!nextVersion || bumpType === 'none') {
|
|
3738
|
+
return createSkippedResult('No version bump needed');
|
|
3739
|
+
}
|
|
3740
|
+
// In a full implementation, this would:
|
|
3741
|
+
// 1. Discover all workspace packages
|
|
3742
|
+
// 2. Update each package.json to the same version
|
|
3743
|
+
// 3. Update cross-references between packages
|
|
3744
|
+
// For now, just update the root package.json as a demo
|
|
3745
|
+
const rootPackageJson = `${workspaceRoot}/package.json`;
|
|
3746
|
+
const modifiedFiles = [];
|
|
3747
|
+
try {
|
|
3748
|
+
const content = tree.read(rootPackageJson, 'utf-8');
|
|
3749
|
+
if (content) {
|
|
3750
|
+
const pkg = parse(content);
|
|
3751
|
+
pkg['version'] = nextVersion;
|
|
3752
|
+
tree.write(rootPackageJson, stringify(pkg, null, 2) + '\n');
|
|
3753
|
+
modifiedFiles.push(rootPackageJson);
|
|
3754
|
+
logger.info(`Updated root package.json to ${nextVersion}`);
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
catch (error) {
|
|
3758
|
+
logger.warn(`Could not update root package.json: ${error}`);
|
|
3759
|
+
}
|
|
3760
|
+
return {
|
|
3761
|
+
status: 'success',
|
|
3762
|
+
stateUpdates: {
|
|
3763
|
+
modifiedFiles: [...(state.modifiedFiles ?? []), ...modifiedFiles],
|
|
3764
|
+
},
|
|
3765
|
+
message: `Synced ${modifiedFiles.length} package(s) to version ${nextVersion}`,
|
|
3766
|
+
};
|
|
3767
|
+
}, {
|
|
3768
|
+
dependsOn: ['calculate-bump'],
|
|
3769
|
+
});
|
|
3770
|
+
}
|
|
3771
|
+
/**
|
|
3772
|
+
* Creates a step that generates a combined changelog for all packages.
|
|
3773
|
+
*
|
|
3774
|
+
* @returns A FlowStep that creates a combined changelog
|
|
3775
|
+
*/
|
|
3776
|
+
function createCombinedChangelogStep() {
|
|
3777
|
+
return createStep('combined-changelog', 'Generate Combined Changelog', async (ctx) => {
|
|
3778
|
+
const { config, state, logger } = ctx;
|
|
3779
|
+
const { nextVersion, bumpType } = state;
|
|
3780
|
+
// Skip if no bump or changelog disabled
|
|
3781
|
+
if (!nextVersion || bumpType === 'none' || config.skipChangelog) {
|
|
3782
|
+
return createSkippedResult('No changelog to generate');
|
|
3783
|
+
}
|
|
3784
|
+
// In a synced flow, we generate a single changelog at the workspace root
|
|
3785
|
+
// This is a simplified version - full implementation would aggregate
|
|
3786
|
+
// commits from all packages
|
|
3787
|
+
logger.info('Generating combined changelog for workspace');
|
|
3788
|
+
// Reuse the standard changelog generation
|
|
3789
|
+
// The actual entry is generated by createGenerateChangelogStep
|
|
3790
|
+
return {
|
|
3791
|
+
status: 'success',
|
|
3792
|
+
message: 'Combined changelog preparation complete',
|
|
3793
|
+
};
|
|
3794
|
+
}, {
|
|
3795
|
+
dependsOn: ['check-idempotency'],
|
|
3796
|
+
});
|
|
3797
|
+
}
|
|
3798
|
+
/**
|
|
3799
|
+
* Creates a synced versioning flow.
|
|
3800
|
+
*
|
|
3801
|
+
* This flow maintains the same version across all packages
|
|
3802
|
+
* in a monorepo. When any package changes, all packages
|
|
3803
|
+
* get the same new version.
|
|
3804
|
+
*
|
|
3805
|
+
* Flow steps:
|
|
3806
|
+
* 1. Fetch published version from registry
|
|
3807
|
+
* 2. Analyze commits across all packages
|
|
3808
|
+
* 3. Calculate version bump (highest needed)
|
|
3809
|
+
* 4. Check if version already published
|
|
3810
|
+
* 5. Sync all package versions
|
|
3811
|
+
* 6. Generate combined changelog
|
|
3812
|
+
* 7. Write changelog to root
|
|
3813
|
+
* 8. Create git commit
|
|
3814
|
+
* 9. Create single git tag
|
|
3815
|
+
*
|
|
3816
|
+
* @param config - Optional configuration overrides
|
|
3817
|
+
* @returns A VersionFlow configured for synced versioning
|
|
3818
|
+
*
|
|
3819
|
+
* @example
|
|
3820
|
+
* ```typescript
|
|
3821
|
+
* import { createSyncedFlow, executeFlow } from '@hyperfrontend/versioning'
|
|
3822
|
+
*
|
|
3823
|
+
* const flow = createSyncedFlow()
|
|
3824
|
+
* const result = await executeFlow(flow, 'workspace', '/workspace')
|
|
3825
|
+
*
|
|
3826
|
+
* // All packages now share the same version
|
|
3827
|
+
* console.log(`Released v${result.state.nextVersion}`)
|
|
3828
|
+
* ```
|
|
3829
|
+
*/
|
|
3830
|
+
function createSyncedFlow(config) {
|
|
3831
|
+
const mergedConfig = { ...SYNCED_FLOW_CONFIG, ...config };
|
|
3832
|
+
return createFlow('synced', 'Synced Versioning Flow', [
|
|
3833
|
+
createFetchRegistryStep(),
|
|
3834
|
+
createAnalyzeCommitsStep(),
|
|
3835
|
+
createCalculateBumpStep(),
|
|
3836
|
+
createCheckIdempotencyStep(),
|
|
3837
|
+
createSyncAllPackagesStep(),
|
|
3838
|
+
createCombinedChangelogStep(),
|
|
3839
|
+
createGenerateChangelogStep(),
|
|
3840
|
+
createWriteChangelogStep(),
|
|
3841
|
+
createGitCommitStep(),
|
|
3842
|
+
createTagStep(),
|
|
3843
|
+
], {
|
|
3844
|
+
description: 'Keep all packages at the same version',
|
|
3845
|
+
config: mergedConfig,
|
|
3846
|
+
});
|
|
3847
|
+
}
|
|
3848
|
+
/**
|
|
3849
|
+
* Creates a fixed versioning flow.
|
|
3850
|
+
*
|
|
3851
|
+
* Similar to synced, but uses a fixed version scheme
|
|
3852
|
+
* where the version is explicitly provided rather than
|
|
3853
|
+
* calculated from commits.
|
|
3854
|
+
*
|
|
3855
|
+
* @param version - The fixed version to use
|
|
3856
|
+
* @param config - Optional configuration overrides
|
|
3857
|
+
* @returns A VersionFlow with a fixed version
|
|
3858
|
+
*/
|
|
3859
|
+
function createFixedVersionFlow(version, config) {
|
|
3860
|
+
// Override the calculate-bump step to use fixed version
|
|
3861
|
+
const fixedBumpStep = createStep('calculate-bump', 'Use Fixed Version', async () => ({
|
|
3862
|
+
status: 'success',
|
|
3863
|
+
stateUpdates: {
|
|
3864
|
+
bumpType: 'minor', // Arbitrary, version is fixed
|
|
3865
|
+
nextVersion: version,
|
|
3866
|
+
},
|
|
3867
|
+
message: `Using fixed version: ${version}`,
|
|
3868
|
+
}), {
|
|
3869
|
+
dependsOn: ['analyze-commits'],
|
|
3870
|
+
});
|
|
3871
|
+
return createFlow('fixed', 'Fixed Version Flow', [
|
|
3872
|
+
createFetchRegistryStep(),
|
|
3873
|
+
createAnalyzeCommitsStep(),
|
|
3874
|
+
fixedBumpStep,
|
|
3875
|
+
createCheckIdempotencyStep(),
|
|
3876
|
+
createSyncAllPackagesStep(),
|
|
3877
|
+
createCombinedChangelogStep(),
|
|
3878
|
+
createGenerateChangelogStep(),
|
|
3879
|
+
createWriteChangelogStep(),
|
|
3880
|
+
createGitCommitStep(),
|
|
3881
|
+
createTagStep(),
|
|
3882
|
+
], {
|
|
3883
|
+
description: `Release with fixed version ${version}`,
|
|
3884
|
+
config: { ...SYNCED_FLOW_CONFIG, ...config },
|
|
3885
|
+
});
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3888
|
+
export { CONVENTIONAL_FLOW_CONFIG, INDEPENDENT_FLOW_CONFIG, SYNCED_FLOW_CONFIG, createBatchReleaseFlow, createChangelogOnlyFlow, createCheckDependentBumpsStep, createCombinedChangelogStep, createConventionalFlow, createFixedVersionFlow, createIndependentFlow, createMinimalFlow, createSyncAllPackagesStep, createSyncedFlow };
|
|
3889
|
+
//# sourceMappingURL=index.esm.js.map
|