@williamthorsen/release-kit 5.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +105 -65
  2. package/README.md +160 -57
  3. package/cliff.toml.template +26 -17
  4. package/dist/esm/.cache +1 -1
  5. package/dist/esm/bin/release-kit.js +96 -3
  6. package/dist/esm/buildChangelogEntries.d.ts +1 -0
  7. package/dist/esm/buildChangelogEntries.js +39 -25
  8. package/dist/esm/checkWorkTypesDrift.d.ts +11 -0
  9. package/dist/esm/checkWorkTypesDrift.js +110 -0
  10. package/dist/esm/collectPolicyViolations.d.ts +6 -0
  11. package/dist/esm/collectPolicyViolations.js +15 -0
  12. package/dist/esm/createGithubRelease.d.ts +12 -2
  13. package/dist/esm/createGithubRelease.js +12 -8
  14. package/dist/esm/createGithubReleaseCommand.js +10 -6
  15. package/dist/esm/decideRelease.d.ts +3 -0
  16. package/dist/esm/decideRelease.js +19 -3
  17. package/dist/esm/defaults.d.ts +7 -0
  18. package/dist/esm/defaults.js +41 -20
  19. package/dist/esm/deriveWorkspaceConfig.js +3 -0
  20. package/dist/esm/determineBumpFromCommits.d.ts +6 -1
  21. package/dist/esm/determineBumpFromCommits.js +9 -3
  22. package/dist/esm/generateChangelogs.js +14 -29
  23. package/dist/esm/loadConfig.js +14 -22
  24. package/dist/esm/parseCommitMessage.d.ts +8 -2
  25. package/dist/esm/parseCommitMessage.js +32 -3
  26. package/dist/esm/publishCommand.js +21 -2
  27. package/dist/esm/releasePrepare.js +39 -15
  28. package/dist/esm/releasePrepareMono.js +26 -3
  29. package/dist/esm/releasePrepareProject.js +13 -1
  30. package/dist/esm/renderReleaseNotes.js +2 -1
  31. package/dist/esm/reportPrepare.js +18 -0
  32. package/dist/esm/resolveCommandTags.js +16 -6
  33. package/dist/esm/resolveReleaseTags.d.ts +8 -1
  34. package/dist/esm/resolveReleaseTags.js +11 -7
  35. package/dist/esm/runGitCliff.d.ts +2 -0
  36. package/dist/esm/runGitCliff.js +27 -0
  37. package/dist/esm/stripEmojiPrefix.d.ts +1 -0
  38. package/dist/esm/stripEmojiPrefix.js +7 -0
  39. package/dist/esm/syncWorkTypes.d.ts +10 -0
  40. package/dist/esm/syncWorkTypes.js +90 -0
  41. package/dist/esm/types.d.ts +15 -0
  42. package/dist/esm/work-types.json +127 -0
  43. package/dist/esm/work-types.schema.json +73 -0
  44. package/dist/esm/workTypesData.d.ts +14 -0
  45. package/dist/esm/workTypesData.js +59 -0
  46. package/dist/esm/workTypesUtils.d.ts +5 -0
  47. package/dist/esm/workTypesUtils.js +16 -0
  48. package/package.json +6 -3
  49. package/dist/esm/version.d.ts +0 -1
  50. package/dist/esm/version.js +0 -4
