@absolutejs/changelog 0.1.1 → 0.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,27 @@ This file is generated by `absolute-changelog` from the entries in
6
6
  `changelog/`. Edit an entry, not this file — and add new ones under
7
7
  `changelog/unreleased/`.
8
8
 
9
+ ## 0.2.0 — 2026-09-10
10
+
11
+ ### Added
12
+
13
+ - **Adopting writes a prepublishOnly gate, so no publish can go round the check**
14
+ - **Entries can be written against the package's own API, so a symbol that is not an export is a compile error** (`Change`, `change`)
15
+ - **Reconciliation reports migrations that do not describe what the package did** (`Reconciliation`)
16
+ - **The gate catches a package that stopped shipping the documents, and files in the entry directory that no release will read** (`strayEntries`)
17
+ - **The gate checks that a migration describes what the package actually did** (`reconcile`)
18
+
19
+ ### Fixed
20
+
21
+ - **Compare against the newest published version, not the one being published** (`packageVersions`)
22
+ - **Read a document written against an older contract rather than refusing it** (`parseChangelog`)
23
+
24
+ ## 0.1.2 — 2026-09-10
25
+
26
+ ### Fixed
27
+
28
+ - **Keep each package.json's own indentation when writing it back** (`writeJsonLike`)
29
+
9
30
  ## 0.1.1 — 2026-09-10
10
31
 
11
32
  ### Fixed
package/changelog.json CHANGED
@@ -1,31 +1,99 @@
1
1
  {
2
- "contract": 1,
3
- "name": "@absolutejs/changelog",
4
- "releases": [
5
- {
6
- "changes": [
7
- {
8
- "kind": "fixed",
9
- "summary": "Fall back to the full packument when the registry has not built the abbreviated one yet",
10
- "symbols": ["packageVersions"]
11
- },
12
- {
13
- "kind": "fixed",
14
- "summary": "Keep the formatter off the generated changelog copies when adopting"
15
- }
16
- ],
17
- "date": "2026-09-10",
18
- "version": "0.1.1"
19
- },
20
- {
21
- "changes": [
22
- {
23
- "kind": "added",
24
- "summary": "The changelog contract: typed entries, a gate that checks them against the package's own types, and migrations as data"
25
- }
26
- ],
27
- "date": "2026-09-10",
28
- "version": "0.1.0"
29
- }
30
- ]
2
+ "contract": 1,
3
+ "name": "@absolutejs/changelog",
4
+ "releases": [
5
+ {
6
+ "changes": [
7
+ {
8
+ "kind": "added",
9
+ "summary": "Adopting writes a prepublishOnly gate, so no publish can go round the check"
10
+ },
11
+ {
12
+ "kind": "fixed",
13
+ "summary": "Compare against the newest published version, not the one being published",
14
+ "symbols": [
15
+ "packageVersions"
16
+ ]
17
+ },
18
+ {
19
+ "kind": "added",
20
+ "summary": "Entries can be written against the package's own API, so a symbol that is not an export is a compile error",
21
+ "symbols": [
22
+ "Change",
23
+ "change"
24
+ ]
25
+ },
26
+ {
27
+ "kind": "fixed",
28
+ "summary": "Read a document written against an older contract rather than refusing it",
29
+ "symbols": [
30
+ "parseChangelog"
31
+ ]
32
+ },
33
+ {
34
+ "kind": "added",
35
+ "summary": "Reconciliation reports migrations that do not describe what the package did",
36
+ "symbols": [
37
+ "Reconciliation"
38
+ ]
39
+ },
40
+ {
41
+ "kind": "added",
42
+ "summary": "The gate catches a package that stopped shipping the documents, and files in the entry directory that no release will read",
43
+ "symbols": [
44
+ "strayEntries"
45
+ ]
46
+ },
47
+ {
48
+ "kind": "added",
49
+ "summary": "The gate checks that a migration describes what the package actually did",
50
+ "symbols": [
51
+ "reconcile"
52
+ ]
53
+ }
54
+ ],
55
+ "date": "2026-09-10",
56
+ "version": "0.2.0"
57
+ },
58
+ {
59
+ "changes": [
60
+ {
61
+ "kind": "fixed",
62
+ "summary": "Keep each package.json's own indentation when writing it back",
63
+ "symbols": [
64
+ "writeJsonLike"
65
+ ]
66
+ }
67
+ ],
68
+ "date": "2026-09-10",
69
+ "version": "0.1.2"
70
+ },
71
+ {
72
+ "changes": [
73
+ {
74
+ "kind": "fixed",
75
+ "summary": "Fall back to the full packument when the registry has not built the abbreviated one yet",
76
+ "symbols": [
77
+ "packageVersions"
78
+ ]
79
+ },
80
+ {
81
+ "kind": "fixed",
82
+ "summary": "Keep the formatter off the generated changelog copies when adopting"
83
+ }
84
+ ],
85
+ "date": "2026-09-10",
86
+ "version": "0.1.1"
87
+ },
88
+ {
89
+ "changes": [
90
+ {
91
+ "kind": "added",
92
+ "summary": "The changelog contract: typed entries, a gate that checks them against the package's own types, and migrations as data"
93
+ }
94
+ ],
95
+ "date": "2026-09-10",
96
+ "version": "0.1.0"
97
+ }
98
+ ]
31
99
  }
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  // src/cli.ts
5
5
  import { existsSync } from "fs";
