@mutmutco/cli 4.6.25 → 4.6.27

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/main.cjs CHANGED
@@ -25243,6 +25243,9 @@ var CMD_RE = /`mmi-cli ((?:[a-z][a-z-]*)(?: [a-z][a-z-]*){0,3})/g;
25243
25243
  var RETIRED_RE = /\b(retired|historical|removed|deleted|gone|no longer|superseded|legacy)\b/i;
25244
25244
  var ROOT_DOCS = ["README.md", "architecture.md"];
25245
25245
  var SKIP_WALK = ["docs/decisions/"];
25246
+ function formatDocRefFinding(finding) {
25247
+ return `${finding.doc}:${finding.line} [${finding.kind}] ${finding.detail}${finding.suggest?.length ? ` (did you mean ${finding.suggest[0]}?)` : ""}`;
25248
+ }
25246
25249
  function refFirstSegment(ref) {
25247
25250
  return ref.replace(/^(\.\/)+/, "").split("/")[0];
25248
25251
  }
@@ -25663,21 +25666,14 @@ function defaultIsIgnored(root, relPaths, exec3 = import_node_child_process6.exe
25663
25666
  throw error;
25664
25667
  }
25665
25668
  }
25666
- function defaultTrackedFirstSegments(root, firstSegments, exec3 = import_node_child_process6.execFileSync) {
25667
- if (firstSegments.length === 0) return /* @__PURE__ */ new Set();
25669
+ function defaultTrackedFiles(root, firstSegments, exec3 = import_node_child_process6.execFileSync) {
25668
25670
  try {
25669
25671
  const out = exec3("git", ["ls-files", "-z", "--", ...firstSegments.map((s) => `:(literal)${s}`)], {
25670
25672
  cwd: root,
25671
25673
  encoding: "utf8",
25672
25674
  maxBuffer: CHECK_IGNORE_MAX_BUFFER
25673
25675
  });
25674
- const tracked = /* @__PURE__ */ new Set();
25675
- for (const path2 of out.split("\0")) {
25676
- if (path2) for (const segment of firstSegments) {
25677
- if (path2 === segment || path2.startsWith(`${segment}/`)) tracked.add(segment);
25678
- }
25679
- }
25680
- return tracked;
25676
+ return out.split("\0").filter(Boolean);
25681
25677
  } catch (error) {
25682
25678
  if (error?.status === 128) return null;
25683
25679
  if (error?.code === "ENOENT") return null;
@@ -25721,7 +25717,12 @@ function runDocRefs(root, deps = {}) {
25721
25717
  const exists = deps.exists ?? import_node_fs19.existsSync;
25722
25718
  const listDocs = deps.listDocs ?? defaultListDocs;
25723
25719
  const isIgnored = deps.isIgnored ?? ((paths) => defaultIsIgnored(root, paths));
25724
- const trackedFirstSegments = deps.trackedFirstSegments ?? ((segs) => defaultTrackedFirstSegments(root, segs));
25720
+ let inventory;
25721
+ const trackedFiles = () => inventory === void 0 ? inventory = deps.trackedFiles ? deps.trackedFiles() : deps.trackedFirstSegments ? null : defaultTrackedFiles(root, []) : inventory;
25722
+ const trackedFirstSegments = deps.trackedFirstSegments ?? ((segs) => {
25723
+ const files = trackedFiles();
25724
+ return files == null ? null : new Set(segs.filter((seg) => files.some((path2) => path2 === seg || path2.startsWith(`${seg}/`))));
25725
+ });
25725
25726
  const commandPaths = deps.commandPaths ?? null;
25726
25727
  const walked = listDocs(root);
25727
25728
  const ignoredDocs = walked.length ? isIgnored(walked) : /* @__PURE__ */ new Set();
@@ -25761,6 +25762,18 @@ ${sourceMessages}`;
25761
25762
  (override ? warnings : findings).push(...stale);
25762
25763
  if (!/^Seed-Runbook-Affected:[ \t]*\S.*$/m.test(allMessages)) findings.push(...seedStale);
25763
25764
  }
