@pgpmjs/transform 0.21.0 → 0.23.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,23 @@
1
+ /**
2
+ * Byte-correct statement slicing.
3
+ *
4
+ * libpg-query reports statement offsets (`stmt_location` / `stmt_len`, and the
5
+ * `span` that `@pgsql/semantics` derives from them) as UTF-8 **byte** offsets,
6
+ * but JavaScript string indexing is UTF-16. The two agree only while a script
7
+ * is pure ASCII; a single non-ASCII byte (an em dash in a comment, an accented
8
+ * word in a seed row, an emoji) shifts every later byte offset past its JS
9
+ * index, so slicing the source string directly with `String.prototype.slice`
10
+ * mis-cuts every statement that follows it — e.g. `COMMENT ON ...` reappears as
11
+ * `MMENT ON ...` and fails to reparse.
12
+ *
13
+ * Slice on the UTF-8 bytes instead and decode back to text. Statement offsets
14
+ * always fall on character boundaries, so decoding a byte range never splits a
15
+ * multibyte character.
16
+ */
17
+ export declare const sqlSourceBytes: (source: string) => Buffer;
18
+ /**
19
+ * Slice a statement's verbatim text out of the source bytes by its byte-offset
20
+ * span. `len === undefined` slices to the end of the source (libpg-query omits
21
+ * `stmt_len` for the final statement).
22
+ */
23
+ export declare const sliceStatementBytes: (sourceBytes: Buffer, start: number, len?: number) => string;
package/byte-slice.js ADDED
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sliceStatementBytes = exports.sqlSourceBytes = void 0;
4
+ /**
5
+ * Byte-correct statement slicing.
6
+ *
7
+ * libpg-query reports statement offsets (`stmt_location` / `stmt_len`, and the
8
+ * `span` that `@pgsql/semantics` derives from them) as UTF-8 **byte** offsets,
9
+ * but JavaScript string indexing is UTF-16. The two agree only while a script
10
+ * is pure ASCII; a single non-ASCII byte (an em dash in a comment, an accented
11
+ * word in a seed row, an emoji) shifts every later byte offset past its JS
12
+ * index, so slicing the source string directly with `String.prototype.slice`
13
+ * mis-cuts every statement that follows it — e.g. `COMMENT ON ...` reappears as
14
+ * `MMENT ON ...` and fails to reparse.
15
+ *
16
+ * Slice on the UTF-8 bytes instead and decode back to text. Statement offsets
17
+ * always fall on character boundaries, so decoding a byte range never splits a
18
+ * multibyte character.
19
+ */
20
+ const sqlSourceBytes = (source) => Buffer.from(source, 'utf8');
21
+ exports.sqlSourceBytes = sqlSourceBytes;
22
+ /**
23
+ * Slice a statement's verbatim text out of the source bytes by its byte-offset
24
+ * span. `len === undefined` slices to the end of the source (libpg-query omits
25
+ * `stmt_len` for the final statement).
26
+ */
27
+ const sliceStatementBytes = (sourceBytes, start, len) => {
28
+ const end = len === undefined ? sourceBytes.length : start + len;
29
+ return sourceBytes.subarray(start, end).toString('utf8');
30
+ };
31
+ exports.sliceStatementBytes = sliceStatementBytes;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Byte-correct statement slicing.
3
+ *
4
+ * libpg-query reports statement offsets (`stmt_location` / `stmt_len`, and the
5
+ * `span` that `@pgsql/semantics` derives from them) as UTF-8 **byte** offsets,
6
+ * but JavaScript string indexing is UTF-16. The two agree only while a script
7
+ * is pure ASCII; a single non-ASCII byte (an em dash in a comment, an accented
8
+ * word in a seed row, an emoji) shifts every later byte offset past its JS
9
+ * index, so slicing the source string directly with `String.prototype.slice`
10
+ * mis-cuts every statement that follows it — e.g. `COMMENT ON ...` reappears as
11
+ * `MMENT ON ...` and fails to reparse.
12
+ *
13
+ * Slice on the UTF-8 bytes instead and decode back to text. Statement offsets
14
+ * always fall on character boundaries, so decoding a byte range never splits a
15
+ * multibyte character.
16
+ */
17
+ export const sqlSourceBytes = (source) => Buffer.from(source, 'utf8');
18
+ /**
19
+ * Slice a statement's verbatim text out of the source bytes by its byte-offset
20
+ * span. `len === undefined` slices to the end of the source (libpg-query omits
21
+ * `stmt_len` for the final statement).
22
+ */
23
+ export const sliceStatementBytes = (sourceBytes, start, len) => {
24
+ const end = len === undefined ? sourceBytes.length : start + len;
25
+ return sourceBytes.subarray(start, end).toString('utf8');
26
+ };
@@ -2,6 +2,7 @@ import { pathFor } from '@pgpmjs/naming-spec';
2
2
  import { revertFor, verifyFor } from '@pgsql/scripts';
3
3
  import { classifyStatements } from '@pgsql/semantics';
4
4
  import { buildStatementGraph, identityOf, restructureSql } from '@pgsql/transform';
5
+ import { sliceStatementBytes, sqlSourceBytes } from './byte-slice';
5
6
  import { nameUnnamedConstraints, subObjectIdentityOf } from './sub-object';
6
7
  export const CHANGE_GRANULARITIES = ['alteration', 'object', 'single'];
7
8
  export const isChangeGranularity = (value) => CHANGE_GRANULARITIES.includes(value);
