lockwire 0.1.31 → 0.1.33

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 (54) hide show
  1. package/README.md +17 -4
  2. package/dist/actions.d.ts +82 -5
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +395 -10
  5. package/dist/actions.js.map +1 -1
  6. package/dist/cli.js +69 -6
  7. package/dist/cli.js.map +1 -1
  8. package/dist/compat.d.ts +21 -0
  9. package/dist/compat.d.ts.map +1 -0
  10. package/dist/compat.js +59 -0
  11. package/dist/compat.js.map +1 -0
  12. package/dist/format.d.ts +3 -1
  13. package/dist/format.d.ts.map +1 -1
  14. package/dist/format.js +42 -9
  15. package/dist/format.js.map +1 -1
  16. package/dist/hook-claude.d.ts +6 -0
  17. package/dist/hook-claude.d.ts.map +1 -1
  18. package/dist/hook-claude.js +26 -0
  19. package/dist/hook-claude.js.map +1 -1
  20. package/dist/hook-common.d.ts +5 -0
  21. package/dist/hook-common.d.ts.map +1 -1
  22. package/dist/hook-common.js +12 -2
  23. package/dist/hook-common.js.map +1 -1
  24. package/dist/index.d.ts +3 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/lockfile.d.ts +2 -0
  29. package/dist/lockfile.d.ts.map +1 -1
  30. package/dist/lockfile.js +13 -2
  31. package/dist/lockfile.js.map +1 -1
  32. package/dist/mcp-protocol.d.ts +118 -0
  33. package/dist/mcp-protocol.d.ts.map +1 -0
  34. package/dist/mcp-protocol.js +237 -0
  35. package/dist/mcp-protocol.js.map +1 -0
  36. package/dist/mcp-server.d.ts +1 -1
  37. package/dist/mcp-server.d.ts.map +1 -1
  38. package/dist/mcp-server.js +45 -6
  39. package/dist/mcp-server.js.map +1 -1
  40. package/dist/moves.d.ts +49 -0
  41. package/dist/moves.d.ts.map +1 -0
  42. package/dist/moves.js +243 -0
  43. package/dist/moves.js.map +1 -0
  44. package/dist/reexports.d.ts +26 -0
  45. package/dist/reexports.d.ts.map +1 -0
  46. package/dist/reexports.js +174 -0
  47. package/dist/reexports.js.map +1 -0
  48. package/dist/tasks.d.ts.map +1 -1
  49. package/dist/tasks.js +33 -5
  50. package/dist/tasks.js.map +1 -1
  51. package/dist/types.d.ts +5 -0
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/types.js.map +1 -1
  54. package/package.json +2 -2
package/dist/actions.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { headCommit } from "./changed.js";
4
+ import { unreadableReason } from "./compat.js";
4
5
  import { loadDocIndex } from "./docindex.js";
5
6
  import { computeWholeFileTiers, extractFileSymbols, FINGERPRINT_VERSION, fileExportsFingerprint, } from "./extract.js";
6
7
  import { langForPath, parserFor } from "./grammar.js";
@@ -8,9 +9,13 @@ import { fingerprint, fingerprintSet } from "./hash.js";
8
9
  import { appendEvent, historyFor, readLedger } from "./ledger.js";
9
10
  import { anchorsForPath, lockfilePath, newAnchorId, readLockfile, removeAnchor, upsertAnchor, writeFileAtomic, writeLockfile, } from "./lockfile.js";
10
11
  import { claimUnchanged, retargetMarkerLine, scanMarkers, stampMarkerLine, } from "./markers.js";
12
+ import { isTrivialBody, NO_DEPS, SourceIndex, sigFingerprints } from "./moves.js";
13
+ import { extractReExports, NO_REEXPORTS, resolveModule } from "./reexports.js";
11
14
  import { canonicalDocPath, globToRegExp, isScannedDoc, matchesAny, outsideRepoMessage, resolveInsideRepo, stripBom, walkFiles, } from "./repo.js";
12
15
  import { ALL_TIERS, tierFingerprint } from "./types.js";
13
16
  import { checkClaimAgainstValue, dataFormatOf, resolveValue, valueFingerprintInput, wholeFileContent, } from "./values.js";
17
+ /** How many re-exports deep `resolveTarget` follows a name before giving up (`a` → `b` → `c` …). */
18
+ const MAX_REEXPORT_HOPS = 8;
14
19
  /** The fingerprint version an anchor was stamped with (anchors from before versions are version 1). */
