@rheged-studio/changelog-core 1.1.1
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/LICENSE +21 -0
- package/README.md +92 -0
- package/changelog-contract.md +129 -0
- package/config.json +9 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +106 -0
- package/dist/commands/add-links.d.ts +8 -0
- package/dist/commands/add-links.d.ts.map +1 -0
- package/dist/commands/add-links.js +258 -0
- package/dist/commands/backfill-commits.d.ts +19 -0
- package/dist/commands/backfill-commits.d.ts.map +1 -0
- package/dist/commands/backfill-commits.js +236 -0
- package/dist/commands/check-completeness.d.ts +13 -0
- package/dist/commands/check-completeness.d.ts.map +1 -0
- package/dist/commands/check-completeness.js +164 -0
- package/dist/commands/enrich.d.ts +17 -0
- package/dist/commands/enrich.d.ts.map +1 -0
- package/dist/commands/enrich.js +194 -0
- package/dist/commands/finalise.d.ts +25 -0
- package/dist/commands/finalise.d.ts.map +1 -0
- package/dist/commands/finalise.js +249 -0
- package/dist/commands/set-affected-packages.d.ts +13 -0
- package/dist/commands/set-affected-packages.d.ts.map +1 -0
- package/dist/commands/set-affected-packages.js +207 -0
- package/dist/commands/validate.d.ts +6 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +293 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/lib/blank.d.ts +5 -0
- package/dist/lib/blank.d.ts.map +1 -0
- package/dist/lib/blank.js +6 -0
- package/dist/lib/changelog.d.ts +6 -0
- package/dist/lib/changelog.d.ts.map +1 -0
- package/dist/lib/changelog.js +47 -0
- package/dist/lib/cli-entry.d.ts +10 -0
- package/dist/lib/cli-entry.d.ts.map +1 -0
- package/dist/lib/cli-entry.js +22 -0
- package/dist/lib/commit-count.d.ts +16 -0
- package/dist/lib/commit-count.d.ts.map +1 -0
- package/dist/lib/commit-count.js +72 -0
- package/dist/lib/config.d.ts +25 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +156 -0
- package/dist/lib/derive-packages.d.ts +10 -0
- package/dist/lib/derive-packages.d.ts.map +1 -0
- package/dist/lib/derive-packages.js +42 -0
- package/dist/lib/enrich.d.ts +29 -0
- package/dist/lib/enrich.d.ts.map +1 -0
- package/dist/lib/enrich.js +54 -0
- package/dist/lib/frontmatter.d.ts +11 -0
- package/dist/lib/frontmatter.d.ts.map +1 -0
- package/dist/lib/frontmatter.js +355 -0
- package/dist/lib/stamp.d.ts +10 -0
- package/dist/lib/stamp.d.ts.map +1 -0
- package/dist/lib/stamp.js +35 -0
- package/dist/lib/vendor/issue-keys.d.ts +16 -0
- package/dist/lib/vendor/issue-keys.d.ts.map +1 -0
- package/dist/lib/vendor/issue-keys.js +29 -0
- package/package.json +124 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// Validates the individual dated changelog entries under `changelog/`.
|
|
2
|
+
//
|
|
3
|
+
// The single authored changelog validator (A-369): this is the only copy —
|
|
4
|
+
// the root `validate:changelog` script and consumers both run it.
|
|
5
|
+
// - `version` is accepted (typed-when-present semver string).
|
|
6
|
+
// - `affected_packages` is accepted (typed-when-present string array) — owned
|
|
7
|
+
// by the merge-time set-affected-packages step on monorepo consumers.
|
|
8
|
+
// - the REQUIRED set is relaxed to title/created_at/category/breaking so that
|
|
9
|
+
// both backfilled historical entries (no branch/author/stats) and in-flight
|
|
10
|
+
// entries (no version/merged_at/pr/commit/stats until enriched) validate.
|
|
11
|
+
//
|
|
12
|
+
// The pure `validateEntry(name, raw)` returns an array of error strings (empty
|
|
13
|
+
// means valid), so it's trivially unit-testable; main() walks the directory.
|
|
14
|
+
// Zero-dep — Node built-ins + the package's frontmatter parser.
|
|
15
|
+
import { isCliEntry } from "../lib/cli-entry.js";
|
|
16
|
+
import { loadConfig } from "../lib/config.js";
|
|
17
|
+
import { parseFrontmatter } from "../lib/frontmatter.js";
|
|
18
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
19
|
+
import { basename, join } from "node:path";
|
|
20
|
+
import { argv } from "node:process";
|
|
21
|
+
const FILENAME_RE = /^(\d{8})-(\d{6})-([a-z0-9-]+)\.md$/;
|
|
22
|
+
const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
23
|
+
// SemVer 2.0.0: prerelease and build identifiers are dot-separated and may
|
|
24
|
+
// contain ASCII alphanumerics and hyphens (e.g. 1.2.3-rc-1, 1.2.3+build-45).
|
|
25
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
26
|
+
const SHA7_RE = /^[0-9a-f]{7}$/;
|
|
27
|
+
const ISSUE_RE = /^[A-Z]+-\d+$/;
|
|
28
|
+
const CATEGORIES = new Set([
|
|
29
|
+
"chore",
|
|
30
|
+
"docs",
|
|
31
|
+
"feature",
|
|
32
|
+
"fix",
|
|
33
|
+
"perf",
|
|
34
|
+
"refactor",
|
|
35
|
+
]);
|
|
36
|
+
const SECTION_RE = /^##\s+(Breaking|Added|Changed|Fixed)\b/m;
|
|
37
|
+
const REQUIRED = ["title", "created_at", "category", "breaking"];
|
|
38
|
+
/**
|
|
39
|
+
* True when a value is set to something meaningful (not null/undefined/"").
|
|
40
|
+
*/
|
|
41
|
+
function present(value) {
|
|
42
|
+
return value !== null && value !== undefined && value !== "";
|
|
43
|
+
}
|
|
44
|
+
function isInt(value) {
|
|
45
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
46
|
+
}
|
|
47
|
+
function isNonNegInt(value) {
|
|
48
|
+
return isInt(value) && value >= 0;
|
|
49
|
+
}
|
|
50
|
+
function isStringArray(value) {
|
|
51
|
+
return (Array.isArray(value) && value.every((item) => typeof item === "string"));
|
|
52
|
+
}
|
|
53
|
+
function asIso(value) {
|
|
54
|
+
// The frontmatter parser only ever yields strings for timestamps, so a
|
|
55
|
+
// non-string is treated as absent.
|
|
56
|
+
return typeof value === "string" ? value : "";
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Validate one entry. Returns an array of human-readable error strings.
|
|
60
|
+
*/
|
|
61
|
+
export function validateEntry(name, raw) {
|
|
62
|
+
const errors = [];
|
|
63
|
+
function fail(message) {
|
|
64
|
+
errors.push(`${name}: ${message}`);
|
|
65
|
+
}
|
|
66
|
+
if (!FILENAME_RE.test(name)) {
|
|
67
|
+
fail("filename must match YYYYMMDD-HHMMSS-<slug>.md (slug: [a-z0-9-]+)");
|
|
68
|
+
return errors;
|
|
69
|
+
}
|
|
70
|
+
let parsed;
|
|
71
|
+
try {
|
|
72
|
+
parsed = parseFrontmatter(raw);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
76
|
+
fail(`frontmatter unparseable: ${message}`);
|
|
77
|
+
return errors;
|
|
78
|
+
}
|
|
79
|
+
const fm = parsed.data ?? {};
|
|
80
|
+
const body = parsed.content ?? "";
|
|
81
|
+
for (const key of REQUIRED) {
|
|
82
|
+
if (!(key in fm)) {
|
|
83
|
+
fail(`missing required field: ${key}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if ("title" in fm &&
|
|
87
|
+
(typeof fm.title !== "string" || fm.title.trim() === "")) {
|
|
88
|
+
fail("title must be a non-empty string");
|
|
89
|
+
}
|
|
90
|
+
if ("release_note" in fm &&
|
|
91
|
+
fm.release_note !== null &&
|
|
92
|
+
typeof fm.release_note !== "string") {
|
|
93
|
+
fail("release_note must be a string or null when present");
|
|
94
|
+
}
|
|
95
|
+
if (present(fm.version) &&
|
|
96
|
+
(typeof fm.version !== "string" || !SEMVER_RE.test(fm.version))) {
|
|
97
|
+
fail(`version must be a semver string when set (got ${JSON.stringify(fm.version)})`);
|
|
98
|
+
}
|
|
99
|
+
if ("created_at" in fm && !ISO_UTC_RE.test(asIso(fm.created_at))) {
|
|
100
|
+
fail(`created_at must be ISO 8601 UTC with Z suffix (got ${JSON.stringify(fm.created_at)})`);
|
|
101
|
+
}
|
|
102
|
+
if (present(fm.merged_at) && !ISO_UTC_RE.test(asIso(fm.merged_at))) {
|
|
103
|
+
fail("merged_at must be ISO 8601 UTC with Z suffix when set");
|
|
104
|
+
}
|
|
105
|
+
if ("branch" in fm &&
|
|
106
|
+
(typeof fm.branch !== "string" || fm.branch.trim() === "")) {
|
|
107
|
+
fail("branch must be a non-empty string when present");
|
|
108
|
+
}
|
|
109
|
+
if (present(fm.pr) && !isInt(fm.pr)) {
|
|
110
|
+
fail("pr must be an integer when set");
|
|
111
|
+
}
|
|
112
|
+
if (present(fm.commit) && !SHA7_RE.test(String(fm.commit))) {
|
|
113
|
+
fail("commit must be a 7-char hex SHA when set");
|
|
114
|
+
}
|
|
115
|
+
if ("author" in fm &&
|
|
116
|
+
(typeof fm.author !== "string" || fm.author.trim() === "")) {
|
|
117
|
+
fail("author must be a non-empty string when present");
|
|
118
|
+
}
|
|
119
|
+
if ("co_authors" in fm && !isStringArray(fm.co_authors)) {
|
|
120
|
+
fail("co_authors must be an array of strings (use [] when none)");
|
|
121
|
+
}
|
|
122
|
+
if ("category" in fm && !CATEGORIES.has(String(fm.category))) {
|
|
123
|
+
fail(`category must be one of: ${[...CATEGORIES].join(", ")}`);
|
|
124
|
+
}
|
|
125
|
+
if ("breaking" in fm && typeof fm.breaking !== "boolean") {
|
|
126
|
+
fail("breaking must be a boolean");
|
|
127
|
+
}
|
|
128
|
+
if ("issues" in fm) {
|
|
129
|
+
if (isStringArray(fm.issues)) {
|
|
130
|
+
for (const id of fm.issues) {
|
|
131
|
+
if (!ISSUE_RE.test(id)) {
|
|
132
|
+
fail(`issues entry ${JSON.stringify(id)} must match [A-Z]+-\\d+`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
fail("issues must be an array of strings when present");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// affected_packages is owned by the merge-time set-affected-packages step on
|
|
141
|
+
// monorepo consumers. The author emits an empty array as a placeholder; the
|
|
142
|
+
// step overwrites it with the canonical list derived from the PR diff. Only
|
|
143
|
+
// enforce structure (string array) when present.
|
|
144
|
+
if ("affected_packages" in fm &&
|
|
145
|
+
fm.affected_packages !== null &&
|
|
146
|
+
!isStringArray(fm.affected_packages)) {
|
|
147
|
+
fail("affected_packages must be an array of strings (use [] when unpopulated)");
|
|
148
|
+
}
|
|
149
|
+
// PR stats live under stats: { files_changed, loc_added, loc_removed, commits }.
|
|
150
|
+
const statKeys = ["files_changed", "loc_added", "loc_removed", "commits"];
|
|
151
|
+
for (const key of statKeys) {
|
|
152
|
+
if (key in fm) {
|
|
153
|
+
fail(`${key} must be under stats, not top-level`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// stats is optional (filled by enrichment), but must be a well-formed object
|
|
157
|
+
// with non-negative integer values when present.
|
|
158
|
+
if (present(fm.stats)) {
|
|
159
|
+
if (typeof fm.stats !== "object" || Array.isArray(fm.stats)) {
|
|
160
|
+
fail("stats must be an object");
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const stats = fm.stats;
|
|
164
|
+
for (const key of statKeys) {
|
|
165
|
+
if (key in stats && present(stats[key]) && !isNonNegInt(stats[key])) {
|
|
166
|
+
fail(`stats.${key} must be a non-negative integer when set`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// The schema requires "## Breaking" to be the FIRST body section when
|
|
172
|
+
// breaking: true — not merely present somewhere.
|
|
173
|
+
if (fm.breaking === true) {
|
|
174
|
+
const firstSection = body.match(/^##\s+([A-Za-z]+)\b/m)?.[1];
|
|
175
|
+
if (firstSection !== "Breaking") {
|
|
176
|
+
fail('breaking: true requires "## Breaking" as the first body section');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!SECTION_RE.test(body)) {
|
|
180
|
+
fail("body must contain at least one of: ## Breaking | ## Added | ## Changed | ## Fixed");
|
|
181
|
+
}
|
|
182
|
+
return errors;
|
|
183
|
+
}
|
|
184
|
+
const USAGE = `validate — validate the dated changelog/ entries against the contract
|
|
185
|
+
|
|
186
|
+
Usage:
|
|
187
|
+
changelog-core validate Validate every changelog/<ts>-<slug>.md entry
|
|
188
|
+
changelog-core validate --self-test Run the built-in offline smoke test
|
|
189
|
+
changelog-core validate --help Show this message (alias: -h)
|
|
190
|
+
|
|
191
|
+
Exits 1 when an entry is invalid, 2 when the changelog directory is missing.`;
|
|
192
|
+
// Offline smoke test: run the pure validateEntry over a known-good and a
|
|
193
|
+
// known-bad entry. This is a light check that the exported logic is wired up,
|
|
194
|
+
// with no filesystem or network access.
|
|
195
|
+
function selfTest() {
|
|
196
|
+
const cases = [];
|
|
197
|
+
const goodName = "20260101-000000-a-1-sample.md";
|
|
198
|
+
const goodRaw = `---
|
|
199
|
+
title: A sample entry
|
|
200
|
+
created_at: '2026-01-01T00:00:00Z'
|
|
201
|
+
category: feature
|
|
202
|
+
breaking: false
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Added
|
|
206
|
+
|
|
207
|
+
- Something.
|
|
208
|
+
`;
|
|
209
|
+
cases.push({
|
|
210
|
+
name: "a well-formed entry produces no errors",
|
|
211
|
+
ok: validateEntry(goodName, goodRaw).length === 0,
|
|
212
|
+
});
|
|
213
|
+
cases.push({
|
|
214
|
+
name: "a bad filename is rejected",
|
|
215
|
+
ok: validateEntry("not-a-valid-name.md", goodRaw).length > 0,
|
|
216
|
+
});
|
|
217
|
+
const badCategory = goodRaw.replace("category: feature", "category: nope");
|
|
218
|
+
cases.push({
|
|
219
|
+
name: "an unknown category is rejected",
|
|
220
|
+
ok: validateEntry(goodName, badCategory).length > 0,
|
|
221
|
+
});
|
|
222
|
+
const missingSection = `---
|
|
223
|
+
title: No body section
|
|
224
|
+
created_at: '2026-01-01T00:00:00Z'
|
|
225
|
+
category: docs
|
|
226
|
+
breaking: false
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
Just prose, no heading.
|
|
230
|
+
`;
|
|
231
|
+
cases.push({
|
|
232
|
+
name: "a body with no recognised section is rejected",
|
|
233
|
+
ok: validateEntry(goodName, missingSection).length > 0,
|
|
234
|
+
});
|
|
235
|
+
let failed = 0;
|
|
236
|
+
for (const { name, ok } of cases) {
|
|
237
|
+
if (ok) {
|
|
238
|
+
console.log(` ok ${name}`);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
failed += 1;
|
|
242
|
+
console.log(` FAIL ${name}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
console.log(`\n${cases.length - failed}/${cases.length} passed`);
|
|
246
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
247
|
+
}
|
|
248
|
+
function listEntries(directory) {
|
|
249
|
+
let stat;
|
|
250
|
+
try {
|
|
251
|
+
stat = statSync(directory);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
console.error(`changelog directory not found: ${directory}`);
|
|
255
|
+
process.exit(2);
|
|
256
|
+
}
|
|
257
|
+
if (!stat.isDirectory()) {
|
|
258
|
+
console.error(`${directory} is not a directory`);
|
|
259
|
+
process.exit(2);
|
|
260
|
+
}
|
|
261
|
+
return readdirSync(directory)
|
|
262
|
+
.filter((name) => name.endsWith(".md") && name !== "README.md")
|
|
263
|
+
.map((name) => join(directory, name));
|
|
264
|
+
}
|
|
265
|
+
export function main() {
|
|
266
|
+
const args = argv.slice(2);
|
|
267
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
268
|
+
console.log(USAGE);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (args.includes("--self-test")) {
|
|
272
|
+
selfTest();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const files = listEntries(loadConfig().changelogDir);
|
|
276
|
+
const errors = [];
|
|
277
|
+
for (const file of files) {
|
|
278
|
+
errors.push(...validateEntry(basename(file), readFileSync(file, "utf8")));
|
|
279
|
+
}
|
|
280
|
+
if (errors.length > 0) {
|
|
281
|
+
console.error(`Changelog validation failed with ${errors.length} error(s):\n`);
|
|
282
|
+
for (const message of errors) {
|
|
283
|
+
console.error(` - ${message}`);
|
|
284
|
+
}
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
console.log(`Changelog validation passed (${files.length} entr${files.length === 1 ? "y" : "ies"} checked).`);
|
|
288
|
+
}
|
|
289
|
+
// Only run the filesystem pass when invoked as a CLI, not when imported (e.g.
|
|
290
|
+
// by unit tests exercising validateEntry).
|
|
291
|
+
if (isCliEntry(import.meta.filename)) {
|
|
292
|
+
main();
|
|
293
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API for @acme-skunkworks/changelog-core.
|
|
3
|
+
*
|
|
4
|
+
* Library helpers and command pure functions — the CLI lives in `cli.ts`.
|
|
5
|
+
*/
|
|
6
|
+
export { frontmatterBranch, rewriteBody, splitFrontmatter, } from "./commands/add-links.js";
|
|
7
|
+
export { backfillEntry, resolvePrNumber } from "./commands/backfill-commits.js";
|
|
8
|
+
export { changelogEntryPattern, checkCompleteness, type CompletenessResult, hasChangelogEntry, isReleaseTriggering, } from "./commands/check-completeness.js";
|
|
9
|
+
export { enrichEntry, readEnvironmentInput } from "./commands/enrich.js";
|
|
10
|
+
export { finaliseEntry, makeResolver, type PrResolver, type ResolvedPr, } from "./commands/finalise.js";
|
|
11
|
+
export { buildAffectedPackagesFrontmatter } from "./commands/set-affected-packages.js";
|
|
12
|
+
export { validateEntry } from "./commands/validate.js";
|
|
13
|
+
export { blank } from "./lib/blank.js";
|
|
14
|
+
export { findEntryByBranch } from "./lib/changelog.js";
|
|
15
|
+
export { isCliEntry } from "./lib/cli-entry.js";
|
|
16
|
+
export { nonMergeCommitCount, realRunner, type Runner, } from "./lib/commit-count.js";
|
|
17
|
+
export { type ChangelogConfig, loadConfig, parseConfig, resetConfigCache, } from "./lib/config.js";
|
|
18
|
+
export { derivePackagesFromPaths, type DerivePackagesOptions, } from "./lib/derive-packages.js";
|
|
19
|
+
export { enrichFrontmatter, type EnrichInput } from "./lib/enrich.js";
|
|
20
|
+
export { type FrontmatterData, type FrontmatterValue, type ParsedFrontmatter, parseFrontmatter, stringifyFrontmatter, } from "./lib/frontmatter.js";
|
|
21
|
+
export { readPackageVersion, stampVersion } from "./lib/stamp.js";
|
|
22
|
+
export { buildIssueRe, escapeRegex } from "./lib/vendor/issue-keys.js";
|
|
23
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,EACL,iBAAiB,EACjB,WAAW,EACX,gBAAgB,GACjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AAChF,OAAO,EACL,qBAAqB,EACrB,iBAAiB,EACjB,KAAK,kBAAkB,EACvB,iBAAiB,EACjB,mBAAmB,GACpB,MAAM,kCAAkC,CAAC;AAC1C,OAAO,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AACzE,OAAO,EACL,aAAa,EACb,YAAY,EACZ,KAAK,UAAU,EACf,KAAK,UAAU,GAChB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,gCAAgC,EAAE,MAAM,qCAAqC,CAAC;AACvF,OAAO,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAIvD,OAAO,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AACvC,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,EACL,mBAAmB,EACnB,UAAU,EACV,KAAK,MAAM,GACZ,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,KAAK,eAAe,EACpB,UAAU,EACV,WAAW,EACX,gBAAgB,GACjB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,uBAAuB,EACvB,KAAK,qBAAqB,GAC3B,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,iBAAiB,EAAE,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AACtE,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACtB,gBAAgB,EAChB,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,kBAAkB,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAClE,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API for @acme-skunkworks/changelog-core.
|
|
3
|
+
*
|
|
4
|
+
* Library helpers and command pure functions — the CLI lives in `cli.ts`.
|
|
5
|
+
*/
|
|
6
|
+
// --- commands (pure helpers) ------------------------------------------------
|
|
7
|
+
export { frontmatterBranch, rewriteBody, splitFrontmatter, } from "./commands/add-links.js";
|
|
8
|
+
export { backfillEntry, resolvePrNumber } from "./commands/backfill-commits.js";
|
|
9
|
+
export { changelogEntryPattern, checkCompleteness, hasChangelogEntry, isReleaseTriggering, } from "./commands/check-completeness.js";
|
|
10
|
+
export { enrichEntry, readEnvironmentInput } from "./commands/enrich.js";
|
|
11
|
+
export { finaliseEntry, makeResolver, } from "./commands/finalise.js";
|
|
12
|
+
export { buildAffectedPackagesFrontmatter } from "./commands/set-affected-packages.js";
|
|
13
|
+
export { validateEntry } from "./commands/validate.js";
|
|
14
|
+
// --- lib --------------------------------------------------------------------
|
|
15
|
+
export { blank } from "./lib/blank.js";
|
|
16
|
+
export { findEntryByBranch } from "./lib/changelog.js";
|
|
17
|
+
export { isCliEntry } from "./lib/cli-entry.js";
|
|
18
|
+
export { nonMergeCommitCount, realRunner, } from "./lib/commit-count.js";
|
|
19
|
+
export { loadConfig, parseConfig, resetConfigCache, } from "./lib/config.js";
|
|
20
|
+
export { derivePackagesFromPaths, } from "./lib/derive-packages.js";
|
|
21
|
+
export { enrichFrontmatter } from "./lib/enrich.js";
|
|
22
|
+
export { parseFrontmatter, stringifyFrontmatter, } from "./lib/frontmatter.js";
|
|
23
|
+
export { readPackageVersion, stampVersion } from "./lib/stamp.js";
|
|
24
|
+
export { buildIssueRe, escapeRegex } from "./lib/vendor/issue-keys.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blank.d.ts","sourceRoot":"","sources":["../../src/lib/blank.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE7C"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find the changelog entry whose frontmatter `branch:` equals `branch`.
|
|
3
|
+
* Returns the matching entry's path, or null if none matches.
|
|
4
|
+
*/
|
|
5
|
+
export declare function findEntryByBranch(branch: string, changelogDirectory?: string): null | string;
|
|
6
|
+
//# sourceMappingURL=changelog.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"changelog.d.ts","sourceRoot":"","sources":["../../src/lib/changelog.ts"],"names":[],"mappings":"AAYA;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,EACd,kBAAkB,SAAwB,GACzC,IAAI,GAAG,MAAM,CAqCf"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Shared helpers for locating changelog entries on disk.
|
|
2
|
+
//
|
|
3
|
+
// `findEntryByBranch` is the one entry-lookup rule the enrichment scripts share,
|
|
4
|
+
// so the rule can't drift between callers — the same reasoning that produced
|
|
5
|
+
// derive-packages.ts.
|
|
6
|
+
import { parseFrontmatter } from "./frontmatter.js";
|
|
7
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
const DEFAULT_CHANGELOG_DIR = "changelog";
|
|
10
|
+
/**
|
|
11
|
+
* Find the changelog entry whose frontmatter `branch:` equals `branch`.
|
|
12
|
+
* Returns the matching entry's path, or null if none matches.
|
|
13
|
+
*/
|
|
14
|
+
export function findEntryByBranch(branch, changelogDirectory = DEFAULT_CHANGELOG_DIR) {
|
|
15
|
+
let names;
|
|
16
|
+
try {
|
|
17
|
+
names = readdirSync(changelogDirectory);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
// A repo with no `changelog/` directory yet means "no entry found", not a
|
|
21
|
+
// crash — callers (e.g. set-affected-packages) already handle null.
|
|
22
|
+
const nodeError = error;
|
|
23
|
+
if (nodeError.code === "ENOENT") {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
const files = names
|
|
29
|
+
.filter((name) => name.endsWith(".md") && name !== "README.md")
|
|
30
|
+
.map((name) => join(changelogDirectory, name));
|
|
31
|
+
for (const entryPath of files) {
|
|
32
|
+
let data;
|
|
33
|
+
try {
|
|
34
|
+
({ data } = parseFrontmatter(readFileSync(entryPath, "utf8")));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
// Rethrow with the offending file named — the raw parser error carries no
|
|
38
|
+
// filename, so a malformed entry anywhere in the corpus is hard to locate.
|
|
39
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
40
|
+
throw new Error(`Failed to parse changelog frontmatter in ${entryPath}: ${message}`);
|
|
41
|
+
}
|
|
42
|
+
if (data?.branch === branch) {
|
|
43
|
+
return entryPath;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when the calling module is the process entry point — i.e. it was run as a
|
|
3
|
+
* CLI, not imported (e.g. by unit tests exercising its pure helpers).
|
|
4
|
+
*
|
|
5
|
+
* Pass the caller's `import.meta.filename`. Both sides are resolved with
|
|
6
|
+
* `realpathSync` so a symlinked entry point (macOS `/var`→`/private/var`, pnpm's
|
|
7
|
+
* store) isn't a false negative that skips `main()`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isCliEntry(moduleFilename: string): boolean;
|
|
10
|
+
//# sourceMappingURL=cli-entry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli-entry.d.ts","sourceRoot":"","sources":["../../src/lib/cli-entry.ts"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAW1D"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { argv } from "node:process";
|
|
3
|
+
/**
|
|
4
|
+
* True when the calling module is the process entry point — i.e. it was run as a
|
|
5
|
+
* CLI, not imported (e.g. by unit tests exercising its pure helpers).
|
|
6
|
+
*
|
|
7
|
+
* Pass the caller's `import.meta.filename`. Both sides are resolved with
|
|
8
|
+
* `realpathSync` so a symlinked entry point (macOS `/var`→`/private/var`, pnpm's
|
|
9
|
+
* store) isn't a false negative that skips `main()`.
|
|
10
|
+
*/
|
|
11
|
+
export function isCliEntry(moduleFilename) {
|
|
12
|
+
const entry = argv[1];
|
|
13
|
+
if (!entry) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
return realpathSync(moduleFilename) === realpathSync(entry);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type Runner = (cmd: string, args: readonly string[]) => string;
|
|
2
|
+
/**
|
|
3
|
+
* Default Runner backed by `execFileSync` — shared by finalise and
|
|
4
|
+
* backfill-commits so the timeout / stdio options can't drift.
|
|
5
|
+
*/
|
|
6
|
+
export declare function realRunner(cmd: string, args: readonly string[]): string;
|
|
7
|
+
/**
|
|
8
|
+
* Count a merged PR's commits, excluding merge commits (more than one parent).
|
|
9
|
+
* Returns the non-merge commit count as a string, or null when the response
|
|
10
|
+
* isn't an array. Throws (rather than returning null) if the gh output isn't
|
|
11
|
+
* valid JSON — a network/auth failure must surface to the caller, not be
|
|
12
|
+
* silently recorded as a count; both callers own that (finalise's resolver and
|
|
13
|
+
* backfill's main() each wrap the call).
|
|
14
|
+
*/
|
|
15
|
+
export declare function nonMergeCommitCount(run: Runner, prNumber: number | string): null | string;
|
|
16
|
+
//# sourceMappingURL=commit-count.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"commit-count.d.ts","sourceRoot":"","sources":["../../src/lib/commit-count.ts"],"names":[],"mappings":"AA0BA,MAAM,MAAM,MAAM,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,KAAK,MAAM,CAAC;AAEtE;;;GAGG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CASvE;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,GAAG,MAAM,GACxB,IAAI,GAAG,MAAM,CA2Bf"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Resolve a merged PR's commit count EXCLUDING merge commits, via the GitHub
|
|
2
|
+
// REST commits endpoint. Shared by finalise (release-time enrichment) and
|
|
3
|
+
// backfill-commits (the one-off backlog backfill) — A-560.
|
|
4
|
+
//
|
|
5
|
+
// Works for both squash and merge-commit trunk strategies (A-825):
|
|
6
|
+
//
|
|
7
|
+
// - **Squash:** the per-commit count is lost from local trunk history (the PR
|
|
8
|
+
// lands as a single-parent squash SHA), but the PR commits endpoint still
|
|
9
|
+
// returns the original branch commits post-merge, so the count stays
|
|
10
|
+
// recoverable.
|
|
11
|
+
// - **Merge commits on trunk:** branch history is preserved on `main`; the same
|
|
12
|
+
// PR commits endpoint still yields the authored (non-merge) count. Excluding
|
|
13
|
+
// commits with more than one parent also drops the merge commit itself and
|
|
14
|
+
// any `main`-merge upkeep commits on the branch.
|
|
15
|
+
//
|
|
16
|
+
// In both cases the REST commit object's `parents` array is the reliable signal
|
|
17
|
+
// (`gh pr view --json commits` omits parent data).
|
|
18
|
+
//
|
|
19
|
+
// Runner-injectable `(cmd, args) -> stdout`, mirroring finalise's makeResolver,
|
|
20
|
+
// so it's unit-testable with a fake runner and never reaches the network in
|
|
21
|
+
// tests. `gh api --paginate` substitutes `{owner}`/`{repo}` from the repo's
|
|
22
|
+
// remote and merges the paged arrays, so PRs with more than one page of commits
|
|
23
|
+
// still count correctly.
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
/**
|
|
26
|
+
* Default Runner backed by `execFileSync` — shared by finalise and
|
|
27
|
+
* backfill-commits so the timeout / stdio options can't drift.
|
|
28
|
+
*/
|
|
29
|
+
export function realRunner(cmd, args) {
|
|
30
|
+
return execFileSync(cmd, args, {
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
33
|
+
// Fail fast if gh/git stalls (network/auth). Enrichment is best-effort, so
|
|
34
|
+
// a timeout throws → callers' try/catch falls back rather than hanging the
|
|
35
|
+
// release until the whole job times out.
|
|
36
|
+
timeout: 30_000,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Count a merged PR's commits, excluding merge commits (more than one parent).
|
|
41
|
+
* Returns the non-merge commit count as a string, or null when the response
|
|
42
|
+
* isn't an array. Throws (rather than returning null) if the gh output isn't
|
|
43
|
+
* valid JSON — a network/auth failure must surface to the caller, not be
|
|
44
|
+
* silently recorded as a count; both callers own that (finalise's resolver and
|
|
45
|
+
* backfill's main() each wrap the call).
|
|
46
|
+
*/
|
|
47
|
+
export function nonMergeCommitCount(run, prNumber) {
|
|
48
|
+
const json = run("gh", [
|
|
49
|
+
"api",
|
|
50
|
+
"--paginate",
|
|
51
|
+
`repos/{owner}/{repo}/pulls/${prNumber}/commits`,
|
|
52
|
+
]);
|
|
53
|
+
const commits = JSON.parse(json);
|
|
54
|
+
if (!Array.isArray(commits)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
// Exclude only commits we can positively identify as merges (>1 parent). The
|
|
58
|
+
// REST commits endpoint always includes `parents`, so a missing/malformed
|
|
59
|
+
// `parents` is anomalous — treat it as 0 parents (a normal commit) so we err
|
|
60
|
+
// toward counting authored work rather than silently dropping it; a real merge
|
|
61
|
+
// always arrives with its two parents present.
|
|
62
|
+
const count = commits.filter((commit) => {
|
|
63
|
+
const parents = commit !== null &&
|
|
64
|
+
typeof commit === "object" &&
|
|
65
|
+
"parents" in commit &&
|
|
66
|
+
Array.isArray(commit.parents)
|
|
67
|
+
? commit.parents
|
|
68
|
+
: [];
|
|
69
|
+
return parents.length <= 1;
|
|
70
|
+
}).length;
|
|
71
|
+
return String(count);
|
|
72
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type ChangelogConfig = {
|
|
2
|
+
affectedPackages: boolean;
|
|
3
|
+
baseBranch: string;
|
|
4
|
+
changelogDir: string;
|
|
5
|
+
fallbackPackage: string;
|
|
6
|
+
issueKeys: string[];
|
|
7
|
+
linearWorkspaceSlug: string;
|
|
8
|
+
packageRoots: string[];
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Validate a raw config JSON string and merge it over the structural defaults.
|
|
12
|
+
* Exported so the fail-loud contract is unit-testable without filesystem setup.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseConfig(raw: string, source?: string): ChangelogConfig;
|
|
15
|
+
/**
|
|
16
|
+
* Load and cache the changelog config from the consumer cwd (or an explicit path).
|
|
17
|
+
*/
|
|
18
|
+
export declare function loadConfig(options?: {
|
|
19
|
+
configPath?: string;
|
|
20
|
+
}): ChangelogConfig;
|
|
21
|
+
/**
|
|
22
|
+
* Clear the cached config — for tests that swap config between cases.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resetConfigCache(): void;
|
|
25
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAuBA,MAAM,MAAM,eAAe,GAAG;IAC5B,gBAAgB,EAAE,OAAO,CAAC;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB,CAAC;AAgCF;;;GAGG;AACH,wBAAgB,WAAW,CACzB,GAAG,EAAE,MAAM,EACX,MAAM,SAAgB,GACrB,eAAe,CAkEjB;AAqCD;;GAEG;AACH,wBAAgB,UAAU,CAAC,OAAO,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,eAAe,CAsB7E;AAED;;GAEG;AACH,wBAAgB,gBAAgB,IAAI,IAAI,CAEvC"}
|