@pgpmjs/core 6.32.1 → 6.33.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.
- package/esm/files/sql/header.js +259 -0
- package/esm/files/sql/index.js +1 -0
- package/esm/resolution/deps.js +26 -63
- package/esm/slice/slice.js +11 -3
- package/files/sql/header.d.ts +114 -0
- package/files/sql/header.js +266 -0
- package/files/sql/index.d.ts +1 -0
- package/files/sql/index.js +1 -0
- package/package.json +5 -5
- package/resolution/deps.js +26 -63
- package/slice/slice.d.ts +2 -2
- package/slice/slice.js +11 -3
- package/slice/types.d.ts +15 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { parsePlanFile } from '../plan/parser';
|
|
4
|
+
import { parseReference } from '../plan/validators';
|
|
5
|
+
const HEADER_LINE = /^--\s*(Deploy|Revert|Verify)\b(:)?\s*(.*?)\s*$/;
|
|
6
|
+
const REQUIRES_LINE = /^--\s*requires:\s*(.*?)\s*$/;
|
|
7
|
+
const COMMENT_LINE = /^\s*--/;
|
|
8
|
+
const TO_PG_SUFFIX = /\s+to\s+pg\s*$/;
|
|
9
|
+
/**
|
|
10
|
+
* Parse the header block of a pgpm SQL script into a structured model.
|
|
11
|
+
*
|
|
12
|
+
* Recognizes both header formats:
|
|
13
|
+
* `-- Deploy <project>:<change> to pg` (legacy sqitch-compatible)
|
|
14
|
+
* `-- Deploy <change>` / `-- Deploy: <change>` (new)
|
|
15
|
+
* along with `-- requires: <ref>` lines where `<ref>` may be a plain change
|
|
16
|
+
* name, `{package}:{change}`, `@tag`, or `{package}:@tag`.
|
|
17
|
+
*
|
|
18
|
+
* The parse is lossless: `header.lines` plus `body` reassemble the original
|
|
19
|
+
* content byte-exactly (see {@link writePgpmScript}).
|
|
20
|
+
*/
|
|
21
|
+
export function parsePgpmHeader(content) {
|
|
22
|
+
const header = {
|
|
23
|
+
verb: null,
|
|
24
|
+
project: null,
|
|
25
|
+
change: null,
|
|
26
|
+
toPg: false,
|
|
27
|
+
requires: [],
|
|
28
|
+
lines: []
|
|
29
|
+
};
|
|
30
|
+
const lines = content.split('\n');
|
|
31
|
+
let lastHeaderIdx = -1;
|
|
32
|
+
for (let i = 0; i < lines.length; i++) {
|
|
33
|
+
const line = lines[i];
|
|
34
|
+
if (line.trim() === '') {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!COMMENT_LINE.test(line)) {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
const headerMatch = line.match(HEADER_LINE);
|
|
41
|
+
if (headerMatch && header.verb === null) {
|
|
42
|
+
header.verb = headerMatch[1];
|
|
43
|
+
let target = headerMatch[3];
|
|
44
|
+
if (TO_PG_SUFFIX.test(target)) {
|
|
45
|
+
header.toPg = true;
|
|
46
|
+
target = target.replace(TO_PG_SUFFIX, '');
|
|
47
|
+
}
|
|
48
|
+
const colonIndex = target.indexOf(':');
|
|
49
|
+
if (colonIndex > 0 && /^[a-zA-Z0-9_-]+$/.test(target.substring(0, colonIndex))) {
|
|
50
|
+
header.project = target.substring(0, colonIndex);
|
|
51
|
+
header.change = target.substring(colonIndex + 1).trim();
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
header.change = target.trim() || null;
|
|
55
|
+
}
|
|
56
|
+
lastHeaderIdx = i;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const requiresMatch = line.match(REQUIRES_LINE);
|
|
60
|
+
if (requiresMatch) {
|
|
61
|
+
const raw = requiresMatch[1];
|
|
62
|
+
header.requires.push({ raw, ref: parseReference(raw) });
|
|
63
|
+
lastHeaderIdx = i;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// Other comment lines (e.g. attribution) before/between header lines are
|
|
67
|
+
// tolerated but only absorbed into the header when a later header line
|
|
68
|
+
// follows; trailing comments belong to the body.
|
|
69
|
+
}
|
|
70
|
+
header.lines = lines.slice(0, lastHeaderIdx + 1);
|
|
71
|
+
return {
|
|
72
|
+
header,
|
|
73
|
+
body: lines.slice(lastHeaderIdx + 1).join('\n')
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Serialize a header back to its raw lines. When the structured fields have
|
|
78
|
+
* been modified via {@link renameInHeader}, the raw lines already reflect the
|
|
79
|
+
* change; this simply joins header and body losslessly.
|
|
80
|
+
*/
|
|
81
|
+
export function writePgpmScript(script) {
|
|
82
|
+
if (script.header.lines.length === 0)
|
|
83
|
+
return script.body;
|
|
84
|
+
return script.header.lines.join('\n') + '\n' + script.body;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Rewrite change names in a script header according to a rename map
|
|
88
|
+
* (old change name -> new change name). Rewrites both the `-- Deploy` line's
|
|
89
|
+
* change identity and any `-- requires:` lines referencing renamed changes,
|
|
90
|
+
* preserving format (project qualifier, ` to pg` suffix, tag refs untouched).
|
|
91
|
+
*
|
|
92
|
+
* Returns the number of substitutions applied; mutates `script` in place.
|
|
93
|
+
*/
|
|
94
|
+
export function renameInHeader(script, renames) {
|
|
95
|
+
const { header } = script;
|
|
96
|
+
let count = 0;
|
|
97
|
+
const renameRef = (raw) => {
|
|
98
|
+
const ref = parseReference(raw);
|
|
99
|
+
if (!ref) {
|
|
100
|
+
const to = renames.get(raw);
|
|
101
|
+
return to ?? null;
|
|
102
|
+
}
|
|
103
|
+
if (ref.tag || ref.symbolic || ref.relative)
|
|
104
|
+
return null;
|
|
105
|
+
const change = ref.change;
|
|
106
|
+
if (!change)
|
|
107
|
+
return null;
|
|
108
|
+
const to = renames.get(change);
|
|
109
|
+
if (!to)
|
|
110
|
+
return null;
|
|
111
|
+
return ref.package ? `${ref.package}:${to}` : to;
|
|
112
|
+
};
|
|
113
|
+
header.lines = header.lines.map(line => {
|
|
114
|
+
const headerMatch = line.match(HEADER_LINE);
|
|
115
|
+
if (headerMatch && header.change && renames.has(header.change)) {
|
|
116
|
+
const to = renames.get(header.change);
|
|
117
|
+
const oldTarget = header.project ? `${header.project}:${header.change}` : header.change;
|
|
118
|
+
const newTarget = header.project ? `${header.project}:${to}` : to;
|
|
119
|
+
const rewritten = line.replace(oldTarget, newTarget);
|
|
120
|
+
if (rewritten !== line) {
|
|
121
|
+
header.change = to;
|
|
122
|
+
count++;
|
|
123
|
+
return rewritten;
|
|
124
|
+
}
|
|
125
|
+
return line;
|
|
126
|
+
}
|
|
127
|
+
const requiresMatch = line.match(REQUIRES_LINE);
|
|
128
|
+
if (requiresMatch) {
|
|
129
|
+
const raw = requiresMatch[1];
|
|
130
|
+
const renamed = renameRef(raw);
|
|
131
|
+
if (renamed !== null) {
|
|
132
|
+
count++;
|
|
133
|
+
return line.replace(raw, renamed);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return line;
|
|
137
|
+
});
|
|
138
|
+
header.requires = header.requires.map(req => {
|
|
139
|
+
const renamed = renameRef(req.raw);
|
|
140
|
+
if (renamed === null)
|
|
141
|
+
return req;
|
|
142
|
+
return { raw: renamed, ref: parseReference(renamed) };
|
|
143
|
+
});
|
|
144
|
+
return count;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Scan a deploy script the way dependency resolution historically has:
|
|
148
|
+
* line-by-line over the whole file, collecting `-- requires: <ref>` lines
|
|
149
|
+
* verbatim and validating any `-- Deploy <project>:<change>[ to pg]` line
|
|
150
|
+
* against the script's path identity and project name.
|
|
151
|
+
*
|
|
152
|
+
* Kept strictly line-regex-compatible with the original resolver behavior
|
|
153
|
+
* (requires lines are honored anywhere in the file, and the legacy
|
|
154
|
+
* `-- Deploy: <change>` colon form is not identity-validated).
|
|
155
|
+
*
|
|
156
|
+
* @throws when the Deploy line's project or change identity mismatches.
|
|
157
|
+
*/
|
|
158
|
+
export function scanDeployScript(content, options) {
|
|
159
|
+
const { key, extname, makeKey } = options;
|
|
160
|
+
const requires = [];
|
|
161
|
+
for (const line of content.split('\n')) {
|
|
162
|
+
const requiresMatch = line.match(/^-- requires: (.*)/);
|
|
163
|
+
if (requiresMatch) {
|
|
164
|
+
requires.push(requiresMatch[1].trim());
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (/:/.test(line)) {
|
|
168
|
+
const m2 = line.match(/^-- Deploy ([^:]*):([\w\/]+)(?:\s+to\s+pg)?/);
|
|
169
|
+
if (m2) {
|
|
170
|
+
const actualProject = m2[1];
|
|
171
|
+
const keyToTest = m2[2];
|
|
172
|
+
if (extname !== actualProject) {
|
|
173
|
+
throw new Error(`Mismatched project name in deploy file:
|
|
174
|
+
Expected project: ${extname}
|
|
175
|
+
Found in line : ${actualProject}
|
|
176
|
+
Line : ${line}`);
|
|
177
|
+
}
|
|
178
|
+
const expectedKey = makeKey(keyToTest);
|
|
179
|
+
if (key !== expectedKey) {
|
|
180
|
+
throw new Error(`Deployment script path or internal name mismatch:
|
|
181
|
+
Expected key : ${key}
|
|
182
|
+
Found in line : ${expectedKey}
|
|
183
|
+
Line : ${line}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
const m2 = line.match(/^-- Deploy (.*?)(?:\s+to\s+pg)?\s*$/);
|
|
189
|
+
if (m2) {
|
|
190
|
+
const keyToTest = m2[1].trim();
|
|
191
|
+
if (key !== makeKey(keyToTest)) {
|
|
192
|
+
throw new Error('deployment script in wrong place or is named wrong internally\n' + line);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { requires };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Verify that every change in a module's pgpm.plan has a deploy script whose
|
|
201
|
+
* header identity matches its path, and report dependency drift between the
|
|
202
|
+
* plan's dep lists and the scripts' `-- requires:` lines.
|
|
203
|
+
*
|
|
204
|
+
* Read-only diagnostic; returns issues rather than throwing.
|
|
205
|
+
*/
|
|
206
|
+
export function verifyPlanMatchesHeaders(moduleDir) {
|
|
207
|
+
const issues = [];
|
|
208
|
+
const planPath = join(moduleDir, 'pgpm.plan');
|
|
209
|
+
const parsed = parsePlanFile(planPath);
|
|
210
|
+
if (!parsed.data) {
|
|
211
|
+
throw new Error(`Failed to parse plan file: ${planPath}`);
|
|
212
|
+
}
|
|
213
|
+
for (const planChange of parsed.data.changes) {
|
|
214
|
+
const change = planChange.name;
|
|
215
|
+
const file = join(moduleDir, 'deploy', `${change}.sql`);
|
|
216
|
+
let content;
|
|
217
|
+
try {
|
|
218
|
+
content = readFileSync(file, 'utf-8');
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
issues.push({
|
|
222
|
+
change,
|
|
223
|
+
file,
|
|
224
|
+
kind: 'missing-file',
|
|
225
|
+
message: `deploy script not found for plan change "${change}"`
|
|
226
|
+
});
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const { header } = parsePgpmHeader(content);
|
|
230
|
+
if (header.change && header.change !== change) {
|
|
231
|
+
issues.push({
|
|
232
|
+
change,
|
|
233
|
+
file,
|
|
234
|
+
kind: 'identity-mismatch',
|
|
235
|
+
message: `header declares "${header.change}" but path identity is "${change}"`
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
const planDeps = new Set(planChange.dependencies || []);
|
|
239
|
+
const headerDeps = new Set(header.requires.map(r => r.raw));
|
|
240
|
+
const missingInHeader = [...planDeps].filter(d => !headerDeps.has(d));
|
|
241
|
+
const missingInPlan = [...headerDeps].filter(d => !planDeps.has(d));
|
|
242
|
+
if (missingInHeader.length > 0 || missingInPlan.length > 0) {
|
|
243
|
+
const parts = [];
|
|
244
|
+
if (missingInHeader.length > 0) {
|
|
245
|
+
parts.push(`in plan but not in header requires: ${missingInHeader.join(', ')}`);
|
|
246
|
+
}
|
|
247
|
+
if (missingInPlan.length > 0) {
|
|
248
|
+
parts.push(`in header requires but not in plan: ${missingInPlan.join(', ')}`);
|
|
249
|
+
}
|
|
250
|
+
issues.push({
|
|
251
|
+
change,
|
|
252
|
+
file,
|
|
253
|
+
kind: 'requires-drift',
|
|
254
|
+
message: parts.join('; ')
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return issues;
|
|
259
|
+
}
|
package/esm/files/sql/index.js
CHANGED
package/esm/resolution/deps.js
CHANGED
|
@@ -3,6 +3,7 @@ import { sync as glob } from 'glob';
|
|
|
3
3
|
import { join, relative } from 'path';
|
|
4
4
|
import { PgpmPackage } from '../core/class/pgpm';
|
|
5
5
|
import { parsePlanFile } from '../files/plan/parser';
|
|
6
|
+
import { scanDeployScript } from '../files/sql/header';
|
|
6
7
|
import { errors } from '@pgpmjs/types';
|
|
7
8
|
/**
|
|
8
9
|
* Core dependency resolution algorithm that handles circular dependency detection
|
|
@@ -333,84 +334,46 @@ export const resolveDependencies = (packageDir, extname, options = {}) => {
|
|
|
333
334
|
const files = glob(`${packageDir}/deploy/**/*.sql`).sort((a, b) => a.localeCompare(b));
|
|
334
335
|
for (const file of files) {
|
|
335
336
|
const data = readFileSync(file, 'utf-8');
|
|
336
|
-
const lines = data.split('\n');
|
|
337
337
|
const key = '/' + relative(packageDir, file);
|
|
338
338
|
deps[key] = [];
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
if (
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
// Full resolution: replace tag with actual change
|
|
358
|
-
const resolvedDep = `${projectName}:${taggedChange}`;
|
|
359
|
-
deps[key].push(resolvedDep);
|
|
360
|
-
}
|
|
361
|
-
else if (tagResolution === 'internal') {
|
|
362
|
-
// Internal resolution: keep tag in deps but track mapping
|
|
363
|
-
tagMappings[dep] = `${projectName}:${taggedChange}`;
|
|
364
|
-
deps[key].push(dep);
|
|
365
|
-
}
|
|
339
|
+
const { requires } = scanDeployScript(data, { key, extname, makeKey });
|
|
340
|
+
for (const dep of requires) {
|
|
341
|
+
// For 'preserve' mode, just add the dependency as-is (like original getDeps)
|
|
342
|
+
if (tagResolution === 'preserve') {
|
|
343
|
+
deps[key].push(dep);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
// For other modes, handle tag resolution
|
|
347
|
+
if (dep.includes('@')) {
|
|
348
|
+
const match = dep.match(/^([^:]+):@(.+)$/);
|
|
349
|
+
if (match) {
|
|
350
|
+
const [, projectName, tagName] = match;
|
|
351
|
+
const taggedChange = resolveTagToChange(projectName, tagName);
|
|
352
|
+
if (taggedChange) {
|
|
353
|
+
if (tagResolution === 'resolve') {
|
|
354
|
+
// Full resolution: replace tag with actual change
|
|
355
|
+
const resolvedDep = `${projectName}:${taggedChange}`;
|
|
356
|
+
deps[key].push(resolvedDep);
|
|
366
357
|
}
|
|
367
|
-
else {
|
|
368
|
-
//
|
|
358
|
+
else if (tagResolution === 'internal') {
|
|
359
|
+
// Internal resolution: keep tag in deps but track mapping
|
|
360
|
+
tagMappings[dep] = `${projectName}:${taggedChange}`;
|
|
369
361
|
deps[key].push(dep);
|
|
370
362
|
}
|
|
371
363
|
}
|
|
372
364
|
else {
|
|
373
|
-
//
|
|
365
|
+
// Could not resolve tag, keep it as is
|
|
374
366
|
deps[key].push(dep);
|
|
375
367
|
}
|
|
376
368
|
}
|
|
377
369
|
else {
|
|
378
|
-
//
|
|
370
|
+
// Invalid tag format, keep as is
|
|
379
371
|
deps[key].push(dep);
|
|
380
372
|
}
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
// Handle deploy statements - exactly as in original
|
|
384
|
-
let m2;
|
|
385
|
-
let keyToTest;
|
|
386
|
-
if (/:/.test(line)) {
|
|
387
|
-
m2 = line.match(/^-- Deploy ([^:]*):([\w\/]+)(?:\s+to\s+pg)?/);
|
|
388
|
-
if (m2) {
|
|
389
|
-
const actualProject = m2[1];
|
|
390
|
-
keyToTest = m2[2];
|
|
391
|
-
if (extname !== actualProject) {
|
|
392
|
-
throw new Error(`Mismatched project name in deploy file:
|
|
393
|
-
Expected project: ${extname}
|
|
394
|
-
Found in line : ${actualProject}
|
|
395
|
-
Line : ${line}`);
|
|
396
|
-
}
|
|
397
|
-
const expectedKey = makeKey(keyToTest);
|
|
398
|
-
if (key !== expectedKey) {
|
|
399
|
-
throw new Error(`Deployment script path or internal name mismatch:
|
|
400
|
-
Expected key : ${key}
|
|
401
|
-
Found in line : ${expectedKey}
|
|
402
|
-
Line : ${line}`);
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
373
|
}
|
|
406
374
|
else {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
keyToTest = m2[1].trim();
|
|
410
|
-
if (key !== makeKey(keyToTest)) {
|
|
411
|
-
throw new Error('deployment script in wrong place or is named wrong internally\n' + line);
|
|
412
|
-
}
|
|
413
|
-
}
|
|
375
|
+
// Not a tag, keep as is
|
|
376
|
+
deps[key].push(dep);
|
|
414
377
|
}
|
|
415
378
|
}
|
|
416
379
|
}
|
package/esm/slice/slice.js
CHANGED
|
@@ -340,7 +340,9 @@ export function generateControlContent(pkgName, deps) {
|
|
|
340
340
|
/**
|
|
341
341
|
* Generate a single package output
|
|
342
342
|
*/
|
|
343
|
-
export function generateSinglePackage(pkgName, changes, graph, changeToPackage, pkgDeps,
|
|
343
|
+
export function generateSinglePackage(pkgName, changes, graph, changeToPackage, pkgDeps, crossPackageDeps = 'change') {
|
|
344
|
+
// Back-compat: earlier signature took useTagsForCrossPackageDeps: boolean
|
|
345
|
+
const crossPackageDepMode = crossPackageDeps === true ? 'tag' : crossPackageDeps === false ? 'change' : crossPackageDeps;
|
|
344
346
|
// Sort changes in topological order within package
|
|
345
347
|
const sortedChanges = topologicalSortWithinPackage(changes, graph);
|
|
346
348
|
// Build plan entries with updated dependencies
|
|
@@ -361,7 +363,13 @@ export function generateSinglePackage(pkgName, changes, graph, changeToPackage,
|
|
|
361
363
|
}
|
|
362
364
|
else {
|
|
363
365
|
// Cross-package dependency
|
|
364
|
-
if (
|
|
366
|
+
if (crossPackageDepMode === 'control-only') {
|
|
367
|
+
// Dropped from the plan line; carried by the control file's
|
|
368
|
+
// `requires` (extension install ordering deploys the whole
|
|
369
|
+
// dependency package first)
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (crossPackageDepMode === 'tag') {
|
|
365
373
|
// Find latest tag in the dependency package
|
|
366
374
|
const depPkgChanges = [...graph.nodes.keys()]
|
|
367
375
|
.filter(c => changeToPackage.get(c) === depPkg);
|
|
@@ -461,7 +469,7 @@ export function slicePlan(config) {
|
|
|
461
469
|
const changes = assignments.get(pkgName);
|
|
462
470
|
if (!changes || changes.size === 0)
|
|
463
471
|
continue;
|
|
464
|
-
const pkgOutput = generateSinglePackage(pkgName, changes, graph, changeToPackage, packageDeps.get(pkgName) || new Set(), config.useTagsForCrossPackageDeps
|
|
472
|
+
const pkgOutput = generateSinglePackage(pkgName, changes, graph, changeToPackage, packageDeps.get(pkgName) || new Set(), config.crossPackageDepMode ?? (config.useTagsForCrossPackageDeps ? 'tag' : 'change'));
|
|
465
473
|
packages.push(pkgOutput);
|
|
466
474
|
// Check for heavy cross-package dependencies
|
|
467
475
|
let crossDeps = 0;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { ParsedReference } from '../plan/validators';
|
|
2
|
+
/**
|
|
3
|
+
* A parsed `-- requires:` line from a pgpm SQL script header.
|
|
4
|
+
*/
|
|
5
|
+
export interface PgpmHeaderRequire {
|
|
6
|
+
/** The dependency reference exactly as written (e.g. `schema`, `pkg:tables/users`, `pkg:@v1`). */
|
|
7
|
+
raw: string;
|
|
8
|
+
/** Structured breakdown of the reference (package/change/tag), when parseable. */
|
|
9
|
+
ref: ParsedReference | null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The parsed header block of a pgpm SQL script (deploy/revert/verify).
|
|
13
|
+
*
|
|
14
|
+
* The header is everything from the top of the file through the last
|
|
15
|
+
* header-relevant comment line (`-- Deploy|Revert|Verify ...`,
|
|
16
|
+
* `-- requires: ...`, and any interleaved comment/blank lines), with the
|
|
17
|
+
* remainder of the file preserved as `body`.
|
|
18
|
+
*/
|
|
19
|
+
export interface PgpmHeader {
|
|
20
|
+
/** Header verb: Deploy, Revert or Verify. Null when no header line was found. */
|
|
21
|
+
verb: 'Deploy' | 'Revert' | 'Verify' | null;
|
|
22
|
+
/** The project (extension) name from `-- Deploy <project>:<change>`, if present. */
|
|
23
|
+
project: string | null;
|
|
24
|
+
/**
|
|
25
|
+
* The change name from the header line. By convention this must equal the
|
|
26
|
+
* script's path identity: `deploy/<change>.sql`.
|
|
27
|
+
*/
|
|
28
|
+
change: string | null;
|
|
29
|
+
/** Whether the header line carried the legacy ` to pg` suffix. */
|
|
30
|
+
toPg: boolean;
|
|
31
|
+
/** Parsed `-- requires:` dependencies, in file order. */
|
|
32
|
+
requires: PgpmHeaderRequire[];
|
|
33
|
+
/** The raw header lines, byte-exact, in file order. */
|
|
34
|
+
lines: string[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Result of splitting a SQL script into header and body.
|
|
38
|
+
*/
|
|
39
|
+
export interface ParsedPgpmScript {
|
|
40
|
+
header: PgpmHeader;
|
|
41
|
+
/** Everything after the header block, byte-exact. */
|
|
42
|
+
body: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Parse the header block of a pgpm SQL script into a structured model.
|
|
46
|
+
*
|
|
47
|
+
* Recognizes both header formats:
|
|
48
|
+
* `-- Deploy <project>:<change> to pg` (legacy sqitch-compatible)
|
|
49
|
+
* `-- Deploy <change>` / `-- Deploy: <change>` (new)
|
|
50
|
+
* along with `-- requires: <ref>` lines where `<ref>` may be a plain change
|
|
51
|
+
* name, `{package}:{change}`, `@tag`, or `{package}:@tag`.
|
|
52
|
+
*
|
|
53
|
+
* The parse is lossless: `header.lines` plus `body` reassemble the original
|
|
54
|
+
* content byte-exactly (see {@link writePgpmScript}).
|
|
55
|
+
*/
|
|
56
|
+
export declare function parsePgpmHeader(content: string): ParsedPgpmScript;
|
|
57
|
+
/**
|
|
58
|
+
* Serialize a header back to its raw lines. When the structured fields have
|
|
59
|
+
* been modified via {@link renameInHeader}, the raw lines already reflect the
|
|
60
|
+
* change; this simply joins header and body losslessly.
|
|
61
|
+
*/
|
|
62
|
+
export declare function writePgpmScript(script: ParsedPgpmScript): string;
|
|
63
|
+
/**
|
|
64
|
+
* Rewrite change names in a script header according to a rename map
|
|
65
|
+
* (old change name -> new change name). Rewrites both the `-- Deploy` line's
|
|
66
|
+
* change identity and any `-- requires:` lines referencing renamed changes,
|
|
67
|
+
* preserving format (project qualifier, ` to pg` suffix, tag refs untouched).
|
|
68
|
+
*
|
|
69
|
+
* Returns the number of substitutions applied; mutates `script` in place.
|
|
70
|
+
*/
|
|
71
|
+
export declare function renameInHeader(script: ParsedPgpmScript, renames: Map<string, string>): number;
|
|
72
|
+
/**
|
|
73
|
+
* Options for {@link scanDeployScript}.
|
|
74
|
+
*/
|
|
75
|
+
export interface ScanDeployScriptOptions {
|
|
76
|
+
/** The script's path identity key (e.g. `/deploy/tables/users.sql`). */
|
|
77
|
+
key: string;
|
|
78
|
+
/** The current extension (project) name. */
|
|
79
|
+
extname: string;
|
|
80
|
+
/** Maps a change name to its path identity key. */
|
|
81
|
+
makeKey: (change: string) => string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Scan a deploy script the way dependency resolution historically has:
|
|
85
|
+
* line-by-line over the whole file, collecting `-- requires: <ref>` lines
|
|
86
|
+
* verbatim and validating any `-- Deploy <project>:<change>[ to pg]` line
|
|
87
|
+
* against the script's path identity and project name.
|
|
88
|
+
*
|
|
89
|
+
* Kept strictly line-regex-compatible with the original resolver behavior
|
|
90
|
+
* (requires lines are honored anywhere in the file, and the legacy
|
|
91
|
+
* `-- Deploy: <change>` colon form is not identity-validated).
|
|
92
|
+
*
|
|
93
|
+
* @throws when the Deploy line's project or change identity mismatches.
|
|
94
|
+
*/
|
|
95
|
+
export declare function scanDeployScript(content: string, options: ScanDeployScriptOptions): {
|
|
96
|
+
requires: string[];
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* A single plan-vs-header discrepancy found by {@link verifyPlanMatchesHeaders}.
|
|
100
|
+
*/
|
|
101
|
+
export interface HeaderDriftIssue {
|
|
102
|
+
change: string;
|
|
103
|
+
file: string;
|
|
104
|
+
kind: 'missing-file' | 'identity-mismatch' | 'requires-drift';
|
|
105
|
+
message: string;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Verify that every change in a module's pgpm.plan has a deploy script whose
|
|
109
|
+
* header identity matches its path, and report dependency drift between the
|
|
110
|
+
* plan's dep lists and the scripts' `-- requires:` lines.
|
|
111
|
+
*
|
|
112
|
+
* Read-only diagnostic; returns issues rather than throwing.
|
|
113
|
+
*/
|
|
114
|
+
export declare function verifyPlanMatchesHeaders(moduleDir: string): HeaderDriftIssue[];
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parsePgpmHeader = parsePgpmHeader;
|
|
4
|
+
exports.writePgpmScript = writePgpmScript;
|
|
5
|
+
exports.renameInHeader = renameInHeader;
|
|
6
|
+
exports.scanDeployScript = scanDeployScript;
|
|
7
|
+
exports.verifyPlanMatchesHeaders = verifyPlanMatchesHeaders;
|
|
8
|
+
const fs_1 = require("fs");
|
|
9
|
+
const path_1 = require("path");
|
|
10
|
+
const parser_1 = require("../plan/parser");
|
|
11
|
+
const validators_1 = require("../plan/validators");
|
|
12
|
+
const HEADER_LINE = /^--\s*(Deploy|Revert|Verify)\b(:)?\s*(.*?)\s*$/;
|
|
13
|
+
const REQUIRES_LINE = /^--\s*requires:\s*(.*?)\s*$/;
|
|
14
|
+
const COMMENT_LINE = /^\s*--/;
|
|
15
|
+
const TO_PG_SUFFIX = /\s+to\s+pg\s*$/;
|
|
16
|
+
/**
|
|
17
|
+
* Parse the header block of a pgpm SQL script into a structured model.
|
|
18
|
+
*
|
|
19
|
+
* Recognizes both header formats:
|
|
20
|
+
* `-- Deploy <project>:<change> to pg` (legacy sqitch-compatible)
|
|
21
|
+
* `-- Deploy <change>` / `-- Deploy: <change>` (new)
|
|
22
|
+
* along with `-- requires: <ref>` lines where `<ref>` may be a plain change
|
|
23
|
+
* name, `{package}:{change}`, `@tag`, or `{package}:@tag`.
|
|
24
|
+
*
|
|
25
|
+
* The parse is lossless: `header.lines` plus `body` reassemble the original
|
|
26
|
+
* content byte-exactly (see {@link writePgpmScript}).
|
|
27
|
+
*/
|
|
28
|
+
function parsePgpmHeader(content) {
|
|
29
|
+
const header = {
|
|
30
|
+
verb: null,
|
|
31
|
+
project: null,
|
|
32
|
+
change: null,
|
|
33
|
+
toPg: false,
|
|
34
|
+
requires: [],
|
|
35
|
+
lines: []
|
|
36
|
+
};
|
|
37
|
+
const lines = content.split('\n');
|
|
38
|
+
let lastHeaderIdx = -1;
|
|
39
|
+
for (let i = 0; i < lines.length; i++) {
|
|
40
|
+
const line = lines[i];
|
|
41
|
+
if (line.trim() === '') {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!COMMENT_LINE.test(line)) {
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
const headerMatch = line.match(HEADER_LINE);
|
|
48
|
+
if (headerMatch && header.verb === null) {
|
|
49
|
+
header.verb = headerMatch[1];
|
|
50
|
+
let target = headerMatch[3];
|
|
51
|
+
if (TO_PG_SUFFIX.test(target)) {
|
|
52
|
+
header.toPg = true;
|
|
53
|
+
target = target.replace(TO_PG_SUFFIX, '');
|
|
54
|
+
}
|
|
55
|
+
const colonIndex = target.indexOf(':');
|
|
56
|
+
if (colonIndex > 0 && /^[a-zA-Z0-9_-]+$/.test(target.substring(0, colonIndex))) {
|
|
57
|
+
header.project = target.substring(0, colonIndex);
|
|
58
|
+
header.change = target.substring(colonIndex + 1).trim();
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
header.change = target.trim() || null;
|
|
62
|
+
}
|
|
63
|
+
lastHeaderIdx = i;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const requiresMatch = line.match(REQUIRES_LINE);
|
|
67
|
+
if (requiresMatch) {
|
|
68
|
+
const raw = requiresMatch[1];
|
|
69
|
+
header.requires.push({ raw, ref: (0, validators_1.parseReference)(raw) });
|
|
70
|
+
lastHeaderIdx = i;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// Other comment lines (e.g. attribution) before/between header lines are
|
|
74
|
+
// tolerated but only absorbed into the header when a later header line
|
|
75
|
+
// follows; trailing comments belong to the body.
|
|
76
|
+
}
|
|
77
|
+
header.lines = lines.slice(0, lastHeaderIdx + 1);
|
|
78
|
+
return {
|
|
79
|
+
header,
|
|
80
|
+
body: lines.slice(lastHeaderIdx + 1).join('\n')
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Serialize a header back to its raw lines. When the structured fields have
|
|
85
|
+
* been modified via {@link renameInHeader}, the raw lines already reflect the
|
|
86
|
+
* change; this simply joins header and body losslessly.
|
|
87
|
+
*/
|
|
88
|
+
function writePgpmScript(script) {
|
|
89
|
+
if (script.header.lines.length === 0)
|
|
90
|
+
return script.body;
|
|
91
|
+
return script.header.lines.join('\n') + '\n' + script.body;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Rewrite change names in a script header according to a rename map
|
|
95
|
+
* (old change name -> new change name). Rewrites both the `-- Deploy` line's
|
|
96
|
+
* change identity and any `-- requires:` lines referencing renamed changes,
|
|
97
|
+
* preserving format (project qualifier, ` to pg` suffix, tag refs untouched).
|
|
98
|
+
*
|
|
99
|
+
* Returns the number of substitutions applied; mutates `script` in place.
|
|
100
|
+
*/
|
|
101
|
+
function renameInHeader(script, renames) {
|
|
102
|
+
const { header } = script;
|
|
103
|
+
let count = 0;
|
|
104
|
+
const renameRef = (raw) => {
|
|
105
|
+
const ref = (0, validators_1.parseReference)(raw);
|
|
106
|
+
if (!ref) {
|
|
107
|
+
const to = renames.get(raw);
|
|
108
|
+
return to ?? null;
|
|
109
|
+
}
|
|
110
|
+
if (ref.tag || ref.symbolic || ref.relative)
|
|
111
|
+
return null;
|
|
112
|
+
const change = ref.change;
|
|
113
|
+
if (!change)
|
|
114
|
+
return null;
|
|
115
|
+
const to = renames.get(change);
|
|
116
|
+
if (!to)
|
|
117
|
+
return null;
|
|
118
|
+
return ref.package ? `${ref.package}:${to}` : to;
|
|
119
|
+
};
|
|
120
|
+
header.lines = header.lines.map(line => {
|
|
121
|
+
const headerMatch = line.match(HEADER_LINE);
|
|
122
|
+
if (headerMatch && header.change && renames.has(header.change)) {
|
|
123
|
+
const to = renames.get(header.change);
|
|
124
|
+
const oldTarget = header.project ? `${header.project}:${header.change}` : header.change;
|
|
125
|
+
const newTarget = header.project ? `${header.project}:${to}` : to;
|
|
126
|
+
const rewritten = line.replace(oldTarget, newTarget);
|
|
127
|
+
if (rewritten !== line) {
|
|
128
|
+
header.change = to;
|
|
129
|
+
count++;
|
|
130
|
+
return rewritten;
|
|
131
|
+
}
|
|
132
|
+
return line;
|
|
133
|
+
}
|
|
134
|
+
const requiresMatch = line.match(REQUIRES_LINE);
|
|
135
|
+
if (requiresMatch) {
|
|
136
|
+
const raw = requiresMatch[1];
|
|
137
|
+
const renamed = renameRef(raw);
|
|
138
|
+
if (renamed !== null) {
|
|
139
|
+
count++;
|
|
140
|
+
return line.replace(raw, renamed);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return line;
|
|
144
|
+
});
|
|
145
|
+
header.requires = header.requires.map(req => {
|
|
146
|
+
const renamed = renameRef(req.raw);
|
|
147
|
+
if (renamed === null)
|
|
148
|
+
return req;
|
|
149
|
+
return { raw: renamed, ref: (0, validators_1.parseReference)(renamed) };
|
|
150
|
+
});
|
|
151
|
+
return count;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Scan a deploy script the way dependency resolution historically has:
|
|
155
|
+
* line-by-line over the whole file, collecting `-- requires: <ref>` lines
|
|
156
|
+
* verbatim and validating any `-- Deploy <project>:<change>[ to pg]` line
|
|
157
|
+
* against the script's path identity and project name.
|
|
158
|
+
*
|
|
159
|
+
* Kept strictly line-regex-compatible with the original resolver behavior
|
|
160
|
+
* (requires lines are honored anywhere in the file, and the legacy
|
|
161
|
+
* `-- Deploy: <change>` colon form is not identity-validated).
|
|
162
|
+
*
|
|
163
|
+
* @throws when the Deploy line's project or change identity mismatches.
|
|
164
|
+
*/
|
|
165
|
+
function scanDeployScript(content, options) {
|
|
166
|
+
const { key, extname, makeKey } = options;
|
|
167
|
+
const requires = [];
|
|
168
|
+
for (const line of content.split('\n')) {
|
|
169
|
+
const requiresMatch = line.match(/^-- requires: (.*)/);
|
|
170
|
+
if (requiresMatch) {
|
|
171
|
+
requires.push(requiresMatch[1].trim());
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (/:/.test(line)) {
|
|
175
|
+
const m2 = line.match(/^-- Deploy ([^:]*):([\w\/]+)(?:\s+to\s+pg)?/);
|
|
176
|
+
if (m2) {
|
|
177
|
+
const actualProject = m2[1];
|
|
178
|
+
const keyToTest = m2[2];
|
|
179
|
+
if (extname !== actualProject) {
|
|
180
|
+
throw new Error(`Mismatched project name in deploy file:
|
|
181
|
+
Expected project: ${extname}
|
|
182
|
+
Found in line : ${actualProject}
|
|
183
|
+
Line : ${line}`);
|
|
184
|
+
}
|
|
185
|
+
const expectedKey = makeKey(keyToTest);
|
|
186
|
+
if (key !== expectedKey) {
|
|
187
|
+
throw new Error(`Deployment script path or internal name mismatch:
|
|
188
|
+
Expected key : ${key}
|
|
189
|
+
Found in line : ${expectedKey}
|
|
190
|
+
Line : ${line}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
const m2 = line.match(/^-- Deploy (.*?)(?:\s+to\s+pg)?\s*$/);
|
|
196
|
+
if (m2) {
|
|
197
|
+
const keyToTest = m2[1].trim();
|
|
198
|
+
if (key !== makeKey(keyToTest)) {
|
|
199
|
+
throw new Error('deployment script in wrong place or is named wrong internally\n' + line);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { requires };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Verify that every change in a module's pgpm.plan has a deploy script whose
|
|
208
|
+
* header identity matches its path, and report dependency drift between the
|
|
209
|
+
* plan's dep lists and the scripts' `-- requires:` lines.
|
|
210
|
+
*
|
|
211
|
+
* Read-only diagnostic; returns issues rather than throwing.
|
|
212
|
+
*/
|
|
213
|
+
function verifyPlanMatchesHeaders(moduleDir) {
|
|
214
|
+
const issues = [];
|
|
215
|
+
const planPath = (0, path_1.join)(moduleDir, 'pgpm.plan');
|
|
216
|
+
const parsed = (0, parser_1.parsePlanFile)(planPath);
|
|
217
|
+
if (!parsed.data) {
|
|
218
|
+
throw new Error(`Failed to parse plan file: ${planPath}`);
|
|
219
|
+
}
|
|
220
|
+
for (const planChange of parsed.data.changes) {
|
|
221
|
+
const change = planChange.name;
|
|
222
|
+
const file = (0, path_1.join)(moduleDir, 'deploy', `${change}.sql`);
|
|
223
|
+
let content;
|
|
224
|
+
try {
|
|
225
|
+
content = (0, fs_1.readFileSync)(file, 'utf-8');
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
issues.push({
|
|
229
|
+
change,
|
|
230
|
+
file,
|
|
231
|
+
kind: 'missing-file',
|
|
232
|
+
message: `deploy script not found for plan change "${change}"`
|
|
233
|
+
});
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const { header } = parsePgpmHeader(content);
|
|
237
|
+
if (header.change && header.change !== change) {
|
|
238
|
+
issues.push({
|
|
239
|
+
change,
|
|
240
|
+
file,
|
|
241
|
+
kind: 'identity-mismatch',
|
|
242
|
+
message: `header declares "${header.change}" but path identity is "${change}"`
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
const planDeps = new Set(planChange.dependencies || []);
|
|
246
|
+
const headerDeps = new Set(header.requires.map(r => r.raw));
|
|
247
|
+
const missingInHeader = [...planDeps].filter(d => !headerDeps.has(d));
|
|
248
|
+
const missingInPlan = [...headerDeps].filter(d => !planDeps.has(d));
|
|
249
|
+
if (missingInHeader.length > 0 || missingInPlan.length > 0) {
|
|
250
|
+
const parts = [];
|
|
251
|
+
if (missingInHeader.length > 0) {
|
|
252
|
+
parts.push(`in plan but not in header requires: ${missingInHeader.join(', ')}`);
|
|
253
|
+
}
|
|
254
|
+
if (missingInPlan.length > 0) {
|
|
255
|
+
parts.push(`in header requires but not in plan: ${missingInPlan.join(', ')}`);
|
|
256
|
+
}
|
|
257
|
+
issues.push({
|
|
258
|
+
change,
|
|
259
|
+
file,
|
|
260
|
+
kind: 'requires-drift',
|
|
261
|
+
message: parts.join('; ')
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return issues;
|
|
266
|
+
}
|
package/files/sql/index.d.ts
CHANGED
package/files/sql/index.js
CHANGED
|
@@ -14,4 +14,5 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./header"), exports);
|
|
17
18
|
__exportStar(require("./writer"), exports);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pgpmjs/core",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.33.0",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "PGPM Package and Migration Tools",
|
|
6
6
|
"main": "index.js",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"@pgpmjs/logger": "^2.14.0",
|
|
53
53
|
"@pgpmjs/server-utils": "^3.15.3",
|
|
54
54
|
"@pgpmjs/types": "^2.35.1",
|
|
55
|
-
"csv-to-pg": "^3.21.
|
|
55
|
+
"csv-to-pg": "^3.21.1",
|
|
56
56
|
"genomic": "^5.6.2",
|
|
57
57
|
"glob": "^13.0.6",
|
|
58
58
|
"minimatch": "^10.2.5",
|
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
"pg": "^8.21.0",
|
|
61
61
|
"pg-cache": "^3.15.2",
|
|
62
62
|
"pg-env": "^1.18.1",
|
|
63
|
-
"pgsql-deparser": "^17.18.
|
|
64
|
-
"pgsql-parser": "^17.9.
|
|
63
|
+
"pgsql-deparser": "^17.18.5",
|
|
64
|
+
"pgsql-parser": "^17.9.17",
|
|
65
65
|
"yanse": "^0.2.1"
|
|
66
66
|
},
|
|
67
|
-
"gitHead": "
|
|
67
|
+
"gitHead": "6a1d3e65a4a786e602efe5ec3835238eb1648734"
|
|
68
68
|
}
|
package/resolution/deps.js
CHANGED
|
@@ -6,6 +6,7 @@ const glob_1 = require("glob");
|
|
|
6
6
|
const path_1 = require("path");
|
|
7
7
|
const pgpm_1 = require("../core/class/pgpm");
|
|
8
8
|
const parser_1 = require("../files/plan/parser");
|
|
9
|
+
const header_1 = require("../files/sql/header");
|
|
9
10
|
const types_1 = require("@pgpmjs/types");
|
|
10
11
|
/**
|
|
11
12
|
* Core dependency resolution algorithm that handles circular dependency detection
|
|
@@ -337,84 +338,46 @@ const resolveDependencies = (packageDir, extname, options = {}) => {
|
|
|
337
338
|
const files = (0, glob_1.sync)(`${packageDir}/deploy/**/*.sql`).sort((a, b) => a.localeCompare(b));
|
|
338
339
|
for (const file of files) {
|
|
339
340
|
const data = (0, fs_1.readFileSync)(file, 'utf-8');
|
|
340
|
-
const lines = data.split('\n');
|
|
341
341
|
const key = '/' + (0, path_1.relative)(packageDir, file);
|
|
342
342
|
deps[key] = [];
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if (
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
if (
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
// Full resolution: replace tag with actual change
|
|
362
|
-
const resolvedDep = `${projectName}:${taggedChange}`;
|
|
363
|
-
deps[key].push(resolvedDep);
|
|
364
|
-
}
|
|
365
|
-
else if (tagResolution === 'internal') {
|
|
366
|
-
// Internal resolution: keep tag in deps but track mapping
|
|
367
|
-
tagMappings[dep] = `${projectName}:${taggedChange}`;
|
|
368
|
-
deps[key].push(dep);
|
|
369
|
-
}
|
|
343
|
+
const { requires } = (0, header_1.scanDeployScript)(data, { key, extname, makeKey });
|
|
344
|
+
for (const dep of requires) {
|
|
345
|
+
// For 'preserve' mode, just add the dependency as-is (like original getDeps)
|
|
346
|
+
if (tagResolution === 'preserve') {
|
|
347
|
+
deps[key].push(dep);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
// For other modes, handle tag resolution
|
|
351
|
+
if (dep.includes('@')) {
|
|
352
|
+
const match = dep.match(/^([^:]+):@(.+)$/);
|
|
353
|
+
if (match) {
|
|
354
|
+
const [, projectName, tagName] = match;
|
|
355
|
+
const taggedChange = resolveTagToChange(projectName, tagName);
|
|
356
|
+
if (taggedChange) {
|
|
357
|
+
if (tagResolution === 'resolve') {
|
|
358
|
+
// Full resolution: replace tag with actual change
|
|
359
|
+
const resolvedDep = `${projectName}:${taggedChange}`;
|
|
360
|
+
deps[key].push(resolvedDep);
|
|
370
361
|
}
|
|
371
|
-
else {
|
|
372
|
-
//
|
|
362
|
+
else if (tagResolution === 'internal') {
|
|
363
|
+
// Internal resolution: keep tag in deps but track mapping
|
|
364
|
+
tagMappings[dep] = `${projectName}:${taggedChange}`;
|
|
373
365
|
deps[key].push(dep);
|
|
374
366
|
}
|
|
375
367
|
}
|
|
376
368
|
else {
|
|
377
|
-
//
|
|
369
|
+
// Could not resolve tag, keep it as is
|
|
378
370
|
deps[key].push(dep);
|
|
379
371
|
}
|
|
380
372
|
}
|
|
381
373
|
else {
|
|
382
|
-
//
|
|
374
|
+
// Invalid tag format, keep as is
|
|
383
375
|
deps[key].push(dep);
|
|
384
376
|
}
|
|
385
|
-
continue;
|
|
386
|
-
}
|
|
387
|
-
// Handle deploy statements - exactly as in original
|
|
388
|
-
let m2;
|
|
389
|
-
let keyToTest;
|
|
390
|
-
if (/:/.test(line)) {
|
|
391
|
-
m2 = line.match(/^-- Deploy ([^:]*):([\w\/]+)(?:\s+to\s+pg)?/);
|
|
392
|
-
if (m2) {
|
|
393
|
-
const actualProject = m2[1];
|
|
394
|
-
keyToTest = m2[2];
|
|
395
|
-
if (extname !== actualProject) {
|
|
396
|
-
throw new Error(`Mismatched project name in deploy file:
|
|
397
|
-
Expected project: ${extname}
|
|
398
|
-
Found in line : ${actualProject}
|
|
399
|
-
Line : ${line}`);
|
|
400
|
-
}
|
|
401
|
-
const expectedKey = makeKey(keyToTest);
|
|
402
|
-
if (key !== expectedKey) {
|
|
403
|
-
throw new Error(`Deployment script path or internal name mismatch:
|
|
404
|
-
Expected key : ${key}
|
|
405
|
-
Found in line : ${expectedKey}
|
|
406
|
-
Line : ${line}`);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
377
|
}
|
|
410
378
|
else {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
keyToTest = m2[1].trim();
|
|
414
|
-
if (key !== makeKey(keyToTest)) {
|
|
415
|
-
throw new Error('deployment script in wrong place or is named wrong internally\n' + line);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
379
|
+
// Not a tag, keep as is
|
|
380
|
+
deps[key].push(dep);
|
|
418
381
|
}
|
|
419
382
|
}
|
|
420
383
|
}
|
package/slice/slice.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Change, Tag, ExtendedPlanFile } from '../files/types';
|
|
2
|
-
import { SliceConfig, SliceResult, DependencyGraph, PackageOutput, GroupingStrategy, PatternStrategy } from './types';
|
|
2
|
+
import { SliceConfig, SliceResult, DependencyGraph, PackageOutput, GroupingStrategy, PatternStrategy, CrossPackageDepMode } from './types';
|
|
3
3
|
/**
|
|
4
4
|
* Build a dependency graph from a parsed plan file
|
|
5
5
|
*/
|
|
@@ -52,7 +52,7 @@ export declare function generateControlContent(pkgName: string, deps: Set<string
|
|
|
52
52
|
/**
|
|
53
53
|
* Generate a single package output
|
|
54
54
|
*/
|
|
55
|
-
export declare function generateSinglePackage(pkgName: string, changes: Set<string>, graph: DependencyGraph, changeToPackage: Map<string, string>, pkgDeps: Set<string>,
|
|
55
|
+
export declare function generateSinglePackage(pkgName: string, changes: Set<string>, graph: DependencyGraph, changeToPackage: Map<string, string>, pkgDeps: Set<string>, crossPackageDeps?: boolean | CrossPackageDepMode): PackageOutput;
|
|
56
56
|
/**
|
|
57
57
|
* Main slicing function
|
|
58
58
|
*/
|
package/slice/slice.js
CHANGED
|
@@ -356,7 +356,9 @@ function generateControlContent(pkgName, deps) {
|
|
|
356
356
|
/**
|
|
357
357
|
* Generate a single package output
|
|
358
358
|
*/
|
|
359
|
-
function generateSinglePackage(pkgName, changes, graph, changeToPackage, pkgDeps,
|
|
359
|
+
function generateSinglePackage(pkgName, changes, graph, changeToPackage, pkgDeps, crossPackageDeps = 'change') {
|
|
360
|
+
// Back-compat: earlier signature took useTagsForCrossPackageDeps: boolean
|
|
361
|
+
const crossPackageDepMode = crossPackageDeps === true ? 'tag' : crossPackageDeps === false ? 'change' : crossPackageDeps;
|
|
360
362
|
// Sort changes in topological order within package
|
|
361
363
|
const sortedChanges = topologicalSortWithinPackage(changes, graph);
|
|
362
364
|
// Build plan entries with updated dependencies
|
|
@@ -377,7 +379,13 @@ function generateSinglePackage(pkgName, changes, graph, changeToPackage, pkgDeps
|
|
|
377
379
|
}
|
|
378
380
|
else {
|
|
379
381
|
// Cross-package dependency
|
|
380
|
-
if (
|
|
382
|
+
if (crossPackageDepMode === 'control-only') {
|
|
383
|
+
// Dropped from the plan line; carried by the control file's
|
|
384
|
+
// `requires` (extension install ordering deploys the whole
|
|
385
|
+
// dependency package first)
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (crossPackageDepMode === 'tag') {
|
|
381
389
|
// Find latest tag in the dependency package
|
|
382
390
|
const depPkgChanges = [...graph.nodes.keys()]
|
|
383
391
|
.filter(c => changeToPackage.get(c) === depPkg);
|
|
@@ -477,7 +485,7 @@ function slicePlan(config) {
|
|
|
477
485
|
const changes = assignments.get(pkgName);
|
|
478
486
|
if (!changes || changes.size === 0)
|
|
479
487
|
continue;
|
|
480
|
-
const pkgOutput = generateSinglePackage(pkgName, changes, graph, changeToPackage, packageDeps.get(pkgName) || new Set(), config.useTagsForCrossPackageDeps
|
|
488
|
+
const pkgOutput = generateSinglePackage(pkgName, changes, graph, changeToPackage, packageDeps.get(pkgName) || new Set(), config.crossPackageDepMode ?? (config.useTagsForCrossPackageDeps ? 'tag' : 'change'));
|
|
481
489
|
packages.push(pkgOutput);
|
|
482
490
|
// Check for heavy cross-package dependencies
|
|
483
491
|
let crossDeps = 0;
|
package/slice/types.d.ts
CHANGED
|
@@ -13,11 +13,26 @@ export interface SliceConfig {
|
|
|
13
13
|
defaultPackage?: string;
|
|
14
14
|
/** Whether to use tags for cross-package deps */
|
|
15
15
|
useTagsForCrossPackageDeps?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* How cross-package dependencies are recorded in sliced plans:
|
|
18
|
+
* - 'change' (default): per-change refs (`{package}:{change}`)
|
|
19
|
+
* - 'tag': per-change tag refs (`{package}:@tag`) when available
|
|
20
|
+
* (equivalent to `useTagsForCrossPackageDeps: true`)
|
|
21
|
+
* - 'control-only': drop per-change cross-package refs from plan lines
|
|
22
|
+
* entirely; the dependency is carried only by the control file's
|
|
23
|
+
* `requires` (extension install ordering deploys the whole dependency
|
|
24
|
+
* package first, which subsumes any per-change ordering constraint)
|
|
25
|
+
*/
|
|
26
|
+
crossPackageDepMode?: CrossPackageDepMode;
|
|
16
27
|
/** Minimum changes per package (merge smaller groups) */
|
|
17
28
|
minChangesPerPackage?: number;
|
|
18
29
|
/** Author for generated plan files */
|
|
19
30
|
author?: string;
|
|
20
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* How cross-package dependencies are represented in sliced plan files
|
|
34
|
+
*/
|
|
35
|
+
export type CrossPackageDepMode = 'change' | 'tag' | 'control-only';
|
|
21
36
|
/**
|
|
22
37
|
* Grouping strategy for slicing
|
|
23
38
|
*/
|