@@ -0,0 +1,11 @@
1
+ export declare const UPSTREAM_WORK_TYPES_URL = "https://raw.githubusercontent.com/williamthorsen/codeassembly/main/packages/agents/content/skills/_data/work-types.json";
2
+ export interface DriftCheckResult {
3
+ exitCode: 0 | 1 | 2 | 3;
4
+ message: string;
5
+ }
6
+ export interface CheckWorkTypesDriftDependencies {
7
+ localPath?: string;
8
+ fetch?: typeof globalThis.fetch;
9
+ upstreamUrl?: string;
10
+ }
11
+ export declare function checkWorkTypesDrift(dependencies?: CheckWorkTypesDriftDependencies): Promise<DriftCheckResult>;
@@ -0,0 +1,110 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { errorMessage, hasExpectedTopLevelShape } from "./workTypesUtils.js";
5
+ const UPSTREAM_WORK_TYPES_URL = "https://raw.githubusercontent.com/williamthorsen/codeassembly/main/packages/agents/content/skills/_data/work-types.json";
6
+ function resolveDefaultLocalPath() {
7
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
8
+ return resolve(moduleDir, "work-types.json");
9
+ }
10
+ async function checkWorkTypesDrift(dependencies = {}) {
11
+ const localPath = dependencies.localPath ?? resolveDefaultLocalPath();
12
+ const fetcher = dependencies.fetch ?? globalThis.fetch;
13
+ const url = dependencies.upstreamUrl ?? UPSTREAM_WORK_TYPES_URL;
14
+ const localContent = readFileSync(localPath, "utf8");
15
+ let localJson;
16
+ try {
17
+ localJson = JSON.parse(localContent);
18
+ } catch (error) {
19
+ return {
20
+ exitCode: 3,
21
+ message: `Local work-types.json is not valid JSON: ${errorMessage(error)}`
22
+ };
23
+ }
24
+ let response;
25
+ try {
26
+ response = await fetcher(url);
27
+ } catch (error) {
28
+ return {
29
+ exitCode: 2,
30
+ message: `Network error fetching upstream work-types.json: ${errorMessage(error)}`
31
+ };
32
+ }
33
+ if (response.status === 404) {
34
+ return {
35
+ exitCode: 0,
36
+ message: `Upstream work-types.json not yet published at ${url}; skipping drift check.`
37
+ };
38
+ }
39
+ if (!response.ok) {
40
+ return {
41
+ exitCode: 2,
42
+ message: `Failed to fetch upstream work-types.json: HTTP ${response.status} ${response.statusText}`
43
+ };
44
+ }
45
+ const upstreamText = await response.text();
46
+ let upstreamJson;
47
+ try {
48
+ upstreamJson = JSON.parse(upstreamText);
49
+ } catch (error) {
50
+ return {
51
+ exitCode: 3,
52
+ message: `Upstream work-types.json is not valid JSON: ${errorMessage(error)}`
53
+ };
54
+ }
55
+ if (!hasExpectedTopLevelShape(upstreamJson)) {
56
+ return {
57
+ exitCode: 3,
58
+ message: "Upstream work-types.json does not match the expected schema shape (missing `tiers` or `types`)."
59
+ };
60
+ }
61
+ const normalisedLocal = stripLocalOnlyFields(localJson);
62
+ if (deepEqual(normalisedLocal, upstreamJson)) {
63
+ return {
64
+ exitCode: 0,
65
+ message: "Local work-types.json matches upstream."
66
+ };
67
+ }
68
+ return {
69
+ exitCode: 1,
70
+ message: `Drift detected. Local and upstream work-types.json differ. Run \`nmr work-types:sync\` to update from upstream.
71
+ Local: ${localPath}
72
+ Upstream: ${url}`
73
+ };
74
+ }
75
+ function stripLocalOnlyFields(value) {
76
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
77
+ return value;
78
+ }
79
+ const record = { ...value };
80
+ delete record.$schema;
81
+ return record;
82
+ }
83
+ function deepEqual(a, b) {
84
+ if (a === b) return true;
85
+ if (typeof a !== typeof b) return false;
86
+ if (a === null || b === null) return a === b;
87
+ if (Array.isArray(a) && Array.isArray(b)) {
88
+ if (a.length !== b.length) return false;
89
+ for (let i = 0; i < a.length; i++) {
90
+ if (!deepEqual(a[i], b[i])) return false;
91
+ }
92
+ return true;
93
+ }
94
+ if (typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
95
+ const aRecord = { ...a };
96
+ const bRecord = { ...b };
97
+ const aKeys = Object.keys(aRecord);
98
+ const bKeys = Object.keys(bRecord);
99
+ if (aKeys.length !== bKeys.length) return false;
100
+ for (const key of aKeys) {
101
+ if (!deepEqual(aRecord[key], bRecord[key])) return false;
102
+ }
103
+ return true;
104
+ }
105
+ return false;
106
+ }
107
+ export {
108
+ UPSTREAM_WORK_TYPES_URL,
109
+ checkWorkTypesDrift
110
+ };
@@ -0,0 +1,6 @@
1
+ import type { PolicyViolationHandler } from './parseCommitMessage.ts';
2
+ import type { PolicyViolation } from './types.ts';
3
+ export declare function createPolicyViolationCollector(): {
4
+ violations: PolicyViolation[];
5
+ onPolicyViolation: PolicyViolationHandler;
6
+ };
@@ -0,0 +1,15 @@
1
+ function createPolicyViolationCollector() {
2
+ const violations = [];
3
+ const onPolicyViolation = (commit, type, surface) => {
4
+ violations.push({
5
+ commitHash: commit.hash,
6
+ commitSubject: commit.message.split("\n", 1)[0] ?? "",
7
+ type,
8
+ surface
9
+ });
10
+ };
11
+ return { violations, onPolicyViolation };
12
+ }
13
+ export {
14
+ createPolicyViolationCollector
15
+ };
@@ -4,10 +4,20 @@ export interface CreateGithubReleaseOptions {
4
4
  dryRun: boolean;
5
5
  sectionOrder?: string[];
6
6
  }
