@biffo/cli 0.289.0 → 0.290.1

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.
Files changed (2) hide show
  1. package/dist/index.js +283 -90
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -4122,6 +4122,24 @@ function printMigrationBodyDrift(migrations, pairings) {
4122
4122
  " 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."
4123
4123
  )
4124
4124
  );
4125
+ if (d.declared) {
4126
+ console.log(
4127
+ chalk4.dim(" declared: ") + chalk4.cyan(d.declared.classification) + chalk4.dim(` \u2014 ${d.declared.reason}`)
4128
+ );
4129
+ const handPortText = "- port the body change into your copy by hand";
4130
+ const followOnText = "- ask upstream for a follow-on migration";
4131
+ if (d.declared.classification === "replay-safe") {
4132
+ console.log(chalk4.dim(` ${handPortText}`));
4133
+ console.log(
4134
+ chalk4.strikethrough.dim(` ${followOnText}`) + chalk4.dim(" (rules out \u2014 replay-safe means your database is already correct)")
4135
+ );
4136
+ } else {
4137
+ console.log(
4138
+ chalk4.strikethrough.dim(` ${handPortText}`) + chalk4.dim(" (rules out \u2014 restating this body cannot fix an already-wrong schema)")
4139
+ );
4140
+ console.log(chalk4.dim(` ${followOnText}`));
4141
+ }
4142
+ }
4125
4143
  }
