@williamthorsen/release-kit 5.0.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 (73) hide show
  1. package/CHANGELOG.md +149 -49
  2. package/README.md +275 -78
  3. package/cliff.toml.template +26 -17
  4. package/dist/esm/.cache +1 -1
  5. package/dist/esm/assertCleanWorkingTree.js +1 -1
  6. package/dist/esm/bin/release-kit.js +97 -4
  7. package/dist/esm/buildChangelogEntries.d.ts +4 -0
  8. package/dist/esm/buildChangelogEntries.js +173 -0
  9. package/dist/esm/buildDependencyGraph.d.ts +1 -0
  10. package/dist/esm/buildDependencyGraph.js +8 -1
  11. package/dist/esm/buildReleaseSummary.js +9 -1
  12. package/dist/esm/buildSyntheticChangelogEntry.d.ts +5 -0
  13. package/dist/esm/buildSyntheticChangelogEntry.js +13 -0
  14. package/dist/esm/changelogJsonFile.d.ts +4 -0
  15. package/dist/esm/changelogJsonFile.js +68 -0
  16. package/dist/esm/checkWorkTypesDrift.d.ts +11 -0
  17. package/dist/esm/checkWorkTypesDrift.js +110 -0
  18. package/dist/esm/collectPolicyViolations.d.ts +6 -0
  19. package/dist/esm/collectPolicyViolations.js +15 -0
  20. package/dist/esm/createGithubRelease.d.ts +12 -2
  21. package/dist/esm/createGithubRelease.js +12 -8
  22. package/dist/esm/createGithubReleaseCommand.js +10 -6
  23. package/dist/esm/decideRelease.d.ts +28 -0
  24. package/dist/esm/decideRelease.js +44 -0
  25. package/dist/esm/defaults.d.ts +8 -0
  26. package/dist/esm/defaults.js +43 -20
  27. package/dist/esm/deriveWorkspaceConfig.js +3 -0
  28. package/dist/esm/determineBumpFromCommits.d.ts +6 -1
  29. package/dist/esm/determineBumpFromCommits.js +9 -3
  30. package/dist/esm/generateChangelogs.js +14 -29
  31. package/dist/esm/index.d.ts +2 -43
  32. package/dist/esm/index.js +0 -82
  33. package/dist/esm/init/templates.js +2 -2
  34. package/dist/esm/loadConfig.d.ts +10 -1
  35. package/dist/esm/loadConfig.js +110 -24
  36. package/dist/esm/parseCommitMessage.d.ts +8 -2
  37. package/dist/esm/parseCommitMessage.js +32 -3
  38. package/dist/esm/prepareCommand.js +51 -9
  39. package/dist/esm/publish.d.ts +0 -1
  40. package/dist/esm/publish.js +3 -3
  41. package/dist/esm/publishCommand.js +31 -3
  42. package/dist/esm/releasePrepare.js +109 -41
  43. package/dist/esm/releasePrepareMono.js +156 -87
  44. package/dist/esm/releasePrepareProject.d.ts +9 -0
  45. package/dist/esm/releasePrepareProject.js +121 -0
  46. package/dist/esm/renderReleaseNotes.js +2 -1
  47. package/dist/esm/reportPrepare.js +88 -24
  48. package/dist/esm/resolveCommandTags.js +16 -6
  49. package/dist/esm/resolveReleaseTags.d.ts +8 -1
  50. package/dist/esm/resolveReleaseTags.js +11 -7
  51. package/dist/esm/runGitCliff.d.ts +2 -0
  52. package/dist/esm/runGitCliff.js +27 -0
  53. package/dist/esm/stripEmojiPrefix.d.ts +1 -0
  54. package/dist/esm/stripEmojiPrefix.js +7 -0
  55. package/dist/esm/syncWorkTypes.d.ts +10 -0
  56. package/dist/esm/syncWorkTypes.js +90 -0
  57. package/dist/esm/types.d.ts +72 -14
  58. package/dist/esm/validateConfig.js +26 -0
  59. package/dist/esm/validateOnlyExcludesStrandedDependents.d.ts +14 -0
  60. package/dist/esm/validateOnlyExcludesStrandedDependents.js +109 -0
  61. package/dist/esm/work-types.json +127 -0
  62. package/dist/esm/work-types.schema.json +73 -0
  63. package/dist/esm/workTypesData.d.ts +14 -0
  64. package/dist/esm/workTypesData.js +59 -0
  65. package/dist/esm/workTypesUtils.d.ts +5 -0
  66. package/dist/esm/workTypesUtils.js +16 -0
  67. package/package.json +9 -3
  68. package/presets/labels/common.yaml +9 -6
  69. package/schemas/label-map.json +24 -0
  70. package/dist/esm/generateChangelogJson.d.ts +0 -7
  71. package/dist/esm/generateChangelogJson.js +0 -232
  72. package/dist/esm/version.d.ts +0 -1
  73. package/dist/esm/version.js +0 -4
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { parseArgs, translateParseError } from "@williamthorsen/nmr-core";
2
+ import { parseArgs, readPackageVersion, translateParseError } from "@williamthorsen/nmr-core";
3
+ import { checkWorkTypesDrift } from "../checkWorkTypesDrift.js";
3
4
  import { commitCommand } from "../commitCommand.js";