6
- import { mkdir, readFile as readFile2, rm, writeFile } from "fs/promises";
6
+ import { mkdir, readFile as readFile3, rm, writeFile as writeFile2 } from "fs/promises";
7
7
  import { join as join2 } from "path";
8
8
 
9
9
  // src/entries.ts
@@ -235,8 +235,8 @@ var parseChangelog = (value) => {
235
235
  return { problems: ["not an object"] };
236
236
  if (!isText(value.name))
237
237
  problems.push("needs a package name");
238
- if (value.contract !== CHANGELOG_CONTRACT)
239
- problems.push(`contract ${JSON.stringify(value.contract)} is not ${CHANGELOG_CONTRACT}`);
238
+ if (typeof value.contract !== "number" || value.contract > CHANGELOG_CONTRACT)
239
+ problems.push(`contract ${JSON.stringify(value.contract)} is newer than ${CHANGELOG_CONTRACT}, which this reader understands`);
240
240
  const raw = value.releases;
241
241
  if (!Array.isArray(raw))
242
242
  problems.push("releases is not a list");
@@ -535,29 +535,84 @@ var readJsonFile = async (path) => {
535
535
  return;
536
536
  }
537
537
  };
538
+ var strayEntries = async (packageRoot) => {
539
+ const names = await readdir(join(packageRoot, UNRELEASED_DIRECTORY)).catch(() => []);
540
+ return names.filter((name) => !ENTRY.test(name) && name !== ".gitkeep" && !name.startsWith("."));
541
+ };
542
+
543
+ // src/json.ts
544
+ import { readFile as readFile2, writeFile } from "fs/promises";
545
+ var indentOf = (source) => {
546
+ const found = /\n([\t ]+)\S/u.exec(source)?.[1];
547
+ return found === undefined || found === "" ? "\t" : found;
548
+ };
549
+ var writeJsonLike = async (path, document) => {
550
+ const source = await readFile2(path, "utf8").catch(() => "");
551
+ await writeFile(path, `${JSON.stringify(document, null, indentOf(source))}
552
+ `);
553
+ };
538
554
 
539
555
  // src/reconcile.ts