15
20
  export const versionOf = (anchor) => anchor.fingerprintVersion ?? 1;
16
21
  /**
@@ -23,7 +28,83 @@ export async function resolveTarget(repoRoot, target, config, version = FINGERPR
23
28
  throw new Error(outsideRepoMessage(target.path));
24
29
  if (!existsSync(absPath))
25
30
  return null;
26
- return resolveTargetInText(target, await readFile(absPath, "utf8"), config, version);
31
+ const here = await resolveTargetInText(target, await readFile(absPath, "utf8"), config, version);
32
+ if (here.found || !target.symbol || !here.reExports)
33
+ return here;
34
+ const via = await followReExport(repoRoot, target, here.reExports, config, version, [
35
+ target.path,
36
+ ]);
37
+ if (!via)
38
+ return here;
39
+ // Bound to the old path: `path` stays that path's, and the claim keeps the name it was stamped under.
40
+ return { ...via, fingerprints: { ...via.fingerprints, path: here.fingerprints.path } };
41
+ }
42
+ /**
43
+ * The symbol a file doesn't define but re-exports, resolved where it is defined, with its `sig`
44
+ * rendered under the name the target asks for (`export { fooV2 as foo }` answers for `foo`).
45
+ * `seen` is the chain so far, so a re-export cycle ends instead of looping.
46
+ */
47
+ async function followReExport(repoRoot, target, reExports, config, version, seen) {
48
+ const symbol = target.symbol;
49
+ if (!symbol || seen.length > MAX_REEXPORT_HOPS)
50
+ return null;
51
+ const dot = symbol.indexOf(".");
52
+ const head = dot < 0 ? symbol : symbol.slice(0, dot);
53
+ const rest = dot < 0 ? "" : symbol.slice(dot + 1);
54
+ const routes = [];
55
+ for (const r of reExports.named) {
56
+ if (r.name !== head || r.imported === "default")
57
+ continue;
58
+ if (r.imported === "*") {
59
+ if (rest)
60
+ routes.push({ from: r.from, symbol: rest }); // `ns.foo` through `export * as ns`
61
+ }
62
+ else
63
+ routes.push({ from: r.from, symbol: rest ? `${r.imported}.${rest}` : r.imported });
64
+ }
65
+ for (const from of reExports.star)
66
+ routes.push({ from, symbol });
67
+ for (const route of routes) {
68
+ const file = resolveModule(repoRoot, target.path, route.from);
69
+ if (!file || seen.includes(file))
70
+ continue;
71
+ const abs = resolveInsideRepo(repoRoot, file);
72
+ if (!abs)
73
+ continue;
74
+ let there;
75
+ try {
76
+ there = await resolveTargetInText({ path: file, symbol: route.symbol }, await readFile(abs, "utf8"), config, version);
77
+ }
78
+ catch {
79
+ continue;
80
+ }
81
+ if (!there.found) {
82
+ if (!there.reExports)
83
+ continue;
84
+ const deeper = await followReExport(repoRoot, { path: file, symbol: route.symbol }, there.reExports, config, version, [...seen, file]);
85
+ if (deeper)
86
+ return renamedAs(deeper, symbol, route.symbol);
87
+ continue;
88
+ }
89
+ return renamedAs({ ...there, definedAt: { path: file, symbol: route.symbol } }, symbol, route.symbol);
90
+ }
91
+ return null;
92
+ }
93
+ /** A re-exported symbol's `sig` under the name it is asked for, when an alias renamed it on the way. */
94
+ function renamedAs(r, asked, defined) {
95
+ const name = (p) => p.slice(p.lastIndexOf(".") + 1);
96
+ if (name(asked) === name(defined))
97
+ return r;
98
+ const def = r.symbols?.bySymbolPath.get(r.definedAt?.symbol ?? defined);
99
+ const sig = def?.sigFor?.(name(asked));
100
+ return sig ? { ...r, fingerprints: { ...r.fingerprints, sig: fingerprint(sig) } } : r;
101
+ }
102
+ /** A source file's symbols, parsed (or served from the parse cache) the way `version` reads them. */
103
+ export async function symbolsOfSource(path, content, config, version = FINGERPRINT_VERSION) {
104
+ const lang = langForPath(path);
105
+ if (!lang)
106
+ return null;
107
+ return (await parseSource(path, content, lang, config, version, false)).symbols;
27
108
  }
