mdkg 0.3.2 → 0.3.4

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.
@@ -4,7 +4,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.collectFixPlan = collectFixPlan;
7
+ exports.collectFixApply = collectFixApply;
7
8
  exports.runFixPlanCommand = runFixPlanCommand;
9
+ exports.runFixApplyCommand = runFixApplyCommand;
10
+ exports.runFixIdsCommand = runFixIdsCommand;
8
11
  const crypto_1 = __importDefault(require("crypto"));
9
12
  const child_process_1 = require("child_process");
10
13
  const fs_1 = __importDefault(require("fs"));
@@ -23,6 +26,9 @@ const template_schema_1 = require("../graph/template_schema");
23
26
  const workspace_files_1 = require("../graph/workspace_files");
24
27
  const errors_1 = require("../util/errors");
25
28
  const refs_1 = require("../util/refs");
29
+ const atomic_1 = require("../util/atomic");
30
+ const lock_1 = require("../util/lock");
31
+ const index_1 = require("./index");
26
32
  const FAMILY_VALUES = new Set(["index", "refs", "ids", "all"]);
27
33
  const CONCRETE_FAMILIES = ["index", "refs", "ids"];
28
34
  function stableValue(value) {
@@ -668,14 +674,143 @@ function planRefRepairs(root, target) {
668
674
  return { proposed: changes, blocked: [] };
669
675
  }
670
676
  function candidateDuplicateId(baseId, used) {
671
- for (let index = 2;; index += 1) {
672
- const candidate = `${baseId}-dup-${index}`;
677
+ const match = /^([a-z]+)-([0-9]+)$/.exec(baseId);
678
+ if (!match) {
679
+ throw new errors_1.UsageError(`duplicate id ${baseId} cannot be repaired automatically because it is not a canonical numeric id`);
680
+ }
681
+ const prefix = match[1];
682
+ const start = Number.parseInt(match[2], 10) + 1;
683
+ for (let index = start;; index += 1) {
684
+ const candidate = `${prefix}-${index}`;
673
685
  if (!used.has(candidate)) {
674
686
  used.add(candidate);
675
687
  return candidate;
676
688
  }
677
689
  }
678
690
  }
691
+ function gitShow(root, refPath) {
692
+ const result = (0, child_process_1.spawnSync)("git", ["show", refPath], { cwd: root, encoding: "utf8" });
693
+ if (result.status !== 0) {
694
+ return undefined;
695
+ }
696
+ return result.stdout;
697
+ }
698
+ function runGitStrict(root, args) {
699
+ const result = (0, child_process_1.spawnSync)("git", args, { cwd: root, encoding: "utf8" });
700
+ if (result.status !== 0) {
701
+ throw new errors_1.UsageError(`git ${args.join(" ")} failed: ${(result.stderr || result.stdout).trim() || "unknown error"}`);
702
+ }
703
+ }
704
+ function baseRefIdPaths(root, baseRef, files, config) {
705
+ const pathsById = new Map();
706
+ if (!baseRef) {
707
+ return pathsById;
708
+ }
709
+ const templateSchemas = (0, template_schema_1.loadTemplateSchemas)(root, config, node_1.ALLOWED_TYPES);
710
+ for (const absPath of files) {
711
+ const relativePath = rel(root, absPath);
712
+ const content = gitShow(root, `${baseRef}:${relativePath}`);
713
+ if (content === undefined) {
714
+ continue;
715
+ }
716
+ try {
717
+ const node = (0, node_1.parseNode)(content, absPath, {
718
+ workStatusEnum: config.work.status_enum,
719
+ priorityMin: config.work.priority_min,
720
+ priorityMax: config.work.priority_max,
721
+ templateSchemas,
722
+ });
723
+ if (!pathsById.has(node.id)) {
724
+ pathsById.set(node.id, new Set());
725
+ }
726
+ pathsById.get(node.id)?.add(relativePath);
727
+ }
728
+ catch {
729
+ continue;
730
+ }
731
+ }
732
+ return pathsById;
733
+ }
734
+ function rewriteIdInNodeContent(content, fromId, toId) {
735
+ const lines = content.split(/\n/);
736
+ let inFrontmatter = false;
737
+ let frontmatterClosed = false;
738
+ let idRewritten = false;
739
+ const rewritten = lines.map((line, index) => {
740
+ if (index === 0 && line.trim() === "---") {
741
+ inFrontmatter = true;
742
+ return line;
743
+ }
744
+ if (inFrontmatter && !frontmatterClosed && line.trim() === "---") {
745
+ frontmatterClosed = true;
746
+ inFrontmatter = false;
747
+ return line;
748
+ }
749
+ if (inFrontmatter && !idRewritten && line === `id: ${fromId}`) {
750
+ idRewritten = true;
751
+ return `id: ${toId}`;
752
+ }
753
+ return line.split(fromId).join(toId);
754
+ });
755
+ if (!idRewritten) {
756
+ throw new errors_1.UsageError(`unable to rewrite id ${fromId}; frontmatter id line not found`);
757
+ }
758
+ return rewritten.join("\n");
759
+ }
760
+ function isInsideRoot(root, filePath) {
761
+ const realRoot = `${relativeRoot(root)}${path_1.default.sep}`;
762
+ return path_1.default.resolve(filePath).startsWith(realRoot);
763
+ }
764
+ function replaceIdInRelativePath(relativePath, fromId, toId, usedPaths) {
765
+ const parsed = path_1.default.posix.parse(relativePath);
766
+ const baseName = parsed.base.includes(fromId)
767
+ ? parsed.base.replace(fromId, toId)
768
+ : `${toId}-${parsed.base}`;
769
+ let candidate = path_1.default.posix.join(parsed.dir, baseName);
770
+ if (!usedPaths.has(candidate)) {
771
+ usedPaths.add(candidate);
772
+ return candidate;
773
+ }
774
+ for (let index = 2;; index += 1) {
775
+ const suffixed = path_1.default.posix.join(parsed.dir, `${parsed.name}-${toId}-${index}${parsed.ext}`);
776
+ if (!usedPaths.has(suffixed)) {
777
+ usedPaths.add(suffixed);
778
+ return suffixed;
779
+ }
780
+ }
781
+ }
782
+ function gitConflictStages(root) {
783
+ const output = runGit(root, ["ls-files", "-u", "--", ".mdkg"]) ?? "";
784
+ const groups = new Map();
785
+ for (const line of output.split(/\r?\n/).filter(Boolean)) {
786
+ const match = /^([0-7]+) ([0-9a-f]+) ([123])\t(.+)$/.exec(line);
787
+ if (!match || !match[4].endsWith(".md")) {
788
+ continue;
789
+ }
790
+ const entry = {
791
+ mode: match[1],
792
+ object: match[2],
793
+ stage: Number.parseInt(match[3], 10),
794
+ path: match[4],
795
+ };
796
+ groups.set(entry.path, [...(groups.get(entry.path) ?? []), entry]);
797
+ }
798
+ return groups;
799
+ }
800
+ function workspaceAliasForPath(root, config, relativePath) {
801
+ const absPath = path_1.default.resolve(root, relativePath);
802
+ for (const alias of Object.keys(config.workspaces).sort()) {
803
+ const entry = config.workspaces[alias];
804
+ if (!entry.enabled) {
805
+ continue;
806
+ }
807
+ const wsRoot = path_1.default.resolve(root, entry.path, entry.mdkg_dir);
808
+ if (absPath === wsRoot || absPath.startsWith(`${wsRoot}${path_1.default.sep}`)) {
809
+ return alias;
810
+ }
811
+ }
812
+ return undefined;
813
+ }
679
814
  function filesContaining(root, files, needle) {
680
815
  return files
681
816
  .filter((filePath) => {
@@ -728,18 +863,118 @@ function referenceRewriteItems(root, files, from, to) {
728
863
  .filter((item) => Boolean(item))
729
864
  .sort((a, b) => a.path.localeCompare(b.path));
730
865
  }
731
- function planDuplicateIdRepairs(root, target) {
866
+ function planGitStageDuplicateIdRepairs(root, target, config, usedIdsByAlias, usedPaths, startIndex) {
867
+ const templateSchemas = (0, template_schema_1.loadTemplateSchemas)(root, config, node_1.ALLOWED_TYPES);
868
+ const proposed = [];
869
+ let matchedTarget = false;
870
+ for (const [relativePath, stages] of [...gitConflictStages(root).entries()].sort(([a], [b]) => a.localeCompare(b))) {
871
+ const ours = stages.find((entry) => entry.stage === 2);
872
+ const theirs = stages.find((entry) => entry.stage === 3);
873
+ if (!ours || !theirs) {
874
+ continue;
875
+ }
876
+ const alias = workspaceAliasForPath(root, config, relativePath);
877
+ if (!alias) {
878
+ continue;
879
+ }
880
+ const oursContent = gitShow(root, `:2:${relativePath}`);
881
+ const theirsContent = gitShow(root, `:3:${relativePath}`);
882
+ if (oursContent === undefined || theirsContent === undefined) {
883
+ continue;
884
+ }
885
+ try {
886
+ const absPath = path_1.default.resolve(root, relativePath);
887
+ const oursNode = (0, node_1.parseNode)(oursContent, absPath, {
888
+ workStatusEnum: config.work.status_enum,
889
+ priorityMin: config.work.priority_min,
890
+ priorityMax: config.work.priority_max,
891
+ templateSchemas,
892
+ });
893
+ const theirsNode = (0, node_1.parseNode)(theirsContent, absPath, {
894
+ workStatusEnum: config.work.status_enum,
895
+ priorityMin: config.work.priority_min,
896
+ priorityMax: config.work.priority_max,
897
+ templateSchemas,
898
+ });
899
+ if (oursNode.id !== theirsNode.id) {
900
+ continue;
901
+ }
902
+ const duplicateId = oursNode.id;
903
+ const qid = `${alias}:${duplicateId}`;
904
+ const targetMatches = !target || target.toLowerCase() === duplicateId || target.toLowerCase() === qid || target === relativePath;
905
+ if (!targetMatches) {
906
+ continue;
907
+ }
908
+ matchedTarget = true;
909
+ const usedIds = usedIdsByAlias.get(alias) ?? new Set();
910
+ usedIds.add(oursNode.id);
911
+ usedIds.add(theirsNode.id);
912
+ usedIdsByAlias.set(alias, usedIds);
913
+ const candidate = candidateDuplicateId(duplicateId, usedIds);
914
+ const candidatePath = replaceIdInRelativePath(relativePath, duplicateId, candidate, usedPaths);
915
+ proposed.push({
916
+ id: `ids.${String(startIndex + proposed.length + 1).padStart(3, "0")}`,
917
+ family: "ids",
918
+ risk: "high",
919
+ status: "planned",
920
+ reason: "git_stage_duplicate_id",
921
+ paths: [relativePath, candidatePath],
922
+ refs: [qid, `${alias}:${candidate}`].sort(),
923
+ evidence: {
924
+ conflict_kind: "git_index_unresolved_duplicate_id",
925
+ workspace: alias,
926
+ duplicate_id: duplicateId,
927
+ conflict_path: relativePath,
928
+ canonical_stage: 2,
929
+ duplicate_stage: 3,
930
+ current_blob: ours.object,
931
+ incoming_blob: theirs.object,
932
+ deterministic_rule: "keep stage 2 at the conflicted path, rewrite stage 3 to the next unused canonical numeric id and path, then git add both files",
933
+ },
934
+ before: {
935
+ duplicate_id: duplicateId,
936
+ workspace: alias,
937
+ conflict_path: relativePath,
938
+ canonical_stage: 2,
939
+ duplicate_stage: 3,
940
+ current_blob: ours.object,
941
+ incoming_blob: theirs.object,
942
+ },
943
+ after: {
944
+ candidate_id: candidate,
945
+ candidate_qid: `${alias}:${candidate}`,
946
+ candidate_path: candidatePath,
947
+ canonical_path: relativePath,
948
+ collision_free: true,
949
+ deterministic_rule: "keep stage 2 at the conflicted path, rewrite stage 3 to the next unused canonical numeric id and path, then git add both files",
950
+ },
951
+ command_hint: `mdkg fix ids --target ${duplicateId} --apply --json`,
952
+ apply_supported: true,
953
+ apply_kind: "git_stage_duplicate_id_rewrite",
954
+ });
955
+ }
956
+ catch {
957
+ continue;
958
+ }
959
+ }
960
+ return { proposed, matchedTarget };
961
+ }
962
+ function planDuplicateIdRepairs(root, target, baseRef) {
732
963
  const config = (0, config_1.loadConfig)(root);
733
964
  const templateSchemas = (0, template_schema_1.loadTemplateSchemas)(root, config, node_1.ALLOWED_TYPES);
734
965
  const docsByAlias = (0, workspace_files_1.listWorkspaceDocFilesByAlias)(root, config);
735
966
  const proposed = [];
736
967
  const blocked = [];
968
+ const usedIdsByAlias = new Map();
969
+ const usedPaths = new Set();
737
970
  let matchedTarget = !target;
738
971
  for (const alias of Object.keys(docsByAlias).sort()) {
739
972
  const records = [];
740
973
  const usedIds = new Set();
741
974
  const files = docsByAlias[alias].sort();
975
+ const basePathsById = baseRefIdPaths(root, baseRef, files, config);
742
976
  for (const filePath of files) {
977
+ usedPaths.add(rel(root, filePath));
743
978
  if (path_1.default.basename(filePath) === "core.md" && path_1.default.basename(path_1.default.dirname(filePath)) === "core") {
744
979
  continue;
745
980
  }
@@ -762,6 +997,7 @@ function planDuplicateIdRepairs(root, target) {
762
997
  continue;
763
998
  }
764
999
  }
1000
+ usedIdsByAlias.set(alias, usedIds);
765
1001
  const groups = new Map();
766
1002
  for (const record of records) {
767
1003
  groups.set(record.id, [...(groups.get(record.id) ?? []), record]);
@@ -778,18 +1014,29 @@ function planDuplicateIdRepairs(root, target) {
778
1014
  continue;
779
1015
  }
780
1016
  matchedTarget = true;
781
- const canonical = group[0];
1017
+ const basePaths = basePathsById.get(id);
1018
+ const baseCanonical = basePaths ? group.find((record) => basePaths.has(record.path)) : undefined;
1019
+ const canonical = baseCanonical ?? group[0];
782
1020
  const referencePaths = filesContaining(root, files, id);
783
- const duplicateRecords = group.slice(1);
1021
+ const duplicateRecords = group.filter((record) => record.path !== canonical.path);
784
1022
  const groupPaths = group.map((record) => record.path).sort();
785
- const deterministicRule = "keep the lexicographically first path unchanged; propose <id>-dup-<n> for each later path";
1023
+ const deterministicRule = baseCanonical
1024
+ ? "keep the path that already existed at --base-ref unchanged; propose the next unused canonical numeric id for each other path"
1025
+ : "keep the lexicographically first path unchanged; propose the next unused canonical numeric id for each later path";
786
1026
  for (const duplicate of duplicateRecords) {
787
1027
  const candidate = candidateDuplicateId(id, usedIds);
1028
+ const selfReferenceRewrites = referenceRewriteItems(root, [duplicate.absPath], id, candidate);
1029
+ const externalReferenceRewrites = referenceRewriteItems(root, files.filter((filePath) => filePath !== duplicate.absPath), id, candidate);
1030
+ const safeReferenceRewrites = baseRef
1031
+ ? externalReferenceRewrites.filter((item) => gitShow(root, `${baseRef}:${item.path}`) === undefined)
1032
+ : [];
1033
+ const safeReferencePaths = new Set(safeReferenceRewrites.map((item) => item.path));
1034
+ const ambiguousReferenceRewrites = externalReferenceRewrites.filter((item) => !safeReferencePaths.has(item.path));
788
1035
  proposed.push({
789
1036
  id: `ids.${String(proposed.length + 1).padStart(3, "0")}`,
790
1037
  family: "ids",
791
1038
  risk: "high",
792
- status: "manual_review",
1039
+ status: "planned",
793
1040
  reason: "duplicate_id",
794
1041
  paths: [duplicate.path],
795
1042
  refs: Array.from(new Set([canonical.qid, duplicate.qid])).sort(),
@@ -797,6 +1044,8 @@ function planDuplicateIdRepairs(root, target) {
797
1044
  conflict_kind: "duplicate_local_id",
798
1045
  branch_merge_suspected: true,
799
1046
  workspace: alias,
1047
+ base_ref: baseRef ?? null,
1048
+ base_ref_canonical: Boolean(baseCanonical),
800
1049
  duplicate_id: id,
801
1050
  group_size: group.length,
802
1051
  group_paths: groupPaths,
@@ -813,6 +1062,7 @@ function planDuplicateIdRepairs(root, target) {
813
1062
  before: {
814
1063
  duplicate_id: id,
815
1064
  workspace: alias,
1065
+ base_ref: baseRef ?? null,
816
1066
  canonical_path: canonical.path,
817
1067
  duplicate_path: duplicate.path,
818
1068
  duplicate_group: {
@@ -827,14 +1077,23 @@ function planDuplicateIdRepairs(root, target) {
827
1077
  collision_free: true,
828
1078
  deterministic_rule: deterministicRule,
829
1079
  reference_paths: referencePaths,
830
- reference_rewrite_plan: referenceRewriteItems(root, files, id, candidate),
1080
+ self_reference_rewrites: selfReferenceRewrites,
1081
+ safe_reference_rewrites: safeReferenceRewrites,
1082
+ ambiguous_reference_rewrites: ambiguousReferenceRewrites,
1083
+ reference_rewrite_plan: [...selfReferenceRewrites, ...safeReferenceRewrites, ...ambiguousReferenceRewrites].sort((a, b) => a.path.localeCompare(b.path)),
831
1084
  },
832
- command_hint: `review ${duplicate.path} and update id ${id} to ${candidate}`,
833
- apply_supported: false,
1085
+ command_hint: `mdkg fix apply --family ids --target ${id}${baseRef ? ` --base-ref ${baseRef}` : ""} --json`,
1086
+ apply_supported: true,
1087
+ apply_kind: "duplicate_id_rewrite",
834
1088
  });
835
1089
  }
836
1090
  }
837
1091
  }
1092
+ const stageRepairs = planGitStageDuplicateIdRepairs(root, target, config, usedIdsByAlias, usedPaths, proposed.length);
1093
+ proposed.push(...stageRepairs.proposed);
1094
+ if (stageRepairs.matchedTarget) {
1095
+ matchedTarget = true;
1096
+ }
838
1097
  if (!matchedTarget && target) {
839
1098
  blocked.push({
840
1099
  id: "ids.target.001",
@@ -881,15 +1140,20 @@ function collectFixPlan(options) {
881
1140
  const root = relativeRoot(options.root);
882
1141
  const indexRepairs = selected.includes("index") ? planIndexRepairs(root) : { proposed: [], blocked: [] };
883
1142
  const refRepairs = selected.includes("refs") ? planRefRepairs(root, options.target) : { proposed: [], blocked: [] };
884
- const idRepairs = selected.includes("ids") ? planDuplicateIdRepairs(root, options.target) : { proposed: [], blocked: [] };
1143
+ const idRepairs = selected.includes("ids")
1144
+ ? planDuplicateIdRepairs(root, options.target, options.baseRef)
1145
+ : { proposed: [], blocked: [] };
885
1146
  const proposedChanges = sortChanges([...indexRepairs.proposed, ...refRepairs.proposed, ...idRepairs.proposed]);
886
1147
  const blockedChanges = sortChanges([...indexRepairs.blocked, ...refRepairs.blocked, ...idRepairs.blocked]);
1148
+ const supportedApplyCount = proposedChanges.filter((change) => change.apply_supported).length;
1149
+ const unsupportedApplyCount = proposedChanges.filter((change) => !change.apply_supported).length;
887
1150
  const body = {
888
1151
  action: "fix.plan",
889
1152
  schema_version: 1,
890
1153
  root,
891
1154
  family,
892
1155
  target: options.target ?? null,
1156
+ base_ref: options.baseRef ?? null,
893
1157
  dirty: collectDirtyState(root),
894
1158
  families: emptyFamilySummaries(selected).map((entry) => ({
895
1159
  ...entry,
@@ -903,9 +1167,13 @@ function collectFixPlan(options) {
903
1167
  selected_families: selected,
904
1168
  proposed_count: proposedChanges.length,
905
1169
  blocked_count: blockedChanges.length,
906
- apply_supported: false,
907
- apply_deferred: true,
908
- message: "fix apply is not available; this command is review-only and writes no files",
1170
+ apply_supported: supportedApplyCount > 0,
1171
+ apply_deferred: unsupportedApplyCount > 0 || blockedChanges.length > 0,
1172
+ supported_apply_count: supportedApplyCount,
1173
+ unsupported_apply_count: unsupportedApplyCount,
1174
+ message: supportedApplyCount > 0
1175
+ ? "ids-family duplicate-id repairs can be applied with mdkg fix apply --family ids or mdkg fix ids --apply"
1176
+ : "this command is review-only for the selected findings and writes no files",
909
1177
  },
910
1178
  };
911
1179
  const planHash = sha256(body);
@@ -917,6 +1185,171 @@ function collectFixPlan(options) {
917
1185
  plan_id: `fix-plan-${planHash.slice("sha256:".length, "sha256:".length + 16)}`,
918
1186
  };
919
1187
  }
1188
+ function normalizeApplyFamily(value) {
1189
+ const family = normalizeFamily(value ?? "ids");
1190
+ if (family !== "ids") {
1191
+ throw new errors_1.UsageError("fix apply currently supports only --family ids");
1192
+ }
1193
+ return "ids";
1194
+ }
1195
+ function applyDuplicateIdChange(root, change) {
1196
+ if (change.family !== "ids" || change.reason !== "duplicate_id" || change.apply_kind !== "duplicate_id_rewrite") {
1197
+ throw new errors_1.UsageError(`unsupported fix apply change ${change.id}`);
1198
+ }
1199
+ const relativePath = change.paths[0];
1200
+ if (!relativePath) {
1201
+ throw new errors_1.UsageError(`fix apply change ${change.id} is missing a path`);
1202
+ }
1203
+ const absPath = path_1.default.resolve(root, relativePath);
1204
+ if (!isInsideRoot(root, absPath)) {
1205
+ throw new errors_1.UsageError(`fix apply refused path outside repo: ${relativePath}`);
1206
+ }
1207
+ const before = change.before;
1208
+ const after = change.after;
1209
+ const fromId = typeof before.duplicate_id === "string" ? before.duplicate_id : undefined;
1210
+ const toId = typeof after.candidate_id === "string" ? after.candidate_id : undefined;
1211
+ if (!fromId || !toId) {
1212
+ throw new errors_1.UsageError(`fix apply change ${change.id} is missing duplicate id rewrite details`);
1213
+ }
1214
+ const current = fs_1.default.readFileSync(absPath, "utf8");
1215
+ const rewritten = rewriteIdInNodeContent(current, fromId, toId);
1216
+ if (rewritten === current) {
1217
+ throw new errors_1.UsageError(`fix apply change ${change.id} produced no file changes`);
1218
+ }
1219
+ (0, atomic_1.atomicWriteFile)(absPath, rewritten);
1220
+ const afterDetails = change.after;
1221
+ const safeReferenceRewrites = Array.isArray(afterDetails.safe_reference_rewrites)
1222
+ ? afterDetails.safe_reference_rewrites
1223
+ : [];
1224
+ const touchedPaths = new Set([relativePath]);
1225
+ for (const rewrite of safeReferenceRewrites) {
1226
+ if (typeof rewrite.path !== "string" || typeof rewrite.from !== "string" || typeof rewrite.to !== "string") {
1227
+ continue;
1228
+ }
1229
+ const rewriteAbs = path_1.default.resolve(root, rewrite.path);
1230
+ if (!isInsideRoot(root, rewriteAbs) || !fs_1.default.existsSync(rewriteAbs)) {
1231
+ continue;
1232
+ }
1233
+ const rewriteCurrent = fs_1.default.readFileSync(rewriteAbs, "utf8");
1234
+ const rewriteNext = rewriteCurrent.split(rewrite.from).join(rewrite.to);
1235
+ if (rewriteNext !== rewriteCurrent) {
1236
+ (0, atomic_1.atomicWriteFile)(rewriteAbs, rewriteNext);
1237
+ touchedPaths.add(rewrite.path);
1238
+ }
1239
+ }
1240
+ return {
1241
+ id: change.id,
1242
+ family: "ids",
1243
+ reason: change.reason,
1244
+ apply_kind: "duplicate_id_rewrite",
1245
+ path: relativePath,
1246
+ touched_paths: Array.from(touchedPaths).sort(),
1247
+ refs: change.refs,
1248
+ before: change.before,
1249
+ after: change.after,
1250
+ };
1251
+ }
1252
+ function applyGitStageDuplicateIdChange(root, change) {
1253
+ if (change.family !== "ids" || change.reason !== "git_stage_duplicate_id" || change.apply_kind !== "git_stage_duplicate_id_rewrite") {
1254
+ throw new errors_1.UsageError(`unsupported git-stage fix apply change ${change.id}`);
1255
+ }
1256
+ const before = change.before;
1257
+ const after = change.after;
1258
+ const fromId = typeof before.duplicate_id === "string" ? before.duplicate_id : undefined;
1259
+ const conflictPath = typeof before.conflict_path === "string" ? before.conflict_path : undefined;
1260
+ const candidateId = typeof after.candidate_id === "string" ? after.candidate_id : undefined;
1261
+ const candidatePath = typeof after.candidate_path === "string" ? after.candidate_path : undefined;
1262
+ if (!fromId || !conflictPath || !candidateId || !candidatePath) {
1263
+ throw new errors_1.UsageError(`fix apply change ${change.id} is missing git-stage rewrite details`);
1264
+ }
1265
+ const canonicalContent = gitShow(root, `:2:${conflictPath}`);
1266
+ const duplicateContent = gitShow(root, `:3:${conflictPath}`);
1267
+ if (canonicalContent === undefined || duplicateContent === undefined) {
1268
+ throw new errors_1.UsageError(`fix apply change ${change.id} could not read Git conflict stages for ${conflictPath}`);
1269
+ }
1270
+ const canonicalAbs = path_1.default.resolve(root, conflictPath);
1271
+ const candidateAbs = path_1.default.resolve(root, candidatePath);
1272
+ if (!isInsideRoot(root, canonicalAbs) || !isInsideRoot(root, candidateAbs)) {
1273
+ throw new errors_1.UsageError(`fix apply refused path outside repo while resolving ${conflictPath}`);
1274
+ }
1275
+ const rewrittenDuplicate = rewriteIdInNodeContent(duplicateContent, fromId, candidateId);
1276
+ (0, atomic_1.atomicWriteFile)(canonicalAbs, canonicalContent);
1277
+ (0, atomic_1.atomicWriteFile)(candidateAbs, rewrittenDuplicate);
1278
+ runGitStrict(root, ["add", "--", conflictPath, candidatePath]);
1279
+ return {
1280
+ id: change.id,
1281
+ family: "ids",
1282
+ reason: change.reason,
1283
+ apply_kind: "git_stage_duplicate_id_rewrite",
1284
+ path: conflictPath,
1285
+ touched_paths: [conflictPath, candidatePath],
1286
+ refs: change.refs,
1287
+ before: change.before,
1288
+ after: change.after,
1289
+ };
1290
+ }
1291
+ function collectFixApply(options) {
1292
+ const family = normalizeApplyFamily(options.family);
1293
+ const root = relativeRoot(options.root);
1294
+ const config = (0, config_1.loadConfig)(root);
1295
+ return (0, lock_1.withMutationLock)(root, config.index.lock_timeout_ms, () => {
1296
+ const plan = collectFixPlan({ ...options, root, family });
1297
+ const applicable = plan.proposed_changes.filter((change) => change.apply_supported);
1298
+ const unsupported = plan.proposed_changes.filter((change) => !change.apply_supported);
1299
+ if (plan.blocked_changes.length > 0) {
1300
+ throw new errors_1.UsageError("fix apply refused because the plan contains blocked changes");
1301
+ }
1302
+ if (applicable.length === 0) {
1303
+ throw new errors_1.UsageError("fix apply found no supported ids-family changes to apply");
1304
+ }
1305
+ const appliedChanges = applicable.map((change) => change.apply_kind === "git_stage_duplicate_id_rewrite"
1306
+ ? applyGitStageDuplicateIdChange(root, change)
1307
+ : applyDuplicateIdChange(root, change));
1308
+ const indexReceipt = (0, index_1.rebuildDerivedIndexCaches)({ root, tolerant: true });
1309
+ const body = {
1310
+ action: "fix.apply",
1311
+ ok: true,
1312
+ schema_version: 1,
1313
+ root,
1314
+ family,
1315
+ target: options.target ?? null,
1316
+ base_ref: options.baseRef ?? null,
1317
+ plan_id: plan.plan_id,
1318
+ plan_hash: plan.plan_hash,
1319
+ applied_changes: appliedChanges,
1320
+ blocked_changes: plan.blocked_changes,
1321
+ unsupported_changes: unsupported,
1322
+ touched_paths: Array.from(new Set(appliedChanges.flatMap((change) => change.touched_paths))).sort(),
1323
+ ambiguous_reference_rewrites: applicable.flatMap((change) => {
1324
+ const after = change.after;
1325
+ return Array.isArray(after.ambiguous_reference_rewrites) ? after.ambiguous_reference_rewrites : [];
1326
+ }),
1327
+ index: {
1328
+ rebuilt: true,
1329
+ paths: {
1330
+ nodes: rel(root, indexReceipt.paths.nodes),
1331
+ skills: rel(root, indexReceipt.paths.skills),
1332
+ capabilities: rel(root, indexReceipt.paths.capabilities),
1333
+ subgraphs: rel(root, indexReceipt.paths.subgraphs),
1334
+ sqlite: indexReceipt.paths.sqlite ? rel(root, indexReceipt.paths.sqlite) : null,
1335
+ },
1336
+ },
1337
+ summary: {
1338
+ applied_count: appliedChanges.length,
1339
+ unsupported_count: unsupported.length,
1340
+ blocked_count: plan.blocked_changes.length,
1341
+ message: unsupported.length > 0
1342
+ ? "applied supported ids-family changes; unsupported findings remain review-only"
1343
+ : "applied supported ids-family duplicate-id repairs",
1344
+ },
1345
+ };
1346
+ return {
1347
+ ...body,
1348
+ generated_at: new Date().toISOString(),
1349
+ receipt_hash: sha256(body),
1350
+ };
1351
+ });
1352
+ }
920
1353
  function runFixPlanCommand(options) {
921
1354
  const payload = collectFixPlan(options);
922
1355
  if (options.json) {
@@ -929,6 +1362,25 @@ function runFixPlanCommand(options) {
929
1362
  console.log(`family: ${payload.family}`);
930
1363
  console.log(`proposed_changes: ${payload.proposed_changes.length}`);
931
1364
  console.log(`blocked_changes: ${payload.blocked_changes.length}`);
932
- console.log("apply_supported: false");
933
- console.log("note: fix apply is not available; rerun with --json for the machine-readable receipt");
1365
+ console.log(`apply_supported: ${payload.summary.apply_supported}`);
1366
+ console.log("note: use --json for the machine-readable receipt");
1367
+ }
1368
+ function runFixApplyCommand(options) {
1369
+ const payload = collectFixApply(options);
1370
+ if (options.json) {
1371
+ console.log(JSON.stringify(payload, null, 2));
1372
+ return;
1373
+ }
1374
+ console.log("fix apply");
1375
+ console.log(`receipt_hash: ${payload.receipt_hash}`);
1376
+ console.log(`family: ${payload.family}`);
1377
+ console.log(`applied_changes: ${payload.applied_changes.length}`);
1378
+ console.log(`touched_paths: ${payload.touched_paths.join(", ")}`);
1379
+ }
1380
+ function runFixIdsCommand(options) {
1381
+ if (options.apply) {
1382
+ runFixApplyCommand({ ...options, family: "ids" });
1383
+ return;
1384
+ }
1385
+ runFixPlanCommand({ ...options, family: "ids" });
934
1386
  }