@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.
@@ -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
+ }
@@ -1 +1,2 @@
1
+ export * from './header';
1
2
  export * from './writer';
@@ -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
- for (const line of lines) {
340
- // Handle requires statements
341
- const requiresMatch = line.match(/^-- requires: (.*)/);
342
- if (requiresMatch) {
343
- const dep = requiresMatch[1].trim();
344
- // For 'preserve' mode, just add the dependency as-is (like original getDeps)
345
- if (tagResolution === 'preserve') {
346
- deps[key].push(dep);
347
- continue;
348
- }
349
- // For other modes, handle tag resolution
350
- if (dep.includes('@')) {
351
- const match = dep.match(/^([^:]+):@(.+)$/);
352
- if (match) {
353
- const [, projectName, tagName] = match;
354
- const taggedChange = resolveTagToChange(projectName, tagName);
355
- if (taggedChange) {
356
- if (tagResolution === 'resolve') {
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
- // Could not resolve tag, keep it as is
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
- // Invalid tag format, keep as is
365
+ // Could not resolve tag, keep it as is
374
366
  deps[key].push(dep);
375
367
  }
376
368
  }
377
369
  else {
378
- // Not a tag, keep as is
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
- m2 = line.match(/^-- Deploy (.*?)(?:\s+to\s+pg)?\s*$/);
408
- if (m2) {
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
  }
@@ -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, useTagsForCrossPackageDeps = false) {
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 (useTagsForCrossPackageDeps) {
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 || false);
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
+ }
@@ -1 +1,2 @@
1
+ export * from './header';
1
2
  export * from './writer';
@@ -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.32.1",
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.0",
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.3",
64
- "pgsql-parser": "^17.9.15",
63
+ "pgsql-deparser": "^17.18.5",
64
+ "pgsql-parser": "^17.9.17",
65
65
  "yanse": "^0.2.1"
66
66
  },
67
- "gitHead": "e53a570a4a70987e82dc074bd73501971fd82828"
67
+ "gitHead": "6a1d3e65a4a786e602efe5ec3835238eb1648734"
68
68
  }
@@ -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
- for (const line of lines) {
344
- // Handle requires statements
345
- const requiresMatch = line.match(/^-- requires: (.*)/);
346
- if (requiresMatch) {
347
- const dep = requiresMatch[1].trim();
348
- // For 'preserve' mode, just add the dependency as-is (like original getDeps)
349
- if (tagResolution === 'preserve') {
350
- deps[key].push(dep);
351
- continue;
352
- }
353
- // For other modes, handle tag resolution
354
- if (dep.includes('@')) {
355
- const match = dep.match(/^([^:]+):@(.+)$/);
356
- if (match) {
357
- const [, projectName, tagName] = match;
358
- const taggedChange = resolveTagToChange(projectName, tagName);
359
- if (taggedChange) {
360
- if (tagResolution === 'resolve') {
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
- // Could not resolve tag, keep it as is
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
- // Invalid tag format, keep as is
369
+ // Could not resolve tag, keep it as is
378
370
  deps[key].push(dep);
379
371
  }
380
372
  }
381
373
  else {
382
- // Not a tag, keep as is
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
- m2 = line.match(/^-- Deploy (.*?)(?:\s+to\s+pg)?\s*$/);
412
- if (m2) {
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>, useTagsForCrossPackageDeps?: boolean): PackageOutput;
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, useTagsForCrossPackageDeps = false) {
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 (useTagsForCrossPackageDeps) {
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 || false);
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
  */