@tallyrelease/core 0.2.0 → 0.3.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,247 @@
1
+ import { changelogPath, changelogSection, updateChangelog, } from "./changelog.js";
2
+ import { normalizeRoot } from "./discover.js";
3
+ import { TallyError } from "./errors.js";
4
+ import { runHook } from "./plugins.js";
5
+ import { schemeRegistry } from "./schemes.js";
6
+ const TOKEN = /\{([^{}]*)\}/gu;
7
+ const SPECIAL = /[\\^$.*+?()[\]{}|/]/gu;
8
+ const CORE_TOKENS = ["major", "minor", "patch"];
9
+ const VERSION = "\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?";
10
+ const ALNUM = /[0-9A-Za-z]/u;
11
+ const escape = (text) => text.replace(SPECIAL, "\\$&");
12
+ const configError = (pattern, unit, problem) => new TallyError("E_CONFIG", `version file pattern "${pattern}" of unit "${unit}" ${problem}`, { unit, pattern });
13
+ /**
14
+ * Compiles a template pattern: literal parts escaped, `{id}` literal, version
15
+ * tokens captured. An edge that is a token or an alphanumeric literal must not
16
+ * touch another alphanumeric, and the end must not be followed by `.<digit>`.
17
+ * Throws `E_CONFIG` for an unknown token, no version token, `{version}` mixed
18
+ * with `{major}`, `{minor}` or `{patch}` (their values could disagree), or no
19
+ * literal text.
20
+ */
21
+ const compileTemplate = (pattern, unit) => {
22
+ const tokens = [];
23
+ let source = "";
24
+ let literal = false;
25
+ let last = 0;
26
+ for (const m of pattern.matchAll(TOKEN)) {
27
+ const text = pattern.slice(last, m.index);
28
+ literal ||= text !== "";
29
+ source += escape(text);
30
+ last = m.index + m[0].length;
31
+ const token = m[1] ?? "";
32
+ if (token === "id") {
33
+ source += escape(unit);
34
+ }
35
+ else if (token === "version") {
36
+ tokens.push(token);
37
+ source += `(${VERSION})`;
38
+ }
39
+ else if (CORE_TOKENS.includes(token)) {
40
+ tokens.push(token);
41
+ source += "(\\d+)";
42
+ }
43
+ else {
44
+ throw configError(pattern, unit, `uses unknown token "${m[0]}"; use {id}, {version}, {major}, {minor} or {patch}`);
45
+ }
46
+ }
47
+ const tail = pattern.slice(last);
48
+ literal ||= tail !== "";
49
+ source += escape(tail);
50
+ if (tokens.length === 0) {
51
+ throw configError(pattern, unit, "has no version token");
52
+ }
53
+ if (tokens.includes("version") && tokens.some((t) => t !== "version")) {
54
+ throw configError(pattern, unit, "mixes {version} with {major}, {minor} or {patch}");
55
+ }
56
+ if (!literal) {
57
+ throw configError(pattern, unit, "has no literal text");
58
+ }
59
+ const bounded = (edge, brace) => edge === brace || ALNUM.test(edge ?? "");
60
+ const before = bounded(pattern.at(0), "{") ? "(?<![0-9A-Za-z])" : "";
61
+ const after = bounded(pattern.at(-1), "}")
62
+ ? "(?![0-9A-Za-z]|\\.\\d)"
63
+ : "(?!\\.\\d)";
64
+ return { re: new RegExp(`${before}${source}${after}`, "gu"), tokens };
65
+ };
66
+ /**
67
+ * Replaces each match of `pattern` whose captured values render a version `v`
68
+ * with `from <= v < next` (compared over the tokens present) by the rendering
69
+ * of `next`. `hit` is false when the pattern matches nothing.
70
+ */
71
+ const applyTemplate = (content, { pattern, unit, from, next, scheme, }) => {
72
+ const { re, tokens } = compileTemplate(pattern, unit);
73
+ const low = scheme.parse(from);
74
+ const high = scheme.parse(next);
75
+ if (low === undefined || high === undefined) {
76
+ throw new Error(`"${from}" or "${next}" is not a version of the scheme`);
77
+ }
78
+ const present = CORE_TOKENS.filter((t) => tokens.includes(t));
79
+ const key = (v) => present.map((t) => scheme.core(v)[t]);
80
+ const compareKeys = (a, b) => a.reduce((c, n, i) => c || n - (b[i] ?? 0), 0);
81
+ const rendered = pattern.replace(TOKEN, (_, token) => token === "id"
82
+ ? unit
83
+ : token === "version"
84
+ ? next
85
+ : String(scheme.core(high)[token]));
86
+ let hit = false;
87
+ const replaced = content.replace(re, (match, ...groups) => {
88
+ const values = new Map();
89
+ for (const [i, token] of tokens.entries()) {
90
+ const value = String(groups[i]);
91
+ if ((values.get(token) ?? value) !== value) {
92
+ return match;
93
+ }
94
+ values.set(token, value);
95
+ }
96
+ const raw = values.get("version");
97
+ const v = raw === undefined ? undefined : scheme.parse(raw);
98
+ if (raw !== undefined && v === undefined) {
99
+ return match;
100
+ }
101
+ hit = true;
102
+ const found = () => present.map((t) => Number(values.get(t)));
103
+ const inRange = v === undefined
104
+ ? compareKeys(found(), key(low)) >= 0 &&
105
+ compareKeys(found(), key(high)) < 0
106
+ : scheme.compare(v, low) >= 0 && scheme.compare(v, high) < 0;
107
+ return inRange ? rendered : match;
108
+ });
109
+ return { content: replaced, hit };
110
+ };
111
+ /** Repo-relative template file; `E_CONFIG` for `.`, absolute or `..` paths. */
112
+ const templateFile = (file, unit) => {
113
+ const invalid = () => new TallyError("E_CONFIG", `version file "${file}" of unit "${unit}" must be a repo-relative file without ".." segments`, { unit, file });
114
+ let path;
115
+ try {
116
+ path = normalizeRoot(file);
117
+ }
118
+ catch {
119
+ throw invalid();
120
+ }
121
+ if (path === ".") {
122
+ throw invalid();
123
+ }
124
+ return path;
125
+ };
126
+ /** Checks template files and patterns (`E_CONFIG`); runs no plugin hook. */
127
+ export const validateTemplates = (workspace) => {
128
+ for (const [id, templates] of workspace.release.templates) {
129
+ for (const { file, pattern } of templates) {
130
+ templateFile(file, id);
131
+ compileTemplate(pattern, id);
132
+ }
133
+ }
134
+ };
135
+ /**
136
+ * Plugin version files by unit id. Throws `E_VERSION_FILE_CLAIM` when a path
137
+ * is a plugin version file of two units or also a template. Templates may
138
+ * share a file.
139
+ */
140
+ export const versionFileClaims = async (workspace) => {
141
+ const files = await workspace.versionFiles();
142
+ const claims = new Map();
143
+ for (const [id, list] of files) {
144
+ for (const { path } of list) {
145
+ const prior = claims.get(path);
146
+ if (prior !== undefined) {
147
+ throw new TallyError("E_VERSION_FILE_CLAIM", `version file "${path}" is claimed by units "${prior}" and "${id}"`, { path, units: [prior, id] });
148
+ }
149
+ claims.set(path, id);
150
+ }
151
+ }
152
+ for (const [id, templates] of workspace.release.templates) {
153
+ for (const { file } of templates) {
154
+ const path = templateFile(file, id);
155
+ const owner = claims.get(path);
156
+ if (owner !== undefined) {
157
+ throw new TallyError("E_VERSION_FILE_CLAIM", `version file template of unit "${id}" targets "${path}", a version file of unit "${owner}"`, { path, units: [owner, id] });
158
+ }
159
+ }
160
+ }
161
+ return files;
162
+ };
163
+ /**
164
+ * Files of a release of `items` (stable `next`): plugin version files,
165
+ * templates and `<root>/CHANGELOG.md` per unit, read through `fs`. Returns
166
+ * only files whose content changes, and each unit's changelog section;
167
+ * checks claims first. Templates skip first releases and warn
168
+ * `W_VERSION_FILE_NO_MATCH` without any match; a missing version file is
169
+ * `E_VERSION_FILE_MISSING`.
170
+ */
171
+ export const releaseFiles = async (workspace, plan, opts) => {
172
+ const { fs, items, repoUrl, date } = opts;
173
+ const pluginFiles = await versionFileClaims(workspace);
174
+ const schemes = schemeRegistry(workspace.schemes);
175
+ const units = new Map(workspace.units.map((u) => [u.id, u]));
176
+ const planned = new Map(plan.units.map((u) => [u.id, u]));
177
+ const versions = new Map(items.flatMap((i) => i.units.map((id) => [id, i.version])));
178
+ const pr = (item) => item.mode === "shared" ? "shared" : item.name;
179
+ const files = new Map();
180
+ const sections = new Map();
181
+ const warnings = [];
182
+ const read = (path) => files.get(path) ?? fs.read(path);
183
+ const required = (path, unit) => {
184
+ const content = read(path);
185
+ if (content === undefined) {
186
+ throw new TallyError("E_VERSION_FILE_MISSING", `version file "${path}" of unit "${unit}" does not exist`, { path, unit });
187
+ }
188
+ return content;
189
+ };
190
+ for (const item of items) {
191
+ const prUnits = items
192
+ .filter((i) => pr(i) === pr(item))
193
+ .flatMap((i) => i.units);
194
+ for (const id of item.units) {
195
+ const unit = units.get(id);
196
+ const entry = planned.get(id);
197
+ const scheme = unit && schemes.get(unit.scheme);
198
+ if (!unit || !entry || !scheme) {
199
+ continue;
200
+ }
201
+ const ctx = { unit, units: workspace.units, versions };
202
+ for (const file of pluginFiles.get(id) ?? []) {
203
+ const content = required(file.path, id);
204
+ // eslint-disable-next-line no-await-in-loop -- files are written in order
205
+ const written = await runHook(unit.plugin, "write", () => file.write(content, item.version, ctx));
206
+ files.set(file.path, written);
207
+ }
208
+ const { from } = entry;
209
+ const templates = from === null ? [] : (workspace.release.templates.get(id) ?? []);
210
+ for (const template of templates) {
211
+ const { pattern } = template;
212
+ const file = templateFile(template.file, id);
213
+ const result = applyTemplate(required(file, id), {
214
+ pattern,
215
+ unit: id,
216
+ from: from ?? "",
217
+ next: item.version,
218
+ scheme,
219
+ });
220
+ files.set(file, result.content);
221
+ if (!result.hit) {
222
+ warnings.push({
223
+ code: "W_VERSION_FILE_NO_MATCH",
224
+ message: `version file pattern "${pattern}" of unit "${id}" matches nothing in "${file}"`,
225
+ unit: id,
226
+ });
227
+ }
228
+ }
229
+ const section = changelogSection(workspace, plan, {
230
+ id,
231
+ repoUrl,
232
+ date,
233
+ prUnits,
234
+ });
235
+ if (section !== undefined) {
236
+ sections.set(id, section);
237
+ const path = changelogPath(unit);
238
+ files.set(path, updateChangelog(read(path), section, { baseVersion: from, scheme }));
239
+ }
240
+ }
241
+ }
242
+ return {
243
+ files: new Map([...files].filter(([path, c]) => fs.read(path) !== c)),
244
+ sections,
245
+ warnings,
246
+ };
247
+ };
@@ -1,7 +1,7 @@
1
1
  import type { Warning } from "./errors.js";