556
+ var migrationHolds = (input) => {
557
+ const { after, change, subpaths } = input;
558
+ const wrong = [];
559
+ const { migration } = change;
560
+ if ("rename" in migration) {
561
+ const { from } = migration.rename;
562
+ const into = migration.rename.to;
563
+ if (!after.has(into))
564
+ wrong.push(`\`${change.summary}\` renames to \`${into}\`, which this version does not export`);
565
+ if (after.has(from))
566
+ wrong.push(`\`${change.summary}\` renames \`${from}\` away, but this version still exports it`);
567
+ if (!change.symbols.includes(from))
568
+ wrong.push(`\`${change.summary}\` renames \`${from}\`, which is not in its own symbols`);
569
+ }
570
+ if ("moved" in migration) {
571
+ const { symbol } = migration.moved;
572
+ const into = migration.moved.to;
573
+ if (!after.has(symbol))
574
+ wrong.push(`\`${change.summary}\` moves \`${symbol}\`, which this version does not export`);
575
+ if (subpaths.size > 0 && !subpaths.has(into))
576
+ wrong.push(`\`${change.summary}\` moves \`${symbol}\` to \`${into}\`, which is not an entry point of this package`);
577
+ }
578
+ if ("resubpath" in migration && subpaths.size > 0 && !subpaths.has(migration.resubpath.to))
579
+ wrong.push(`\`${change.summary}\` points at \`${migration.resubpath.to}\`, which is not an entry point of this package`);
580
+ if (change.kind === "removed") {
581
+ for (const symbol of change.symbols)
582
+ if (after.has(symbol))
583
+ wrong.push(`\`${change.summary}\` says \`${symbol}\` was removed, but this version still exports it`);
584
+ }
585
+ return wrong;
586
+ };
540
587
  var describeReconciliation = (found) => {
541
588
  const lines = [];
542
589
  for (const entry of found.unannounced)
543
590
  lines.push(entry.kind === "removed" ? `\`${entry.name}\` is no longer exported and no entry says so` : `\`${entry.name}\` changed its ${entry.kind} and no entry says so`);
544
591
  for (const name of found.unknownSymbols)
545
592
  lines.push(`an entry names \`${name}\`, which this package does not export`);
593
+ lines.push(...found.wrongMigrations);
546
594
  return lines;
547
595
  };