@@ -53,12 +54,13 @@ export function restructureChanges(changes, options) {
53
54
  // target (creates[0]), so a table's CREATE and its remaining ALTERs land
54
55
  // in the same change regardless of statement kind.
55
56
  const facts = classifyStatements(sql);
57
+ const sqlBytes = sqlSourceBytes(sql);
56
58
  if (options.changeGranularity === 'single') {
57
59
  const name = options.singleChangeName ?? 'module/init';
58
60
  const inputNames = new Set(changes.map(c => c.name));
59
61
  const dependencies = [...new Set(changes.flatMap(c => c.dependencies).filter(d => !inputNames.has(d)))].sort();
60
62
  const statements = facts
61
- .map(f => sql.slice(f.span.start, f.span.start + f.span.len).trim())
63
+ .map(f => sliceStatementBytes(sqlBytes, f.span.start, f.span.len).trim())
62
64
  .filter(Boolean)
63
65
  .map(text => (text.endsWith(';') ? text : `${text};`));
64
66
  const revert = revertFor(facts);
@@ -137,7 +139,7 @@ export function restructureChanges(changes, options) {
137
139
  const groupSql = groupNames.map(() => []);
138
140
  const groupStatements = groupNames.map(() => []);
139
141
  facts.forEach((f, i) => {
140
- const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
142
+ const text = sliceStatementBytes(sqlBytes, f.span.start, f.span.len).trim();
141
143
  const g = groupOf[i];
142
144
  if (g !== -1 && text) {
143
145
  groupSql[g].push(text.endsWith(';') ? text : `${text};`);
package/esm/index.js CHANGED
@@ -11,7 +11,7 @@ export { PartitionCycleError, partitionUnits, RESIDUAL_UNIT_PATH, } from './part
11
11
  export { emitSqlProgram, parseSqlProgram } from './program';
12
12
  export { classifyScript, isStubScript, regenerateScripts, } from './regen';
13
13
  export { EXPORT_GRANULARITIES, isExportGranularity, restructureExportRows } from './restructure';
14
- export { diffChangeSets, diffSchemas, } from './semantic-diff-driver';
14
+ export { coverChanges, diffChangeSets, diffSchemas, } from './semantic-diff-driver';
15
15
  export { nameUnnamedConstraints, subObjectIdentityOf } from './sub-object';
16
16
  export * from '@pgsql/transform';
17
17
  export { loadModule } from 'plpgsql-parser';
@@ -1,10 +1,4 @@
1
- /**
2
- * Module ingestion for the dials pipeline: load an on-disk pgpm module into
3
- * the flattened, classified change list the transform/partition drivers
4
- * consume. `pgpm transform` uses this today; a future `pgpm import` (dumps)
5
- * and `pgpm diff` are expected to reuse the same seam.
6
- */
7
- import { parsePgpmHeader, parsePlanFile, readScript } from '@pgpmjs/ast';
1
+ import { parsePgpmHeader, parsePlanFile, readScript, resolveReference } from '@pgpmjs/ast';
8
2
  import * as path from 'path';
9
3
  const TX_LINE = /^\s*(BEGIN|COMMIT|ROLLBACK)\s*;\s*$/i;
10
4
  /**
@@ -17,22 +11,64 @@ export const stripTransactionWrapper = (sql) => sql
17
11
  .filter(line => !TX_LINE.test(line))
18
12
  .join('\n')
19
13
  .trim();
14
+ const CROSS_PACKAGE_TAG = /^([^:]+):@(.+)$/;
15
+ /**
16
+ * Resolve a plan dependency token to a concrete change identity.
17
+ *
18
+ * Non-tag tokens (a bare change name, or a `pkg:change` cross-package name)
19
+ * pass through unchanged — they are already the identity the ledger and
20
+ * generated plans record. A tag token is resolved so a dependency written
21
+ * against a tag is never re-emitted verbatim into a generated plan or a
22
+ * ledger row:
23
+ * - a local tag (`@tag`) resolves to its change name via this plan;
24
+ * - a cross-package tag (`pkg:@tag`) resolves to the canonical `pkg:change`
25
+ * qualified name via `crossPackagePlans[pkg]` when available.
26
+ *
27
+ * Anything that cannot be resolved (unknown tag, or a cross-package tag with
28
+ * no plan in context) is kept verbatim — never dropped — with a warning.
29
+ */
30
+ const resolveDependencyToken = (token, plan, warnings, changeName, crossPackagePlans) => {
31
+ if (!token.includes('@'))
32
+ return token;
33
+ const cross = token.match(CROSS_PACKAGE_TAG);
34
+ if (cross && cross[1] !== plan.package) {
35
+ const pkg = cross[1];
36
+ const otherPlan = crossPackagePlans?.get(pkg);
37
+ if (!otherPlan) {
38
+ warnings.push(`${changeName}: dependency tag "${token}" is cross-package; left unresolved (no plan for "${pkg}" in context)`);
39
+ return token;
40
+ }
41
+ const resolved = resolveReference(token, otherPlan, pkg);
42
+ if (resolved.error || !resolved.change) {
43
+ warnings.push(`${changeName}: could not resolve cross-package dependency tag "${token}" (${resolved.error ?? 'no matching change'}); left as-is`);
44
+ return token;
45
+ }
46
+ return `${pkg}:${resolved.change}`;
47
+ }
48
+ const resolved = resolveReference(token, plan, plan.package);
49
+ if (resolved.error || !resolved.change) {
50
+ warnings.push(`${changeName}: could not resolve dependency tag "${token}" (${resolved.error ?? 'no matching change'}); left as-is`);
51
+ return token;
52
+ }
53
+ return resolved.change;
54
+ };
20
55
  /**
21
56
  * Load a pgpm module from disk: parse pgpm.plan and read each change's
22
57
  * deploy script, stripping pgpm headers and transaction wrappers so the
23
58
  * result can be flattened straight into the dials pipeline
24
59
  * (`restructureChanges` / `partitionUnits`).
25
60
  */
26
- export const loadModuleSource = (moduleDir) => {
61
+ export const loadModuleSource = (moduleDir, options = {}) => {
27
62
  const modulePath = path.resolve(moduleDir);
28
63
  const planResult = parsePlanFile(path.join(modulePath, 'pgpm.plan'));
29
64
  if (!planResult.data) {
30
65
  const reasons = planResult.errors.map(e => e.message).join(', ');
31
66
  throw new Error(`Failed to parse pgpm.plan in ${modulePath}: ${reasons}`);
32
67
  }
68
+ const plan = planResult.data;
33
69
  const warnings = [];
34
70
  const changes = [];
35
- for (const change of planResult.data.changes) {
71
+ for (const change of plan.changes) {
36
72
  const raw = readScript(modulePath, 'deploy', change.name);
37
73
  if (!raw) {
38
74
  warnings.push(`${change.name}: no deploy script found, skipping`);
@@ -41,7 +77,7 @@ export const loadModuleSource = (moduleDir) => {
41
77
  const { body } = parsePgpmHeader(raw);
42
78
  changes.push({
43
79
  name: change.name,
44
- dependencies: change.dependencies ?? [],
80
+ dependencies: (change.dependencies ?? []).map(dep => resolveDependencyToken(dep, plan, warnings, change.name, options.crossPackagePlans)),
45
81
  deploy: stripTransactionWrapper(body)
46
82
  });
47
83
  }
@@ -23,6 +23,7 @@
23
23
  import { pathFor } from '@pgpmjs/naming-spec';
24
24
  import { classifyStatements } from '@pgsql/semantics';
25
25
  import { buildStatementGraph, identityOf } from '@pgsql/transform';
26
+ import { sliceStatementBytes, sqlSourceBytes } from './byte-slice';
26
27
  /** A partition whose packages require each other in a cycle — unshippable. */
27
28
  export class PartitionCycleError extends Error {
28
29
  packages;
@@ -81,6 +82,7 @@ export const RESIDUAL_UNIT_PATH = 'misc/statements';
81
82
  */
82
83
  function buildUnitGraph(sql, style, splitRiders) {
83
84
  const facts = classifyStatements(sql);
85
+ const sqlBytes = sqlSourceBytes(sql);
84
86
  const graph = buildStatementGraph(facts);
85
87
  const unitOf = new Array(facts.length).fill(-1);
86
88
  const units = [];
@@ -163,7 +165,7 @@ function buildUnitGraph(sql, style, splitRiders) {
163
165
  unitOf[i] = unitFor(null, f.kind);
164
166
  });
165
167
  facts.forEach((f, i) => {
166
- const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
168
+ const text = sliceStatementBytes(sqlBytes, f.span.start, f.span.len).trim();
167
169
  if (!text)
168
170
  return;
169
171
  const unit = units[unitOf[i]];
package/esm/program.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { classifyStatements } from '@pgsql/semantics';
2
2
  import { Deparser, parseSql } from 'plpgsql-parser';
3
+ import { sliceStatementBytes, sqlSourceBytes } from './byte-slice';
3
4
  /**
4
5
  * Parse a SQL script into a statement program. Requires `loadModule()` from
5
6
  * `plpgsql-parser` to have completed.
@@ -8,6 +9,7 @@ export function parseSqlProgram(source) {
8
9
  const parseResult = parseSql(source);
9
10
  const stmts = parseResult?.stmts ?? [];
10
11
  const facts = classifyStatements(source);
12
+ const sourceBytes = sqlSourceBytes(source);
11
13
  const statements = [];
12
14
  for (let i = 0; i < stmts.length; i++) {
13
15
  const rawStmt = stmts[i];
@@ -18,7 +20,7 @@ export function parseSqlProgram(source) {
18
20
  stmt: rawStmt.stmt,
19
21
  facts: facts[i],
20
22
  span: { start, len },
21
- raw: source.slice(start, start + len),
23
+ raw: sliceStatementBytes(sourceBytes, start, len),
22
24
  dirty: false
23
25
  });
24
26
  }
@@ -34,16 +36,22 @@ export function emitSqlProgram(program) {
34
36
  const { source, statements } = program;
35
37
  if (!statements.some(s => s.dirty))
36
38
  return source;
39
+ // Spans are UTF-8 byte offsets; slice statements and the verbatim gaps
40
+ // between them on the bytes so a multibyte character never mis-cuts the
41
+ // reassembly. Statement boundaries fall on character boundaries.
42
+ const sourceBytes = sqlSourceBytes(source);
37
43
  const pieces = [];
38
44
  let cursor = 0;
39
45
  for (const statement of statements) {
40
46
  const { start, len } = statement.span;
41
47
  if (start > cursor)
42
- pieces.push(source.slice(cursor, start));
43
- pieces.push(statement.dirty ? Deparser.deparse(statement.stmt) : source.slice(start, start + len));
48
+ pieces.push(sliceStatementBytes(sourceBytes, cursor, start - cursor));
49
+ pieces.push(statement.dirty
50
+ ? Deparser.deparse(statement.stmt)
51
+ : sliceStatementBytes(sourceBytes, start, len));
44
52
  cursor = start + len;
45
53
  }
46
- if (cursor < source.length)
47
- pieces.push(source.slice(cursor));
54
+ if (cursor < sourceBytes.length)
55
+ pieces.push(sliceStatementBytes(sourceBytes, cursor));
48
56
  return pieces.join('');
49
57
  }
@@ -31,6 +31,7 @@ import { existenceCheck, invertStatement, revertFor, verifyFor } from '@pgsql/sc
31
31
  import { classifyStatements } from '@pgsql/semantics';
32
32
  import { buildStatementGraph, cleanTree, identityOf } from '@pgsql/transform';
33
33
  import { Deparser, parseSync } from 'plpgsql-parser';
34
+ import { sliceStatementBytes, sqlSourceBytes } from './byte-slice';
34
35
  import { defaultConstraintName } from './constraint-names';
35
36
  import { restructureChanges } from './granularity-driver';
36
37
  const identityKey = (id) => `${id.kind}\u0000${id.schema ?? ''}\u0000${id.table ?? ''}\u0000${id.name}`;
@@ -47,8 +48,9 @@ function readSide(sql, warnings, label) {
47
48
  const order = [];
48
49
  const unitOf = new Array(facts.length).fill(null);
49
50
  const byObject = new Map();
51
+ const sqlBytes = sqlSourceBytes(sql);
50
52
  facts.forEach((f, i) => {
51
- const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
53
+ const text = sliceStatementBytes(sqlBytes, f.span.start, f.span.len).trim();
52
54
  if (!text)
53
55
  return;
54
56
  let identity = groupingIdentity(f);
@@ -502,3 +504,38 @@ export function diffChangeSets(from, to, options = {}) {
502
504
  const flatten = (changes) => changes.map(c => c.deploy.trim()).filter(Boolean).join('\n\n');
503
505
  return diffSchemas(flatten(from), flatten(to), options);
504
506
  }
507
+ /**
508
+ * Cover each `to`-side change against a semantic diff's per-object delta:
509
+ * which of the objects the change touches are already present and identical
510
+ * on the `from` side. A `satisfied` change deploys nothing new — its ledger
511
+ * entry can be backfilled instead of executing it; an `unsatisfied` change is
512
+ * the genuine delta. Identity keying is shared with the diff itself, so
513
+ * regrouped/renamed/reordered authorship of the same objects covers the same.
514
+ *
515
+ * `diff` must come from diffing the same `from` side against these changes
516
+ * (`diffChangeSets(fromChanges, toChanges)`).
517
+ */
518
+ export function coverChanges(changes, diff, options = {}) {
519
+ const style = options.style ?? 'directory';
520
+ const deltaByKey = new Map();
521
+ for (const obj of diff.objects) {
522
+ deltaByKey.set(identityKey(obj.identity), obj.delta);
523
+ }
524
+ return changes.map(change => {
525
+ const sideWarnings = [];
526
+ const side = readSide(change.deploy, sideWarnings, change.name);
527
+ const objects = side.order.map(key => {
528
+ const unit = side.units.get(key);
529
+ return {
530
+ path: pathFor(unit.identity, { style }),
531
+ delta: deltaByKey.get(key) ?? 'unchanged'
532
+ };
533
+ });
534
+ const touched = objects.filter(o => o.delta !== 'unchanged').length;
535
+ const status = objects.length === 0 ? 'inert'
536
+ : touched === 0 ? 'satisfied'
537
+ : touched === objects.length ? 'unsatisfied'
538
+ : 'partial';
539
+ return { name: change.name, status, objects };
540
+ });
541
+ }
package/esm/sub-object.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Deparser, parseSync } from 'plpgsql-parser';
2
+ import { sliceStatementBytes, sqlSourceBytes } from './byte-slice';
2
3
  import { defaultConstraintName } from './constraint-names';
3
4
  /** The single AlterTableCmd of a one-command ALTER TABLE, or null. */
4
5
  function singleCmd(stmt) {
@@ -46,6 +47,7 @@ export function subObjectIdentityOf(facts) {
46
47
  */
47
48
  export function nameUnnamedConstraints(sql) {
48
49
  const parsed = parseSync(sql);
50
+ const sqlBytes = sqlSourceBytes(sql);
49
51
  const pieces = [];
50
52
  for (const raw of parsed.sql.stmts) {
51
53
  const node = raw.stmt;
@@ -71,7 +73,7 @@ export function nameUnnamedConstraints(sql) {
71
73
  else {
72
74
  const start = raw.stmt_location ?? 0;
73
75
  const len = raw.stmt_len;
74
- const text = len === undefined ? sql.slice(start) : sql.slice(start, start + len);
76
+ const text = sliceStatementBytes(sqlBytes, start, len);
75
77
  const trimmed = text.trim();
76
78
  pieces.push(trimmed.endsWith(';') ? trimmed : `${trimmed};`);
77
79
  }
@@ -7,6 +7,7 @@ const naming_spec_1 = require("@pgpmjs/naming-spec");
7
7
  const scripts_1 = require("@pgsql/scripts");
8
8
  const semantics_1 = require("@pgsql/semantics");
9
9
  const transform_1 = require("@pgsql/transform");
10
+ const byte_slice_1 = require("./byte-slice");
10
11
  const sub_object_1 = require("./sub-object");
11
12
  exports.CHANGE_GRANULARITIES = ['alteration', 'object', 'single'];
12
13
  const isChangeGranularity = (value) => exports.CHANGE_GRANULARITIES.includes(value);
@@ -59,12 +60,13 @@ function restructureChanges(changes, options) {
59
60
  // target (creates[0]), so a table's CREATE and its remaining ALTERs land
60
61
  // in the same change regardless of statement kind.
61
62
  const facts = (0, semantics_1.classifyStatements)(sql);
63
+ const sqlBytes = (0, byte_slice_1.sqlSourceBytes)(sql);
62
64
  if (options.changeGranularity === 'single') {
63
65
  const name = options.singleChangeName ?? 'module/init';
64
66
  const inputNames = new Set(changes.map(c => c.name));
65
67
  const dependencies = [...new Set(changes.flatMap(c => c.dependencies).filter(d => !inputNames.has(d)))].sort();
66
68
  const statements = facts
67
- .map(f => sql.slice(f.span.start, f.span.start + f.span.len).trim())
69
+ .map(f => (0, byte_slice_1.sliceStatementBytes)(sqlBytes, f.span.start, f.span.len).trim())
68
70
  .filter(Boolean)
69
71
  .map(text => (text.endsWith(';') ? text : `${text};`));
70
72
  const revert = (0, scripts_1.revertFor)(facts);
@@ -143,7 +145,7 @@ function restructureChanges(changes, options) {
143
145
  const groupSql = groupNames.map(() => []);
144
146
  const groupStatements = groupNames.map(() => []);
145
147
  facts.forEach((f, i) => {
146
- const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
148
+ const text = (0, byte_slice_1.sliceStatementBytes)(sqlBytes, f.span.start, f.span.len).trim();
147
149
  const g = groupOf[i];
148
150
  if (g !== -1 && text) {
149
151
  groupSql[g].push(text.endsWith(';') ? text : `${text};`);
package/index.d.ts CHANGED
@@ -24,8 +24,8 @@ export type { GeneratedScript, RegeneratedScripts, } from './regen';
24
24
  export { classifyScript, isStubScript, regenerateScripts, } from './regen';
25
25
  export type { ExportGranularity, RestructureExportRowsOptions, RestructureExportRowsResult } from './restructure';
26
26
  export { EXPORT_GRANULARITIES, isExportGranularity, restructureExportRows } from './restructure';
27
- export type { DiffInputChange, ObjectDelta, SemanticDeltaChange, SemanticDiffOptions, SemanticDiffResult, SemanticObjectDiff, } from './semantic-diff-driver';
28
- export { diffChangeSets, diffSchemas, } from './semantic-diff-driver';
27
+ export type { ChangeCoverage, ChangeObjectCoverage, ChangeObjectDelta, ChangeSatisfaction, DiffInputChange, ObjectDelta, SemanticDeltaChange, SemanticDiffOptions, SemanticDiffResult, SemanticObjectDiff, } from './semantic-diff-driver';
28
+ export { coverChanges, diffChangeSets, diffSchemas, } from './semantic-diff-driver';
29
29
  export { nameUnnamedConstraints, subObjectIdentityOf } from './sub-object';
30
30
  export * from '@pgsql/transform';
31
31
  export { loadModule } from 'plpgsql-parser';
package/index.js CHANGED
@@ -14,7 +14,7 @@ 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
- exports.loadModule = exports.subObjectIdentityOf = exports.nameUnnamedConstraints = exports.diffSchemas = exports.diffChangeSets = exports.restructureExportRows = exports.isExportGranularity = exports.EXPORT_GRANULARITIES = exports.regenerateScripts = exports.isStubScript = exports.classifyScript = exports.parseSqlProgram = exports.emitSqlProgram = exports.RESIDUAL_UNIT_PATH = exports.partitionUnits = exports.PartitionCycleError = exports.partitionExportRows = exports.parsePartitionConfig = exports.stripTransactionWrapper = exports.loadModuleSource = exports.writeModule = exports.writeControlFile = exports.checkOverwrite = exports.appendModule = exports.restructureChanges = exports.isChangeGranularity = exports.defaultChangeName = exports.CHANGE_GRANULARITIES = exports.resolveFixtureClosure = exports.firstColumnRef = exports.defaultConstraintName = exports.TIER_PROFILE = exports.categorizeChange = exports.buildCategoryOf = exports.withoutColumnOrder = exports.snapshotCatalog = exports.diffCatalogSnapshots = exports.makeSchemaTranspiler = exports.makeNamespaceValidator = exports.buildSchemaRouter = exports.buildExtensionRouter = void 0;
17
+ exports.loadModule = exports.subObjectIdentityOf = exports.nameUnnamedConstraints = exports.diffSchemas = exports.diffChangeSets = exports.coverChanges = exports.restructureExportRows = exports.isExportGranularity = exports.EXPORT_GRANULARITIES = exports.regenerateScripts = exports.isStubScript = exports.classifyScript = exports.parseSqlProgram = exports.emitSqlProgram = exports.RESIDUAL_UNIT_PATH = exports.partitionUnits = exports.PartitionCycleError = exports.partitionExportRows = exports.parsePartitionConfig = exports.stripTransactionWrapper = exports.loadModuleSource = exports.writeModule = exports.writeControlFile = exports.checkOverwrite = exports.appendModule = exports.restructureChanges = exports.isChangeGranularity = exports.defaultChangeName = exports.CHANGE_GRANULARITIES = exports.resolveFixtureClosure = exports.firstColumnRef = exports.defaultConstraintName = exports.TIER_PROFILE = exports.categorizeChange = exports.buildCategoryOf = exports.withoutColumnOrder = exports.snapshotCatalog = exports.diffCatalogSnapshots = exports.makeSchemaTranspiler = exports.makeNamespaceValidator = exports.buildSchemaRouter = exports.buildExtensionRouter = void 0;
18
18
  var bundle_driver_1 = require("./bundle-driver");
19
19
  Object.defineProperty(exports, "buildExtensionRouter", { enumerable: true, get: function () { return bundle_driver_1.buildExtensionRouter; } });
20
20
  Object.defineProperty(exports, "buildSchemaRouter", { enumerable: true, get: function () { return bundle_driver_1.buildSchemaRouter; } });
@@ -65,6 +65,7 @@ Object.defineProperty(exports, "EXPORT_GRANULARITIES", { enumerable: true, get:
65
65
  Object.defineProperty(exports, "isExportGranularity", { enumerable: true, get: function () { return restructure_1.isExportGranularity; } });
66
66
  Object.defineProperty(exports, "restructureExportRows", { enumerable: true, get: function () { return restructure_1.restructureExportRows; } });
67
67
  var semantic_diff_driver_1 = require("./semantic-diff-driver");
68
+ Object.defineProperty(exports, "coverChanges", { enumerable: true, get: function () { return semantic_diff_driver_1.coverChanges; } });
68
69
  Object.defineProperty(exports, "diffChangeSets", { enumerable: true, get: function () { return semantic_diff_driver_1.diffChangeSets; } });
69
70
  Object.defineProperty(exports, "diffSchemas", { enumerable: true, get: function () { return semantic_diff_driver_1.diffSchemas; } });
70
71
  var sub_object_1 = require("./sub-object");
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Module ingestion for the dials pipeline: load an on-disk pgpm module into
3
+ * the flattened, classified change list the transform/partition drivers
4
+ * consume. `pgpm transform` uses this today; a future `pgpm import` (dumps)
5
+ * and `pgpm diff` are expected to reuse the same seam.
6
+ */
7
+ import type { ExtendedPlanFile } from '@pgpmjs/ast';
1
8
  /** One change of a loaded module: headerless, unwrapped deploy SQL. */
2
9
  export interface ModuleSourceChange {
3
10
  /** Change name (plan token). */
@@ -24,10 +31,21 @@ export interface ModuleSource {
24
31
  * transaction control.
25
32
  */
26
33
  export declare const stripTransactionWrapper: (sql: string) => string;
34
+ /** Options controlling how {@link loadModuleSource} ingests a module. */
35
+ export interface LoadModuleSourceOptions {
36
+ /**
37
+ * Parsed plans of the other packages in the same workspace, keyed by
38
+ * package name (`%project`). Lets a cross-package tag dependency
39
+ * (`pkg:@tag`) resolve against the plan that defines the tag. Without it,
40
+ * cross-package tags cannot be resolved from a single module's plan and are
41
+ * left verbatim with a warning.
42
+ */
43
+ crossPackagePlans?: Map<string, ExtendedPlanFile>;
44
+ }
27
45
  /**
28
46
  * Load a pgpm module from disk: parse pgpm.plan and read each change's
29
47
  * deploy script, stripping pgpm headers and transaction wrappers so the
30
48
  * result can be flattened straight into the dials pipeline
31
49
  * (`restructureChanges` / `partitionUnits`).
32
50
  */
33
- export declare const loadModuleSource: (moduleDir: string) => ModuleSource;
51
+ export declare const loadModuleSource: (moduleDir: string, options?: LoadModuleSourceOptions) => ModuleSource;
package/module-source.js CHANGED
@@ -34,12 +34,6 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.loadModuleSource = exports.stripTransactionWrapper = void 0;
37
- /**
38
- * Module ingestion for the dials pipeline: load an on-disk pgpm module into
39
- * the flattened, classified change list the transform/partition drivers
40
- * consume. `pgpm transform` uses this today; a future `pgpm import` (dumps)
41
- * and `pgpm diff` are expected to reuse the same seam.
42
- */
43
37
  const ast_1 = require("@pgpmjs/ast");
44
38
  const path = __importStar(require("path"));
45
39
  const TX_LINE = /^\s*(BEGIN|COMMIT|ROLLBACK)\s*;\s*$/i;
@@ -54,22 +48,64 @@ const stripTransactionWrapper = (sql) => sql
54
48
  .join('\n')
55
49
  .trim();
56
50
  exports.stripTransactionWrapper = stripTransactionWrapper;
51
+ const CROSS_PACKAGE_TAG = /^([^:]+):@(.+)$/;
52
+ /**
53
+ * Resolve a plan dependency token to a concrete change identity.
54
+ *
55
+ * Non-tag tokens (a bare change name, or a `pkg:change` cross-package name)
56
+ * pass through unchanged — they are already the identity the ledger and
57
+ * generated plans record. A tag token is resolved so a dependency written
58
+ * against a tag is never re-emitted verbatim into a generated plan or a
59
+ * ledger row:
60
+ * - a local tag (`@tag`) resolves to its change name via this plan;
61
+ * - a cross-package tag (`pkg:@tag`) resolves to the canonical `pkg:change`
62
+ * qualified name via `crossPackagePlans[pkg]` when available.
63
+ *
64
+ * Anything that cannot be resolved (unknown tag, or a cross-package tag with
65
+ * no plan in context) is kept verbatim — never dropped — with a warning.
66
+ */
67
+ const resolveDependencyToken = (token, plan, warnings, changeName, crossPackagePlans) => {
68
+ if (!token.includes('@'))
69
+ return token;
70
+ const cross = token.match(CROSS_PACKAGE_TAG);
71
+ if (cross && cross[1] !== plan.package) {
72
+ const pkg = cross[1];
73
+ const otherPlan = crossPackagePlans?.get(pkg);
74
+ if (!otherPlan) {
75
+ warnings.push(`${changeName}: dependency tag "${token}" is cross-package; left unresolved (no plan for "${pkg}" in context)`);
76
+ return token;
77
+ }
78
+ const resolved = (0, ast_1.resolveReference)(token, otherPlan, pkg);
79
+ if (resolved.error || !resolved.change) {
80
+ warnings.push(`${changeName}: could not resolve cross-package dependency tag "${token}" (${resolved.error ?? 'no matching change'}); left as-is`);
81
+ return token;
82
+ }
83
+ return `${pkg}:${resolved.change}`;
84
+ }
85
+ const resolved = (0, ast_1.resolveReference)(token, plan, plan.package);
86
+ if (resolved.error || !resolved.change) {
87
+ warnings.push(`${changeName}: could not resolve dependency tag "${token}" (${resolved.error ?? 'no matching change'}); left as-is`);
88
+ return token;
89
+ }
90
+ return resolved.change;
91
+ };
57
92
  /**
58
93
  * Load a pgpm module from disk: parse pgpm.plan and read each change's
59
94
  * deploy script, stripping pgpm headers and transaction wrappers so the
60
95
  * result can be flattened straight into the dials pipeline
61
96
  * (`restructureChanges` / `partitionUnits`).
62
97
  */
63
- const loadModuleSource = (moduleDir) => {
98
+ const loadModuleSource = (moduleDir, options = {}) => {
64
99
  const modulePath = path.resolve(moduleDir);
65
100
  const planResult = (0, ast_1.parsePlanFile)(path.join(modulePath, 'pgpm.plan'));
66
101
  if (!planResult.data) {
67
102
  const reasons = planResult.errors.map(e => e.message).join(', ');
68
103
  throw new Error(`Failed to parse pgpm.plan in ${modulePath}: ${reasons}`);
69
104
  }
105
+ const plan = planResult.data;
70
106
  const warnings = [];
71
107
  const changes = [];
72
- for (const change of planResult.data.changes) {
108
+ for (const change of plan.changes) {
73
109
  const raw = (0, ast_1.readScript)(modulePath, 'deploy', change.name);
74
110
  if (!raw) {
75
111
  warnings.push(`${change.name}: no deploy script found, skipping`);
@@ -78,7 +114,7 @@ const loadModuleSource = (moduleDir) => {
78
114
  const { body } = (0, ast_1.parsePgpmHeader)(raw);
79
115
  changes.push({
80
116
  name: change.name,
81
- dependencies: change.dependencies ?? [],
117
+ dependencies: (change.dependencies ?? []).map(dep => resolveDependencyToken(dep, plan, warnings, change.name, options.crossPackagePlans)),
82
118
  deploy: (0, exports.stripTransactionWrapper)(body)
83
119
  });
84
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pgpmjs/transform",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Change-aware SQL transformation for PGPM — superset of @pgsql/transform plus change categorization, fixture closure, and bundle transpile/apply drivers",
6
6
  "main": "index.js",
@@ -39,16 +39,16 @@
39
39
  "plpgsql"
40
40
  ],
41
41
  "devDependencies": {
42
- "@pgpmjs/bundle": "^0.14.0",
42
+ "@pgpmjs/bundle": "^0.14.1",
43
43
  "makage": "^0.3.0"
44
44
  },
45
45
  "dependencies": {
46
- "@pgpmjs/ast": "^0.11.0",
46
+ "@pgpmjs/ast": "^0.11.1",
47
47
  "@pgpmjs/naming-spec": "^0.3.0",
48
- "@pgsql/scripts": "^18.3.2",
49
- "@pgsql/semantics": "^18.1.0",
50
- "@pgsql/transform": "^18.15.0",
51
- "plpgsql-parser": "^18.5.1"
48
+ "@pgsql/scripts": "^18.3.4",
49
+ "@pgsql/semantics": "^18.1.2",
50
+ "@pgsql/transform": "^18.15.2",
51
+ "plpgsql-parser": "^18.5.3"
52
52
  },
53
- "gitHead": "d508453a32a2f1d70ff6916871046615226e6f33"
53
+ "gitHead": "c9dcb0c96174dfb0bc6fd63e46cc7776ea7af173"
54
54
  }
@@ -27,6 +27,7 @@ exports.partitionUnits = partitionUnits;
27
27
  const naming_spec_1 = require("@pgpmjs/naming-spec");
28
28
  const semantics_1 = require("@pgsql/semantics");
29
29
  const transform_1 = require("@pgsql/transform");
30
+ const byte_slice_1 = require("./byte-slice");
30
31
  /** A partition whose packages require each other in a cycle — unshippable. */
31
32
  class PartitionCycleError extends Error {
32
33
  packages;
@@ -86,6 +87,7 @@ exports.RESIDUAL_UNIT_PATH = 'misc/statements';
86
87
  */
87
88
  function buildUnitGraph(sql, style, splitRiders) {
88
89
  const facts = (0, semantics_1.classifyStatements)(sql);
90
+ const sqlBytes = (0, byte_slice_1.sqlSourceBytes)(sql);
89
91
  const graph = (0, transform_1.buildStatementGraph)(facts);
90
92
  const unitOf = new Array(facts.length).fill(-1);
91
93
  const units = [];
@@ -168,7 +170,7 @@ function buildUnitGraph(sql, style, splitRiders) {
168
170
  unitOf[i] = unitFor(null, f.kind);
169
171
  });
170
172
  facts.forEach((f, i) => {
171
- const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
173
+ const text = (0, byte_slice_1.sliceStatementBytes)(sqlBytes, f.span.start, f.span.len).trim();
172
174
  if (!text)
173
175
  return;
174
176
  const unit = units[unitOf[i]];
package/program.js CHANGED
@@ -4,6 +4,7 @@ exports.parseSqlProgram = parseSqlProgram;
4
4
  exports.emitSqlProgram = emitSqlProgram;
5
5
  const semantics_1 = require("@pgsql/semantics");
6
6
  const plpgsql_parser_1 = require("plpgsql-parser");
7
+ const byte_slice_1 = require("./byte-slice");
7
8
  /**
8
9
  * Parse a SQL script into a statement program. Requires `loadModule()` from
9
10
  * `plpgsql-parser` to have completed.
@@ -12,6 +13,7 @@ function parseSqlProgram(source) {
12
13
  const parseResult = (0, plpgsql_parser_1.parseSql)(source);
13
14
  const stmts = parseResult?.stmts ?? [];
14
15
  const facts = (0, semantics_1.classifyStatements)(source);
16
+ const sourceBytes = (0, byte_slice_1.sqlSourceBytes)(source);
15
17
  const statements = [];
16
18
  for (let i = 0; i < stmts.length; i++) {
17
19
  const rawStmt = stmts[i];
@@ -22,7 +24,7 @@ function parseSqlProgram(source) {
22
24
  stmt: rawStmt.stmt,
23
25
  facts: facts[i],
24
26
  span: { start, len },
25
- raw: source.slice(start, start + len),
27
+ raw: (0, byte_slice_1.sliceStatementBytes)(sourceBytes, start, len),
26
28
  dirty: false
27
29
  });
28
30
  }
@@ -38,16 +40,22 @@ function emitSqlProgram(program) {
38
40
  const { source, statements } = program;
39
41
  if (!statements.some(s => s.dirty))
40
42
  return source;
43
+ // Spans are UTF-8 byte offsets; slice statements and the verbatim gaps
44
+ // between them on the bytes so a multibyte character never mis-cuts the
45
+ // reassembly. Statement boundaries fall on character boundaries.
46
+ const sourceBytes = (0, byte_slice_1.sqlSourceBytes)(source);
41
47
  const pieces = [];
42
48
  let cursor = 0;
43
49
  for (const statement of statements) {
44
50
  const { start, len } = statement.span;
45
51
  if (start > cursor)
46
- pieces.push(source.slice(cursor, start));
47
- pieces.push(statement.dirty ? plpgsql_parser_1.Deparser.deparse(statement.stmt) : source.slice(start, start + len));
52
+ pieces.push((0, byte_slice_1.sliceStatementBytes)(sourceBytes, cursor, start - cursor));
53
+ pieces.push(statement.dirty
54
+ ? plpgsql_parser_1.Deparser.deparse(statement.stmt)
55
+ : (0, byte_slice_1.sliceStatementBytes)(sourceBytes, start, len));
48
56
  cursor = start + len;
49
57
  }
50
- if (cursor < source.length)
51
- pieces.push(source.slice(cursor));
58
+ if (cursor < sourceBytes.length)
59
+ pieces.push((0, byte_slice_1.sliceStatementBytes)(sourceBytes, cursor));
52
60
  return pieces.join('');
53
61
  }
@@ -90,3 +90,38 @@ export interface DiffInputChange {
90
90
  * schema yields an identical diff.
91
91
  */
92
92
  export declare function diffChangeSets(from: DiffInputChange[], to: DiffInputChange[], options?: SemanticDiffOptions): SemanticDiffResult;
93
+ /** How one object a change touches relates to the diff's delta. */
94
+ export type ChangeObjectDelta = ObjectDelta | 'unchanged';
95
+ /**
96
+ * Whether a `to`-side change is already satisfied by the `from` side:
97
+ * - `satisfied`: every object it touches is unchanged in the diff
98
+ * - `unsatisfied`: every object it touches is added/modified in the diff
99
+ * - `partial`: a mix of unchanged and delta objects
100
+ * - `inert`: no identifiable objects (seed data, grants-only, dynamic SQL)
101
+ */
102
+ export type ChangeSatisfaction = 'satisfied' | 'unsatisfied' | 'partial' | 'inert';
103
+ export interface ChangeObjectCoverage {
104
+ /** Canonical naming-spec path for the object. */
105
+ path: string;
106
+ delta: ChangeObjectDelta;
107
+ }
108
+ export interface ChangeCoverage {
109
+ /** The input change's name (as given, e.g. `pkg:change`). */
110
+ name: string;
111
+ status: ChangeSatisfaction;
112
+ objects: ChangeObjectCoverage[];
113
+ }
114
+ /**
115
+ * Cover each `to`-side change against a semantic diff's per-object delta:
116
+ * which of the objects the change touches are already present and identical
117
+ * on the `from` side. A `satisfied` change deploys nothing new — its ledger
118
+ * entry can be backfilled instead of executing it; an `unsatisfied` change is
119
+ * the genuine delta. Identity keying is shared with the diff itself, so
120
+ * regrouped/renamed/reordered authorship of the same objects covers the same.
121
+ *
122
+ * `diff` must come from diffing the same `from` side against these changes
123
+ * (`diffChangeSets(fromChanges, toChanges)`).
124
+ */
125
+ export declare function coverChanges(changes: DiffInputChange[], diff: SemanticDiffResult, options?: {
126
+ style?: PathStyle;
127
+ }): ChangeCoverage[];
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.diffSchemas = diffSchemas;
4
4
  exports.diffChangeSets = diffChangeSets;
5
+ exports.coverChanges = coverChanges;
5
6
  /**
6
7
  * Semantic diff driver: identity-keyed schema delta between two deploy
7
8
  * surfaces.
@@ -35,6 +36,7 @@ const scripts_1 = require("@pgsql/scripts");
35
36
  const semantics_1 = require("@pgsql/semantics");
36
37
  const transform_1 = require("@pgsql/transform");
37
38
  const plpgsql_parser_1 = require("plpgsql-parser");
39
+ const byte_slice_1 = require("./byte-slice");
38
40
  const constraint_names_1 = require("./constraint-names");
39
41
  const granularity_driver_1 = require("./granularity-driver");
40
42
  const identityKey = (id) => `${id.kind}\u0000${id.schema ?? ''}\u0000${id.table ?? ''}\u0000${id.name}`;
@@ -51,8 +53,9 @@ function readSide(sql, warnings, label) {
51
53
  const order = [];
52
54
  const unitOf = new Array(facts.length).fill(null);
53
55
  const byObject = new Map();
56
+ const sqlBytes = (0, byte_slice_1.sqlSourceBytes)(sql);
54
57
  facts.forEach((f, i) => {
55
- const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
58
+ const text = (0, byte_slice_1.sliceStatementBytes)(sqlBytes, f.span.start, f.span.len).trim();
56
59
  if (!text)
57
60
  return;
58
61
  let identity = groupingIdentity(f);
@@ -506,3 +509,38 @@ function diffChangeSets(from, to, options = {}) {
506
509
  const flatten = (changes) => changes.map(c => c.deploy.trim()).filter(Boolean).join('\n\n');
507
510
  return diffSchemas(flatten(from), flatten(to), options);
508
511
  }
512
+ /**
513
+ * Cover each `to`-side change against a semantic diff's per-object delta:
514
+ * which of the objects the change touches are already present and identical
515
+ * on the `from` side. A `satisfied` change deploys nothing new — its ledger
516
+ * entry can be backfilled instead of executing it; an `unsatisfied` change is
517
+ * the genuine delta. Identity keying is shared with the diff itself, so
518
+ * regrouped/renamed/reordered authorship of the same objects covers the same.
519
+ *
520
+ * `diff` must come from diffing the same `from` side against these changes
521
+ * (`diffChangeSets(fromChanges, toChanges)`).
522
+ */
523
+ function coverChanges(changes, diff, options = {}) {
524
+ const style = options.style ?? 'directory';
525
+ const deltaByKey = new Map();
526
+ for (const obj of diff.objects) {
527
+ deltaByKey.set(identityKey(obj.identity), obj.delta);
528
+ }
529
+ return changes.map(change => {
530
+ const sideWarnings = [];
531
+ const side = readSide(change.deploy, sideWarnings, change.name);
532
+ const objects = side.order.map(key => {
533
+ const unit = side.units.get(key);
534
+ return {
535
+ path: (0, naming_spec_1.pathFor)(unit.identity, { style }),
536
+ delta: deltaByKey.get(key) ?? 'unchanged'
537
+ };
538
+ });
539
+ const touched = objects.filter(o => o.delta !== 'unchanged').length;
540
+ const status = objects.length === 0 ? 'inert'
541
+ : touched === 0 ? 'satisfied'
542
+ : touched === objects.length ? 'unsatisfied'
543
+ : 'partial';
544
+ return { name: change.name, status, objects };
545
+ });
546
+ }
package/sub-object.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.subObjectIdentityOf = subObjectIdentityOf;
4
4
  exports.nameUnnamedConstraints = nameUnnamedConstraints;
5
5
  const plpgsql_parser_1 = require("plpgsql-parser");
6
+ const byte_slice_1 = require("./byte-slice");
6
7
  const constraint_names_1 = require("./constraint-names");
7
8
  /** The single AlterTableCmd of a one-command ALTER TABLE, or null. */
8
9
  function singleCmd(stmt) {
@@ -50,6 +51,7 @@ function subObjectIdentityOf(facts) {
50
51
  */
51
52
  function nameUnnamedConstraints(sql) {
52
53
  const parsed = (0, plpgsql_parser_1.parseSync)(sql);
54
+ const sqlBytes = (0, byte_slice_1.sqlSourceBytes)(sql);
53
55
  const pieces = [];
54
56
  for (const raw of parsed.sql.stmts) {
55
57
  const node = raw.stmt;
@@ -75,7 +77,7 @@ function nameUnnamedConstraints(sql) {
75
77
  else {
76
78
  const start = raw.stmt_location ?? 0;
77
79
  const len = raw.stmt_len;
78
- const text = len === undefined ? sql.slice(start) : sql.slice(start, start + len);
80
+ const text = (0, byte_slice_1.sliceStatementBytes)(sqlBytes, start, len);
79
81
  const trimmed = text.trim();
80
82
  pieces.push(trimmed.endsWith(';') ? trimmed : `${trimmed};`);
81
83
  }