25765
+ for (const finding of findings) {
25766
+ if (finding.kind !== "missing-path") continue;
25767
+ const directory = import_node_path16.posix.dirname(import_node_path16.posix.normalize(finding.detail)).split("/");
25768
+ const distance = (path2) => {
25769
+ const parts = import_node_path16.posix.dirname(path2).split("/");
25770
+ let common = 0;
25771
+ while (common < directory.length && common < parts.length && directory[common] === parts[common]) common += 1;
25772
+ return directory.length + parts.length - 2 * common;
25773
+ };
25774
+ const suggest = [...new Set(trackedFiles() ?? [])].filter((path2) => import_node_path16.posix.basename(path2) === import_node_path16.posix.basename(finding.detail) && exists((0, import_node_path16.join)(root, path2))).sort((a, b) => distance(a) - distance(b) || a.localeCompare(b)).slice(0, 3);
25775
+ if (suggest.length) finding.suggest = suggest;
25776
+ }
25764
25777
  return {
25765
25778
  ok: findings.length === 0,
25766
25779
  findings,
@@ -28166,10 +28179,10 @@ var rollout_plan_default = {
28166
28179
  note: "The v4.0.0 stamp happens at cut time (D6e #4463); until then the candidate is the origin/development head artifacts (built cli/dist + npm pack), identity proven by dist content hash (D6a)."
28167
28180
  },
28168
28181
  baseline: {
28169
- version: "4.6.25",
28170
- tag: "v4.6.25",
28171
- commit: "8ca2371aca5a",
28172
- npm: "@mutmutco/cli@4.6.25"
28182
+ version: "4.6.27",
28183
+ tag: "v4.6.27",
28184
+ commit: "b83c0c39cb1f",
28185
+ npm: "@mutmutco/cli@4.6.27"
28173
28186
  },
28174
28187
  exitCriterion: "fleet-n-of-n",
28175
28188
  hubOnlyShortcut: "forbidden",
@@ -28186,14 +28199,14 @@ var rollout_plan_default = {
28186
28199
  repo: "mutmutco/mmi-hub",
28187
28200
  role: "canary",
28188
28201
  schedule: "train",
28189
- v3Target: "v4.6.25"
28202
+ v3Target: "v4.6.27"
28190
28203
  }
28191
28204
  ],
28192
28205
  rollbackTrigger: "Any red inside the post-contract soak window: `devops train gate` FAIL attributable to the v4 doors, Hub endpoint health probe failure, a pre-v4 client admitted instead of receiving actionable HTTP 426, or npm consumer install/doctor failure on the v4-only dist.",
28193
28206
  rollback: {
28194
28207
  independent: true,
28195
- mechanism: "npm dist-tag latest -> 4.6.25 and redeploy the Hub Lambda from tag v4.6.25 (8ca2371aca5a); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
28196
- v3Target: "v4.6.25 (@mutmutco/cli@4.6.25, tag commit 8ca2371aca5a \u2014 last known-good release carrying the repo-index v4-only contract)"
28208
+ mechanism: "npm dist-tag latest -> 4.6.27 and redeploy the Hub Lambda from tag v4.6.27 (b83c0c39cb1f); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
28209
+ v3Target: "v4.6.27 (@mutmutco/cli@4.6.27, tag commit b83c0c39cb1f \u2014 last known-good release carrying the repo-index v4-only contract)"
28197
28210
  }
28198
28211
  },
28199
28212
  {
@@ -43918,6 +43931,20 @@ ${list}
43918
43931
  Co-authored-by: GitHub <noreply@github.com>
43919
43932
  `;
43920
43933
  }
43934
+ function checkQueueSquashOverride(title, pr, messages, root) {
43935
+ if (messages.length <= 1 || !messages.some((message2) => /^\s*Test-Policy-Override\s*:/im.test(`${message2.headline}
43936
+ ${message2.body}`)) || readMessageOverride2(githubQueueSquashMessage(title, pr, messages), root)) return void 0;
43937
+ return {
43938
+ mergeStatus: "failed",
43939
+ preparation: {
43940
+ status: "preparation-needed",
43941
+ reason: "queue-message-override",
43942
+ issueTargets: [],
43943
+ nextAction: "PR author: this repository squashes with COMMIT_MESSAGES, and GitHub composes a multi-commit squash as a list of every commit message followed by its own co-author block, so this Test-Policy-Override trailer would not be in the final trailer block the queue check reads. First, add one commit on this same branch whose message ends with a trailer block carrying the Test-Policy-Override waiver, push (no force-push), then rerun pr land on the same PR. This makes the waiver the final trailer block of the LAST commit. As a fallback, put the change in ONE commit whose message ends with a trailer block carrying the waiver: push a new branch whose single commit has the same tree, parented on the PR base; open it as a new PR and close this one, then rerun pr land. Keep repository settings unchanged."
43944
+ },
43945
+ error: "queue preparation needed: the multi-commit COMMIT_MESSAGES squash would bury the Test-Policy-Override waiver mid-message, so the queue check would fail malformed-override-trailer"
43946
+ };
43947
+ }
43921
43948
  async function inspectQueuePreparation(pr, repo, options) {
43922
43949
  const read = options.read ?? readApi;
43923
43950
  const queueRead = options.read ?? actorReadApi;
@@ -44031,21 +44058,9 @@ ${pull.body}`;
44031
44058
  ])];