28
109
  /**
29
110
  * {@link resolveTarget} on given file contents rather than the file on disk — used to fingerprint a
@@ -62,7 +143,12 @@ export async function resolveTargetInText(target, content, config, version = FIN
62
143
  if (target.symbol) {
63
144
  const symbol = symbols.bySymbolPath.get(target.symbol);
64
145
  if (!symbol)
65
- return { found: false, fingerprints: { path: pathFp, sig: "", body: "", deps: "" }, symbols };
146
+ return {
147
+ found: false,
148
+ fingerprints: { path: pathFp, sig: "", body: "", deps: "" },
149
+ symbols,
150
+ reExports: parsed.reExports,
151
+ };
66
152
  return {
67
153
  found: true,
68
154
  fingerprints: {
@@ -113,6 +199,9 @@ async function parseSource(path, content, lang, config, version, needWhole) {
113
199
  symbols: hit?.content === content
114
200
  ? hit.symbols
115
201
  : extractFileSymbols(tree.rootNode, lang, config.normalizeLocals, version),
202
+ reExports: hit?.content === content
203
+ ? hit.reExports
204
+ : safeReExports(() => extractReExports(tree.rootNode, lang)),
116
205
  ...(needWhole
117
206
  ? { whole: computeWholeFileTiers(tree.rootNode, lang, config.normalizeLocals, version) }
118
207
  : {}),
@@ -130,19 +219,30 @@ async function parseSource(path, content, lang, config, version, needWhole) {
130
219
  }
131
220
  return parsed;
132
221
  }
222
+ /** Re-exports are a convenience on top of resolution: a form this reader trips on just isn't followed. */
223
+ function safeReExports(read) {
224
+ try {
225
+ return read();
226
+ }
227
+ catch {
228
+ return NO_REEXPORTS;
229
+ }
230
+ }
133
231
  /**
134
232
  * Same-file rename detection: if a symbol vanished but exactly one other symbol in the file has the
135
233
  * same signature apart from its name, relink to it. `sig` fingerprints include the symbol's name,
136
234
  * so each candidate is re-rendered under the missing symbol's old name before comparing -- that
137
235
  * keeps stored fingerprints valid. Symbols already bound by another anchor are skipped, so an
138
- * unrelated neighbour with the same shape isn't mistaken for the rename target. Cross-file
139
- * relocation (the symbol moved to a different file) is not attempted in P0 — see docs/concepts.md limitations.
236
+ * unrelated neighbour with the same shape isn't mistaken for the rename target. A symbol moved to a
237
+ * different file is found by `SourceIndex.findMove` (moves.ts) and re-pointed by {@link repoint}.
140
238
  */
141
239
  export function findRelocationCandidate(symbols, missingSigFp, oldName, taken = new Set(),
142
240
  /**
143
241
  * The vanished symbol's stamped body and deps. When given, a candidate must also match one of them:
144
242
  * a rename keeps the implementation, while a deleted function's look-alike neighbour (same
145
- * parameters, different code) is not where its claim went.
243
+ * parameters, different code) is not where its claim went. An empty body or no calls at all is no
244
+ * evidence: any two functions with the same parameters would "match" on it (`findPosX` and
245
+ * `findPosY`, both calling nothing).
146
246
  */
147
247
  stamped) {
148
248
  const candidates = [];
@@ -156,8 +256,8 @@ stamped) {
156
256
  if (fingerprint(sig) !== missingSigFp)
157
257
  continue;
158
258
  if (stamped &&
159
- fingerprint(symbol.bodyNormalized) !== stamped.body &&
160
- fingerprintSet(symbol.deps) !== stamped.deps)
259
+ !(!isTrivialBody(stamped.body) && fingerprint(symbol.bodyNormalized) === stamped.body) &&
260
+ !(stamped.deps !== NO_DEPS && fingerprintSet(symbol.deps) === stamped.deps))
161
261
  continue;
162
262
  candidates.push(path);
163
263
  }
@@ -234,6 +334,15 @@ export async function linkDoc(repoRoot, docPath, config, actor, opts = {}) {
234
334
  existing = undefined;
235
335
  }
236
336
  }
