@tallyrelease/core 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.
@@ -0,0 +1,368 @@
1
+ import { compare, maxLevel, uniqueSorted } from "./util.js";
2
+ const DEFAULT_TYPES = {
3
+ feat: "minor",
4
+ fix: "patch",
5
+ perf: "patch",
6
+ revert: "patch",
7
+ };
8
+ /** Release-As values per commit for one unit, in range order. */
9
+ const commitValues = (id, changes) => {
10
+ const commits = new Map();
11
+ changes.forEach(({ change, units }, i) => {
12
+ const key = change.sha ?? `#${String(i)}`;
13
+ const entry = commits.get(key) ?? {
14
+ ...(change.sha === undefined ? {} : { sha: change.sha }),
15
+ qualified: [],
16
+ plain: [],
17
+ };
18
+ commits.set(key, entry);
19
+ for (const r of change.releaseAs ?? []) {
20
+ if (r.unit === id) {
21
+ entry.qualified.push(r.version);
22
+ }
23
+ else if (r.unit === undefined && units.includes(id)) {
24
+ entry.plain.push(r.version);
25
+ }
26
+ }
27
+ });
28
+ return [...commits.values()].map(({ qualified, plain, ...rest }) => ({
29
+ ...rest,
30
+ values: qualified.length > 0 ? qualified : plain,
31
+ }));
32
+ };
33
+ const at = (sha) => (sha === undefined ? "" : ` in ${sha}`);
34
+ /** A Release-As value without a single leading `v`, parsed by the scheme. */
35
+ const parseReleaseAs = (scheme, raw) => scheme.parse(raw.startsWith("v") ? raw.slice(1) : raw);
36
+ /** Why a parsed Release-As value is ignored, else undefined. */
37
+ const releaseAsProblem = (scheme, version, from) => {
38
+ if (scheme.isPrerelease(version)) {
39
+ return "is a prerelease";
40
+ }
41
+ return from && scheme.compare(version, from.version) <= 0
42
+ ? `does not exceed ${from.label}`
43
+ : undefined;
44
+ };
45
+ /** Newest valid value wins; invalid values and other values warn. */
46
+ const pickReleaseAs = (input, warnings) => {
47
+ const { unit, scheme, from, commits } = input;
48
+ let winner;
49
+ for (const { sha, values } of commits) {
50
+ for (const raw of values) {
51
+ const version = parseReleaseAs(scheme, raw);
52
+ const problem = version === undefined
53
+ ? `is not a ${unit.scheme} version`
54
+ : releaseAsProblem(scheme, version, from);
55
+ if (problem !== undefined || version === undefined) {
56
+ warnings.push({
57
+ code: "W_RELEASE_AS_INVALID",
58
+ message: `unit "${unit.id}": Release-As "${raw}"${at(sha)} ${problem ?? ""}; ignored`,
59
+ unit: unit.id,
60
+ });
61
+ }
62
+ else if (!winner) {
63
+ winner = { ...(sha === undefined ? {} : { sha }), raw, version };
64
+ }
65
+ else if (scheme.compare(version, winner.version) !== 0) {
66
+ warnings.push({
67
+ code: "W_RELEASE_AS_CONFLICT",
68
+ message: `unit "${unit.id}": Release-As "${winner.raw}"${at(winner.sha)} wins over "${raw}"${at(sha)}`,
69
+ unit: unit.id,
70
+ });
71
+ }
72
+ }
73
+ }
74
+ return winner;
75
+ };
76
+ /**
77
+ * Level of one change. While the major is 0, breaking changes use
78
+ * `preMajor.breaking` and changes whose type level is minor `preMajor.feat`.
79
+ * An absorbed change contributes `bumps.absorbed` when its own level is set
80
+ * (`minor` caps it at minor).
81
+ */
82
+ const changeLevel = ({ change, via }, settings, preMajor) => {
83
+ const typed = settings.bumps.get(change.type) ?? null;
84
+ const level = change.breaking
85
+ ? preMajor
86
+ ? settings.preMajor.breaking
87
+ : "major"
88
+ : preMajor && typed === "minor"
89
+ ? settings.preMajor.feat
90
+ : typed;
91
+ if (via === undefined || level === null) {
92
+ return level;
93
+ }
94
+ return settings.absorbed === "minor" && level !== "patch" ? "minor" : "patch";
95
+ };
96
+ /** Max level over the changes. */
97
+ const ownBump = (changes, settings, preMajor) => changes.reduce((bump, change) => maxLevel(bump, changeLevel(change, settings, preMajor)), null);
98
+ /** Changes of the range attributed to the unit or to an absorbed unit. */
99
+ const unitChanges = (id, range, absorbed) => range.changes.flatMap(({ change, units }) => {
100
+ if (units.includes(id)) {
101
+ return [{ change }];
102
+ }
103
+ const via = units.find((u) => absorbed.has(u));
104
+ return via === undefined ? [] : [{ change, via }];
105
+ });
106
+ /** Warns when a first release's initial version is not above the unit's highest prerelease tag. */
107
+ const initialBelowPrerelease = (scheme, initial, facts) => {
108
+ const highest = facts.tags
109
+ .filter((t) => {
110
+ const v = scheme.parse(t.version);
111
+ return v !== undefined && scheme.isPrerelease(v);
112
+ })
113
+ .at(-1);
114
+ const version = highest && scheme.parse(highest.version);
115
+ if (!highest || version === undefined) {
116
+ return [];
117
+ }
118
+ return scheme.compare(initial, version) > 0
119
+ ? []
120
+ : [
121
+ {
122
+ code: "W_INITIAL_BELOW_PRERELEASE",
123
+ message: `unit "${facts.id}": initial version ${scheme.format(initial)} does not exceed prerelease tag "${highest.name}"`,
124
+ unit: facts.id,
125
+ },
126
+ ];
127
+ };
128
+ /** Normalized value of the first valid Release-As of a commit. */
129
+ const appliedValue = (scheme, values) => {
130
+ for (const raw of values) {
131
+ const version = parseReleaseAs(scheme, raw);
132
+ if (version !== undefined && !scheme.isPrerelease(version)) {
133
+ return scheme.format(version);
134
+ }
135
+ }
136
+ return undefined;
137
+ };
138
+ /** The `from` version of a unit, from `facts.fromTag`. */
139
+ const fromOf = (scheme, facts) => {
140
+ const tag = facts.fromTag;
141
+ const version = tag === null ? undefined : scheme.parse(tag.version);
142
+ return tag === null || version === undefined
143
+ ? undefined
144
+ : {
145
+ version,
146
+ label: facts.group === null
147
+ ? tag.version
148
+ : `${tag.version} (group ${facts.group})`,
149
+ };
150
+ };
151
+ /** `from` bumped by `bump`; the initial version on a first release. */
152
+ const bumpedOf = (scheme, bump, { from, initial }) => bump === null
153
+ ? undefined
154
+ : from === undefined
155
+ ? initial
156
+ : scheme.bump(from.version, bump);
157
+ const ctxOf = (unit, head, at) => ({
158
+ unit,
159
+ head,
160
+ baseTag: at.baseTag?.name ?? null,
161
+ tags: at.tags,
162
+ firstParent: { sinceLineBase: at.sinceLineBase, total: at.total },
163
+ });
164
+ const initialOf = (unit, scheme) => (unit.initialVersion === null
165
+ ? undefined
166
+ : scheme.parse(unit.initialVersion)) ?? scheme.initial();
167
+ /** `next` and prerelease of `raw` under `ctx`. */
168
+ const render = ({ scheme, raw, ctx, channel, }) => ({
169
+ next: raw === undefined ? null : scheme.format(scheme.release(raw, ctx)),
170
+ prerelease: raw === undefined || channel === null
171
+ ? null
172
+ : scheme.format(scheme.prerelease(raw, { ...ctx, channel })),
173
+ });
174
+ /**
175
+ * Unreleased units reachable over `id`'s dependencies, never stepping into or
176
+ * through a released unit.
177
+ */
178
+ export const reach = (id, dependencies, released) => {
179
+ const found = new Set();
180
+ const queue = [id];
181
+ for (const v of queue) {
182
+ for (const w of dependencies.get(v) ?? []) {
183
+ if (!released.has(w) && !found.has(w)) {
184
+ found.add(w);
185
+ queue.push(w);
186
+ }
187
+ }
188
+ }
189
+ return found;
190
+ };
191
+ /** Applies the defaults to `bumps` and `branches`. */
192
+ export const releaseSettings = (config) => ({
193
+ bumps: new Map(Object.entries({ ...DEFAULT_TYPES, ...config.bumps?.types })),
194
+ absorbed: config.bumps?.absorbed ?? "patch",
195
+ preMajor: {
196
+ breaking: config.bumps?.preMajor?.breaking ?? "minor",
197
+ feat: config.bumps?.preMajor?.feat ?? "minor",
198
+ },
199
+ branches: (config.branches ?? []).map((b) => ({
200
+ name: b.name,
201
+ line: b.line,
202
+ channel: b.channel ?? null,
203
+ unit: b.unit ?? null,
204
+ group: b.group ?? null,
205
+ })),
206
+ });
207
+ /**
208
+ * Warns for qualified Release-As values naming unknown or unreleased units and
209
+ * for plain values on commits attributed to no released unit.
210
+ */
211
+ export const releaseAsWarnings = (units, ranges) => {
212
+ const known = new Set(units.map((u) => u.id));
213
+ const released = new Set(units.filter((u) => u.version !== "none").map((u) => u.id));
214
+ const warnings = new Map();
215
+ const warn = (code, message, unit) => {
216
+ warnings.set(`${code}\0${message}`, {
217
+ code,
218
+ message,
219
+ ...(unit === undefined ? {} : { unit }),
220
+ });
221
+ };
222
+ for (const { changes } of ranges) {
223
+ for (const { change, units: owners } of changes) {
224
+ for (const { unit, version } of change.releaseAs ?? []) {
225
+ if (unit === undefined) {
226
+ if (!owners.some((id) => released.has(id))) {
227
+ warn("W_RELEASE_AS_IGNORED", `Release-As "${version}"${at(change.sha)} is not attributed to a released unit`);
228
+ }
229
+ }
230
+ else if (!known.has(unit)) {
231
+ warn("W_RELEASE_AS_UNKNOWN", `Release-As "${unit}@${version}"${at(change.sha)} names unknown unit "${unit}"`);
232
+ }
233
+ else if (!released.has(unit)) {
234
+ warn("W_RELEASE_AS_IGNORED", `Release-As "${unit}@${version}"${at(change.sha)} names unreleased unit "${unit}"`, unit);
235
+ }
236
+ }
237
+ }
238
+ }
239
+ return [...warnings.values()];
240
+ };
241
+ /**
242
+ * Own and absorbed bump and Release-As of a released unit. Release-As values
243
+ * are read newest first (core commit order); a single leading `v` is
244
+ * stripped, as release-please does. Footers are not absorbed.
245
+ */
246
+ export const unitIntent = (input, warnings) => {
247
+ const { unit, scheme, facts } = input;
248
+ const from = fromOf(scheme, facts);
249
+ const changes = unitChanges(unit.id, input.range, input.absorbed);
250
+ const commits = commitValues(unit.id, input.range.changes);
251
+ return {
252
+ bump: ownBump(changes, input.settings, from !== undefined && scheme.core(from.version).major === 0),
253
+ releaseAs: pickReleaseAs({ unit, scheme, from, commits }, warnings),
254
+ changes,
255
+ absorbedFrom: uniqueSorted(input.range.changes.flatMap(({ units }) => units.includes(unit.id)
256
+ ? []
257
+ : units.filter((u) => input.absorbed.has(u)))),
258
+ releaseAsBySha: new Map(commits.flatMap(({ sha, values }) => {
259
+ const value = appliedValue(scheme, values);
260
+ return sha === undefined || value === undefined ? [] : [[sha, value]];
261
+ })),
262
+ };
263
+ };
264
+ /** The recorded footer of a Release-As winner. */
265
+ export const footerOf = (winner) => winner
266
+ ? {
267
+ ...(winner.sha === undefined ? {} : { sha: winner.sha }),
268
+ raw: winner.raw,
269
+ }
270
+ : null;
271
+ /**
272
+ * `next` and prerelease of a released unit from its bump after propagation;
273
+ * Release-As overrides `next`.
274
+ */
275
+ export const releaseUnit = (input, intent, warnings) => {
276
+ const { unit, scheme, facts } = input;
277
+ const { bump } = intent;
278
+ const releaseAs = intent.releaseAs?.version;
279
+ const from = fromOf(scheme, facts);
280
+ const initial = initialOf(unit, scheme);
281
+ const bumped = bumpedOf(scheme, bump, { from, initial });
282
+ if (from === undefined && releaseAs === undefined && bumped !== undefined) {
283
+ warnings.push(...initialBelowPrerelease(scheme, initial, facts));
284
+ }
285
+ const raw = releaseAs ?? bumped;
286
+ return {
287
+ from: facts.baseTag?.version ?? null,
288
+ bump,
289
+ releaseAs: releaseAs === undefined ? null : scheme.format(releaseAs),
290
+ releaseAsFooter: footerOf(intent.releaseAs),
291
+ nextSource: raw === undefined
292
+ ? null
293
+ : releaseAs !== undefined
294
+ ? "release-as"
295
+ : from === undefined
296
+ ? "initial"
297
+ : "bump",
298
+ ...render({
299
+ scheme,
300
+ raw,
301
+ ctx: ctxOf(unit, input.head, {
302
+ ...facts,
303
+ tags: facts.tags.map((t) => t.name),
304
+ }),
305
+ channel: input.channel,
306
+ }),
307
+ };
308
+ };
309
+ /**
310
+ * Release of a fixed group with members sorted by id:
311
+ * `next = max(bump(groupFrom, L), max_i releaseAs_i)`, the initial version
312
+ * (max over members) instead of the bump on a first release. One `PreCtx`:
313
+ * first member, group base tag, union of member tags, group line base
314
+ * counter, max total.
315
+ */
316
+ export const releaseGroup = (group, members, warnings) => {
317
+ const [first] = members;
318
+ if (!first) {
319
+ throw new TypeError("a group has at least one member");
320
+ }
321
+ const { scheme, facts, channel, head } = first.input;
322
+ const max = (a, b) => a === undefined || (b !== undefined && scheme.compare(b, a) > 0) ? b : a;
323
+ const from = fromOf(scheme, facts);
324
+ const bump = members.reduce((level, m) => maxLevel(level, m.bump), null);
325
+ const releaseAs = members.reduce((value, m) => max(value, m.releaseAs?.version), undefined);
326
+ const initial = members.reduce((value, m) => max(value, initialOf(m.input.unit, scheme)) ?? value, initialOf(first.input.unit, scheme));
327
+ const bumped = bumpedOf(scheme, bump, { from, initial });
328
+ if (from === undefined && releaseAs === undefined && bumped !== undefined) {
329
+ for (const m of members) {
330
+ warnings.push(...initialBelowPrerelease(scheme, initial, m.input.facts));
331
+ }
332
+ }
333
+ const tags = members
334
+ .flatMap((m) => m.input.facts.tags)
335
+ .flatMap((t) => {
336
+ const version = scheme.parse(t.version);
337
+ return version === undefined ? [] : [{ name: t.name, version }];
338
+ })
339
+ .sort((a, b) => scheme.compare(a.version, b.version) || compare(a.name, b.name))
340
+ .map((t) => t.name);
341
+ const raw = max(bumped, releaseAs);
342
+ const byReleaseAs = releaseAs !== undefined &&
343
+ (bumped === undefined || scheme.compare(releaseAs, bumped) > 0);
344
+ return {
345
+ from: group.baseTag?.version ?? null,
346
+ bump,
347
+ releaseAs: releaseAs === undefined ? null : scheme.format(releaseAs),
348
+ nextSource: raw === undefined
349
+ ? null
350
+ : byReleaseAs
351
+ ? "release-as"
352
+ : from === undefined
353
+ ? "initial"
354
+ : "bump",
355
+ ...render({
356
+ scheme,
357
+ raw,
358
+ ctx: ctxOf(first.input.unit, head, {
359
+ baseTag: group.baseTag,
360
+ tags,
361
+ sinceLineBase: group.sinceLineBase,
362
+ total: Math.max(...members.map((m) => m.input.facts.total)),
363
+ }),
364
+ channel,
365
+ }),
366
+ };
367
+ };
368
+ export { changeLevel };
@@ -0,0 +1,12 @@
1
+ import type { Plugin, VersionScheme } from "./plugin.js";
2
+ export interface SchemeEntry {
3
+ readonly plugin: string;
4
+ readonly name: string;
5
+ readonly scheme: VersionScheme<any>;
6
+ }
7
+ /** Schemes of core and the given plugins, in that order. */
8
+ export declare const collectSchemes: (active: readonly {
9
+ readonly plugin: Plugin;
10
+ }[]) => SchemeEntry[];
11
+ /** Name to scheme; throws `E_SCHEME_CLASH` on a duplicate name. */
12
+ export declare const schemeRegistry: (list: readonly SchemeEntry[]) => ReadonlyMap<string, VersionScheme<any>>;
@@ -0,0 +1,22 @@
1
+ import { corePlugin } from "./builtin.js";
2
+ import { TallyError } from "./errors.js";
3
+ /** Schemes of core and the given plugins, in that order. */
4
+ export const collectSchemes = (active) => [corePlugin, ...active.map((a) => a.plugin)].flatMap((plugin) => Object.entries(plugin.schemes ?? {}).map(([name, scheme]) => ({
5
+ plugin: plugin.name,
6
+ name,
7
+ scheme,
8
+ })));
9
+ /** Name to scheme; throws `E_SCHEME_CLASH` on a duplicate name. */
10
+ export const schemeRegistry = (list) => {
11
+ const owners = new Map();
12
+ const registry = new Map();
13
+ for (const { plugin, name, scheme } of list) {
14
+ const owner = owners.get(name);
15
+ if (owner !== undefined) {
16
+ throw new TallyError("E_SCHEME_CLASH", `scheme "${name}" is registered by "${owner}" and "${plugin}"`, { scheme: name, plugins: [owner, plugin] });
17
+ }
18
+ owners.set(name, plugin);
19
+ registry.set(name, scheme);
20
+ }
21
+ return registry;
22
+ };
@@ -0,0 +1,11 @@
1
+ import type { VersionScheme } from "./plugin.js";
2
+ /** A parsed SemVer 2.0 version. */
3
+ export interface SemVer {
4
+ readonly major: number;
5
+ readonly minor: number;
6
+ readonly patch: number;
7
+ readonly prerelease: readonly (string | number)[];
8
+ readonly build: readonly string[];
9
+ }
10
+ /** SemVer 2.0 scheme; bumps follow the `semver` package `inc`. */
11
+ export declare const semver: VersionScheme<SemVer>;
package/dist/semver.js ADDED
@@ -0,0 +1,53 @@
1
+ import lib from "semver";
2
+ import { TallyError } from "./errors.js";
3
+ const format = (v) => {
4
+ const pre = v.prerelease.length > 0 ? `-${v.prerelease.join(".")}` : "";
5
+ const build = v.build.length > 0 ? `+${v.build.join(".")}` : "";
6
+ return `${[v.major, v.minor, v.patch].join(".")}${pre}${build}`;
7
+ };
8
+ /** Strict: the input must be exactly its canonical form (no `v`, no padding). */
9
+ const parse = (raw) => {
10
+ const parsed = lib.parse(raw);
11
+ if (!parsed) {
12
+ return undefined;
13
+ }
14
+ const v = {
15
+ major: parsed.major,
16
+ minor: parsed.minor,
17
+ patch: parsed.patch,
18
+ prerelease: [...parsed.prerelease],
19
+ build: [...parsed.build],
20
+ };
21
+ return format(v) === raw ? v : undefined;
22
+ };
23
+ const mustParse = (raw) => {
24
+ const v = parse(raw);
25
+ if (!v) {
26
+ throw new TallyError("E_VERSION_INVALID", `invalid version "${raw}"`, {
27
+ version: raw,
28
+ });
29
+ }
30
+ return v;
31
+ };
32
+ const core = ({ major, minor, patch }) => ({ major, minor, patch });
33
+ /** SemVer 2.0 scheme; bumps follow the `semver` package `inc`. */
34
+ export const semver = {
35
+ parse,
36
+ format,
37
+ compare: (a, b) => lib.compare(format(a), format(b)),
38
+ core,
39
+ isPrerelease: (v) => v.prerelease.length > 0,
40
+ initial: () => ({ major: 1, minor: 0, patch: 0, prerelease: [], build: [] }),
41
+ bump: (v, level) => mustParse(lib.inc(format(v), level) ?? ""),
42
+ release: (next) => next,
43
+ prerelease: (next, ctx) => {
44
+ const { major, minor, patch } = core(next);
45
+ const n = String(ctx.firstParent.sinceLineBase);
46
+ const raw = `${[major, minor, patch].join(".")}-${ctx.channel}.${n}`;
47
+ const v = parse(raw);
48
+ if (!v) {
49
+ throw new TallyError("E_CHANNEL_INVALID", `channel "${ctx.channel}" does not form a valid prerelease`, { channel: ctx.channel });
50
+ }
51
+ return v;
52
+ },
53
+ };
package/dist/tags.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { Warning } from "./errors.js";
2
+ import type { TagRef, Unit, VersionScheme } from "./plugin.js";
3
+ /** A unit's `tagFormat` compiled to an anchored regex. */
4
+ export interface TagFormat {
5
+ /** The format with `{id}` substituted. */
6
+ readonly rendered: string;
7
+ /** The raw `{version}` part of a matching tag, else undefined. */
8
+ match: (tag: string) => string | undefined;
9
+ }
10
+ /** A tag of a unit whose `{version}` part parses with the unit's scheme. */
11
+ export interface UnitTag {
12
+ readonly name: string;
13
+ readonly sha: string;
14
+ readonly version: string;
15
+ }
16
+ /** Compiles `format`; literal parts and `{id}` are escaped, `{version}` must occur once. */
17
+ export declare const compileTagFormat: (format: string, unitId: string) => TagFormat;
18
+ /** Tags of a released unit and its scheme. */
19
+ export interface UnitTags {
20
+ readonly scheme: VersionScheme<any>;
21
+ /** Sorted by scheme order, then name. */
22
+ readonly tags: UnitTag[];
23
+ }
24
+ /**
25
+ * Tags of each released unit, sorted by scheme order, then name. Unreleased
26
+ * units never match. Throws `E_TAG_FORMAT_OVERLAP` if two released units render
27
+ * the same format or a tag parses for more than one of them.
28
+ */
29
+ export declare const matchUnitTags: (units: readonly Unit[], tags: readonly TagRef[], schemes: ReadonlyMap<string, VersionScheme<any>>) => Map<string, UnitTags>;
30
+ /**
31
+ * Released units without a matching tag while some tag not matched by a
32
+ * released unit contains their id, delimited by non-alphanumeric characters
33
+ * or the tag's ends.
34
+ */
35
+ export declare const noMatchingTag: (units: readonly Unit[], matched: readonly {
36
+ id: string;
37
+ tags: readonly {
38
+ name: string;
39
+ }[];
40
+ }[], tagNames: readonly string[]) => Warning[];
package/dist/tags.js ADDED
@@ -0,0 +1,92 @@
1
+ import { TallyError } from "./errors.js";
2
+ import { compare } from "./util.js";
3
+ const SPECIAL = /[\\^$.*+?()[\]{}|/]/gu;
4
+ const overlap = (units, detail) => new TallyError("E_TAG_FORMAT_OVERLAP", `released units "${units.join('", "')}" share tags (${Object.values(detail).join(", ")})`, { units, ...detail });
5
+ /** Compiles `format`; literal parts and `{id}` are escaped, `{version}` must occur once. */
6
+ export const compileTagFormat = (format, unitId) => {
7
+ const parts = format.split("{version}");
8
+ if (parts.length !== 2) {
9
+ throw new TallyError("E_TAG_FORMAT", `tag format "${format}" of unit "${unitId}" must contain {version} exactly once`, { unit: unitId, format });
10
+ }
11
+ const literal = (part) => part.replaceAll("{id}", unitId).replace(SPECIAL, "\\$&");
12
+ const re = new RegExp(`^${literal(parts[0] ?? "")}(.+)${literal(parts[1] ?? "")}$`, "u");
13
+ return {
14
+ rendered: format.replaceAll("{id}", unitId),
15
+ match: (tag) => re.exec(tag)?.[1],
16
+ };
17
+ };
18
+ /**
19
+ * Tags of each released unit, sorted by scheme order, then name. Unreleased
20
+ * units never match. Throws `E_TAG_FORMAT_OVERLAP` if two released units render
21
+ * the same format or a tag parses for more than one of them.
22
+ */
23
+ export const matchUnitTags = (units, tags, schemes) => {
24
+ const released = units
25
+ .filter((u) => u.version !== "none")
26
+ .map((unit) => {
27
+ const scheme = schemes.get(unit.scheme);
28
+ if (!scheme) {
29
+ throw new TallyError("E_SCHEME_UNKNOWN", `unit "${unit.id}" uses unknown scheme "${unit.scheme}"`, { unit: unit.id, scheme: unit.scheme });
30
+ }
31
+ return {
32
+ unit,
33
+ scheme,
34
+ format: compileTagFormat(unit.tagFormat, unit.id),
35
+ };
36
+ });
37
+ const rendered = new Map();
38
+ for (const { unit, format } of released) {
39
+ const other = rendered.get(format.rendered);
40
+ if (other !== undefined) {
41
+ throw overlap([other, unit.id], { format: format.rendered });
42
+ }
43
+ rendered.set(format.rendered, unit.id);
44
+ }
45
+ const matched = new Map(released.map((r) => [
46
+ r.unit.id,
47
+ { scheme: r.scheme, tags: [] },
48
+ ]));
49
+ for (const tag of tags) {
50
+ const owners = released.filter((r) => {
51
+ const raw = r.format.match(tag.name);
52
+ return raw !== undefined && r.scheme.parse(raw) !== undefined;
53
+ });
54
+ if (owners.length > 1) {
55
+ throw overlap(owners.map((r) => r.unit.id), { tag: tag.name });
56
+ }
57
+ const [owner] = owners;
58
+ const version = owner?.format.match(tag.name);
59
+ if (owner && version !== undefined) {
60
+ matched.get(owner.unit.id)?.tags.push({ ...tag, version });
61
+ }
62
+ }
63
+ for (const { scheme, tags: list } of matched.values()) {
64
+ list.sort((a, b) => scheme.compare(scheme.parse(a.version), scheme.parse(b.version)) ||
65
+ compare(a.name, b.name));
66
+ }
67
+ return matched;
68
+ };
69
+ /**
70
+ * Released units without a matching tag while some tag not matched by a
71
+ * released unit contains their id, delimited by non-alphanumeric characters
72
+ * or the tag's ends.
73
+ */
74
+ export const noMatchingTag = (units, matched, tagNames) => {
75
+ const names = new Set(matched.flatMap((m) => m.tags.map((t) => t.name)));
76
+ const tags = tagNames.filter((t) => !names.has(t));
77
+ return matched.flatMap((m) => {
78
+ const unit = units.find((u) => u.id === m.id);
79
+ const id = new RegExp(`(?:^|[^A-Za-z0-9])${m.id.replace(SPECIAL, "\\$&")}(?:[^A-Za-z0-9]|$)`, "u");
80
+ const similar = tags.find((t) => id.test(t));
81
+ if (!unit || m.tags.length > 0 || similar === undefined) {
82
+ return [];
83
+ }
84
+ return [
85
+ {
86
+ code: "W_NO_MATCHING_TAG",
87
+ message: `released unit "${m.id}" has no tag matching "${unit.tagFormat.replaceAll("{id}", m.id)}", but "${similar}" contains its id`,
88
+ unit: m.id,
89
+ },
90
+ ];
91
+ });
92
+ };
package/dist/testing.d.ts CHANGED
@@ -37,6 +37,8 @@ export interface Dag {
37
37
  export interface MemoryGit extends GitReader {
38
38
  /** File contents at a commit id, tag name or `HEAD` (the last commit). */
39
39
  tree: (ref: string) => Record<string, string>;
40
+ /** Parent commit ids of a ref. */
41
+ parents: (ref: string) => string[];
40
42
  }
41
43
  /** In-memory `GitReader` over a commit graph. */
42
44
  export declare const memoryGit: (dag: Dag) => MemoryGit;
@@ -48,6 +50,7 @@ export type ReaderFactory = (dag: Dag) => MaybePromise<{
48
50
  /**
49
51
  * Shared `GitReader` spec. Fixture: a - b - d - m - e - f on the first-parent
50
52
  * chain, c branches from b and merges into m, d renames pkg/x.ts to pkg/y.ts,
51
- * f adds a path with a quote, a tab and a non-ASCII character.
53
+ * f adds a path with a quote, a tab and a non-ASCII character, g is empty and
54
+ * h carries footers. `a` has a later committer date than b, c and d.
52
55
  */
53
56
  export declare const gitReaderConformance: (makeReader: ReaderFactory) => void;
package/dist/testing.js CHANGED
@@ -5,6 +5,7 @@ const SKIPPED = new Set([".git", "node_modules"]);
5
5
  const changed = (a, b) => uniqueSorted([...a.keys(), ...b.keys()]).filter((p) => a.get(p) !== b.get(p));
6
6
  /** Path git would C-quote without `-z`. */
7
7
  const ODD_PATH = 'odd/a "q"\tü.txt';
8
+ const FOOTER_MESSAGE = "feat: h\n\nbody\n\nRelease-As: core@1.0.0\nRefs: #1";
8
9
  const CONFORMANCE_DAG = {
9
10
  commits: [
10
11
  {
@@ -29,6 +30,12 @@ const CONFORMANCE_DAG = {
29
30
  files: { "README.md": "e\n" },
30
31
  },
31
32
  { id: "f", message: "chore: odd", files: { [ODD_PATH]: "f\n" } },
33
+ { id: "g", message: "chore: empty" },
34
+ {
35
+ id: "h",
36
+ message: FOOTER_MESSAGE,
37
+ files: { "README.md": "h\n" },
38
+ },
32
39
  ],
33
40
  tags: [
34
41
  { name: "v1", commit: "a" },
@@ -165,12 +172,14 @@ export const memoryGit = (dag) => {
165
172
  },
166
173
  diffFiles: (from, to) => changed(node(from).tree, node(to).tree),
167
174
  tree: (ref) => Object.fromEntries(node(ref).tree),
175
+ parents: (ref) => [...node(ref).parents],
168
176
  };
169
177
  };
170
178
  /**
171
179
  * Shared `GitReader` spec. Fixture: a - b - d - m - e - f on the first-parent
172
180
  * chain, c branches from b and merges into m, d renames pkg/x.ts to pkg/y.ts,
173
- * f adds a path with a quote, a tab and a non-ASCII character.
181
+ * f adds a path with a quote, a tab and a non-ASCII character, g is empty and
182
+ * h carries footers. `a` has a later committer date than b, c and d.
174
183
  */
175
184
  export const gitReaderConformance = (makeReader) => {
176
185
  describe("GitReader conformance", () => {
@@ -238,6 +247,24 @@ export const gitReaderConformance = (makeReader) => {
238
247
  expect(Date.parse(byId("a")?.date ?? "")).toBe(Date.parse("2024-01-01T10:00:00Z"));
239
248
  expect(Date.parse(byId("e")?.date ?? "")).toBe(Date.parse("2024-01-02T12:30:00Z"));
240
249
  });
250
+ it("lists every commit before its parents", async () => {
251
+ const commits = await git.log(null, sha("e"));
252
+ const index = new Map(commits.map((c, i) => [c.sha, i]));
253
+ for (const [i, c] of commits.entries()) {
254
+ for (const parent of c.parents) {
255
+ expect(index.get(parent) ?? Infinity).toBeGreaterThan(i);
256
+ }
257
+ }
258
+ });
259
+ it("logs empty commits and footers verbatim", async () => {
260
+ const commits = await git.log(sha("f"), sha("h"));
261
+ expect(commits.map((c) => c.sha).sort(compare)).toEqual(shas(["g", "h"]));
262
+ expect(commits.find((c) => c.sha === sha("g"))?.files).toEqual([]);
263
+ expect(commits.find((c) => c.sha === sha("h"))).toMatchObject({
264
+ message: FOOTER_MESSAGE,
265
+ files: ["README.md"],
266
+ });
267
+ });
241
268
  it("counts first-parent commits", async () => {
242
269
  const count = (from, to) => git.countFirstParent(from === null ? null : sha(from), sha(to));
243
270
  expect(await count(null, "e")).toBe(5);