@gmickel/gno 1.30.7 → 1.32.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 (71) hide show
  1. package/README.md +6 -5
  2. package/assets/skill/SKILL.md +25 -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.32.0.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/package.json +1 -1
  8. package/spec/cli.md +19 -0
  9. package/spec/db/schema.sql +55 -0
  10. package/spec/mcp.md +176 -11
  11. package/spec/output-schemas/file-refactor-apply-result.schema.json +305 -0
  12. package/spec/output-schemas/file-refactor-preview.schema.json +393 -0
  13. package/spec/output-schemas/section-target-create-result.schema.json +20 -0
  14. package/spec/output-schemas/section-target-resolve-result.schema.json +194 -0
  15. package/spec/output-schemas/section-target.schema.json +118 -0
  16. package/spec/output-schemas/section.schema.json +113 -0
  17. package/src/core/document-capabilities.ts +13 -0
  18. package/src/core/file-ops.ts +129 -1
  19. package/src/core/file-refactor-adapter.ts +329 -0
  20. package/src/core/file-refactor-apply-edits.ts +61 -0
  21. package/src/core/file-refactor-apply-fs.ts +512 -0
  22. package/src/core/file-refactor-apply-safety.ts +340 -0
  23. package/src/core/file-refactor-apply-validate.ts +401 -0
  24. package/src/core/file-refactor-contract.ts +486 -0
  25. package/src/core/file-refactor-destination.ts +123 -0
  26. package/src/core/file-refactor-from-snapshot.ts +148 -0
  27. package/src/core/file-refactor-journal-port.ts +150 -0
  28. package/src/core/file-refactor-journal.ts +347 -0
  29. package/src/core/file-refactor-paths.ts +60 -0
  30. package/src/core/file-refactor-plan-classify.ts +208 -0
  31. package/src/core/file-refactor-plan-validate.ts +169 -0
  32. package/src/core/file-refactor-planner-types.ts +62 -0
  33. package/src/core/file-refactor-planner.ts +423 -0
  34. package/src/core/file-refactor-resolve.ts +280 -0
  35. package/src/core/file-refactor-service.ts +468 -0
  36. package/src/core/file-refactors.ts +84 -56
  37. package/src/core/link-destination-parse.ts +275 -0
  38. package/src/core/link-inventory-markdown.ts +454 -0
  39. package/src/core/link-inventory-opaque.ts +244 -0
  40. package/src/core/link-inventory-types.ts +47 -0
  41. package/src/core/link-inventory.ts +182 -0
  42. package/src/core/link-relevance.ts +150 -0
  43. package/src/core/section-parse.ts +187 -0
  44. package/src/core/section-target-link.ts +154 -0
  45. package/src/core/section-target-resolve.ts +351 -0
  46. package/src/core/section-target-transport.ts +519 -0
  47. package/src/core/section-target.ts +263 -0
  48. package/src/core/sections.ts +60 -115
  49. package/src/mcp/AGENTS.md +1 -0
  50. package/src/mcp/CLAUDE.md +1 -0
  51. package/src/mcp/http-egress.ts +1 -0
  52. package/src/mcp/tools/index.ts +37 -17
  53. package/src/mcp/tools/sections.ts +512 -0
  54. package/src/mcp/tools/workspace-write.ts +215 -97
  55. package/src/sdk/client.ts +238 -116
  56. package/src/sdk/index.ts +12 -0
  57. package/src/sdk/types.ts +61 -3
  58. package/src/serve/file-refactor-http.ts +239 -0
  59. package/src/serve/public/components/RefactorImpactPreview.tsx +227 -0
  60. package/src/serve/public/globals.built.css +1 -1
  61. package/src/serve/public/lib/section-links.ts +189 -0
  62. package/src/serve/public/pages/DocView.tsx +395 -77
  63. package/src/serve/routes/api.ts +191 -104
  64. package/src/serve/routes/section-targets.ts +221 -0
  65. package/src/serve/server.ts +34 -0
  66. package/src/store/migrations/026-file-refactor-recovery-journal.ts +72 -0
  67. package/src/store/migrations/index.ts +2 -0
  68. package/src/store/sqlite/adapter.ts +452 -0
  69. package/src/store/sqlite/file-refactor-journal-store.ts +275 -0
  70. package/src/store/types.ts +84 -0
  71. package/browser-extension/artifacts/gno-browser-clipper-v1.30.7.zip.sha256 +0 -1
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Wiki / markdown / reference-definition inventory scanners.
3
+ *
4
+ * @module src/core/link-inventory-markdown
5
+ */
6
+
7
+ import type { ExcludedRange } from "../ingestion/strip";
8
+ import type { LinkInventoryToken } from "./link-inventory-types";
9
+
10
+ import { offsetToPosition } from "../ingestion/position";
11
+ import { rangeIntersectsExcluded } from "../ingestion/strip";
12
+ import {
13
+ detectEncodingStyle,
14
+ parseParenthesizedDestination,
15
+ splitDestinationPath,
16
+ stripAngleBracketDestination,
17
+ unescapeCommonMarkDestination,
18
+ } from "./link-destination-parse";
19
+ import {
20
+ codeContextReason,
21
+ findExcludedKind,
22
+ makeOpaqueToken,
23
+ pushInventoryToken,
24
+ } from "./link-inventory-opaque";
25
+ import { isRelevantDestination } from "./link-relevance";
26
+ import { parseTargetParts } from "./links";
27
+
28
+ const EXTERNAL_URL_REGEX = /^[a-z][a-z0-9+.-]*:/i;
29
+
30
+ export function inventoryWikiLinks(
31
+ markdown: string,
32
+ lineOffsets: number[],
33
+ sourceKeys: ReadonlySet<string>,
34
+ excluded: ExcludedRange[],
35
+ tokens: LinkInventoryToken[],
36
+ truncated: { value: boolean },
37
+ consumed: Set<number>
38
+ ): boolean {
39
+ const wikiRegex = /\[\[([^\]\n]*?)\]\]/g;
40
+ let match: RegExpExecArray | null;
41
+ while ((match = wikiRegex.exec(markdown)) !== null) {
42
+ const startOffset = match.index;
43
+ const endOffset = startOffset + match[0].length;
44
+ if (consumed.has(startOffset)) continue;
45
+ if (startOffset > 0 && markdown[startOffset - 1] === "!") continue;
46
+ if (
47
+ markdown.slice(Math.max(0, startOffset - 2), startOffset) === "](" &&
48
+ markdown.slice(endOffset, endOffset + 1) === ")"
49
+ ) {
50
+ continue;
51
+ }
52
+
53
+ const content = match[1] ?? "";
54
+ const pipeIndex = content.indexOf("|");
55
+ const targetPart = pipeIndex >= 0 ? content.slice(0, pipeIndex) : content;
56
+ const trimmedTarget = targetPart.trim();
57
+ if (!trimmedTarget) continue;
58
+ const leadingWs = targetPart.length - targetPart.trimStart().length;
59
+ const parts = parseTargetParts(trimmedTarget);
60
+ if (!parts.ref || !isRelevantDestination(parts.ref, sourceKeys)) continue;
61
+
62
+ const destStart = startOffset + 2 + leadingWs;
63
+ const destEnd = destStart + parts.ref.length;
64
+ const codeReason = codeContextReason(
65
+ findExcludedKind(startOffset, endOffset, excluded)
66
+ );
67
+
68
+ if (codeReason) {
69
+ if (
70
+ !pushInventoryToken(
71
+ tokens,
72
+ truncated,
73
+ makeOpaqueToken(
74
+ markdown,
75
+ lineOffsets,
76
+ "opaque",
77
+ "unchanged",
78
+ codeReason,
79
+ startOffset,
80
+ endOffset,
81
+ destStart,
82
+ destEnd
83
+ )
84
+ )
85
+ ) {
86
+ return false;
87
+ }
88
+ for (let i = startOffset; i < endOffset; i += 1) consumed.add(i);
89
+ continue;
90
+ }
91
+ if (findExcludedKind(startOffset, endOffset, excluded)) continue;
92
+ if (
93
+ EXTERNAL_URL_REGEX.test(trimmedTarget) ||
94
+ trimmedTarget.startsWith("//")
95
+ ) {
96
+ continue;
97
+ }
98
+
99
+ const startPos = offsetToPosition(startOffset, lineOffsets);
100
+ const endPos = offsetToPosition(endOffset, lineOffsets);
101
+ if (
102
+ !pushInventoryToken(tokens, truncated, {
103
+ kind: "wiki",
104
+ raw: match[0],
105
+ originalDestination: parts.ref,
106
+ destinationStart: destStart,
107
+ destinationEnd: destEnd,
108
+ startOffset,
109
+ endOffset,
110
+ startLine: startPos.line,
111
+ startCol: startPos.col,
112
+ endLine: endPos.line,
113
+ endCol: endPos.col,
114
+ targetRef: parts.ref,
115
+ targetAnchor: parts.anchor,
116
+ targetCollection: parts.collection,
117
+ hadLeadingDotSlash: parts.ref.startsWith("./"),
118
+ encodingStyle: detectEncodingStyle(parts.ref),
119
+ })
120
+ ) {
121
+ return false;
122
+ }
123
+ for (let i = startOffset; i < endOffset; i += 1) consumed.add(i);
124
+ }
125
+ return true;
126
+ }
127
+
128
+ export function inventoryReferenceDefinitions(
129
+ markdown: string,
130
+ lineOffsets: number[],
131
+ sourceKeys: ReadonlySet<string>,
132
+ excluded: ExcludedRange[],
133
+ tokens: LinkInventoryToken[],
134
+ truncated: { value: boolean },
135
+ consumed: Set<number>
136
+ ): boolean {
137
+ let lineStart = 0;
138
+ while (lineStart <= markdown.length) {
139
+ const lineEndIdx = markdown.indexOf("\n", lineStart);
140
+ const lineEnd = lineEndIdx >= 0 ? lineEndIdx : markdown.length;
141
+ const line = markdown.slice(lineStart, lineEnd);
142
+ const defMatch = /^ {0,3}\[([^\]]+)\]:\s*/.exec(line);
143
+ if (defMatch) {
144
+ const startOffset = lineStart;
145
+ const endOffset = lineEnd;
146
+ if (!consumed.has(startOffset)) {
147
+ let cursor = defMatch[0].length;
148
+ while (cursor < line.length && /[ \t]/.test(line[cursor] ?? "")) {
149
+ cursor += 1;
150
+ }
151
+ let destRaw = "";
152
+ let pathStartInLine = cursor;
153
+ let angleBrackets = false;
154
+
155
+ if (line[cursor] === "<") {
156
+ const close = line.indexOf(">", cursor + 1);
157
+ if (close >= 0) {
158
+ angleBrackets = true;
159
+ destRaw = line.slice(cursor, close + 1);
160
+ cursor = close + 1;
161
+ }
162
+ } else {
163
+ let i = cursor;
164
+ while (i < line.length) {
165
+ const ch = line[i];
166
+ if (ch === "\\" && i + 1 < line.length) {
167
+ i += 2;
168
+ continue;
169
+ }
170
+ if (ch === " " || ch === "\t") break;
171
+ i += 1;
172
+ }
173
+ destRaw = line.slice(cursor, i);
174
+ cursor = i;
175
+ }
176
+
177
+ if (destRaw) {
178
+ const angled = stripAngleBracketDestination(destRaw);
179
+ const destParts = splitDestinationPath(
180
+ unescapeCommonMarkDestination(angled.path)
181
+ );
182
+ if (destParts.path) {
183
+ let pathStart = startOffset + pathStartInLine;
184
+ let pathEnd = pathStart + destRaw.length;
185
+ if (angleBrackets) {
186
+ pathStart += 1;
187
+ const innerSplit = splitDestinationPath(angled.path);
188
+ pathEnd = pathStart + innerSplit.path.length;
189
+ } else {
190
+ const rawSplit = splitDestinationPath(destRaw);
191
+ pathEnd = pathStart + rawSplit.path.length;
192
+ }
193
+
194
+ const pathForRelevance = angleBrackets
195
+ ? destParts.path
196
+ : splitDestinationPath(destRaw).path;
197
+ const originalDestination = markdown.slice(pathStart, pathEnd);
198
+ const codeReason = codeContextReason(
199
+ findExcludedKind(startOffset, endOffset, excluded)
200
+ );
201
+
202
+ if (
203
+ codeReason &&
204
+ isRelevantDestination(pathForRelevance, sourceKeys)
205
+ ) {
206
+ if (
207
+ !pushInventoryToken(
208
+ tokens,
209
+ truncated,
210
+ makeOpaqueToken(
211
+ markdown,
212
+ lineOffsets,
213
+ "opaque",
214
+ "unchanged",
215
+ codeReason,
216
+ startOffset,
217
+ endOffset,
218
+ pathStart,
219
+ pathEnd
220
+ )
221
+ )
222
+ ) {
223
+ return false;
224
+ }
225
+ } else if (
226
+ !rangeIntersectsExcluded(startOffset, endOffset, excluded)
227
+ ) {
228
+ if (EXTERNAL_URL_REGEX.test(destParts.path)) {
229
+ if (isRelevantDestination(pathForRelevance, sourceKeys)) {
230
+ if (
231
+ !pushInventoryToken(
232
+ tokens,
233
+ truncated,
234
+ makeOpaqueToken(
235
+ markdown,
236
+ lineOffsets,
237
+ "markdown_definition",
238
+ "unchanged",
239
+ "external_destination",
240
+ startOffset,
241
+ endOffset,
242
+ pathStart,
243
+ pathEnd
244
+ )
245
+ )
246
+ ) {
247
+ return false;
248
+ }
249
+ }
250
+ } else if (isRelevantDestination(pathForRelevance, sourceKeys)) {
251
+ const startPos = offsetToPosition(startOffset, lineOffsets);
252
+ const endPos = offsetToPosition(endOffset, lineOffsets);
253
+ if (
254
+ !pushInventoryToken(tokens, truncated, {
255
+ kind: "markdown_definition",
256
+ reasonCode: "reference_definition_site",
257
+ raw: markdown.slice(startOffset, endOffset),
258
+ originalDestination,
259
+ destinationStart: pathStart,
260
+ destinationEnd: pathEnd,
261
+ startOffset,
262
+ endOffset,
263
+ startLine: startPos.line,
264
+ startCol: startPos.col,
265
+ endLine: endPos.line,
266
+ endCol: endPos.col,
267
+ targetRef: originalDestination,
268
+ targetAnchor: destParts.anchor,
269
+ targetQuery: destParts.query,
270
+ hadLeadingDotSlash: destParts.path.startsWith("./"),
271
+ encodingStyle: detectEncodingStyle(originalDestination),
272
+ })
273
+ ) {
274
+ return false;
275
+ }
276
+ }
277
+ }
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ if (lineEndIdx < 0) break;
284
+ lineStart = lineEndIdx + 1;
285
+ }
286
+ return true;
287
+ }
288
+
289
+ export function inventoryInlineMarkdown(
290
+ markdown: string,
291
+ lineOffsets: number[],
292
+ sourceKeys: ReadonlySet<string>,
293
+ excluded: ExcludedRange[],
294
+ tokens: LinkInventoryToken[],
295
+ truncated: { value: boolean },
296
+ consumed: Set<number>
297
+ ): boolean {
298
+ let searchFrom = 0;
299
+ while (searchFrom < markdown.length) {
300
+ const labelOpen = markdown.indexOf("[", searchFrom);
301
+ if (labelOpen < 0) break;
302
+ if (labelOpen > 0 && markdown[labelOpen - 1] === "!") {
303
+ searchFrom = labelOpen + 1;
304
+ continue;
305
+ }
306
+ const labelClose = markdown.indexOf("]", labelOpen + 1);
307
+ if (labelClose < 0) break;
308
+ if (markdown[labelClose + 1] !== "(") {
309
+ searchFrom = labelOpen + 1;
310
+ continue;
311
+ }
312
+ const parsed = parseParenthesizedDestination(markdown, labelClose + 1);
313
+ if (!parsed) {
314
+ searchFrom = labelOpen + 1;
315
+ continue;
316
+ }
317
+ const startOffset = labelOpen;
318
+ const endOffset = parsed.closeParenOffset + 1;
319
+ searchFrom = endOffset;
320
+ if (consumed.has(startOffset)) continue;
321
+
322
+ const destRaw = parsed.destinationRaw.trim();
323
+ if (destRaw.startsWith("[[") && destRaw.endsWith("]]")) continue;
324
+
325
+ const angled = stripAngleBracketDestination(destRaw);
326
+ const unescapedPath = unescapeCommonMarkDestination(angled.path);
327
+ const destParts = splitDestinationPath(unescapedPath);
328
+ const pathToken = destParts.path;
329
+ if (!pathToken) continue;
330
+
331
+ const leading =
332
+ parsed.destinationRaw.length - parsed.destinationRaw.trimStart().length;
333
+ let destStart = parsed.destinationStart + leading;
334
+ let destEnd = destStart + destRaw.length;
335
+ if (angled.angleBrackets) {
336
+ destStart += 1;
337
+ const innerSplit = splitDestinationPath(angled.path);
338
+ destEnd = destStart + innerSplit.path.length;
339
+ } else {
340
+ const rawSplit = splitDestinationPath(destRaw);
341
+ destEnd = destStart + rawSplit.path.length;
342
+ }
343
+
344
+ const originalDestination = markdown.slice(destStart, destEnd);
345
+ if (
346
+ !isRelevantDestination(originalDestination, sourceKeys) &&
347
+ !isRelevantDestination(pathToken, sourceKeys)
348
+ ) {
349
+ continue;
350
+ }
351
+
352
+ const excludedKind = findExcludedKind(startOffset, endOffset, excluded);
353
+ const codeReason = codeContextReason(excludedKind);
354
+ if (codeReason) {
355
+ if (
356
+ !pushInventoryToken(
357
+ tokens,
358
+ truncated,
359
+ makeOpaqueToken(
360
+ markdown,
361
+ lineOffsets,
362
+ "opaque",
363
+ "unchanged",
364
+ codeReason,
365
+ startOffset,
366
+ endOffset,
367
+ destStart,
368
+ destEnd
369
+ )
370
+ )
371
+ ) {
372
+ return false;
373
+ }
374
+ for (let i = startOffset; i < endOffset; i += 1) consumed.add(i);
375
+ continue;
376
+ }
377
+ if (excludedKind) continue;
378
+
379
+ if (EXTERNAL_URL_REGEX.test(pathToken) || pathToken.startsWith("//")) {
380
+ if (
381
+ !pushInventoryToken(
382
+ tokens,
383
+ truncated,
384
+ makeOpaqueToken(
385
+ markdown,
386
+ lineOffsets,
387
+ "markdown",
388
+ "unchanged",
389
+ "external_destination",
390
+ startOffset,
391
+ endOffset,
392
+ destStart,
393
+ destEnd
394
+ )
395
+ )
396
+ ) {
397
+ return false;
398
+ }
399
+ continue;
400
+ }
401
+
402
+ const parts = parseTargetParts(pathToken);
403
+ if (parts.collection) {
404
+ if (
405
+ !pushInventoryToken(
406
+ tokens,
407
+ truncated,
408
+ makeOpaqueToken(
409
+ markdown,
410
+ lineOffsets,
411
+ "markdown",
412
+ "unsupported",
413
+ "cross_collection_unsupported",
414
+ startOffset,
415
+ endOffset,
416
+ destStart,
417
+ destEnd
418
+ )
419
+ )
420
+ ) {
421
+ return false;
422
+ }
423
+ continue;
424
+ }
425
+
426
+ const startPos = offsetToPosition(startOffset, lineOffsets);
427
+ const endPos = offsetToPosition(endOffset, lineOffsets);
428
+ if (
429
+ !pushInventoryToken(tokens, truncated, {
430
+ kind: "markdown",
431
+ raw: markdown.slice(startOffset, endOffset),
432
+ originalDestination,
433
+ destinationStart: destStart,
434
+ destinationEnd: destEnd,
435
+ startOffset,
436
+ endOffset,
437
+ startLine: startPos.line,
438
+ startCol: startPos.col,
439
+ endLine: endPos.line,
440
+ endCol: endPos.col,
441
+ targetRef: originalDestination,
442
+ targetAnchor: destParts.anchor ?? parts.anchor,
443
+ targetQuery: destParts.query,
444
+ targetCollection: parts.collection,
445
+ hadLeadingDotSlash: pathToken.startsWith("./"),
446
+ encodingStyle: detectEncodingStyle(originalDestination),
447
+ })
448
+ ) {
449
+ return false;
450
+ }
451
+ for (let i = startOffset; i < endOffset; i += 1) consumed.add(i);
452
+ }
453
+ return true;
454
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Opaque / unsupported inventory scanners for embeds, HTML, and malformed wiki.
3
+ *
4
+ * @module src/core/link-inventory-opaque
5
+ */
6
+
7
+ import type { ExcludedRange } from "../ingestion/strip";
8
+ import type {
9
+ FileRefactorReasonCode,
10
+ FileRefactorReferenceClassification,
11
+ FileRefactorReferenceKind,
12
+ } from "./file-refactor-contract";
13
+ import type { LinkInventoryToken } from "./link-inventory-types";
14
+
15
+ import { buildLineOffsets, offsetToPosition } from "../ingestion/position";
16
+ import { detectEncodingStyle } from "./link-destination-parse";
17
+ import { LINK_INVENTORY_CAPS } from "./link-inventory-types";
18
+ import { isRelevantDestination } from "./link-relevance";
19
+
20
+ const HTML_HREF_REGEX = /<a\b[^>]*\bhref\s*=\s*(["'])([^"']*)\1[^>]*>/gi;
21
+
22
+ export function pushInventoryToken(
23
+ tokens: LinkInventoryToken[],
24
+ truncated: { value: boolean },
25
+ token: LinkInventoryToken
26
+ ): boolean {
27
+ if (tokens.length >= LINK_INVENTORY_CAPS.maxTokensPerDocument) {
28
+ truncated.value = true;
29
+ return false;
30
+ }
31
+ tokens.push(token);
32
+ return true;
33
+ }
34
+
35
+ export function makeOpaqueToken(
36
+ markdown: string,
37
+ lineOffsets: number[],
38
+ kind: FileRefactorReferenceKind,
39
+ classification: FileRefactorReferenceClassification,
40
+ reasonCode: FileRefactorReasonCode,
41
+ startOffset: number,
42
+ endOffset: number,
43
+ destinationStart: number,
44
+ destinationEnd: number
45
+ ): LinkInventoryToken {
46
+ const startPos = offsetToPosition(startOffset, lineOffsets);
47
+ const endPos = offsetToPosition(endOffset, lineOffsets);
48
+ const originalDestination = markdown.slice(destinationStart, destinationEnd);
49
+ return {
50
+ kind,
51
+ classification,
52
+ reasonCode,
53
+ raw: markdown.slice(startOffset, endOffset),
54
+ originalDestination,
55
+ destinationStart,
56
+ destinationEnd,
57
+ startOffset,
58
+ endOffset,
59
+ startLine: startPos.line,
60
+ startCol: startPos.col,
61
+ endLine: endPos.line,
62
+ endCol: endPos.col,
63
+ targetRef: originalDestination,
64
+ hadLeadingDotSlash: originalDestination.startsWith("./"),
65
+ encodingStyle: detectEncodingStyle(originalDestination),
66
+ };
67
+ }
68
+
69
+ export function findExcludedKind(
70
+ start: number,
71
+ end: number,
72
+ excluded: ExcludedRange[]
73
+ ): ExcludedRange["kind"] | null {
74
+ for (const range of excluded) {
75
+ if (start < range.end && end > range.start) return range.kind;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /** Map strip exclusion kind → refactor reason for code-context opaque tokens. */
81
+ export function codeContextReason(
82
+ kind: ExcludedRange["kind"] | null
83
+ ): "code_fence_context" | "inline_code_context" | null {
84
+ if (kind === "fenced_code") return "code_fence_context";
85
+ if (kind === "inline_code") return "inline_code_context";
86
+ return null;
87
+ }
88
+
89
+ export function inventoryEmbeds(
90
+ markdown: string,
91
+ lineOffsets: number[],
92
+ sourceKeys: ReadonlySet<string>,
93
+ excluded: ExcludedRange[],
94
+ tokens: LinkInventoryToken[],
95
+ truncated: { value: boolean },
96
+ consumed: Set<number>
97
+ ): boolean {
98
+ const embedRegex = /!\[\[([^\]]*)\]\]/g;
99
+ let match: RegExpExecArray | null;
100
+ while ((match = embedRegex.exec(markdown)) !== null) {
101
+ const startOffset = match.index;
102
+ const endOffset = startOffset + match[0].length;
103
+ const inner = match[1] ?? "";
104
+ const pipe = inner.indexOf("|");
105
+ const hash = inner.indexOf("#");
106
+ let destEndInInner = inner.length;
107
+ if (pipe >= 0) destEndInInner = Math.min(destEndInInner, pipe);
108
+ if (hash >= 0) destEndInInner = Math.min(destEndInInner, hash);
109
+ const destStart = startOffset + 3;
110
+ const destEnd = destStart + destEndInInner;
111
+ if (
112
+ !isRelevantDestination(markdown.slice(destStart, destEnd), sourceKeys)
113
+ ) {
114
+ continue;
115
+ }
116
+ for (let i = startOffset; i < endOffset; i += 1) consumed.add(i);
117
+ const codeReason = codeContextReason(
118
+ findExcludedKind(startOffset, endOffset, excluded)
119
+ );
120
+ if (
121
+ !pushInventoryToken(
122
+ tokens,
123
+ truncated,
124
+ makeOpaqueToken(
125
+ markdown,
126
+ lineOffsets,
127
+ "opaque",
128
+ codeReason ? "unchanged" : "unsupported",
129
+ codeReason ?? "unsupported_syntax",
130
+ startOffset,
131
+ endOffset,
132
+ destStart,
133
+ destEnd
134
+ )
135
+ )
136
+ ) {
137
+ return false;
138
+ }
139
+ }
140
+ return true;
141
+ }
142
+
143
+ export function inventoryHtmlHrefs(
144
+ markdown: string,
145
+ lineOffsets: number[],
146
+ sourceKeys: ReadonlySet<string>,
147
+ excluded: ExcludedRange[],
148
+ tokens: LinkInventoryToken[],
149
+ truncated: { value: boolean },
150
+ consumed: Set<number>
151
+ ): boolean {
152
+ HTML_HREF_REGEX.lastIndex = 0;
153
+ let match: RegExpExecArray | null;
154
+ while ((match = HTML_HREF_REGEX.exec(markdown)) !== null) {
155
+ const startOffset = match.index;
156
+ const endOffset = startOffset + match[0].length;
157
+ const href = match[2] ?? "";
158
+ const hrefStartInMatch = match[0].indexOf(href);
159
+ if (hrefStartInMatch < 0) continue;
160
+ if (!isRelevantDestination(href, sourceKeys)) continue;
161
+ const destStart = startOffset + hrefStartInMatch;
162
+ const destEnd = destStart + href.length;
163
+ for (let i = startOffset; i < endOffset; i += 1) consumed.add(i);
164
+ const codeReason = codeContextReason(
165
+ findExcludedKind(startOffset, endOffset, excluded)
166
+ );
167
+ if (
168
+ !pushInventoryToken(
169
+ tokens,
170
+ truncated,
171
+ makeOpaqueToken(
172
+ markdown,
173
+ lineOffsets,
174
+ "opaque",
175
+ codeReason ? "unchanged" : "unsupported",
176
+ codeReason ?? "html_context",
177
+ startOffset,
178
+ endOffset,
179
+ destStart,
180
+ destEnd
181
+ )
182
+ )
183
+ ) {
184
+ return false;
185
+ }
186
+ }
187
+ return true;
188
+ }
189
+
190
+ export function inventoryMalformedWiki(
191
+ markdown: string,
192
+ lineOffsets: number[],
193
+ sourceKeys: ReadonlySet<string>,
194
+ excluded: ExcludedRange[],
195
+ tokens: LinkInventoryToken[],
196
+ truncated: { value: boolean },
197
+ consumed: Set<number>
198
+ ): boolean {
199
+ const openWikiRegex = /\[\[/g;
200
+ let match: RegExpExecArray | null;
201
+ while ((match = openWikiRegex.exec(markdown)) !== null) {
202
+ const startOffset = match.index;
203
+ if (consumed.has(startOffset)) continue;
204
+ if (startOffset > 0 && markdown[startOffset - 1] === "!") continue;
205
+ const close = markdown.indexOf("]]", startOffset + 2);
206
+ const nextOpen = markdown.indexOf("[[", startOffset + 2);
207
+ if (findExcludedKind(startOffset, startOffset + 2, excluded)) continue;
208
+ if (close < 0 || (nextOpen >= 0 && nextOpen < close)) {
209
+ const lineEnd = markdown.indexOf("\n", startOffset);
210
+ const endOffset = lineEnd >= 0 ? lineEnd : markdown.length;
211
+ const slice = markdown.slice(startOffset, endOffset);
212
+ const pipe = slice.indexOf("|");
213
+ const destEnd =
214
+ startOffset + (pipe >= 0 ? pipe : Math.min(slice.length, 64));
215
+ const destStart = startOffset + 2;
216
+ const dest = markdown.slice(destStart, destEnd);
217
+ if (!dest || !isRelevantDestination(dest, sourceKeys)) continue;
218
+ for (let i = startOffset; i < endOffset; i += 1) consumed.add(i);
219
+ if (
220
+ !pushInventoryToken(
221
+ tokens,
222
+ truncated,
223
+ makeOpaqueToken(
224
+ markdown,
225
+ lineOffsets,
226
+ "opaque",
227
+ "malformed",
228
+ "malformed_syntax",
229
+ startOffset,
230
+ endOffset,
231
+ destStart,
232
+ Math.max(destStart, destEnd)
233
+ )
234
+ )
235
+ ) {
236
+ return false;
237
+ }
238
+ }
239
+ }
240
+ return true;
241
+ }
242
+
243
+ /** Re-export for callers that need line offsets without importing position. */
244
+ export { buildLineOffsets };