@tallyrelease/core 0.0.0-stage → 0.0.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,42 @@
1
+ import type { Warning } from "./errors.js";
2
+ import type { GitReader } from "./plugin.js";
3
+ import type { Workspace } from "./workspace.js";
4
+ export type BaseSource = "cli" | "push-before" | "pr-merge-base" | "merge-group" | "forge-last-success" | "none";
5
+ export type AffectedReason = "direct" | "dependent" | "global" | "all";
6
+ export interface AffectedUnit {
7
+ id: string;
8
+ /** Unit root, `.` for the repo root. */
9
+ path: string;
10
+ plugin: string;
11
+ reason: AffectedReason;
12
+ /** Affected direct dependencies that made a dependent affected. */
13
+ via: string[];
14
+ }
15
+ /** `tallyrelease affected --json`, schema 1. */
16
+ export interface AffectedDoc {
17
+ schema: 1;
18
+ tool: string;
19
+ repo: string;
20
+ branch: string;
21
+ head: string;
22
+ build: {
23
+ base: string | null;
24
+ baseSource: BaseSource;
25
+ all: boolean;
26
+ globalInputs: string[];
27
+ };
28
+ units: AffectedUnit[];
29
+ unattributed: string[];
30
+ warnings: Warning[];
31
+ }
32
+ export interface AffectedOptions {
33
+ /** For example `tallyrelease@0.1.0`. */
34
+ tool: string;
35
+ branch: string;
36
+ head: string;
37
+ /** Null marks every unit affected without a diff. */
38
+ base: string | null;
39
+ baseSource: BaseSource;
40
+ }
41
+ /** Computes the build set for `base..head` from a tree-to-tree diff. */
42
+ export declare const computeAffected: (workspace: Workspace, git: GitReader, opts: AffectedOptions) => Promise<AffectedDoc>;
@@ -0,0 +1,74 @@
1
+ import { attribution, matcher } from "./attribution.js";
2
+ import { uniqueSorted } from "./util.js";
3
+ /** Computes the build set for `base..head` from a tree-to-tree diff. */
4
+ export const computeAffected = async (workspace, git, opts) => {
5
+ const { units, graph } = workspace;
6
+ const doc = (affected, build, unattributed) => ({
7
+ schema: 1,
8
+ tool: opts.tool,
9
+ repo: workspace.repo,
10
+ branch: opts.branch,
11
+ head: opts.head,
12
+ build: { base: opts.base, baseSource: opts.baseSource, ...build },
13
+ units: affected,
14
+ unattributed,
15
+ warnings: [...workspace.warnings],
16
+ });
17
+ const everything = (reason) => units.map((u) => ({
18
+ id: u.id,
19
+ path: u.root,
20
+ plugin: u.plugin,
21
+ reason,
22
+ via: [],
23
+ }));
24
+ if (opts.base === null) {
25
+ return doc(everything("all"), { all: true, globalInputs: [] }, []);
26
+ }
27
+ const ignored = matcher(workspace.ignore);
28
+ const isGlobal = matcher(workspace.globalInputs);
29
+ const owners = attribution(units);
30
+ const files = (await git.diffFiles(opts.base, opts.head)).filter((f) => !ignored(f));
31
+ const hits = uniqueSorted(files.filter(isGlobal));
32
+ const direct = new Set();
33
+ const unattributed = [];
34
+ for (const file of files) {
35
+ if (isGlobal(file)) {
36
+ continue;
37
+ }
38
+ const ids = owners(file);
39
+ if (ids.length === 0) {
40
+ unattributed.push(file);
41
+ }
42
+ for (const id of ids) {
43
+ direct.add(id);
44
+ }
45
+ }
46
+ if (hits.length > 0) {
47
+ return doc(everything("global"), { all: true, globalInputs: hits }, uniqueSorted(unattributed));
48
+ }
49
+ const affected = new Set(direct);
50
+ const queue = [...direct];
51
+ for (const id of queue) {
52
+ for (const dependent of graph.dependents.get(id) ?? []) {
53
+ if (!affected.has(dependent)) {
54
+ affected.add(dependent);
55
+ queue.push(dependent);
56
+ }
57
+ }
58
+ }
59
+ const result = units
60
+ .filter((u) => affected.has(u.id))
61
+ .map((u) => {
62
+ const isDirect = direct.has(u.id);
63
+ return {
64
+ id: u.id,
65
+ path: u.root,
66
+ plugin: u.plugin,
67
+ reason: isDirect ? "direct" : "dependent",
68
+ via: isDirect
69
+ ? []
70
+ : (graph.dependencies.get(u.id) ?? []).filter((d) => affected.has(d)),
71
+ };
72
+ });
73
+ return doc(result, { all: false, globalInputs: [] }, uniqueSorted(unattributed));
74
+ };
@@ -0,0 +1,6 @@
1
+ import type { Unit } from "./plugin.js";
2
+ export type PathMatcher = (path: string) => boolean;
3
+ /** Matches any of `patterns`; an empty list matches nothing. */
4
+ export declare const matcher: (patterns: readonly string[]) => PathMatcher;
5
+ /** Returns the ids of units owning a path: `include` matches, `exclude` does not. */
6
+ export declare const attribution: (units: readonly Unit[]) => ((path: string) => string[]);
@@ -0,0 +1,12 @@
1
+ import picomatch from "picomatch/posix.js";
2
+ /** Matches any of `patterns`; an empty list matches nothing. */
3
+ export const matcher = (patterns) => patterns.length === 0 ? () => false : picomatch([...patterns], { dot: true });
4
+ /** Returns the ids of units owning a path: `include` matches, `exclude` does not. */
5
+ export const attribution = (units) => {
6
+ const owners = units.map((u) => ({
7
+ id: u.id,
8
+ include: matcher(u.include),
9
+ exclude: matcher(u.exclude),
10
+ }));
11
+ return (path) => owners.filter((o) => o.include(path) && !o.exclude(path)).map((o) => o.id);
12
+ };
@@ -0,0 +1,36 @@
1
+ import type { Propagate, VersionMode } from "./plugin.js";
2
+ /** Config overlay for the unit at a root; creates a config-only unit if none is discovered. */
3
+ export interface UnitOverlay {
4
+ id?: string;
5
+ include?: string[];
6
+ exclude?: string[];
7
+ version?: VersionMode;
8
+ scheme?: string;
9
+ tagFormat?: string;
10
+ propagate?: Propagate;
11
+ }
12
+ export interface DepConfig {
13
+ from: string;
14
+ to: string;
15
+ pinned?: boolean;
16
+ reason?: string;
17
+ }
18
+ /** Parsed `tallyrelease.yaml`, as passed by the cli. */
19
+ export interface TallyConfig {
20
+ /**
21
+ * Enabled plugins in run order, mapped to their raw options. The core-owned
22
+ * key `globalInputs: false` disables that plugin's default global inputs.
23
+ */
24
+ plugins: Record<string, Record<string, unknown> | null>;
25
+ defaults?: {
26
+ tagFormat?: string;
27
+ propagate?: Propagate;
28
+ };
29
+ /** Overlays keyed by unit root. */
30
+ units?: Record<string, UnitOverlay>;
31
+ deps?: DepConfig[];
32
+ ignore?: string[];
33
+ globalInputs?: string[];
34
+ }
35
+ /** Global input owned by core. */
36
+ export declare const CONFIG_FILE = "tallyrelease.yaml";
package/dist/config.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Global input owned by core. */
2
+ export const CONFIG_FILE = "tallyrelease.yaml";
@@ -0,0 +1,7 @@
1
+ import type { Unit } from "./plugin.js";
2
+ import type { ActivePlugin, PluginEnv } from "./plugins.js";
3
+ /**
4
+ * Runs `discover` hooks in config order, applies config overlays and fills
5
+ * defaults. Returns units sorted by id.
6
+ */
7
+ export declare const discoverUnits: (active: readonly ActivePlugin[], env: Pick<PluginEnv, "config" | "fs" | "repo">) => Promise<Unit[]>;
@@ -0,0 +1,110 @@
1
+ import { TallyError } from "./errors.js";
2
+ import { defaultUnitId } from "./plugin.js";
3
+ import { runHook } from "./plugins.js";
4
+ import { compare } from "./util.js";
5
+ const UNIT_ID = /^(?!\.\.?$)[A-Za-z0-9._-]+$/;
6
+ /** Drops `.` and empty segments; rejects absolute roots and `..` segments. */
7
+ const normalizeRoot = (root) => {
8
+ const segments = root.split("/");
9
+ if (root.startsWith("/") || segments.includes("..")) {
10
+ throw new TallyError("E_ROOT_INVALID", `root "${root}" must be repo-relative without ".." segments`, { root });
11
+ }
12
+ return segments.filter((s) => s !== "" && s !== ".").join("/") || ".";
13
+ };
14
+ /** Escapes glob metacharacters so a root matches literally. */
15
+ const escapeGlob = (path) => path.replace(/[[\]{}()!*?+@\\]/g, "\\$&");
16
+ const isInside = (path, root) => path !== root && (root === "." || path.startsWith(`${root}/`));
17
+ /**
18
+ * Runs `discover` hooks in config order, applies config overlays and fills
19
+ * defaults. Returns units sorted by id.
20
+ */
21
+ export const discoverUnits = async (active, env) => {
22
+ const byRoot = new Map();
23
+ for (const { plugin, ctx } of active) {
24
+ const discover = plugin.discover?.bind(plugin);
25
+ if (!discover) {
26
+ continue;
27
+ }
28
+ // eslint-disable-next-line no-await-in-loop -- hooks run sequentially in config order
29
+ const drafts = await runHook(plugin.name, "discover", () => discover(ctx));
30
+ for (const { meta, ...draft } of drafts) {
31
+ const root = normalizeRoot(draft.root);
32
+ const prior = byRoot.get(root);
33
+ if (prior) {
34
+ throw new TallyError("E_DUPLICATE_ROOT", `root "${root}" is discovered by "${prior.plugin}" and "${plugin.name}"`, { root, plugins: [prior.plugin, plugin.name] });
35
+ }
36
+ byRoot.set(root, {
37
+ plugin: plugin.name,
38
+ draft: { ...draft, root },
39
+ meta: meta ? { [plugin.name]: Object.freeze({ ...meta }) } : {},
40
+ });
41
+ }
42
+ }
43
+ for (const [key, overlay] of Object.entries(env.config.units ?? {})) {
44
+ const root = normalizeRoot(key);
45
+ const entry = byRoot.get(root);
46
+ if (entry) {
47
+ entry.draft = { ...entry.draft, ...overlay };
48
+ }
49
+ else {
50
+ byRoot.set(root, {
51
+ plugin: "config",
52
+ draft: {
53
+ ...overlay,
54
+ id: overlay.id ?? defaultUnitId(root, env.repo.name),
55
+ root,
56
+ },
57
+ meta: {},
58
+ });
59
+ }
60
+ }
61
+ const byId = new Map();
62
+ for (const entry of byRoot.values()) {
63
+ const { id, root } = entry.draft;
64
+ if (!UNIT_ID.test(id)) {
65
+ throw new TallyError("E_UNIT_ID", `unit id "${id}" at "${root}" must match ${UNIT_ID.source}`, { id, root, plugin: entry.plugin });
66
+ }
67
+ const prior = byId.get(id);
68
+ if (prior) {
69
+ throw new TallyError("E_DUPLICATE_UNIT", `unit id "${id}" is used by "${prior.plugin}" at "${prior.draft.root}" and "${entry.plugin}" at "${root}"`, {
70
+ id,
71
+ owners: [
72
+ { plugin: prior.plugin, root: prior.draft.root },
73
+ { plugin: entry.plugin, root },
74
+ ],
75
+ });
76
+ }
77
+ byId.set(id, entry);
78
+ if (root !== "." && !env.fs.exists(root)) {
79
+ throw new TallyError("E_ROOT_MISSING", `root "${root}" of unit "${id}" does not exist`, { id, root });
80
+ }
81
+ }
82
+ const roots = [...byRoot.keys()].sort(compare);
83
+ const { defaults } = env.config;
84
+ return [...byId.values()]
85
+ .map(({ plugin, draft, meta }) => {
86
+ const { root } = draft;
87
+ const nested = roots
88
+ .filter((r) => isInside(r, root))
89
+ .map((r) => `${escapeGlob(r)}/**`);
90
+ return Object.freeze({
91
+ id: draft.id,
92
+ root,
93
+ plugin,
94
+ include: Object.freeze([
95
+ ...(draft.include ?? [
96
+ root === "." ? "**" : `${escapeGlob(root)}/**`,
97
+ ]),
98
+ ]),
99
+ exclude: Object.freeze([...nested, ...(draft.exclude ?? [])]),
100
+ version: draft.version ?? "tag",
101
+ scheme: draft.scheme ?? "semver",
102
+ tagFormat: draft.tagFormat ??
103
+ defaults?.tagFormat ??
104
+ (root === "." ? "v{version}" : "{id}-v{version}"),
105
+ propagate: draft.propagate ?? defaults?.propagate ?? "auto",
106
+ meta: Object.freeze(meta),
107
+ });
108
+ })
109
+ .sort((a, b) => compare(a.id, b.id));
110
+ };
@@ -0,0 +1,13 @@
1
+ /** Fatal error with a stable `E_*` code. */
2
+ export declare class TallyError extends Error {
3
+ readonly name = "TallyError";
4
+ readonly code: string;
5
+ readonly details: Readonly<Record<string, unknown>>;
6
+ constructor(code: string, message: string, details?: Record<string, unknown>);
7
+ }
8
+ /** Non-fatal finding with a stable `W_*` code. */
9
+ export interface Warning {
10
+ code: string;
11
+ message: string;
12
+ plugin?: string;
13
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,11 @@
1
+ /** Fatal error with a stable `E_*` code. */
2
+ export class TallyError extends Error {
3
+ name = "TallyError";
4
+ code;
5
+ details;
6
+ constructor(code, message, details = {}) {
7
+ super(message);
8
+ this.code = code;
9
+ this.details = details;
10
+ }
11
+ }
@@ -0,0 +1,19 @@
1
+ import type { Warning } from "./errors.js";
2
+ import type { Edge, EdgeDraft, Unit } from "./plugin.js";
3
+ /** Edge draft with its origin: a plugin name or `config`. */
4
+ export interface SourcedEdge extends EdgeDraft {
5
+ source: string;
6
+ }
7
+ export interface Graph {
8
+ /** Deduplicated edges sorted by `from`, then `to`. */
9
+ readonly edges: readonly Edge[];
10
+ /** Unit id to the sorted ids of units depending on it. */
11
+ readonly dependents: ReadonlyMap<string, readonly string[]>;
12
+ /** Unit id to the sorted ids of units it depends on. */
13
+ readonly dependencies: ReadonlyMap<string, readonly string[]>;
14
+ /** Dependencies before dependents; members of a cycle by id. */
15
+ readonly order: readonly string[];
16
+ readonly warnings: readonly Warning[];
17
+ }
18
+ /** Validates and deduplicates edges, then derives adjacency and order. */
19
+ export declare const buildGraph: (units: readonly Unit[], drafts: readonly SourcedEdge[]) => Graph;
package/dist/graph.js ADDED
@@ -0,0 +1,129 @@
1
+ import { TallyError } from "./errors.js";
2
+ import { compare, uniqueSorted } from "./util.js";
3
+ /** Tarjan's algorithm; components are emitted after everything they reach. */
4
+ const stronglyConnected = (ids, next) => {
5
+ const index = new Map();
6
+ const low = new Map();
7
+ const stack = [];
8
+ const onStack = new Set();
9
+ const components = [];
10
+ const visit = (v) => {
11
+ const own = index.size;
12
+ index.set(v, own);
13
+ low.set(v, own);
14
+ stack.push(v);
15
+ onStack.add(v);
16
+ for (const w of next.get(v) ?? []) {
17
+ if (!index.has(w)) {
18
+ visit(w);
19
+ low.set(v, Math.min(low.get(v) ?? own, low.get(w) ?? own));
20
+ }
21
+ else if (onStack.has(w)) {
22
+ low.set(v, Math.min(low.get(v) ?? own, index.get(w) ?? own));
23
+ }
24
+ }
25
+ if (low.get(v) === own) {
26
+ const component = [];
27
+ let w;
28
+ do {
29
+ w = stack.pop();
30
+ if (w !== undefined) {
31
+ onStack.delete(w);
32
+ component.push(w);
33
+ }
34
+ } while (w !== undefined && w !== v);
35
+ components.push(component.sort(compare));
36
+ }
37
+ };
38
+ for (const id of ids) {
39
+ if (!index.has(id)) {
40
+ visit(id);
41
+ }
42
+ }
43
+ return components;
44
+ };
45
+ /** Shortest cycle through the smallest member id, following dependents. */
46
+ const cyclePath = (component, dependents) => {
47
+ const members = new Set(component);
48
+ const start = component[0] ?? "";
49
+ const parent = new Map();
50
+ const queue = [start];
51
+ for (const v of queue) {
52
+ for (const w of dependents.get(v) ?? []) {
53
+ if (w === start) {
54
+ const path = [];
55
+ for (let n = v; n !== undefined; n = parent.get(n)) {
56
+ path.unshift(n);
57
+ }
58
+ return [...path, start];
59
+ }
60
+ if (members.has(w) && !parent.has(w)) {
61
+ parent.set(w, v);
62
+ queue.push(w);
63
+ }
64
+ }
65
+ }
66
+ return [start, start];
67
+ };
68
+ /** Validates and deduplicates edges, then derives adjacency and order. */
69
+ export const buildGraph = (units, drafts) => {
70
+ const ids = units.map((u) => u.id).sort(compare);
71
+ const known = new Set(ids);
72
+ const merged = new Map();
73
+ for (const { from, to, reason, pinned, source } of drafts) {
74
+ for (const id of [from, to]) {
75
+ if (!known.has(id)) {
76
+ throw new TallyError("E_EDGE_UNKNOWN_UNIT", `edge ${from} -> ${to} from "${source}" names unknown unit "${id}"`, { from, to, source, unit: id });
77
+ }
78
+ }
79
+ if (from === to) {
80
+ throw new TallyError("E_EDGE_SELF", `edge ${from} -> ${to} from "${source}" points to itself`, { from, to, source });
81
+ }
82
+ const key = `${from}\n${to}`;
83
+ const edge = merged.get(key);
84
+ if (edge) {
85
+ edge.pinned ||= pinned;
86
+ edge.reasons.push(reason);
87
+ edge.sources.push(source);
88
+ }
89
+ else {
90
+ merged.set(key, {
91
+ from,
92
+ to,
93
+ reasons: [reason],
94
+ pinned,
95
+ sources: [source],
96
+ });
97
+ }
98
+ }
99
+ const edges = [...merged.values()]
100
+ .map((e) => ({
101
+ ...e,
102
+ reasons: uniqueSorted(e.reasons),
103
+ sources: uniqueSorted(e.sources),
104
+ }))
105
+ .sort((a, b) => compare(a.from, b.from) || compare(a.to, b.to));
106
+ const dependents = new Map(ids.map((id) => [id, []]));
107
+ const dependencies = new Map(ids.map((id) => [id, []]));
108
+ for (const { from, to } of edges) {
109
+ dependents.get(from)?.push(to);
110
+ dependencies.get(to)?.push(from);
111
+ }
112
+ for (const list of dependencies.values()) {
113
+ list.sort(compare);
114
+ }
115
+ const components = stronglyConnected(ids, dependencies);
116
+ const warnings = components
117
+ .filter((c) => c.length > 1)
118
+ .map((c) => ({
119
+ code: "W_CYCLE",
120
+ message: `dependency cycle: ${cyclePath(c, dependents).join(" -> ")}`,
121
+ }));
122
+ return {
123
+ edges,
124
+ dependents,
125
+ dependencies,
126
+ order: components.flat(),
127
+ warnings,
128
+ };
129
+ };
@@ -0,0 +1,16 @@
1
+ export declare const version = "0.0.0";
2
+ export type { AffectedDoc, AffectedOptions, AffectedReason, AffectedUnit, BaseSource, } from "./affected.js";
3
+ export type { DepConfig, TallyConfig, UnitOverlay } from "./config.js";
4
+ export type { Warning } from "./errors.js";
5
+ export type { Graph, SourcedEdge } from "./graph.js";
6
+ export type { BumpLevel, Change, Ctx, Edge, EdgeDraft, GitReader, IntentSource, MaybePromise, Plugin, PreCtx, Propagate, RawCommit, ReadonlyFs, TagRef, Unit, UnitDraft, VersionFile, VersionMode, VersionScheme, WriteCtx, } from "./plugin.js";
7
+ export type { ActivePlugin, PluginEnv } from "./plugins.js";
8
+ export type { Workspace, WorkspaceInput } from "./workspace.js";
9
+ export { computeAffected } from "./affected.js";
10
+ export { CONFIG_FILE } from "./config.js";
11
+ export { discoverUnits } from "./discover.js";
12
+ export { TallyError } from "./errors.js";
13
+ export { buildGraph } from "./graph.js";
14
+ export { definePlugin, defaultUnitId, PLUGIN_API_VERSION } from "./plugin.js";
15
+ export { activatePlugins } from "./plugins.js";
16
+ export { loadWorkspace } from "./workspace.js";
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export const version = "0.0.0";
2
+ export { computeAffected } from "./affected.js";
3
+ export { CONFIG_FILE } from "./config.js";
4
+ export { discoverUnits } from "./discover.js";
5
+ export { TallyError } from "./errors.js";
6
+ export { buildGraph } from "./graph.js";
7
+ export { definePlugin, defaultUnitId, PLUGIN_API_VERSION } from "./plugin.js";
8
+ export { activatePlugins } from "./plugins.js";
9
+ export { loadWorkspace } from "./workspace.js";
@@ -0,0 +1,186 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ export type MaybePromise<T> = T | Promise<T>;
3
+ export declare const PLUGIN_API_VERSION = 1;
4
+ export interface Plugin<O = unknown> {
5
+ apiVersion: typeof PLUGIN_API_VERSION;
6
+ name: string;
7
+ /** Validates `plugins.<name>` minus core-owned keys (`globalInputs`). */
8
+ options?: StandardSchemaV1<unknown, O>;
9
+ discover?: (ctx: Ctx<O>) => MaybePromise<UnitDraft[]>;
10
+ edges?: (ctx: Ctx<O>, units: readonly Unit[]) => MaybePromise<EdgeDraft[]>;
11
+ /** Default global inputs of this plugin. */
12
+ inputs?: (ctx: Ctx<O>) => MaybePromise<{
13
+ global?: string[];
14
+ }>;
15
+ /** Called only for units owned by this plugin. */
16
+ versionFiles?: (ctx: Ctx<O>, unit: Unit) => MaybePromise<VersionFile[]>;
17
+ schemes?: Record<string, VersionScheme<any>>;
18
+ intent?: IntentSource<O>[];
19
+ }
20
+ export interface Ctx<O = unknown> {
21
+ readonly options: O;
22
+ readonly repo: {
23
+ readonly name: string;
24
+ };
25
+ readonly fs: ReadonlyFs;
26
+ /** Warnings land in the output document's `warnings`. */
27
+ readonly log: {
28
+ warn: (msg: string) => void;
29
+ debug: (msg: string) => void;
30
+ };
31
+ }
32
+ /** Read-only file access with repo-relative POSIX paths. */
33
+ export interface ReadonlyFs {
34
+ /** UTF-8 content, undefined if missing. */
35
+ read: (path: string) => string | undefined;
36
+ exists: (path: string) => boolean;
37
+ /** Sorted matches; skips `.git`, `node_modules` and the top-level `vendor`. */
38
+ glob: (patterns: string[], opts?: {
39
+ dirs?: boolean;
40
+ }) => string[];
41
+ }
42
+ /** Read-only git access. */
43
+ export interface GitReader {
44
+ /** All tags, peeled to commits. */
45
+ tags: () => MaybePromise<readonly TagRef[]>;
46
+ /** Tags reachable from `ref` (`git tag --merged`). */
47
+ tagsMerged: (ref: string) => MaybePromise<readonly TagRef[]>;
48
+ /** True if `a` is reachable from `b` (inclusive). */
49
+ isAncestor: (a: string, b: string) => MaybePromise<boolean>;
50
+ /** True if `a` is on the first-parent chain of `b` (inclusive). */
51
+ isFirstParentAncestor: (a: string, b: string) => MaybePromise<boolean>;
52
+ /** Commits in `from..to` without merges; files listed without rename detection. */
53
+ log: (from: string | null, to: string) => MaybePromise<readonly RawCommit[]>;
54
+ /** Commits on the first-parent chain of `to` not reachable from `from`. */
55
+ countFirstParent: (from: string | null, to: string) => MaybePromise<number>;
56
+ /** Tree-to-tree changed paths without rename detection, sorted. */
57
+ diffFiles: (from: string, to: string) => MaybePromise<readonly string[]>;
58
+ }
59
+ export interface TagRef {
60
+ name: string;
61
+ sha: string;
62
+ }
63
+ export interface RawCommit {
64
+ sha: string;
65
+ parents: string[];
66
+ message: string;
67
+ files: string[];
68
+ /** Committer date, ISO 8601. */
69
+ date: string;
70
+ }
71
+ export type Propagate = "auto" | "patch" | "rebuild";
72
+ /** `file`: tags and version files; `tag`: tags only; `none`: build-only. */
73
+ export type VersionMode = "file" | "tag" | "none";
74
+ export interface UnitDraft {
75
+ id: string;
76
+ root: string;
77
+ include?: string[];
78
+ exclude?: string[];
79
+ version?: VersionMode;
80
+ scheme?: string;
81
+ tagFormat?: string;
82
+ propagate?: Propagate;
83
+ /** Stored at `unit.meta[plugin.name]`. */
84
+ meta?: Record<string, unknown>;
85
+ }
86
+ export interface Unit {
87
+ readonly id: string;
88
+ readonly root: string;
89
+ /** Owning plugin, `config` for config-only units. */
90
+ readonly plugin: string;
91
+ readonly include: readonly string[];
92
+ readonly exclude: readonly string[];
93
+ readonly version: VersionMode;
94
+ readonly scheme: string;
95
+ readonly tagFormat: string;
96
+ readonly propagate: Propagate;
97
+ readonly meta: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
98
+ }
99
+ /** `to` depends on `from`. */
100
+ export interface EdgeDraft {
101
+ from: string;
102
+ to: string;
103
+ reason: string;
104
+ /** True when `to`'s published manifest changes with `from`'s version. */
105
+ pinned: boolean;
106
+ }
107
+ export interface Edge {
108
+ from: string;
109
+ to: string;
110
+ reasons: string[];
111
+ pinned: boolean;
112
+ sources: string[];
113
+ }
114
+ export interface VersionFile {
115
+ path: string;
116
+ read: (content: string) => string | undefined;
117
+ write: (content: string, next: string, ctx: WriteCtx) => string;
118
+ }
119
+ export interface WriteCtx {
120
+ unit: Unit;
121
+ /** Unit id to version in the current plan. */
122
+ versions: ReadonlyMap<string, string>;
123
+ }
124
+ export type BumpLevel = "major" | "minor" | "patch";
125
+ export interface VersionScheme<V> {
126
+ /** Undefined when `raw` is not a version of this scheme. */
127
+ parse: (raw: string) => V | undefined;
128
+ format: (v: V) => string;
129
+ compare: (a: V, b: V) => number;
130
+ core: (v: V) => {
131
+ major: number;
132
+ minor: number;
133
+ patch: number;
134
+ };
135
+ isPrerelease: (v: V) => boolean;
136
+ initial: () => V;
137
+ bump: (v: V, level: BumpLevel) => V;
138
+ release: (next: V, ctx: PreCtx) => V;
139
+ prerelease: (next: V, ctx: PreCtx & {
140
+ channel: string;
141
+ }) => V;
142
+ }
143
+ /** Git-derived data passed to a scheme. */
144
+ export interface PreCtx {
145
+ unit: Unit;
146
+ /** Full sha of the planned commit. */
147
+ head: string;
148
+ baseTag: string | null;
149
+ /** All tags of this unit, reachable or not, scheme-sorted. */
150
+ tags: readonly string[];
151
+ firstParent: {
152
+ sinceLineBase: number;
153
+ total: number;
154
+ };
155
+ }
156
+ export interface IntentSource<O = unknown> {
157
+ name: string;
158
+ collect: (ctx: Ctx<O>, input: {
159
+ from: string | null;
160
+ to: string;
161
+ commits: readonly RawCommit[];
162
+ }) => MaybePromise<Change[]>;
163
+ }
164
+ export interface Change {
165
+ /** Commit-derived changes are attributed via that commit's files. */
166
+ sha?: string;
167
+ /** Explicit attribution. */
168
+ units?: string[];
169
+ type: string;
170
+ breaking: boolean;
171
+ scope?: string;
172
+ subject: string;
173
+ body?: string;
174
+ releaseAs?: string;
175
+ pr?: number;
176
+ date?: string;
177
+ }
178
+ /** Throws `E_PLUGIN_API` or `E_PLUGIN_NAME` for an unsupported plugin. */
179
+ export declare const assertPlugin: (plugin: {
180
+ apiVersion: unknown;
181
+ name: unknown;
182
+ }) => void;
183
+ /** Validates name and API version, returns the plugin unchanged. */
184
+ export declare const definePlugin: <O>(plugin: Plugin<O>) => Plugin<O>;
185
+ /** `.` maps to the repo name, any other root to its last segment. */
186
+ export declare const defaultUnitId: (root: string, repoName: string) => string;
package/dist/plugin.js ADDED
@@ -0,0 +1,20 @@
1
+ import { TallyError } from "./errors.js";
2
+ const PLUGIN_NAME = /^[a-z][a-z0-9-]*$/;
3
+ export const PLUGIN_API_VERSION = 1;
4
+ /** Throws `E_PLUGIN_API` or `E_PLUGIN_NAME` for an unsupported plugin. */
5
+ export const assertPlugin = (plugin) => {
6
+ const name = String(plugin.name);
7
+ if (plugin.apiVersion !== PLUGIN_API_VERSION) {
8
+ throw new TallyError("E_PLUGIN_API", `plugin "${name}" uses API version ${String(plugin.apiVersion)}, supported is ${String(PLUGIN_API_VERSION)}`, { plugin: name, apiVersion: plugin.apiVersion });
9
+ }
10
+ if (typeof plugin.name !== "string" || !PLUGIN_NAME.test(plugin.name)) {
11
+ throw new TallyError("E_PLUGIN_NAME", `plugin name "${name}" must match ${PLUGIN_NAME.source}`, { plugin: name });
12
+ }
13
+ };
14
+ /** Validates name and API version, returns the plugin unchanged. */
15
+ export const definePlugin = (plugin) => {
16
+ assertPlugin(plugin);
17
+ return plugin;
18
+ };
19
+ /** `.` maps to the repo name, any other root to its last segment. */
20
+ export const defaultUnitId = (root, repoName) => root === "." ? repoName : (root.split("/").at(-1) ?? root);
@@ -0,0 +1,24 @@
1
+ import type { TallyConfig } from "./config.js";
2
+ import type { Warning } from "./errors.js";
3
+ import type { Ctx, MaybePromise, Plugin, ReadonlyFs } from "./plugin.js";
4
+ /** An enabled plugin with its validated options. */
5
+ export interface ActivePlugin {
6
+ readonly plugin: Plugin;
7
+ readonly ctx: Ctx;
8
+ /** False when `plugins.<name>.globalInputs: false`. */
9
+ readonly globalInputs: boolean;
10
+ }
11
+ export interface PluginEnv {
12
+ config: TallyConfig;
13
+ /** Available plugins; `config.plugins` selects and orders them. */
14
+ plugins: readonly Plugin[];
15
+ fs: ReadonlyFs;
16
+ repo: {
17
+ name: string;
18
+ };
19
+ debug?: (message: string) => void;
20
+ }
21
+ /** Resolves enabled plugins in config order and validates their options. */
22
+ export declare const activatePlugins: (env: PluginEnv, warnings: Warning[]) => Promise<ActivePlugin[]>;
23
+ /** Awaits a plugin hook, wrapping thrown errors as `E_PLUGIN_HOOK`. */
24
+ export declare const runHook: <T>(plugin: string, hook: string, call: () => MaybePromise<T>) => Promise<T>;
@@ -0,0 +1,48 @@
1
+ import { TallyError } from "./errors.js";
2
+ import { assertPlugin } from "./plugin.js";
3
+ /** Resolves enabled plugins in config order and validates their options. */
4
+ export const activatePlugins = async (env, warnings) => {
5
+ const active = [];
6
+ for (const [name, raw] of Object.entries(env.config.plugins)) {
7
+ const plugin = env.plugins.find((p) => p.name === name);
8
+ if (!plugin) {
9
+ throw new TallyError("E_PLUGIN_UNKNOWN", `unknown plugin "${name}"`, {
10
+ plugin: name,
11
+ });
12
+ }
13
+ assertPlugin(plugin);
14
+ const { globalInputs, ...rest } = raw ?? {};
15
+ let options = rest;
16
+ if (plugin.options) {
17
+ // eslint-disable-next-line no-await-in-loop -- plugins load sequentially in config order
18
+ const result = await plugin.options["~standard"].validate(rest);
19
+ if (result.issues) {
20
+ throw new TallyError("E_PLUGIN_OPTIONS", `invalid options for plugin "${name}": ${result.issues.map((i) => i.message).join("; ")}`, { plugin: name, issues: result.issues });
21
+ }
22
+ options = result.value;
23
+ }
24
+ const ctx = Object.freeze({
25
+ options,
26
+ repo: Object.freeze({ name: env.repo.name }),
27
+ fs: env.fs,
28
+ log: Object.freeze({
29
+ warn: (message) => {
30
+ warnings.push({ code: "W_PLUGIN", message, plugin: name });
31
+ },
32
+ debug: (message) => env.debug?.(`${name}: ${message}`),
33
+ }),
34
+ });
35
+ active.push({ plugin, ctx, globalInputs: globalInputs !== false });
36
+ }
37
+ return active;
38
+ };
39
+ /** Awaits a plugin hook, wrapping thrown errors as `E_PLUGIN_HOOK`. */
40
+ export const runHook = async (plugin, hook, call) => {
41
+ try {
42
+ return await call();
43
+ }
44
+ catch (error) {
45
+ const reason = error instanceof Error ? error.message : String(error);
46
+ throw new TallyError("E_PLUGIN_HOOK", `plugin "${plugin}" failed in ${hook}: ${reason}`, { plugin, hook, error });
47
+ }
48
+ };
@@ -0,0 +1,53 @@
1
+ import type { Ctx, GitReader, MaybePromise, ReadonlyFs } from "./plugin.js";
2
+ /** In-memory `ReadonlyFs` over a path-to-content map. */
3
+ export declare const memoryFs: (files: Record<string, string>) => ReadonlyFs;
4
+ export interface MemoryCtxOptions<O> {
5
+ options?: O;
6
+ /** Defaults to `repo`. */
7
+ repo?: string;
8
+ files?: Record<string, string>;
9
+ }
10
+ /** Plugin `Ctx` over `memoryFs`; collects `log.warn` messages in `warnings`. */
11
+ export declare const memoryCtx: <O = Record<string, never>>(opts?: MemoryCtxOptions<O>) => Ctx<O> & {
12
+ readonly warnings: string[];
13
+ };
14
+ export interface DagCommit {
15
+ id: string;
16
+ /** Defaults to the previous commit in the list, none for the first. */
17
+ parents?: string[];
18
+ message?: string;
19
+ /** Committer date, ISO 8601. */
20
+ date?: string;
21
+ /**
22
+ * Changes applied on top of the parents' tree; `null` deletes. A merge
23
+ * starts from a three-way merge of its parents (later parents win conflicts).
24
+ */
25
+ files?: Record<string, string | null>;
26
+ }
27
+ export interface DagTag {
28
+ name: string;
29
+ commit: string;
30
+ annotated?: boolean;
31
+ }
32
+ /** Commit graph in parent-before-child order. Commit ids double as shas. */
33
+ export interface Dag {
34
+ commits: DagCommit[];
35
+ tags?: DagTag[];
36
+ }
37
+ export interface MemoryGit extends GitReader {
38
+ /** File contents at a commit id, tag name or `HEAD` (the last commit). */
39
+ tree: (ref: string) => Record<string, string>;
40
+ }
41
+ /** In-memory `GitReader` over a commit graph. */
42
+ export declare const memoryGit: (dag: Dag) => MemoryGit;
43
+ /** Builds a reader for `dag`; `sha` maps a commit id to the reader's sha. */
44
+ export type ReaderFactory = (dag: Dag) => MaybePromise<{
45
+ git: GitReader;
46
+ sha: (id: string) => string;
47
+ }>;
48
+ /**
49
+ * Shared `GitReader` spec. Fixture: a - b - d - m - e - f on the first-parent
50
+ * 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.
52
+ */
53
+ export declare const gitReaderConformance: (makeReader: ReaderFactory) => void;
@@ -0,0 +1,267 @@
1
+ import picomatch from "picomatch/posix.js";
2
+ import { beforeAll, describe, expect, it } from "vitest";
3
+ import { compare, uniqueSorted } from "./util.js";
4
+ const SKIPPED = new Set([".git", "node_modules"]);
5
+ const changed = (a, b) => uniqueSorted([...a.keys(), ...b.keys()]).filter((p) => a.get(p) !== b.get(p));
6
+ /** Path git would C-quote without `-z`. */
7
+ const ODD_PATH = 'odd/a "q"\tü.txt';
8
+ const CONFORMANCE_DAG = {
9
+ commits: [
10
+ {
11
+ id: "a",
12
+ message: "chore: init",
13
+ date: "2024-01-01T10:00:00Z",
14
+ files: { "README.md": "a\n", "pkg/x.ts": "x\n", "lib/keep.ts": "k\n" },
15
+ },
16
+ { id: "b", message: "feat: b", files: { "README.md": "b\n" } },
17
+ { id: "c", message: "feat: side", files: { "side.txt": "side\n" } },
18
+ {
19
+ id: "d",
20
+ parents: ["b"],
21
+ message: "refactor: rename",
22
+ files: { "pkg/x.ts": null, "pkg/y.ts": "x\n" },
23
+ },
24
+ { id: "m", parents: ["d", "c"], message: "Merge side" },
25
+ {
26
+ id: "e",
27
+ message: "fix: e\n\nbody line",
28
+ date: "2024-01-02T12:30:00Z",
29
+ files: { "README.md": "e\n" },
30
+ },
31
+ { id: "f", message: "chore: odd", files: { [ODD_PATH]: "f\n" } },
32
+ ],
33
+ tags: [
34
+ { name: "v1", commit: "a" },
35
+ { name: "v2", commit: "b", annotated: true },
36
+ { name: "side", commit: "c" },
37
+ ],
38
+ };
39
+ /** In-memory `ReadonlyFs` over a path-to-content map. */
40
+ export const memoryFs = (files) => {
41
+ const paths = Object.keys(files).sort(compare);
42
+ const dirs = uniqueSorted(paths.flatMap((p) => {
43
+ const parts = p.split("/");
44
+ return parts.slice(1).map((_, i) => parts.slice(0, i + 1).join("/"));
45
+ }));
46
+ const visible = (p) => {
47
+ const segments = p.split("/");
48
+ return segments[0] !== "vendor" && !segments.some((s) => SKIPPED.has(s));
49
+ };
50
+ return {
51
+ read: (path) => (Object.hasOwn(files, path) ? files[path] : undefined),
52
+ exists: (path) => path === "." || Object.hasOwn(files, path) || dirs.includes(path),
53
+ glob: (patterns, opts) => {
54
+ const match = picomatch(patterns, { dot: true });
55
+ return (opts?.dirs ? dirs : paths).filter((p) => visible(p) && match(p));
56
+ },
57
+ };
58
+ };
59
+ /** Plugin `Ctx` over `memoryFs`; collects `log.warn` messages in `warnings`. */
60
+ export const memoryCtx = (opts = {}) => {
61
+ const warnings = [];
62
+ const options = opts.options ?? {};
63
+ return {
64
+ options: options,
65
+ repo: { name: opts.repo ?? "repo" },
66
+ fs: memoryFs(opts.files ?? {}),
67
+ log: { warn: (msg) => warnings.push(msg), debug: () => undefined },
68
+ warnings,
69
+ };
70
+ };
71
+ /** In-memory `GitReader` over a commit graph. */
72
+ export const memoryGit = (dag) => {
73
+ const nodes = new Map();
74
+ const node = (ref) => {
75
+ const id = ref === "HEAD" ? dag.commits.at(-1)?.id : ref;
76
+ const found = nodes.get(id ?? "") ??
77
+ nodes.get(dag.tags?.find((t) => t.name === ref)?.commit ?? "");
78
+ if (!found) {
79
+ throw new Error(`unknown ref "${ref}"`);
80
+ }
81
+ return found;
82
+ };
83
+ const mergeBase = (a, b) => {
84
+ const common = [...a.ancestors].filter((id) => b.ancestors.has(id));
85
+ const best = common.find((id) => !common.some((o) => o !== id && node(o).ancestors.has(id)));
86
+ return best === undefined ? new Map() : node(best).tree;
87
+ };
88
+ dag.commits.forEach((c, i) => {
89
+ const prior = dag.commits[i - 1];
90
+ const parents = (c.parents ?? (prior ? [prior.id] : [])).map(node);
91
+ const [first, ...others] = parents;
92
+ const tree = new Map(first?.tree);
93
+ for (const other of others) {
94
+ const base = first ? mergeBase(first, other) : new Map();
95
+ for (const path of changed(base, other.tree)) {
96
+ const content = other.tree.get(path);
97
+ if (content === undefined) {
98
+ tree.delete(path);
99
+ }
100
+ else {
101
+ tree.set(path, content);
102
+ }
103
+ }
104
+ }
105
+ for (const [path, content] of Object.entries(c.files ?? {})) {
106
+ if (content === null) {
107
+ tree.delete(path);
108
+ }
109
+ else {
110
+ tree.set(path, content);
111
+ }
112
+ }
113
+ nodes.set(c.id, {
114
+ id: c.id,
115
+ parents: parents.map((p) => p.id),
116
+ message: c.message ?? c.id,
117
+ date: c.date ?? new Date(Date.UTC(2024, 0, 1, 0, i)).toISOString(),
118
+ tree,
119
+ ancestors: new Set([c.id, ...parents.flatMap((p) => [...p.ancestors])]),
120
+ });
121
+ });
122
+ const firstParentChain = (to) => {
123
+ const chain = [];
124
+ for (let n = node(to); n;) {
125
+ chain.push(n);
126
+ const parent = n.parents[0];
127
+ n = parent === undefined ? undefined : node(parent);
128
+ }
129
+ return chain;
130
+ };
131
+ const tagRefs = () => (dag.tags ?? [])
132
+ .map((t) => ({ name: t.name, sha: node(t.commit).id }))
133
+ .sort((a, b) => compare(a.name, b.name));
134
+ const excluded = (from) => from === null ? new Set() : node(from).ancestors;
135
+ return {
136
+ tags: tagRefs,
137
+ tagsMerged: (ref) => {
138
+ const reachable = node(ref).ancestors;
139
+ return tagRefs().filter((t) => reachable.has(t.sha));
140
+ },
141
+ isAncestor: (a, b) => node(b).ancestors.has(node(a).id),
142
+ isFirstParentAncestor: (a, b) => {
143
+ const id = node(a).id;
144
+ return firstParentChain(b).some((n) => n.id === id);
145
+ },
146
+ log: (from, to) => {
147
+ const skip = excluded(from);
148
+ const reachable = node(to).ancestors;
149
+ return dag.commits
150
+ .map((c) => node(c.id))
151
+ .filter((n) => reachable.has(n.id) && !skip.has(n.id))
152
+ .filter((n) => n.parents.length <= 1)
153
+ .reverse()
154
+ .map((n) => ({
155
+ sha: n.id,
156
+ parents: [...n.parents],
157
+ message: n.message,
158
+ files: changed(n.parents[0] === undefined ? new Map() : node(n.parents[0]).tree, n.tree),
159
+ date: n.date,
160
+ }));
161
+ },
162
+ countFirstParent: (from, to) => {
163
+ const skip = excluded(from);
164
+ return firstParentChain(to).filter((n) => !skip.has(n.id)).length;
165
+ },
166
+ diffFiles: (from, to) => changed(node(from).tree, node(to).tree),
167
+ tree: (ref) => Object.fromEntries(node(ref).tree),
168
+ };
169
+ };
170
+ /**
171
+ * Shared `GitReader` spec. Fixture: a - b - d - m - e - f on the first-parent
172
+ * 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.
174
+ */
175
+ export const gitReaderConformance = (makeReader) => {
176
+ describe("GitReader conformance", () => {
177
+ let git;
178
+ let sha;
179
+ const shas = (ids) => ids.map((id) => sha(id)).sort(compare);
180
+ const logShas = async (from, to) => (await git.log(from === null ? null : sha(from), sha(to)))
181
+ .map((c) => c.sha)
182
+ .sort(compare);
183
+ beforeAll(async () => {
184
+ ({ git, sha } = await makeReader(CONFORMANCE_DAG));
185
+ });
186
+ it("lists tags peeled to commits", async () => {
187
+ const tags = [...(await git.tags())].sort((x, y) => compare(x.name, y.name));
188
+ expect(tags).toEqual([
189
+ { name: "side", sha: sha("c") },
190
+ { name: "v1", sha: sha("a") },
191
+ { name: "v2", sha: sha("b") },
192
+ ]);
193
+ });
194
+ it("lists tags merged into a ref", async () => {
195
+ const names = async (id) => (await git.tagsMerged(sha(id))).map((t) => t.name).sort(compare);
196
+ expect(await names("d")).toEqual(["v1", "v2"]);
197
+ expect(await names("e")).toEqual(["side", "v1", "v2"]);
198
+ });
199
+ it("checks ancestry", async () => {
200
+ const check = (a, b) => git.isAncestor(sha(a), sha(b));
201
+ expect(await check("a", "e")).toBe(true);
202
+ expect(await check("c", "e")).toBe(true);
203
+ expect(await check("d", "d")).toBe(true);
204
+ expect(await check("e", "a")).toBe(false);
205
+ expect(await check("c", "d")).toBe(false);
206
+ });
207
+ it("checks first-parent ancestry", async () => {
208
+ const check = (a, b) => git.isFirstParentAncestor(sha(a), sha(b));
209
+ expect(await check("a", "e")).toBe(true);
210
+ expect(await check("d", "e")).toBe(true);
211
+ expect(await check("e", "e")).toBe(true);
212
+ expect(await check("c", "e")).toBe(false);
213
+ });
214
+ it("logs commits without merges", async () => {
215
+ expect(await logShas(null, "e")).toEqual(shas(["a", "b", "c", "d", "e"]));
216
+ expect(await logShas("b", "e")).toEqual(shas(["c", "d", "e"]));
217
+ expect(await logShas("d", "e")).toEqual(shas(["c", "e"]));
218
+ expect(await logShas("e", "e")).toEqual([]);
219
+ });
220
+ it("logs commit details with renames as delete plus add", async () => {
221
+ const commits = await git.log(null, sha("e"));
222
+ const byId = (id) => commits.find((c) => c.sha === sha(id));
223
+ expect(byId("a")).toMatchObject({
224
+ parents: [],
225
+ message: "chore: init",
226
+ files: ["README.md", "lib/keep.ts", "pkg/x.ts"],
227
+ });
228
+ expect(byId("d")).toMatchObject({
229
+ parents: [sha("b")],
230
+ message: "refactor: rename",
231
+ files: ["pkg/x.ts", "pkg/y.ts"],
232
+ });
233
+ expect(byId("e")).toMatchObject({
234
+ parents: [sha("m")],
235
+ message: "fix: e\n\nbody line",
236
+ files: ["README.md"],
237
+ });
238
+ expect(Date.parse(byId("a")?.date ?? "")).toBe(Date.parse("2024-01-01T10:00:00Z"));
239
+ expect(Date.parse(byId("e")?.date ?? "")).toBe(Date.parse("2024-01-02T12:30:00Z"));
240
+ });
241
+ it("counts first-parent commits", async () => {
242
+ const count = (from, to) => git.countFirstParent(from === null ? null : sha(from), sha(to));
243
+ expect(await count(null, "e")).toBe(5);
244
+ expect(await count("b", "e")).toBe(3);
245
+ expect(await count("c", "e")).toBe(3);
246
+ expect(await count("e", "e")).toBe(0);
247
+ });
248
+ it("diffs trees", async () => {
249
+ const diff = async (a, b) => await git.diffFiles(sha(a), sha(b));
250
+ expect(await diff("b", "e")).toEqual([
251
+ "README.md",
252
+ "pkg/x.ts",
253
+ "pkg/y.ts",
254
+ "side.txt",
255
+ ]);
256
+ expect(await diff("e", "b")).toEqual(await diff("b", "e"));
257
+ expect(await diff("d", "m")).toEqual(["side.txt"]);
258
+ expect(await diff("e", "e")).toEqual([]);
259
+ });
260
+ it("lists paths with quotes, tabs and non-ASCII characters verbatim", async () => {
261
+ expect(await git.diffFiles(sha("e"), sha("f"))).toEqual([ODD_PATH]);
262
+ expect((await git.log(sha("e"), sha("f"))).map((c) => c.files)).toEqual([
263
+ [ODD_PATH],
264
+ ]);
265
+ });
266
+ });
267
+ };
package/dist/util.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** Code-unit string order, independent of locale. */
2
+ export declare const compare: (a: string, b: string) => number;
3
+ /** Sorted distinct values. */
4
+ export declare const uniqueSorted: (values: Iterable<string>) => string[];
package/dist/util.js ADDED
@@ -0,0 +1,4 @@
1
+ /** Code-unit string order, independent of locale. */
2
+ export const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
3
+ /** Sorted distinct values. */
4
+ export const uniqueSorted = (values) => [...new Set(values)].sort(compare);
@@ -0,0 +1,21 @@
1
+ import type { Warning } from "./errors.js";
2
+ import type { Graph } from "./graph.js";
3
+ import type { Unit } from "./plugin.js";
4
+ import type { PluginEnv } from "./plugins.js";
5
+ export interface WorkspaceInput extends PluginEnv {
6
+ /** Repo-relative config file path, a global input; defaults to `CONFIG_FILE`. */
7
+ configFile?: string;
8
+ }
9
+ /** Units, graph and path rules of a repo at one commit. */
10
+ export interface Workspace {
11
+ readonly repo: string;
12
+ /** Sorted by id. */
13
+ readonly units: readonly Unit[];
14
+ readonly graph: Graph;
15
+ /** Sorted globs. */
16
+ readonly globalInputs: readonly string[];
17
+ readonly ignore: readonly string[];
18
+ readonly warnings: readonly Warning[];
19
+ }
20
+ /** Runs plugin hooks sequentially in config order and builds the graph. */
21
+ export declare const loadWorkspace: (input: WorkspaceInput) => Promise<Workspace>;
@@ -0,0 +1,57 @@
1
+ import { CONFIG_FILE } from "./config.js";
2
+ import { discoverUnits } from "./discover.js";
3
+ import { buildGraph } from "./graph.js";
4
+ import { activatePlugins, runHook } from "./plugins.js";
5
+ import { uniqueSorted } from "./util.js";
6
+ const collectEdges = async (active, config, units) => {
7
+ const edges = [];
8
+ const frozen = Object.freeze([...units]);
9
+ for (const { plugin, ctx } of active) {
10
+ const hook = plugin.edges?.bind(plugin);
11
+ if (!hook) {
12
+ continue;
13
+ }
14
+ // eslint-disable-next-line no-await-in-loop -- hooks run sequentially in config order
15
+ const drafts = await runHook(plugin.name, "edges", () => hook(ctx, frozen));
16
+ edges.push(...drafts.map((d) => ({ ...d, source: plugin.name })));
17
+ }
18
+ for (const { from, to, pinned, reason } of config.deps ?? []) {
19
+ edges.push({
20
+ from,
21
+ to,
22
+ pinned: pinned ?? false,
23
+ reason: reason ?? "config",
24
+ source: "config",
25
+ });
26
+ }
27
+ return edges;
28
+ };
29
+ const collectGlobalInputs = async (active, config, configFile) => {
30
+ const globs = [configFile, ...(config.globalInputs ?? [])];
31
+ for (const { plugin, ctx, globalInputs } of active) {
32
+ const hook = plugin.inputs?.bind(plugin);
33
+ if (!hook || !globalInputs) {
34
+ continue;
35
+ }
36
+ // eslint-disable-next-line no-await-in-loop -- hooks run sequentially in config order
37
+ const inputs = await runHook(plugin.name, "inputs", () => hook(ctx));
38
+ globs.push(...(inputs.global ?? []));
39
+ }
40
+ return uniqueSorted(globs);
41
+ };
42
+ /** Runs plugin hooks sequentially in config order and builds the graph. */
43
+ export const loadWorkspace = async (input) => {
44
+ const warnings = [];
45
+ const active = await activatePlugins(input, warnings);
46
+ const units = await discoverUnits(active, input);
47
+ const graph = buildGraph(units, await collectEdges(active, input.config, units));
48
+ const globalInputs = await collectGlobalInputs(active, input.config, input.configFile ?? CONFIG_FILE);
49
+ return {
50
+ repo: input.repo.name,
51
+ units,
52
+ graph,
53
+ globalInputs,
54
+ ignore: input.config.ignore ?? [],
55
+ warnings: [...warnings, ...graph.warnings],
56
+ };
57
+ };
package/package.json CHANGED
@@ -1,6 +1,94 @@
1
1
  {
2
2
  "name": "@tallyrelease/core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.0",
4
+ "description": "Pure planning core of tallyrelease",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/abinnovision/tally-release.git",
8
+ "directory": "packages/core"
9
+ },
10
+ "license": "Apache-2.0",
11
+ "author": {
12
+ "name": "abi group GmbH",
13
+ "email": "info@abigroup.io",
14
+ "url": "https://abigroup.io"
15
+ },
16
+ "type": "module",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./testing": {
24
+ "types": "./dist/testing.d.ts",
25
+ "import": "./dist/testing.js",
26
+ "default": "./dist/testing.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "scripts": {
33
+ "build": "tsc --build tsconfig.build.json",
34
+ "check:purity": "node scripts/check-purity.mjs src/index.ts",
35
+ "format:check": "prettier --check '{{src,test}/**/*,*}.{{t,j}s{,x},json{,5},md,y{,a}ml,css,html,gql,graphql}'",
36
+ "format:fix": "prettier --write '{{src,test}/**/*,*}.{{t,j}s{,x},json{,5},md,y{,a}ml,css,html,gql,graphql}'",
37
+ "lint:check": "eslint '{{src,test}/**/*,*}.{t,j}s{,x}'",
38
+ "lint:fix": "eslint '{{src,test}/**/*,*}.{t,j}s{,x}' --fix",
39
+ "test-unit": "vitest --passWithNoTests --run --coverage --config vitest.config.ts --project='*#unit'",
40
+ "test-unit:watch": "vitest --passWithNoTests --config vitest.config.ts --project='*#unit'",
41
+ "typecheck": "tsc -p tsconfig.json"
42
+ },
43
+ "lint-staged": {
44
+ "{{src,test}/**/*,*}.{{t,j}s{,x},json{,5},md,y{,a}ml,css,html,gql,graphql}": "prettier --write",
45
+ "{{src,test}/**/*,*}.{t,j}s{,x}": "eslint --fix"
46
+ },
47
+ "prettier": "@abinnovision/prettier-config",
48
+ "dependencies": {
49
+ "@standard-schema/spec": "^1.1.0",
50
+ "picomatch": "^4.0.7"
51
+ },
52
+ "devDependencies": {
53
+ "@abinnovision/eslint-config-base": "^3.4.2",
54
+ "@abinnovision/prettier-config": "^2.2.0",
55
+ "@internal/tsconfig": "0.0.0",
56
+ "@types/node": "^24.0.0",
57
+ "@types/picomatch": "^4.0.3",
58
+ "@vitest/coverage-v8": "^5.0.3",
59
+ "esbuild": "^0.25.0",
60
+ "eslint": "^10.12.0",
61
+ "prettier": "^3.9.9",
62
+ "typescript": "^6.0.3",
63
+ "vite": "^8.3.4",
64
+ "vitest": "^5.0.3",
65
+ "yaml": "^2.9.1"
66
+ },
67
+ "peerDependencies": {
68
+ "vitest": "^5.0.3"
69
+ },
70
+ "peerDependenciesMeta": {
71
+ "vitest": {
72
+ "optional": true
73
+ }
74
+ },
75
+ "engines": {
76
+ "node": ">=24"
77
+ },
78
+ "publishConfig": {
79
+ "access": "public",
80
+ "exports": {
81
+ ".": {
82
+ "types": "./dist/index.d.ts",
83
+ "import": "./dist/index.js",
84
+ "default": "./dist/index.js"
85
+ },
86
+ "./testing": {
87
+ "types": "./dist/testing.d.ts",
88
+ "import": "./dist/testing.js",
89
+ "default": "./dist/testing.js"
90
+ }
91
+ },
92
+ "npm": true
93
+ }
6
94
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.