@biffo/cli 0.149.0 → 0.151.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +137 -7
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -25,8 +25,19 @@ import { dirname, join, parse as parsePath } from "path";
|
|
|
25
25
|
import { fileURLToPath } from "url";
|
|
26
26
|
import { z } from "zod";
|
|
27
27
|
var SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
28
|
+
var DeclinedMigrationSchema = z.object({
|
|
29
|
+
/** The *template's* filename for the migration, e.g. `0010_add_organizations.py`. */
|
|
30
|
+
file: z.string({ required_error: "file is required \u2014 it must name a template migration" }).min(1, "file is required \u2014 it must name a template migration"),
|
|
31
|
+
// `required_error` as well as `min(1)`: zod reports a bare "Required" for an
|
|
32
|
+
// absent key, which would lose the guidance in the exact case most likely to
|
|
33
|
+
// occur — someone adding an entry and omitting the field.
|
|
34
|
+
reason: z.string({ required_error: "reason is required \u2014 a decline nobody can review is drift" }).min(1, "reason is required \u2014 a decline nobody can review is drift"),
|
|
35
|
+
/** Optional `owner/repo#123` recording where the decline is being resolved. */
|
|
36
|
+
upstream: z.string().optional()
|
|
37
|
+
});
|
|
28
38
|
var CoreManifestSchema = z.object({
|
|
29
|
-
version: z.string().regex(SEMVER, "must be a semver, e.g. 1.2.3")
|
|
39
|
+
version: z.string().regex(SEMVER, "must be a semver, e.g. 1.2.3"),
|
|
40
|
+
declinedMigrations: z.array(DeclinedMigrationSchema).optional()
|
|
30
41
|
});
|
|
31
42
|
var CORE_VERSION_FILE = "core.version";
|
|
32
43
|
var INSTANCE_CORE_FILE = "biffo.core.json";
|
|
@@ -112,6 +123,9 @@ function readInstanceCoreVersion(cwd) {
|
|
|
112
123
|
const inherited = join(cwd, CORE_VERSION_FILE);
|
|
113
124
|
return existsSync(inherited) ? readCoreVersionFile(inherited) : null;
|
|
114
125
|
}
|
|
126
|
+
return parseInstanceCoreManifest(path).validated.version;
|
|
127
|
+
}
|
|
128
|
+
function parseInstanceCoreManifest(path) {
|
|
115
129
|
let parsed;
|
|
116
130
|
try {
|
|
117
131
|
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -123,7 +137,15 @@ function readInstanceCoreVersion(cwd) {
|
|
|
123
137
|
const detail = result.error.issues[0]?.message ?? "unexpected shape";
|
|
124
138
|
throw new Error(`${INSTANCE_CORE_FILE} is invalid: ${detail}`);
|
|
125
139
|
}
|
|
126
|
-
return
|
|
140
|
+
return {
|
|
141
|
+
validated: result.data,
|
|
142
|
+
raw: parsed ?? {}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function readDeclinedMigrations(cwd) {
|
|
146
|
+
const path = join(cwd, INSTANCE_CORE_FILE);
|
|
147
|
+
if (!existsSync(path)) return [];
|
|
148
|
+
return parseInstanceCoreManifest(path).validated.declinedMigrations ?? [];
|
|
127
149
|
}
|
|
128
150
|
function planCoreVersionCleanup(cwd) {
|
|
129
151
|
const path = join(cwd, CORE_VERSION_FILE);
|
|
@@ -150,13 +172,19 @@ function coreVersionsEqual(a, b) {
|
|
|
150
172
|
return false;
|
|
151
173
|
}
|
|
152
174
|
}
|
|
153
|
-
function serializeInstanceCoreVersion(version) {
|
|
175
|
+
function serializeInstanceCoreVersion(version, rest = {}) {
|
|
154
176
|
parseCoreVersion(version);
|
|
155
|
-
return `${JSON.stringify({ version }, null, 2)}
|
|
177
|
+
return `${JSON.stringify({ version, ...rest }, null, 2)}
|
|
156
178
|
`;
|
|
157
179
|
}
|
|
158
180
|
function writeInstanceCoreVersion(cwd, version) {
|
|
159
|
-
|
|
181
|
+
const path = join(cwd, INSTANCE_CORE_FILE);
|
|
182
|
+
let rest = {};
|
|
183
|
+
if (existsSync(path)) {
|
|
184
|
+
rest = { ...parseInstanceCoreManifest(path).raw };
|
|
185
|
+
delete rest.version;
|
|
186
|
+
}
|
|
187
|
+
writeFileSync(path, serializeInstanceCoreVersion(version, rest));
|
|
160
188
|
}
|
|
161
189
|
function latestCoreVersionFromTags(repo, git = defaultTagRunner, options = {}) {
|
|
162
190
|
if (options.fetch !== false) {
|
|
@@ -1536,14 +1564,33 @@ function planMigrationCarry(options) {
|
|
|
1536
1564
|
const entries = [];
|
|
1537
1565
|
const skipped = [];
|
|
1538
1566
|
const recognised = [];
|
|
1567
|
+
const declinedIndex = new Map((options.declined ?? []).map((d) => [d.file, d]));
|
|
1568
|
+
const declined = [];
|
|
1569
|
+
const divergedBodies = [];
|
|
1539
1570
|
let head = instanceHead;
|
|
1540
1571
|
for (const m of chainOrder(template)) {
|
|
1572
|
+
const decline = declinedIndex.get(m.file);
|
|
1573
|
+
if (decline) {
|
|
1574
|
+
declined.push({
|
|
1575
|
+
file: m.file,
|
|
1576
|
+
reason: decline.reason,
|
|
1577
|
+
...decline.upstream !== void 0 && { upstream: decline.upstream }
|
|
1578
|
+
});
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1541
1581
|
const already = alreadyCarried(m, identity, templateBodyCounts);
|
|
1542
1582
|
if (already) {
|
|
1543
1583
|
skipped.push(m.file);
|
|
1544
1584
|
if (already.how !== "filename") {
|
|
1545
1585
|
recognised.push({ file: m.file, instanceFile: already.instance.file, how: already.how });
|
|
1546
1586
|
}
|
|
1587
|
+
if (migrationBodyHash(m.content) !== migrationBodyHash(already.instance.content)) {
|
|
1588
|
+
divergedBodies.push({
|
|
1589
|
+
file: m.file,
|
|
1590
|
+
instanceFile: already.instance.file,
|
|
1591
|
+
how: already.how
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1547
1594
|
continue;
|
|
1548
1595
|
}
|
|
1549
1596
|
let revision = m.revision;
|
|
@@ -1584,7 +1631,33 @@ function planMigrationCarry(options) {
|
|
|
1584
1631
|
],
|
|
1585
1632
|
`${MIGRATIONS_VERSIONS_DIR} (after carry)`
|
|
1586
1633
|
);
|
|
1587
|
-
|
|
1634
|
+
const templateFiles = new Set(template.map((m) => m.file));
|
|
1635
|
+
const staleDeclines = [...declinedIndex.keys()].filter((f) => !templateFiles.has(f));
|
|
1636
|
+
return {
|
|
1637
|
+
entries,
|
|
1638
|
+
instanceHead,
|
|
1639
|
+
skipped,
|
|
1640
|
+
recognised,
|
|
1641
|
+
declined,
|
|
1642
|
+
staleDeclines,
|
|
1643
|
+
divergedBodies
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
var TEST_PATH_RE = /(^|\/)tests?\/.*\btest_[^/]*\.py$/;
|
|
1647
|
+
function findMigrationTestPairings(changes, divergedBodies) {
|
|
1648
|
+
if (divergedBodies.length === 0) return [];
|
|
1649
|
+
const pairings = [];
|
|
1650
|
+
for (const change of changes) {
|
|
1651
|
+
if (!TEST_PATH_RE.test(change.path)) continue;
|
|
1652
|
+
const content = change.content;
|
|
1653
|
+
if (content === void 0) continue;
|
|
1654
|
+
for (const d of divergedBodies) {
|
|
1655
|
+
if (content.includes(d.file)) {
|
|
1656
|
+
pairings.push({ testPath: change.path, migration: d.file, instanceFile: d.instanceFile });
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
return pairings;
|
|
1588
1661
|
}
|
|
1589
1662
|
function indexInstanceMigrations(instance) {
|
|
1590
1663
|
const index = {
|
|
@@ -2452,7 +2525,11 @@ async function runCoreUpgradeResolved(options, deps, cleanups) {
|
|
|
2452
2525
|
console.log(` merge base: ${fromVersion}`);
|
|
2453
2526
|
console.log(` target: ${toVersion}
|
|
2454
2527
|
`);
|
|
2455
|
-
const migrations = planMigrationCarry({
|
|
2528
|
+
const migrations = planMigrationCarry({
|
|
2529
|
+
templateDir: theirsDir,
|
|
2530
|
+
instanceDir: options.cwd,
|
|
2531
|
+
declined: readDeclinedMigrations(options.cwd)
|
|
2532
|
+
});
|
|
2456
2533
|
const coreVersionCleanup = planCoreVersionCleanup(options.cwd);
|
|
2457
2534
|
if (plan.changes.length === 0 && migrations.entries.length === 0) {
|
|
2458
2535
|
log.success("Nothing to upgrade \u2014 the instance already matches the target for all core files.");
|
|
@@ -2468,6 +2545,10 @@ async function runCoreUpgradeResolved(options, deps, cleanups) {
|
|
|
2468
2545
|
printBreakingChanges(breaking, options.apply === true);
|
|
2469
2546
|
printPlan(plan);
|
|
2470
2547
|
printMigrationCarry(migrations);
|
|
2548
|
+
printMigrationBodyDrift(
|
|
2549
|
+
migrations,
|
|
2550
|
+
findMigrationTestPairings(plan.changes, migrations.divergedBodies)
|
|
2551
|
+
);
|
|
2471
2552
|
printCoreVersionCleanup(coreVersionCleanup, options.apply === true);
|
|
2472
2553
|
warnStaleFirstPartyCopies(options.cwd);
|
|
2473
2554
|
console.log(
|
|
@@ -2689,6 +2770,22 @@ function printMigrationCarry(migrations) {
|
|
|
2689
2770
|
` ${chalk4.dim("already carried".padEnd(15))} ${r.file} ` + chalk4.dim(`\u2192 this instance calls it ${r.instanceFile} (matched by ${r.how})`)
|
|
2690
2771
|
);
|
|
2691
2772
|
}
|
|
2773
|
+
for (const d of migrations.declined) {
|
|
2774
|
+
const upstream = d.upstream ? chalk4.dim(` [${d.upstream}]`) : "";
|
|
2775
|
+
console.log(
|
|
2776
|
+
` ${chalk4.yellow("declined".padEnd(15))} ${d.file} ` + chalk4.dim(`\u2192 skipped per biffo.core.json: ${d.reason}`) + upstream
|
|
2777
|
+
);
|
|
2778
|
+
}
|
|
2779
|
+
for (const f of migrations.staleDeclines) {
|
|
2780
|
+
console.log(
|
|
2781
|
+
` ${chalk4.yellow("stale decline".padEnd(15))} ${f} ` + chalk4.dim("\u2192 declined in biffo.core.json, but the target template has no such migration.")
|
|
2782
|
+
);
|
|
2783
|
+
console.log(
|
|
2784
|
+
chalk4.dim(
|
|
2785
|
+
" Either the filename is wrong (declining nothing) or the decline\n has outlived its cause and should be deleted."
|
|
2786
|
+
)
|
|
2787
|
+
);
|
|
2788
|
+
}
|
|
2692
2789
|
for (const e of migrations.entries) {
|
|
2693
2790
|
const suffix = e.reissuedFrom ? chalk4.dim(
|
|
2694
2791
|
` (revision ${e.revision}, re-issued from ${e.reissuedFrom}; revises ${e.downRevision ?? "base"})`
|
|
@@ -2696,6 +2793,39 @@ function printMigrationCarry(migrations) {
|
|
|
2696
2793
|
console.log(` ${chalk4.green("migration".padEnd(15))} ${e.path}${suffix}`);
|
|
2697
2794
|
}
|
|
2698
2795
|
}
|
|
2796
|
+
function printMigrationBodyDrift(migrations, pairings) {
|
|
2797
|
+
if (migrations.divergedBodies.length === 0) return;
|
|
2798
|
+
console.log();
|
|
2799
|
+
for (const d of migrations.divergedBodies) {
|
|
2800
|
+
const alias = d.instanceFile === d.file ? "" : chalk4.dim(` (this instance calls it ${d.instanceFile})`);
|
|
2801
|
+
console.log(
|
|
2802
|
+
` ${chalk4.yellow("body drift".padEnd(15))} ${d.file}${alias}
|
|
2803
|
+
` + chalk4.dim(
|
|
2804
|
+
" The template has changed this migration since you carried it.\n An applied migration cannot be re-run, so the carry left yours\n alone and this change will NOT reach you."
|
|
2805
|
+
)
|
|
2806
|
+
);
|
|
2807
|
+
}
|
|
2808
|
+
if (pairings.length === 0) {
|
|
2809
|
+
console.log(
|
|
2810
|
+
chalk4.dim(
|
|
2811
|
+
"\n No arriving test asserts the new body, so this upgrade should still go green.\n It stays a convergence gap: fresh environments get the new body, you keep the old."
|
|
2812
|
+
)
|
|
2813
|
+
);
|
|
2814
|
+
return;
|
|
2815
|
+
}
|
|
2816
|
+
console.log(
|
|
2817
|
+
`
|
|
2818
|
+
${chalk4.red.bold("This upgrade will not go green.")} ${pairings.length} arriving test(s) assert a migration body you will not receive:`
|
|
2819
|
+
);
|
|
2820
|
+
for (const p of pairings) {
|
|
2821
|
+
console.log(` ${chalk4.red(p.testPath)} ${chalk4.dim(`\u2192 asserts ${p.migration}`)}`);
|
|
2822
|
+
}
|
|
2823
|
+
console.log(
|
|
2824
|
+
chalk4.dim(
|
|
2825
|
+
"\n Resolve before merging, by one of:\n - port the migration body change into your copy by hand, keeping its\n `# biffo:carried-from:` marker \u2014 safe only if re-stating the DDL is a\n no-op against your already-migrated database;\n - drop the arriving test from this PR and raise it upstream, if the\n property it asserts cannot hold here;\n - ask upstream to ship the change as a follow-on migration instead, which\n is the only option that actually converges an applied chain.\n"
|
|
2826
|
+
)
|
|
2827
|
+
);
|
|
2828
|
+
}
|
|
2699
2829
|
function printCoreVersionCleanup(cleanup, applying) {
|
|
2700
2830
|
if (cleanup === null) return;
|
|
2701
2831
|
if (cleanup.action === "delete") {
|