2
2
  import type { Graph } from "./graph.js";
3
3
  import type { Group } from "./groups.js";
4
- import type { IntentSource, Unit } from "./plugin.js";
4
+ import type { IntentSource, Unit, VersionFile } from "./plugin.js";
5
5
  import type { PluginEnv } from "./plugins.js";
6
6
  import type { ReleaseSettings } from "./release.js";
7
7
  import type { SchemeEntry } from "./schemes.js";
@@ -33,6 +33,8 @@ export interface Workspace {
33
33
  readonly release: ReleaseSettings;
34
34
  /** Fixed groups, sorted by name. */
35
35
  readonly groups: readonly Group[];
36
+ /** Plugin version files of released units by unit id; runs the hooks once. */
37
+ readonly versionFiles: () => Promise<ReadonlyMap<string, readonly VersionFile[]>>;
36
38
  }
37
39
  /** Runs plugin hooks sequentially in config order and builds the graph. */
38
40
  export declare const loadWorkspace: (input: WorkspaceInput) => Promise<Workspace>;
package/dist/workspace.js CHANGED
@@ -43,6 +43,29 @@ const collectGlobalInputs = async (active, config, configFile) => {
43
43
  }
44
44
  return uniqueSorted(globs);
45
45
  };
46
+ /**
47
+ * Plugin version files of released units with version source `file`, by unit
48
+ * id; hooks run on the first call only.
49
+ */
50
+ const versionFilesOf = (active, units) => {
51
+ let files;
52
+ const collect = async () => {
53
+ const found = new Map();
54
+ for (const { plugin, ctx } of active) {
55
+ const hook = plugin.versionFiles?.bind(plugin);
56
+ for (const unit of units) {
57
+ if (!hook || unit.plugin !== plugin.name || unit.version !== "file") {
58
+ continue;
59
+ }
60
+ // eslint-disable-next-line no-await-in-loop -- hooks run sequentially in config order
61
+ const list = await runHook(plugin.name, "versionFiles", () => hook(ctx, unit));
62
+ found.set(unit.id, list);
63
+ }
64
+ }
65
+ return found;
66
+ };
67
+ return async () => await (files ??= collect());
68
+ };
46
69
  /** Runs plugin hooks sequentially in config order and builds the graph. */
47
70
  export const loadWorkspace = async (input) => {
48
71
  const warnings = [];
@@ -68,7 +91,8 @@ export const loadWorkspace = async (input) => {
68
91
  warnings: [...warnings, ...graph.warnings],
69
92
  schemes: collectSchemes(active),
70
93
  intents,
71
- release: releaseSettings(input.config),
94
+ release: releaseSettings(input.config, units),
72
95
  groups,
96
+ versionFiles: versionFilesOf(active, units),
73
97
  };
74
98
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyrelease/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Pure planning core of tallyrelease",
5
5
  "repository": {
6
6
  "type": "git",