arcane-cli 0.22.13 → 0.22.14

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 CHANGED
@@ -3595,8 +3595,8 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
3595
3595
  // src/commands/doctor.ts
3596
3596
  import { access as access4, readFile as readFile6, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
3597
3597
  import { join as join11, dirname as dirname3 } from "path";
3598
- import { execFile as execFile2 } from "child_process";
3599
- import { promisify } from "util";
3598
+ import { execFile as execFile3 } from "child_process";
3599
+ import { promisify as promisify2 } from "util";
3600
3600
 
3601
3601
  // src/config/incidents.ts
3602
3602
  var INCIDENT_QUEUE = [
@@ -3663,8 +3663,177 @@ function evaluateIncidentGate(records) {
3663
3663
  };
3664
3664
  }
3665
3665
 
3666
- // src/commands/doctor.ts
3666
+ // src/modules/platform-policy.ts
3667
+ import { execFile as execFile2 } from "child_process";
3668
+ import { promisify } from "util";
3667
3669
  var execFileAsync = promisify(execFile2);
3670
+ var SANCTIONED_MERGE_METHODS = ["merge", "rebase"];
3671
+ function compareToLadder(effective, declaredButBlocked, interactionNote) {
3672
+ const disallowedButPermitted = effective.filter(
3673
+ (m) => !SANCTIONED_MERGE_METHODS.includes(m)
3674
+ );
3675
+ const missingSanctioned = SANCTIONED_MERGE_METHODS.filter(
3676
+ (m) => !effective.includes(m)
3677
+ );
3678
+ const passed = disallowedButPermitted.length === 0 && missingSanctioned.length === 0;
3679
+ const messages = [];
3680
+ if (interactionNote) messages.push(interactionNote);
3681
+ if (disallowedButPermitted.length > 0) {
3682
+ messages.push(
3683
+ `disallowed method(s) effectively permitted: ${disallowedButPermitted.join(", ")} (never sanctioned per git-conventions.md)`
3684
+ );
3685
+ }
3686
+ if (missingSanctioned.length > 0) {
3687
+ messages.push(
3688
+ `sanctioned method(s) not effectively available: ${missingSanctioned.join(", ")}`
3689
+ );
3690
+ }
3691
+ return {
3692
+ passed,
3693
+ effectiveMergeMethods: effective,
3694
+ declaredButBlocked,
3695
+ disallowedButPermitted,
3696
+ message: passed ? `effective merge methods [${effective.join(", ")}] match the declared ladder` : messages.join("; ")
3697
+ };
3698
+ }
3699
+ function evaluateGitHubMergePolicy(rulesets) {
3700
+ const active = rulesets.filter(
3701
+ (r) => r.enforcement === "active" && r.target === "branch"
3702
+ );
3703
+ if (active.length === 0) {
3704
+ return {
3705
+ passed: false,
3706
+ effectiveMergeMethods: null,
3707
+ declaredButBlocked: [],
3708
+ disallowedButPermitted: [],
3709
+ message: "No active branch ruleset covers the target branch (checked via Rulesets, not the classic protection endpoint, which false-negatives on Rulesets-only repos)."
3710
+ };
3711
+ }
3712
+ let allowedMergeMethods = null;
3713
+ let hasRequiredLinearHistory = false;
3714
+ for (const ruleset of active) {
3715
+ for (const rule of ruleset.rules) {
3716
+ if (rule.type === "required_linear_history") {
3717
+ hasRequiredLinearHistory = true;
3718
+ }
3719
+ if (rule.type === "pull_request") {
3720
+ const methods = rule.parameters?.["allowed_merge_methods"];
3721
+ if (Array.isArray(methods)) {
3722
+ const typed = methods;
3723
+ allowedMergeMethods = allowedMergeMethods === null ? typed : allowedMergeMethods.filter((m) => typed.includes(m));
3724
+ }
3725
+ }
3726
+ }
3727
+ }
3728
+ const nominal = allowedMergeMethods ?? ["merge", "squash", "rebase"];
3729
+ const effective = hasRequiredLinearHistory ? nominal.filter((m) => m !== "merge") : nominal;
3730
+ const declaredButBlocked = hasRequiredLinearHistory && nominal.includes("merge") ? ["merge"] : [];
3731
+ const interactionNote = declaredButBlocked.length > 0 ? `allowed_merge_methods declares [${nominal.join(", ")}] but a required_linear_history rule silently blocks merge commits regardless -- effective methods are [${effective.join(", ")}]` : void 0;
3732
+ return compareToLadder(effective, declaredButBlocked, interactionNote);
3733
+ }
3734
+ async function fetchGitHubRulesets(owner, repo) {
3735
+ try {
3736
+ const { stdout: listOut } = await execFileAsync("gh", [
3737
+ "api",
3738
+ `repos/${owner}/${repo}/rulesets`
3739
+ ]);
3740
+ const summaries = JSON.parse(listOut);
3741
+ const detailed = await Promise.all(
3742
+ summaries.map(async (s) => {
3743
+ const { stdout } = await execFileAsync("gh", [
3744
+ "api",
3745
+ `repos/${owner}/${repo}/rulesets/${s.id}`
3746
+ ]);
3747
+ return JSON.parse(stdout);
3748
+ })
3749
+ );
3750
+ return detailed;
3751
+ } catch {
3752
+ return null;
3753
+ }
3754
+ }
3755
+ function evaluateAdoMergePolicy(policies) {
3756
+ const enabled = policies.filter((p) => p.isEnabled);
3757
+ if (enabled.length === 0) {
3758
+ return {
3759
+ passed: false,
3760
+ effectiveMergeMethods: null,
3761
+ declaredButBlocked: [],
3762
+ disallowedButPermitted: [],
3763
+ message: 'No enabled "Limit merge types" branch policy found for the target branch.'
3764
+ };
3765
+ }
3766
+ const effective = /* @__PURE__ */ new Set();
3767
+ for (const policy of enabled) {
3768
+ const s = policy.settings ?? {};
3769
+ if (s.allowNoFastForward) effective.add("merge");
3770
+ if (s.allowRebase || s.allowRebaseMerge) effective.add("rebase");
3771
+ if (s.useSquashMerge) effective.add("squash");
3772
+ }
3773
+ return compareToLadder([...effective], []);
3774
+ }
3775
+ async function fetchAdoMergeTypePolicies(org, project, repoName, branch) {
3776
+ try {
3777
+ const orgUrl = `https://dev.azure.com/${org}`;
3778
+ const { stdout: repoOut } = await execFileAsync("az", [
3779
+ "repos",
3780
+ "show",
3781
+ "--repository",
3782
+ repoName,
3783
+ "--org",
3784
+ orgUrl,
3785
+ "--project",
3786
+ project,
3787
+ "--query",
3788
+ "id",
3789
+ "--output",
3790
+ "tsv"
3791
+ ]);
3792
+ const repositoryId = repoOut.trim();
3793
+ if (!repositoryId) return null;
3794
+ const { stdout } = await execFileAsync("az", [
3795
+ "repos",
3796
+ "policy",
3797
+ "list",
3798
+ "--repository-id",
3799
+ repositoryId,
3800
+ "--branch",
3801
+ branch,
3802
+ "--org",
3803
+ orgUrl,
3804
+ "--project",
3805
+ project,
3806
+ "--output",
3807
+ "json"
3808
+ ]);
3809
+ return JSON.parse(stdout);
3810
+ } catch {
3811
+ return null;
3812
+ }
3813
+ }
3814
+ function parseGitHubRemote(url) {
3815
+ const match = /github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?\/?$/.exec(url);
3816
+ if (!match) return null;
3817
+ const [, owner, repo] = match;
3818
+ if (!owner || !repo) return null;
3819
+ return { owner, repo };
3820
+ }
3821
+ function parseAdoRemote(url) {
3822
+ const modern = /dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/?]+)/.exec(url);
3823
+ if (modern) {
3824
+ const [, org, project, repo] = modern;
3825
+ if (org && project && repo) return { org, project, repo };
3826
+ }
3827
+ const legacy = /([^./]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/?]+)/.exec(url);
3828
+ if (legacy) {
3829
+ const [, org, project, repo] = legacy;
3830
+ if (org && project && repo) return { org, project, repo };
3831
+ }
3832
+ return null;
3833
+ }
3834
+
3835
+ // src/commands/doctor.ts
3836
+ var execFileAsync2 = promisify2(execFile3);
3668
3837
  async function checkNodeVersion() {
3669
3838
  const name = "Node.js version";
3670
3839
  const raw = process.version;
@@ -3702,7 +3871,7 @@ async function checkVSCodeExtension(id, label) {
3702
3871
  let installBin = "code";
3703
3872
  for (const bin of ["code", "code-insiders"]) {
3704
3873
  try {
3705
- await execFileAsync(bin, ["--version"]);
3874
+ await execFileAsync2(bin, ["--version"]);
3706
3875
  installBin = bin;
3707
3876
  break;
3708
3877
  } catch {
@@ -3894,6 +4063,49 @@ async function checkPushPolicy(targetDir) {
3894
4063
  message: `declared "blocked" but not enforced \u2014 ${missing.join("; ")}. The manifest claims a protection this repository does not have.`
3895
4064
  };
3896
4065
  }
4066
+ async function checkPlatformBranchPolicy(targetDir) {
4067
+ const name = "Platform branch/merge policy (T11)";
4068
+ const targets = await pushTargets(targetDir);
4069
+ const origin = targets.find((t) => t.remote === "origin") ?? targets[0];
4070
+ const url = origin?.urls[0];
4071
+ if (!url) {
4072
+ return { name, passed: true, message: "no remote configured \u2014 nothing to check", blocking: false };
4073
+ }
4074
+ const github = parseGitHubRemote(url);
4075
+ if (github) {
4076
+ const rulesets = await fetchGitHubRulesets(github.owner, github.repo);
4077
+ if (rulesets === null) {
4078
+ return {
4079
+ name,
4080
+ passed: false,
4081
+ blocking: false,
4082
+ message: `could not query GitHub Rulesets for ${github.owner}/${github.repo} \u2014 is \`gh\` installed and authenticated? Verify manually: gh api repos/${github.owner}/${github.repo}/rulesets`
4083
+ };
4084
+ }
4085
+ const result = evaluateGitHubMergePolicy(rulesets);
4086
+ return { name, passed: result.passed, blocking: false, message: result.message };
4087
+ }
4088
+ const ado = parseAdoRemote(url);
4089
+ if (ado) {
4090
+ const policies = await fetchAdoMergeTypePolicies(ado.org, ado.project, ado.repo, "main");
4091
+ if (policies === null) {
4092
+ return {
4093
+ name,
4094
+ passed: false,
4095
+ blocking: false,
4096
+ message: `could not query the Azure DevOps "Limit merge types" branch policy for ${ado.org}/${ado.project}/${ado.repo} \u2014 is \`az\` installed and authenticated? Verify manually in the ADO web UI under Branch Policies.`
4097
+ };
4098
+ }
4099
+ const result = evaluateAdoMergePolicy(policies);
4100
+ return { name, passed: result.passed, blocking: false, message: result.message };
4101
+ }
4102
+ return {
4103
+ name,
4104
+ passed: true,
4105
+ blocking: false,
4106
+ message: "remote is neither GitHub nor Azure DevOps \u2014 platform policy verification not available for this provider"
4107
+ };
4108
+ }
3897
4109
  async function runDoctor(targetDir, options = {}, assetsDir) {
3898
4110
  console.log("\nspell doctor \u2014 checking your Arcane environment\n");
3899
4111
  const results = await Promise.all([
@@ -3902,7 +4114,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
3902
4114
  checkArcaneManifest(targetDir),
3903
4115
  Promise.resolve(checkIncidentReleaseGate()),
3904
4116
  checkPullRebase(targetDir),
3905
- checkPushPolicy(targetDir)
4117
+ checkPushPolicy(targetDir),
4118
+ checkPlatformBranchPolicy(targetDir)
3906
4119
  ]);
3907
4120
  const sessionResults = await checkSessionContinuity(targetDir);
3908
4121
  results.push(...sessionResults);