@hraness/kb 0.17.1

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 (200) hide show
  1. package/DISCLOSURE +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +545 -0
  4. package/dist/agent-context.js +35 -0
  5. package/dist/agent-guide-audit.js +18 -0
  6. package/dist/attachments.js +19 -0
  7. package/dist/authoring.js +33 -0
  8. package/dist/benchmark.js +19 -0
  9. package/dist/capture.js +49 -0
  10. package/dist/cli.js +3646 -0
  11. package/dist/clip/acquire.js +57 -0
  12. package/dist/clip/args.js +21 -0
  13. package/dist/clip/bounded-byte-buffer.js +9 -0
  14. package/dist/clip/browser-profiles.js +17 -0
  15. package/dist/clip/bundle-reader.js +11 -0
  16. package/dist/clip/cli.js +27 -0
  17. package/dist/clip/cookies.js +21 -0
  18. package/dist/clip/defuddle-worker.js +84 -0
  19. package/dist/clip/doctor.js +32 -0
  20. package/dist/clip/jobs.js +784 -0
  21. package/dist/clip/network-proxy.js +9 -0
  22. package/dist/clip/network.js +28 -0
  23. package/dist/clip/persist.js +30 -0
  24. package/dist/clip/refresh.js +11 -0
  25. package/dist/clip/terminal.js +11 -0
  26. package/dist/evaluation-builder.js +12238 -0
  27. package/dist/evaluation-kb.js +26 -0
  28. package/dist/evaluation.js +35 -0
  29. package/dist/git.js +39 -0
  30. package/dist/graph.js +39 -0
  31. package/dist/index-01jj6rbv.js +1103 -0
  32. package/dist/index-06c9ctr6.js +738 -0
  33. package/dist/index-0kavxzqj.js +2480 -0
  34. package/dist/index-1gwbassd.js +743 -0
  35. package/dist/index-1n418kb9.js +827 -0
  36. package/dist/index-1vrd1rmn.js +56 -0
  37. package/dist/index-1xxnjn0d.js +171 -0
  38. package/dist/index-3rm7cz6h.js +373 -0
  39. package/dist/index-3v2z4f0q.js +328 -0
  40. package/dist/index-48pz4jpc.js +392 -0
  41. package/dist/index-4j3tt0c3.js +234 -0
  42. package/dist/index-5n05se68.js +307 -0
  43. package/dist/index-5vwpzb5a.js +764 -0
  44. package/dist/index-6g2pv9d2.js +370 -0
  45. package/dist/index-7fzc32gf.js +712 -0
  46. package/dist/index-84x0vjjp.js +432 -0
  47. package/dist/index-adx6khj5.js +488 -0
  48. package/dist/index-b88v3vtm.js +805 -0
  49. package/dist/index-cv6fh7z5.js +462 -0
  50. package/dist/index-cxfrakt7.js +1067 -0
  51. package/dist/index-d13v9ckt.js +150 -0
  52. package/dist/index-dycjbhw2.js +1943 -0
  53. package/dist/index-dyqwejk5.js +531 -0
  54. package/dist/index-e5fbsywq.js +658 -0
  55. package/dist/index-f984hw45.js +927 -0
  56. package/dist/index-gh719d91.js +63 -0
  57. package/dist/index-hgve9rh2.js +2046 -0
  58. package/dist/index-hya40gb2.js +474 -0
  59. package/dist/index-j4zgmzjr.js +152 -0
  60. package/dist/index-jsmvyyvf.js +1166 -0
  61. package/dist/index-kh0pwvdd.js +1974 -0
  62. package/dist/index-mqx4nd6v.js +119 -0
  63. package/dist/index-mxxxytys.js +1254 -0
  64. package/dist/index-n5dd7r0v.js +711 -0
  65. package/dist/index-npg9z1a4.js +431 -0
  66. package/dist/index-pj501bh1.js +140 -0
  67. package/dist/index-qry4vhxk.js +1041 -0
  68. package/dist/index-s2gw5aw9.js +199 -0
  69. package/dist/index-vvdj6kca.js +70 -0
  70. package/dist/index-w2zc0vwa.js +571 -0
  71. package/dist/index-x3fthpsc.js +585 -0
  72. package/dist/index-zr53sf63.js +44 -0
  73. package/dist/index-zxdy5pby.js +2728 -0
  74. package/dist/index-zzhgcwyt.js +966 -0
  75. package/dist/index.js +453 -0
  76. package/dist/navigation.js +13 -0
  77. package/dist/pdf.js +85 -0
  78. package/dist/percolate.js +24 -0
  79. package/dist/portfolio.js +77 -0
  80. package/dist/query.js +29 -0
  81. package/dist/repository-memory.js +60 -0
  82. package/dist/sdk.js +36 -0
  83. package/dist/search-rules.js +34 -0
  84. package/dist/search.js +22 -0
  85. package/dist/semantic.js +39 -0
  86. package/dist/source-inbox.js +17 -0
  87. package/dist/untrusted-content.js +32 -0
  88. package/dist/url-intelligence.js +74 -0
  89. package/dist/workflow.js +23 -0
  90. package/dist/workflows/decision-context.js +20 -0
  91. package/dist/workflows/explain-change.js +8 -0
  92. package/dist/workflows/index.js +28 -0
  93. package/dist/workflows/plan-radar.js +10 -0
  94. package/package.json +409 -0
  95. package/skills/kb/AGENTS.md +19 -0
  96. package/skills/kb/SKILL.md +82 -0
  97. package/skills/kb/agents/openai.yaml +4 -0
  98. package/skills/kb/references/pdf-review.md +51 -0
  99. package/skills/kb/references/percolate.md +144 -0
  100. package/skills/kb/references/plan-structure.md +105 -0
  101. package/skills/kb/references/plan.md +115 -0
  102. package/skills/kb/references/query.md +231 -0
  103. package/skills/kb/references/refresh.md +133 -0
  104. package/skills/kb/references/save-pdf.md +125 -0
  105. package/skills/kb/references/save-url.md +178 -0
  106. package/skills/kb/references/url-authentication.md +75 -0
  107. package/skills/kb/references/url-platforms.md +25 -0
  108. package/src/agent-context.ts +1241 -0
  109. package/src/agent-guide-audit.ts +750 -0
  110. package/src/attachments.ts +723 -0
  111. package/src/authoring.ts +1725 -0
  112. package/src/benchmark.ts +362 -0
  113. package/src/capture.ts +68 -0
  114. package/src/cli.ts +3660 -0
  115. package/src/clip/acquire.ts +1273 -0
  116. package/src/clip/archive-today.ts +402 -0
  117. package/src/clip/args.ts +423 -0
  118. package/src/clip/assets.ts +262 -0
  119. package/src/clip/bounded-byte-buffer.ts +75 -0
  120. package/src/clip/browser-profiles.ts +372 -0
  121. package/src/clip/bundle-reader.ts +608 -0
  122. package/src/clip/capture.ts +1054 -0
  123. package/src/clip/cli.ts +181 -0
  124. package/src/clip/cookies.ts +512 -0
  125. package/src/clip/doctor.ts +1110 -0
  126. package/src/clip/extract.ts +868 -0
  127. package/src/clip/jobs.ts +959 -0
  128. package/src/clip/lib.ts +861 -0
  129. package/src/clip/media.ts +1474 -0
  130. package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
  131. package/src/clip/metadata-search-tool/Cargo.toml +18 -0
  132. package/src/clip/metadata-search-tool/runner.ts +685 -0
  133. package/src/clip/metadata-search-tool/src/main.rs +389 -0
  134. package/src/clip/metadata-search.ts +687 -0
  135. package/src/clip/network-proxy.ts +627 -0
  136. package/src/clip/network.ts +950 -0
  137. package/src/clip/package-root.ts +54 -0
  138. package/src/clip/persist.ts +1472 -0
  139. package/src/clip/platforms.ts +1824 -0
  140. package/src/clip/refresh.ts +197 -0
  141. package/src/clip/structured.ts +432 -0
  142. package/src/clip/terminal.ts +197 -0
  143. package/src/clip/url-intelligence.ts +581 -0
  144. package/src/clip/url-metadata-backfill.ts +616 -0
  145. package/src/clip/url-metadata-cli.ts +251 -0
  146. package/src/clip/url-metadata.ts +1397 -0
  147. package/src/evaluation-analysis-v2.ts +1440 -0
  148. package/src/evaluation-builder.ts +2539 -0
  149. package/src/evaluation-corpus-authoring.ts +2052 -0
  150. package/src/evaluation-evidence.ts +1147 -0
  151. package/src/evaluation-execution-v2.ts +426 -0
  152. package/src/evaluation-implementation.ts +131 -0
  153. package/src/evaluation-kb-closure.ts +1430 -0
  154. package/src/evaluation-kb-runner-v2.ts +735 -0
  155. package/src/evaluation-kb-v2.ts +2567 -0
  156. package/src/evaluation-kb.ts +1018 -0
  157. package/src/evaluation-measurement-v2.ts +1462 -0
  158. package/src/evaluation-packing-v2.ts +452 -0
  159. package/src/evaluation-redaction.ts +28 -0
  160. package/src/evaluation-v2.ts +3857 -0
  161. package/src/evaluation.ts +1220 -0
  162. package/src/git.ts +1276 -0
  163. package/src/graph.ts +1487 -0
  164. package/src/index.ts +20 -0
  165. package/src/init.ts +123 -0
  166. package/src/navigation.ts +277 -0
  167. package/src/note-lock.ts +529 -0
  168. package/src/pdf/args.ts +202 -0
  169. package/src/pdf/capture.ts +298 -0
  170. package/src/pdf/cli.ts +159 -0
  171. package/src/pdf/extract.ts +303 -0
  172. package/src/pdf/layout.ts +618 -0
  173. package/src/pdf/markdown.ts +154 -0
  174. package/src/pdf/model.ts +234 -0
  175. package/src/pdf/ocr.ts +190 -0
  176. package/src/pdf/persist.ts +314 -0
  177. package/src/pdf/source.ts +135 -0
  178. package/src/pdf/tools.ts +146 -0
  179. package/src/pdf.ts +92 -0
  180. package/src/percolate.ts +877 -0
  181. package/src/portfolio-audit.ts +574 -0
  182. package/src/portfolio-identity.ts +162 -0
  183. package/src/portfolio-registry.ts +473 -0
  184. package/src/portfolio.ts +765 -0
  185. package/src/query.ts +646 -0
  186. package/src/repository-memory.ts +1150 -0
  187. package/src/sdk.ts +1452 -0
  188. package/src/search-rules.ts +712 -0
  189. package/src/search.ts +710 -0
  190. package/src/semantic-runtime.ts +1139 -0
  191. package/src/semantic.ts +2480 -0
  192. package/src/source-inbox.ts +232 -0
  193. package/src/untrusted-content.ts +401 -0
  194. package/src/url-intelligence.ts +104 -0
  195. package/src/vault.ts +636 -0
  196. package/src/workflow.ts +653 -0
  197. package/src/workflows/decision-context.ts +81 -0
  198. package/src/workflows/explain-change.ts +73 -0
  199. package/src/workflows/index.ts +3 -0
  200. package/src/workflows/plan-radar.ts +101 -0