4
5
  import { createGithubReleaseCommand } from "../createGithubReleaseCommand.js";
5
6
  import { initCommand } from "../init/initCommand.js";
@@ -10,8 +11,9 @@ import { showTagPrefixesCommand } from "../showTagPrefixesCommand.js";
10
11
  import { generateCommand } from "../sync-labels/generateCommand.js";
11
12
  import { syncLabelsInitCommand } from "../sync-labels/initCommand.js";
12
13
  import { syncLabelsCommand } from "../sync-labels/syncCommand.js";
14
+ import { syncWorkTypes } from "../syncWorkTypes.js";
13
15
  import { tagCommand } from "../tagCommand.js";
14
- import { VERSION } from "../version.js";
16
+ const VERSION = readPackageVersion(import.meta.url);
15
17
  function showUsage() {
16
18
  console.info(`
17
19
  Usage: release-kit <command> [options]
@@ -26,6 +28,7 @@ Commands:
26
28
  show-tag-prefixes Show derived and declared legacy tag prefixes per workspace
27
29
  init Initialize release-kit in the current repository
28
30
  sync-labels Manage GitHub label synchronization
31
+ work-types Check for or sync work-type taxonomy drift against the upstream canonical
29
32
 
30
33
  Options:
31
34
  --dry-run Preview changes without writing files
@@ -171,6 +174,49 @@ legacy tag prefixes. Surfaces any release-shaped tags whose prefix is neither a
171
174
  derived prefix nor declared in \`legacyIdentities\`, with a copy-pasteable
172
175
  config snippet.
173
176
 
177
+ Options:
178
+ --help, -h Show this help message
179
+ `);
180
+ }
181
+ function showWorkTypesHelp() {
182
+ console.info(`
183
+ Usage: release-kit work-types <subcommand>
184
+
185
+ Manage the canonical work-types taxonomy used by changelog and release-notes generation.
186
+
187
+ Subcommands:
188
+ check Compare the local work-types.json against the upstream codeassembly canonical
189
+ sync Overwrite the local work-types.json with the upstream contents
190
+
191
+ Exit codes (check):
192
+ 0 Match (or upstream missing \u2014 transitional warning printed)
193
+ 1 Drift detected
194
+ 2 Network error
195
+ 3 Schema mismatch
196
+
197
+ Options:
198
+ --help, -h Show this help message
199
+ `);
200
+ }
201
+ function showWorkTypesCheckHelp() {
202
+ console.info(`
203
+ Usage: release-kit work-types check
204
+
205
+ Compare the local work-types.json against the upstream codeassembly canonical and report
206
+ drift. Exit 0 on match, 1 on drift, 0 + warning when upstream is missing (transitional),
207
+ 2 on network error, 3 on schema mismatch.
208
+
209
+ Options:
210
+ --help, -h Show this help message
211
+ `);
212
+ }
213
+ function showWorkTypesSyncHelp() {
214
+ console.info(`
215
+ Usage: release-kit work-types sync
216
+
217
+ Fetch the upstream work-types.json, validate its top-level shape, and overwrite the local
218
+ copy with the upstream content (formatted with 2-space indent + trailing newline).
219
+
174
220
  Options:
175
221
  --help, -h Show this help message
176
222
  `);
@@ -179,11 +225,13 @@ function showPublishHelp() {
179
225
  console.info(`
180
226
  Usage: release-kit publish [options]
181
227
 
182
- Publish packages that have release tags on HEAD.
228
+ Publish packages that have release tags on HEAD. Operates only on workspaces where
229
+ package.json#private is absent or false. Without --tags, unpublishable workspaces are
230
+ silently filtered out. With --tags, naming an unpublishable tag is an error.
183
231
 
184
232
  Options:
185
233
  --dry-run Preview without publishing
186
- --no-git-checks Skip git checks (pnpm only)
234
+ --no-git-checks Skip the clean-working-tree check
187
235
  --tags=tag1,tag2 Only publish the named tags (comma-separated, full tag names)
188
236
  --provenance Generate provenance statement (requires OIDC, not supported by classic yarn)
189
237
  --help, -h Show this help message
@@ -341,6 +389,51 @@ if (command === "sync-labels") {
341
389
  showSyncLabelsHelp();
342
390
  process.exit(1);
343
391
  }
392
+ if (command === "work-types") {
393
+ const subcommand = flags[0];
394
+ const subflags = flags.slice(1);
395
+ if (subcommand === "--help" || subcommand === "-h" || subcommand === void 0) {
396
+ showWorkTypesHelp();
397
+ process.exit(0);
398
+ }
399
+ if (subcommand === "check") {
400
+ if (subflags.some((f) => f === "--help" || f === "-h")) {
401
+ showWorkTypesCheckHelp();
402
+ process.exit(0);
403
+ }
404
+ if (subflags.length > 0) {
405
+ console.error(`Error: Unknown option: ${subflags[0]}`);
406
+ process.exit(1);
407
+ }
408
+ const result = await checkWorkTypesDrift();
409
+ if (result.exitCode === 0) {
410
+ console.info(result.message);
411
+ } else {
412
+ console.error(result.message);
413
+ }
414
+ process.exit(result.exitCode);
415
+ }
416
+ if (subcommand === "sync") {
417
+ if (subflags.some((f) => f === "--help" || f === "-h")) {
418
+ showWorkTypesSyncHelp();
419
+ process.exit(0);
420
+ }
421
+ if (subflags.length > 0) {
422
+ console.error(`Error: Unknown option: ${subflags[0]}`);
423
+ process.exit(1);
424
+ }
425
+ const result = await syncWorkTypes();
426
+ if (result.exitCode === 0) {
427
+ console.info(result.message);
428
+ } else {
429
+ console.error(result.message);
430
+ }
431
+ process.exit(result.exitCode);
432
+ }
433
+ console.error(`Error: Unknown subcommand: ${subcommand}`);
434
+ showWorkTypesHelp();
435
+ process.exit(1);
436
+ }
344
437
  console.error(`Error: Unknown command: ${command}`);
345
438
  showUsage();
346
439
  process.exit(1);
@@ -0,0 +1,4 @@
1
+ import type { GenerateChangelogOptions } from './generateChangelogs.ts';
2
+ import type { ChangelogEntry, ReleaseConfig } from './types.ts';
3
+ export declare function stripGroupDecorations(group: string): string;
4
+ export declare function buildChangelogEntries(config: Pick<ReleaseConfig, 'cliffConfigPath' | 'changelogJson'>, tag: string, options?: GenerateChangelogOptions): ChangelogEntry[];
@@ -0,0 +1,173 @@
1
+ import { extractVersion } from "./changelogJsonUtils.js";
2
+ import { DEFAULT_WORK_TYPES } from "./defaults.js";
3
+ import { COMMIT_PREPROCESSOR_PATTERNS } from "./parseCommitMessage.js";
4
+ import { resolveCliffConfigPath } from "./resolveCliffConfigPath.js";
5
+ import { runGitCliff } from "./runGitCliff.js";
6
+ import { stripEmojiPrefix } from "./stripEmojiPrefix.js";
7
+ import { isRecord, isUnknownArray } from "./typeGuards.js";
8
+ const HTML_COMMENT_PREFIX_PATTERN = /^<!--[^>]*-->/;
9
+ const CANONICAL_SECTION_ORDER = new Map(
10
+ Object.values(DEFAULT_WORK_TYPES).map((config, index) => [stripGroupDecorations(config.header), index])
11
+ );
12
+ function canonicalSectionPriority(title) {
13
+ const index = CANONICAL_SECTION_ORDER.get(stripGroupDecorations(title));
14
+ return index ?? Number.POSITIVE_INFINITY;
15
+ }
16
+ function stripGroupDecorations(group) {
17
+ return stripEmojiPrefix(group.replace(HTML_COMMENT_PREFIX_PATTERN, ""));
18
+ }
19
+ function buildChangelogEntries(config, tag, options) {
20
+ const resolvedConfigPath = resolveCliffConfigPath(config.cliffConfigPath, import.meta.url);
21
+ const cliffArgs = ["--context", "--tag", tag];
22
+ if (options?.tagPattern !== void 0) {
23
+ cliffArgs.push("--tag-pattern", options.tagPattern);
24
+ }
25
+ for (const includePath of options?.includePaths ?? []) {
26
+ cliffArgs.push("--include-path", includePath);
27
+ }
28
+ try {
29
+ const contextJson = runGitCliff(resolvedConfigPath, cliffArgs, ["pipe", "pipe", "inherit"]);
30
+ const releases = parseCliffContext(contextJson);
31
+ const devOnlySections = new Set(config.changelogJson.devOnlySections);
32
+ return transformReleases(releases, devOnlySections);
33
+ } catch (error) {
34
+ throw new Error(
35
+ `Failed to build changelog entries for tag ${tag}: ${error instanceof Error ? error.message : String(error)}`
36
+ );
37
+ }
38
+ }
39
+ function parseCliffContext(json) {
40
+ const parsed = JSON.parse(json);
41
+ if (!isUnknownArray(parsed)) {
42
+ throw new TypeError("Expected git-cliff --context output to be an array");
43
+ }
44
+ return parsed.map(toCliffContextRelease);
45
+ }
46
+ function toCliffContextRelease(value) {
47
+ if (!isRecord(value)) {
48
+ return {};
49
+ }
50
+ const release = {};
51
+ if (typeof value.version === "string") {
52
+ release.version = value.version;
53
+ }
54
+ if (typeof value.timestamp === "number") {
55
+ release.timestamp = value.timestamp;
56
+ }
57
+ if (isUnknownArray(value.commits)) {
58
+ release.commits = value.commits.map(toCliffContextCommit);
59
+ }
60
+ return release;
61
+ }
62
+ function toCliffContextCommit(value) {
63
+ if (!isRecord(value)) {
64
+ return { message: "" };
65
+ }
66
+ const commit = {
67
+ message: typeof value.message === "string" ? value.message : ""
68
+ };
69
+ if (typeof value.group === "string") {
70
+ commit.group = value.group;
71
+ }
72
+ return commit;
73
+ }
74
+ function transformReleases(releases, devOnlySections) {
75
+ const entries = [];
76
+ const devOnlyNormalised = new Set([...devOnlySections].map(stripGroupDecorations));
77
+ for (const release of releases) {
78
+ if (release.version === void 0) {
79
+ continue;
80
+ }
81
+ const version = extractVersion(release.version);
82
+ const date = release.timestamp !== void 0 ? new Date(release.timestamp * 1e3).toISOString().slice(0, 10) : "unreleased";
83
+ const sectionMap = /* @__PURE__ */ new Map();
84
+ for (const commit of release.commits ?? []) {
85
+ const group = stripCommentPrefix(commit.group ?? "Other");
86
+ const description = extractDescription(commit.message);
87
+ const body = extractBody(commit.message);
88
+ const breaking = subjectHasBreakingMarker(commit.message);
89
+ let items = sectionMap.get(group);
90
+ if (items === void 0) {
91
+ items = [];
92
+ sectionMap.set(group, items);
93
+ }
94
+ const item = { description };
95
+ if (body !== void 0) {
96
+ item.body = body;
97
+ }
98
+ if (breaking) {
99
+ item.breaking = true;
100
+ }
101
+ items.push(item);
102
+ }
103
+ const sections = [];
104
+ for (const [title, items] of sectionMap) {
105
+ if (items.length === 0) {
106
+ continue;
107
+ }
108
+ sections.push({
109
+ title,
110
+ audience: devOnlyNormalised.has(stripGroupDecorations(title)) ? "dev" : "all",
111
+ items
112
+ });
113
+ }
114
+ sections.sort((a, b) => canonicalSectionPriority(a.title) - canonicalSectionPriority(b.title));
115
+ if (sections.length > 0) {
116
+ entries.push({ version, date, sections });
117
+ }
118
+ }
119
+ return entries;
120
+ }
121
+ function stripCommentPrefix(group) {
122
+ return group.replace(HTML_COMMENT_PREFIX_PATTERN, "");
123
+ }
124
+ function subjectHasBreakingMarker(message) {
125
+ let subject = message.split("\n", 1)[0] ?? "";
126
+ for (const pattern of COMMIT_PREPROCESSOR_PATTERNS) {
127
+ subject = subject.replace(pattern, "");
128
+ }
129
+ return /^(?:[^|]+\|)?\w+(?:\([^)]+\))?!:/.test(subject);
130
+ }
131
+ function extractDescription(message) {
132
+ const firstLine = message.split("\n")[0] ?? message;
133
+ const afterColon = firstLine.split(": ").slice(1).join(": ");
134
+ if (afterColon.length > 0) {
135
+ return afterColon.charAt(0).toUpperCase() + afterColon.slice(1);
136
+ }
137
+ return firstLine;
138
+ }
139
+ const TRAILER_PATTERNS = [
140
+ /^Signed-off-by:/i,
141
+ /^Co-authored-by:/i,
142
+ /^(Closes|Fixes|Resolves)\s+#\d+\s*$/i,
143
+ /^https?:\/\/\S+\/pull\/\d+\/?\s*$/
144
+ ];
145
+ function extractBody(message) {
146
+ const lines = message.split("\n").slice(1);
147
+ let start = 0;
148
+ while (start < lines.length && (lines[start] ?? "").trim() === "") {
149
+ start += 1;
150
+ }
151
+ let end = lines.length;
152
+ while (end > start) {
153
+ const line = lines[end - 1] ?? "";
154
+ const trimmed = line.trim();
155
+ if (trimmed === "") {
156
+ end -= 1;
157
+ continue;
158
+ }
159
+ if (TRAILER_PATTERNS.some((pattern) => pattern.test(trimmed))) {
160
+ end -= 1;
161
+ continue;
162
+ }
163
+ break;
164
+ }
165
+ if (end <= start) {
166
+ return void 0;
167
+ }
168
+ return lines.slice(start, end).join("\n").trim();
169
+ }
170
+ export {
171
+ buildChangelogEntries,
172
+ stripGroupDecorations
173
+ };
@@ -3,5 +3,6 @@ export interface DependencyGraph {
3
3
  packageNameToDir: Map<string, string>;
4
4
  dirToPackageName: Map<string, string>;
5
5
  dependentsOf: Map<string, WorkspaceConfig[]>;
6
+ dependenciesOf: Map<string, Set<string>>;
6
7
  }
7
8
  export declare function buildDependencyGraph(workspaces: readonly WorkspaceConfig[]): DependencyGraph;
@@ -6,6 +6,7 @@ function buildDependencyGraph(workspaces) {
6
6
  const packageNameToDir = /* @__PURE__ */ new Map();
7
7
  const dirToPackageName = /* @__PURE__ */ new Map();
8
8
  const dependentsOf = /* @__PURE__ */ new Map();
9
+ const dependenciesOf = /* @__PURE__ */ new Map();
9
10
  const workspacePackages = /* @__PURE__ */ new Map();
10
11
  for (const workspace of workspaces) {
11
12
  const primaryPackageFile = workspace.packageFiles[0];
@@ -32,9 +33,15 @@ function buildDependencyGraph(workspaces) {
32
33
  } else {
33
34
  existing.push(workspace);
34
35
  }
36
+ const forward = dependenciesOf.get(workspace.dir);
37
+ if (forward === void 0) {
38
+ dependenciesOf.set(workspace.dir, /* @__PURE__ */ new Set([depName]));
39
+ } else {
40
+ forward.add(depName);
41
+ }
35
42
  }
36
43
  }
37
- return { packageNameToDir, dirToPackageName, dependentsOf };
44
+ return { packageNameToDir, dirToPackageName, dependentsOf, dependenciesOf };
38
45
  }
39
46
  function readPackageJsonSubset(filePath) {
40
47
  let content;
@@ -2,7 +2,7 @@ import { stripScope } from "./stripScope.js";
2
2
  function buildReleaseSummary(result) {
3
3
  const sections = [];
4
4
  for (const workspace of result.workspaces) {
5
- if (workspace.status !== "released" || workspace.tag === void 0) {
5
+ if (workspace.status !== "released") {
6
6
  continue;
7
7
  }
8
8
  const commits = workspace.commits;
@@ -15,6 +15,14 @@ function buildReleaseSummary(result) {
15
15
  }
16
16
  sections.push(lines.join("\n"));
17
17
  }
18
+ const project = result.project;
19
+ if (project !== void 0 && project.status === "released" && project.commits.length > 0) {
20
+ const lines = [project.tag];
21
+ for (const commit of project.commits) {
22
+ lines.push(`- ${stripScope(commit.message)}`);
23
+ }
24
+ sections.push(lines.join("\n"));
25
+ }
18
26
  return sections.join("\n\n");
19
27
  }
20
28
  export {
@@ -0,0 +1,5 @@
1
+ import type { ChangelogEntry } from './types.ts';
2
+ export declare function buildSyntheticChangelogEntry(propagatedFrom: ReadonlyArray<{
3
+ packageName: string;
4
+ newVersion: string;
5
+ }>, version: string, date: string): ChangelogEntry;
@@ -0,0 +1,13 @@
1
+ function buildSyntheticChangelogEntry(propagatedFrom, version, date) {
2
+ const items = propagatedFrom.map((dep) => ({
3
+ description: `Bumped \`${dep.packageName}\` to ${dep.newVersion}`
4
+ }));
5
+ return {
6
+ version,
7
+ date,
8
+ sections: [{ title: "Dependency updates", audience: "dev", items }]
9
+ };
10
+ }
11
+ export {
12
+ buildSyntheticChangelogEntry
13
+ };
@@ -0,0 +1,4 @@
1
+ import type { ChangelogEntry, ReleaseConfig } from './types.ts';
2
+ export declare function resolveChangelogJsonPath(config: Pick<ReleaseConfig, 'changelogJson'>, changelogPath: string): string;
3
+ export declare function writeChangelogJson(filePath: string, entries: ChangelogEntry[]): string;
4
+ export declare function upsertChangelogJson(filePath: string, entries: ChangelogEntry[]): string;
@@ -0,0 +1,68 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import stringify from "json-stringify-pretty-compact";
4
+ import semver from "semver";
5
+ import { isChangelogEntry } from "./changelogJsonUtils.js";
6
+ import { isUnknownArray } from "./typeGuards.js";
7
+ function resolveChangelogJsonPath(config, changelogPath) {
8
+ return join(changelogPath, config.changelogJson.outputPath);
9
+ }
10
+ function writeChangelogJson(filePath, entries) {
11
+ const sorted = sortNewestFirst(entries);
12
+ mkdirSync(dirname(filePath), { recursive: true });
13
+ writeFileSync(filePath, stringify(sorted, { maxLength: 100 }) + "\n", "utf8");
14
+ return filePath;
15
+ }
16
+ function upsertChangelogJson(filePath, entries) {
17
+ const existing = readExistingEntries(filePath);
18
+ const merged = mergeEntries(entries, existing);
19
+ mkdirSync(dirname(filePath), { recursive: true });
20
+ writeFileSync(filePath, stringify(merged, { maxLength: 100 }) + "\n", "utf8");
21
+ return filePath;
22
+ }
23
+ function sortNewestFirst(entries) {
24
+ return [...entries].sort((a, b) => compareVersionsDescending(a.version, b.version));
25
+ }
26
+ function readExistingEntries(filePath) {
27
+ if (!existsSync(filePath)) {
28
+ return [];
29
+ }
30
+ try {
31
+ const content = readFileSync(filePath, "utf8");
32
+ const parsed = JSON.parse(content);
33
+ if (!isUnknownArray(parsed)) {
34
+ return [];
35
+ }
36
+ return parsed.filter(isChangelogEntry);
37
+ } catch (error) {
38
+ console.warn(
39
+ `Warning: could not parse existing ${filePath}: ${error instanceof Error ? error.message : String(error)}; treating as empty`
40
+ );
41
+ return [];
42
+ }
43
+ }
44
+ function mergeEntries(newEntries, existingEntries) {
45
+ const versionMap = /* @__PURE__ */ new Map();
46
+ for (const entry of existingEntries) {
47
+ versionMap.set(entry.version, entry);
48
+ }
49
+ for (const entry of newEntries) {
50
+ versionMap.set(entry.version, entry);
51
+ }
52
+ return sortNewestFirst(versionMap.values());
53
+ }
54
+ function compareVersionsDescending(a, b) {
55
+ const aValid = semver.valid(a);
56
+ const bValid = semver.valid(b);
57
+ if (aValid && bValid) return semver.rcompare(aValid, bValid);
58
+ if (aValid) return -1;
59
+ if (bValid) return 1;
60
+ if (a > b) return -1;
61
+ if (a < b) return 1;
62
+ return 0;
63
+ }
64
+ export {
65
+ resolveChangelogJsonPath,
66
+ upsertChangelogJson,
67
+ writeChangelogJson
68
+ };
@@ -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;