@pgpmjs/core 3.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.
Files changed (140) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +99 -0
  3. package/core/boilerplate-scanner.d.ts +41 -0
  4. package/core/boilerplate-scanner.js +106 -0
  5. package/core/boilerplate-types.d.ts +52 -0
  6. package/core/boilerplate-types.js +6 -0
  7. package/core/class/pgpm.d.ts +150 -0
  8. package/core/class/pgpm.js +1470 -0
  9. package/core/template-scaffold.d.ts +29 -0
  10. package/core/template-scaffold.js +168 -0
  11. package/esm/core/boilerplate-scanner.js +96 -0
  12. package/esm/core/boilerplate-types.js +5 -0
  13. package/esm/core/class/pgpm.js +1430 -0
  14. package/esm/core/template-scaffold.js +161 -0
  15. package/esm/export/export-meta.js +240 -0
  16. package/esm/export/export-migrations.js +180 -0
  17. package/esm/extensions/extensions.js +31 -0
  18. package/esm/files/extension/index.js +3 -0
  19. package/esm/files/extension/reader.js +79 -0
  20. package/esm/files/extension/writer.js +63 -0
  21. package/esm/files/index.js +6 -0
  22. package/esm/files/plan/generator.js +49 -0
  23. package/esm/files/plan/index.js +5 -0
  24. package/esm/files/plan/parser.js +296 -0
  25. package/esm/files/plan/validators.js +181 -0
  26. package/esm/files/plan/writer.js +114 -0
  27. package/esm/files/sql/index.js +1 -0
  28. package/esm/files/sql/writer.js +107 -0
  29. package/esm/files/sql-scripts/index.js +2 -0
  30. package/esm/files/sql-scripts/reader.js +19 -0
  31. package/esm/files/types/index.js +1 -0
  32. package/esm/files/types/package.js +1 -0
  33. package/esm/index.js +21 -0
  34. package/esm/init/client.js +144 -0
  35. package/esm/init/sql/bootstrap-roles.sql +55 -0
  36. package/esm/init/sql/bootstrap-test-roles.sql +72 -0
  37. package/esm/migrate/clean.js +23 -0
  38. package/esm/migrate/client.js +551 -0
  39. package/esm/migrate/index.js +5 -0
  40. package/esm/migrate/sql/procedures.sql +258 -0
  41. package/esm/migrate/sql/schema.sql +37 -0
  42. package/esm/migrate/types.js +1 -0
  43. package/esm/migrate/utils/event-logger.js +28 -0
  44. package/esm/migrate/utils/hash.js +27 -0
  45. package/esm/migrate/utils/transaction.js +125 -0
  46. package/esm/modules/modules.js +49 -0
  47. package/esm/packaging/package.js +96 -0
  48. package/esm/packaging/transform.js +70 -0
  49. package/esm/projects/deploy.js +123 -0
  50. package/esm/projects/revert.js +75 -0
  51. package/esm/projects/verify.js +61 -0
  52. package/esm/resolution/deps.js +526 -0
  53. package/esm/resolution/resolve.js +101 -0
  54. package/esm/utils/debug.js +147 -0
  55. package/esm/utils/target-utils.js +37 -0
  56. package/esm/workspace/paths.js +43 -0
  57. package/esm/workspace/utils.js +31 -0
  58. package/export/export-meta.d.ts +8 -0
  59. package/export/export-meta.js +244 -0
  60. package/export/export-migrations.d.ts +17 -0
  61. package/export/export-migrations.js +187 -0
  62. package/extensions/extensions.d.ts +5 -0
  63. package/extensions/extensions.js +35 -0
  64. package/files/extension/index.d.ts +2 -0
  65. package/files/extension/index.js +19 -0
  66. package/files/extension/reader.d.ts +24 -0
  67. package/files/extension/reader.js +86 -0
  68. package/files/extension/writer.d.ts +39 -0
  69. package/files/extension/writer.js +70 -0
  70. package/files/index.d.ts +5 -0
  71. package/files/index.js +22 -0
  72. package/files/plan/generator.d.ts +22 -0
  73. package/files/plan/generator.js +57 -0
  74. package/files/plan/index.d.ts +4 -0
  75. package/files/plan/index.js +21 -0
  76. package/files/plan/parser.d.ts +27 -0
  77. package/files/plan/parser.js +303 -0
  78. package/files/plan/validators.d.ts +52 -0
  79. package/files/plan/validators.js +187 -0
  80. package/files/plan/writer.d.ts +27 -0
  81. package/files/plan/writer.js +124 -0
  82. package/files/sql/index.d.ts +1 -0
  83. package/files/sql/index.js +17 -0
  84. package/files/sql/writer.d.ts +12 -0
  85. package/files/sql/writer.js +114 -0
  86. package/files/sql-scripts/index.d.ts +1 -0
  87. package/files/sql-scripts/index.js +18 -0
  88. package/files/sql-scripts/reader.d.ts +8 -0
  89. package/files/sql-scripts/reader.js +23 -0
  90. package/files/types/index.d.ts +46 -0
  91. package/files/types/index.js +17 -0
  92. package/files/types/package.d.ts +20 -0
  93. package/files/types/package.js +2 -0
  94. package/index.d.ts +21 -0
  95. package/index.js +45 -0
  96. package/init/client.d.ts +26 -0
  97. package/init/client.js +148 -0
  98. package/init/sql/bootstrap-roles.sql +55 -0
  99. package/init/sql/bootstrap-test-roles.sql +72 -0
  100. package/migrate/clean.d.ts +1 -0
  101. package/migrate/clean.js +27 -0
  102. package/migrate/client.d.ts +80 -0
  103. package/migrate/client.js +555 -0
  104. package/migrate/index.d.ts +5 -0
  105. package/migrate/index.js +21 -0
  106. package/migrate/sql/procedures.sql +258 -0
  107. package/migrate/sql/schema.sql +37 -0
  108. package/migrate/types.d.ts +67 -0
  109. package/migrate/types.js +2 -0
  110. package/migrate/utils/event-logger.d.ts +13 -0
  111. package/migrate/utils/event-logger.js +32 -0
  112. package/migrate/utils/hash.d.ts +12 -0
  113. package/migrate/utils/hash.js +32 -0
  114. package/migrate/utils/transaction.d.ts +27 -0
  115. package/migrate/utils/transaction.js +129 -0
  116. package/modules/modules.d.ts +31 -0
  117. package/modules/modules.js +56 -0
  118. package/package.json +70 -0
  119. package/packaging/package.d.ts +19 -0
  120. package/packaging/package.js +102 -0
  121. package/packaging/transform.d.ts +22 -0
  122. package/packaging/transform.js +75 -0
  123. package/projects/deploy.d.ts +8 -0
  124. package/projects/deploy.js +160 -0
  125. package/projects/revert.d.ts +15 -0
  126. package/projects/revert.js +112 -0
  127. package/projects/verify.d.ts +8 -0
  128. package/projects/verify.js +98 -0
  129. package/resolution/deps.d.ts +57 -0
  130. package/resolution/deps.js +531 -0
  131. package/resolution/resolve.d.ts +37 -0
  132. package/resolution/resolve.js +107 -0
  133. package/utils/debug.d.ts +21 -0
  134. package/utils/debug.js +153 -0
  135. package/utils/target-utils.d.ts +5 -0
  136. package/utils/target-utils.js +40 -0
  137. package/workspace/paths.d.ts +14 -0
  138. package/workspace/paths.js +50 -0
  139. package/workspace/utils.d.ts +8 -0
  140. package/workspace/utils.js +36 -0
