@pgpmjs/core 6.32.2 → 6.34.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';
package/esm/index.js CHANGED
@@ -13,6 +13,7 @@ export * from './core/boilerplate-types';
13
13
  export * from './core/boilerplate-scanner';
14
14
  // Export package-files functionality (now integrated into core)
15
15
  export * from './files';
16
+ export * from './refactor';
16
17
  export { cleanSql } from './migrate/clean';
17
18
  export { PgpmMigrate } from './migrate/client';
18
19
  export { PgpmInit } from './init/client';
@@ -0,0 +1 @@
1
+ export * from './rename';
@@ -0,0 +1,200 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, statSync, writeFileSync } from 'fs';
2
+ import { dirname, join, relative, sep } from 'path';
3
+ import { parsePlanFileSimple } from '../files/plan/parser';
4
+ import { parsePgpmHeader, renameInHeader, writePgpmScript } from '../files/sql/header';
5
+ const SCRIPT_DIRS = ['deploy', 'revert', 'verify'];
6
+ function escapeRe(str) {
7
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
+ }
9
+ function findSqlFiles(dir) {
10
+ const files = [];
11
+ if (!existsSync(dir))
12
+ return files;
13
+ const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
14
+ for (const entry of entries) {
15
+ const fullPath = join(dir, entry.name);
16
+ if (entry.isDirectory()) {
17
+ files.push(...findSqlFiles(fullPath));
18
+ }
19
+ else if (entry.isFile() && entry.name.endsWith('.sql')) {
20
+ files.push(fullPath);
21
+ }
22
+ }
23
+ return files;
24
+ }
25
+ function removeEmptyDirs(dir, stopAt) {
26
+ let current = dir;
27
+ while (current !== stopAt && current.startsWith(stopAt)) {
28
+ if (!existsSync(current) || readdirSync(current).length > 0)
29
+ return;
30
+ rmdirSync(current);
31
+ current = dirname(current);
32
+ }
33
+ }
34
+ /**
35
+ * Validate a rename map against a module directory: every source change must
36
+ * exist in the plan, no target may collide with an existing change, and no
37
+ * two sources may map to the same target.
38
+ *
39
+ * @throws on any violation.
40
+ */
41
+ export function validateRenames(moduleDir, renames) {
42
+ const planPath = join(moduleDir, 'pgpm.plan');
43
+ const plan = parsePlanFileSimple(planPath);
44
+ const changeNames = new Set(plan.changes.map(c => c.name));
45
+ const targets = new Set();
46
+ for (const [from, to] of renames) {
47
+ if (from === to) {
48
+ throw new Error(`Rename maps change to itself: ${from}`);
49
+ }
50
+ if (!changeNames.has(from)) {
51
+ throw new Error(`Cannot rename unknown change: ${from}`);
52
+ }
53
+ if (changeNames.has(to) && !renames.has(to)) {
54
+ throw new Error(`Rename target already exists in plan: ${to}`);
55
+ }
56
+ if (targets.has(to)) {
57
+ throw new Error(`Multiple changes rename to the same target: ${to}`);
58
+ }
59
+ targets.add(to);
60
+ }
61
+ }
62
+ /**
63
+ * Rewrite change names in a pgpm.plan's content according to a rename map.
64
+ * Textual and format-preserving: only the change-name token at the start of a
65
+ * change line, plain (same-package) dependency refs inside `[...]`, and tag
66
+ * lines' change field are rewritten; timestamps, planners, comments,
67
+ * cross-package refs, and tag refs are untouched.
68
+ */
69
+ export function renameInPlanContent(content, renames) {
70
+ const froms = Array.from(renames.keys()).sort((a, b) => b.length - a.length);
71
+ return content
72
+ .split('\n')
73
+ .map(line => {
74
+ const trimmed = line.trim();
75
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('%'))
76
+ return line;
77
+ let result = line;
78
+ for (const from of froms) {
79
+ const to = renames.get(from);
80
+ const re = new RegExp(`(^|[\\s\\[])${escapeRe(from)}(?=$|[\\s\\]@])`, 'g');
81
+ result = result.replace(re, `$1${to}`);
82
+ }
83
+ return result;
84
+ })
85
+ .join('\n');
86
+ }
87
+ /**
88
+ * Atomically rename/move changes within a pgpm module: the coupled 4-way
89
+ * rewrite of (1) deploy/revert/verify script files on disk, (2) the moved
90
+ * scripts' own header identity, (3) `-- requires:` headers in every script
91
+ * that references a renamed change, and (4) the pgpm.plan.
92
+ *
93
+ * SQL statement bodies are NOT touched: schema-name rewrites inside SQL are a
94
+ * separate AST-level concern for callers to compose (e.g. transform passes).
95
+ */
96
+ export function renameChanges(moduleDir, renames, options = {}) {
97
+ const dryRun = options.dryRun ?? false;
98
+ const result = {
99
+ filesMoved: [],
100
+ headersRewritten: [],
101
+ planRewritten: false
102
+ };
103
+ if (dryRun)
104
+ result.dryRunReport = [];
105
+ if (renames.size === 0)
106
+ return result;
107
+ validateRenames(moduleDir, renames);
108
+ // 1. Move script files
109
+ for (const scriptDir of SCRIPT_DIRS) {
110
+ for (const [from, to] of renames) {
111
+ const oldPath = join(moduleDir, scriptDir, `${from}.sql`);
112
+ if (!existsSync(oldPath) || !statSync(oldPath).isFile())
113
+ continue;
114
+ const newPath = join(moduleDir, scriptDir, `${to}.sql`);
115
+ if (existsSync(newPath)) {
116
+ throw new Error(`Rename target file already exists: ${relative(moduleDir, newPath)}`);
117
+ }
118
+ if (dryRun) {
119
+ result.dryRunReport.push(`Would move: ${relative(moduleDir, oldPath)} -> ${relative(moduleDir, newPath)}`);
120
+ }
121
+ else {
122
+ mkdirSync(dirname(newPath), { recursive: true });
123
+ renameSync(oldPath, newPath);
124
+ removeEmptyDirs(dirname(oldPath), join(moduleDir, scriptDir));
125
+ }
126
+ result.filesMoved.push({
127
+ from: [scriptDir, `${from}.sql`].join(sep),
128
+ to: [scriptDir, `${to}.sql`].join(sep)
129
+ });
130
+ }
131
+ }
132
+ // 2 + 3. Rewrite headers in every script (identity of moved files,
133
+ // requires of dependents)
134
+ for (const scriptDir of SCRIPT_DIRS) {
135
+ const dir = join(moduleDir, scriptDir);
136
+ for (const file of findSqlFiles(dir)) {
137
+ const content = readFileSync(file, 'utf-8');
138
+ const script = parsePgpmHeader(content);
139
+ const count = renameInHeader(script, renames);
140
+ if (count === 0)
141
+ continue;
142
+ const rewritten = writePgpmScript(script);
143
+ if (rewritten === content)
144
+ continue;
145
+ if (dryRun) {
146
+ result.dryRunReport.push(`Would rewrite header: ${relative(moduleDir, file)}`);
147
+ }
148
+ else {
149
+ writeFileSync(file, rewritten);
150
+ }
151
+ result.headersRewritten.push(relative(moduleDir, file));
152
+ }
153
+ }
154
+ // In dry-run mode files were not moved, so dependents' headers are found at
155
+ // their original paths above; the moved files' own headers are still
156
+ // reported through the same scan.
157
+ // 4. Rewrite pgpm.plan
158
+ const planPath = join(moduleDir, 'pgpm.plan');
159
+ const planContent = readFileSync(planPath, 'utf-8');
160
+ const newPlanContent = renameInPlanContent(planContent, renames);
161
+ if (newPlanContent !== planContent) {
162
+ if (dryRun) {
163
+ result.dryRunReport.push('Would rewrite: pgpm.plan');
164
+ }
165
+ else {
166
+ writeFileSync(planPath, newPlanContent);
167
+ }
168
+ result.planRewritten = true;
169
+ }
170
+ return result;
171
+ }
172
+ /**
173
+ * Derive a change rename map from a schema mapping (old schema name -> new
174
+ * schema name) by rewriting the `schemas/<name>/` path segment of every plan
175
+ * change that lives under a renamed schema.
176
+ *
177
+ * This is the module-level counterpart of directory-based schema renames:
178
+ * `schemas/my-app/tables/users` with mapping `my-app -> my_app` yields
179
+ * `schemas/my_app/tables/users`.
180
+ */
181
+ export function deriveRenamesFromSchemaMapping(moduleDir, schemaMapping) {
182
+ const renames = new Map();
183
+ const planPath = join(moduleDir, 'pgpm.plan');
184
+ const plan = parsePlanFileSimple(planPath);
185
+ const schemas = Array.from(schemaMapping.keys()).sort((a, b) => b.length - a.length);
186
+ for (const change of plan.changes) {
187
+ let renamed = change.name;
188
+ for (const schema of schemas) {
189
+ const to = schemaMapping.get(schema);
190
+ if (!to || to === schema)
191
+ continue;
192
+ const re = new RegExp(`(^|/)(schemas/)${escapeRe(schema)}(/|$)`, 'g');
193
+ renamed = renamed.replace(re, `$1$2${to}$3`);
194
+ }
195
+ if (renamed !== change.name) {
196
+ renames.set(change.name, renamed);
197
+ }
198
+ }
199
+ return renames;
200
+ }
@@ -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;