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.
- package/README.md +17 -4
- package/dist/actions.d.ts +82 -5
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +395 -10
- package/dist/actions.js.map +1 -1
- package/dist/cli.js +69 -6
- package/dist/cli.js.map +1 -1
- package/dist/compat.d.ts +21 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +59 -0
- package/dist/compat.js.map +1 -0
- package/dist/format.d.ts +3 -1
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +42 -9
- package/dist/format.js.map +1 -1
- package/dist/hook-claude.d.ts +6 -0
- package/dist/hook-claude.d.ts.map +1 -1
- package/dist/hook-claude.js +26 -0
- package/dist/hook-claude.js.map +1 -1
- package/dist/hook-common.d.ts +5 -0
- package/dist/hook-common.d.ts.map +1 -1
- package/dist/hook-common.js +12 -2
- package/dist/hook-common.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lockfile.d.ts +2 -0
- package/dist/lockfile.d.ts.map +1 -1
- package/dist/lockfile.js +13 -2
- package/dist/lockfile.js.map +1 -1
- package/dist/mcp-protocol.d.ts +118 -0
- package/dist/mcp-protocol.d.ts.map +1 -0
- package/dist/mcp-protocol.js +237 -0
- package/dist/mcp-protocol.js.map +1 -0
- package/dist/mcp-server.d.ts +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +45 -6
- package/dist/mcp-server.js.map +1 -1
- package/dist/moves.d.ts +49 -0
- package/dist/moves.d.ts.map +1 -0
- package/dist/moves.js +243 -0
- package/dist/moves.js.map +1 -0
- package/dist/reexports.d.ts +26 -0
- package/dist/reexports.d.ts.map +1 -0
- package/dist/reexports.js +174 -0
- package/dist/reexports.js.map +1 -0
- package/dist/tasks.d.ts.map +1 -1
- package/dist/tasks.js +33 -5
- package/dist/tasks.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- 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
|
-
|
|
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 {
|
|
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.
|
|
139
|
-
*
|
|
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)
|
|
160
|
-
fingerprintSet(symbol.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
|
-
|
|
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
|
-
|
|
704
|
-
|
|
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.
|