@@ -0,0 +1,723 @@
1
+ import {
2
+ lstat,
3
+ readdir,
4
+ realpath,
5
+ } from "node:fs/promises";
6
+ import {
7
+ dirname,
8
+ extname,
9
+ isAbsolute,
10
+ relative,
11
+ resolve,
12
+ sep,
13
+ } from "node:path";
14
+
15
+ export const MAX_ATTACHMENT_REFERENCES = 10_000;
16
+ export const MAX_ATTACHMENT_SOURCE_BYTES = 16 * 1_024 * 1_024;
17
+ export const MAX_ATTACHMENT_PATH_BYTES = 16 * 1_024;
18
+ export const MAX_ATTACHMENT_SCAN_ENTRIES = 100_000;
19
+
20
+ const supportedExtensions = new Set([
21
+ ".apng",
22
+ ".avif",
23
+ ".bmp",
24
+ ".gif",
25
+ ".heic",
26
+ ".heif",
27
+ ".jpeg",
28
+ ".jpg",
29
+ ".pdf",
30
+ ".png",
31
+ ".svg",
32
+ ".tldr",
33
+ ".tldraw",
34
+ ".webp",
35
+ ]);
36
+ const externalPattern = /^(?:[a-z][a-z\d+.-]*:|\/\/)/iu;
37
+ const windowsAbsolutePattern = /^[a-z]:[\\/]/iu;
38
+ const ignoredInventoryDirectories = new Set([
39
+ ".git",
40
+ ".next",
41
+ ".turbo",
42
+ ".vercel",
43
+ "coverage",
44
+ "dist",
45
+ "node_modules",
46
+ ]);
47
+
48
+ function hasControl(value: string): boolean {
49
+ return [...value].some((character) => {
50
+ const code = character.codePointAt(0) ?? 0;
51
+ return code <= 0x1f || code === 0x7f;
52
+ });
53
+ }
54
+
55
+ export type AttachmentSyntax =
56
+ | "markdown-image"
57
+ | "markdown-link"
58
+ | "obsidian-embed"
59
+ | "obsidian-link";
60
+
61
+ export type LocalAttachmentReference = {
62
+ readonly source: string;
63
+ readonly line: number;
64
+ readonly syntax: AttachmentSyntax;
65
+ readonly rawTarget: string;
66
+ /** Decoded target without query, heading, block, or page fragment. */
67
+ readonly target: string;
68
+ };
69
+
70
+ export type AttachmentIssue = {
71
+ readonly kind:
72
+ | "ambiguous"
73
+ | "budget"
74
+ | "case-collision"
75
+ | "case-mismatch"
76
+ | "hardlink"
77
+ | "malformed-target"
78
+ | "missing"
79
+ | "non-regular"
80
+ | "symlink"
81
+ | "traversal";
82
+ readonly source: string;
83
+ readonly line: number;
84
+ readonly syntax: AttachmentSyntax;
85
+ readonly target: string;
86
+ readonly message: string;
87
+ readonly candidates?: readonly string[];
88
+ };
89
+
90
+ export type ParsedAttachmentReferences = {
91
+ readonly references: readonly LocalAttachmentReference[];
92
+ readonly issues: readonly AttachmentIssue[];
93
+ readonly truncated: boolean;
94
+ };
95
+
96
+ export type ValidatedAttachment = LocalAttachmentReference & {
97
+ readonly path: string;
98
+ readonly bytes: number;
99
+ };
100
+
101
+ export type AttachmentValidationReport = ParsedAttachmentReferences & {
102
+ readonly root: string;
103
+ readonly attachments: readonly ValidatedAttachment[];
104
+ };
105
+
106
+ export type MarkdownAttachmentDocument = {
107
+ /** Vault-relative Markdown source path used for source-relative resolution. */
108
+ readonly path: string;
109
+ readonly content: string;
110
+ };
111
+
112
+ function attachmentExtension(target: string): string {
113
+ return extname(target).toLocaleLowerCase("en-US");
114
+ }
115
+
116
+ function isSupportedAttachment(target: string): boolean {
117
+ return supportedExtensions.has(attachmentExtension(target));
118
+ }
119
+
120
+ function issueFor(
121
+ reference: Pick<LocalAttachmentReference, "source" | "line" | "syntax" | "rawTarget">,
122
+ kind: AttachmentIssue["kind"],
123
+ message: string,
124
+ candidates?: readonly string[],
125
+ ): AttachmentIssue {
126
+ return Object.freeze({
127
+ kind,
128
+ source: reference.source,
129
+ line: reference.line,
130
+ syntax: reference.syntax,
131
+ target: reference.rawTarget,
132
+ message,
133
+ ...(candidates === undefined ? {} : { candidates: Object.freeze([...candidates]) }),
134
+ });
135
+ }
136
+
137
+ function decodeLocalTarget(
138
+ rawTarget: string,
139
+ reference: Pick<LocalAttachmentReference, "source" | "line" | "syntax" | "rawTarget">,
140
+ ): { readonly target?: string; readonly issue?: AttachmentIssue; readonly external: boolean } {
141
+ const withoutDecoration = rawTarget.trim().split(/[?#^]/u, 1)[0] ?? "";
142
+ if (withoutDecoration === "" || withoutDecoration.startsWith("#")) {
143
+ return { external: true };
144
+ }
145
+ let decoded: string;
146
+ try {
147
+ decoded = decodeURIComponent(withoutDecoration)
148
+ .replace(/\\([\\()[\] ])/gu, "$1")
149
+ .normalize("NFC");
150
+ } catch {
151
+ if (!isSupportedAttachment(withoutDecoration)) return { external: true };
152
+ return {
153
+ external: false,
154
+ issue: issueFor(reference, "malformed-target", "Attachment target has invalid percent encoding."),
155
+ };
156
+ }
157
+ if (windowsAbsolutePattern.test(decoded)) {
158
+ if (!isSupportedAttachment(decoded)) return { external: true };
159
+ return {
160
+ external: false,
161
+ issue: issueFor(reference, "traversal", "Attachment targets must be vault-relative."),
162
+ };
163
+ }
164
+ if (externalPattern.test(decoded)) return { external: true };
165
+ if (!isSupportedAttachment(decoded)) return { external: true };
166
+ if (decoded === "" || hasControl(decoded)) {
167
+ return {
168
+ external: false,
169
+ issue: issueFor(reference, "malformed-target", "Attachment target is empty or contains control characters."),
170
+ };
171
+ }
172
+ if (Buffer.byteLength(decoded, "utf8") > MAX_ATTACHMENT_PATH_BYTES) {
173
+ return {
174
+ external: false,
175
+ issue: issueFor(
176
+ reference,
177
+ "budget",
178
+ `Attachment targets may use at most ${MAX_ATTACHMENT_PATH_BYTES.toLocaleString("en-US")} UTF-8 bytes.`,
179
+ ),
180
+ };
181
+ }
182
+ return { target: decoded, external: false };
183
+ }
184
+
185
+ function markdownDestination(value: string): string {
186
+ const trimmed = value.trim();
187
+ if (trimmed.startsWith("<")) {
188
+ const end = trimmed.indexOf(">");
189
+ return end === -1 ? trimmed : trimmed.slice(1, end);
190
+ }
191
+ const match = /^(?:\\.|[^\s])+/u.exec(trimmed);
192
+ return match?.[0] ?? "";
193
+ }
194
+
195
+ function normalizedReferenceLabel(value: string): string {
196
+ return value.trim().replace(/\s+/gu, " ").toLocaleLowerCase("en-US");
197
+ }
198
+
199
+ function inlineMarkdownLinks(
200
+ line: string,
201
+ ): readonly { readonly image: boolean; readonly rawTarget: string }[] {
202
+ const links: { image: boolean; rawTarget: string }[] = [];
203
+ for (let cursor = 0; cursor < line.length; cursor += 1) {
204
+ const image = line[cursor] === "!" && line[cursor + 1] === "[";
205
+ if (line[cursor] !== "[" && !image) continue;
206
+ const opening = image ? cursor + 1 : cursor;
207
+ if (line[opening - 1] === "\\" || line[opening + 1] === "[") continue;
208
+ let closing = opening + 1;
209
+ for (; closing < line.length; closing += 1) {
210
+ if (line[closing] === "]" && line[closing - 1] !== "\\") break;
211
+ }
212
+ if (closing >= line.length || line[closing + 1] !== "(") continue;
213
+ let depth = 1;
214
+ let destinationEnd = closing + 2;
215
+ let angle = false;
216
+ for (; destinationEnd < line.length; destinationEnd += 1) {
217
+ const character = line[destinationEnd];
218
+ if (character === "\\") {
219
+ destinationEnd += 1;
220
+ continue;
221
+ }
222
+ if (character === "<" && depth === 1) angle = true;
223
+ else if (character === ">" && angle) angle = false;
224
+ else if (!angle && character === "(") depth += 1;
225
+ else if (!angle && character === ")") {
226
+ depth -= 1;
227
+ if (depth === 0) break;
228
+ }
229
+ }
230
+ if (depth !== 0) continue;
231
+ links.push({
232
+ image,
233
+ rawTarget: markdownDestination(line.slice(closing + 2, destinationEnd)),
234
+ });
235
+ cursor = destinationEnd;
236
+ }
237
+ return links;
238
+ }
239
+
240
+ function maskInlineCode(value: string): string {
241
+ const characters = [...value];
242
+ let cursor = 0;
243
+ while (cursor < characters.length) {
244
+ if (characters[cursor] !== "`") {
245
+ cursor += 1;
246
+ continue;
247
+ }
248
+ let ticks = 1;
249
+ while (characters[cursor + ticks] === "`") ticks += 1;
250
+ const delimiter = "`".repeat(ticks);
251
+ const remainder = characters.slice(cursor + ticks).join("");
252
+ const closingOffset = remainder.indexOf(delimiter);
253
+ if (closingOffset === -1) break;
254
+ const end = cursor + ticks + [...remainder.slice(0, closingOffset)].length + ticks;
255
+ for (let index = cursor; index < end; index += 1) characters[index] = " ";
256
+ cursor = end;
257
+ }
258
+ return characters.join("");
259
+ }
260
+
261
+ function maskHtmlComments(
262
+ value: string,
263
+ startsInsideComment: boolean,
264
+ ): { readonly line: string; readonly insideComment: boolean } {
265
+ let cursor = 0;
266
+ let insideComment = startsInsideComment;
267
+ let masked = "";
268
+ while (cursor < value.length) {
269
+ if (insideComment) {
270
+ const closing = value.indexOf("-->", cursor);
271
+ if (closing === -1) {
272
+ masked += " ".repeat(value.length - cursor);
273
+ return { line: masked, insideComment: true };
274
+ }
275
+ masked += " ".repeat(closing + 3 - cursor);
276
+ cursor = closing + 3;
277
+ insideComment = false;
278
+ continue;
279
+ }
280
+ const opening = value.indexOf("<!--", cursor);
281
+ if (opening === -1) {
282
+ masked += value.slice(cursor);
283
+ break;
284
+ }
285
+ masked += value.slice(cursor, opening);
286
+ masked += " ".repeat(4);
287
+ cursor = opening + 4;
288
+ insideComment = true;
289
+ }
290
+ return { line: masked, insideComment };
291
+ }
292
+
293
+ function normalizedSource(source: string): string {
294
+ const normalized = source.replaceAll("\\", "/").normalize("NFC");
295
+ if (
296
+ normalized === ""
297
+ || isAbsolute(normalized)
298
+ || windowsAbsolutePattern.test(normalized)
299
+ || hasControl(normalized)
300
+ || normalized.split("/").some((part, index) =>
301
+ part === ".." || part === "" || (part === "." && index !== 0))
302
+ ) {
303
+ throw new TypeError("Attachment source paths must be normalized vault-relative paths.");
304
+ }
305
+ return normalized.replace(/^\.\//u, "");
306
+ }
307
+
308
+ /** Parse supported local attachments without reading or interpreting binary data. */
309
+ export function parseLocalAttachmentReferences(
310
+ sourceInput: string,
311
+ markdown: string,
312
+ options: { readonly maxReferences?: number; readonly maxBytes?: number } = {},
313
+ ): ParsedAttachmentReferences {
314
+ const source = normalizedSource(sourceInput);
315
+ const maxReferences = options.maxReferences ?? MAX_ATTACHMENT_REFERENCES;
316
+ const maxBytes = options.maxBytes ?? MAX_ATTACHMENT_SOURCE_BYTES;
317
+ if (!Number.isSafeInteger(maxReferences) || maxReferences < 1 || maxReferences > MAX_ATTACHMENT_REFERENCES) {
318
+ throw new RangeError(`Attachment reference limit must be from 1 through ${MAX_ATTACHMENT_REFERENCES}.`);
319
+ }
320
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_ATTACHMENT_SOURCE_BYTES) {
321
+ throw new RangeError(`Attachment source byte limit must be from 1 through ${MAX_ATTACHMENT_SOURCE_BYTES}.`);
322
+ }
323
+ if (Buffer.byteLength(markdown, "utf8") > maxBytes) {
324
+ throw new RangeError(`Attachment source exceeds the ${maxBytes.toLocaleString("en-US")}-byte limit.`);
325
+ }
326
+
327
+ const references: LocalAttachmentReference[] = [];
328
+ const issues: AttachmentIssue[] = [];
329
+ let truncated = false;
330
+ let fence: string | null = null;
331
+ let htmlComment = false;
332
+ let frontmatter = false;
333
+ const add = (line: number, syntax: AttachmentSyntax, rawTarget: string): void => {
334
+ if (references.length + issues.length >= maxReferences) {
335
+ truncated = true;
336
+ return;
337
+ }
338
+ const shell = { source, line, syntax, rawTarget } as const;
339
+ const decoded = decodeLocalTarget(rawTarget, shell);
340
+ if (decoded.external) return;
341
+ if (decoded.issue !== undefined) {
342
+ issues.push(decoded.issue);
343
+ return;
344
+ }
345
+ const target = decoded.target;
346
+ if (target === undefined) return;
347
+ references.push(Object.freeze({ ...shell, target }));
348
+ };
349
+
350
+ const lines = markdown.split(/\r?\n/u).map((rawLine, index) => {
351
+ if (index === 0 && /^\uFEFF?---\s*$/u.test(rawLine)) {
352
+ frontmatter = true;
353
+ return { index, line: "", definition: true };
354
+ }
355
+ if (frontmatter) {
356
+ if (/^(?:---|\.\.\.)\s*$/u.test(rawLine)) frontmatter = false;
357
+ return { index, line: "", definition: true };
358
+ }
359
+ if (fence !== null) {
360
+ const fenceMatch = /^\s*(`{3,}|~{3,})/u.exec(rawLine);
361
+ const marker = fenceMatch?.[1]?.[0];
362
+ if (marker === fence) fence = null;
363
+ return { index, line: "", definition: true };
364
+ }
365
+ const inlineMasked = htmlComment ? rawLine : maskInlineCode(rawLine);
366
+ const commentMasked = maskHtmlComments(inlineMasked, htmlComment);
367
+ htmlComment = commentMasked.insideComment;
368
+ const fenceMatch = /^\s*(`{3,}|~{3,})/u.exec(commentMasked.line);
369
+ if (fenceMatch !== null) {
370
+ const marker = fenceMatch[1]?.[0];
371
+ if (marker !== undefined) fence = marker;
372
+ return { index, line: "", definition: true };
373
+ }
374
+ if (/^(?: {4}|\t)/u.test(rawLine)) {
375
+ return { index, line: "", definition: true };
376
+ }
377
+ return {
378
+ index,
379
+ line: htmlComment ? commentMasked.line : maskInlineCode(commentMasked.line),
380
+ definition: false,
381
+ };
382
+ });
383
+ const definitions = new Map<string, string>();
384
+ for (const entry of lines) {
385
+ const match = /^ {0,3}\[([^\]\n]+)\]:\s*(.*)$/u.exec(entry.line);
386
+ if (match === null) continue;
387
+ entry.definition = true;
388
+ const label = normalizedReferenceLabel(match[1] ?? "");
389
+ if (label !== "" && !definitions.has(label)) {
390
+ definitions.set(label, markdownDestination(match[2] ?? ""));
391
+ }
392
+ }
393
+
394
+ for (const { index, line, definition } of lines) {
395
+ if (definition || truncated) continue;
396
+ for (const match of inlineMarkdownLinks(line)) {
397
+ add(index + 1, match.image ? "markdown-image" : "markdown-link", match.rawTarget);
398
+ }
399
+ for (const match of line.matchAll(/(!?)\[([^\]\n]*)\](?:\[([^\]\n]*)\])?/gu)) {
400
+ const matchIndex = match.index;
401
+ if (matchIndex === undefined) continue;
402
+ const whole = match[0] ?? "";
403
+ if (line[matchIndex - 1] === "[" || line[matchIndex + whole.length] === "(") continue;
404
+ const explicitLabel = match[3];
405
+ const label = normalizedReferenceLabel(explicitLabel === undefined || explicitLabel === ""
406
+ ? match[2] ?? ""
407
+ : explicitLabel);
408
+ const rawTarget = definitions.get(label);
409
+ if (rawTarget !== undefined) {
410
+ add(index + 1, match[1] === "!" ? "markdown-image" : "markdown-link", rawTarget);
411
+ }
412
+ }
413
+ for (const match of line.matchAll(/(!?)\[\[([^\]\n]+)\]\]/gu)) {
414
+ const rawTarget = (match[2] ?? "").split("|", 1)[0]?.trim() ?? "";
415
+ add(index + 1, match[1] === "!" ? "obsidian-embed" : "obsidian-link", rawTarget);
416
+ }
417
+ }
418
+ if (truncated) {
419
+ issues.push(Object.freeze({
420
+ kind: "budget",
421
+ source,
422
+ line: 1,
423
+ syntax: "markdown-link",
424
+ target: "",
425
+ message: `Attachment source exceeds the ${maxReferences.toLocaleString("en-US")}-reference limit.`,
426
+ }));
427
+ }
428
+ return Object.freeze({
429
+ references: Object.freeze(references),
430
+ issues: Object.freeze(issues),
431
+ truncated,
432
+ });
433
+ }
434
+
435
+ function insideRoot(root: string, candidate: string): boolean {
436
+ const path = relative(root, candidate);
437
+ return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path));
438
+ }
439
+
440
+ type AttachmentInventory = {
441
+ readonly files: readonly string[];
442
+ readonly basename: ReadonlyMap<string, readonly string[]>;
443
+ readonly collisions: ReadonlyMap<string, readonly string[]>;
444
+ };
445
+
446
+ function folded(value: string): string {
447
+ return value.normalize("NFC").toLocaleLowerCase("en-US");
448
+ }
449
+
450
+ async function inventoryAttachments(root: string, maximumEntries: number): Promise<AttachmentInventory> {
451
+ const files: string[] = [];
452
+ const basename = new Map<string, string[]>();
453
+ const collisions = new Map<string, string[]>();
454
+ const pending = [""];
455
+ let entries = 0;
456
+ while (pending.length > 0) {
457
+ const directory = pending.shift();
458
+ if (directory === undefined) break;
459
+ const absoluteDirectory = resolve(root, directory);
460
+ const children = await readdir(absoluteDirectory, { withFileTypes: true });
461
+ const siblings = new Map<string, string[]>();
462
+ for (const child of children) {
463
+ entries += 1;
464
+ if (entries > maximumEntries) {
465
+ throw new RangeError(`Attachment inventory exceeds the ${maximumEntries.toLocaleString("en-US")}-entry limit.`);
466
+ }
467
+ const relativePath = directory === "" ? child.name : `${directory}/${child.name}`;
468
+ const key = folded(child.name);
469
+ const matches = siblings.get(key) ?? [];
470
+ matches.push(relativePath);
471
+ siblings.set(key, matches);
472
+ if (isSupportedAttachment(child.name)) {
473
+ const names = basename.get(key) ?? [];
474
+ names.push(relativePath);
475
+ basename.set(key, names);
476
+ }
477
+ if (child.isSymbolicLink()) continue;
478
+ if (child.isDirectory()) {
479
+ if (!child.name.startsWith(".") && !ignoredInventoryDirectories.has(child.name)) {
480
+ pending.push(relativePath);
481
+ }
482
+ }
483
+ else if (child.isFile() && isSupportedAttachment(child.name)) {
484
+ files.push(relativePath);
485
+ }
486
+ }
487
+ for (const [key, matches] of siblings) {
488
+ if (matches.length > 1) collisions.set(`${directory}\0${key}`, matches.toSorted());
489
+ }
490
+ }
491
+ return Object.freeze({
492
+ files: Object.freeze(files.toSorted()),
493
+ basename: new Map([...basename].map(([key, values]) => [key, Object.freeze(values.toSorted())])),
494
+ collisions: new Map([...collisions].map(([key, values]) => [key, Object.freeze(values)])),
495
+ });
496
+ }
497
+
498
+ async function inspectExactPath(
499
+ root: string,
500
+ candidateRelative: string,
501
+ inventory: AttachmentInventory,
502
+ reference: LocalAttachmentReference,
503
+ ): Promise<{ readonly attachment?: ValidatedAttachment; readonly issue?: AttachmentIssue }> {
504
+ const parts = candidateRelative.split("/").filter(Boolean);
505
+ let directory = "";
506
+ for (const [index, part] of parts.entries()) {
507
+ const key = `${directory}\0${folded(part)}`;
508
+ const collision = inventory.collisions.get(key);
509
+ if (collision !== undefined) {
510
+ return {
511
+ issue: issueFor(
512
+ reference,
513
+ "case-collision",
514
+ "Attachment path is ambiguous under case-insensitive or Unicode-normalized lookup.",
515
+ collision,
516
+ ),
517
+ };
518
+ }
519
+ const children = await readdir(resolve(root, directory), { withFileTypes: true });
520
+ const equivalent = children.filter(({ name }) => folded(name) === folded(part));
521
+ const exact = equivalent.find(({ name }) => name === part);
522
+ if (exact === undefined) {
523
+ if (equivalent.length > 0) {
524
+ const actual = equivalent.map(({ name }) => directory === "" ? name : `${directory}/${name}`);
525
+ return {
526
+ issue: issueFor(
527
+ reference,
528
+ "case-mismatch",
529
+ "Attachment target casing or Unicode normalization does not match the filesystem.",
530
+ actual,
531
+ ),
532
+ };
533
+ }
534
+ return { issue: issueFor(reference, "missing", "Attachment target does not exist.") };
535
+ }
536
+ const next = directory === "" ? exact.name : `${directory}/${exact.name}`;
537
+ const status = await lstat(resolve(root, next));
538
+ if (status.isSymbolicLink()) {
539
+ return { issue: issueFor(reference, "symlink", "Attachment paths may not traverse symbolic links.") };
540
+ }
541
+ if (index < parts.length - 1 && !status.isDirectory()) {
542
+ return { issue: issueFor(reference, "non-regular", "An attachment path ancestor is not a directory.") };
543
+ }
544
+ directory = next;
545
+ }
546
+ const status = await lstat(resolve(root, candidateRelative));
547
+ if (!status.isFile()) {
548
+ return { issue: issueFor(reference, "non-regular", "Attachment target must be a regular file.") };
549
+ }
550
+ if (status.nlink !== 1) {
551
+ return { issue: issueFor(reference, "hardlink", "Attachment target may not be a hard-linked file.") };
552
+ }
553
+ return {
554
+ attachment: Object.freeze({
555
+ ...reference,
556
+ path: candidateRelative,
557
+ bytes: status.size,
558
+ }),
559
+ };
560
+ }
561
+
562
+ function candidateFor(
563
+ root: string,
564
+ reference: LocalAttachmentReference,
565
+ inventory: AttachmentInventory,
566
+ ): { readonly path?: string; readonly issue?: AttachmentIssue } {
567
+ const slashTarget = reference.target.replaceAll("\\", "/");
568
+ if (isAbsolute(slashTarget)) {
569
+ return { issue: issueFor(reference, "traversal", "Attachment targets must be vault-relative.") };
570
+ }
571
+ let candidateRelative: string;
572
+ if (reference.syntax === "markdown-image" || reference.syntax === "markdown-link") {
573
+ const absolute = resolve(root, dirname(reference.source), slashTarget);
574
+ if (!insideRoot(root, absolute)) {
575
+ return { issue: issueFor(reference, "traversal", "Attachment target escapes the vault root.") };
576
+ }
577
+ candidateRelative = relative(root, absolute).split(sep).join("/");
578
+ } else if (slashTarget.includes("/")) {
579
+ const absolute = resolve(root, slashTarget);
580
+ if (!insideRoot(root, absolute) || slashTarget.split("/").includes("..")) {
581
+ return { issue: issueFor(reference, "traversal", "Obsidian attachment paths must be vault-relative without traversal.") };
582
+ }
583
+ candidateRelative = relative(root, absolute).split(sep).join("/");
584
+ } else {
585
+ const matches = inventory.basename.get(folded(slashTarget)) ?? [];
586
+ if (matches.length === 0) {
587
+ return { issue: issueFor(reference, "missing", "Obsidian attachment basename was not found in the vault.") };
588
+ }
589
+ const collidingMatches = matches.flatMap((path) => {
590
+ const separator = path.lastIndexOf("/");
591
+ const directory = separator === -1 ? "" : path.slice(0, separator);
592
+ return inventory.collisions.get(`${directory}\0${folded(slashTarget)}`) ?? [];
593
+ });
594
+ if (collidingMatches.length > 0) {
595
+ return {
596
+ issue: issueFor(
597
+ reference,
598
+ "case-collision",
599
+ "Obsidian attachment basename collides under case-insensitive or Unicode-normalized lookup.",
600
+ [...new Set(collidingMatches)].toSorted(),
601
+ ),
602
+ };
603
+ }
604
+ if (matches.length > 1) {
605
+ return {
606
+ issue: issueFor(
607
+ reference,
608
+ "ambiguous",
609
+ "Obsidian attachment basename resolves to more than one vault file.",
610
+ matches,
611
+ ),
612
+ };
613
+ }
614
+ candidateRelative = matches[0] ?? "";
615
+ const actualBasename = candidateRelative.slice(candidateRelative.lastIndexOf("/") + 1);
616
+ if (actualBasename !== slashTarget) {
617
+ return {
618
+ issue: issueFor(
619
+ reference,
620
+ "case-mismatch",
621
+ "Obsidian attachment basename casing or Unicode normalization does not match the filesystem.",
622
+ [candidateRelative],
623
+ ),
624
+ };
625
+ }
626
+ }
627
+ return { path: candidateRelative };
628
+ }
629
+
630
+ /**
631
+ * Validate parsed references against local metadata only. Markdown destinations
632
+ * resolve relative to their source note. Obsidian paths containing `/` resolve
633
+ * from the vault root; bare Obsidian basenames must be unique across the vault.
634
+ */
635
+ export async function validateAttachmentReferences(options: {
636
+ readonly root: string;
637
+ readonly references: readonly LocalAttachmentReference[];
638
+ readonly parseIssues?: readonly AttachmentIssue[];
639
+ readonly parseTruncated?: boolean;
640
+ readonly maxEntries?: number;
641
+ }): Promise<AttachmentValidationReport> {
642
+ if (options.references.length > MAX_ATTACHMENT_REFERENCES) {
643
+ throw new RangeError(`Attachment validation accepts at most ${MAX_ATTACHMENT_REFERENCES} references.`);
644
+ }
645
+ if ((options.parseIssues?.length ?? 0) > MAX_ATTACHMENT_REFERENCES + 1) {
646
+ throw new RangeError(
647
+ `Attachment validation accepts at most ${MAX_ATTACHMENT_REFERENCES + 1} parse issues.`,
648
+ );
649
+ }
650
+ const maxEntries = options.maxEntries ?? MAX_ATTACHMENT_SCAN_ENTRIES;
651
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1 || maxEntries > MAX_ATTACHMENT_SCAN_ENTRIES) {
652
+ throw new RangeError(`Attachment inventory limit must be from 1 through ${MAX_ATTACHMENT_SCAN_ENTRIES}.`);
653
+ }
654
+ const requestedRootStatus = await lstat(options.root);
655
+ if (requestedRootStatus.isSymbolicLink()) {
656
+ throw new TypeError("Attachment root may not be a symbolic link.");
657
+ }
658
+ const root = await realpath(options.root);
659
+ const rootStatus = await lstat(root);
660
+ if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink()) {
661
+ throw new TypeError("Attachment root must be a real directory.");
662
+ }
663
+ const inventory = await inventoryAttachments(root, maxEntries);
664
+ const attachments: ValidatedAttachment[] = [];
665
+ const issues: AttachmentIssue[] = [...(options.parseIssues ?? [])];
666
+ for (const reference of options.references) {
667
+ const candidate = candidateFor(root, reference, inventory);
668
+ if (candidate.issue !== undefined) {
669
+ issues.push(candidate.issue);
670
+ continue;
671
+ }
672
+ const candidatePath = candidate.path;
673
+ if (candidatePath === undefined) continue;
674
+ const inspected = await inspectExactPath(root, candidatePath, inventory, reference);
675
+ if (inspected.issue !== undefined) issues.push(inspected.issue);
676
+ else if (inspected.attachment !== undefined) attachments.push(inspected.attachment);
677
+ }
678
+ return Object.freeze({
679
+ root,
680
+ references: Object.freeze([...options.references]),
681
+ attachments: Object.freeze(attachments),
682
+ issues: Object.freeze(issues),
683
+ truncated: options.parseTruncated === true,
684
+ });
685
+ }
686
+
687
+ /** Parse and validate a bounded set of Markdown documents in one inventory pass. */
688
+ export async function validateMarkdownAttachments(options: {
689
+ readonly root: string;
690
+ readonly documents: readonly MarkdownAttachmentDocument[];
691
+ readonly maxReferences?: number;
692
+ readonly maxEntries?: number;
693
+ }): Promise<AttachmentValidationReport> {
694
+ const maximum = options.maxReferences ?? MAX_ATTACHMENT_REFERENCES;
695
+ if (!Number.isSafeInteger(maximum) || maximum < 1 || maximum > MAX_ATTACHMENT_REFERENCES) {
696
+ throw new RangeError(`Attachment reference limit must be from 1 through ${MAX_ATTACHMENT_REFERENCES}.`);
697
+ }
698
+ const references: LocalAttachmentReference[] = [];
699
+ const issues: AttachmentIssue[] = [];
700
+ let truncated = false;
701
+ for (const document of options.documents) {
702
+ const remaining = maximum - references.length - issues.length;
703
+ if (remaining <= 0) {
704
+ truncated = true;
705
+ break;
706
+ }
707
+ const parsed = parseLocalAttachmentReferences(document.path, document.content, {
708
+ maxReferences: remaining,
709
+ });
710
+ references.push(...parsed.references);
711
+ issues.push(...parsed.issues);
712
+ truncated ||= parsed.truncated;
713
+ if (parsed.truncated) break;
714
+ }
715
+ const report = await validateAttachmentReferences({
716
+ root: options.root,
717
+ references,
718
+ parseIssues: issues,
719
+ parseTruncated: truncated,
720
+ ...(options.maxEntries === undefined ? {} : { maxEntries: options.maxEntries }),
721
+ });
722
+ return Object.freeze({ ...report, truncated: truncated || report.truncated });
723
+ }