548
596
  var reconcile = (input) => {
549
- const announced = new Set(input.changes.filter(isDisruptive).flatMap((change) => [...change.symbols]));
597
+ const disruptive = new Set(input.changes.filter(isDisruptive).flatMap((change) => [...change.symbols]));
598
+ const mentioned = new Set(input.changes.flatMap((change) => [...change.symbols ?? []]));
550
599
  const unannounced = [
551
- ...input.diff.removed.map((name) => ({ kind: "removed", name })),
552
- ...notable(input.diff).map((change) => ({
600
+ ...input.diff.removed.filter((name) => !disruptive.has(name)).map((name) => ({ kind: "removed", name })),
601
+ ...notable(input.diff).filter((change) => !mentioned.has(change.name)).map((change) => ({
553
602
  kind: change.shape === "parameters" ? "parameters" : "members",
554
603
  name: change.name
555
604
  }))
556
- ].filter((entry) => !announced.has(entry.name));
605
+ ];
557
606
  const unknownSymbols = [
558
607
  ...new Set(input.changes.flatMap((change) => [...change.symbols ?? []]).filter((name) => !input.known.has(name)))
559
608
  ];
560
- return { unannounced, unknownSymbols };
609
+ const { after } = input;
610
+ const wrongMigrations = after === undefined ? [] : input.changes.filter(isDisruptive).flatMap((change) => migrationHolds({
611
+ after,
612
+ change,
613
+ subpaths: input.subpaths ?? new Set
614
+ }));
615
+ return { unannounced, unknownSymbols, wrongMigrations };
561
616
  };
562
617
 
563
618
  // src/registry.ts
@@ -798,16 +853,22 @@ var readChangelog = async (root, name) => {
798
853
  `));
799
854
  return read.changelog;
800
855
  };
801
- var historyOf = async (root) => readFile2(changelogPaths(root).history, "utf8").catch(() => {
856
+ var historyOf = async (root) => readFile3(changelogPaths(root).history, "utf8").catch(() => {
802
857
  return;
803
858
  });
804
859
  var writeBoth = async (root, changelog) => {
805
860
  const paths = changelogPaths(root);
806
861
  const history = await historyOf(root);
807
- await writeFile(paths.json, renderChangelogJson(changelog));
808
- await writeFile(paths.markdown, renderMarkdown(changelog, history === undefined ? {} : { history }));
862
+ await writeFile2(paths.json, renderChangelogJson(changelog));
863
+ await writeFile2(paths.markdown, renderMarkdown(changelog, history === undefined ? {} : { history }));
809
864
  };
810
865
  var slug = (summary) => summary.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "").slice(0, SLUG_MAX_LENGTH) || "change";
866
+ var sourceEntryOf = (root) => {
867
+ for (const candidate of ["src/index.ts", "src/index.tsx", "index.ts"])
868
+ if (existsSync(join2(root, candidate)))
869
+ return `../../${candidate.replace(/\.tsx?$/u, "")}`;
870
+ return;
871
+ };
811
872
  var add = async (root, flags) => {
812
873
  const kind = text(flags, "kind");
813
874
  const summary = text(flags, "summary");
@@ -831,10 +892,12 @@ var add = async (root, flags) => {
831
892
  ] : [
832
893
  ` migration: { instruction: ${JSON.stringify(instruction)}, manual: true },`
833
894
  ];
895
+ const api = sourceEntryOf(root);
834
896
  const body = [
897
+ ...api === undefined ? [] : [`import type * as Api from '${api}';`],
835
898
  "import type { Change } from '@absolutejs/changelog';",
836
899
  "",
837
- "export const change: Change = {",
900
+ `export const change: Change${api === undefined ? "" : "<typeof Api>"} = {`,
838
901
  ` kind: ${JSON.stringify(kind)},`,
839
902
  ...disruptive ? migration : [],
840
903
  ` summary: ${JSON.stringify(summary)}${symbols.length > 0 ? "," : ""}`,
@@ -846,7 +909,7 @@ var add = async (root, flags) => {
846
909
  const directory = join2(root, UNRELEASED_DIRECTORY);
847
910
  await mkdir(directory, { recursive: true });
848
911
  const file = join2(directory, `${slug(summary)}.ts`);
849
- await writeFile(file, body);
912
+ await writeFile2(file, body);
850
913
  console.log(`wrote ${file}`);
851
914
  };
852
915
  var release = async (root, flags) => {
@@ -892,8 +955,7 @@ var release = async (root, flags) => {
892
955
  return;
893
956
  }
894
957
  await writeBoth(root, written);
895
- await writeFile(join2(root, "package.json"), `${JSON.stringify({ ...manifest, version }, null, "\t")}
896
- `);
958
+ await writeJsonLike(join2(root, "package.json"), { ...manifest, version });
897
959
  await Promise.all(entries.map((entry) => rm(join2(root, entry.file), { force: true })));
898
960
  console.log(`${manifest.name} ${manifest.version} \u2192 ${version} (${entries.length} change${entries.length === 1 ? "" : "s"})`);
899
961
  };
@@ -919,7 +981,7 @@ var check = async (root, flags) => {
919
981
  failures.push(`${CHANGELOG_FILE} is for ${current.name}, not ${manifest.name}`);
920
982
  const history = await historyOf(root);
921
983
  const expected = renderMarkdown(current, history === undefined ? {} : { history });
922
- const actual = await readFile2(changelogPaths(root).markdown, "utf8").catch(() => {
984
+ const actual = await readFile3(changelogPaths(root).markdown, "utf8").catch(() => {
923
985
  return;
924
986
  });
925
987
  if (newest !== undefined && actual !== expected)
@@ -928,23 +990,46 @@ var check = async (root, flags) => {
928
990
  const include = isPackage(tsconfig) ? tsconfig.include : undefined;
929
991
  if (Array.isArray(include) && !coversEntries(include))
930
992
  failures.push('tsconfig.json does not compile changelog/, so nothing is checking the entries \u2014 add "changelog/**/*" to its include list');
993
+ for (const stray of await strayEntries(root))
994
+ failures.push(`${UNRELEASED_DIRECTORY}/${stray} is not an entry, so no release will ever read it`);
995
+ const shipped = Array.isArray(manifest.files) ? manifest.files : [];
996
+ for (const file of [CHANGELOG_FILE, CHANGELOG_MARKDOWN])
997
+ if (shipped.length > 0 && !shipped.includes(file))
998
+ failures.push(`package.json does not ship ${file}`);
931
999
  const offline = flags.offline === true;
932
1000
  const built = await builtSurface(root);
933
- if (!offline && built !== undefined && newest !== undefined) {
934
- const published = await publishedSurface(manifest.name ?? "", newest.version);
1001
+ const versions = offline ? undefined : await packageVersions(manifest.name ?? "");
1002
+ const baseline = (versions?.published ?? []).filter((version) => compareVersions(version, manifest.version) <= 0).sort(compareVersions).at(LAST3);
1003
+ if (!offline && built !== undefined && baseline !== undefined) {
1004
+ const published = await publishedSurface(manifest.name ?? "", baseline);
935
1005
  if (published === undefined)
936
- console.warn(` (could not read ${manifest.name}@${newest.version} from the registry; the types were not checked)`);
1006
+ console.warn(` (could not read ${manifest.name}@${baseline} from the registry; the types were not checked)`);
1007
+ const speaking = [
1008
+ ...entries.map((entry) => entry.change),
1009
+ ...current.releases.filter((entry) => compareVersions(entry.version, baseline) > 0).flatMap((entry) => [...entry.changes])
1010
+ ];
937
1011
  failures.push(...published === undefined ? [] : describeReconciliation(reconcile({
938
- changes: entries.map((entry) => entry.change),
1012
+ after: new Set(built.keys()),
1013
+ changes: speaking,
939
1014
  diff: diffSurface(published, built),
940
- known: new Set([...published.keys(), ...built.keys()])
1015
+ known: new Set([...published.keys(), ...built.keys()]),
1016
+ subpaths: subpathsOf(manifest)
941
1017
  })));
942
1018
  }
1019
+ if (!offline && built === undefined)
1020
+ console.warn(" (no dist to read, so the types were not checked \u2014 run the build before the gate)");
943
1021
  if (failures.length > 0)
944
1022
  throw new CliError(["This package\u2019s changelog does not hold:", ...failures].join(`
945
1023
  `));
946
1024
  console.log(`${manifest.name}@${manifest.version}: changelog holds (${entries.length} pending, ${current.releases.length} released)`);
947
1025
  };
1026
+ var LAST3 = -1;
1027
+ var subpathsOf = (manifest) => {
1028
+ const { exports } = manifest;
1029
+ if (typeof exports !== "object" || exports === null)
1030
+ return new Set;
1031
+ return new Set(Object.keys(exports).map((key) => key === "." ? manifest.name ?? "" : `${manifest.name}${key.slice(1)}`));
1032
+ };
948
1033
  var CHECKING_CONFIGS = ["tsconfig.json", "tsconfig.eslint.json"];
949
1034
  var coversEntries = (include) => Array.isArray(include) && include.some((entry) => typeof entry === "string" && entry.startsWith("changelog"));
950
1035
  var includeEntriesIn = async (root, file) => {
@@ -954,8 +1039,10 @@ var includeEntriesIn = async (root, file) => {
954
1039
  return false;
955
1040
  if (coversEntries(document.include))
956
1041
  return false;
957
- await writeFile(path, `${JSON.stringify({ ...document, include: [...document.include, "changelog/**/*"] }, null, "\t")}
958
- `);
1042
+ await writeJsonLike(path, {
1043
+ ...document,
1044
+ include: [...document.include, "changelog/**/*"]
1045
+ });
959
1046
  return true;
960
1047
  };
961
1048
  var includeEntries = async (root) => {
@@ -964,7 +1051,7 @@ var includeEntries = async (root) => {
964
1051
  };
965
1052
  var ignoreGenerated = async (root) => {
966
1053
  const path = join2(root, ".prettierignore");
967
- const existing = await readFile2(path, "utf8").catch(() => "");
1054
+ const existing = await readFile3(path, "utf8").catch(() => "");
968
1055
  const wanted = [CHANGELOG_FILE, CHANGELOG_MARKDOWN].filter((name) => !existing.split(`
969
1056
  `).includes(name));
970
1057
  if (wanted.length === 0)
@@ -972,26 +1059,40 @@ var ignoreGenerated = async (root) => {
972
1059
  const head = existing.trim() === "" ? `# Generated by absolute-changelog. Edit the entries, not these.
973
1060
  ` : `${existing.trimEnd()}
974
1061
  `;
975
- await writeFile(path, `${head}${wanted.join(`
1062
+ await writeFile2(path, `${head}${wanted.join(`
976
1063
  `)}
977
1064
  `);
978
1065
  };
1066
+ var PUBLISH_GATE = "absolute-changelog check";
1067
+ var withPublishGate = (scripts) => {
1068
+ const existing = scripts.prepublishOnly;
1069
+ if (typeof existing === "string" && existing.includes(PUBLISH_GATE))
1070
+ return;
1071
+ return {
1072
+ ...scripts,
1073
+ prepublishOnly: typeof existing === "string" && existing.trim() !== "" ? `${existing} && ${PUBLISH_GATE}` : PUBLISH_GATE
1074
+ };
1075
+ };
979
1076
  var adopt = async (root) => {
980
1077
  const manifest = await readPackage(root);
981
1078
  const paths = changelogPaths(root);
982
1079
  await mkdir(paths.unreleased, { recursive: true });
983
- await writeFile(join2(paths.unreleased, ".gitkeep"), "# Entries wait here. One file per change; `absolute-changelog release` consumes them.\n");
1080
+ await writeFile2(join2(paths.unreleased, ".gitkeep"), "# Entries wait here. One file per change; `absolute-changelog release` consumes them.\n");
984
1081
  if (existsSync(paths.markdown) && !existsSync(paths.history)) {
985
- const previous = await readFile2(paths.markdown, "utf8");
1082
+ const previous = await readFile3(paths.markdown, "utf8");
986
1083
  await mkdir(join2(root, "changelog"), { recursive: true });
987
- await writeFile(paths.history, previous.replace(/^#\s+Changelog\s*/u, "").trimStart());
1084
+ await writeFile2(paths.history, previous.replace(/^#\s+Changelog\s*/u, "").trimStart());
988
1085
  await rm(paths.markdown, { force: true });
989
1086
  }
990
1087
  const files = manifest.files ?? [];
991
1088
  const wanted = [CHANGELOG_FILE, CHANGELOG_MARKDOWN].filter((entry) => !files.includes(entry));
992
- if (wanted.length > 0)
993
- await writeFile(join2(root, "package.json"), `${JSON.stringify({ ...manifest, files: [...files, ...wanted].sort() }, null, "\t")}
994
- `);
1089
+ const scripts = typeof manifest.scripts === "object" && manifest.scripts !== null ? withPublishGate(manifest.scripts) : undefined;
1090
+ if (wanted.length > 0 || scripts !== undefined)
1091
+ await writeJsonLike(join2(root, "package.json"), {
1092
+ ...manifest,
1093
+ ...scripts === undefined ? {} : { scripts },
1094
+ files: [...files, ...wanted].sort()
1095
+ });
995
1096
  await ignoreGenerated(root);
996
1097
  const note = await includeEntries(root);
997
1098
  if (note !== undefined)
@@ -1019,5 +1120,5 @@ await main().catch((error) => {
1019
1120
  process.exit(1);
1020
1121
  });
1021
1122
 
1022
- //# debugId=20166C506E99909264756E2164756E21
1123
+ //# debugId=7CE32A12E2B0BC0C64756E2164756E21
1023
1124
  //# sourceMappingURL=cli.js.map