44032
44059
  return { kind: "refused", result: preparation("queue-message", targets, `GitHub's unchanged ${policy.body} message is unsafe for ${targets.map((number) => `${repo}#${number}`).join(", ")}. ${closingGuardDiagnostic(verdict.message)} See preparation.nextAction for the exact \`mmi-cli devops pr edit ${pr} --repo ${repo} --body-file .jerv/tmp/pr-${pr}-body.md\` remedy.`) };
44033
44060
  }
44034
- if (method === "SQUASH" && policy.body === "COMMIT_MESSAGES" && messages.length > 1 && options.bodyText == null && messages.some((message2) => /^\s*Test-Policy-Override\s*:/im.test(`${message2.headline}
44035
- ${message2.body}`)) && !readMessageOverride2(githubQueueSquashMessage(pull.title, pr, messages), process.cwd())) {
44036
- return {
44037
- kind: "refused",
44038
- result: {
44039
- mergeStatus: "failed",
44040
- preparation: {
44041
- status: "preparation-needed",
44042
- reason: "queue-message-override",
44043
- issueTargets: [],
44044
- nextAction: "PR author: this repository squashes with COMMIT_MESSAGES, and GitHub composes a multi-commit squash as a list of every commit message followed by its own co-author block, so this Test-Policy-Override trailer would not be in the final trailer block the queue check reads. Put the change in ONE commit whose message ends with a trailer block carrying the waiver (no force-push: push a new branch whose single commit has the same tree, parented on the PR base; open it as a new PR and close this one), or make the waiver the final trailer block of the LAST commit, then rerun pr land. Keep repository settings unchanged."
44045
- },
44046
- error: "queue preparation needed: the multi-commit COMMIT_MESSAGES squash would bury the Test-Policy-Override waiver mid-message, so the queue check would fail malformed-override-trailer"
44047
- }
44048
- };
44061
+ if (method === "SQUASH" && policy.body === "COMMIT_MESSAGES" && options.bodyText == null) {
44062
+ const refusal = checkQueueSquashOverride(pull.title, pr, messages, process.cwd());
44063
+ if (refusal) return { kind: "refused", result: refusal };
44049
44064
  }
44050
44065
  const requiredBody = options.bodyText ?? composedBody;
44051
44066
  const prBodyPreservesOverrides = requiredBody != null && method === "SQUASH" && policy.body === "PR_BODY" && options.bodyText == null && prBodyPreservesCommitOverrides(messages, headline, pull.body, process.cwd());
@@ -47934,6 +47949,16 @@ function evaluateFilelessComposeGuard(input) {
47934
47949
  }
47935
47950
  return { ok: true };
47936
47951
  }
47952
+ function positionalOrRepoFlagTarget(commandName, positional, repoFlag) {
47953
+ const pos = positional?.trim() || void 0;
47954
+ const flag = repoFlag?.trim() || void 0;
47955
+ if (!pos || !flag) return pos ?? flag;
47956
+ const [a, b] = [pos.toLowerCase(), flag.toLowerCase()];
47957
+ const tail = (s) => s.split("/").pop();
47958
+ const same2 = a === b || (!a.includes("/") || !b.includes("/")) && tail(a) === tail(b);
47959
+ if (!same2) throw new Error(`${commandName}: positional target ${pos} and --repo ${flag} name different repos; pass one of them`);
47960
+ return pos;
47961
+ }
47937
47962
  function requireProjectTarget(commandName, explicitTarget, currentRepo) {
47938
47963
  const target = explicitTarget?.trim() || currentRepo?.trim();
47939
47964
  if (!target) {
@@ -56260,6 +56285,7 @@ function githubCustodyStore(repo, baseSha, api = defaultGitHubClient()) {
56260
56285
  }
56261
56286
  };
56262
56287
  }
56288
+ var DISPATCH_ABSENT_AFTER_MS = 18e4;
56263
56289
  var ReleaseCustodySession = class _ReleaseCustodySession {
56264
56290
  constructor(store, sha, state) {
56265
56291
  this.store = store;
@@ -56358,6 +56384,13 @@ var ReleaseCustodySession = class _ReleaseCustodySession {
56358
56384
  async reconcile(observer) {
56359
56385
  for (const effect of Object.values(this.state.effects).filter((e) => e.state === "intent")) {
56360
56386
  const observation = await observer(effect);
56387
+ if (!observation.observed && observation.absent) {
56388
+ await this.change((state) => {
56389
+ const { [effect.id]: _dropped, ...effects } = state.effects;
56390
+ return { ...state, effects };
56391
+ });
56392
+ continue;
56393
+ }
56361
56394
  if (!observation.observed) throw new ReleaseCustodyError(`effect ${effect.kind} (${effect.id}) has no proven outcome; observe it before retrying`);
56362
56395
  await this.change((state) => ({ ...state, effects: {
56363
56396
  ...state.effects,
@@ -56528,7 +56561,8 @@ function effectObserver(api) {
56528
56561
  const fields = i.fields;
56529
56562
  const title = effect.kind === "tenant-deploy" ? `tenant-deploy ${i.slug} ${i.stage} @ ${i.ref}` : workflow === "get-publish.yml" ? `get-publish (${fields.product} ${fields.mode || "full"})` : workflow === "tenant-deploy.yml" ? `tenant-deploy ${fields.slug} ${fields.stage} @ ${fields.ref}` : null;
56530
56563
  const matches = runs2.workflow_runs.filter((run) => title && run.display_title === title && run.event === "workflow_dispatch" && run.head_sha === i.sha && !i.beforeRunIds?.includes(run.id) && Date.parse(run.created_at) >= Date.parse(effect.startedAt) - 1e3 && Date.parse(run.created_at) <= Date.parse(effect.startedAt) + 12e4);
56531
- return matches.length === 1 ? { observed: true, result: effect.kind === "tenant-deploy" ? null : "" } : { observed: false };
56564
+ if (matches.length === 1) return { observed: true, result: effect.kind === "tenant-deploy" ? null : "" };
56565
+ return matches.length === 0 && Date.now() > Date.parse(effect.startedAt) + DISPATCH_ABSENT_AFTER_MS ? { observed: false, absent: true } : { observed: false };
56532
56566
  }
56533
56567
  return { observed: false };
56534
56568
  };
@@ -60815,6 +60849,25 @@ function createPreparationDeps(input, io = {}) {
60815
60849
  if (!await docs.refResolves(candidate.root, candidate.headSha) || !await docs.refResolves(candidate.root, candidate.baseSha)) {
60816
60850
  return result("skipped", "exact remote candidate objects are not available locally; committed test policy was not checked");
60817
60851
  }
60852
+ const log = (await exec3("git", ["-C", candidate.root, "log", "--reverse", "--format=%B%x00", `${candidate.baseSha}..${candidate.headSha}`], { timeout: GIT_TIMEOUT_MS })).stdout;
60853
+ const messages = log.split("\0").filter((message2) => message2.trim()).map((message2) => {
60854
+ const [headline, ...body] = message2.trim().split("\n");
60855
+ return { headline, body: body.join("\n") };
60856
+ });
60857
+ const refusal = checkQueueSquashOverride("Candidate PR", "0", messages, candidate.root);
60858
+ if (refusal) {
60859
+ const [owner, name] = candidate.repo.split("/");
60860
+ const metadata = await client.graphql(
60861
+ "query($owner:String!,$name:String!,$base:String!) { repository(owner:$owner,name:$name) { squashMergeCommitMessage mergeQueue(branch:$base) { configuration { mergeMethod } } } }",
60862
+ { owner, name, base: candidate.base }
60863
+ );
60864
+ const rules = await client.restPaginate(`repos/${candidate.repo}/rules/branches/${encodeURIComponent(candidate.base)}?per_page=100`);
60865
+ const methods = [...new Set(rules.filter((rule) => rule.type === "merge_queue").map((rule) => rule.parameters?.merge_method))];
60866
+ const method = methods.length === 1 ? methods[0] : !methods.length ? metadata.repository.mergeQueue?.configuration?.mergeMethod : void 0;
60867
+ if (method === "SQUASH" && metadata.repository.squashMergeCommitMessage === "COMMIT_MESSAGES") {
60868
+ return result("failed", `${refusal.error} \u2014 ${refusal.preparation.nextAction}`);
60869
+ }
60870
+ }
60818
60871
  const policy = runTestPolicy(candidate.root, { candidate: { head: candidate.headSha, base: candidate.baseSha } });
60819
60872
  return result(policy.ok ? "passed" : "failed", policy.ok ? `committed test policy passed at ${candidate.headSha}` : policy.findings.map((finding) => finding.detail).join("\n"));
60820
60873
  }
@@ -72535,7 +72588,7 @@ function registerDeveloperCommands(program3) {
72535
72588
  if (!result.ok) process.exitCode = 1;
72536
72589
  return;
72537
72590
  }
72538
- for (const w of result.warnings) console.warn(`WARNING: ${w.doc}:${w.line} [${w.kind}] ${w.detail}`);
72591
+ for (const w of result.warnings) console.warn(`WARNING: ${formatDocRefFinding(w)}`);
72539
72592
  if (result.ok) {
72540
72593
  console.log(
72541
72594
  `doc refs: ${result.docCount} docs, every reference resolves` + (result.warnings.length ? ` (${result.warnings.length} warning(s))` : "")
@@ -72545,7 +72598,7 @@ function registerDeveloperCommands(program3) {
72545
72598
  const hasStale = result.findings.some((f) => f.kind === "runbook-section-stale" || f.kind === "seed-runbook-unreviewed");
72546
72599
  const hasRefs = result.findings.some((f) => f.kind !== "runbook-section-stale" && f.kind !== "seed-runbook-unreviewed");
72547
72600
  console.error(hasStale && !hasRefs ? "doc refs: stale runbook section(s)" : hasStale && hasRefs ? "doc refs: unresolved references and stale runbook section(s)" : "doc refs: unresolved references");
72548
- for (const f of result.findings) console.error(`- ${f.doc}:${f.line} [${f.kind}] ${f.detail}`);
72601
+ for (const f of result.findings) console.error(`- ${formatDocRefFinding(f)}`);
72549
72602
  process.exitCode = 1;
72550
72603
  } catch (e) {
72551
72604
  await failGraceful(e.message);
@@ -73536,13 +73589,13 @@ project.command("sync-info [owner/repo]").description("synchronize the owning Gi
73536
73589
  }
73537
73590
  });
73538
73591
  async function runProjectDeployGet(repoOrSlug, o) {
73539
- const cfg = await loadConfig();
73540
73592
  let target;
73541
73593
  try {
73542
- target = await projectTarget("org project deploy get", repoOrSlug);
73594
+ target = await projectTarget("org project deploy get", positionalOrRepoFlagTarget("org project deploy get", repoOrSlug, o.repo));
73543
73595
  } catch (e) {
73544
73596
  return fail(e.message);
73545
73597
  }
73598
+ const cfg = await loadConfig();
73546
73599
  const out = await fetchDeployFactsBySlug(slugOf(target), registryClientDeps(cfg));
73547
73600
  if (!out) return failGraceful(`org project deploy get: Hub deploy facts read failed for ${target}`);
73548
73601
  const stage = o.stage?.trim();
@@ -73550,8 +73603,12 @@ async function runProjectDeployGet(repoOrSlug, o) {
73550
73603
  const payload = stage ? { slug: out.slug, stage, deploy: out.stages[stage] ?? null } : out;
73551
73604
  console.log(JSON.stringify(payload, null, 2));
73552
73605
  }
73553
- var projectDeploy = project.command("deploy").description("read nonsecret DEPLOY# facts (domain, port, deploy path, substrate, host presence)").argument("[ownerRepo]", "shorthand for `get <owner/repo>`").addOption(new Option("--stage <stage>", "dev | rc | main").choices(["dev", "rc", "main"])).option("--json", "machine-readable output").action(async (ownerRepo, o) => runProjectDeployGet(ownerRepo, o));
73554
- projectDeploy.command("get [owner/repo]").description("read nonsecret DEPLOY# facts for one project; defaults to the current repo").addOption(new Option("--stage <stage>", "dev | rc | main").choices(["dev", "rc", "main"])).option("--json", "machine-readable output").action(async (repoOrSlug, o) => runProjectDeployGet(repoOrSlug, { stage: projectDeploy.opts().stage, json: o.json }));
73606
+ var projectDeploy = project.command("deploy").description("read nonsecret DEPLOY# facts (domain, port, deploy path, substrate, host presence)").argument("[ownerRepo]", "shorthand for `get <owner/repo>`").addOption(new Option("--stage <stage>", "dev | rc | main").choices(["dev", "rc", "main"])).option("--repo <owner/repo>", "target repo (alias for the positional; defaults to the current repo)").option("--json", "machine-readable output").action(async (ownerRepo, o) => runProjectDeployGet(ownerRepo, o));
73607
+ projectDeploy.command("get [owner/repo]").description("read nonsecret DEPLOY# facts for one project; defaults to the current repo").addOption(new Option("--stage <stage>", "dev | rc | main").choices(["dev", "rc", "main"])).option("--repo <owner/repo>", "target repo (alias for the positional; defaults to the current repo)").option("--json", "machine-readable output").action(async (repoOrSlug, o) => runProjectDeployGet(repoOrSlug, {
73608
+ stage: projectDeploy.opts().stage,
73609
+ json: o.json,
73610
+ repo: projectDeploy.opts().repo ?? o.repo
73611
+ }));
73555
73612
  projectDeploy.command("doctor").description("read-only estate port scan; --box lists its DEPLOY# owners and suggests free 8xxx ports (master-only)").option("--box <name>", "resolve box from the live Hetzner inventory and inspect its ports").option("--json", "machine-readable output").action(async (o) => {
73556
73613
  const cfg = await loadConfig();
73557
73614
  const report = await fetchDeployPortCollisions(registryClientDeps(cfg));
@@ -73580,13 +73637,14 @@ projectDeploy.command("doctor").description("read-only estate port scan; --box l
73580
73637
  if (report.error && report.status !== 403) return failGraceful(`org project deploy doctor: ${report.error}`);
73581
73638
  if (!report.ok && !report.error) process.exitCode = 1;
73582
73639
  });
73583
- projectDeploy.command("retire [owner/repo]").description("retire (tombstone) one stage's DEPLOY# row for a project that no longer deploys through it (#7095, master-only). --stage is required. DRY-RUN by default \u2014 pass --apply to actually write; defaults to the current repo. Refuses a tenant-container project's live main row.").addOption(new Option("--stage <stage>", "dev | rc | main (required)").choices(["dev", "rc", "main"])).option("--apply", "actually retire (default is a dry-run preview of the row that would be retired)").option("--decommissioned", "main only (#8148): retire a tenant-container main row after `runtime tenant control <repo> main retire`; the box must show main gone").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
73640
+ projectDeploy.command("retire [owner/repo]").description("retire (tombstone) one stage's DEPLOY# row for a project that no longer deploys through it (#7095, master-only). --stage is required. DRY-RUN by default \u2014 pass --apply to actually write; defaults to the current repo. Refuses a tenant-container project's live main row.").addOption(new Option("--stage <stage>", "dev | rc | main (required)").choices(["dev", "rc", "main"])).option("--apply", "actually retire (default is a dry-run preview of the row that would be retired)").option("--decommissioned", "main only (#8148): retire a tenant-container main row after `runtime tenant control <repo> main retire`; the box must show main gone").option("--repo <owner/repo>", "target repo (alias for the positional; defaults to the current repo)").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
73584
73641
  const stage = projectDeploy.opts().stage;
73585
73642
  if (!stage) return fail("org project deploy retire: --stage is required (dev | rc | main)");
73586
73643
  const cfg = await loadConfig();
73587
73644
  let target;
73588
73645
  try {
73589
- target = await projectTarget("org project deploy retire", repoOrSlug);
73646
+ const repoFlag = projectDeploy.opts().repo ?? o.repo;
73647
+ target = await projectTarget("org project deploy retire", positionalOrRepoFlagTarget("org project deploy retire", repoOrSlug, repoFlag));
73590
73648
  } catch (e) {
73591
73649
  return fail(e.message);
73592
73650
  }
@@ -8312,21 +8312,14 @@ function defaultIsIgnored(root, relPaths, exec2 = import_node_child_process.exec
8312
8312
  throw error;
8313
8313
  }
8314
8314
  }
8315
- function defaultTrackedFirstSegments(root, firstSegments, exec2 = import_node_child_process.execFileSync) {
8316
- if (firstSegments.length === 0) return /* @__PURE__ */ new Set();
8315
+ function defaultTrackedFiles(root, firstSegments, exec2 = import_node_child_process.execFileSync) {
8317
8316
  try {
8318
8317
  const out = exec2("git", ["ls-files", "-z", "--", ...firstSegments.map((s) => `:(literal)${s}`)], {
8319
8318
  cwd: root,
8320
8319
  encoding: "utf8",
8321
8320
  maxBuffer: CHECK_IGNORE_MAX_BUFFER
8322
8321
  });
8323
- const tracked = /* @__PURE__ */ new Set();
8324
- for (const path2 of out.split("\0")) {
8325
- if (path2) for (const segment of firstSegments) {
8326
- if (path2 === segment || path2.startsWith(`${segment}/`)) tracked.add(segment);
8327
- }
8328
- }
8329
- return tracked;
8322
+ return out.split("\0").filter(Boolean);
8330
8323
  } catch (error) {
8331
8324
  if (error?.status === 128) return null;
8332
8325
  if (error?.code === "ENOENT") return null;
@@ -8370,7 +8363,12 @@ function runDocRefs(root, deps = {}) {
8370
8363
  const exists = deps.exists ?? import_node_fs.existsSync;
8371
8364
  const listDocs = deps.listDocs ?? defaultListDocs;
8372
8365
  const isIgnored = deps.isIgnored ?? ((paths) => defaultIsIgnored(root, paths));
8373
- const trackedFirstSegments = deps.trackedFirstSegments ?? ((segs) => defaultTrackedFirstSegments(root, segs));
8366
+ let inventory;
8367
+ const trackedFiles = () => inventory === void 0 ? inventory = deps.trackedFiles ? deps.trackedFiles() : deps.trackedFirstSegments ? null : defaultTrackedFiles(root, []) : inventory;
8368
+ const trackedFirstSegments = deps.trackedFirstSegments ?? ((segs) => {
8369
+ const files = trackedFiles();
8370
+ return files == null ? null : new Set(segs.filter((seg) => files.some((path2) => path2 === seg || path2.startsWith(`${seg}/`))));
8371
+ });
8374
8372
  const commandPaths = deps.commandPaths ?? null;
8375
8373
  const walked = listDocs(root);
8376
8374
  const ignoredDocs = walked.length ? isIgnored(walked) : /* @__PURE__ */ new Set();
@@ -8410,6 +8408,18 @@ ${sourceMessages}`;
8410
8408
  (override ? warnings : findings).push(...stale);
8411
8409
  if (!/^Seed-Runbook-Affected:[ \t]*\S.*$/m.test(allMessages)) findings.push(...seedStale);
8412
8410
  }
8411
+ for (const finding of findings) {
8412
+ if (finding.kind !== "missing-path") continue;
8413
+ const directory = import_node_path.posix.dirname(import_node_path.posix.normalize(finding.detail)).split("/");
8414
+ const distance = (path2) => {
8415
+ const parts = import_node_path.posix.dirname(path2).split("/");
8416
+ let common = 0;
8417
+ while (common < directory.length && common < parts.length && directory[common] === parts[common]) common += 1;
8418
+ return directory.length + parts.length - 2 * common;
8419
+ };
8420
+ const suggest = [...new Set(trackedFiles() ?? [])].filter((path2) => import_node_path.posix.basename(path2) === import_node_path.posix.basename(finding.detail) && exists((0, import_node_path.join)(root, path2))).sort((a, b) => distance(a) - distance(b) || a.localeCompare(b)).slice(0, 3);
8421
+ if (suggest.length) finding.suggest = suggest;
8422
+ }
8413
8423
  return {
8414
8424
  ok: findings.length === 0,
8415
8425
  findings,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "4.6.25",
3
+ "version": "4.6.27",
4
4
  "description": "MMI Future CLI — the org dev toolbox and shared cross-IDE engine for every registry-declared MMI coding surface.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",