@absolutejs/changelog 0.1.2 → 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,21 @@ 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
+
9
24
  ## 0.1.2 — 2026-09-10
10
25
 
11
26
  ### Fixed
package/changelog.json CHANGED
@@ -1,42 +1,99 @@
1
1
  {
2
- "contract": 1,
3
- "name": "@absolutejs/changelog",
4
- "releases": [
5
- {
6
- "changes": [
7
- {
8
- "kind": "fixed",
9
- "summary": "Keep each package.json's own indentation when writing it back",
10
- "symbols": ["writeJsonLike"]
11
- }
12
- ],
13
- "date": "2026-09-10",
14
- "version": "0.1.2"
15
- },
16
- {
17
- "changes": [
18
- {
19
- "kind": "fixed",
20
- "summary": "Fall back to the full packument when the registry has not built the abbreviated one yet",
21
- "symbols": ["packageVersions"]
22
- },
23
- {
24
- "kind": "fixed",
25
- "summary": "Keep the formatter off the generated changelog copies when adopting"
26
- }
27
- ],
28
- "date": "2026-09-10",
29
- "version": "0.1.1"
30
- },
31
- {
32
- "changes": [
33
- {
34
- "kind": "added",
35
- "summary": "The changelog contract: typed entries, a gate that checks them against the package's own types, and migrations as data"
36
- }
37
- ],
38
- "date": "2026-09-10",
39
- "version": "0.1.0"
40
- }
41
- ]
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
+ ]
42
99
  }
package/dist/cli.js CHANGED
@@ -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,6 +535,10 @@ 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
+ };
538
542
 
539
543
  // src/json.ts
540
544
  import { readFile as readFile2, writeFile } from "fs/promises";
@@ -549,27 +553,66 @@ var writeJsonLike = async (path, document) => {
549
553
  };
550
554
 
551
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
+ };
552
587
  var describeReconciliation = (found) => {
553
588
  const lines = [];
554
589
  for (const entry of found.unannounced)
555
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`);
556
591
  for (const name of found.unknownSymbols)
557
592
  lines.push(`an entry names \`${name}\`, which this package does not export`);
593
+ lines.push(...found.wrongMigrations);
558
594
  return lines;
559
595
  };
560
596
  var reconcile = (input) => {
561
- 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 ?? []]));
562
599
  const unannounced = [
563
- ...input.diff.removed.map((name) => ({ kind: "removed", name })),
564
- ...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) => ({
565
602
  kind: change.shape === "parameters" ? "parameters" : "members",
566
603
  name: change.name
567
604
  }))
568
- ].filter((entry) => !announced.has(entry.name));
605
+ ];
569
606
  const unknownSymbols = [
570
607
  ...new Set(input.changes.flatMap((change) => [...change.symbols ?? []]).filter((name) => !input.known.has(name)))
571
608
  ];
572
- 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 };
573
616
  };
574
617
 
575
618
  // src/registry.ts
@@ -820,6 +863,12 @@ var writeBoth = async (root, changelog) => {
820
863
  await writeFile2(paths.markdown, renderMarkdown(changelog, history === undefined ? {} : { history }));
821
864
  };
822
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
+ };
823
872
  var add = async (root, flags) => {
824
873
  const kind = text(flags, "kind");
825
874
  const summary = text(flags, "summary");
@@ -843,10 +892,12 @@ var add = async (root, flags) => {
843
892
  ] : [
844
893
  ` migration: { instruction: ${JSON.stringify(instruction)}, manual: true },`
845
894
  ];
895
+ const api = sourceEntryOf(root);
846
896
  const body = [
897
+ ...api === undefined ? [] : [`import type * as Api from '${api}';`],
847
898
  "import type { Change } from '@absolutejs/changelog';",
848
899
  "",
849
- "export const change: Change = {",
900
+ `export const change: Change${api === undefined ? "" : "<typeof Api>"} = {`,
850
901
  ` kind: ${JSON.stringify(kind)},`,
851
902
  ...disruptive ? migration : [],
852
903
  ` summary: ${JSON.stringify(summary)}${symbols.length > 0 ? "," : ""}`,
@@ -939,23 +990,46 @@ var check = async (root, flags) => {
939
990
  const include = isPackage(tsconfig) ? tsconfig.include : undefined;
940
991
  if (Array.isArray(include) && !coversEntries(include))
941
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}`);
942
999
  const offline = flags.offline === true;
943
1000
  const built = await builtSurface(root);
944
- if (!offline && built !== undefined && newest !== undefined) {
945
- 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);
946
1005
  if (published === undefined)
947
- 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
+ ];
948
1011
  failures.push(...published === undefined ? [] : describeReconciliation(reconcile({
949
- changes: entries.map((entry) => entry.change),
1012
+ after: new Set(built.keys()),
1013
+ changes: speaking,
950
1014
  diff: diffSurface(published, built),
951
- known: new Set([...published.keys(), ...built.keys()])
1015
+ known: new Set([...published.keys(), ...built.keys()]),
1016
+ subpaths: subpathsOf(manifest)
952
1017
  })));
953
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)");
954
1021
  if (failures.length > 0)
955
1022
  throw new CliError(["This package\u2019s changelog does not hold:", ...failures].join(`
956
1023
  `));
957
1024
  console.log(`${manifest.name}@${manifest.version}: changelog holds (${entries.length} pending, ${current.releases.length} released)`);
958
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
+ };
959
1033
  var CHECKING_CONFIGS = ["tsconfig.json", "tsconfig.eslint.json"];
960
1034
  var coversEntries = (include) => Array.isArray(include) && include.some((entry) => typeof entry === "string" && entry.startsWith("changelog"));
961
1035
  var includeEntriesIn = async (root, file) => {
@@ -989,6 +1063,16 @@ var ignoreGenerated = async (root) => {
989
1063
  `)}
990
1064
  `);
991
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
+ };
992
1076
  var adopt = async (root) => {
993
1077
  const manifest = await readPackage(root);
994
1078
  const paths = changelogPaths(root);
@@ -1002,9 +1086,11 @@ var adopt = async (root) => {
1002
1086
  }
1003
1087
  const files = manifest.files ?? [];
1004
1088
  const wanted = [CHANGELOG_FILE, CHANGELOG_MARKDOWN].filter((entry) => !files.includes(entry));
1005
- if (wanted.length > 0)
1089
+ const scripts = typeof manifest.scripts === "object" && manifest.scripts !== null ? withPublishGate(manifest.scripts) : undefined;
1090
+ if (wanted.length > 0 || scripts !== undefined)
1006
1091
  await writeJsonLike(join2(root, "package.json"), {
1007
1092
  ...manifest,
1093
+ ...scripts === undefined ? {} : { scripts },
1008
1094
  files: [...files, ...wanted].sort()
1009
1095
  });
1010
1096
  await ignoreGenerated(root);
@@ -1034,5 +1120,5 @@ await main().catch((error) => {
1034
1120
  process.exit(1);
1035
1121
  });
1036
1122
 
1037
- //# debugId=3B21DCC7DC15D28564756E2164756E21
1123
+ //# debugId=7CE32A12E2B0BC0C64756E2164756E21
1038
1124
  //# sourceMappingURL=cli.js.map