@@ -0,0 +1,63 @@
1
+ import { writeFileSync } from 'fs';
2
+ import { getExtensionInfo } from './reader';
3
+ /**
4
+ * Write the Makefile for the extension.
5
+ */
6
+ export const writeExtensionMakefile = (outputPath, extname, version) => {
7
+ const content = `EXTENSION = ${extname}
8
+ DATA = sql/${extname}--${version}.sql
9
+
10
+ PG_CONFIG = pg_config
11
+ PGXS := $(shell $(PG_CONFIG) --pgxs)
12
+ include $(PGXS)
13
+ `;
14
+ writeFileSync(outputPath, content);
15
+ };
16
+ /**
17
+ * Generate content for a .control file
18
+ * https://www.postgresql.org/docs/current/extend-extensions.html
19
+ */
20
+ export function generateControlFileContent(options) {
21
+ const { name, version, comment = `${name} extension`, requires = [], default_version = version, relocatable = false, superuser = false, schema, module_pathname } = options;
22
+ let content = `# ${name} extension
23
+ comment = '${comment}'
24
+ default_version = '${default_version}'
25
+ `;
26
+ if (module_pathname) {
27
+ content += `module_pathname = '${module_pathname}'\n`;
28
+ }
29
+ else {
30
+ content += `module_pathname = '$libdir/${name}'\n`;
31
+ }
32
+ if (requires.length > 0) {
33
+ content += `requires = '${requires.join(',')}'\n`;
34
+ }
35
+ content += `relocatable = ${relocatable}
36
+ superuser = ${superuser}\n`;
37
+ if (schema) {
38
+ content += `schema = ${schema}\n`;
39
+ }
40
+ return content;
41
+ }
42
+ /**
43
+ * Write the control file for the extension.
44
+ */
45
+ export const writeExtensionControlFile = (outputPath, extname, extensions, version) => {
46
+ const content = generateControlFileContent({
47
+ name: extname,
48
+ version,
49
+ requires: extensions
50
+ });
51
+ writeFileSync(outputPath, content);
52
+ };
53
+ /**
54
+ * Write control and Makefile for the extension with given data.
55
+ * If extInfo is not provided, it will be generated from packageDir.
56
+ */
57
+ export const writeExtensions = (packageDir, extensions, extInfo) => {
58
+ // If extInfo is not provided, get it from packageDir
59
+ const info = extInfo || getExtensionInfo(packageDir);
60
+ const { controlFile, Makefile, extname, version } = info;
61
+ writeExtensionControlFile(controlFile, extname, extensions, version);
62
+ writeExtensionMakefile(Makefile, extname, version);
63
+ };
@@ -0,0 +1,6 @@
1
+ // Re-export all functionality from package-files
2
+ export * from './extension';
3
+ export * from './plan';
4
+ export * from './sql';
5
+ export * from './sql-scripts';
6
+ export * from './types';
@@ -0,0 +1,49 @@
1
+ import fs from 'fs';
2
+ /**
3
+ * Get a UTC timestamp string
4
+ */
5
+ function getUTCTimestamp(d = new Date()) {
6
+ return (d.getUTCFullYear() +
7
+ '-' + String(d.getUTCMonth() + 1).padStart(2, '0') +
8
+ '-' + String(d.getUTCDate()).padStart(2, '0') +
9
+ 'T' + String(d.getUTCHours()).padStart(2, '0') +
10
+ ':' + String(d.getUTCMinutes()).padStart(2, '0') +
11
+ ':' + String(d.getUTCSeconds()).padStart(2, '0') +
12
+ 'Z');
13
+ }
14
+ /**
15
+ * Get a timestamp for the plan file
16
+ */
17
+ export function getNow() {
18
+ return process.env.NODE_ENV === 'test'
19
+ ? getUTCTimestamp(new Date('2017-08-11T08:11:51Z'))
20
+ : getUTCTimestamp(new Date());
21
+ }
22
+ /**
23
+ * Generate a plan file content from structured data
24
+ */
25
+ export function generatePlan(options) {
26
+ const { moduleName, uri, entries } = options;
27
+ const now = getNow();
28
+ const planfile = [
29
+ `%syntax-version=1.0.0`,
30
+ `%project=${moduleName}`,
31
+ `%uri=${uri || moduleName}`
32
+ ];
33
+ // Generate the plan entries
34
+ entries.forEach(entry => {
35
+ if (entry.dependencies && entry.dependencies.length > 0) {
36
+ planfile.push(`${entry.change} [${entry.dependencies.join(' ')}] ${now} launchql <launchql@5b0c196eeb62>${entry.comment ? ` # ${entry.comment}` : ''}`);
37
+ }
38
+ else {
39
+ planfile.push(`${entry.change} ${now} launchql <launchql@5b0c196eeb62>${entry.comment ? ` # ${entry.comment}` : ''}`);
40
+ }
41
+ });
42
+ return planfile.join('\n');
43
+ }
44
+ /**
45
+ * Write a generated plan file to disk
46
+ */
47
+ export function writePlan(planPath, plan) {
48
+ fs.writeFileSync(planPath, plan);
49
+ }
@@ -0,0 +1,5 @@
1
+ // Re-export all plan functionality
2
+ export * from './generator';
3
+ export * from './parser';
4
+ export * from './validators';
5
+ export * from './writer';
@@ -0,0 +1,296 @@
1
+ import { readFileSync } from 'fs';
2
+ import { isValidChangeName, isValidDependency, isValidTagName, parseReference } from './validators';
3
+ import { errors } from '@pgpmjs/types';
4
+ /**
5
+ * Parse a Sqitch plan file with full validation
6
+ * Supports both changes and tags
7
+ */
8
+ export function parsePlanFile(planPath) {
9
+ const content = readFileSync(planPath, 'utf-8');
10
+ const lines = content.split('\n');
11
+ let project = '';
12
+ let uri = '';
13
+ const changes = [];
14
+ const tags = [];
15
+ const errors = [];
16
+ for (let i = 0; i < lines.length; i++) {
17
+ const line = lines[i];
18
+ const lineNum = i + 1;
19
+ const trimmed = line.trim();
20
+ // Skip empty lines
21
+ if (!trimmed)
22
+ continue;
23
+ // Skip comments
24
+ if (trimmed.startsWith('#'))
25
+ continue;
26
+ // Parse package metadata
27
+ if (trimmed.startsWith('%project=')) {
28
+ project = trimmed.substring('%project='.length);
29
+ continue;
30
+ }
31
+ if (trimmed.startsWith('%uri=')) {
32
+ uri = trimmed.substring('%uri='.length);
33
+ continue;
34
+ }
35
+ // Skip other metadata lines
36
+ if (trimmed.startsWith('%')) {
37
+ continue;
38
+ }
39
+ // Parse tag lines
40
+ if (trimmed.startsWith('@')) {
41
+ const lastChangeName = changes.length > 0 ? changes[changes.length - 1].name : null;
42
+ const tag = parseTagLine(trimmed, lastChangeName);
43
+ if (tag) {
44
+ if (isValidTagName(tag.name)) {
45
+ tags.push(tag);
46
+ }
47
+ else {
48
+ errors.push({
49
+ line: lineNum,
50
+ message: `Invalid tag name: ${tag.name}`
51
+ });
52
+ }
53
+ }
54
+ else {
55
+ errors.push({
56
+ line: lineNum,
57
+ message: 'Invalid tag line format'
58
+ });
59
+ }
60
+ continue;
61
+ }
62
+ // Parse change lines
63
+ const change = parseChangeLine(trimmed);
64
+ if (change) {
65
+ // Validate change name
66
+ if (!isValidChangeName(change.name)) {
67
+ errors.push({
68
+ line: lineNum,
69
+ message: `Invalid change name: ${change.name}`
70
+ });
71
+ continue;
72
+ }
73
+ // Validate dependencies
74
+ for (const dep of change.dependencies) {
75
+ if (!isValidDependency(dep)) {
76
+ errors.push({
77
+ line: lineNum,
78
+ message: `Invalid dependency reference: ${dep}`
79
+ });
80
+ }
81
+ }
82
+ changes.push(change);
83
+ }
84
+ else if (trimmed) {
85
+ // Non-empty line that couldn't be parsed
86
+ errors.push({
87
+ line: lineNum,
88
+ message: 'Invalid line format'
89
+ });
90
+ }
91
+ }
92
+ if (errors.length > 0) {
93
+ return { errors };
94
+ }
95
+ return {
96
+ data: { package: project, uri, changes, tags },
97
+ errors: []
98
+ };
99
+ }
100
+ /**
101
+ * Parse a single change line from a plan file
102
+ * Format: change_name [deps] timestamp planner <email> # comment
103
+ */
104
+ function parseChangeLine(line) {
105
+ // More flexible regex that handles various formats, including planner names with spaces
106
+ // Format: change_name [deps] timestamp planner <email> # comment
107
+ // The timestamp is required if planner/email/comment are present
108
+ const regex = /^(\S+)(?:\s+\[([^\]]*)\])?(?:\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)(?:\s+([^<]+?))?(?:\s+<([^>]+)>)?(?:\s+#\s+(.*))?)?$/;
109
+ const match = line.match(regex);
110
+ if (!match) {
111
+ return null;
112
+ }
113
+ const [, name, depsStr, timestamp, planner, email, comment] = match;
114
+ // Parse dependencies
115
+ const dependencies = depsStr
116
+ ? depsStr.split(/\s+/).filter(dep => dep.length > 0)
117
+ : [];
118
+ return {
119
+ name,
120
+ dependencies,
121
+ timestamp,
122
+ planner,
123
+ email,
124
+ comment
125
+ };
126
+ }
127
+ /**
128
+ * Parse a tag line from a plan file
129
+ * Format: @tag_name change_name timestamp planner <email> # comment
130
+ */
131
+ function parseTagLine(line, lastChangeName) {
132
+ // Tag lines start with @
133
+ if (!line.startsWith('@')) {
134
+ return null;
135
+ }
136
+ // Remove the @ and parse
137
+ const tagContent = line.substring(1);
138
+ // Two possible formats:
139
+ // 1. tagname timestamp planner <email> # comment (tag for last change)
140
+ // 2. tagname changename timestamp planner <email> # comment (tag for specific change)
141
+ // First try to match with change name (format 2)
142
+ const regexWithChange = /^(\S+)\s+(\S+)\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)\s+(.+?)\s+<([^>]+)>(?:\s+#\s+(.*))?$/;
143
+ let match = tagContent.match(regexWithChange);
144
+ if (match) {
145
+ const [, name, secondToken, timestamp, planner, email, comment] = match;
146
+ // Check if the second token is a timestamp (format 1) or change name (format 2)
147
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(secondToken)) {
148
+ // Format 1: no change name specified
149
+ return {
150
+ name,
151
+ change: lastChangeName || '', // Tag is associated with the last change
152
+ timestamp: secondToken,
153
+ planner: timestamp, // What we thought was timestamp is actually planner
154
+ email: planner, // Shift everything
155
+ comment: email || comment
156
+ };
157
+ }
158
+ else {
159
+ // Format 2: explicit change name
160
+ return {
161
+ name,
162
+ change: secondToken,
163
+ timestamp,
164
+ planner,
165
+ email,
166
+ comment
167
+ };
168
+ }
169
+ }
170
+ // Try simple format without change name
171
+ const regexSimple = /^(\S+)\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)\s+(.+?)\s+<([^>]+)>(?:\s+#\s+(.*))?$/;
172
+ match = tagContent.match(regexSimple);
173
+ if (!match) {
174
+ return null;
175
+ }
176
+ const [, name, timestamp, planner, email, comment] = match;
177
+ return {
178
+ name,
179
+ change: lastChangeName || '', // Tag is associated with the last change
180
+ timestamp,
181
+ planner,
182
+ email,
183
+ comment
184
+ };
185
+ }
186
+ /**
187
+ * Resolve a reference within a plan file context
188
+ * Handles symbolic references (HEAD, ROOT) and relative references
189
+ */
190
+ export function resolveReference(ref, plan, currentPackage) {
191
+ const parsed = parseReference(ref);
192
+ if (!parsed) {
193
+ return { error: `Invalid reference: ${ref}` };
194
+ }
195
+ // Handle package qualifier
196
+ const planPackage = currentPackage || plan.package;
197
+ if (parsed.package && parsed.package !== planPackage) {
198
+ // Cross-package reference - return as-is
199
+ return { change: ref };
200
+ }
201
+ // Handle SHA1 references
202
+ if (parsed.sha1) {
203
+ // Would need to look up in database
204
+ return { change: ref };
205
+ }
206
+ // Handle symbolic references
207
+ if (parsed.symbolic === 'HEAD' && plan.changes.length > 0) {
208
+ return { change: plan.changes[plan.changes.length - 1].name };
209
+ }
210
+ if (parsed.symbolic === 'ROOT' && plan.changes.length > 0) {
211
+ return { change: plan.changes[0].name };
212
+ }
213
+ // Handle relative references
214
+ if (parsed.relative) {
215
+ const baseResolved = resolveReference(parsed.relative.base, plan, currentPackage);
216
+ if (baseResolved.error) {
217
+ return baseResolved;
218
+ }
219
+ // Get the change name from the resolved reference
220
+ const baseChange = baseResolved.change;
221
+ if (!baseChange) {
222
+ return { error: `Cannot resolve base reference: ${parsed.relative.base}` };
223
+ }
224
+ const changeIndex = plan.changes.findIndex(c => c.name === baseChange);
225
+ if (changeIndex === -1) {
226
+ return { error: `Change not found: ${baseChange}` };
227
+ }
228
+ let targetIndex;
229
+ if (parsed.relative.direction === '^') {
230
+ // Go backwards
231
+ targetIndex = changeIndex - parsed.relative.count;
232
+ }
233
+ else {
234
+ // Go forwards
235
+ targetIndex = changeIndex + parsed.relative.count;
236
+ }
237
+ if (targetIndex < 0 || targetIndex >= plan.changes.length) {
238
+ return { error: `Relative reference out of bounds: ${ref}` };
239
+ }
240
+ return { change: plan.changes[targetIndex].name };
241
+ }
242
+ // Handle tag references
243
+ if (parsed.tag) {
244
+ const tag = plan.tags.find(t => t.name === parsed.tag);
245
+ if (!tag) {
246
+ return { error: `Tag not found: ${parsed.tag}` };
247
+ }
248
+ return { tag: parsed.tag, change: tag.change };
249
+ }
250
+ // Handle change@tag format
251
+ if (parsed.change && parsed.tag) {
252
+ return { change: parsed.change, tag: parsed.tag };
253
+ }
254
+ // Plain change reference
255
+ if (parsed.change) {
256
+ // Validate that the change exists in the plan
257
+ const changeExists = plan.changes.some(c => c.name === parsed.change);
258
+ if (!changeExists) {
259
+ return { error: `Change not found: ${parsed.change}` };
260
+ }
261
+ return { change: parsed.change };
262
+ }
263
+ return { error: `Cannot resolve reference: ${ref}` };
264
+ }
265
+ /**
266
+ * Simple plan file parser without validation (for backwards compatibility)
267
+ */
268
+ export function parsePlanFileSimple(planPath) {
269
+ const result = parsePlanFile(planPath);
270
+ if (result.data) {
271
+ // Return without tags for simple format
272
+ const { tags, ...planWithoutTags } = result.data;
273
+ return planWithoutTags;
274
+ }
275
+ // If there are errors, throw with details
276
+ if (result.errors && result.errors.length > 0) {
277
+ const errorMessages = result.errors.map(e => `Line ${e.line}: ${e.message}`).join('\n');
278
+ throw errors.PLAN_PARSE_ERROR({ planPath, errors: errorMessages });
279
+ }
280
+ // Return empty plan if no data and no errors
281
+ return { package: '', uri: '', changes: [] };
282
+ }
283
+ /**
284
+ * Get all change names from a plan file
285
+ */
286
+ export function getChanges(planPath) {
287
+ const plan = parsePlanFileSimple(planPath);
288
+ return plan.changes.map(change => change.name);
289
+ }
290
+ /**
291
+ * Get the latest (last) change from a plan file
292
+ */
293
+ export function getLatestChange(planPath) {
294
+ const changes = getChanges(planPath);
295
+ return changes[changes.length - 1] || '';
296
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Validators for Sqitch change and tag names according to the spec:
3
+ * https://sqitch.org/docs/manual/sqitchchanges/
4
+ */
5
+ /**
6
+ * Check if a character is punctuation (excluding underscore)
7
+ */
8
+ function isPunctuation(char) {
9
+ // Common punctuation marks, excluding underscore
10
+ const punctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^`{|}~]/;
11
+ return punctuation.test(char);
12
+ }
13
+ /**
14
+ * Validate a change name according to Sqitch rules:
15
+ * - ≥1 character
16
+ * - Cannot contain whitespace
17
+ * - First and last characters must not be punctuation (except _)
18
+ * - Must not end in ~, ^, /, = or % followed by digits
19
+ * - Disallowed characters anywhere: :, @, #, \
20
+ */
21
+ export function isValidChangeName(name) {
22
+ if (!name || name.length === 0) {
23
+ return false;
24
+ }
25
+ // Check for whitespace
26
+ if (/\s/.test(name)) {
27
+ return false;
28
+ }
29
+ // Check for disallowed characters anywhere
30
+ if (/[:@#\\]/.test(name)) {
31
+ return false;
32
+ }
33
+ // Check first character - must not be punctuation except underscore
34
+ const firstChar = name[0];
35
+ if (firstChar !== '_' && isPunctuation(firstChar)) {
36
+ return false;
37
+ }
38
+ // Check last character - must not be punctuation except underscore
39
+ const lastChar = name[name.length - 1];
40
+ if (lastChar !== '_' && isPunctuation(lastChar)) {
41
+ return false;
42
+ }
43
+ // Check for endings like ~N, ^N, /N, =N, %N where N is digits
44
+ if (/[~^/=%]\d+$/.test(name)) {
45
+ return false;
46
+ }
47
+ return true;
48
+ }
49
+ /**
50
+ * Validate a tag name according to Sqitch rules:
51
+ * - Same as change name rules
52
+ * - Additionally, must not contain /
53
+ */
54
+ export function isValidTagName(name) {
55
+ if (!isValidChangeName(name)) {
56
+ return false;
57
+ }
58
+ // Tags must not contain /
59
+ if (name.includes('/')) {
60
+ return false;
61
+ }
62
+ return true;
63
+ }
64
+ /**
65
+ * Check if a string is a valid SHA1 hash (40 hex characters)
66
+ */
67
+ function isSHA1(str) {
68
+ return /^[0-9a-f]{40}$/i.test(str);
69
+ }
70
+ /**
71
+ * Parse a reference string into its components
72
+ */
73
+ export function parseReference(ref) {
74
+ if (!ref) {
75
+ return null;
76
+ }
77
+ const result = {};
78
+ // Check for package qualifier
79
+ let workingRef = ref;
80
+ const colonIndex = ref.indexOf(':');
81
+ if (colonIndex > 0) {
82
+ const projectPart = ref.substring(0, colonIndex);
83
+ const remainingPart = ref.substring(colonIndex + 1);
84
+ // Check if this is actually a package qualifier or just a colon in the name
85
+ // Package names should be valid identifiers (alphanumeric, dash, underscore)
86
+ // Also check that the remaining part doesn't have more colons
87
+ if (/^[a-zA-Z0-9_-]+$/.test(projectPart) &&
88
+ remainingPart.length > 0 &&
89
+ remainingPart.indexOf(':') === -1) {
90
+ result.package = projectPart;
91
+ workingRef = remainingPart;
92
+ }
93
+ else {
94
+ // Not a valid package qualifier, treat the whole thing as a reference
95
+ workingRef = ref;
96
+ }
97
+ }
98
+ // Check for symbolic references
99
+ if (workingRef === 'HEAD' || workingRef === '@HEAD') {
100
+ result.symbolic = 'HEAD';
101
+ return result;
102
+ }
103
+ if (workingRef === 'ROOT' || workingRef === '@ROOT') {
104
+ result.symbolic = 'ROOT';
105
+ return result;
106
+ }
107
+ // Check for relative references (e.g., HEAD^, @beta~2, HEAD^^)
108
+ // Handle multiple ^ characters
109
+ const multiCaretMatch = workingRef.match(/^(.+?)(\^+)$/);
110
+ if (multiCaretMatch) {
111
+ const [, base, carets] = multiCaretMatch;
112
+ result.relative = {
113
+ base: base, // Keep the original base including @ prefix
114
+ direction: '^',
115
+ count: carets.length
116
+ };
117
+ return result;
118
+ }
119
+ // Handle ^N or ~N format
120
+ const relativeMatch = workingRef.match(/^(.+?)([~^])(\d+)$/);
121
+ if (relativeMatch) {
122
+ const [, base, direction, countStr] = relativeMatch;
123
+ const count = parseInt(countStr, 10);
124
+ result.relative = {
125
+ base: base, // Keep the original base including @ prefix
126
+ direction: direction,
127
+ count
128
+ };
129
+ return result;
130
+ }
131
+ // Handle single ^ or ~ without number
132
+ const singleRelativeMatch = workingRef.match(/^(.+?)([~^])$/);
133
+ if (singleRelativeMatch) {
134
+ const [, base, direction] = singleRelativeMatch;
135
+ result.relative = {
136
+ base: base, // Keep the original base including @ prefix
137
+ direction: direction,
138
+ count: 1
139
+ };
140
+ return result;
141
+ }
142
+ // Check if it's a SHA1
143
+ if (isSHA1(workingRef)) {
144
+ result.sha1 = workingRef;
145
+ return result;
146
+ }
147
+ // Check for tag reference (starts with @)
148
+ if (workingRef.startsWith('@')) {
149
+ const tagName = workingRef.substring(1);
150
+ if (isValidTagName(tagName)) {
151
+ result.tag = tagName;
152
+ return result;
153
+ }
154
+ return null; // Invalid tag name
155
+ }
156
+ // Check for change@tag format
157
+ const atIndex = workingRef.indexOf('@');
158
+ if (atIndex > 0) {
159
+ const changeName = workingRef.substring(0, atIndex);
160
+ const tagName = workingRef.substring(atIndex + 1);
161
+ if (isValidChangeName(changeName) && isValidTagName(tagName)) {
162
+ result.change = changeName;
163
+ result.tag = tagName;
164
+ return result;
165
+ }
166
+ return null; // Invalid change or tag name
167
+ }
168
+ // Must be a plain change name
169
+ if (isValidChangeName(workingRef)) {
170
+ result.change = workingRef;
171
+ return result;
172
+ }
173
+ return null; // Invalid reference
174
+ }
175
+ /**
176
+ * Validate a dependency reference
177
+ * Dependencies can be any valid reference format
178
+ */
179
+ export function isValidDependency(dep) {
180
+ return parseReference(dep) !== null;
181
+ }