337
+ // Re-stamping what a newer lockwire wrote would downgrade it to what this one understands.
338
+ const tooNew = existing && !copiedId ? unreadableReason(existing) : null;
339
+ if (tooNew) {
340
+ result.skipped.push({
341
+ reason: `stamped by a newer lockwire (${tooNew}): update this lockwire to re-link it`,
342
+ target: targetLabel,
343
+ });
344
+ continue;
345
+ }
237
346
  // One unsupported or unparseable target must not stop the rest of the doc from linking.
238
347
  let resolved;
239
348
  try {
@@ -587,17 +696,31 @@ export async function check(repoRoot, config, onlyPaths, opts = {}) {
587
696
  let current = lockfile;
588
697
  let relocatedCount = 0;
589
698
  const markersIn = markerReader(repoRoot);
699
+ const moveSearchMs = opts.moveSearchMs ?? Number.POSITIVE_INFINITY;
700
+ const boundTargets = new Set(lockfile.anchors.flatMap((a) => a.target.symbol && a.status !== "superseded" ? [`${a.target.path}#${a.target.symbol}`] : []));
701
+ let sources;
702
+ /** Where an anchor's code as stamped lives now, if it moved to another file. */
703
+ const movedOf = async (a) => {
704
+ if (!a.target.symbol || moveSearchMs <= 0)
705
+ return {};
706
+ sources ??= new SourceIndex(repoRoot, config, (path, content, version) => symbolsOfSource(path, content, config, version), Date.now() + moveSearchMs);
707
+ const hint = await sources.findMove(a, versionOf(a), boundTargets);
708
+ return hint ? { moved: hint } : {};
709
+ };
590
710
  for (const stored of lockfile.anchors) {
591
711
  let anchor = stored;
592
712
  const inScope = !onlyPaths ||
593
713
  inPathScope(anchor.target.path, onlyPaths) ||
594
714
  (anchor.doc !== null && inPathScope(anchor.doc, onlyPaths));
595
- if (!inScope) {
715
+ // A newer lockwire stamped it: checking it the way this one reads would report drift that isn't there.
716
+ const tooNew = unreadableReason(anchor);
717
+ if (!inScope || tooNew) {
596
718
  results.push({
597
719
  anchor,
598
720
  status: anchor.status,
599
721
  driftedTiers: [],
600
722
  skipped: true,
723
+ ...(tooNew ? { unreadable: tooNew } : {}),
601
724
  singleHashWouldFlag: false,
602
725
  });
603
726
  continue;
@@ -666,11 +789,13 @@ export async function check(repoRoot, config, onlyPaths, opts = {}) {
666
789
  }
667
790
  let resolved;
668
791
  let unresolvedNote = "file not found";
792
+ let unreadable = false;
669
793
  try {
670
794
  resolved = await resolveTarget(repoRoot, anchor.target, config, versionOf(anchor));
671
795
  }
672
796
  catch (err) {
673
797
  resolved = null;
798
+ unreadable = true;
674
799
  unresolvedNote = err instanceof Error ? err.message : String(err);
675
800
  }
676
801
  if (!resolved) {
@@ -690,6 +815,7 @@ export async function check(repoRoot, config, onlyPaths, opts = {}) {
690
815
  anchor: orphaned,
691
816
  status: "orphaned",
692
817
  driftedTiers: [],
818
+ ...(unreadable ? {} : await movedOf(anchor)),
693
819
  singleHashWouldFlag: true,
694
820
  });
695
821
  continue;
@@ -700,8 +826,14 @@ export async function check(repoRoot, config, onlyPaths, opts = {}) {
700
826
  .filter((a) => a.id !== anchor.id && a.target.path === anchor.target.path)
701
827
  .flatMap((a) => (a.target.symbol ? [a.target.symbol] : []))), anchor.fingerprints)
702
828
  : null;
703
- if (relocatedTo) {
704
- const reResolved = await resolveTarget(repoRoot, { path: anchor.target.path, symbol: relocatedTo }, config, versionOf(anchor));
829
+ const reResolved = relocatedTo
830
+ ? await resolveTarget(repoRoot, { path: anchor.target.path, symbol: relocatedTo }, config, versionOf(anchor))
831
+ : null;
832
+ // A candidate that matched only on the calls it makes (the body differs) is a guess: if the
833
+ // symbol's code as stamped lives in another file, that is where the claim went.
834
+ const keptBody = reResolved && tierFingerprint(reResolved.fingerprints, "body") === anchor.fingerprints.body;
835
+ const movedElsewhere = relocatedTo && !keptBody ? await movedOf(anchor) : {};
836
+ if (relocatedTo && !movedElsewhere.moved?.to) {
705
837
  const target = { path: anchor.target.path, symbol: relocatedTo };
706
838
  // A rename must not launder other changes: `sig` is expected to differ (it carries the
707
839
  // name, and the candidate already matched on it), but any other bound tier that moved is
@@ -766,15 +898,26 @@ export async function check(repoRoot, config, onlyPaths, opts = {}) {
766
898
  note: "symbol not found",
767
899
  });
768
900
  }
901
+ const moved = await movedOf(anchor);
902
+ const sameSignature = !moved.moved?.to && resolved.symbols
903
+ ? findRelocationCandidate(resolved.symbols, anchor.fingerprints.sig, anchor.target.symbol.slice(anchor.target.symbol.lastIndexOf(".") + 1), new Set(lockfile.anchors
904
+ .filter((a) => a.id !== anchor.id && a.target.path === anchor.target.path)
905
+ .flatMap((a) => (a.target.symbol ? [a.target.symbol] : []))))
906
+ : null;
769
907
  results.push({
770
908
  anchor: orphaned,
771
909
  status: "orphaned",
772
910
  driftedTiers: [],
911
+ ...moved,
912
+ ...(sameSignature
913
+ ? { similar: { path: anchor.target.path, symbol: sameSignature } }
914
+ : {}),
773
915
  singleHashWouldFlag: true,
774
916
  });
775
917
  }
776
918
  continue;
777
919
  }
920
+ const definedAt = resolved.definedAt ? { definedAt: resolved.definedAt } : {};
778
921
  const nowFingerprints = resolved.fingerprints;
779
922
  const moved = (t) => tierFingerprint(nowFingerprints, t) !== tierFingerprint(anchor.fingerprints, t);
780
923
  let driftedTiers = anchor.tiers.filter(moved);
@@ -842,12 +985,17 @@ export async function check(repoRoot, config, onlyPaths, opts = {}) {
842
985
  });
843
986
  }