7
- export declare function createGithubRelease(options: CreateGithubReleaseOptions): boolean;
7
+ export type CreateReleaseSkipReason = 'no-entry' | 'no-audience-content' | 'empty-body';
8
+ export type CreateReleaseResult = {
9
+ status: 'created';
10
+ } | {
11
+ status: 'skipped';
12
+ reason: CreateReleaseSkipReason;
13
+ };
14
+ export declare function createGithubRelease(options: CreateGithubReleaseOptions): CreateReleaseResult;
8
15
  export interface CreateGithubReleasesOutcome {
9
16
  created: string[];
10
- skipped: string[];
17
+ skipped: Array<{
18
+ tag: string;
19
+ reason: CreateReleaseSkipReason;
20
+ }>;
11
21
  }
12
22
  export declare function createGithubReleases(tags: Array<{
13
23
  tag: string;
@@ -7,21 +7,21 @@ function createGithubRelease(options) {
7
7
  const { tag, changelogJsonPath, dryRun, sectionOrder } = options;
8
8
  if (!existsSync(changelogJsonPath)) {
9
9
  console.warn(`Warning: ${changelogJsonPath} not found; skipping GitHub Release creation`);
10
- return false;
10
+ return { status: "skipped", reason: "no-entry" };
11
11
  }
12
12
  const version = extractVersion(tag);
13
13
  const entries = readChangelogEntries(changelogJsonPath);
14
14
  if (entries === void 0) {
15
15
  console.warn(`Warning: could not parse ${changelogJsonPath}; skipping GitHub Release creation`);
16
- return false;
16
+ return { status: "skipped", reason: "no-entry" };
17
17
  }
18
18
  const entry = entries.find((e) => e.version === version);
19
19
  if (entry === void 0) {
20
20
  console.warn(`Warning: no changelog entry for version ${version}; skipping GitHub Release creation`);
21
- return false;
21
+ return { status: "skipped", reason: "no-entry" };
22
22
  }
23
23
  if (!entry.sections.some(matchesAudience("all"))) {
24
- return false;
24
+ return { status: "skipped", reason: "no-audience-content" };
25
25
  }
26
26
  const body = renderReleaseNotesSingle(entry, {
27
27
  filter: matchesAudience("all"),
@@ -29,15 +29,15 @@ function createGithubRelease(options) {
29
29
  ...sectionOrder === void 0 ? {} : { sectionOrder }
30
30
  });
31
31
  if (body.trim() === "") {
32
- return false;
32
+ return { status: "skipped", reason: "empty-body" };
33
33
  }
34
34
  const args = ["release", "create", tag, "--title", tag, "--notes", body];
35
35
  if (dryRun) {
36
36
  console.info(`[dry-run] Would run: gh ${args.join(" ")}`);
37
- return true;
37
+ return { status: "created" };
38
38
  }
39
39
  execFileSync("gh", args, { stdio: "inherit" });
40
- return true;
40
+ return { status: "created" };
41
41
  }
42
42
  function createGithubReleases(tags, changelogJsonOutputPath, dryRun, sectionOrder) {
43
43
  const created = [];
@@ -50,7 +50,11 @@ function createGithubReleases(tags, changelogJsonOutputPath, dryRun, sectionOrde
50
50
  dryRun,
51
51
  ...sectionOrder === void 0 ? {} : { sectionOrder }
52
52
  });
53
- (result ? created : skipped).push(tag);
53
+ if (result.status === "created") {
54
+ created.push(tag);
55
+ } else {
56
+ skipped.push({ tag, reason: result.reason });
57
+ }
54
58
  }
55
59
  return { created, skipped };
56
60
  }
@@ -26,14 +26,18 @@ async function createGithubReleaseCommand(argv) {
26
26
  console.error(`Error creating GitHub Releases: ${error instanceof Error ? error.message : String(error)}`);
27
27
  process.exit(1);
28
28
  }
29
- if (requestedTags !== void 0 && outcome.created.length === 0) {
30
- console.error(
31
- `Error: no GitHub Releases were created for requested tags: ${outcome.skipped.join(", ")}. Each was skipped (missing changelog entry, no all-audience content, or empty rendered body).`
32
- );
33
- process.exit(1);
29
+ if (requestedTags !== void 0) {
30
+ const noEntryTags = outcome.skipped.filter((s) => s.reason === "no-entry").map((s) => s.tag);
31
+ if (noEntryTags.length > 0) {
32
+ console.error(
33
+ `Error: requested tags have no changelog entry: ${noEntryTags.join(", ")}. Verify the tag names match a published changelog version.`
34
+ );
35
+ process.exit(1);
36
+ }
34
37
  }
35
38
  if (outcome.skipped.length > 0) {
36
- console.info(`Skipped ${outcome.skipped.length} tag(s) with no releasable content: ${outcome.skipped.join(", ")}.`);
39
+ const formatted = outcome.skipped.map((s) => `${s.tag} (${s.reason})`).join(", ");
40
+ console.info(`Skipped ${outcome.skipped.length} tag(s) with no releasable content: ${formatted}.`);
37
41
  }
38
42
  }
39
43
  export {
@@ -1,3 +1,4 @@
1
+ import { type PolicyViolationHandler } from './parseCommitMessage.ts';
1
2
  import type { Commit, ReleaseType, VersionPatterns, WorkTypeConfig } from './types.ts';
2
3
  export interface DecideReleaseArgs {
3
4
  commits: readonly Commit[];
@@ -6,6 +7,8 @@ export interface DecideReleaseArgs {
6
7
  workTypes: Record<string, WorkTypeConfig>;
7
8
  versionPatterns: VersionPatterns;
8
9
  scopeAliases: Record<string, string> | undefined;
10
+ breakingPolicies?: Record<string, 'forbidden' | 'optional' | 'required'>;
11
+ onPolicyViolation?: PolicyViolationHandler;
9
12
  skipReasons: {
10
13
  noCommits: string;
11
14
  noBumpWorthy: string;
@@ -1,11 +1,27 @@
1
1
  import { determineBumpType } from "./determineBumpType.js";
2
- import { parseCommitMessage } from "./parseCommitMessage.js";
2
+ import {
3
+ parseCommitMessage
4
+ } from "./parseCommitMessage.js";
3
5
  function decideRelease(args) {
4
- const { commits, force = false, bumpOverride, workTypes, versionPatterns, scopeAliases, skipReasons } = args;
6
+ const {
7
+ commits,
8
+ force = false,
9
+ bumpOverride,
10
+ workTypes,
11
+ versionPatterns,
12
+ scopeAliases,
13
+ breakingPolicies,
14
+ onPolicyViolation,
15
+ skipReasons
16
+ } = args;
17
+ const parseOptions = {
18
+ ...breakingPolicies !== void 0 && { breakingPolicies },
19
+ ...onPolicyViolation !== void 0 && { onPolicyViolation }
20
+ };
5
21
  const parsedCommits = [];
6
22
  const unparseable = [];
7
23
  for (const commit of commits) {
8
- const parsed = parseCommitMessage(commit.message, commit.hash, workTypes, scopeAliases);
24
+ const parsed = parseCommitMessage(commit.message, commit.hash, workTypes, scopeAliases, parseOptions);
9
25
  if (parsed === void 0) {
10
26
  unparseable.push(commit);
11
27
  } else {
@@ -1,5 +1,12 @@
1
1
  import type { ChangelogJsonConfig, ReleaseNotesConfig, VersionPatterns, WorkTypeConfig } from './types.ts';
2
+ export type { WorkTypesData } from './workTypesData.ts';
3
+ export { WORK_TYPES_DATA } from './workTypesData.ts';
4
+ export declare function composeHeader(entry: {
5
+ emoji: string;
6
+ label: string;
7
+ }): string;
2
8
  export declare const DEFAULT_WORK_TYPES: Record<string, WorkTypeConfig>;
9
+ export declare const DEFAULT_BREAKING_POLICIES: Record<string, 'forbidden' | 'optional' | 'required'>;
3
10
  export declare const DEFAULT_VERSION_PATTERNS: VersionPatterns;
4
11
  export declare const DEFAULT_CHANGELOG_JSON_CONFIG: ChangelogJsonConfig;
5
12
  export declare const DEFAULT_RELEASE_NOTES_CONFIG: ReleaseNotesConfig;
@@ -1,21 +1,39 @@
1
- const DEFAULT_WORK_TYPES = {
2
- fix: { header: "Bug fixes", aliases: ["bugfix"] },
3
- deprecate: { header: "Deprecated" },
4
- feat: { header: "Features", aliases: ["feature"] },
5
- internal: { header: "Internal" },
6
- perf: { header: "Performance", aliases: ["performance"] },
7
- refactor: { header: "Refactoring" },
8
- sec: { header: "Security", aliases: ["security"] },
9
- tests: { header: "Tests", aliases: ["test"] },
10
- tooling: { header: "Tooling" },
11
- ci: { header: "CI" },
12
- deps: { header: "Dependencies", aliases: ["dep"] },
13
- docs: { header: "Documentation", aliases: ["doc"] },
14
- ai: { header: "Agentic support" },
15
- // `fmt` is retained for bump-determination (`parseCommitMessage`), even though
16
- // `cliff.toml.template` skips `fmt:` commits so they never enter the changelog.
17
- fmt: { header: "Formatting" }
18
- };
1
+ import { WORK_TYPES_DATA } from "./workTypesData.js";
2
+ import { WORK_TYPES_DATA as WORK_TYPES_DATA2 } from "./workTypesData.js";
3
+ function composeHeader(entry) {
4
+ return `${entry.emoji} ${entry.label}`;
5
+ }
6
+ const DEV_ONLY_TIERS = /* @__PURE__ */ new Set(["Internal", "Process"]);
7
+ function deriveDefaultWorkTypes() {
8
+ const result = {};
9
+ for (const entry of WORK_TYPES_DATA.types) {
10
+ const config = {
11
+ header: composeHeader(entry)
12
+ };
13
+ if (entry.aliases.length > 0) {
14
+ config.aliases = [...entry.aliases];
15
+ }
16
+ result[entry.key] = config;
17
+ }
18
+ return result;
19
+ }
20
+ function deriveDevOnlySections() {
21
+ const sections = [];
22
+ for (const entry of WORK_TYPES_DATA.types) {
23
+ if (!DEV_ONLY_TIERS.has(entry.tier)) {
24
+ continue;
25
+ }
26
+ if (entry.excludedFromChangelog === true) {
27
+ continue;
28
+ }
29
+ sections.push(composeHeader(entry));
30
+ }
31
+ return sections;
32
+ }
33
+ const DEFAULT_WORK_TYPES = deriveDefaultWorkTypes();
34
+ const DEFAULT_BREAKING_POLICIES = Object.fromEntries(
35
+ WORK_TYPES_DATA.types.map((entry) => [entry.key, entry.breakingPolicy])
36
+ );
19
37
  const DEFAULT_VERSION_PATTERNS = {
20
38
  major: ["!"],
21
39
  minor: ["feat"]
@@ -23,16 +41,19 @@ const DEFAULT_VERSION_PATTERNS = {
23
41
  const DEFAULT_CHANGELOG_JSON_CONFIG = {
24
42
  enabled: true,
25
43
  outputPath: ".meta/changelog.json",
26
- devOnlySections: ["Agentic support", "CI", "Dependencies", "Internal", "Refactoring", "Tests", "Tooling"]
44
+ devOnlySections: deriveDevOnlySections()
27
45
  };
28
46
  const DEFAULT_RELEASE_NOTES_CONFIG = {
29
47
  shouldInjectIntoReadme: false
30
48
  };
31
49
  const DEFAULT_PROJECT_TAG_PREFIX = "v";
32
50
  export {
51
+ DEFAULT_BREAKING_POLICIES,
33
52
  DEFAULT_CHANGELOG_JSON_CONFIG,
34
53
  DEFAULT_PROJECT_TAG_PREFIX,
35
54
  DEFAULT_RELEASE_NOTES_CONFIG,
36
55
  DEFAULT_VERSION_PATTERNS,
37
- DEFAULT_WORK_TYPES
56
+ DEFAULT_WORK_TYPES,
57
+ WORK_TYPES_DATA2 as WORK_TYPES_DATA,
58
+ composeHeader
38
59
  };
@@ -16,11 +16,14 @@ function deriveWorkspaceConfig(workspacePath) {
16
16
  throw new Error(`${packageJsonPath} is missing a 'name' field (required for tag derivation).`);
17
17
  }
18
18
  const unscopedName = stripNpmScope(name);
19
+ const privateField = isRecord(parsed) ? parsed.private : void 0;
20
+ const isPublishable = privateField === void 0 || privateField === false;
19
21
  return {
20
22
  dir,
21
23
  name,
22
24
  tagPrefix: `${unscopedName}-v`,
23
25
  workspacePath,
26
+ isPublishable,
24
27
  packageFiles: [packageJsonPath],
25
28
  changelogPaths: [workspacePath],
26
29
  paths: [`${workspacePath}/**`]
@@ -1,7 +1,12 @@
1
+ import { type PolicyViolationHandler } from './parseCommitMessage.ts';
1
2
  import type { Commit, ReleaseType, VersionPatterns, WorkTypeConfig } from './types.ts';
2
3
  export interface BumpDetermination {
3
4
  releaseType: ReleaseType | undefined;
4
5
  parsedCommitCount: number;
5
6
  unparseableCommits: Commit[] | undefined;
6
7
  }
7
- export declare function determineBumpFromCommits(commits: Commit[], workTypes: Record<string, WorkTypeConfig>, versionPatterns: VersionPatterns, scopeAliases: Record<string, string> | undefined): BumpDetermination;
8
+ export interface DetermineBumpOptions {
9
+ breakingPolicies?: Record<string, 'forbidden' | 'optional' | 'required'>;
10
+ onPolicyViolation?: PolicyViolationHandler;
11
+ }
12
+ export declare function determineBumpFromCommits(commits: Commit[], workTypes: Record<string, WorkTypeConfig>, versionPatterns: VersionPatterns, scopeAliases: Record<string, string> | undefined, options?: DetermineBumpOptions): BumpDetermination;
@@ -1,10 +1,16 @@
1
1
  import { determineBumpType } from "./determineBumpType.js";
2
- import { parseCommitMessage } from "./parseCommitMessage.js";
3
- function determineBumpFromCommits(commits, workTypes, versionPatterns, scopeAliases) {
2
+ import {
3
+ parseCommitMessage
4
+ } from "./parseCommitMessage.js";
5
+ function determineBumpFromCommits(commits, workTypes, versionPatterns, scopeAliases, options) {
6
+ const parseOptions = {
7
+ ...options?.breakingPolicies !== void 0 && { breakingPolicies: options.breakingPolicies },
8
+ ...options?.onPolicyViolation !== void 0 && { onPolicyViolation: options.onPolicyViolation }
9
+ };
4
10
  const parsedCommits = [];
5
11
  const unparseable = [];
6
12
  for (const commit of commits) {
7
- const parsed = parseCommitMessage(commit.message, commit.hash, workTypes, scopeAliases);
13
+ const parsed = parseCommitMessage(commit.message, commit.hash, workTypes, scopeAliases, parseOptions);
8
14
  if (parsed === void 0) {
9
15
  unparseable.push(commit);
10
16
  } else {
@@ -1,8 +1,5 @@
1
- import { execFileSync } from "node:child_process";
2
- import { copyFileSync, mkdtempSync, rmSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
1
  import { resolveCliffConfigPath } from "./resolveCliffConfigPath.js";
2
+ import { runGitCliff } from "./runGitCliff.js";
6
3
  function buildTagPattern(tagPrefixes) {
7
4
  if (tagPrefixes.length === 0) {
8
5
  throw new Error("buildTagPattern: tagPrefixes must contain at least one entry");
@@ -18,38 +15,26 @@ function escapeRegex(value) {
18
15
  return value.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
19
16
  }
20
17
  function generateChangelog(config, changelogPath, tag, dryRun, options) {
21
- const resolvedConfigPath = resolveCliffConfigPath(config.cliffConfigPath, import.meta.url);
22
- let cliffConfigPath = resolvedConfigPath;
23
- let tempDir;
24
- if (resolvedConfigPath.endsWith(".template")) {
25
- tempDir = mkdtempSync(join(tmpdir(), "cliff-"));
26
- cliffConfigPath = join(tempDir, "cliff.toml");
27
- copyFileSync(resolvedConfigPath, cliffConfigPath);
28
- }
29
18
  const outputFile = `${changelogPath}/CHANGELOG.md`;
30
- const args = ["--config", cliffConfigPath, "--output", outputFile, "--tag", tag];
19
+ if (dryRun) {
20
+ return [outputFile];
21
+ }
22
+ const resolvedConfigPath = resolveCliffConfigPath(config.cliffConfigPath, import.meta.url);
23
+ const cliffArgs = ["--output", outputFile, "--tag", tag];
31
24
  if (options?.tagPattern !== void 0) {
32
- args.push("--tag-pattern", options.tagPattern);
25
+ cliffArgs.push("--tag-pattern", options.tagPattern);
33
26
  }
34
27
  for (const includePath of options?.includePaths ?? []) {
35
- args.push("--include-path", includePath);
28
+ cliffArgs.push("--include-path", includePath);
36
29
  }
37
30
  try {
38
- if (!dryRun) {
39
- try {
40
- execFileSync("npx", ["--yes", "git-cliff", ...args], { stdio: "inherit" });
41
- } catch (error) {
42
- throw new Error(
43
- `Failed to generate changelog for ${outputFile}: ${error instanceof Error ? error.message : String(error)}`
44
- );
45
- }
46
- }
47
- return [outputFile];
48
- } finally {
49
- if (tempDir !== void 0) {
50
- rmSync(tempDir, { recursive: true, force: true });
51
- }
31
+ runGitCliff(resolvedConfigPath, cliffArgs, "inherit");
32
+ } catch (error) {
33
+ throw new Error(
34
+ `Failed to generate changelog for ${outputFile}: ${error instanceof Error ? error.message : String(error)}`
35
+ );
52
36
  }
37
+ return [outputFile];
53
38
  }
54
39
  function generateChangelogs(config, tag, dryRun) {
55
40
  const tagPattern = buildTagPattern([config.tagPrefix]);
@@ -92,19 +92,22 @@ function mergeMonorepoConfig(discoveredPaths, userConfig, rootPackage) {
92
92
  if (project !== void 0) {
93
93
  result.project = project;
94
94
  }
95
- const formatCommand = userConfig?.formatCommand;
96
- if (formatCommand !== void 0) {
97
- result.formatCommand = formatCommand;
95
+ applyOptionalPassthroughFields(result, userConfig);
96
+ return result;
97
+ }
98
+ function applyOptionalPassthroughFields(result, userConfig) {
99
+ if (userConfig?.formatCommand !== void 0) {
100
+ result.formatCommand = userConfig.formatCommand;
98
101
  }
99
- const cliffConfigPath = userConfig?.cliffConfigPath;
100
- if (cliffConfigPath !== void 0) {
101
- result.cliffConfigPath = cliffConfigPath;
102
+ if (userConfig?.cliffConfigPath !== void 0) {
103
+ result.cliffConfigPath = userConfig.cliffConfigPath;
102
104
  }
103
- const scopeAliases = userConfig?.scopeAliases;
104
- if (scopeAliases !== void 0) {
105
- result.scopeAliases = scopeAliases;
105
+ if (userConfig?.scopeAliases !== void 0) {
106
+ result.scopeAliases = userConfig.scopeAliases;
107
+ }
108
+ if (userConfig?.breakingPolicies !== void 0) {
109
+ result.breakingPolicies = userConfig.breakingPolicies;
106
110
  }
107
- return result;
108
111
  }
109
112
  function mergeSinglePackageConfig(userConfig) {
110
113
  if (userConfig?.project !== void 0) {
@@ -123,18 +126,7 @@ function mergeSinglePackageConfig(userConfig) {
123
126
  changelogJson,
124
127
  releaseNotes
125
128
  };
126
- const formatCommand = userConfig?.formatCommand;
127
- if (formatCommand !== void 0) {
128
- result.formatCommand = formatCommand;
129
- }
130
- const cliffConfigPath = userConfig?.cliffConfigPath;
131
- if (cliffConfigPath !== void 0) {
132
- result.cliffConfigPath = cliffConfigPath;
133
- }
134
- const scopeAliases = userConfig?.scopeAliases;
135
- if (scopeAliases !== void 0) {
136
- result.scopeAliases = scopeAliases;
137
- }
129
+ applyOptionalPassthroughFields(result, userConfig);
138
130
  return result;
139
131
  }
140
132
  function resolveWorkTypes(userWorkTypes) {
@@ -1,3 +1,9 @@
1
- import type { ParsedCommit, WorkTypeConfig } from './types.ts';
1
+ import type { Commit, ParsedCommit, WorkTypeConfig } from './types.ts';
2
2
  export declare const COMMIT_PREPROCESSOR_PATTERNS: readonly RegExp[];
3
- export declare function parseCommitMessage(message: string, hash: string, workTypes: Record<string, WorkTypeConfig>, scopeAliases?: Record<string, string>): ParsedCommit | undefined;
3
+ export type PolicyViolationSurface = 'prefix' | 'body';
4
+ export type PolicyViolationHandler = (commit: Commit, type: string, surface: PolicyViolationSurface) => void;
5
+ export interface ParseCommitMessageOptions {
6
+ breakingPolicies?: Record<string, 'forbidden' | 'optional' | 'required'>;
7
+ onPolicyViolation?: PolicyViolationHandler;
8
+ }
9
+ export declare function parseCommitMessage(message: string, hash: string, workTypes: Record<string, WorkTypeConfig>, scopeAliases?: Record<string, string>, options?: ParseCommitMessageOptions): ParsedCommit | undefined;
@@ -1,6 +1,7 @@
1
1
  const COMMIT_PREPROCESSOR_PATTERNS = [/^##\s+/, /^#\d+([.-]\d+)?\s+/, /^[A-Z]+-\d+\s+/];
2
- function parseCommitMessage(message, hash, workTypes, scopeAliases) {
3
- const stripped = stripTicketPrefix(message);
2
+ function parseCommitMessage(message, hash, workTypes, scopeAliases, options) {
3
+ const firstLine = message.split("\n", 1)[0] ?? "";
4
+ const stripped = stripTicketPrefix(firstLine);
4
5
  const match = stripped.match(/^(?:([^|]+)\|)?(\w+)(?:\(([^)]+)\))?(!)?:\s*(.*)$/);
5
6
  if (!match) {
6
7
  return void 0;
@@ -17,7 +18,15 @@ function parseCommitMessage(message, hash, workTypes, scopeAliases) {
17
18
  if (resolvedType === void 0) {
18
19
  return void 0;
19
20
  }
20
- const breaking = breakingMarker === "!" || message.includes("BREAKING CHANGE:");
21
+ const commit = { message, hash };
22
+ const breaking = evaluateBreakingPolicy({
23
+ commit,
24
+ resolvedType,
25
+ hasPrefixBreaking: breakingMarker === "!",
26
+ hasFooterBreaking: message.includes("BREAKING CHANGE:"),
27
+ policy: options?.breakingPolicies?.[resolvedType] ?? "optional",
28
+ onPolicyViolation: options?.onPolicyViolation
29
+ });
21
30
  const rawScope = pipeScope ?? parenthesizedScope;
22
31
  const resolvedScope = rawScope !== void 0 && scopeAliases !== void 0 ? scopeAliases[rawScope] ?? rawScope : rawScope;
23
32
  return {
@@ -29,6 +38,26 @@ function parseCommitMessage(message, hash, workTypes, scopeAliases) {
29
38
  ...resolvedScope !== void 0 && { scope: resolvedScope }
30
39
  };
31
40
  }
41
+ function evaluateBreakingPolicy(inputs) {
42
+ const { commit, resolvedType, hasPrefixBreaking, hasFooterBreaking, policy, onPolicyViolation } = inputs;
43
+ if (policy === "forbidden") {
44
+ if (hasPrefixBreaking) {
45
+ onPolicyViolation?.(commit, resolvedType, "prefix");
46
+ }
47
+ if (hasFooterBreaking) {
48
+ onPolicyViolation?.(commit, resolvedType, "body");
49
+ }
50
+ return false;
51
+ }
52
+ if (policy === "required") {
53
+ if (!hasPrefixBreaking) {
54
+ onPolicyViolation?.(commit, resolvedType, "prefix");
55
+ return false;
56
+ }
57
+ return true;
58
+ }
59
+ return hasPrefixBreaking || hasFooterBreaking;
60
+ }
32
61
  function resolveType(rawType, workTypes) {
33
62
  const lowered = rawType.toLowerCase();
34
63
  for (const [key, config] of Object.entries(workTypes)) {