@tallyrelease/core 0.3.0 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.1](https://github.com/abinnovision/tallyrelease/compare/core-v0.3.0...core-v0.3.1) (2026-10-16)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **core:** count prereleases from a unit's introduction without a line base ([#48](https://github.com/abinnovision/tallyrelease/issues/48)) ([06a591b](https://github.com/abinnovision/tallyrelease/commit/06a591b3522cf60ce5bde56e393f464617ffa101))
9
+
3
10
  ## [0.3.0](https://github.com/abinnovision/tally-release/compare/core-v0.2.0...core-v0.3.0) (2026-10-16)
4
11
 
5
12
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @tallyrelease/core
2
2
 
3
- Pure planning core and plugin contract for [tallyrelease](https://github.com/abinnovision/tally-release). It does no I/O: files and git are read through the injected `ReadonlyFs`, `Ctx` and `GitReader`.
3
+ Pure planning core and plugin contract for [tallyrelease](https://github.com/abinnovision/tallyrelease). It does no I/O: files and git are read through the injected `ReadonlyFs`, `Ctx` and `GitReader`.
4
4
 
5
5
  Most users want [`@tallyrelease/cli`](https://www.npmjs.com/package/@tallyrelease/cli) instead. Install this package to write an ecosystem plugin or to embed the planner.
6
6
 
@@ -22,7 +22,7 @@ export const example = definePlugin({
22
22
  });
23
23
  ```
24
24
 
25
- See `Plugin` in [`src/plugin.ts`](https://github.com/abinnovision/tally-release/blob/main/packages/core/src/plugin.ts) for the available hooks (`discover`, `edges`, `inputs`, `versionFiles`, `schemes`, `intent`).
25
+ See `Plugin` in [`src/plugin.ts`](https://github.com/abinnovision/tallyrelease/blob/main/packages/core/src/plugin.ts) for the available hooks (`discover`, `edges`, `inputs`, `versionFiles`, `schemes`, `intent`).
26
26
 
27
27
  ## Testing
28
28
 
package/dist/explain.d.ts CHANGED
@@ -25,6 +25,8 @@ export interface UnitExplanation {
25
25
  name: string;
26
26
  members: string[];
27
27
  } | null;
28
+ /** Origin of the prerelease counter without a line base; see `UnitFacts`. */
29
+ introduced: string | null;
28
30
  /** What set `unit.next`: the bump, the initial version or a Release-As value. */
29
31
  nextSource: "bump" | "initial" | "release-as" | null;
30
32
  /** Changed unit files (reason `direct`) or changed global inputs (`global`). */
package/dist/explain.js CHANGED
@@ -52,6 +52,7 @@ export const explainUnit = async (workspace, git, { unit: id, ...opts }) => {
52
52
  plan,
53
53
  unit,
54
54
  group: group ? { name: group.name, members: [...group.members] } : null,
55
+ introduced: unitFacts?.introduced ?? null,
55
56
  nextSource: releases.get(id)?.nextSource ?? null,
56
57
  files,
57
58
  changes,
package/dist/facts.d.ts CHANGED
@@ -40,6 +40,15 @@ export interface UnitFacts {
40
40
  readonly fromTag: UnitTag | null;
41
41
  /** Highest stable tag on head's first-parent chain. */
42
42
  readonly lineBaseTag: UnitTag | null;
43
+ /**
44
+ * Without a line base and with a root other than `.`: the oldest commit on
45
+ * head's first-parent chain that touches the unit root.
46
+ */
47
+ readonly introduced: string | null;
48
+ /**
49
+ * First-parent commits since the line base; without one, since and
50
+ * including `introduced`, else `total`.
51
+ */
43
52
  readonly sinceLineBase: number;
44
53
  readonly total: number;
45
54
  /** Changes since the base tag attributed to this unit, core commit order. */
@@ -61,6 +70,7 @@ export interface GroupFacts {
61
70
  readonly baseTag: UnitTag | null;
62
71
  /** Highest stable member tag on head's first-parent chain. */
63
72
  readonly lineBaseTag: UnitTag | null;
73
+ /** Without a line base, the max over the members. */
64
74
  readonly sinceLineBase: number;
65
75
  }
66
76
  /** Git and intent data of every released unit, collected before release math. */
package/dist/facts.js CHANGED
@@ -156,6 +156,20 @@ export const collectPlanFacts = async (workspace, git, opts) => {
156
156
  }
157
157
  return a.sha !== b.sha && (await isAncestor(a.sha, b.sha)) ? b : a;
158
158
  };
159
+ /** Counter since the line base; without one, since the unit's introduction. */
160
+ const lineCount = async (root, lineBaseTag) => {
161
+ if (lineBaseTag) {
162
+ return {
163
+ introduced: null,
164
+ sinceLineBase: await countSince(lineBaseTag.sha),
165
+ };
166
+ }
167
+ const introduced = root === "." ? null : await git.introduced(root, head);
168
+ return {
169
+ introduced,
170
+ sinceLineBase: introduced === null ? total : (await countSince(introduced)) + 1,
171
+ };
172
+ };
159
173
  const bases = new Map();
160
174
  for (const unit of workspace.units) {
161
175
  const matched = tags.get(unit.id);
@@ -173,9 +187,14 @@ export const collectPlanFacts = async (workspace, git, opts) => {
173
187
  break;
174
188
  }
175
189
  }
176
- bases.set(unit.id, { scheme, baseTag: stable.at(-1) ?? null, lineBaseTag });
190
+ bases.set(unit.id, {
191
+ scheme,
192
+ baseTag: stable.at(-1) ?? null,
193
+ lineBaseTag,
194
+ // eslint-disable-next-line no-await-in-loop -- one git call per unit
195
+ ...(await lineCount(unit.root, lineBaseTag)),
196
+ });
177
197
  }
178
- const sinceLine = async (tag) => tag ? await countSince(tag.sha) : total;
179
198
  const groups = [];
180
199
  for (const { name, members } of workspace.groups) {
181
200
  let baseTag = null;
@@ -194,8 +213,10 @@ export const collectPlanFacts = async (workspace, git, opts) => {
194
213
  members,
195
214
  baseTag,
196
215
  lineBaseTag,
197
- // eslint-disable-next-line no-await-in-loop -- memoized per line base
198
- sinceLineBase: await sinceLine(lineBaseTag),
216
+ sinceLineBase: lineBaseTag
217
+ ? // eslint-disable-next-line no-await-in-loop -- memoized per line base
218
+ await countSince(lineBaseTag.sha)
219
+ : Math.max(0, ...members.map((id) => bases.get(id)?.sinceLineBase ?? 0)),
199
220
  });
200
221
  }
201
222
  const groupOf = new Map(groups.flatMap((g) => g.members.map((id) => [id, g])));
@@ -206,7 +227,7 @@ export const collectPlanFacts = async (workspace, git, opts) => {
206
227
  if (!matched || !own) {
207
228
  continue;
208
229
  }
209
- const { baseTag, lineBaseTag } = own;
230
+ const { baseTag, lineBaseTag, introduced, sinceLineBase } = own;
210
231
  const group = groupOf.get(unit.id);
211
232
  const fromTag = group ? group.baseTag : baseTag;
212
233
  const rangeBase = group &&
@@ -227,8 +248,8 @@ export const collectPlanFacts = async (workspace, git, opts) => {
227
248
  baseTag,
228
249
  fromTag,
229
250
  lineBaseTag,
230
- // eslint-disable-next-line no-await-in-loop -- memoized per line base
231
- sinceLineBase: await sinceLine(lineBaseTag),
251
+ introduced,
252
+ sinceLineBase,
232
253
  total,
233
254
  changes: changes.filter((c) => c.units.includes(unit.id)),
234
255
  releaseAs: changes.flatMap(({ change }) => (change.releaseAs ?? [])
package/dist/plugin.d.ts CHANGED
@@ -55,6 +55,12 @@ export interface GitReader {
55
55
  countFirstParent: (from: string | null, to: string) => MaybePromise<number>;
56
56
  /** Tree-to-tree changed paths without rename detection, sorted. */
57
57
  diffFiles: (from: string, to: string) => MaybePromise<readonly string[]>;
58
+ /**
59
+ * Oldest commit on the first-parent chain of `to` whose diff to its first
60
+ * parent (the empty tree for a root commit) touches a file under directory
61
+ * `path`; null if none.
62
+ */
63
+ introduced: (path: string, to: string) => MaybePromise<string | null>;
58
64
  }
59
65
  export interface TagRef {
60
66
  name: string;
package/dist/testing.d.ts CHANGED
@@ -52,5 +52,6 @@ export type ReaderFactory = (dag: Dag) => MaybePromise<{
52
52
  * chain, c branches from b and merges into m, d renames pkg/x.ts to pkg/y.ts,
53
53
  * f adds a path with a quote, a tab and a non-ASCII character, g is empty and
54
54
  * h carries footers. `a` has a later committer date than b, c and d.
55
+ * `introduced` runs on its own fixture.
55
56
  */
56
57
  export declare const gitReaderConformance: (makeReader: ReaderFactory) => void;
package/dist/testing.js CHANGED
@@ -43,6 +43,26 @@ const CONFORMANCE_DAG = {
43
43
  { name: "side", commit: "c" },
44
44
  ],
45
45
  };
46
+ /**
47
+ * First-parent chain r - s - t - u - m - v; k branches from r and merges into
48
+ * m. s adds pkg/ab, t adds pkg/a, u deletes it and v re-adds it. k adds side
49
+ * and gone, m drops gone and v adds it again.
50
+ */
51
+ const INTRODUCED_DAG = {
52
+ commits: [
53
+ { id: "r", files: { "lib/r.ts": "r\n" } },
54
+ { id: "s", files: { "pkg/ab/x.ts": "s\n" } },
55
+ {
56
+ id: "k",
57
+ parents: ["r"],
58
+ files: { "side/k.ts": "k\n", "gone/x.ts": "k\n" },
59
+ },
60
+ { id: "t", parents: ["s"], files: { "pkg/a/x.ts": "t\n" } },
61
+ { id: "u", files: { "pkg/a/x.ts": null } },
62
+ { id: "m", parents: ["u", "k"], files: { "gone/x.ts": null } },
63
+ { id: "v", files: { "pkg/a/x.ts": "v\n", "gone/x.ts": "v\n" } },
64
+ ],
65
+ };
46
66
  /** In-memory `ReadonlyFs` over a path-to-content map. */
47
67
  export const memoryFs = (files) => {
48
68
  const paths = Object.keys(files).sort(compare);
@@ -171,6 +191,13 @@ export const memoryGit = (dag) => {
171
191
  return firstParentChain(to).filter((n) => !skip.has(n.id)).length;
172
192
  },
173
193
  diffFiles: (from, to) => changed(node(from).tree, node(to).tree),
194
+ introduced: (path, to) => {
195
+ const under = (p) => p === path || p.startsWith(`${path}/`);
196
+ const intro = firstParentChain(to)
197
+ .reverse()
198
+ .find((n) => changed(n.parents[0] === undefined ? new Map() : node(n.parents[0]).tree, n.tree).some(under));
199
+ return intro?.id ?? null;
200
+ },
174
201
  tree: (ref) => Object.fromEntries(node(ref).tree),
175
202
  parents: (ref) => [...node(ref).parents],
176
203
  };
@@ -180,6 +207,7 @@ export const memoryGit = (dag) => {
180
207
  * chain, c branches from b and merges into m, d renames pkg/x.ts to pkg/y.ts,
181
208
  * f adds a path with a quote, a tab and a non-ASCII character, g is empty and
182
209
  * h carries footers. `a` has a later committer date than b, c and d.
210
+ * `introduced` runs on its own fixture.
183
211
  */
184
212
  export const gitReaderConformance = (makeReader) => {
185
213
  describe("GitReader conformance", () => {
@@ -290,5 +318,29 @@ export const gitReaderConformance = (makeReader) => {
290
318
  [ODD_PATH],
291
319
  ]);
292
320
  });
321
+ describe("introduced", () => {
322
+ let intro;
323
+ let introSha;
324
+ const introduced = async (path) => await intro.introduced(path, introSha("v"));
325
+ beforeAll(async () => {
326
+ ({ git: intro, sha: introSha } = await makeReader(INTRODUCED_DAG));
327
+ });
328
+ it("finds the commit that adds a path on the chain", async () => {
329
+ expect(await introduced("lib")).toBe(introSha("r"));
330
+ expect(await introduced("pkg/ab")).toBe(introSha("s"));
331
+ });
332
+ it("attributes a path added on a side branch to the merge", async () => {
333
+ expect(await introduced("side")).toBe(introSha("m"));
334
+ });
335
+ it("matches whole directories and keeps the oldest commit", async () => {
336
+ expect(await introduced("pkg/a")).toBe(introSha("t"));
337
+ });
338
+ it("skips a merge identical to its first parent for the path", async () => {
339
+ expect(await introduced("gone")).toBe(introSha("v"));
340
+ });
341
+ it("returns null for a path no commit touches", async () => {
342
+ expect(await introduced("missing")).toBeNull();
343
+ });
344
+ });
293
345
  });
294
346
  };
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@tallyrelease/core",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Pure planning core of tallyrelease",
5
5
  "repository": {
6
6
  "type": "git",
7
- "url": "git+https://github.com/abinnovision/tally-release.git",
7
+ "url": "git+https://github.com/abinnovision/tallyrelease.git",
8
8
  "directory": "packages/core"
9
9
  },
10
10
  "license": "Apache-2.0",