844
987
  }
988
+ // The code here changed: maybe it was moved out, unchanged, and something else left in its place
989
+ // (a wrapper kept for compatibility).
990
+ const movedOut = anyTierChanged.includes("body") ? await movedOf(anchor) : {};
845
991
  results.push({
846
992
  anchor: drifted,
847
993
  status: "drifted",
848
994
  driftedTiers,
849
995
  ...(claimChanged ? { claimChanged } : {}),
850
996
  ...(valueReport ? { value: valueReport } : {}),
997
+ ...movedOut,
998
+ ...definedAt,
851
999
  singleHashWouldFlag: anyTierChanged.length > 0,
852
1000
  });
853
1001
  }
@@ -881,6 +1029,7 @@ export async function check(repoRoot, config, onlyPaths, opts = {}) {
881
1029
  anchor: resolvedAnchor,
882
1030
  status: "fresh",
883
1031
  driftedTiers: [],
1032
+ ...definedAt,
884
1033
  singleHashWouldFlag: false,
885
1034
  });
886
1035
  }
@@ -889,6 +1038,7 @@ export async function check(repoRoot, config, onlyPaths, opts = {}) {
889
1038
  anchor,
890
1039
  status: anchor.status,
891
1040
  driftedTiers: [],
1041
+ ...definedAt,
892
1042
  singleHashWouldFlag: anyTierChanged.length > 0,
893
1043
  });
894
1044
  }
