@carllee1983/tagsmith 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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +333 -0
  3. package/dist/cli/check.d.ts +5 -0
  4. package/dist/cli/check.js +83 -0
  5. package/dist/cli/create.d.ts +10 -0
  6. package/dist/cli/create.js +75 -0
  7. package/dist/cli/guidance.d.ts +14 -0
  8. package/dist/cli/guidance.js +43 -0
  9. package/dist/cli/guide.d.ts +17 -0
  10. package/dist/cli/guide.js +61 -0
  11. package/dist/cli/implicit.d.ts +5 -0
  12. package/dist/cli/implicit.js +20 -0
  13. package/dist/cli/index.d.ts +2 -0
  14. package/dist/cli/index.js +96 -0
  15. package/dist/cli/init.d.ts +11 -0
  16. package/dist/cli/init.js +127 -0
  17. package/dist/cli/list.d.ts +6 -0
  18. package/dist/cli/list.js +124 -0
  19. package/dist/cli/next.d.ts +7 -0
  20. package/dist/cli/next.js +57 -0
  21. package/dist/cli/resolve-config.d.ts +6 -0
  22. package/dist/cli/resolve-config.js +16 -0
  23. package/dist/cli/ui.d.ts +8 -0
  24. package/dist/cli/ui.js +16 -0
  25. package/dist/core/analyze.d.ts +21 -0
  26. package/dist/core/analyze.js +55 -0
  27. package/dist/core/check.d.ts +15 -0
  28. package/dist/core/check.js +27 -0
  29. package/dist/core/config.d.ts +21 -0
  30. package/dist/core/config.js +152 -0
  31. package/dist/core/defaults.d.ts +6 -0
  32. package/dist/core/defaults.js +16 -0
  33. package/dist/core/infer.d.ts +6 -0
  34. package/dist/core/infer.js +35 -0
  35. package/dist/core/lines.d.ts +13 -0
  36. package/dist/core/lines.js +27 -0
  37. package/dist/core/models/build.d.ts +13 -0
  38. package/dist/core/models/build.js +33 -0
  39. package/dist/core/models/calver.d.ts +19 -0
  40. package/dist/core/models/calver.js +166 -0
  41. package/dist/core/models/index.d.ts +10 -0
  42. package/dist/core/models/index.js +21 -0
  43. package/dist/core/models/semver.d.ts +13 -0
  44. package/dist/core/models/semver.js +57 -0
  45. package/dist/core/pattern.d.ts +15 -0
  46. package/dist/core/pattern.js +29 -0
  47. package/dist/core/plan.d.ts +33 -0
  48. package/dist/core/plan.js +59 -0
  49. package/dist/git/git.d.ts +23 -0
  50. package/dist/git/git.js +47 -0
  51. package/dist/types.d.ts +66 -0
  52. package/dist/types.js +1 -0
  53. package/package.json +60 -0
  54. package/schema.json +59 -0
