@biffo/cli 0.301.7 → 0.301.8

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.
@@ -252,6 +252,41 @@ export const DEPLOY_ONLY_PREFIXES = [
252
252
  /** An issue reference GitHub linkifies: `#12` or `owner/repo#12`. */
253
253
  const REFERENCE = '(?:[\\w.-]+/[\\w.-]+)?#\\d+'
254
254
 
255
+ /**
256
+ * The whitespace allowed between a closing keyword (plus its optional `:`)
257
+ * and the issue reference it governs, in `closingReferences`,
258
+ * `deliberateClosingReferences` and `negatedClosingReferences` alike — all
259
+ * three end their pattern with the identical `\b:?\s+(REFERENCE)` tail, and
260
+ * all three had the identical bug.
261
+ *
262
+ * A bare `\s+` matches across an ARBITRARY run of blank lines, so a
263
+ * markdown heading ending in a closing keyword matched against a `#N` that
264
+ * starts a completely unrelated paragraph two lines later — nothing in the
265
+ * character class stopped it from spanning the blank line between them.
266
+ * Real instance, PR #1789's own body:
267
+ *
268
+ * ## What this fixes
269
+ *
270
+ * #1717 fixed one of two acceptance routes ...
271
+ *
272
+ * "fixes" is the last word of the heading; "#1717" is the first token of
273
+ * the next paragraph's own, unrelated sentence. `closingReferences`
274
+ * nonetheless reported `#1717` as a hit — `\s+` matched the `"\n\n"`
275
+ * between them — and that false hit reached the `deploy-only-path` branch
276
+ * of `assess`, which (unlike the two ground-truth branches) has no
277
+ * reconciliation step and fails on whatever `closingReferences` reports
278
+ * verbatim. Confirmed against GitHub's own ground truth for that PR
279
+ * (`gh pr view 1789 --json closingIssuesReferences`): only `#1718` — the
280
+ * genuine `Closes #1718` trailer — is ever returned; #1717 never was.
281
+ *
282
+ * A single line break is still allowed: nothing in this repo's corpus
283
+ * needs it, but there is no evidence GitHub REQUIRES same-line adjacency
284
+ * either, and the conservative/fail-closed choice for a guard whose job is
285
+ * "don't miss a real close" is to keep allowing one newline and exclude
286
+ * only a genuine blank line (two or more).
287
+ */
288
+ const KEYWORD_REFERENCE_GAP = '(?:[ \\t]+|[ \\t]*\\n[ \\t]*)'
289
+
255
290
  /**
256
291
  * Blank out fenced code blocks and inline code spans, preserving line count.
257
292
  *
@@ -290,7 +325,10 @@ export function stripCode(body) {
290
325
  export function closingReferences(body, { code = true } = {}) {
291
326
  if (!body) return []
292
327
  const withoutCode = code ? stripCode(body) : body
293
- const pattern = new RegExp(`\\b(${CLOSING_KEYWORDS.join('|')})\\b:?\\s+(${REFERENCE})`, 'gi')
328
+ const pattern = new RegExp(
329
+ `\\b(${CLOSING_KEYWORDS.join('|')})\\b:?${KEYWORD_REFERENCE_GAP}(${REFERENCE})`,
330
+ 'gi',
331
+ )
294
332
  return [...withoutCode.matchAll(pattern)].map((m) => m[2])
295
333
  }
296
334
 
@@ -361,7 +399,7 @@ export function deliberateClosingReferences(text, { code = true } = {}) {
361
399
  while ((m = boundary.exec(stripped))) starts.add(m.index + m[0].length)
362
400
 
363
401
  const pattern = new RegExp(
364
- `^${CLAUSE_DECORATION}\\s*(${CLOSING_KEYWORDS.join('|')})\\b:?\\s+(${REFERENCE})`,
402
+ `^${CLAUSE_DECORATION}\\s*(${CLOSING_KEYWORDS.join('|')})\\b:?${KEYWORD_REFERENCE_GAP}(${REFERENCE})`,
365
403
  'i',
366
404
  )
367
405
  const found = []
@@ -435,7 +473,7 @@ export function negatedClosingReferences(body, { code = true } = {}) {
435
473
  const text = code ? stripCode(body) : body
436
474
  const authored = body.split('\n')
437
475
  const pattern = new RegExp(
438
- `(?:${NEGATIONS.join('|')})\\s+(?:${CLOSING_KEYWORDS.join('|')})\\b:?\\s+(${REFERENCE})`,
476
+ `(?:${NEGATIONS.join('|')})\\s+(?:${CLOSING_KEYWORDS.join('|')})\\b:?${KEYWORD_REFERENCE_GAP}(${REFERENCE})`,
439
477
  'gi',
440
478
  )
441
479
  return [...text.matchAll(pattern)].map((m) => {
@@ -252,6 +252,41 @@ export const DEPLOY_ONLY_PREFIXES = [
252
252
  /** An issue reference GitHub linkifies: `#12` or `owner/repo#12`. */
253
253
  const REFERENCE = '(?:[\\w.-]+/[\\w.-]+)?#\\d+'
254
254
 
255
+ /**
256
+ * The whitespace allowed between a closing keyword (plus its optional `:`)
257
+ * and the issue reference it governs, in `closingReferences`,
258
+ * `deliberateClosingReferences` and `negatedClosingReferences` alike — all
259
+ * three end their pattern with the identical `\b:?\s+(REFERENCE)` tail, and
260
+ * all three had the identical bug.
261
+ *
262
+ * A bare `\s+` matches across an ARBITRARY run of blank lines, so a
263
+ * markdown heading ending in a closing keyword matched against a `#N` that
264
+ * starts a completely unrelated paragraph two lines later — nothing in the
265
+ * character class stopped it from spanning the blank line between them.
266
+ * Real instance, PR #1789's own body:
267
+ *
268
+ * ## What this fixes
269
+ *
270
+ * #1717 fixed one of two acceptance routes ...
271
+ *
272
+ * "fixes" is the last word of the heading; "#1717" is the first token of
273
+ * the next paragraph's own, unrelated sentence. `closingReferences`
274
+ * nonetheless reported `#1717` as a hit — `\s+` matched the `"\n\n"`
275
+ * between them — and that false hit reached the `deploy-only-path` branch
276
+ * of `assess`, which (unlike the two ground-truth branches) has no
277
+ * reconciliation step and fails on whatever `closingReferences` reports
278
+ * verbatim. Confirmed against GitHub's own ground truth for that PR
279
+ * (`gh pr view 1789 --json closingIssuesReferences`): only `#1718` — the
280
+ * genuine `Closes #1718` trailer — is ever returned; #1717 never was.
281
+ *
282
+ * A single line break is still allowed: nothing in this repo's corpus
283
+ * needs it, but there is no evidence GitHub REQUIRES same-line adjacency
284
+ * either, and the conservative/fail-closed choice for a guard whose job is
285
+ * "don't miss a real close" is to keep allowing one newline and exclude
286
+ * only a genuine blank line (two or more).
287
+ */
288
+ const KEYWORD_REFERENCE_GAP = '(?:[ \\t]+|[ \\t]*\\n[ \\t]*)'
289
+
255
290
  /**
256
291
  * Blank out fenced code blocks and inline code spans, preserving line count.
257
292
  *
@@ -290,7 +325,10 @@ export function stripCode(body) {
290
325
  export function closingReferences(body, { code = true } = {}) {
291
326
  if (!body) return []
292
327
  const withoutCode = code ? stripCode(body) : body
293
- const pattern = new RegExp(`\\b(${CLOSING_KEYWORDS.join('|')})\\b:?\\s+(${REFERENCE})`, 'gi')
328
+ const pattern = new RegExp(
329
+ `\\b(${CLOSING_KEYWORDS.join('|')})\\b:?${KEYWORD_REFERENCE_GAP}(${REFERENCE})`,
330
+ 'gi',
331
+ )
294
332
  return [...withoutCode.matchAll(pattern)].map((m) => m[2])
295
333
  }
296
334
 
@@ -361,7 +399,7 @@ export function deliberateClosingReferences(text, { code = true } = {}) {
361
399
  while ((m = boundary.exec(stripped))) starts.add(m.index + m[0].length)
362
400
 
363
401
  const pattern = new RegExp(
364
- `^${CLAUSE_DECORATION}\\s*(${CLOSING_KEYWORDS.join('|')})\\b:?\\s+(${REFERENCE})`,
402
+ `^${CLAUSE_DECORATION}\\s*(${CLOSING_KEYWORDS.join('|')})\\b:?${KEYWORD_REFERENCE_GAP}(${REFERENCE})`,
365
403
  'i',
366
404
  )
367
405
  const found = []
@@ -435,7 +473,7 @@ export function negatedClosingReferences(body, { code = true } = {}) {
435
473
  const text = code ? stripCode(body) : body
436
474
  const authored = body.split('\n')
437
475
  const pattern = new RegExp(
438
- `(?:${NEGATIONS.join('|')})\\s+(?:${CLOSING_KEYWORDS.join('|')})\\b:?\\s+(${REFERENCE})`,
476
+ `(?:${NEGATIONS.join('|')})\\s+(?:${CLOSING_KEYWORDS.join('|')})\\b:?${KEYWORD_REFERENCE_GAP}(${REFERENCE})`,
439
477
  'gi',
440
478
  )
441
479
  return [...text.matchAll(pattern)].map((m) => {
package/dist/index.js CHANGED
@@ -646,7 +646,7 @@ import {
646
646
  writeFileSync as writeFileSync2
647
647
  } from "fs";
648
648
  import { tmpdir } from "os";
649
- import { dirname as dirname3, join as join5 } from "path";
649
+ import { basename, dirname as dirname3, join as join5 } from "path";
650
650
  import { z as z4 } from "zod";
651
651
 
652
652
  // src/lib/core-ownership-guard.ts
@@ -806,6 +806,76 @@ var EMPTY_SUMMARY = () => ({
806
806
  removed: 0,
807
807
  "remove-conflict": 0
808
808
  });
809
+ function pathHasDivergenceTrailerInHistory(oursDir, path, manifest, git = defaultGit) {
810
+ let out;
811
+ try {
812
+ out = git(["-C", oursDir, "log", "--follow", "--format=%x02%H%x01%B", "--", path]);
813
+ } catch {
814
+ return false;
815
+ }
816
+ const entries = out.split("").map((entry) => entry.split("")).filter((parts) => parts.length === 2 && parts[0] !== "");
817
+ const withTrailer = entries.filter(([, message]) => parseDivergenceTrailer(message) !== null);
818
+ if (withTrailer.length === 0) return false;
819
+ const graftedBoundary = shallowGraftCommits(oursDir, git);
820
+ for (const [hash, message] of withTrailer) {
821
+ if (graftedBoundary.has(hash)) continue;
822
+ let touched;
823
+ try {
824
+ touched = git([
825
+ "-C",
826
+ oursDir,
827
+ "diff-tree",
828
+ "--no-commit-id",
829
+ "--name-only",
830
+ "-r",
831
+ "--root",
832
+ hash
833
+ ]).split("\n").filter((p) => p !== "");
834
+ } catch {
835
+ touched = [];
836
+ }
837
+ const templateOwnedTouched = touched.filter((p) => isTemplateOwned(p, manifest));
838
+ if (templateOwnedTouched.length === 1 && templateOwnedTouched[0] === path) return true;
839
+ const reason = parseDivergenceTrailer(message);
840
+ if (reason !== null && trailerNamesPath(reason, path, templateOwnedTouched)) return true;
841
+ }
842
+ return false;
843
+ }
844
+ function shallowGraftCommits(oursDir, git) {
845
+ let isShallow;
846
+ try {
847
+ isShallow = git(["-C", oursDir, "rev-parse", "--is-shallow-repository"]).trim();
848
+ } catch {
849
+ return /* @__PURE__ */ new Set();
850
+ }
851
+ if (isShallow !== "true") return /* @__PURE__ */ new Set();
852
+ try {
853
+ const out = git(["-C", oursDir, "rev-list", "--max-parents=0", "HEAD"]);
854
+ return new Set(
855
+ out.split("\n").map((s) => s.trim()).filter((s) => s !== "")
856
+ );
857
+ } catch {
858
+ return /* @__PURE__ */ new Set();
859
+ }
860
+ }
861
+ function trailerNamesPath(reason, path, templateOwnedTouched) {
862
+ const isBoundary = (ch) => ch === void 0 || !/[A-Za-z0-9_.-]/.test(ch);
863
+ const namedAt = (needle) => {
864
+ if (needle === "") return false;
865
+ let idx = 0;
866
+ for (; ; ) {
867
+ idx = reason.indexOf(needle, idx);
868
+ if (idx === -1) return false;
869
+ if (isBoundary(reason[idx - 1]) && isBoundary(reason[idx + needle.length])) return true;
870
+ idx += 1;
871
+ }
872
+ };
873
+ if (namedAt(path)) return true;
874
+ const base = basename(path);
875
+ if (!namedAt(base)) return false;
876
+ const sharingBasename = templateOwnedTouched.filter((p) => basename(p) === base);
877
+ return sharingBasename.length <= 1;
878
+ }
809
879
  async function planCoreUpgrade(options) {
810
880
  const mergeFile = options.mergeFile ?? gitMergeFile;
811
881
  const trackedOnly = { trackedOnly: true };
@@ -819,7 +889,21 @@ async function planCoreUpgrade(options) {
819
889
  const ours = new Set(listTemplateOwnedFiles(options.oursDir, options.manifest));
820
890
  const theirs = new Set(listTemplateOwnedFiles(options.theirsDir, options.manifest, trackedOnly));
821
891
  const divergentPrefixes = readDivergenceConfig(options.oursDir).warnOnly.map((e) => e.prefix);
822
- const isDeclaredDivergent = (path) => divergentPrefixes.some((prefix) => path.startsWith(prefix));
892
+ const trailerDivergenceCache = /* @__PURE__ */ new Map();
893
+ const isDeclaredDivergent = (path) => {
894
+ if (divergentPrefixes.some((prefix) => path.startsWith(prefix))) return true;
895
+ let viaTrailer = trailerDivergenceCache.get(path);
896
+ if (viaTrailer === void 0) {
897
+ viaTrailer = pathHasDivergenceTrailerInHistory(
898
+ options.oursDir,
899
+ path,
900
+ options.manifest,
901
+ options.git
902
+ );
903
+ trailerDivergenceCache.set(path, viaTrailer);
904
+ }
905
+ return viaTrailer;
906
+ };
823
907
  const paths = [.../* @__PURE__ */ new Set([...base, ...ours, ...theirs])].sort();
824
908
  const entries = [];
825
909
  const divergenceSkips = [];
@@ -3795,7 +3879,7 @@ async function runCoreUpgradeResolved(options, deps, cleanups) {
3795
3879
  }
3796
3880
  printNewInstanceSeams(newSeams);
3797
3881
  printAdoptionReport(adoption);
3798
- printOrphanReport(plan.orphaned, orphanRatchet);
3882
+ printOrphanReport(plan.orphaned, orphanRatchet, plan.entries.length);
3799
3883
  if (orphanRatchet.increased) {
3800
3884
  throw new Error(
3801
3885
  `${String(plan.orphaned.length)} unsanctioned instance file(s) under a template-owned path (baseline ${String(orphanRatchet.baseline)}) \u2014 see the list above (#1026). Move new instance-written files under a sanctioned carve-out (services/api/tests/instance/, modules/**/instance/) or otherwise reduce the count, then re-run.`
@@ -4370,11 +4454,11 @@ function printTargetFidelity(report, toVersion) {
4370
4454
  for (const f of report.findings) console.log(` ${chalk4.red(f.path)} \u2014 ${f.reason}`);
4371
4455
  console.log();
4372
4456
  }
4373
- function printOrphanReport(orphaned, ratchet) {
4457
+ function printOrphanReport(orphaned, ratchet, examined) {
4374
4458
  if (orphaned.length === 0 && ratchet.baseline === null) return;
4375
4459
  console.log(
4376
4460
  chalk4.bold(
4377
- ` ${String(orphaned.length)} unsanctioned instance file(s) under a template-owned path (#1026):`
4461
+ ` ${String(orphaned.length)} unsanctioned instance file(s) under a template-owned path (#1026), of ${String(examined)} template-owned path(s) examined:`
4378
4462
  )
4379
4463
  );
4380
4464
  for (const e of orphaned) console.log(` ${chalk4.yellow(e.path)}`);
@@ -9000,7 +9084,7 @@ function vendorPluginSeed(pluginSourceDir, manifest, cwd) {
9000
9084
 
9001
9085
  // src/lib/plugin-source-copy.ts
9002
9086
  import { copyFileSync as copyFileSync2, cpSync as cpSync4, mkdirSync as mkdirSync10 } from "fs";
9003
- import { basename, dirname as dirname9, join as join31 } from "path";
9087
+ import { basename as basename2, dirname as dirname9, join as join31 } from "path";
9004
9088
  var LOCAL_COPY_EXCLUDES = /* @__PURE__ */ new Set([
9005
9089
  ".git",
9006
9090
  ".venv",
@@ -9028,7 +9112,7 @@ async function copyPluginSource(sourceDir, targetDir) {
9028
9112
  mkdirSync10(targetDir, { recursive: true });
9029
9113
  cpSync4(sourceDir, targetDir, {
9030
9114
  recursive: true,
9031
- filter: (src) => !LOCAL_COPY_EXCLUDES.has(basename(src))
9115
+ filter: (src) => !LOCAL_COPY_EXCLUDES.has(basename2(src))
9032
9116
  });
9033
9117
  return { usedGitIgnoreRules: false };
9034
9118
  }
@@ -12591,7 +12675,7 @@ ${BOLD2}What to do${OFF2}
12591
12675
 
12592
12676
  // src/scripts/check-orphan-ratchet.ts
12593
12677
  import { existsSync as existsSync46 } from "fs";
12594
- import { basename as basename2 } from "path";
12678
+ import { basename as basename3 } from "path";
12595
12679
  function reportOrphan(entry, manifest) {
12596
12680
  console.error(` ${entry.path}`);
12597
12681
  const { templateOwnedMatch, nearestUserOwnedEntries } = explainOwnership(entry.path, manifest);
@@ -12624,7 +12708,7 @@ async function runOrphanRatchetCheck(opts = {}) {
12624
12708
  const theirsDir = opts.theirsDir ?? root;
12625
12709
  const instanceDir = opts.instanceDir ?? theirsDir;
12626
12710
  const baseDir = opts.baseDir ?? theirsDir;
12627
- const label = opts.label ?? (basename2(instanceDir) || instanceDir);
12711
+ const label = opts.label ?? (basename3(instanceDir) || instanceDir);
12628
12712
  if (instanceDir === theirsDir && baseDir === theirsDir) {
12629
12713
  console.log(
12630
12714
  "self-check mode: --instance-dir was not given, so it defaulted to --theirs-dir (this repo's own root) alongside --base-dir \u2014 all three trees are the same, so this run can only ever find zero orphans by construction. See this script's doc comment. Pass a real instance tree with --instance-dir for a check that can actually find something. biffo-template#1714."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.301.7",
3
+ "version": "0.301.8",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",