@@ -898,6 +1048,23 @@ export async function check(repoRoot, config, onlyPaths, opts = {}) {
898
1048
  // rooted at a parent folder of the real repo).
899
1049
  if (write && (lockfile.anchors.length > 0 || existsSync(lockfilePath(repoRoot))))
900
1050
  await writeLockfile(repoRoot, current);
1051
+ // Claims from different places can't all have moved to the same one: identical code in two files
1052
+ // (a copy, a twin that was already there) would otherwise make each look like the other's move.
1053
+ // None gets a new home then; the places stay listed so a person can choose.
1054
+ const origins = new Map();
1055
+ for (const r of results) {
1056
+ const to = r.moved?.to;
1057
+ if (!to || !r.anchor.target.symbol)
1058
+ continue;
1059
+ const key = `${to.path}#${to.symbol}`;
1060
+ const from = `${r.anchor.target.path}#${r.anchor.target.symbol}`;
1061
+ origins.set(key, (origins.get(key) ?? new Set()).add(from));
1062
+ }
1063
+ for (const r of results) {
1064
+ const to = r.moved?.to;
1065
+ if (r.moved && to && (origins.get(`${to.path}#${to.symbol}`)?.size ?? 0) > 1)
1066
+ r.moved = { candidates: r.moved.candidates };
1067
+ }
901
1068
  // Anchors outside a scoped run weren't examined: they stay in `results` (callers like the hooks
902
1069
  // look anchors up there) but must not count towards the summary or the exit code.
903
1070
  const examined = results.filter((r) => !r.skipped);
@@ -922,6 +1089,7 @@ export async function check(repoRoot, config, onlyPaths, opts = {}) {
922
1089
  orphaned: examined.filter((r) => r.status === "orphaned").length,
923
1090
  waived: examined.filter((r) => r.status === "waived").length,
924
1091
  superseded: examined.filter((r) => r.status === "superseded").length,
1092
+ moved: examined.filter((r) => r.moved?.to).length,
925
1093
  unlinked: unlinked.length,
926
1094
  noise: { singleHashWouldFlag, tieredFlagged, reductionPercent },
927
1095
  };
@@ -939,6 +1107,9 @@ export async function ack(repoRoot, anchorId, resolution, note, actor, config) {
939
1107
  const anchor = lockfile.anchors.find((a) => a.id === anchorId);
940
1108
  if (!anchor)
941
1109
  throw new Error(`no anchor ${anchorId}`);
1110
+ const tooNew = unreadableReason(anchor);
1111
+ if (tooNew && resolution !== "superseded")
1112
+ throw new Error(`${anchorId} was stamped by a newer lockwire (${tooNew}): update this lockwire to acknowledge it`);
942
1113
  let updated = anchor;
943
1114
  if (resolution === "superseded") {
944
1115
  updated = { ...anchor, status: "superseded" };
@@ -986,6 +1157,220 @@ export async function ack(repoRoot, anchorId, resolution, note, actor, config) {
986
1157
  });
987
1158
  return updated;
988
1159
  }
1160
+ const targetLabel = (t) => `${t.path}${t.symbol ? `#${t.symbol}` : ""}`;
1161
+ /**
1162
+ * `lockwire repoint`: move claims whose code moved to another file over to its new home, rewriting
1163
+ * each doc marker to match. With no `to`, the new home is where `check` found the symbol's code as
1164
+ * stamped (only when there is exactly one such place); `ids` limits it to those anchors. With `to`
1165
+ * (and one id), it is that target, for a move lockwire couldn't place on its own.
1166
+ *
1167
+ * The path changes by definition, and so may `export` on the signature (a helper moved into a module
1168
+ * of its own is usually exported now). Any other bound tier that differs at the new place is drift and
1169
+ * stays drift, unless `reviewed` says a person checked the claim against the code there.
1170
+ */
1171
+ export async function repoint(repoRoot, config, actor, opts = {}) {
1172
+ if (opts.to && opts.ids?.length !== 1)
1173
+ throw new Error("--to names one new home: pass exactly one anchor id with it");
1174
+ const lockfile = await readLockfile(repoRoot);
1175
+ const result = { repointed: [], skipped: [] };
1176
+ const byId = new Map(lockfile.anchors.map((a) => [a.id, a]));
1177
+ for (const id of opts.ids ?? [])
1178
+ if (!byId.has(id))
1179
+ result.skipped.push({ id, target: "-", reason: `no anchor ${id}` });
1180
+ const plan = [];
1181
+ const wanted = opts.ids ? opts.ids.filter((id) => byId.has(id)) : null;
1182
+ const only = wanted?.[0] ? byId.get(wanted[0]) : undefined;
1183
+ if (opts.to && only)
1184
+ plan.push({ anchor: only, to: opts.to });
1185
+ else if (!opts.to && (!wanted || wanted.length > 0)) {
1186
+ const scope = wanted
1187
+ ? [
1188
+ ...new Set(wanted.flatMap((id) => {
1189
+ const a = byId.get(id);
1190
+ return a ? [a.target.path, ...(a.doc ? [a.doc] : [])] : [];
1191
+ })),
1192
+ ]
1193
+ : undefined;
1194
+ const checked = await check(repoRoot, config, scope, { write: false });
1195
+ for (const r of checked.results) {
1196
+ if (r.skipped || (wanted && !wanted.includes(r.anchor.id)))
1197
+ continue;
1198
+ const stored = byId.get(r.anchor.id);
1199
+ if (!stored)
1200
+ continue;
1201
+ if (r.moved?.to) {
1202
+ plan.push({ anchor: stored, to: r.moved.to });
1203
+ continue;
1204
+ }
1205
+ if (!wanted)
1206
+ continue;
1207
+ result.skipped.push({
1208
+ id: stored.id,
1209
+ target: targetLabel(stored.target),
1210
+ reason: r.moved?.candidates.length
1211
+ ? `its code is in ${r.moved.candidates.length} places (${r.moved.candidates.map(targetLabel).join(", ")}): pass --to <path#symbol> to choose`
1212
+ : r.status === "waived"
1213
+ ? "waived: check doesn't look for its code until the waiver expires; pass --to <path#symbol> to re-point it now"
1214
+ : r.status === "fresh"
1215
+ ? "nothing to re-point: it still resolves where it is"
1216
+ : stored.target.symbol
1217
+ ? "no other file has its code as stamped: if it moved and changed, pass --to <path#symbol>"
1218
+ : "lockwire only finds moved symbols: pass --to <path> for a file that moved",
1219
+ });
1220
+ }
1221
+ }
1222
+ let current = lockfile;
1223
+ const now = new Date().toISOString();
1224
+ const markerEdits = new Map();
1225
+ const outcomes = [];
1226
+ for (const { anchor, to } of plan) {
1227
+ const skip = (reason) => result.skipped.push({ id: anchor.id, target: targetLabel(anchor.target), reason });
1228
+ if (anchor.status === "superseded") {
1229
+ skip("superseded: its claim no longer applies, so there is nothing to re-point");
1230
+ continue;
1231
+ }
1232
+ const tooNew = unreadableReason(anchor);
1233
+ if (tooNew) {
1234
+ skip(`stamped by a newer lockwire (${tooNew}): update this lockwire to re-point it`);
1235
+ continue;
1236
+ }
1237
+ if (to.path === anchor.target.path && to.symbol === anchor.target.symbol) {
1238
+ skip(`already points at ${targetLabel(to)}`);
1239
+ continue;
1240
+ }
1241
+ if (Boolean(to.symbol) !== Boolean(anchor.target.symbol)) {
1242
+ skip(anchor.target.symbol
1243
+ ? `a claim about a symbol re-points to a symbol: use ${to.path}#<symbol>`
1244
+ : "a claim about a whole file re-points to a file, not a symbol");
1245
+ continue;
1246
+ }
1247
+ const tierProblem = inapplicableTiersReason(to, anchor.tiers);
1248
+ if (tierProblem) {
1249
+ skip(tierProblem);
1250
+ continue;
1251
+ }
1252
+ let there = null;
1253
+ try {
1254
+ there = await resolveTarget(repoRoot, to, config, versionOf(anchor));
1255
+ }
1256
+ catch (err) {
1257
+ skip(err instanceof Error ? err.message : String(err));
1258
+ continue;
1259
+ }
1260
+ if (!there?.found) {
1261
+ skip(`${targetLabel(to)} not found`);
1262
+ continue;
1263
+ }
1264
+ // `sig` carries the symbol's name and `export`: compare it as the old name would read, either way.
1265
+ const def = to.symbol
1266
+ ? there.symbols?.bySymbolPath.get(there.definedAt?.symbol ?? to.symbol)
1267
+ : undefined;
1268
+ const oldName = (anchor.target.symbol ?? "").slice((anchor.target.symbol ?? "").lastIndexOf(".") + 1);
1269
+ const sigMatches = def
1270
+ ? sigFingerprints(def.sigFor?.(oldName) ?? def.sigTokens).has(anchor.fingerprints.sig)
1271
+ : there.fingerprints.sig === anchor.fingerprints.sig;
1272
+ const driftedTiers = anchor.tiers.filter((t) => t === "path"
1273
+ ? false
1274
+ : t === "sig"
1275
+ ? !sigMatches
1276
+ : tierFingerprint(there.fingerprints, t) !== tierFingerprint(anchor.fingerprints, t));
1277
+ let fingerprints = there.fingerprints;
1278
+ let fingerprintVersion = versionOf(anchor);
1279
+ let status = anchor.status === "waived" ? "waived" : "fresh";
1280
+ if (opts.reviewed && driftedTiers.length > 0) {
1281
+ const restamp = await resolveTarget(repoRoot, to, config).catch(() => null);
1282
+ if (restamp?.found) {
1283
+ fingerprints = restamp.fingerprints;
1284
+ fingerprintVersion = FINGERPRINT_VERSION;
1285
+ }
1286
+ }
1287
+ else if (driftedTiers.length > 0) {
1288
+ fingerprints = { ...there.fingerprints };
1289
+ for (const t of driftedTiers)
1290
+ fingerprints[t] = tierFingerprint(anchor.fingerprints, t);
1291
+ if (status !== "waived")
1292
+ status = "drifted";
1293
+ }
1294
+ const updated = {
1295
+ ...anchor,
1296
+ target: to,
1297
+ fingerprints,
1298
+ fingerprintVersion,
1299
+ status,
1300
+ ...(there.value ? { value: there.value.display } : {}),
1301
+ };
1302
+ current = upsertAnchor(current, updated);
1303
+ if (anchor.doc && anchor.claim) {
1304
+ const list = markerEdits.get(anchor.doc) ?? [];
1305
+ list.push({ id: anchor.id, to });
1306
+ markerEdits.set(anchor.doc, list);
1307
+ }
1308
+ outcomes.push({
1309
+ id: anchor.id,
1310
+ doc: anchor.doc,
1311
+ from: anchor.target,
1312
+ to,
1313
+ status,
1314
+ driftedTiers: opts.reviewed ? [] : driftedTiers,
1315
+ markerUpdated: false,
1316
+ claimNamesOldPath: false,
1317
+ });
1318
+ }
1319
+ // Each doc is read and written once, however many of its markers moved.
1320
+ for (const [doc, edits] of markerEdits) {
1321
+ const abs = resolveInsideRepo(repoRoot, doc);
1322
+ if (!abs || !existsSync(abs))
1323
+ continue;
1324
+ const text = await readFile(abs, "utf8");
1325
+ const markers = scanMarkers(text);
1326
+ const parts = text.split(/(\r?\n)/);
1327
+ let changed = false;
1328
+ for (const edit of edits) {
1329
+ const marker = markers.find((m) => m.id === edit.id);
1330
+ const outcome = outcomes.find((o) => o.id === edit.id);
1331
+ if (!marker || !outcome)
1332
+ continue;
1333
+ const idx = (marker.line - 1) * 2;
1334
+ const line = parts[idx];
1335
+ if (line === undefined)
1336
+ continue;
1337
+ parts[idx] = retargetMarkerLine(line, edit.to);
1338
+ changed = true;
1339
+ outcome.markerUpdated = true;
1340
+ outcome.claimNamesOldPath = marker.claimText.includes(outcome.from.path);
1341
+ }
1342
+ if (changed && !opts.dryRun)
1343
+ await writeFileAtomic(abs, parts.join(""));
1344
+ }
1345
+ if (!opts.dryRun && outcomes.length > 0) {
1346
+ await writeLockfile(repoRoot, current);
1347
+ const commit = headCommit(repoRoot);
1348
+ for (const o of outcomes) {
1349
+ await appendEvent(repoRoot, {
1350
+ ts: now,
1351
+ event: "anchor.relocated",
1352
+ anchor: o.id,
1353
+ actor,
1354
+ commit,
1355
+ note: `${targetLabel(o.from)} -> ${targetLabel(o.to)} (moved; re-pointed${opts.reviewed ? ", reviewed" : ""})`,
1356
+ });
1357
+ const stored = byId.get(o.id);
1358
+ for (const tier of o.driftedTiers)
1359
+ await appendEvent(repoRoot, {
1360
+ ts: now,
1361
+ event: "anchor.drifted",
1362
+ anchor: o.id,
1363
+ actor,
1364
+ commit,
1365
+ tier,
1366
+ from: stored ? tierFingerprint(stored.fingerprints, tier) : "",
1367
+ note: `differs at ${targetLabel(o.to)}`,
1368
+ });
1369
+ }
1370
+ }
1371
+ result.repointed = outcomes;
1372
+ return result;
1373
+ }
989
1374
  export async function waive(repoRoot, anchorId, reason, expires, actor) {
990
1375
  const expiresAt = Date.parse(expires);
991
1376
  // Date.parse rolls an impossible day over (2027-02-31 is March 3rd); a waiver must say what it means.