@gmickel/gno 1.32.0 → 1.34.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 (54) hide show
  1. package/README.md +17 -3
  2. package/assets/skill/SKILL.md +30 -0
  3. package/assets/skill/cli-reference.md +10 -2
  4. package/browser-extension/artifacts/{gno-browser-clipper-v1.32.0.zip → gno-browser-clipper-v1.34.0.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.34.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/package.json +5 -1
  8. package/spec/cli.md +60 -1
  9. package/spec/mcp.md +21 -0
  10. package/spec/output-schemas/audit-report.schema.json +284 -0
  11. package/spec/output-schemas/publish-artifact.schema.json +76 -1
  12. package/src/cli/commands/audit.ts +231 -0
  13. package/src/cli/commands/publish.ts +43 -7
  14. package/src/cli/errors.ts +9 -2
  15. package/src/cli/program.ts +112 -0
  16. package/src/core/audit-contract.ts +296 -0
  17. package/src/core/audit-freshness.ts +233 -0
  18. package/src/core/audit-links.ts +222 -0
  19. package/src/core/audit-provenance.ts +154 -0
  20. package/src/core/audit-report.ts +318 -0
  21. package/src/core/audit-workspace.ts +678 -0
  22. package/src/core/audit.ts +569 -0
  23. package/src/core/capture.ts +196 -3
  24. package/src/core/document-capabilities.ts +9 -8
  25. package/src/core/record-metadata.ts +33 -0
  26. package/src/ingestion/strip.ts +152 -26
  27. package/src/mcp/http-egress.ts +8 -0
  28. package/src/mcp/tools/audit.ts +97 -0
  29. package/src/mcp/tools/index.ts +13 -0
  30. package/src/publish/artifact-asset-codec.ts +75 -0
  31. package/src/publish/artifact-asset-contract.ts +152 -0
  32. package/src/publish/artifact-asset-parse.ts +401 -0
  33. package/src/publish/artifact-asset-sniff.ts +108 -0
  34. package/src/publish/artifact-asset-validate.ts +209 -0
  35. package/src/publish/artifact-assets.ts +58 -0
  36. package/src/publish/artifact-validation.ts +32 -6
  37. package/src/publish/artifact.ts +50 -3
  38. package/src/publish/attachment-bundle.ts +145 -0
  39. package/src/publish/attachment-discover.ts +203 -0
  40. package/src/publish/attachment-load.ts +133 -0
  41. package/src/publish/attachment-obsidian.ts +45 -0
  42. package/src/publish/attachment-path.ts +334 -0
  43. package/src/publish/attachment-raster.ts +852 -0
  44. package/src/publish/attachment-resolver.ts +280 -0
  45. package/src/publish/attachment-types.ts +54 -0
  46. package/src/publish/encrypted-export.ts +121 -44
  47. package/src/publish/export-attachments.ts +224 -0
  48. package/src/publish/export-service.ts +142 -80
  49. package/src/publish/obsidian-sanitize.ts +121 -13
  50. package/src/serve/routes/api.ts +2 -1
  51. package/src/store/sqlite/adapter.ts +82 -0
  52. package/src/store/sqlite/graph-link-bulk-resolver.ts +191 -0
  53. package/src/store/sqlite/graph-link-resolver.ts +241 -2
  54. package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +0 -1
@@ -176,6 +176,70 @@ const CAPTURE_SOURCE_STRING_KEYS = new Set([
176
176
  "externalId",
177
177
  ]);
178
178
 
179
+ export const CAPTURE_PROVENANCE_REQUIRED_FIELDS = [
180
+ "kind",
181
+ "capturedAt",
182
+ ] as const;
183
+
184
+ export interface CaptureProvenanceIssue {
185
+ field: string;
186
+ reason: "missing" | "invalid";
187
+ }
188
+
189
+ /** Validate only fields declared by the CaptureSource contract. */
190
+ export const validateDeclaredCaptureProvenance = (
191
+ source: Partial<CaptureSource>
192
+ ): CaptureProvenanceIssue[] => {
193
+ const issues: CaptureProvenanceIssue[] = [];
194
+ if (!source.kind) issues.push({ field: "source.kind", reason: "missing" });
195
+ else if (!VALID_SOURCE_KINDS.has(source.kind))
196
+ issues.push({ field: "source.kind", reason: "invalid" });
197
+ const capturedAt = source.capturedAt as unknown;
198
+ if (capturedAt === undefined || capturedAt === null || capturedAt === "")
199
+ issues.push({ field: "source.capturedAt", reason: "missing" });
200
+ else if (
201
+ typeof capturedAt !== "string" ||
202
+ Number.isNaN(new Date(capturedAt).getTime())
203
+ )
204
+ issues.push({ field: "source.capturedAt", reason: "invalid" });
205
+ for (const field of ["observedAt", "publishedAt"] as const) {
206
+ const value = source[field];
207
+ if (value === undefined) continue;
208
+ if (typeof value !== "string" || Number.isNaN(new Date(value).getTime())) {
209
+ issues.push({ field: `source.${field}`, reason: "invalid" });
210
+ }
211
+ }
212
+ for (const field of URL_SOURCE_FIELDS) {
213
+ const value = source[field as keyof CaptureSource];
214
+ if (value === undefined) continue;
215
+ if (typeof value !== "string") {
216
+ issues.push({ field: `source.${field}`, reason: "invalid" });
217
+ continue;
218
+ }
219
+ try {
220
+ new URL(value);
221
+ } catch {
222
+ issues.push({ field: `source.${field}`, reason: "invalid" });
223
+ }
224
+ }
225
+ for (const field of CAPTURE_SOURCE_STRING_KEYS) {
226
+ if (URL_SOURCE_FIELDS.has(field) || field === "publishedAt") continue;
227
+ const value = source[field as keyof CaptureSource];
228
+ if (value !== undefined && value !== null && typeof value !== "string") {
229
+ issues.push({ field: `source.${field}`, reason: "invalid" });
230
+ }
231
+ }
232
+ if (
233
+ source.browserClip !== undefined &&
234
+ !browserClipProvenanceSchema.safeParse(source.browserClip).success
235
+ ) {
236
+ issues.push({ field: "source.browserClip", reason: "invalid" });
237
+ }
238
+ return issues.sort((left, right) =>
239
+ left.field < right.field ? -1 : left.field > right.field ? 1 : 0
240
+ );
241
+ };
242
+
179
243
  function normalizeContentForHash(content: string): string {
180
244
  return content.replace(/\r\n/g, "\n").trim();
181
245
  }
@@ -468,11 +532,35 @@ function shouldSkipNestedFrontmatterLine(line: string): boolean {
468
532
  return line.startsWith(" ") || line.trim() === "";
469
533
  }
470
534
 
535
+ const parseFrontmatterScalar = (rawValue: string): unknown => {
536
+ try {
537
+ return (Bun.YAML.parse(`value: ${rawValue}`) as { value?: unknown }).value;
538
+ } catch {
539
+ return stripYamlString(rawValue);
540
+ }
541
+ };
542
+
471
543
  export function extractCaptureSourceFromFrontmatter(
472
544
  content: string
473
545
  ): Partial<CaptureSource> {
474
546
  const { lines } = splitFrontmatter(content);
475
547
  const source: Partial<CaptureSource> = {};
548
+ let parsedSourceMapping = false;
549
+ try {
550
+ const parsed = Bun.YAML.parse(lines.join("\n")) as { source?: unknown };
551
+ if (
552
+ parsed.source !== null &&
553
+ typeof parsed.source === "object" &&
554
+ !Array.isArray(parsed.source)
555
+ ) {
556
+ parsedSourceMapping = true;
557
+ for (const [key, value] of Object.entries(parsed.source)) {
558
+ source[key as keyof CaptureSource] = value as never;
559
+ }
560
+ }
561
+ } catch {
562
+ // Invalid YAML falls through to the declaration-preserving parser below.
563
+ }
476
564
  for (let index = 0; index < lines.length; index += 1) {
477
565
  const line = lines[index];
478
566
  if (line === undefined) {
@@ -505,6 +593,30 @@ export function extractCaptureSourceFromFrontmatter(
505
593
  if (key !== "source") {
506
594
  continue;
507
595
  }
596
+ if (parsedSourceMapping) {
597
+ continue;
598
+ }
599
+ if (rawValue) {
600
+ try {
601
+ const parsed = Bun.YAML.parse(`source: ${rawValue}`) as {
602
+ source?: unknown;
603
+ };
604
+ if (
605
+ parsed.source !== null &&
606
+ typeof parsed.source === "object" &&
607
+ !Array.isArray(parsed.source)
608
+ ) {
609
+ for (const [nestedKey, nestedValue] of Object.entries(
610
+ parsed.source
611
+ )) {
612
+ source[nestedKey as keyof CaptureSource] = nestedValue as never;
613
+ }
614
+ }
615
+ } catch {
616
+ // Invalid inline YAML remains declaration-visible to the audit.
617
+ }
618
+ continue;
619
+ }
508
620
  for (
509
621
  let nestedIndex = index + 1;
510
622
  nestedIndex < lines.length;
@@ -527,19 +639,100 @@ export function extractCaptureSourceFromFrontmatter(
527
639
  try {
528
640
  const parsed = JSON.parse(nestedValue) as unknown;
529
641
  const provenance = browserClipProvenanceSchema.safeParse(parsed);
530
- if (provenance.success) source.browserClip = provenance.data;
642
+ // Retain invalid declarations so provenance audits can report them.
643
+ // Runtime consumers only inspect known fields via optional chaining.
644
+ source.browserClip = provenance.success
645
+ ? provenance.data
646
+ : (parsed as BrowserClipProvenance);
531
647
  } catch {
532
- // Ignore malformed optional browser provenance.
648
+ source.browserClip = {} as BrowserClipProvenance;
533
649
  }
534
650
  continue;
535
651
  }
536
- source[nestedKey] = stripYamlString(nestedValue) as never;
652
+ source[nestedKey] = parseFrontmatterScalar(nestedValue) as never;
537
653
  }
538
654
  }
539
655
  }
540
656
  return source;
541
657
  }
542
658
 
659
+ /** Whether a note explicitly declares the CaptureSource frontmatter contract. */
660
+ export const hasDeclaredCaptureSource = (content: string): boolean => {
661
+ const { lines } = splitFrontmatter(content);
662
+ const captureKeys = new Set<string>([
663
+ "kind",
664
+ "capturedAt",
665
+ "url",
666
+ "docid",
667
+ "uri",
668
+ "mime",
669
+ "ext",
670
+ "title",
671
+ "author",
672
+ "canonicalUrl",
673
+ "site",
674
+ "publishedAt",
675
+ "observedAt",
676
+ "externalId",
677
+ "browserClip",
678
+ ]);
679
+ try {
680
+ const parsed = Bun.YAML.parse(lines.join("\n")) as { source?: unknown };
681
+ if (
682
+ parsed.source !== null &&
683
+ typeof parsed.source === "object" &&
684
+ !Array.isArray(parsed.source)
685
+ ) {
686
+ const keys = Object.keys(parsed.source);
687
+ if (keys.length === 0 || keys.some((key) => captureKeys.has(key))) {
688
+ return true;
689
+ }
690
+ }
691
+ } catch {
692
+ // Fall through to the declaration-preserving line parser below.
693
+ }
694
+ for (let index = 0; index < lines.length; index += 1) {
695
+ const line = lines[index];
696
+ if (line === undefined) continue;
697
+ const inlineSource = /^source\s*:\s*(\{.*\})\s*$/u.exec(line)?.[1];
698
+ if (inlineSource !== undefined) {
699
+ if (/^\{\s*\}$/u.test(inlineSource)) return true;
700
+ try {
701
+ const parsed = Bun.YAML.parse(`source: ${inlineSource}`) as {
702
+ source?: unknown;
703
+ };
704
+ if (
705
+ parsed.source !== null &&
706
+ typeof parsed.source === "object" &&
707
+ !Array.isArray(parsed.source) &&
708
+ Object.keys(parsed.source).some((key) => captureKeys.has(key))
709
+ ) {
710
+ return true;
711
+ }
712
+ } catch {
713
+ return true;
714
+ }
715
+ continue;
716
+ }
717
+ if (!/^source\s*:\s*$/u.test(line)) continue;
718
+ for (
719
+ let nestedIndex = index + 1;
720
+ nestedIndex < lines.length;
721
+ nestedIndex += 1
722
+ ) {
723
+ const nested = lines[nestedIndex];
724
+ if (!nested?.startsWith(" ")) break;
725
+ const colonIndex = nested.indexOf(":");
726
+ if (colonIndex <= 0) continue;
727
+ const nestedKey = nested
728
+ .slice(0, colonIndex)
729
+ .trim() as keyof CaptureSource;
730
+ if (captureKeys.has(nestedKey)) return true;
731
+ }
732
+ }
733
+ return false;
734
+ };
735
+
543
736
  function sourceFrontmatterLines(source: CaptureSource): string[] {
544
737
  const lines = ["source:"];
545
738
  for (const [key, value] of Object.entries(source)) {
@@ -12,10 +12,14 @@ export interface DocumentCapabilities {
12
12
  reason?: string;
13
13
  }
14
14
 
15
- const EDITABLE_EXTENSIONS = new Set([
15
+ export const MARKDOWN_SOURCE_EXTENSIONS: ReadonlySet<string> = new Set([
16
16
  ".md",
17
17
  ".markdown",
18
18
  ".mdx",
19
+ ]);
20
+
21
+ const EDITABLE_EXTENSIONS = new Set([
22
+ ...MARKDOWN_SOURCE_EXTENSIONS,
19
23
  ".txt",
20
24
  ".text",
21
25
  ]);
@@ -57,7 +61,7 @@ export function getDocumentCapabilities(input: {
57
61
  }
58
62
  const editable =
59
63
  EDITABLE_EXTENSIONS.has(ext) || isTextLikeMime(input.sourceMime);
60
- const tagsWriteback = ext === ".md" || ext === ".markdown" || ext === ".mdx";
64
+ const tagsWriteback = MARKDOWN_SOURCE_EXTENSIONS.has(ext);
61
65
 
62
66
  if (editable) {
63
67
  return {
@@ -89,12 +93,9 @@ export function deriveEditableCopyRelPath(
89
93
  const baseName = parsed.name || "copy";
90
94
  const existing = new Set(existingRelPaths);
91
95
 
92
- const baseCandidate =
93
- parsed.ext.toLowerCase() === ".md" ||
94
- parsed.ext.toLowerCase() === ".markdown" ||
95
- parsed.ext.toLowerCase() === ".mdx"
96
- ? `${prefix}${baseName}.copy.md`
97
- : `${prefix}${baseName}.md`;
96
+ const baseCandidate = MARKDOWN_SOURCE_EXTENSIONS.has(parsed.ext.toLowerCase())
97
+ ? `${prefix}${baseName}.copy.md`
98
+ : `${prefix}${baseName}.md`;
98
99
 
99
100
  if (!existing.has(baseCandidate)) {
100
101
  return baseCandidate;
@@ -21,6 +21,39 @@ interface RecordMetadataSource {
21
21
  recordAdapterFingerprint?: string | null;
22
22
  }
23
23
 
24
+ export const RECORD_PROVENANCE_REQUIRED_FIELDS = [
25
+ "recordKey",
26
+ "recordSourceLocator",
27
+ "converterId",
28
+ "converterVersion",
29
+ "recordAdapterFingerprint",
30
+ ] as const;
31
+
32
+ export interface RecordProvenanceIssue {
33
+ field: (typeof RECORD_PROVENANCE_REQUIRED_FIELDS)[number];
34
+ reason: "missing";
35
+ }
36
+
37
+ /** Generic converter identity alone does not declare a logical-record contract. */
38
+ export const hasDeclaredRecordProvenance = (
39
+ source: RecordMetadataSource
40
+ ): boolean =>
41
+ source.recordKey != null ||
42
+ source.recordSourceLocator != null ||
43
+ source.recordAdapterFingerprint != null ||
44
+ source.recordMetadata != null ||
45
+ source.recordAnchors != null;
46
+
47
+ /** Validate completeness only when logical-record provenance is declared. */
48
+ export const validateDeclaredRecordProvenance = (
49
+ source: RecordMetadataSource
50
+ ): RecordProvenanceIssue[] => {
51
+ if (!hasDeclaredRecordProvenance(source)) return [];
52
+ return RECORD_PROVENANCE_REQUIRED_FIELDS.filter(
53
+ (field) => !source[field]
54
+ ).map((field) => ({ field, reason: "missing" as const }));
55
+ };
56
+
24
57
  /** Project only bounded, collection-relative logical-record provenance. */
25
58
  export const projectRecordEvidenceMetadata = (
26
59
  source: RecordMetadataSource
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Identifies regions to exclude from link/tag extraction:
5
5
  * - YAML frontmatter
6
- * - Fenced code blocks
6
+ * - Fenced code blocks (CommonMark backtick and tilde fences)
7
7
  * - Inline code
8
8
  * - HTML comments
9
9
  *
@@ -39,15 +39,153 @@ export interface ExcludedRange {
39
39
  /** Frontmatter at start of file (YAML between --- delimiters) */
40
40
  const FRONTMATTER_REGEX = /^---\r?\n[\s\S]*?(?:\r?\n)?---(?:\r?\n|$)/;
41
41
 
42
- /** Fenced code blocks (``` with optional language) */
43
- const FENCED_CODE_REGEX = /^```[^\n]*\n[\s\S]*?^```/gm;
42
+ /** CommonMark fence opener: 0–3 spaces, then 3+ backticks or tildes + info. */
43
+ const FENCE_OPEN_REGEX = /^ {0,3}(`{3,}|~{3,})(.*)$/u;
44
44
 
45
- /** Inline code (backticks, non-greedy) */
46
- const INLINE_CODE_REGEX = /`[^`\n]+`/g;
45
+ /** CommonMark fence closer: matching character, length ≥ opener, trailing space/tabs only. */
46
+ const FENCE_CLOSE_REGEX = /^ {0,3}(`{3,}|~{3,})[\t ]*$/u;
47
47
 
48
48
  /** HTML comments */
49
49
  const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
50
50
 
51
+ interface OpenFence {
52
+ marker: "`" | "~";
53
+ length: number;
54
+ start: number;
55
+ }
56
+
57
+ /**
58
+ * Collect CommonMark fenced code ranges (backtick and tilde). A closer must
59
+ * use the same character and be at least as long as the opener; when omitted,
60
+ * CommonMark extends the fenced block through end of input.
61
+ */
62
+ const collectFencedCodeRanges = (markdown: string): ExcludedRange[] => {
63
+ const ranges: ExcludedRange[] = [];
64
+ let offset = 0;
65
+ let open: OpenFence | null = null;
66
+
67
+ while (offset <= markdown.length) {
68
+ const nextNl = markdown.indexOf("\n", offset);
69
+ const lineEnd = nextNl === -1 ? markdown.length : nextNl;
70
+ const rawLine = markdown.slice(offset, lineEnd);
71
+ const logical = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
72
+
73
+ if (open) {
74
+ const closeRun = FENCE_CLOSE_REGEX.exec(logical)?.[1];
75
+ if (
76
+ closeRun &&
77
+ closeRun[0] === open.marker &&
78
+ closeRun.length >= open.length
79
+ ) {
80
+ const end = nextNl === -1 ? markdown.length : nextNl + 1;
81
+ ranges.push({ start: open.start, end, kind: "fenced_code" });
82
+ open = null;
83
+ }
84
+ } else {
85
+ const openMatch = FENCE_OPEN_REGEX.exec(logical);
86
+ const run = openMatch?.[1];
87
+ const suffix = openMatch?.[2] ?? "";
88
+ // Backtick info strings cannot contain backticks (CommonMark).
89
+ if (run && !(run[0] === "`" && suffix.includes("`"))) {
90
+ open = {
91
+ marker: run[0] as OpenFence["marker"],
92
+ length: run.length,
93
+ start: offset,
94
+ };
95
+ }
96
+ }
97
+
98
+ if (nextNl === -1) break;
99
+ offset = nextNl + 1;
100
+ }
101
+
102
+ if (open) {
103
+ ranges.push({
104
+ start: open.start,
105
+ end: markdown.length,
106
+ kind: "fenced_code",
107
+ });
108
+ }
109
+
110
+ return ranges;
111
+ };
112
+
113
+ interface BacktickRun {
114
+ end: number;
115
+ length: number;
116
+ start: number;
117
+ }
118
+
119
+ /** CommonMark code spans close only on a backtick run of equal length. */
120
+ const collectInlineCodeRanges = (
121
+ markdown: string,
122
+ excludedRanges: ExcludedRange[]
123
+ ): ExcludedRange[] => {
124
+ const runs: BacktickRun[] = [];
125
+ let cursor = 0;
126
+ let excludedIndex = 0;
127
+ while (cursor < markdown.length) {
128
+ while (
129
+ excludedRanges[excludedIndex] &&
130
+ excludedRanges[excludedIndex]!.end <= cursor
131
+ ) {
132
+ excludedIndex += 1;
133
+ }
134
+ const excluded = excludedRanges[excludedIndex];
135
+ if (excluded && cursor >= excluded.start && cursor < excluded.end) {
136
+ cursor = excluded.end;
137
+ continue;
138
+ }
139
+ if (markdown[cursor] !== "`") {
140
+ cursor += 1;
141
+ continue;
142
+ }
143
+ const start = cursor;
144
+ while (markdown[cursor] === "`") cursor += 1;
145
+ let backslashes = 0;
146
+ for (let i = start - 1; i >= 0 && markdown[i] === "\\"; i -= 1) {
147
+ backslashes += 1;
148
+ }
149
+ if (backslashes % 2 === 0) {
150
+ runs.push({ start, end: cursor, length: cursor - start });
151
+ }
152
+ }
153
+
154
+ const nextMatchingRun = Array.from<number | undefined>({
155
+ length: runs.length,
156
+ });
157
+ const latestByLength = new Map<number, number>();
158
+ for (let index = runs.length - 1; index >= 0; index -= 1) {
159
+ const run = runs[index];
160
+ if (!run) continue;
161
+ nextMatchingRun[index] = latestByLength.get(run.length);
162
+ latestByLength.set(run.length, index);
163
+ }
164
+
165
+ const ranges: ExcludedRange[] = [];
166
+ let index = 0;
167
+ while (index < runs.length) {
168
+ const closeIndex = nextMatchingRun[index];
169
+ const opener = runs[index];
170
+ if (closeIndex === undefined || !opener) {
171
+ index += 1;
172
+ continue;
173
+ }
174
+ const closer = runs[closeIndex];
175
+ if (!closer) {
176
+ index += 1;
177
+ continue;
178
+ }
179
+ ranges.push({
180
+ start: opener.start,
181
+ end: closer.end,
182
+ kind: "inline_code",
183
+ });
184
+ index = closeIndex + 1;
185
+ }
186
+ return ranges;
187
+ };
188
+
51
189
  // ─────────────────────────────────────────────────────────────────────────────
52
190
  // Main Functions
53
191
  // ─────────────────────────────────────────────────────────────────────────────
@@ -70,29 +208,12 @@ export function getExcludedRanges(markdown: string): ExcludedRange[] {
70
208
  });
71
209
  }
72
210
 
73
- // 2. Fenced code blocks
74
- FENCED_CODE_REGEX.lastIndex = 0;
75
- let match: RegExpExecArray | null;
76
- while ((match = FENCED_CODE_REGEX.exec(markdown)) !== null) {
77
- ranges.push({
78
- start: match.index,
79
- end: match.index + match[0].length,
80
- kind: "fenced_code",
81
- });
82
- }
83
-
84
- // 3. Inline code
85
- INLINE_CODE_REGEX.lastIndex = 0;
86
- while ((match = INLINE_CODE_REGEX.exec(markdown)) !== null) {
87
- ranges.push({
88
- start: match.index,
89
- end: match.index + match[0].length,
90
- kind: "inline_code",
91
- });
92
- }
211
+ // 2. Fenced code blocks (backtick + tilde, CommonMark matching rules)
212
+ ranges.push(...collectFencedCodeRanges(markdown));
93
213
 
94
- // 4. HTML comments
214
+ // 3. HTML comments
95
215
  HTML_COMMENT_REGEX.lastIndex = 0;
216
+ let match: RegExpExecArray | null;
96
217
  while ((match = HTML_COMMENT_REGEX.exec(markdown)) !== null) {
97
218
  ranges.push({
98
219
  start: match.index,
@@ -101,6 +222,11 @@ export function getExcludedRanges(markdown: string): ExcludedRange[] {
101
222
  });
102
223
  }
103
224
 
225
+ // 4. Inline code. Pair delimiters only in visible prose so unmatched
226
+ // backticks inside already-excluded blocks cannot consume later content.
227
+ ranges.sort((a, b) => a.start - b.start);
228
+ ranges.push(...collectInlineCodeRanges(markdown, ranges));
229
+
104
230
  // Sort by start position for efficient lookup
105
231
  ranges.sort((a, b) => a.start - b.start);
106
232
 
@@ -17,6 +17,7 @@ import { evaluateEgressPolicy } from "../core/egress-policy";
17
17
  export const MCP_HTTP_EGRESS_TOOLS = {
18
18
  gno_add_collection: "metadata",
19
19
  gno_ask: "capsule",
20
+ gno_audit: "metadata",
20
21
  gno_backlinks: "metadata",
21
22
  gno_capture: "metadata",
22
23
  gno_changes: "metadata",
@@ -125,6 +126,13 @@ const requestedCollections = (
125
126
  const names = new Set<string>();
126
127
  const direct = args.collection;
127
128
  if (typeof direct === "string") names.add(direct.trim().toLowerCase());
129
+ if (record?.name === "gno_audit" && Array.isArray(args.collections)) {
130
+ for (const value of args.collections) {
131
+ if (typeof value !== "string") continue;
132
+ const normalized = value.trim().toLowerCase();
133
+ if (normalized) names.add(normalized);
134
+ }
135
+ }
128
136
  for (const key of ["ref", "target", "from", "to", "root", "uri"]) {
129
137
  const collection = collectionFromRef(args[key]);
130
138
  if (collection) names.add(collection);
@@ -0,0 +1,97 @@
1
+ /** MCP gno_audit read-only knowledge-integrity tool. */
2
+
3
+ import { z } from "zod";
4
+
5
+ import type { AuditCategory, AuditReport } from "../../core/audit";
6
+ import type { ToolContext } from "../server";
7
+
8
+ import { AUDIT_CATEGORIES } from "../../core/audit";
9
+ import { runWorkspaceAudit } from "../../core/audit-workspace";
10
+ import { normalizeTag, validateTag } from "../../core/tags";
11
+ import { normalizeCollectionName } from "../../core/validation";
12
+ import { runTool, type ToolResult } from "./index";
13
+
14
+ export const auditInputSchema = z
15
+ .object({
16
+ category: z
17
+ .enum(["links", "provenance", "freshness", "all"])
18
+ .default("all"),
19
+ collections: z.array(z.string().min(1)).max(256).default([]),
20
+ paths: z.array(z.string().min(1)).max(256).default([]),
21
+ tags: z.array(z.string().min(1)).max(256).default([]),
22
+ maxFindings: z.number().int().min(1).max(1000).default(100),
23
+ maxAgeDays: z.number().int().min(1).optional(),
24
+ orphanRoots: z.array(z.string().min(1)).max(256).default([]),
25
+ orphanIgnorePrefixes: z.array(z.string().min(1)).max(256).default([]),
26
+ })
27
+ .strict();
28
+
29
+ export type AuditMcpInput = z.infer<typeof auditInputSchema>;
30
+
31
+ export const AUDIT_MCP_ANNOTATIONS = {
32
+ readOnlyHint: true,
33
+ destructiveHint: false,
34
+ idempotentHint: true,
35
+ openWorldHint: false,
36
+ } as const;
37
+
38
+ const categoriesFor = (category: AuditMcpInput["category"]): AuditCategory[] =>
39
+ category === "all" ? [...AUDIT_CATEGORIES] : [category];
40
+
41
+ const formatAudit = (report: AuditReport): string =>
42
+ [
43
+ `Audit: ${report.status}`,
44
+ `Categories: ${report.scope.categories.join(", ")}`,
45
+ `Rules: ${report.counts.rules.total}`,
46
+ `Findings: ${report.counts.findings.total} (${report.counts.findings.returned} returned)`,
47
+ ...report.findings.map(
48
+ (finding) =>
49
+ `[${finding.severity}] ${finding.ruleId}: ${finding.subject}${finding.location ? ` ${finding.location}` : ""} — ${finding.message}`
50
+ ),
51
+ ].join("\n");
52
+
53
+ export const handleAudit = (
54
+ input: AuditMcpInput,
55
+ ctx: ToolContext,
56
+ signal?: AbortSignal
57
+ ): Promise<ToolResult> =>
58
+ runTool(
59
+ ctx,
60
+ "gno_audit",
61
+ async () => {
62
+ const normalizedCollections = input.collections.map(
63
+ normalizeCollectionName
64
+ );
65
+ const normalizedTags = input.tags.map(normalizeTag);
66
+ const invalidTag = normalizedTags.find((tag) => !validateTag(tag));
67
+ if (invalidTag) throw new Error(`Invalid tag: "${invalidTag}"`);
68
+ const missingCollection = normalizedCollections.find(
69
+ (name) =>
70
+ !ctx.collections.some((collection) => collection.name === name)
71
+ );
72
+ if (missingCollection) {
73
+ throw new Error(`Collection not found: ${missingCollection}`);
74
+ }
75
+ const result = await runWorkspaceAudit({
76
+ store: ctx.store,
77
+ config: ctx.config,
78
+ collections: ctx.collections,
79
+ indexName: ctx.indexName,
80
+ categories: categoriesFor(input.category),
81
+ collectionFilters: normalizedCollections,
82
+ pathFilters: input.paths,
83
+ tagFilters: normalizedTags,
84
+ maxFindings: input.maxFindings,
85
+ agePolicy:
86
+ input.maxAgeDays === undefined
87
+ ? undefined
88
+ : { maxAgeDays: input.maxAgeDays },
89
+ orphanRoots: input.orphanRoots,
90
+ orphanIgnorePrefixes: input.orphanIgnorePrefixes,
91
+ signal,
92
+ });
93
+ if (!result.ok) throw new Error(result.error);
94
+ return result.report;
95
+ },
96
+ formatAudit
97
+ );
@@ -20,6 +20,7 @@ import { RETRIEVAL_TRACE_METADATA } from "../../core/retrieval-trace-session";
20
20
  import { normalizeTag } from "../../core/tags";
21
21
  import { handleAddCollection } from "./add-collection";
22
22
  import { askInputSchema, handleAsk } from "./ask";
23
+ import { AUDIT_MCP_ANNOTATIONS, auditInputSchema, handleAudit } from "./audit";
23
24
  import { handleCapture } from "./capture";
24
25
  import {
25
26
  changesInputSchema,
@@ -130,6 +131,8 @@ export const MCP_TOOL_DESCRIPTIONS = {
130
131
  "Create or resolve a durable SectionTargetV1 against one indexed document. action=create needs ref plus exactly one of anchor|line; action=resolve needs ref plus target. Exact/recovered include citation (uri, anchor, title, inclusive lines, fingerprint); ambiguous/stale/missing omit citation and are not safe to navigate or cite. Read-only — does not write or persist targets. Follow navigable ranges with gno_get fromLine/lineCount.",
131
132
  status:
132
133
  "Get index health: collection count, document count, chunk count, embedding backlog, and per-collection stats. Check first when vector/hybrid results look stale or unavailable.",
134
+ audit:
135
+ "Run deterministic, offline, read-only integrity audits for links, declared provenance completeness, and source/index freshness. Returns stable bounded findings and explicit partial/unavailable states; never repairs or mutates the workspace.",
133
136
  context:
134
137
  "Compile one deterministic, budgeted, extractive evidence Capsule with exact line spans, coverage gaps, omissions, provenance, and verification fingerprints. Raw search/get tools remain available for manual retrieval.",
135
138
  contextVerify:
@@ -1090,6 +1093,16 @@ export function registerTools(server: McpServer, ctx: ToolContext): void {
1090
1093
  (args) => handleStatus(args, ctx)
1091
1094
  );
1092
1095
 
1096
+ server.registerTool(
1097
+ "gno_audit",
1098
+ {
1099
+ description: MCP_TOOL_DESCRIPTIONS.audit,
1100
+ inputSchema: auditInputSchema,
1101
+ annotations: AUDIT_MCP_ANNOTATIONS,
1102
+ },
1103
+ (args, extra) => handleAudit(args, ctx, extra.signal)
1104
+ );
1105
+
1093
1106
  server.registerTool(
1094
1107
  "gno_egress_policy_get",
1095
1108
  {