@gmickel/gno 1.30.7 → 1.31.0

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 (32) hide show
  1. package/README.md +2 -2
  2. package/assets/skill/SKILL.md +2 -0
  3. package/assets/skill/mcp-reference.md +6 -0
  4. package/browser-extension/artifacts/{gno-browser-clipper-v1.30.7.zip → gno-browser-clipper-v1.31.0.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.31.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/package.json +1 -1
  8. package/spec/mcp.md +62 -0
  9. package/spec/output-schemas/section-target-create-result.schema.json +20 -0
  10. package/spec/output-schemas/section-target-resolve-result.schema.json +194 -0
  11. package/spec/output-schemas/section-target.schema.json +118 -0
  12. package/spec/output-schemas/section.schema.json +113 -0
  13. package/src/core/section-parse.ts +187 -0
  14. package/src/core/section-target-link.ts +154 -0
  15. package/src/core/section-target-resolve.ts +351 -0
  16. package/src/core/section-target-transport.ts +519 -0
  17. package/src/core/section-target.ts +263 -0
  18. package/src/core/sections.ts +60 -115
  19. package/src/mcp/AGENTS.md +1 -0
  20. package/src/mcp/CLAUDE.md +1 -0
  21. package/src/mcp/http-egress.ts +1 -0
  22. package/src/mcp/tools/index.ts +19 -0
  23. package/src/mcp/tools/sections.ts +512 -0
  24. package/src/sdk/client.ts +71 -1
  25. package/src/sdk/index.ts +5 -0
  26. package/src/sdk/types.ts +29 -1
  27. package/src/serve/public/globals.built.css +1 -1
  28. package/src/serve/public/lib/section-links.ts +189 -0
  29. package/src/serve/public/pages/DocView.tsx +219 -36
  30. package/src/serve/routes/section-targets.ts +221 -0
  31. package/src/serve/server.ts +34 -0
  32. package/browser-extension/artifacts/gno-browser-clipper-v1.30.7.zip.sha256 +0 -1
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Shared Markdown section extraction (ATX headings + fence awareness).
3
+ * Private helper module — import public API from `./sections`.
4
+ *
5
+ * @module src/core/section-parse
6
+ */
7
+
8
+ export interface DocumentSection {
9
+ anchor: string;
10
+ level: number;
11
+ line: number;
12
+ title: string;
13
+ }
14
+
15
+ /** Structural section record used by target create/resolve. */
16
+ export interface SectionRecord {
17
+ section: DocumentSection;
18
+ headingPath: string[];
19
+ occurrence: number;
20
+ /** Inclusive start line through exclusive end line of section body. */
21
+ endLine: number;
22
+ titleStartOffset: number;
23
+ titleEndOffset: number;
24
+ }
25
+
26
+ const HEADING_REGEX = /^(#{1,6})\s+(.+?)\s*#*\s*$/u;
27
+ const FENCE_REGEX = /^ {0,3}(`{3,}|~{3,})(.*)$/u;
28
+ const FENCE_CLOSE_REGEX = /^ {0,3}(`{3,}|~{3,})[\t ]*$/u;
29
+
30
+ interface OpenFence {
31
+ marker: "`" | "~";
32
+ length: number;
33
+ }
34
+
35
+ const fenceOpener = (line: string): OpenFence | null => {
36
+ const match = FENCE_REGEX.exec(line);
37
+ const run = match?.[1];
38
+ const suffix = match?.[2] ?? "";
39
+ if (!run || (run[0] === "`" && suffix.includes("`"))) return null;
40
+ return { marker: run[0] as OpenFence["marker"], length: run.length };
41
+ };
42
+
43
+ const closesFence = (line: string, fence: OpenFence): boolean => {
44
+ const run = FENCE_CLOSE_REGEX.exec(line)?.[1];
45
+ return Boolean(run && run[0] === fence.marker && run.length >= fence.length);
46
+ };
47
+
48
+ export const normalizeHeadingTitle = (title: string): string =>
49
+ title.normalize("NFC").trim();
50
+
51
+ export const pathKey = (headingPath: readonly string[]): string =>
52
+ headingPath.join("\0");
53
+
54
+ export function slugifySectionTitle(title: string): string {
55
+ return (
56
+ title
57
+ .normalize("NFC")
58
+ .toLowerCase()
59
+ .trim()
60
+ .replaceAll(/[^\p{L}\p{N}\s-]/gu, "")
61
+ .replaceAll(/\s+/g, "-")
62
+ .replaceAll(/-+/g, "-")
63
+ .replace(/^-|-$/g, "") || "section"
64
+ );
65
+ }
66
+
67
+ export const lineStartOffsets = (content: string): number[] => {
68
+ const offsets = [0];
69
+ for (let index = 0; index < content.length; index += 1) {
70
+ if (content[index] === "\n") offsets.push(index + 1);
71
+ }
72
+ return offsets;
73
+ };
74
+
75
+ /** Extract structural section records (no quote evidence). */
76
+ export function extractSectionRecords(content: string): SectionRecord[] {
77
+ const records: SectionRecord[] = [];
78
+ const counts = new Map<string, number>();
79
+ const pathCounts = new Map<string, number>();
80
+ const lines = content.split("\n");
81
+ const starts = lineStartOffsets(content);
82
+ let openFence: OpenFence | null = null;
83
+ const stack: { level: number; title: string }[] = [];
84
+
85
+ for (const [index, line] of lines.entries()) {
86
+ if (openFence) {
87
+ if (closesFence(line, openFence)) openFence = null;
88
+ continue;
89
+ }
90
+ const opener = fenceOpener(line);
91
+ if (opener) {
92
+ openFence = opener;
93
+ continue;
94
+ }
95
+ const match = HEADING_REGEX.exec(line);
96
+ if (!match) continue;
97
+
98
+ const level = match[1]?.length ?? 0;
99
+ const title = match[2]?.trim() ?? "";
100
+ if (!title) continue;
101
+
102
+ const normalizedTitle = normalizeHeadingTitle(title);
103
+ while (stack.length > 0 && (stack.at(-1)?.level ?? 0) >= level) {
104
+ stack.pop();
105
+ }
106
+ stack.push({ level, title: normalizedTitle });
107
+ const headingPath = stack.map((entry) => entry.title);
108
+ const occurrence = (pathCounts.get(pathKey(headingPath)) ?? 0) + 1;
109
+ pathCounts.set(pathKey(headingPath), occurrence);
110
+
111
+ const baseAnchor = slugifySectionTitle(title);
112
+ const count = (counts.get(baseAnchor) ?? 0) + 1;
113
+ counts.set(baseAnchor, count);
114
+ const anchor = count === 1 ? baseAnchor : `${baseAnchor}-${count}`;
115
+
116
+ const lineStart = starts[index] ?? 0;
117
+ const titleOffsetInLine = line.indexOf(title);
118
+ const titleStartOffset =
119
+ titleOffsetInLine >= 0 ? lineStart + titleOffsetInLine : lineStart;
120
+ const titleEndOffset = titleStartOffset + title.length;
121
+
122
+ records.push({
123
+ section: {
124
+ anchor,
125
+ level,
126
+ line: index + 1,
127
+ title,
128
+ },
129
+ headingPath,
130
+ occurrence,
131
+ endLine: lines.length,
132
+ titleStartOffset,
133
+ titleEndOffset,
134
+ });
135
+ }
136
+
137
+ for (const [recordIndex, record] of records.entries()) {
138
+ let endLine = lines.length;
139
+ for (let next = recordIndex + 1; next < records.length; next += 1) {
140
+ const candidate = records[next];
141
+ if (candidate && candidate.section.level <= record.section.level) {
142
+ endLine = candidate.section.line - 1;
143
+ break;
144
+ }
145
+ }
146
+ record.endLine = endLine;
147
+ }
148
+
149
+ return records;
150
+ }
151
+
152
+ export function extractSections(content: string): DocumentSection[] {
153
+ return extractSectionRecords(content).map((record) => record.section);
154
+ }
155
+
156
+ /** Extract one inclusive, 1-based line range without normalizing source bytes. */
157
+ export function extractInclusiveLines(
158
+ content: string,
159
+ startLine: number,
160
+ endLine: number
161
+ ): string | null {
162
+ if (
163
+ content.includes("\r") ||
164
+ !Number.isSafeInteger(startLine) ||
165
+ !Number.isSafeInteger(endLine) ||
166
+ startLine < 1 ||
167
+ endLine < startLine
168
+ ) {
169
+ return null;
170
+ }
171
+ const lines = content.split("\n");
172
+ if (endLine > lines.length) return null;
173
+ return lines.slice(startLine - 1, endLine).join("\n");
174
+ }
175
+
176
+ /** Find the nearest Markdown heading governing a 1-based source line. */
177
+ export function headingForLine(
178
+ sections: readonly DocumentSection[],
179
+ line: number
180
+ ): string | null {
181
+ let heading: string | null = null;
182
+ for (const section of sections) {
183
+ if (section.line > line) break;
184
+ heading = section.title;
185
+ }
186
+ return heading;
187
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Privacy-safe, bounded encoding of SectionTargetV1 for additive URL params.
3
+ * Private helper — import public API from `./sections`.
4
+ *
5
+ * Human-readable `#anchor` fragments stay unchanged. The optional `st`
6
+ * query param carries a versioned, size-bounded target for citation-safe
7
+ * recovery. It must never embed a full section body.
8
+ *
9
+ * @module src/core/section-target-link
10
+ */
11
+
12
+ import {
13
+ isBoundedSectionTarget,
14
+ SECTION_TARGET_BOUNDS,
15
+ type SectionTargetV1,
16
+ } from "./section-target";
17
+ import { parseSectionTargetV1 } from "./section-target-transport";
18
+
19
+ /** Query parameter name for the additive durable selector. */
20
+ export const SECTION_TARGET_LINK_PARAM = "st" as const;
21
+
22
+ /** Version prefix for the encoded selector payload. */
23
+ export const SECTION_TARGET_LINK_VERSION = "1" as const;
24
+
25
+ /**
26
+ * Hard cap on the encoded `st` value (version prefix + base64url payload).
27
+ * Keeps citation links shareable in local tooling without giant URLs.
28
+ */
29
+ export const SECTION_TARGET_LINK_MAX_ENCODED_CHARS = 3072 as const;
30
+
31
+ const UTF8 = new TextEncoder();
32
+ const UTF8_DECODER = new TextDecoder();
33
+
34
+ const bytesToBase64Url = (bytes: Uint8Array): string => {
35
+ let binary = "";
36
+ for (const byte of bytes) {
37
+ binary += String.fromCharCode(byte);
38
+ }
39
+ return btoa(binary)
40
+ .replaceAll("+", "-")
41
+ .replaceAll("/", "_")
42
+ .replace(/=+$/u, "");
43
+ };
44
+
45
+ const base64UrlToBytes = (value: string): Uint8Array | null => {
46
+ if (!/^[A-Za-z0-9_-]*$/u.test(value)) {
47
+ return null;
48
+ }
49
+ const padded = value.replaceAll("-", "+").replaceAll("_", "/");
50
+ const padLength = (4 - (padded.length % 4)) % 4;
51
+ const base64 = padded + "=".repeat(padLength);
52
+ try {
53
+ const binary = atob(base64);
54
+ const bytes = new Uint8Array(binary.length);
55
+ for (let index = 0; index < binary.length; index += 1) {
56
+ bytes[index] = binary.charCodeAt(index);
57
+ }
58
+ return bytes;
59
+ } catch {
60
+ return null;
61
+ }
62
+ };
63
+
64
+ /**
65
+ * Encode a bounded SectionTargetV1 as `1.<base64url(json)>`.
66
+ * Returns null when the target is unbounded or the encoded form exceeds
67
+ * {@link SECTION_TARGET_LINK_MAX_ENCODED_CHARS}.
68
+ */
69
+ export function encodeSectionTargetLinkParam(
70
+ target: SectionTargetV1
71
+ ): string | null {
72
+ if (!isBoundedSectionTarget(target)) {
73
+ return null;
74
+ }
75
+ const json = JSON.stringify(target);
76
+ if (UTF8.encode(json).byteLength > SECTION_TARGET_BOUNDS.maxSerializedBytes) {
77
+ return null;
78
+ }
79
+ const encoded = `${SECTION_TARGET_LINK_VERSION}.${bytesToBase64Url(UTF8.encode(json))}`;
80
+ if (encoded.length > SECTION_TARGET_LINK_MAX_ENCODED_CHARS) {
81
+ return null;
82
+ }
83
+ return encoded;
84
+ }
85
+
86
+ /**
87
+ * Decode an `st` query value into a validated SectionTargetV1.
88
+ * Fail-closed: malformed version, padding, JSON, or bounds → null.
89
+ * Error paths never echo quote/body text.
90
+ */
91
+ export function decodeSectionTargetLinkParam(
92
+ value: string
93
+ ): SectionTargetV1 | null {
94
+ if (
95
+ value.length < 3 ||
96
+ value.length > SECTION_TARGET_LINK_MAX_ENCODED_CHARS
97
+ ) {
98
+ return null;
99
+ }
100
+ const dot = value.indexOf(".");
101
+ if (dot < 1) {
102
+ return null;
103
+ }
104
+ const version = value.slice(0, dot);
105
+ const payload = value.slice(dot + 1);
106
+ if (version !== SECTION_TARGET_LINK_VERSION || payload.length < 1) {
107
+ return null;
108
+ }
109
+ const bytes = base64UrlToBytes(payload);
110
+ if (!bytes || bytes.byteLength > SECTION_TARGET_BOUNDS.maxSerializedBytes) {
111
+ return null;
112
+ }
113
+ let parsed: unknown;
114
+ try {
115
+ parsed = JSON.parse(UTF8_DECODER.decode(bytes));
116
+ } catch {
117
+ return null;
118
+ }
119
+ const validated = parseSectionTargetV1(parsed);
120
+ if (!validated.ok) {
121
+ return null;
122
+ }
123
+ return validated.value;
124
+ }
125
+
126
+ /** Stable, content-free reason codes for link decode failures. */
127
+ export type SectionTargetLinkDecodeFailure =
128
+ | "missing"
129
+ | "malformed"
130
+ | "unsupported_version"
131
+ | "oversized";
132
+
133
+ /**
134
+ * Classify why an `st` value could not be decoded without inspecting body text.
135
+ */
136
+ export function classifySectionTargetLinkDecodeFailure(
137
+ value: string | null | undefined
138
+ ): SectionTargetLinkDecodeFailure {
139
+ if (value === null || value === undefined || value.length < 1) {
140
+ return "missing";
141
+ }
142
+ if (value.length > SECTION_TARGET_LINK_MAX_ENCODED_CHARS) {
143
+ return "oversized";
144
+ }
145
+ const dot = value.indexOf(".");
146
+ if (dot < 1) {
147
+ return "malformed";
148
+ }
149
+ const version = value.slice(0, dot);
150
+ if (version !== SECTION_TARGET_LINK_VERSION) {
151
+ return "unsupported_version";
152
+ }
153
+ return "malformed";
154
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Conservative SectionTargetV1 resolution.
3
+ * Private helper module — import public API from `./sections`.
4
+ *
5
+ * @module src/core/section-target-resolve
6
+ */
7
+
8
+ import {
9
+ extractSectionRecords,
10
+ lineStartOffsets,
11
+ normalizeHeadingTitle,
12
+ type DocumentSection,
13
+ type SectionRecord,
14
+ } from "./section-parse";
15
+ import {
16
+ fingerprintSourceContent,
17
+ withQuotes,
18
+ type QuotedSectionRecord,
19
+ type SectionTargetV1,
20
+ } from "./section-target";
21
+
22
+ export type SectionResolutionStatus =
23
+ | "exact"
24
+ | "recovered"
25
+ | "ambiguous"
26
+ | "stale"
27
+ | "missing";
28
+
29
+ export interface SectionResolutionCandidate {
30
+ anchor: string;
31
+ line: number;
32
+ title: string;
33
+ headingPath: string[];
34
+ occurrence: number;
35
+ }
36
+
37
+ export interface SectionResolution {
38
+ status: SectionResolutionStatus;
39
+ target: SectionTargetV1;
40
+ currentFingerprint: string;
41
+ /** Present only when status is exact or recovered (navigable). */
42
+ section?: DocumentSection & { endLine: number };
43
+ /** Safe candidate list when status is ambiguous. */
44
+ candidates?: SectionResolutionCandidate[];
45
+ reason?: string;
46
+ }
47
+
48
+ const pathsEqual = (
49
+ left: readonly string[],
50
+ right: readonly string[]
51
+ ): boolean =>
52
+ left.length === right.length &&
53
+ left.every(
54
+ (value, index) => value === normalizeHeadingTitle(right[index] ?? "")
55
+ );
56
+
57
+ const sectionSpan = (
58
+ content: string,
59
+ record: SectionRecord,
60
+ starts: readonly number[]
61
+ ): { start: number; end: number } => {
62
+ const start = record.titleStartOffset;
63
+ const end =
64
+ record.endLine >= starts.length
65
+ ? content.length
66
+ : (starts[record.endLine] ?? content.length);
67
+ return { start, end };
68
+ };
69
+
70
+ const quoteMatchesAt = (
71
+ content: string,
72
+ quote: SectionTargetV1["quote"]
73
+ ): number[] => {
74
+ if (!quote.exact) return [];
75
+ const hits: number[] = [];
76
+ let from = 0;
77
+ while (from <= content.length) {
78
+ const index = content.indexOf(quote.exact, from);
79
+ if (index < 0) break;
80
+ const prefixOk = content
81
+ .slice(Math.max(0, index - quote.prefix.length), index)
82
+ .endsWith(quote.prefix);
83
+ const suffixOk = content
84
+ .slice(index + quote.exact.length)
85
+ .startsWith(quote.suffix);
86
+ if (prefixOk && suffixOk) hits.push(index);
87
+ from = index + 1;
88
+ }
89
+ return hits;
90
+ };
91
+
92
+ const innermostRecordAt = (
93
+ records: readonly QuotedSectionRecord[],
94
+ offset: number,
95
+ content: string,
96
+ starts: readonly number[]
97
+ ): QuotedSectionRecord | null => {
98
+ let best: QuotedSectionRecord | null = null;
99
+ for (const record of records) {
100
+ const span = sectionSpan(content, record, starts);
101
+ if (offset < span.start || offset >= span.end) continue;
102
+ if (
103
+ !best ||
104
+ record.titleStartOffset > best.titleStartOffset ||
105
+ (record.titleStartOffset === best.titleStartOffset &&
106
+ record.section.level > best.section.level)
107
+ ) {
108
+ best = record;
109
+ }
110
+ }
111
+ return best;
112
+ };
113
+
114
+ const sectionContainsExact = (
115
+ content: string,
116
+ record: SectionRecord,
117
+ exact: string,
118
+ starts: readonly number[]
119
+ ): boolean => {
120
+ if (!exact) return false;
121
+ const span = sectionSpan(content, record, starts);
122
+ return content.slice(span.start, span.end).includes(exact);
123
+ };
124
+
125
+ const findQuoteMatches = (
126
+ content: string,
127
+ records: readonly QuotedSectionRecord[],
128
+ quote: SectionTargetV1["quote"]
129
+ ): QuotedSectionRecord[] => {
130
+ const starts = lineStartOffsets(content);
131
+ const matched = new Map<string, QuotedSectionRecord>();
132
+ for (const offset of quoteMatchesAt(content, quote)) {
133
+ const owner = innermostRecordAt(records, offset, content, starts);
134
+ if (owner) matched.set(owner.section.anchor, owner);
135
+ }
136
+ // Also accept exact structural quote equality for the record itself.
137
+ for (const record of records) {
138
+ if (
139
+ record.quote.exact === quote.exact &&
140
+ record.quote.prefix === quote.prefix &&
141
+ record.quote.suffix === quote.suffix
142
+ ) {
143
+ matched.set(record.section.anchor, record);
144
+ }
145
+ }
146
+ return [...matched.values()];
147
+ };
148
+
149
+ const toCandidate = (
150
+ record: QuotedSectionRecord
151
+ ): SectionResolutionCandidate => ({
152
+ anchor: record.section.anchor,
153
+ line: record.section.line,
154
+ title: record.section.title,
155
+ headingPath: [...record.headingPath],
156
+ occurrence: record.occurrence,
157
+ });
158
+
159
+ const navigable = (
160
+ status: "exact" | "recovered",
161
+ target: SectionTargetV1,
162
+ currentFingerprint: string,
163
+ record: QuotedSectionRecord
164
+ ): SectionResolution => ({
165
+ status,
166
+ target,
167
+ currentFingerprint,
168
+ section: {
169
+ ...record.section,
170
+ endLine: record.endLine,
171
+ },
172
+ });
173
+
174
+ export interface ResolveSectionTargetInput {
175
+ content: string;
176
+ target: SectionTargetV1;
177
+ /** When set, a URI mismatch yields missing (wrong document). */
178
+ uri?: string;
179
+ }
180
+
181
+ /**
182
+ * Conservatively resolve a SectionTargetV1 against current document content.
183
+ * Never silently navigates ambiguous or stale evidence.
184
+ */
185
+ export async function resolveSectionTarget(
186
+ input: ResolveSectionTargetInput
187
+ ): Promise<SectionResolution> {
188
+ const { target } = input;
189
+ const currentFingerprint = await fingerprintSourceContent(input.content);
190
+
191
+ if (input.uri !== undefined && input.uri !== target.document.uri) {
192
+ return {
193
+ status: "missing",
194
+ target,
195
+ currentFingerprint,
196
+ reason: "document_uri_mismatch",
197
+ };
198
+ }
199
+
200
+ const records = withQuotes(
201
+ input.content,
202
+ extractSectionRecords(input.content)
203
+ );
204
+ const sameRevision = currentFingerprint === target.sourceFingerprint;
205
+
206
+ // Stage 1: same-revision structural match (anchor, else path+occurrence).
207
+ if (sameRevision) {
208
+ const byAnchor = records.filter(
209
+ (entry) => entry.section.anchor === target.anchor
210
+ );
211
+ if (byAnchor.length === 1 && byAnchor[0]) {
212
+ return navigable("exact", target, currentFingerprint, byAnchor[0]);
213
+ }
214
+ const byPath = records.filter(
215
+ (entry) =>
216
+ pathsEqual(entry.headingPath, target.headingPath) &&
217
+ entry.occurrence === target.occurrence
218
+ );
219
+ if (byPath.length === 1 && byPath[0]) {
220
+ return navigable("exact", target, currentFingerprint, byPath[0]);
221
+ }
222
+ if (byAnchor.length > 1 || byPath.length > 1) {
223
+ return {
224
+ status: "ambiguous",
225
+ target,
226
+ currentFingerprint,
227
+ candidates: [...byAnchor, ...byPath]
228
+ .filter(
229
+ (entry, index, all) =>
230
+ all.findIndex(
231
+ (candidate) => candidate.section.anchor === entry.section.anchor
232
+ ) === index
233
+ )
234
+ .map(toCandidate),
235
+ reason: "same_revision_multiple_matches",
236
+ };
237
+ }
238
+ }
239
+
240
+ // Quote matches are computed once; non-unique quotes fail closed as ambiguous.
241
+ const starts = lineStartOffsets(input.content);
242
+ const quoteMatches = findQuoteMatches(input.content, records, target.quote);
243
+ if (quoteMatches.length > 1) {
244
+ return {
245
+ status: "ambiguous",
246
+ target,
247
+ currentFingerprint,
248
+ candidates: quoteMatches.map(toCandidate),
249
+ reason: "quote_context_multiple_matches",
250
+ };
251
+ }
252
+
253
+ // Stage 2: exact heading path/occurrence with quote evidence in-span.
254
+ const pathMatches = records.filter(
255
+ (entry) =>
256
+ pathsEqual(entry.headingPath, target.headingPath) &&
257
+ entry.occurrence === target.occurrence
258
+ );
259
+ if (pathMatches.length === 1 && pathMatches[0]) {
260
+ const candidate = pathMatches[0];
261
+ const quoteOk =
262
+ sectionContainsExact(
263
+ input.content,
264
+ candidate,
265
+ target.quote.exact,
266
+ starts
267
+ ) ||
268
+ (quoteMatches.length === 1 &&
269
+ quoteMatches[0]?.section.anchor === candidate.section.anchor);
270
+ if (quoteOk) {
271
+ return navigable(
272
+ sameRevision ? "exact" : "recovered",
273
+ target,
274
+ currentFingerprint,
275
+ candidate
276
+ );
277
+ }
278
+ } else if (pathMatches.length > 1) {
279
+ return {
280
+ status: "ambiguous",
281
+ target,
282
+ currentFingerprint,
283
+ candidates: pathMatches.map(toCandidate),
284
+ reason: "path_occurrence_multiple_matches",
285
+ };
286
+ }
287
+
288
+ // Stage 3: unique quote + context recovery.
289
+ if (quoteMatches.length === 1 && quoteMatches[0]) {
290
+ return navigable(
291
+ sameRevision ? "exact" : "recovered",
292
+ target,
293
+ currentFingerprint,
294
+ quoteMatches[0]
295
+ );
296
+ }
297
+
298
+ // Fail closed: partial structural residue without unique recovery → stale;
299
+ // no residue → missing.
300
+ const pathResidue = records.some((entry) =>
301
+ pathsEqual(entry.headingPath, target.headingPath)
302
+ );
303
+ const titleResidue = records.some(
304
+ (entry) =>
305
+ normalizeHeadingTitle(entry.section.title) ===
306
+ normalizeHeadingTitle(target.headingPath.at(-1) ?? "")
307
+ );
308
+ const anchorResidue = records.some(
309
+ (entry) => entry.section.anchor === target.anchor
310
+ );
311
+
312
+ if (!sameRevision && (pathResidue || titleResidue || anchorResidue)) {
313
+ return {
314
+ status: "stale",
315
+ target,
316
+ currentFingerprint,
317
+ reason: "fingerprint_mismatch_without_unique_recovery",
318
+ candidates: records
319
+ .filter(
320
+ (entry) =>
321
+ pathsEqual(entry.headingPath, target.headingPath) ||
322
+ entry.section.anchor === target.anchor ||
323
+ normalizeHeadingTitle(entry.section.title) ===
324
+ normalizeHeadingTitle(target.headingPath.at(-1) ?? "")
325
+ )
326
+ .map(toCandidate),
327
+ };
328
+ }
329
+
330
+ return {
331
+ status: "missing",
332
+ target,
333
+ currentFingerprint,
334
+ reason: sameRevision
335
+ ? "section_not_found_same_revision"
336
+ : "section_not_found",
337
+ };
338
+ }
339
+
340
+ /** True when the resolution may safely navigate to `section`. */
341
+ export function isNavigableSectionResolution(
342
+ resolution: SectionResolution
343
+ ): resolution is SectionResolution & {
344
+ status: "exact" | "recovered";
345
+ section: DocumentSection & { endLine: number };
346
+ } {
347
+ return (
348
+ (resolution.status === "exact" || resolution.status === "recovered") &&
349
+ resolution.section !== undefined
350
+ );
351
+ }