@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.
- package/README.md +17 -3
- package/assets/skill/SKILL.md +30 -0
- package/assets/skill/cli-reference.md +10 -2
- package/browser-extension/artifacts/{gno-browser-clipper-v1.32.0.zip → gno-browser-clipper-v1.34.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.34.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +5 -1
- package/spec/cli.md +60 -1
- package/spec/mcp.md +21 -0
- package/spec/output-schemas/audit-report.schema.json +284 -0
- package/spec/output-schemas/publish-artifact.schema.json +76 -1
- package/src/cli/commands/audit.ts +231 -0
- package/src/cli/commands/publish.ts +43 -7
- package/src/cli/errors.ts +9 -2
- package/src/cli/program.ts +112 -0
- package/src/core/audit-contract.ts +296 -0
- package/src/core/audit-freshness.ts +233 -0
- package/src/core/audit-links.ts +222 -0
- package/src/core/audit-provenance.ts +154 -0
- package/src/core/audit-report.ts +318 -0
- package/src/core/audit-workspace.ts +678 -0
- package/src/core/audit.ts +569 -0
- package/src/core/capture.ts +196 -3
- package/src/core/document-capabilities.ts +9 -8
- package/src/core/record-metadata.ts +33 -0
- package/src/ingestion/strip.ts +152 -26
- package/src/mcp/http-egress.ts +8 -0
- package/src/mcp/tools/audit.ts +97 -0
- package/src/mcp/tools/index.ts +13 -0
- package/src/publish/artifact-asset-codec.ts +75 -0
- package/src/publish/artifact-asset-contract.ts +152 -0
- package/src/publish/artifact-asset-parse.ts +401 -0
- package/src/publish/artifact-asset-sniff.ts +108 -0
- package/src/publish/artifact-asset-validate.ts +209 -0
- package/src/publish/artifact-assets.ts +58 -0
- package/src/publish/artifact-validation.ts +32 -6
- package/src/publish/artifact.ts +50 -3
- package/src/publish/attachment-bundle.ts +145 -0
- package/src/publish/attachment-discover.ts +203 -0
- package/src/publish/attachment-load.ts +133 -0
- package/src/publish/attachment-obsidian.ts +45 -0
- package/src/publish/attachment-path.ts +334 -0
- package/src/publish/attachment-raster.ts +852 -0
- package/src/publish/attachment-resolver.ts +280 -0
- package/src/publish/attachment-types.ts +54 -0
- package/src/publish/encrypted-export.ts +121 -44
- package/src/publish/export-attachments.ts +224 -0
- package/src/publish/export-service.ts +142 -80
- package/src/publish/obsidian-sanitize.ts +121 -13
- package/src/serve/routes/api.ts +2 -1
- package/src/store/sqlite/adapter.ts +82 -0
- package/src/store/sqlite/graph-link-bulk-resolver.ts +191 -0
- package/src/store/sqlite/graph-link-resolver.ts +241 -2
- package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +0 -1
package/src/core/capture.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
648
|
+
source.browserClip = {} as BrowserClipProvenance;
|
|
533
649
|
}
|
|
534
650
|
continue;
|
|
535
651
|
}
|
|
536
|
-
source[nestedKey] =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
94
|
-
|
|
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
|
package/src/ingestion/strip.ts
CHANGED
|
@@ -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
|
-
/**
|
|
43
|
-
const
|
|
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
|
-
/**
|
|
46
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
package/src/mcp/http-egress.ts
CHANGED
|
@@ -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
|
+
);
|
package/src/mcp/tools/index.ts
CHANGED
|
@@ -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
|
{
|