4126
4144
  if (pairings.length === 0) {
4127
4145
  console.log(
@@ -11659,8 +11677,8 @@ async function runOwnershipCheck(argv) {
11659
11677
  const { stdout } = await execa14("git", ["diff", "--cached", "--name-status"], { cwd: root });
11660
11678
  ({ changed: changedFiles, deleted: deletedFiles } = parseNameStatus(stdout));
11661
11679
  if (messageFile) {
11662
- const { readFileSync: readFileSync45, existsSync: existsSync52 } = await import("fs");
11663
- if (existsSync52(messageFile)) commitMessage = readFileSync45(messageFile, "utf8");
11680
+ const { readFileSync: readFileSync45, existsSync: existsSync53 } = await import("fs");
11681
+ if (existsSync53(messageFile)) commitMessage = readFileSync45(messageFile, "utf8");
11664
11682
  }
11665
11683
  } else {
11666
11684
  const base = process.env["GITHUB_BASE_REF"] ?? args[0];
@@ -12158,10 +12176,180 @@ async function runLambdaOutputCheck() {
12158
12176
  console.log(`\u2713 Lambda output guard: every aws lambda update-function-* call suppresses output`);
12159
12177
  }
12160
12178
 
12179
+ // src/scripts/check-migration-body-change.ts
12180
+ import { execa as execa17 } from "execa";
12181
+ import { existsSync as existsSync44 } from "fs";
12182
+ import { join as join50 } from "path";
12183
+
12184
+ // src/lib/migration-body-change-guard.ts
12185
+ function checkMigrationBodyChangeMarkers(diffs) {
12186
+ const exemptAdded = [];
12187
+ const unchanged = [];
12188
+ const declared = [];
12189
+ const violations = [];
12190
+ for (const d of diffs) {
12191
+ if (d.status === "added") {
12192
+ exemptAdded.push(d.file);
12193
+ continue;
12194
+ }
12195
+ if (d.status !== "modified") {
12196
+ continue;
12197
+ }
12198
+ const oldHash = migrationBodyHash(d.oldContent ?? "");
12199
+ const newHash = migrationBodyHash(d.newContent ?? "");
12200
+ if (oldHash === newHash) {
12201
+ unchanged.push(d.file);
12202
+ continue;
12203
+ }
12204
+ let decl;
12205
+ try {
12206
+ decl = parseBodyChangeDeclaration(d.newContent ?? "");
12207
+ } catch (err) {
12208
+ violations.push({ file: d.file, reason: err.message });
12209
+ continue;
12210
+ }
12211
+ if (decl) {
12212
+ declared.push({ file: d.file, classification: decl.classification });
12213
+ } else {
12214
+ violations.push({
12215
+ file: d.file,
12216
+ reason: `this edit changes the migration's hashed body (DDL, not just a docstring or comment) with no \`${BODY_CHANGE_MARKER}\` declaration.`
12217
+ });
12218
+ }
12219
+ }
12220
+ return {
12221
+ examined: unchanged.length + declared.length + violations.length,
12222
+ exemptAdded,
12223
+ unchanged,
12224
+ declared,
12225
+ violations
12226
+ };
12227
+ }
12228
+
12229
+ // src/scripts/check-migration-body-change.ts
12230
+ var BOLD2 = "\x1B[1m";
12231
+ var DIM2 = "\x1B[2m";
12232
+ var RED2 = "\x1B[31m";
12233
+ var GREEN = "\x1B[32m";
12234
+ var YELLOW2 = "\x1B[33m";
12235
+ var OFF2 = "\x1B[0m";
12236
+ async function showAt(ref, path, cwd) {
12237
+ const result = await execa17("git", ["show", `${ref}:${path}`], { cwd, reject: false });
12238
+ return result.exitCode === 0 ? result.stdout : null;
12239
+ }
12240
+ async function runMigrationBodyChangeCheck(argv) {
12241
+ const root = (await execa17("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12242
+ if (isInstanceRepo(root)) {
12243
+ console.log(
12244
+ `\u2713 migration body-change guard: skipped \u2014 this is an instance (${INSTANCE_CORE_FILE} present). Its migrations/versions/ is a carried, user-owned copy, not the template source this guard protects.`
12245
+ );
12246
+ return;
12247
+ }
12248
+ if (!existsSync44(join50(root, MIGRATIONS_VERSIONS_DIR))) {
12249
+ console.log(`\u2713 migration body-change guard: no ${MIGRATIONS_VERSIONS_DIR} in this repo.`);
12250
+ return;
12251
+ }
12252
+ const base = process.env["GITHUB_BASE_REF"] ?? argv[0];
12253
+ if (!base) {
12254
+ console.error("No base ref: set GITHUB_BASE_REF or pass a base branch as the first argument.");
12255
+ process.exit(2);
12256
+ }
12257
+ await execa17("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
12258
+ const { stdout } = await execa17(
12259
+ "git",
12260
+ [
12261
+ "diff",
12262
+ "--no-renames",
12263
+ "--name-status",
12264
+ `origin/${base}...HEAD`,
12265
+ "--",
12266
+ MIGRATIONS_VERSIONS_DIR
12267
+ ],
12268
+ { cwd: root }
12269
+ );
12270
+ const candidates = [];
12271
+ for (const line of stdout.split("\n")) {
12272
+ const parts = line.split(" ").filter(Boolean);
12273
+ const status = parts[0];
12274
+ const path = parts[parts.length - 1];
12275
+ if (!status || !path || parts.length < 2) continue;
12276
+ if (!path.endsWith(".py") || path.endsWith("/__init__.py")) continue;
12277
+ candidates.push({ file: path, status });
12278
+ }
12279
+ const diffs = [];
12280
+ for (const { file, status } of candidates) {
12281
+ if (status.startsWith("A")) {
12282
+ diffs.push({
12283
+ file,
12284
+ status: "added",
12285
+ oldContent: null,
12286
+ newContent: await showAt("HEAD", file, root)
12287
+ });
12288
+ } else if (status.startsWith("M")) {
12289
+ const [oldContent, newContent] = await Promise.all([
12290
+ showAt(`origin/${base}`, file, root),
12291
+ showAt("HEAD", file, root)
12292
+ ]);
12293
+ diffs.push({ file, status: "modified", oldContent, newContent });
12294
+ } else if (status.startsWith("D")) {
12295
+ diffs.push({
12296
+ file,
12297
+ status: "deleted",
12298
+ oldContent: await showAt(`origin/${base}`, file, root),
12299
+ newContent: null
12300
+ });
12301
+ }
12302
+ }
12303
+ const result = checkMigrationBodyChangeMarkers(diffs);
12304
+ console.log(
12305
+ `${DIM2}migration body-change guard: examined ${result.examined} already-released migration file(s) changed in this PR` + (result.exemptAdded.length > 0 ? ` (+${result.exemptAdded.length} newly added, exempt)` : "") + `.${OFF2}`
12306
+ );
12307
+ for (const { file, classification } of result.declared) {
12308
+ console.log(` ${GREEN}declared${OFF2} ${file} ${DIM2}\u2192 ${classification}${OFF2}`);
12309
+ }
12310
+ if (result.violations.length === 0) {
12311
+ console.log(`${GREEN}\u2713 migration body-change guard: no undeclared body changes.${OFF2}`);
12312
+ return;
12313
+ }
12314
+ console.error(
12315
+ `
12316
+ ${RED2}${BOLD2}\u2717 This PR changes an already-released migration's body with no declaration.${OFF2}
12317
+ `
12318
+ );
12319
+ for (const { file, reason } of result.violations) {
12320
+ console.error(` ${RED2}${file}${OFF2}
12321
+ ${DIM2}${reason}${OFF2}`);
12322
+ }
12323
+ console.error(`
12324
+ ${BOLD2}Why this is blocked${OFF2}
12325
+ An applied migration cannot be re-run, so a body edit here never reaches an
12326
+ instance that already carried it (#739) \u2014 silently, unless a template author
12327
+ says whether that is safe (docs/guides/core-upgrade.md, "When the template
12328
+ edits a migration you already have"). ${YELLOW2}#931${OFF2} shows the safe case: a
12329
+ docstring-only addendum, which this guard never flags, because it compares
12330
+ the same hashed body \`core-upgrade\` itself compares \u2014 DDL, not prose.
12331
+
12332
+ ${BOLD2}What to do${OFF2}
12333
+ Add a marker to the migration, above or beside the edited DDL:
12334
+
12335
+ ${DIM2}# biffo:body-change: replay-safe \u2014 <why an applied database is already correct>${OFF2}
12336
+ ${DIM2}# biffo:body-change: outcome-changing \u2014 <why an applied database is now wrong>${OFF2}
12337
+
12338
+ Use ${DIM2}replay-safe${OFF2} when re-stating the DDL changes nothing about a database
12339
+ that already ran the old body (a guard, an idempotency check, #670's own
12340
+ fix). Use ${DIM2}outcome-changing${OFF2} when it does not \u2014 an already-applied
12341
+ instance's schema is now actually wrong, and only a follow-on migration
12342
+ converges it. Neither label is enforced yet (#751 is reporting-only, pending
12343
+ more examples) \u2014 but recording it now is what lets that decision be made
12344
+ later instead of never.
12345
+ `);
12346
+ process.exit(1);
12347
+ }
12348
+
12161
12349
  // src/scripts/check-pipe-trap.ts
12162
12350
  import { readFileSync as readFileSync37, readdirSync as readdirSync22 } from "fs";
12163
- import { join as join50, relative as relative9 } from "path";
12164
- import { execa as execa17 } from "execa";
12351
+ import { join as join51, relative as relative9 } from "path";
12352
+ import { execa as execa18 } from "execa";
12165
12353
 
12166
12354
  // src/lib/pipe-trap-guard.ts
12167
12355
  var STATUS_BEARING = [
@@ -12257,7 +12445,7 @@ function findPipeTraps(source) {
12257
12445
  function shellFiles(root) {
12258
12446
  const out = [];
12259
12447
  for (const dir of ["scripts", ".githooks"]) {
12260
- const full = join50(root, dir);
12448
+ const full = join51(root, dir);
12261
12449
  let entries;
12262
12450
  try {
12263
12451
  entries = readdirSync22(full, { withFileTypes: true });
@@ -12267,13 +12455,13 @@ function shellFiles(root) {
12267
12455
  for (const entry of entries) {
12268
12456
  if (!entry.isFile()) continue;
12269
12457
  if (dir === "scripts" && !entry.name.endsWith(".sh")) continue;
12270
- out.push(join50(full, entry.name));
12458
+ out.push(join51(full, entry.name));
12271
12459
  }
12272
12460
  }
12273
12461
  return out;
12274
12462
  }
12275
12463
  async function runPipeTrapCheck() {
12276
- const root = (await execa17("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12464
+ const root = (await execa18("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12277
12465
  const files = shellFiles(root);
12278
12466
  console.log(`audited ${files.length} shell file(s) under scripts/ and .githooks/ under ${root}`);
12279
12467
  if (files.length === 0) {
@@ -12300,11 +12488,11 @@ async function runPipeTrapCheck() {
12300
12488
  }
12301
12489
 
12302
12490
  // src/scripts/check-plugin-allowlist-convention.ts
12303
- import { execa as execa18 } from "execa";
12491
+ import { execa as execa19 } from "execa";
12304
12492
 
12305
12493
  // src/lib/plugin-allowlist-convention.ts
12306
12494
  import { readFileSync as readFileSync38 } from "fs";
12307
- import { join as join51 } from "path";
12495
+ import { join as join52 } from "path";
12308
12496
  var COMPUTE_MAIN_TF = "modules/cloud/aws/compute/main.tf";
12309
12497
  var PLUGIN_TEMPLATE_MAIN_TF = "modules/plugins/_template/main.tf";
12310
12498
  var ALLOWLIST_MAIN_TF = "modules/cloud/aws/plugin-allowlist/main.tf";
@@ -12315,7 +12503,7 @@ var PLUGIN = "<plugin>";
12315
12503
  var ACCOUNT = "<account>";
12316
12504
  function read(repoRoot, relative11) {
12317
12505
  try {
12318
- return readFileSync38(join51(repoRoot, relative11), "utf8");
12506
+ return readFileSync38(join52(repoRoot, relative11), "utf8");
12319
12507
  } catch {
12320
12508
  throw new Error(`plugin-allowlist drift guard: cannot read ${relative11}`);
12321
12509
  }
@@ -12411,7 +12599,7 @@ Plugins would be rejected by require_service_principal (ADR-0009). Fix the glob,
12411
12599
 
12412
12600
  // src/scripts/check-plugin-allowlist-convention.ts
12413
12601
  async function runPluginAllowlistConventionCheck() {
12414
- const root = (await execa18("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12602
+ const root = (await execa19("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12415
12603
  let violations;
12416
12604
  try {
12417
12605
  violations = checkAllowlistConvention(root);
@@ -12436,33 +12624,33 @@ async function runPluginAllowlistConventionCheck() {
12436
12624
  }
12437
12625
 
12438
12626
  // src/scripts/check-plugin-collisions.ts
12439
- import { existsSync as existsSync45 } from "fs";
12440
- import { join as join53 } from "path";
12441
- import { execa as execa19 } from "execa";
12627
+ import { existsSync as existsSync46 } from "fs";
12628
+ import { join as join54 } from "path";
12629
+ import { execa as execa20 } from "execa";
12442
12630
 
12443
12631
  // src/lib/plugin-collision-guard.ts
12444
- import { existsSync as existsSync44, readdirSync as readdirSync23, statSync as statSync14 } from "fs";
12445
- import { join as join52 } from "path";
12632
+ import { existsSync as existsSync45, readdirSync as readdirSync23, statSync as statSync14 } from "fs";
12633
+ import { join as join53 } from "path";
12446
12634
  var PYTEST_SPECIAL = /* @__PURE__ */ new Set(["conftest.py"]);
12447
12635
  var IGNORED_DIRS = /* @__PURE__ */ new Set([".venv", "node_modules", "__pycache__", ".git", "dist", "build"]);
12448
12636
  function subdirectories(dir) {
12449
- if (!existsSync44(dir)) return [];
12637
+ if (!existsSync45(dir)) return [];
12450
12638
  return readdirSync23(dir).filter((entry) => {
12451
12639
  if (IGNORED_DIRS.has(entry) || entry.startsWith(".")) return false;
12452
12640
  try {
12453
- return statSync14(join52(dir, entry)).isDirectory();
12641
+ return statSync14(join53(dir, entry)).isDirectory();
12454
12642
  } catch {
12455
12643
  return false;
12456
12644
  }
12457
12645
  });
12458
12646
  }
12459
12647
  function regularPackagesOf(pluginDir2) {
12460
- return subdirectories(pluginDir2).filter((name) => existsSync44(join52(pluginDir2, name, "__init__.py"))).sort();
12648
+ return subdirectories(pluginDir2).filter((name) => existsSync45(join53(pluginDir2, name, "__init__.py"))).sort();
12461
12649
  }
12462
12650
  function bareTestModulesOf(pluginDir2) {
12463
- const testsDir = join52(pluginDir2, "tests");
12464
- if (!existsSync44(testsDir)) return [];
12465
- if (existsSync44(join52(testsDir, "__init__.py"))) return [];
12651
+ const testsDir = join53(pluginDir2, "tests");
12652
+ if (!existsSync45(testsDir)) return [];
12653
+ if (existsSync45(join53(testsDir, "__init__.py"))) return [];
12466
12654
  return readdirSync23(testsDir).filter((f) => f.endsWith(".py") && !PYTEST_SPECIAL.has(f)).sort();
12467
12655
  }
12468
12656
  function findCollisions(servicesDir, pluginDirs) {
@@ -12471,7 +12659,7 @@ function findCollisions(servicesDir, pluginDirs) {
12471
12659
  const gather = (kind, namesOf) => {
12472
12660
  const claims = /* @__PURE__ */ new Map();
12473
12661
  for (const plugin of plugins) {
12474
- for (const name of namesOf(join52(servicesDir, plugin))) {
12662
+ for (const name of namesOf(join53(servicesDir, plugin))) {
12475
12663
  claims.set(name, [...claims.get(name) ?? [], plugin]);
12476
12664
  }
12477
12665
  }
@@ -12508,9 +12696,9 @@ function formatCollisions(collisions) {
12508
12696
 
12509
12697
  // src/scripts/check-plugin-collisions.ts
12510
12698
  async function runPluginCollisionCheck() {
12511
- const root = (await execa19("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12512
- const servicesDir = join53(root, "services");
12513
- if (!existsSync45(servicesDir)) {
12699
+ const root = (await execa20("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12700
+ const servicesDir = join54(root, "services");
12701
+ if (!existsSync46(servicesDir)) {
12514
12702
  console.log("\u2713 plugin collision guard: no services/ directory \u2014 nothing to compare");
12515
12703
  return;
12516
12704
  }
@@ -12527,11 +12715,11 @@ async function runPluginCollisionCheck() {
12527
12715
  }
12528
12716
 
12529
12717
  // src/scripts/check-plugin-terraform.ts
12530
- import { execa as execa20 } from "execa";
12718
+ import { execa as execa21 } from "execa";
12531
12719
 
12532
12720
  // src/lib/plugin-terraform-guard.ts
12533
- import { existsSync as existsSync46, readFileSync as readFileSync39, readdirSync as readdirSync24 } from "fs";
12534
- import { dirname as dirname10, join as join54, relative as relative10, sep as sep4 } from "path";
12721
+ import { existsSync as existsSync47, readFileSync as readFileSync39, readdirSync as readdirSync24 } from "fs";
12722
+ import { dirname as dirname10, join as join55, relative as relative10, sep as sep4 } from "path";
12535
12723
  var SKIP_DIRS3 = /* @__PURE__ */ new Set(["node_modules", ".git", ".worktrees", "dist", ".venv", "__pycache__"]);
12536
12724
  var PLUGIN_MANIFEST_FILE2 = "biffo.plugin.json";
12537
12725
  function findPluginManifests(root) {
@@ -12546,9 +12734,9 @@ function findPluginManifests(root) {
12546
12734
  for (const entry of entries) {
12547
12735
  if (entry.isDirectory()) {
12548
12736
  if (SKIP_DIRS3.has(entry.name)) continue;
12549
- walk2(join54(dir, entry.name));
12737
+ walk2(join55(dir, entry.name));
12550
12738
  } else if (entry.isFile() && entry.name === PLUGIN_MANIFEST_FILE2) {
12551
- found.push(relative10(root, join54(dir, entry.name)).split(sep4).join("/"));
12739
+ found.push(relative10(root, join55(dir, entry.name)).split(sep4).join("/"));
12552
12740
  }
12553
12741
  }
12554
12742
  };
@@ -12573,14 +12761,14 @@ function readSubscriptions(absManifestPath) {
12573
12761
  }
12574
12762
  function checkPluginTerraform(root) {
12575
12763
  const violations = [];
12576
- const coreManifest = existsSync46(join54(root, CORE_MANIFEST_FILE)) ? readCoreManifest(root) : null;
12764
+ const coreManifest = existsSync47(join55(root, CORE_MANIFEST_FILE)) ? readCoreManifest(root) : null;
12577
12765
  for (const manifest of findPluginManifests(root)) {
12578
12766
  if (coreManifest && !isTemplateOwned(manifest, coreManifest)) continue;
12579
- const absManifest = join54(root, manifest);
12767
+ const absManifest = join55(root, manifest);
12580
12768
  const subscriptions = readSubscriptions(absManifest);
12581
12769
  if (subscriptions === null) continue;
12582
12770
  const pluginDir2 = dirname10(absManifest);
12583
- if (existsSync46(join54(pluginDir2, "terraform"))) continue;
12771
+ if (existsSync47(join55(pluginDir2, "terraform"))) continue;
12584
12772
  const relPluginDir = relative10(root, pluginDir2).split(sep4).join("/");
12585
12773
  violations.push({
12586
12774
  manifest,
@@ -12600,7 +12788,7 @@ function formatViolations(violations) {
12600
12788
 
12601
12789
  // src/scripts/check-plugin-terraform.ts
12602
12790
  async function runPluginTerraformCheck() {
12603
- const root = (await execa20("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12791
+ const root = (await execa21("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12604
12792
  const violations = checkPluginTerraform(root);
12605
12793
  if (violations.length > 0) {
12606
12794
  console.error("\u2717 plugin Terraform guard: event subscriptions with no infrastructure\n");
@@ -12611,13 +12799,13 @@ async function runPluginTerraformCheck() {
12611
12799
  }
12612
12800
 
12613
12801
  // src/scripts/check-plugin-tool-supply.ts
12614
- import { existsSync as existsSync48 } from "fs";
12615
- import { join as join56 } from "path";
12616
- import { execa as execa21 } from "execa";
12802
+ import { existsSync as existsSync49 } from "fs";
12803
+ import { join as join57 } from "path";
12804
+ import { execa as execa22 } from "execa";
12617
12805
 
12618
12806
  // src/lib/plugin-tool-supply-audit.ts
12619
- import { existsSync as existsSync47, readFileSync as readFileSync40, readdirSync as readdirSync25, statSync as statSync15 } from "fs";
12620
- import { join as join55 } from "path";
12807
+ import { existsSync as existsSync48, readFileSync as readFileSync40, readdirSync as readdirSync25, statSync as statSync15 } from "fs";
12808
+ import { join as join56 } from "path";
12621
12809
 
12622
12810
  // src/lib/openrouter-model-snapshot.ts
12623
12811
  var OPENROUTER_MODEL_SNAPSHOT_FETCHED_AT = "2026-08-10T06:39:01Z";
@@ -13034,7 +13222,7 @@ function listDirs(root) {
13034
13222
  }
13035
13223
  return entries.filter((e) => {
13036
13224
  try {
13037
- return statSync15(join55(root, e)).isDirectory();
13225
+ return statSync15(join56(root, e)).isDirectory();
13038
13226
  } catch {
13039
13227
  return false;
13040
13228
  }
@@ -13050,7 +13238,7 @@ function walkFiles2(root, accept, skipDir) {
13050
13238
  return;
13051
13239
  }
13052
13240
  for (const entry of entries) {
13053
- const p = join55(dir, entry);
13241
+ const p = join56(dir, entry);
13054
13242
  let st;
13055
13243
  try {
13056
13244
  st = statSync15(p);
@@ -13076,14 +13264,14 @@ function pluginPythonFiles(pluginDir2) {
13076
13264
  );
13077
13265
  }
13078
13266
  function pluginTerraformFiles(pluginDir2) {
13079
- const tfDir = join55(pluginDir2, "terraform");
13267
+ const tfDir = join56(pluginDir2, "terraform");
13080
13268
  let entries;
13081
13269
  try {
13082
13270
  entries = readdirSync25(tfDir);
13083
13271
  } catch {
13084
13272
  return [];
13085
13273
  }
13086
- return entries.filter((e) => e.endsWith(".tf")).map((e) => join55(tfDir, e)).sort();
13274
+ return entries.filter((e) => e.endsWith(".tf")).map((e) => join56(tfDir, e)).sort();
13087
13275
  }
13088
13276
  function extractManifestTools(manifestText) {
13089
13277
  let parsed;
@@ -13335,8 +13523,8 @@ function isSnapshotStale(fetchedAt, now) {
13335
13523
  function normalizeModelId(id) {
13336
13524
  return id.endsWith(":online") ? id.slice(0, -":online".length) : id;
13337
13525
  }
13338
- var CONFIG_PY_PATH = join55("services", "api", "src", "api", "config.py");
13339
- var ORCHESTRATION_SCHEMA_PATH = join55(
13526
+ var CONFIG_PY_PATH = join56("services", "api", "src", "api", "config.py");
13527
+ var ORCHESTRATION_SCHEMA_PATH = join56(
13340
13528
  "services",
13341
13529
  "api",
13342
13530
  "src",
@@ -13348,10 +13536,10 @@ function auditDeclaredModelIds(repoRoot, options = {}) {
13348
13536
  const knownModelIds = options.knownModelIds ?? OPENROUTER_MODEL_IDS;
13349
13537
  const snapshotFetchedAt = options.snapshotFetchedAt ?? OPENROUTER_MODEL_SNAPSHOT_FETCHED_AT;
13350
13538
  const now = options.now ?? /* @__PURE__ */ new Date();
13351
- const configPath = join55(repoRoot, CONFIG_PY_PATH);
13352
- const orchestrationPath = join55(repoRoot, ORCHESTRATION_SCHEMA_PATH);
13353
- const configMissing = !existsSync47(configPath);
13354
- const orchestrationSchemaMissing = !existsSync47(orchestrationPath);
13539
+ const configPath = join56(repoRoot, CONFIG_PY_PATH);
13540
+ const orchestrationPath = join56(repoRoot, ORCHESTRATION_SCHEMA_PATH);
13541
+ const configMissing = !existsSync48(configPath);
13542
+ const orchestrationSchemaMissing = !existsSync48(orchestrationPath);
13355
13543
  const knownSet = new Set(knownModelIds);
13356
13544
  const snapshotEmpty = knownModelIds.length === 0;
13357
13545
  const snapshotStale = isSnapshotStale(snapshotFetchedAt, now);
@@ -13422,7 +13610,7 @@ function auditDeclaredModelIds(repoRoot, options = {}) {
13422
13610
  function discoverPluginDirs(pluginsRoot) {
13423
13611
  return listDirs(pluginsRoot).filter((name) => {
13424
13612
  try {
13425
- return statSync15(join55(pluginsRoot, name, "biffo.plugin.json")).isFile();
13613
+ return statSync15(join56(pluginsRoot, name, "biffo.plugin.json")).isFile();
13426
13614
  } catch {
13427
13615
  return false;
13428
13616
  }
@@ -13435,8 +13623,8 @@ function auditPluginToolSupply(pluginsRoot) {
13435
13623
  let terraformBlind = false;
13436
13624
  let totalDeclaredTools = 0;
13437
13625
  for (const name of pluginNames) {
13438
- const pluginDir2 = join55(pluginsRoot, name);
13439
- const manifestText = readFileSync40(join55(pluginDir2, "biffo.plugin.json"), "utf8");
13626
+ const pluginDir2 = join56(pluginsRoot, name);
13627
+ const manifestText = readFileSync40(join56(pluginDir2, "biffo.plugin.json"), "utf8");
13440
13628
  const manifest = extractManifestTools(manifestText);
13441
13629
  if (manifest.parseError) {
13442
13630
  findings.push({
@@ -13534,7 +13722,7 @@ function auditPluginToolSupply(pluginsRoot) {
13534
13722
  requiredEnvVars: envResult.envVars,
13535
13723
  missingEnvVars: anyWired ? [] : envResult.envVars,
13536
13724
  status: anyWired ? "ok" : "missing-env",
13537
- detail: anyWired ? `${entry.predicate}() is satisfiable: at least one of ${JSON.stringify(envResult.envVars)} is wired in Terraform` : `${entry.predicate}() reads ${JSON.stringify(envResult.envVars)} \u2014 NONE of these are wired by any environment_variables block under ${join55(pluginDir2, "terraform")}, so this deployment can never supply it`
13725
+ detail: anyWired ? `${entry.predicate}() is satisfiable: at least one of ${JSON.stringify(envResult.envVars)} is wired in Terraform` : `${entry.predicate}() reads ${JSON.stringify(envResult.envVars)} \u2014 NONE of these are wired by any environment_variables block under ${join56(pluginDir2, "terraform")}, so this deployment can never supply it`
13538
13726
  });
13539
13727
  }
13540
13728
  }
@@ -13565,10 +13753,10 @@ function auditPluginToolSupply(pluginsRoot) {
13565
13753
 
13566
13754
  // src/scripts/check-plugin-tool-supply.ts
13567
13755
  async function runPluginToolSupplyCheck() {
13568
- const root = (await execa21("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
13756
+ const root = (await execa22("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
13569
13757
  let allOk = true;
13570
- const pluginsRoot = join56(root, "services", "_plugins");
13571
- if (!existsSync48(pluginsRoot)) {
13758
+ const pluginsRoot = join57(root, "services", "_plugins");
13759
+ if (!existsSync49(pluginsRoot)) {
13572
13760
  console.log("\u2713 plugin tool-supply guard: no services/_plugins/ \u2014 nothing to audit");
13573
13761
  } else {
13574
13762
  const report = auditPluginToolSupply(pluginsRoot);
@@ -13598,8 +13786,8 @@ async function runPluginToolSupplyCheck() {
13598
13786
  console.log(`\u2713 plugin tool-supply guard: ${report.summary}`);
13599
13787
  }
13600
13788
  }
13601
- const servicesApiRoot = join56(root, "services", "api");
13602
- if (!existsSync48(servicesApiRoot)) {
13789
+ const servicesApiRoot = join57(root, "services", "api");
13790
+ if (!existsSync49(servicesApiRoot)) {
13603
13791
  console.log("\u2713 plugin model-id guard: no services/api/ \u2014 nothing to audit");
13604
13792
  } else {
13605
13793
  const modelReport = auditDeclaredModelIds(root);
@@ -13645,7 +13833,7 @@ async function runPluginToolSupplyCheck() {
13645
13833
  }
13646
13834
 
13647
13835
  // src/scripts/check-release-subject.ts
13648
- import { execa as execa22 } from "execa";
13836
+ import { execa as execa23 } from "execa";
13649
13837
 
13650
13838
  // src/lib/release-version.ts
13651
13839
  var MINOR_TYPES = /* @__PURE__ */ new Set(["feat"]);
@@ -13682,7 +13870,7 @@ async function fetchPrTitleViaGh({
13682
13870
  PR_NUMBER,
13683
13871
  GH_REPO
13684
13872
  }) {
13685
- const { stdout } = await execa22(
13873
+ const { stdout } = await execa23(
13686
13874
  "gh",
13687
13875
  ["pr", "view", PR_NUMBER, "--repo", GH_REPO, "--json", "title", "--jq", ".title"],
13688
13876
  { env: { ...process.env, GH_TOKEN } }
@@ -13718,7 +13906,7 @@ async function resolveReleaseSubject({
13718
13906
  );
13719
13907
  }
13720
13908
  }
13721
- return (await execa22("git", ["log", "-1", "--format=%s"], { cwd })).stdout.trim();
13909
+ return (await execa23("git", ["log", "-1", "--format=%s"], { cwd })).stdout.trim();
13722
13910
  }
13723
13911
  async function runReleaseSubjectCheck(argv) {
13724
13912
  const base = process.env["GITHUB_BASE_REF"] ?? argv[0];
@@ -13726,9 +13914,9 @@ async function runReleaseSubjectCheck(argv) {
13726
13914
  console.error("No base ref: set GITHUB_BASE_REF or pass a base branch as the first argument.");
13727
13915
  process.exit(2);
13728
13916
  }
13729
- const root = (await execa22("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
13730
- await execa22("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
13731
- const { stdout } = await execa22("git", ["diff", "--name-only", `origin/${base}...HEAD`], {
13917
+ const root = (await execa23("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
13918
+ await execa23("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
13919
+ const { stdout } = await execa23("git", ["diff", "--name-only", `origin/${base}...HEAD`], {
13732
13920
  cwd: root
13733
13921
  });
13734
13922
  const changedFiles = stdout.split("\n").map((s) => s.trim()).filter(Boolean);
@@ -13979,13 +14167,13 @@ async function runSharedFileReductionCheck(args) {
13979
14167
  }
13980
14168
 
13981
14169
  // src/scripts/check-skeleton-drift.ts
13982
- import { existsSync as existsSync49, readdirSync as readdirSync27 } from "fs";
13983
- import { join as join58 } from "path";
13984
- import { execa as execa23 } from "execa";
14170
+ import { existsSync as existsSync50, readdirSync as readdirSync27 } from "fs";
14171
+ import { join as join59 } from "path";
14172
+ import { execa as execa24 } from "execa";
13985
14173
 
13986
14174
  // src/lib/skeleton-drift-guard.ts
13987
14175
  import { readFileSync as readFileSync43, readdirSync as readdirSync26, statSync as statSync16 } from "fs";
13988
- import { join as join57 } from "path";
14176
+ import { join as join58 } from "path";
13989
14177
  var isWorkflow = (rel) => rel.startsWith(".github/workflows/") && (rel.endsWith(".yml") || rel.endsWith(".yaml"));
13990
14178
  var isRootLayout = (rel) => rel.endsWith("src/app/layout.tsx");
13991
14179
  var uncommented = (contents) => contents.split("\n").filter((line) => !/^\s*(\/\/|\/\*|\*)/.test(line)).join("\n");
@@ -14049,7 +14237,7 @@ function walk(dir, base = dir) {
14049
14237
  }
14050
14238
  for (const entry of entries) {
14051
14239
  if (entry === ".venv" || entry === "node_modules" || entry === ".git") continue;
14052
- const abs = join57(dir, entry);
14240
+ const abs = join58(dir, entry);
14053
14241
  let isDir;
14054
14242
  try {
14055
14243
  isDir = statSync16(abs).isDirectory();
@@ -14071,7 +14259,7 @@ function auditSkeleton(skeletonRoot, name, rules = SKELETON_RULES) {
14071
14259
  if (!rule.appliesTo(rel)) continue;
14072
14260
  let contents;
14073
14261
  try {
14074
- contents = readFileSync43(join57(skeletonRoot, rel), "utf8");
14262
+ contents = readFileSync43(join58(skeletonRoot, rel), "utf8");
14075
14263
  } catch {
14076
14264
  continue;
14077
14265
  }
@@ -14100,23 +14288,23 @@ function formatViolations2(violations) {
14100
14288
 
14101
14289
  // src/scripts/check-skeleton-drift.ts
14102
14290
  function discoverSkeletons(root) {
14103
- const skeletonsDir = join58(root, "_skeletons");
14291
+ const skeletonsDir = join59(root, "_skeletons");
14104
14292
  let entries;
14105
14293
  try {
14106
14294
  entries = readdirSync27(skeletonsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
14107
14295
  } catch {
14108
14296
  return [];
14109
14297
  }
14110
- return entries.filter((name) => existsSync49(join58(skeletonsDir, name, ".github", "workflows", "ci.yml"))).sort();
14298
+ return entries.filter((name) => existsSync50(join59(skeletonsDir, name, ".github", "workflows", "ci.yml"))).sort();
14111
14299
  }
14112
14300
  async function runSkeletonDriftCheck() {
14113
- const root = (await execa23("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
14301
+ const root = (await execa24("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
14114
14302
  const skeletons = discoverSkeletons(root);
14115
14303
  let filesConsidered = 0;
14116
14304
  for (const name of skeletons) {
14117
- const skeletonRoot = join58(root, "_skeletons", name);
14305
+ const skeletonRoot = join59(root, "_skeletons", name);
14118
14306
  filesConsidered += findWorkflowFiles(skeletonRoot).length;
14119
- if (existsSync49(join58(skeletonRoot, "apps", "frontend", "src", "app", "layout.tsx"))) {
14307
+ if (existsSync50(join59(skeletonRoot, "apps", "frontend", "src", "app", "layout.tsx"))) {
14120
14308
  filesConsidered += 1;
14121
14309
  }
14122
14310
  }
@@ -14130,7 +14318,7 @@ async function runSkeletonDriftCheck() {
14130
14318
  process.exit(1);
14131
14319
  }
14132
14320
  const violations = skeletons.flatMap(
14133
- (name) => auditSkeleton(join58(root, "_skeletons", name), name)
14321
+ (name) => auditSkeleton(join59(root, "_skeletons", name), name)
14134
14322
  );
14135
14323
  if (violations.length > 0) {
14136
14324
  console.error("\u2717 Skeleton-drift guard: drift found between this repo and its scaffolding\n");
@@ -14142,9 +14330,9 @@ async function runSkeletonDriftCheck() {
14142
14330
  }
14143
14331
 
14144
14332
  // src/scripts/check-terraform-input.ts
14145
- import { execa as execa24 } from "execa";
14333
+ import { execa as execa25 } from "execa";
14146
14334
  async function runTerraformInputCheck() {
14147
- const root = (await execa24("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
14335
+ const root = (await execa25("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
14148
14336
  const files = findWorkflowFiles(root);
14149
14337
  console.log(`audited ${files.length} workflow file(s) under ${root}`);
14150
14338
  if (files.length === 0) {
@@ -14167,11 +14355,16 @@ async function runTerraformInputCheck() {
14167
14355
 
14168
14356
  // src/commands/check.ts
14169
14357
  var checkCommand = new Command24("check").description(
14170
- "Repo guards (ownership, release subject, plugin terraform, plugin collisions, eventbridge-log-permissions, plugin-tool-supply, core-direct-paths, cognito-invite-template, lambda-output, pipe-trap, codeql-suppression, skeleton-drift, terraform-input, plugin-allowlist-convention) run in CI and git hooks, plus out-of-band audits (branch protection, plugin-staleness)"
14358
+ "Repo guards (ownership, release subject, plugin terraform, plugin collisions, eventbridge-log-permissions, plugin-tool-supply, core-direct-paths, cognito-invite-template, lambda-output, pipe-trap, codeql-suppression, skeleton-drift, terraform-input, plugin-allowlist-convention, migration-body-change) run in CI and git hooks, plus out-of-band audits (branch protection, plugin-staleness)"
14171
14359
  );
14172
14360
  checkCommand.command("ownership").description("Refuse changes to template-owned paths in an instance (#370)").argument("[base]", "Base branch to diff against; defaults to $GITHUB_BASE_REF").option("--staged <messageFile>", "Check staged changes instead of a branch diff (commit hook)").allowExcessArguments(true).action(async () => {
14173
14361
  await runOwnershipCheck(rawArgsAfter("ownership"));
14174
14362
  });
14363
+ checkCommand.command("migration-body-change").description(
14364
+ "Refuse a PR that changes an already-released migration's HASHED body (DDL, not a docstring or comment) with no `# biffo:body-change:` marker (#751) \u2014 mirrors migrationBodyHash's normalisation, so a docstring-only edit (#931) stays silent. Reporting-only classification, not enforcement: this only requires the marker exist, it does not act on what it declares."
14365
+ ).argument("[base]", "Base branch to diff against; defaults to $GITHUB_BASE_REF").action(async () => {
14366
+ await runMigrationBodyChangeCheck(rawArgsAfter("migration-body-change"));
14367
+ });
14175
14368
  checkCommand.command("release-subject").description("Require a Conventional Commits PR title on template-owned changes (#423)").argument("[base]", "Base branch to diff against; defaults to $GITHUB_BASE_REF").action(async () => {
14176
14369
  await runReleaseSubjectCheck(rawArgsAfter("release-subject"));
14177
14370
  });
@@ -14280,8 +14473,8 @@ function rawArgsAfter(subcommand) {
14280
14473
  }
14281
14474
 
14282
14475
  // src/commands/doctor.ts
14283
- import { existsSync as existsSync50, readFileSync as readFileSync44 } from "fs";
14284
- import { join as join59, resolve as resolve20 } from "path";
14476
+ import { existsSync as existsSync51, readFileSync as readFileSync44 } from "fs";
14477
+ import { join as join60, resolve as resolve20 } from "path";
14285
14478
  import chalk21 from "chalk";
14286
14479
  import { Command as Command25 } from "commander";
14287
14480
 
@@ -14460,8 +14653,8 @@ async function runDoctor(options, deps = { git: new GitAdapter() }) {
14460
14653
  return runDoctorChecks(facts);
14461
14654
  }
14462
14655
  function readLocalCoreVersion(cwd) {
14463
- const path = join59(cwd, INSTANCE_CORE_FILE);
14464
- if (!existsSync50(path)) return null;
14656
+ const path = join60(cwd, INSTANCE_CORE_FILE);
14657
+ if (!existsSync51(path)) return null;
14465
14658
  try {
14466
14659
  return extractVersionField(readFileSync44(path, "utf8"));
14467
14660
  } catch {
@@ -14483,8 +14676,8 @@ function extractVersionField(contents) {
14483
14676
  return match?.[1] ?? null;
14484
14677
  }
14485
14678
  function readFossil(cwd) {
14486
- const path = join59(cwd, CORE_VERSION_FILE);
14487
- if (!existsSync50(path)) return null;
14679
+ const path = join60(cwd, CORE_VERSION_FILE);
14680
+ if (!existsSync51(path)) return null;
14488
14681
  try {
14489
14682
  const value = readFileSync44(path, "utf8").trim();
14490
14683
  return value === "" ? null : value;
@@ -14935,13 +15128,13 @@ import { fileURLToPath as fileURLToPath6 } from "url";
14935
15128
  import { Command as Command27 } from "commander";
14936
15129
 
14937
15130
  // src/lib/packaged-scripts.ts
14938
- import { existsSync as existsSync51 } from "fs";
14939
- import { dirname as dirname11, join as join60 } from "path";
15131
+ import { existsSync as existsSync52 } from "fs";
15132
+ import { dirname as dirname11, join as join61 } from "path";
14940
15133
  function findPackagedScript(startDir, relativePath) {
14941
15134
  let dir = startDir;
14942
15135
  for (; ; ) {
14943
- const candidate = join60(dir, relativePath);
14944
- if (existsSync51(candidate)) return candidate;
15136
+ const candidate = join61(dir, relativePath);
15137
+ if (existsSync52(candidate)) return candidate;
14945
15138
  const parent = dirname11(dir);
14946
15139
  if (parent === dir) return null;
14947
15140
  dir = parent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.289.0",
3
+ "version": "0.290.1",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",