@@ -0,0 +1,152 @@
1
+ import { readFile, writeFile, access } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { DEFAULT_SEMVER_LINE } from "./defaults.js";
5
+ import { hasConformingTag, inferPattern } from "./infer.js";
6
+ import { createModel } from "./models/index.js";
7
+ export const CONFIG_FILENAME = ".tagsmith.json";
8
+ const semverModelSchema = z.object({
9
+ type: z.literal("semver"),
10
+ allowPrerelease: z.boolean().optional(),
11
+ });
12
+ const calverModelSchema = z.object({
13
+ type: z.literal("calver"),
14
+ format: z.string().min(1),
15
+ });
16
+ const buildModelSchema = z.object({
17
+ type: z.literal("build"),
18
+ padding: z.number().int().min(0).optional(),
19
+ });
20
+ const modelSchema = z.discriminatedUnion("type", [
21
+ semverModelSchema,
22
+ calverModelSchema,
23
+ buildModelSchema,
24
+ ]);
25
+ const patternSchema = z
26
+ .string()
27
+ .refine((p) => p.includes("{version}"), {
28
+ message: "pattern must contain the {version} placeholder",
29
+ });
30
+ const lineSchema = z.object({
31
+ name: z.string().min(1),
32
+ pattern: patternSchema,
33
+ model: modelSchema,
34
+ initialVersion: z.string().min(1),
35
+ push: z.boolean().default(false),
36
+ });
37
+ const multiConfigSchema = z.object({
38
+ tags: z.array(lineSchema).min(1),
39
+ default: z.string().optional(),
40
+ });
41
+ const legacyConfigSchema = z.object({
42
+ pattern: patternSchema,
43
+ model: modelSchema,
44
+ initialVersion: z.string().min(1),
45
+ push: z.boolean().default(false),
46
+ });
47
+ export class ConfigError extends Error {
48
+ }
49
+ /** Thrown by loadConfig when no config file exists (vs. a malformed one). */
50
+ export class MissingConfigError extends ConfigError {
51
+ }
52
+ /** Build semver defaults, inferring pattern from existing tag names when possible. */
53
+ export function buildImplicitConfig(tags) {
54
+ const model = createModel(DEFAULT_SEMVER_LINE.model);
55
+ const pattern = inferPattern(tags, model);
56
+ const line = { ...DEFAULT_SEMVER_LINE, pattern };
57
+ const config = { lines: [line], default: "default" };
58
+ const source = tags.length > 0 && hasConformingTag(tags, pattern, model)
59
+ ? "inferred"
60
+ : "default";
61
+ return { config, source };
62
+ }
63
+ /** Parse, normalise and validate a raw config. Throws ConfigError on failure. */
64
+ export function parseConfig(raw) {
65
+ const isMulti = typeof raw === "object" &&
66
+ raw !== null &&
67
+ Array.isArray(raw["tags"]);
68
+ if (isMulti) {
69
+ const result = multiConfigSchema.safeParse(raw);
70
+ if (!result.success)
71
+ throw configError(result.error);
72
+ return finalizeMulti(result.data.tags, result.data.default);
73
+ }
74
+ const result = legacyConfigSchema.safeParse(raw);
75
+ if (!result.success)
76
+ throw configError(result.error);
77
+ const line = {
78
+ name: "default",
79
+ pattern: result.data.pattern,
80
+ model: result.data.model,
81
+ initialVersion: result.data.initialVersion,
82
+ push: result.data.push,
83
+ };
84
+ return { lines: [line], default: "default" };
85
+ }
86
+ function finalizeMulti(lines, def) {
87
+ const names = lines.map((l) => l.name);
88
+ const dupes = names.filter((n, i) => names.indexOf(n) !== i);
89
+ if (dupes.length > 0) {
90
+ throw new ConfigError(`Invalid ${CONFIG_FILENAME}:\n - tags: duplicate line name(s): ${[...new Set(dupes)].join(", ")}`);
91
+ }
92
+ // names is guaranteed non-empty because the zod schema has .min(1).
93
+ const resolvedDefault = def ?? names[0];
94
+ if (!names.includes(resolvedDefault)) {
95
+ throw new ConfigError(`Invalid ${CONFIG_FILENAME}:\n - default: "${resolvedDefault}" does not match any line name (${names.join(", ")})`);
96
+ }
97
+ return { lines, default: resolvedDefault };
98
+ }
99
+ function configError(error) {
100
+ const issues = error.issues
101
+ .map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`)
102
+ .join("\n");
103
+ return new ConfigError(`Invalid ${CONFIG_FILENAME}:\n${issues}`);
104
+ }
105
+ export function configPath(cwd) {
106
+ return path.join(cwd, CONFIG_FILENAME);
107
+ }
108
+ export async function configExists(cwd) {
109
+ try {
110
+ await access(configPath(cwd));
111
+ return true;
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ /** Load and validate the config from `cwd`. Throws ConfigError when missing. */
118
+ export async function loadConfig(cwd) {
119
+ const file = configPath(cwd);
120
+ let text;
121
+ try {
122
+ text = await readFile(file, "utf8");
123
+ }
124
+ catch {
125
+ throw new MissingConfigError(`No ${CONFIG_FILENAME} found in ${cwd}. Run \`tagsmith init\` first.`);
126
+ }
127
+ let json;
128
+ try {
129
+ json = JSON.parse(text);
130
+ }
131
+ catch (err) {
132
+ throw new ConfigError(`${CONFIG_FILENAME} is not valid JSON: ${err.message}`);
133
+ }
134
+ return parseConfig(json);
135
+ }
136
+ export async function writeConfig(cwd, config) {
137
+ const file = configPath(cwd);
138
+ const fileShape = {
139
+ tags: config.lines.map((l) => ({
140
+ name: l.name,
141
+ pattern: l.pattern,
142
+ model: l.model,
143
+ initialVersion: l.initialVersion,
144
+ push: l.push,
145
+ })),
146
+ default: config.default,
147
+ };
148
+ // Never persist a broken config: validate the on-disk shape first.
149
+ parseConfig(fileShape);
150
+ const body = JSON.stringify(fileShape, null, 2);
151
+ await writeFile(file, `${body}\n`, "utf8");
152
+ }
@@ -0,0 +1,6 @@
1
+ import type { ModelConfig, TagLine, TagsmithConfig } from "../types.js";
2
+ export declare const DEFAULT_SEMVER_MODEL: ModelConfig;
3
+ export declare const DEFAULT_INITIAL_VERSION = "0.1.0";
4
+ export declare const DEFAULT_SEMVER_LINE: TagLine;
5
+ /** Hard-coded semver defaults when no config file and no inferrable tags exist. */
6
+ export declare function defaultConfig(): TagsmithConfig;
@@ -0,0 +1,16 @@
1
+ export const DEFAULT_SEMVER_MODEL = {
2
+ type: "semver",
3
+ allowPrerelease: true,
4
+ };
5
+ export const DEFAULT_INITIAL_VERSION = "0.1.0";
6
+ export const DEFAULT_SEMVER_LINE = {
7
+ name: "default",
8
+ pattern: "v{version}",
9
+ model: DEFAULT_SEMVER_MODEL,
10
+ initialVersion: DEFAULT_INITIAL_VERSION,
11
+ push: false,
12
+ };
13
+ /** Hard-coded semver defaults when no config file and no inferrable tags exist. */
14
+ export function defaultConfig() {
15
+ return { lines: [{ ...DEFAULT_SEMVER_LINE }], default: "default" };
16
+ }
@@ -0,0 +1,6 @@
1
+ import type { VersionModel } from "../types.js";
2
+ export declare const INFER_CANDIDATE_PATTERNS: readonly ["v{version}", "{version}", "release/{version}", "release-{version}", "release/v{version}"];
3
+ /** Pick the candidate pattern that matches the most semver-parseable tags. */
4
+ export declare function inferPattern(tags: readonly string[], model: VersionModel): string;
5
+ /** True when at least one tag conforms to the pattern + model. */
6
+ export declare function hasConformingTag(tags: readonly string[], pattern: string, model: VersionModel): boolean;
@@ -0,0 +1,35 @@
1
+ import { compilePattern } from "./pattern.js";
2
+ export const INFER_CANDIDATE_PATTERNS = [
3
+ "v{version}",
4
+ "{version}",
5
+ "release/{version}",
6
+ "release-{version}",
7
+ "release/v{version}",
8
+ ];
9
+ function scorePattern(tags, pattern, model) {
10
+ const compiled = compilePattern(pattern);
11
+ let score = 0;
12
+ for (const tag of tags) {
13
+ const ver = compiled.extract(tag);
14
+ if (ver !== null && model.parse(ver) !== null)
15
+ score++;
16
+ }
17
+ return score;
18
+ }
19
+ /** Pick the candidate pattern that matches the most semver-parseable tags. */
20
+ export function inferPattern(tags, model) {
21
+ let best = INFER_CANDIDATE_PATTERNS[0];
22
+ let bestScore = 0;
23
+ for (const p of INFER_CANDIDATE_PATTERNS) {
24
+ const s = scorePattern(tags, p, model);
25
+ if (s > bestScore) {
26
+ bestScore = s;
27
+ best = p;
28
+ }
29
+ }
30
+ return best;
31
+ }
32
+ /** True when at least one tag conforms to the pattern + model. */
33
+ export function hasConformingTag(tags, pattern, model) {
34
+ return scorePattern(tags, pattern, model) > 0;
35
+ }
@@ -0,0 +1,13 @@
1
+ import type { TagLine, TagsmithConfig } from "../types.js";
2
+ export interface LineAssignment {
3
+ /** 線名 → 屬於該線的原始 git tag 名(宣告順序、首條命中者勝)。 */
4
+ byLine: Map<string, string[]>;
5
+ /** 不被任何線命中的 tag。 */
6
+ orphans: string[];
7
+ }
8
+ /** 把 git tag 依「宣告順序第一條命中的 pattern」歸屬到各線桶。 */
9
+ export declare function assignTagsToLines(tags: readonly string[], lines: readonly TagLine[]): LineAssignment;
10
+ export declare class LineNotFoundError extends Error {
11
+ }
12
+ /** 取得指定線(省略則取 default);不存在時丟出列有可用線名的錯誤。 */
13
+ export declare function selectLine(config: TagsmithConfig, name?: string): TagLine;
@@ -0,0 +1,27 @@
1
+ import { compilePattern } from "./pattern.js";
2
+ /** 把 git tag 依「宣告順序第一條命中的 pattern」歸屬到各線桶。 */
3
+ export function assignTagsToLines(tags, lines) {
4
+ const compiled = lines.map((l) => ({ name: l.name, p: compilePattern(l.pattern) }));
5
+ const byLine = new Map(lines.map((l) => [l.name, []]));
6
+ const orphans = [];
7
+ for (const tag of tags) {
8
+ const hit = compiled.find((c) => c.p.extract(tag) !== null);
9
+ if (hit)
10
+ byLine.get(hit.name).push(tag);
11
+ else
12
+ orphans.push(tag);
13
+ }
14
+ return { byLine, orphans };
15
+ }
16
+ export class LineNotFoundError extends Error {
17
+ }
18
+ /** 取得指定線(省略則取 default);不存在時丟出列有可用線名的錯誤。 */
19
+ export function selectLine(config, name) {
20
+ const target = name ?? config.default;
21
+ const line = config.lines.find((l) => l.name === target);
22
+ if (!line) {
23
+ const names = config.lines.map((l) => l.name).join(", ");
24
+ throw new LineNotFoundError(`No tag line named "${target}". Available: ${names}`);
25
+ }
26
+ return line;
27
+ }
@@ -0,0 +1,13 @@
1
+ import type { VersionModel } from "../../types.js";
2
+ export interface BuildValue {
3
+ n: number;
4
+ }
5
+ export interface BuildOptions {
6
+ /** Zero-pad to this width (0 = none). */
7
+ padding?: number;
8
+ }
9
+ /**
10
+ * Monotonic build-number model: a single non-negative integer that only ever
11
+ * increments. Any bump level advances it by one.
12
+ */
13
+ export declare function createBuildModel(opts?: BuildOptions): VersionModel<BuildValue>;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Monotonic build-number model: a single non-negative integer that only ever
3
+ * increments. Any bump level advances it by one.
4
+ */
5
+ export function createBuildModel(opts = {}) {
6
+ const padding = opts.padding ?? 0;
7
+ return {
8
+ type: "build",
9
+ parse(raw) {
10
+ // Cap digits so the value stays within Number.MAX_SAFE_INTEGER; beyond
11
+ // that, compare/bump lose precision and the monotonic invariant breaks.
12
+ if (!/^\d{1,15}$/.test(raw))
13
+ return null;
14
+ return { n: Number(raw) };
15
+ },
16
+ compare(a, b) {
17
+ return a.n - b.n;
18
+ },
19
+ format(v) {
20
+ return padding > 0 ? String(v.n).padStart(padding, "0") : String(v.n);
21
+ },
22
+ bump(v, _level) {
23
+ return { n: v.n + 1 };
24
+ },
25
+ initial(raw) {
26
+ const parsed = this.parse(raw);
27
+ if (parsed === null) {
28
+ throw new Error(`initialVersion "${raw}" is not a valid build number.`);
29
+ }
30
+ return parsed;
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,19 @@
1
+ import type { VersionModel } from "../../types.js";
2
+ export interface CalverValue {
3
+ year: number;
4
+ month: number;
5
+ day: number;
6
+ micro: number;
7
+ }
8
+ export interface CalverOptions {
9
+ format: string;
10
+ /** Injected "today" so the model stays pure and testable. */
11
+ now?: Date;
12
+ }
13
+ /**
14
+ * Calendar versioning. The `format` string is a sequence of tokens
15
+ * (YYYY, YY, MM, DD, MICRO) joined by literal separators, e.g. "YYYY.MM.MICRO".
16
+ * Bumping always rolls to the injected current date; MICRO increments when the
17
+ * date is unchanged and resets otherwise.
18
+ */
19
+ export declare function createCalverModel(opts: CalverOptions): VersionModel<CalverValue>;
@@ -0,0 +1,166 @@
1
+ const TOKEN_ORDER = ["YYYY", "YY", "MM", "DD", "MICRO"];
2
+ /**
3
+ * Calendar versioning. The `format` string is a sequence of tokens
4
+ * (YYYY, YY, MM, DD, MICRO) joined by literal separators, e.g. "YYYY.MM.MICRO".
5
+ * Bumping always rolls to the injected current date; MICRO increments when the
6
+ * date is unchanged and resets otherwise.
7
+ */
8
+ export function createCalverModel(opts) {
9
+ const tokens = parseFormatTokens(opts.format);
10
+ const now = opts.now ?? new Date();
11
+ const model = {
12
+ type: "calver",
13
+ parse(raw) {
14
+ const { regex } = buildMatcher(opts.format, tokens);
15
+ const m = regex.exec(raw);
16
+ if (m === null || m.groups === undefined)
17
+ return null;
18
+ const g = m.groups;
19
+ const value = {
20
+ year: g.YYYY !== undefined
21
+ ? Number(g.YYYY)
22
+ : g.YY !== undefined
23
+ ? 2000 + Number(g.YY)
24
+ : 0,
25
+ month: g.MM !== undefined ? Number(g.MM) : 0,
26
+ day: g.DD !== undefined ? Number(g.DD) : 0,
27
+ micro: g.MICRO !== undefined ? Number(g.MICRO) : 0,
28
+ };
29
+ if (value.month > 12 || value.day > 31)
30
+ return null;
31
+ // Reject anything that does not round-trip to its canonical form (e.g.
32
+ // unpadded "2026.6.7" or leading-zero MICRO "...007"), so a non-canonical
33
+ // tag can never masquerade as — or duplicate — a canonical one.
34
+ if (renderTokens(opts.format, tokens, value) !== raw)
35
+ return null;
36
+ return value;
37
+ },
38
+ compare(a, b) {
39
+ return (a.year - b.year ||
40
+ a.month - b.month ||
41
+ a.day - b.day ||
42
+ a.micro - b.micro);
43
+ },
44
+ format(v) {
45
+ return renderTokens(opts.format, tokens, v);
46
+ },
47
+ bump(v, _level) {
48
+ const today = dateValue(now, tokens);
49
+ const sameDate = today.year === v.year &&
50
+ today.month === v.month &&
51
+ today.day === v.day;
52
+ if (!tokens.includes("MICRO")) {
53
+ if (this.compare(today, v) <= 0) {
54
+ throw new Error(`CalVer format "${opts.format}" has no MICRO token; cannot create a second tag for the same date period.`);
55
+ }
56
+ return today;
57
+ }
58
+ // Same date → next MICRO. Future date → reset MICRO. If `today` is not
59
+ // strictly after `v` (clock skew, or a future-dated latest tag), stay on
60
+ // v's date and bump MICRO so the result is always strictly greater.
61
+ if (sameDate)
62
+ return { ...today, micro: v.micro + 1 };
63
+ return this.compare(today, v) > 0
64
+ ? { ...today, micro: 0 }
65
+ : { ...v, micro: v.micro + 1 };
66
+ },
67
+ initial(raw) {
68
+ const parsed = this.parse(raw);
69
+ if (parsed === null) {
70
+ throw new Error(`initialVersion "${raw}" does not match calver format "${opts.format}".`);
71
+ }
72
+ return parsed;
73
+ },
74
+ };
75
+ return model;
76
+ }
77
+ function parseFormatTokens(format) {
78
+ const found = [];
79
+ let i = 0;
80
+ while (i < format.length) {
81
+ const token = TOKEN_ORDER.find((t) => format.startsWith(t, i));
82
+ if (token !== undefined) {
83
+ found.push(token);
84
+ i += token.length;
85
+ }
86
+ else {
87
+ i += 1; // literal separator char
88
+ }
89
+ }
90
+ if (found.length === 0) {
91
+ throw new Error(`CalVer format "${format}" contains no recognised tokens.`);
92
+ }
93
+ return found;
94
+ }
95
+ // Fixed-width matchers prevent a greedy `\d+` from eating across an adjacent
96
+ // numeric token (e.g. format "YYYYMM" on "202606" must split 2026|06, not
97
+ // 20260|6). MICRO is the only unbounded token.
98
+ const TOKEN_REGEX = {
99
+ YYYY: "\\d{4}",
100
+ YY: "\\d{2}",
101
+ MM: "\\d{2}",
102
+ DD: "\\d{2}",
103
+ MICRO: "\\d+",
104
+ };
105
+ function buildMatcher(format, tokens) {
106
+ let pattern = "";
107
+ let i = 0;
108
+ const used = new Set();
109
+ while (i < format.length) {
110
+ const token = tokens.find((t) => format.startsWith(t, i) && !used.has(t))
111
+ ?? TOKEN_ORDER.find((t) => format.startsWith(t, i));
112
+ if (token !== undefined && !used.has(token)) {
113
+ used.add(token);
114
+ pattern += `(?<${token}>${TOKEN_REGEX[token]})`;
115
+ i += token.length;
116
+ }
117
+ else {
118
+ pattern += escapeRegex(format[i] ?? "");
119
+ i += 1;
120
+ }
121
+ }
122
+ return { regex: new RegExp(`^${pattern}$`) };
123
+ }
124
+ function renderTokens(format, tokens, v) {
125
+ let out = "";
126
+ let i = 0;
127
+ const used = new Set();
128
+ while (i < format.length) {
129
+ const token = tokens.find((t) => format.startsWith(t, i) && !used.has(t));
130
+ if (token !== undefined) {
131
+ used.add(token);
132
+ out += renderToken(token, v);
133
+ i += token.length;
134
+ }
135
+ else {
136
+ out += format[i] ?? "";
137
+ i += 1;
138
+ }
139
+ }
140
+ return out;
141
+ }
142
+ function renderToken(token, v) {
143
+ switch (token) {
144
+ case "YYYY":
145
+ return String(v.year).padStart(4, "0");
146
+ case "YY":
147
+ return String(v.year % 100).padStart(2, "0");
148
+ case "MM":
149
+ return String(v.month).padStart(2, "0");
150
+ case "DD":
151
+ return String(v.day).padStart(2, "0");
152
+ case "MICRO":
153
+ return String(v.micro);
154
+ }
155
+ }
156
+ function dateValue(now, tokens) {
157
+ return {
158
+ year: tokens.some((t) => t === "YYYY" || t === "YY") ? now.getFullYear() : 0,
159
+ month: tokens.includes("MM") ? now.getMonth() + 1 : 0,
160
+ day: tokens.includes("DD") ? now.getDate() : 0,
161
+ micro: 0,
162
+ };
163
+ }
164
+ function escapeRegex(ch) {
165
+ return ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
166
+ }
@@ -0,0 +1,10 @@
1
+ import type { ModelConfig, VersionModel } from "../../types.js";
2
+ export { createSemverModel } from "./semver.js";
3
+ export { createCalverModel } from "./calver.js";
4
+ export { createBuildModel } from "./build.js";
5
+ export interface ModelFactoryOptions {
6
+ /** Injected current date for calver; ignored by other models. */
7
+ now?: Date;
8
+ }
9
+ /** Construct the version model described by a config block. */
10
+ export declare function createModel(config: ModelConfig, opts?: ModelFactoryOptions): VersionModel;
@@ -0,0 +1,21 @@
1
+ import { createSemverModel } from "./semver.js";
2
+ import { createCalverModel } from "./calver.js";
3
+ import { createBuildModel } from "./build.js";
4
+ export { createSemverModel } from "./semver.js";
5
+ export { createCalverModel } from "./calver.js";
6
+ export { createBuildModel } from "./build.js";
7
+ /** Construct the version model described by a config block. */
8
+ export function createModel(config, opts = {}) {
9
+ switch (config.type) {
10
+ case "semver":
11
+ return createSemverModel({ allowPrerelease: config.allowPrerelease });
12
+ case "calver":
13
+ return createCalverModel({ format: config.format, now: opts.now });
14
+ case "build":
15
+ return createBuildModel({ padding: config.padding });
16
+ default: {
17
+ const exhaustive = config;
18
+ throw new Error(`Unknown model type: ${JSON.stringify(exhaustive)}`);
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,13 @@
1
+ import type { VersionModel } from "../../types.js";
2
+ export interface SemverValue {
3
+ /** Canonical semver string, e.g. "1.2.3" or "1.2.3-rc.1". */
4
+ value: string;
5
+ }
6
+ export interface SemverOptions {
7
+ allowPrerelease?: boolean;
8
+ }
9
+ /**
10
+ * SemVer model backed by the `semver` package. Comparison and bumping follow
11
+ * standard SemVer precedence, including prerelease handling.
12
+ */
13
+ export declare function createSemverModel(opts?: SemverOptions): VersionModel<SemverValue>;
@@ -0,0 +1,57 @@
1
+ import semver from "semver";
2
+ /**
3
+ * SemVer model backed by the `semver` package. Comparison and bumping follow
4
+ * standard SemVer precedence, including prerelease handling.
5
+ */
6
+ export function createSemverModel(opts = {}) {
7
+ const allowPrerelease = opts.allowPrerelease ?? true;
8
+ return {
9
+ type: "semver",
10
+ parse(raw) {
11
+ const cleaned = semver.valid(raw, { loose: false });
12
+ if (cleaned === null)
13
+ return null;
14
+ if (!allowPrerelease && semver.prerelease(cleaned) !== null)
15
+ return null;
16
+ return { value: cleaned };
17
+ },
18
+ compare(a, b) {
19
+ return semver.compare(a.value, b.value);
20
+ },
21
+ format(v) {
22
+ return v.value;
23
+ },
24
+ bump(v, level) {
25
+ const release = toReleaseType(level);
26
+ const next = semver.inc(v.value, release);
27
+ if (next === null) {
28
+ throw new Error(`Cannot bump semver "${v.value}" by "${level}".`);
29
+ }
30
+ return { value: next };
31
+ },
32
+ initial(raw) {
33
+ const parsed = this.parse(raw);
34
+ if (parsed === null) {
35
+ throw new Error(`initialVersion "${raw}" is not a valid semver.`);
36
+ }
37
+ return parsed;
38
+ },
39
+ };
40
+ }
41
+ function toReleaseType(level) {
42
+ switch (level) {
43
+ case "major":
44
+ return "major";
45
+ case "minor":
46
+ return "minor";
47
+ case "prerelease":
48
+ return "prerelease";
49
+ case "patch":
50
+ case "auto":
51
+ return "patch";
52
+ default: {
53
+ const exhaustive = level;
54
+ throw new Error(`Unsupported semver bump level: ${String(exhaustive)}`);
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,15 @@
1
+ export declare class PatternError extends Error {
2
+ }
3
+ /**
4
+ * A tag pattern is a template containing exactly one `{version}` placeholder,
5
+ * e.g. "v{version}" or "release/{version}". This compiles it into a matcher
6
+ * that can extract the version substring and re-render full tag names.
7
+ */
8
+ export interface CompiledPattern {
9
+ readonly template: string;
10
+ /** Extract the version substring from a tag name, or null if it doesn't match. */
11
+ extract(tag: string): string | null;
12
+ /** Render a full tag name from a version string. */
13
+ render(version: string): string;
14
+ }
15
+ export declare function compilePattern(template: string): CompiledPattern;
@@ -0,0 +1,29 @@
1
+ const PLACEHOLDER = "{version}";
2
+ export class PatternError extends Error {
3
+ }
4
+ export function compilePattern(template) {
5
+ const first = template.indexOf(PLACEHOLDER);
6
+ if (first === -1) {
7
+ throw new PatternError(`Pattern "${template}" must contain the ${PLACEHOLDER} placeholder.`);
8
+ }
9
+ if (template.indexOf(PLACEHOLDER, first + PLACEHOLDER.length) !== -1) {
10
+ throw new PatternError(`Pattern "${template}" must contain ${PLACEHOLDER} exactly once.`);
11
+ }
12
+ const prefix = template.slice(0, first);
13
+ const suffix = template.slice(first + PLACEHOLDER.length);
14
+ // The version capture is non-greedy-safe because prefix/suffix are anchored.
15
+ const regex = new RegExp(`^${escapeRegex(prefix)}(.+)${escapeRegex(suffix)}$`);
16
+ return {
17
+ template,
18
+ extract(tag) {
19
+ const m = regex.exec(tag);
20
+ return m === null ? null : (m[1] ?? null);
21
+ },
22
+ render(version) {
23
+ return `${prefix}${version}${suffix}`;
24
+ },
25
+ };
26
+ }
27
+ function escapeRegex(s) {
28
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29
+ }