@gmickel/gno 2.7.1 → 2.8.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 (96) hide show
  1. package/README.md +3 -2
  2. package/assets/skill/SKILL.md +11 -1
  3. package/assets/skill/cli-reference.md +8 -1
  4. package/assets/skill/examples.md +2 -1
  5. package/assets/skill/mcp-reference.md +3 -1
  6. package/assets/skill/recipes/memory-scoped-recall.md +10 -5
  7. package/assets/spa-production.json.gz +0 -0
  8. package/browser-extension/artifacts/{gno-browser-clipper-v2.7.1.zip → gno-browser-clipper-v2.8.1.zip} +0 -0
  9. package/browser-extension/artifacts/gno-browser-clipper-v2.8.1.zip.sha256 +1 -0
  10. package/browser-extension/dist/manifest.json +1 -1
  11. package/package.json +1 -1
  12. package/spec/cli.md +86 -9
  13. package/spec/db/schema.sql +0 -1
  14. package/spec/mcp.md +26 -7
  15. package/spec/output-schemas/audit-report.schema.json +18 -4
  16. package/spec/output-schemas/backlinks.schema.json +4 -0
  17. package/spec/output-schemas/collection-list.schema.json +13 -0
  18. package/spec/output-schemas/graph.schema.json +2 -0
  19. package/spec/output-schemas/links-list.schema.json +4 -0
  20. package/spec/output-schemas/memory-recall.schema.json +1 -1
  21. package/spec/output-schemas/status.schema.json +18 -3
  22. package/src/cli/commands/audit.ts +23 -4
  23. package/src/cli/commands/collection/list.ts +39 -5
  24. package/src/cli/commands/embed.ts +3 -3
  25. package/src/cli/commands/graph.ts +3 -1
  26. package/src/cli/commands/links.ts +61 -180
  27. package/src/cli/commands/shared.ts +7 -0
  28. package/src/cli/commands/status.ts +6 -0
  29. package/src/cli/program.ts +12 -2
  30. package/src/config/loader.ts +43 -0
  31. package/src/config/types.ts +8 -0
  32. package/src/core/audit-contract.ts +16 -4
  33. package/src/core/audit-freshness.ts +11 -1
  34. package/src/core/audit-links.ts +197 -25
  35. package/src/core/audit-outside-index.ts +215 -0
  36. package/src/core/audit-provenance.ts +11 -4
  37. package/src/core/audit-workspace.ts +30 -9
  38. package/src/core/audit.ts +76 -16
  39. package/src/core/context-compiler.ts +3 -0
  40. package/src/core/context-evidence.ts +11 -0
  41. package/src/core/graph-edge-confidence.ts +23 -1
  42. package/src/core/host-paths.ts +1 -0
  43. package/src/core/knowledge-impact.ts +28 -0
  44. package/src/core/link-inventory-markdown.ts +2 -3
  45. package/src/core/link-workspace.ts +324 -0
  46. package/src/core/links.ts +40 -17
  47. package/src/core/memory-recall.ts +254 -15
  48. package/src/core/memory-types.ts +12 -0
  49. package/src/core/memory.ts +2 -0
  50. package/src/core/retrieval-replay-candidate.ts +6 -0
  51. package/src/core/retrieval-trace-request.ts +3 -0
  52. package/src/index.ts +14 -1
  53. package/src/ingestion/graph-reconciliation.ts +77 -15
  54. package/src/ingestion/source-availability/darwin-path.ts +9 -3
  55. package/src/ingestion/sync.ts +27 -4
  56. package/src/ingestion/types.ts +14 -0
  57. package/src/llm/inference-scope.ts +4 -3
  58. package/src/mcp/http-egress.ts +42 -3
  59. package/src/mcp/tools/audit.ts +11 -2
  60. package/src/mcp/tools/changes.ts +1 -0
  61. package/src/mcp/tools/links.ts +74 -93
  62. package/src/mcp/tools/sessions.ts +33 -4
  63. package/src/mcp/tools/status.ts +4 -0
  64. package/src/pipeline/expansion.ts +19 -31
  65. package/src/pipeline/graph-retrieval.ts +22 -2
  66. package/src/pipeline/hybrid.ts +1 -1
  67. package/src/pipeline/search.ts +2 -0
  68. package/src/pipeline/types.ts +10 -3
  69. package/src/sdk/client.ts +1 -0
  70. package/src/serve/findings-pass.ts +1 -1
  71. package/src/serve/public/components/editor/MarkdownPreview.tsx +5 -3
  72. package/src/serve/public/pages/GraphView.tsx +2 -0
  73. package/src/serve/routes/changes.ts +6 -1
  74. package/src/serve/routes/graph.ts +3 -1
  75. package/src/serve/routes/links.ts +45 -50
  76. package/src/serve/routes/sessions.ts +41 -53
  77. package/src/serve/server.ts +2 -1
  78. package/src/serve/status.ts +1 -0
  79. package/src/sessions/config-refresh.ts +111 -0
  80. package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
  81. package/src/store/migrations/034-collection-link-workspace.ts +47 -0
  82. package/src/store/migrations/index.ts +4 -0
  83. package/src/store/sqlite/adapter.ts +477 -337
  84. package/src/store/sqlite/eligibility.ts +8 -2
  85. package/src/store/sqlite/graph-link-resolver.ts +259 -5
  86. package/src/store/sqlite/graph-neighbors.ts +147 -40
  87. package/src/store/sqlite/graph-reference-state.ts +13 -2
  88. package/src/store/sqlite/graph-similarity.ts +96 -0
  89. package/src/store/sqlite/workspace-link-resolver.ts +742 -0
  90. package/src/store/types.ts +64 -5
  91. package/src/store/vector/stats.ts +1 -1
  92. package/src/store/vector/status.ts +27 -0
  93. package/src/store/vector/stored-vectors.ts +158 -0
  94. package/src/store/vector/types.ts +6 -0
  95. package/src/store/vector/variant-search.ts +30 -14
  96. package/browser-extension/artifacts/gno-browser-clipper-v2.7.1.zip.sha256 +0 -1
@@ -11,8 +11,11 @@ export const AUDIT_RULE_SET_VERSION = "1.0" as const;
11
11
 
12
12
  /** Default returned finding cap; totals remain exact when truncated. */
13
13
  export const AUDIT_DEFAULT_MAX_FINDINGS = 100;
14
- /** Hard upper bound for `--max-findings` / MCP maxFindings. */
15
- export const AUDIT_MAX_FINDINGS_LIMIT = 1000;
14
+ /** Hard numeric upper bound for `--max-findings` / MCP maxFindings. */
15
+ export const AUDIT_MAX_FINDINGS_LIMIT = 100_000;
16
+ /** Returns every finding of the bounded audit snapshot. */
17
+ export const AUDIT_MAX_FINDINGS_ALL = "all" as const;
18
+ export type AuditMaxFindings = number | typeof AUDIT_MAX_FINDINGS_ALL;
16
19
  export const AUDIT_MAX_EVIDENCE_PER_FINDING = 8;
17
20
  export const AUDIT_MAX_GUIDANCE_PER_FINDING = 4;
18
21
  export const AUDIT_MAX_EVIDENCE_DETAIL_CHARS = 512;
@@ -201,7 +204,14 @@ export interface AuditCounts {
201
204
 
202
205
  export interface AuditTruncation {
203
206
  findingsTruncated: boolean;
204
- maxFindings: number;
207
+ maxFindings: AuditMaxFindings;
208
+ /**
209
+ * The bounded audit snapshot (documents or links) was cut; finding totals
210
+ * cover the snapshot only and are not complete-index totals.
211
+ */
212
+ snapshotTruncated: boolean;
213
+ /** Evidence items, candidate lists or evidence detail text were clipped. */
214
+ evidenceTruncated: boolean;
205
215
  }
206
216
 
207
217
  export interface AuditTiming {
@@ -255,6 +265,8 @@ export interface AuditRuleContribution {
255
265
  findings?: AuditFindingDraft[];
256
266
  /** Exact findings before any evaluator-side payload cap. */
257
267
  findingCount?: number;
268
+ /** Evaluator clipped evidence (for example a long tied-candidate list). */
269
+ evidenceTruncated?: boolean;
258
270
  examinedCount?: number;
259
271
  durationMs?: number;
260
272
  skipReason?: string | null;
@@ -283,7 +295,7 @@ export interface AuditRunInput {
283
295
  capabilities: AuditCapabilitySnapshot;
284
296
  captureFingerprints: AuditFingerprintCapture;
285
297
  rules: readonly AuditRuleEvaluator[];
286
- maxFindings?: number;
298
+ maxFindings?: AuditMaxFindings;
287
299
  maxAttempts?: number;
288
300
  gnoVersion?: string;
289
301
  clock?: () => Date;
@@ -33,6 +33,8 @@ export interface AuditFreshnessOptions {
33
33
  now: Date;
34
34
  agePolicy?: AuditAgePolicy;
35
35
  truncated?: boolean;
36
+ /** Effective per-rule cap; defaults to FRESHNESS_AUDIT_MAX_FINDINGS_PER_RULE. */
37
+ maxFindingsPerRule?: number;
36
38
  }
37
39
 
38
40
  const finding = (input: {
@@ -68,6 +70,7 @@ const rule = (input: {
68
70
  skipped?: boolean;
69
71
  message: string;
70
72
  reason?: string;
73
+ maxFindingsPerRule?: number;
71
74
  }): AuditRuleContribution => ({
72
75
  ruleId: input.ruleId,
73
76
  category: "freshness",
@@ -83,7 +86,10 @@ const rule = (input: {
83
86
  message: input.message,
84
87
  findings: [...input.findings]
85
88
  .sort(compareAuditFindingDrafts)
86
- .slice(0, FRESHNESS_AUDIT_MAX_FINDINGS_PER_RULE),
89
+ .slice(
90
+ 0,
91
+ input.maxFindingsPerRule ?? FRESHNESS_AUDIT_MAX_FINDINGS_PER_RULE
92
+ ),
87
93
  findingCount: input.findings.length,
88
94
  examinedCount: input.examinedCount,
89
95
  skipReason: input.reason ?? null,
@@ -186,6 +192,7 @@ export const evaluateFreshnessAudit = (
186
192
  : undefined;
187
193
  return [
188
194
  rule({
195
+ maxFindingsPerRule: options.maxFindingsPerRule,
189
196
  ruleId: "freshness.source-readable",
190
197
  findings: unavailable,
191
198
  examinedCount: documents.length,
@@ -197,6 +204,7 @@ export const evaluateFreshnessAudit = (
197
204
  reason,
198
205
  }),
199
206
  rule({
207
+ maxFindingsPerRule: options.maxFindingsPerRule,
200
208
  ruleId: "freshness.source-index-drift",
201
209
  findings: drift,
202
210
  examinedCount: byteComparableCount,
@@ -207,6 +215,7 @@ export const evaluateFreshnessAudit = (
207
215
  reason,
208
216
  }),
209
217
  rule({
218
+ maxFindingsPerRule: options.maxFindingsPerRule,
210
219
  ruleId: "freshness.index-revision",
211
220
  findings: staleRevision,
212
221
  examinedCount: documents.length,
@@ -217,6 +226,7 @@ export const evaluateFreshnessAudit = (
217
226
  reason,
218
227
  }),
219
228
  rule({
229
+ maxFindingsPerRule: options.maxFindingsPerRule,
220
230
  ruleId: "freshness.configured-age-signal",
221
231
  findings: ageSignals,
222
232
  examinedCount: documents.length,
@@ -6,9 +6,19 @@ import type {
6
6
  } from "../store/sqlite/graph-link-resolver";
7
7
  import type { AuditFindingDraft, AuditRuleContribution } from "./audit";
8
8
 
9
- import { compareAuditCodeUnits, compareAuditFindingDrafts } from "./audit";
9
+ import { isTraversableResolution } from "../store/sqlite/graph-link-resolver";
10
+ import {
11
+ AUDIT_MAX_EVIDENCE_DETAIL_CHARS,
12
+ compareAuditCodeUnits,
13
+ compareAuditFindingDrafts,
14
+ } from "./audit";
10
15
 
11
- export const LINK_AUDIT_RULE_VERSION = "1.0" as const;
16
+ /**
17
+ * 1.1: workspace-wide resolution and reference/resolution/scope evidence.
18
+ * 1.2: `links.outside-index` for workspace files that are not indexed.
19
+ */
20
+ export const LINK_AUDIT_RULE_VERSION = "1.2" as const;
21
+ /** Default per-rule cap; runs pass the effective `maxFindings` instead. */
12
22
  export const LINK_AUDIT_MAX_FINDINGS_PER_RULE = 1000;
13
23
 
14
24
  export interface AuditOrphanPolicy {
@@ -16,23 +26,148 @@ export interface AuditOrphanPolicy {
16
26
  ignorePathPrefixes: readonly string[];
17
27
  /** Mirrored duplicate rows are excluded from orphan claims by default. */
18
28
  ignoreMirrorDuplicates?: boolean;
29
+ /** Effective per-rule cap; defaults to LINK_AUDIT_MAX_FINDINGS_PER_RULE. */
30
+ maxFindingsPerRule?: number;
19
31
  }
20
32
 
21
33
  const boundedFindings = (
22
- findings: readonly AuditFindingDraft[]
34
+ findings: readonly AuditFindingDraft[],
35
+ maxFindingsPerRule: number
23
36
  ): AuditFindingDraft[] =>
24
- [...findings]
25
- .sort(compareAuditFindingDrafts)
26
- .slice(0, LINK_AUDIT_MAX_FINDINGS_PER_RULE);
37
+ [...findings].sort(compareAuditFindingDrafts).slice(0, maxFindingsPerRule);
27
38
 
28
39
  const lineLocation = (line: number, column: number): string =>
29
40
  `L${line}:C${column}`;
30
41
 
31
- const linkFinding = (
42
+ type LinkReferenceKind =
43
+ | "wiki-name"
44
+ | "wiki-path"
45
+ | "explicit-collection"
46
+ | "markdown";
47
+
48
+ const referenceKind = (
32
49
  link: AuditLinkSnapshot["links"][number]
50
+ ): LinkReferenceKind =>
51
+ link.linkType === "markdown"
52
+ ? "markdown"
53
+ : link.explicitCollection === true
54
+ ? "explicit-collection"
55
+ : link.targetRef.includes("/")
56
+ ? "wiki-path"
57
+ : "wiki-name";
58
+
59
+ interface CandidateEvidence {
60
+ /** Canonical-path-ordered tied candidates visible in the audit scope. */
61
+ uris: string[];
62
+ /** Candidates outside the requested collection scope (identity withheld). */
63
+ withheld: number;
64
+ truncated: boolean;
65
+ }
66
+
67
+ const linkFinding = (
68
+ link: AuditLinkSnapshot["links"][number],
69
+ context: {
70
+ documents: ReadonlyMap<number, AuditLinkSnapshotDocument>;
71
+ scope: ReadonlySet<string> | null;
72
+ },
73
+ onEvidenceTruncated: () => void
33
74
  ): AuditFindingDraft => {
34
75
  const ambiguous = (link.resolved?.matchCount ?? 0) > 1;
76
+ const outsideIndex = !ambiguous && link.outsideIndex === true;
35
77
  const target = `${link.targetCollection}:${link.targetRef}`;
78
+ const kind = referenceKind(link);
79
+ const tied = (link.resolved?.candidates ?? []).map((candidate) =>
80
+ context.documents.get(candidate.id)
81
+ );
82
+ const visible = tied.filter(
83
+ (document): document is AuditLinkSnapshotDocument =>
84
+ document !== undefined &&
85
+ (context.scope === null || context.scope.has(document.collection))
86
+ );
87
+ // Scope describes every tied candidate; withheld ones are only counted.
88
+ const resolvedScope = !ambiguous
89
+ ? null
90
+ : link.explicitCollection === true
91
+ ? "explicit-collection"
92
+ : tied.some(
93
+ (document) =>
94
+ document !== undefined &&
95
+ document.collection !== link.sourceCollection
96
+ )
97
+ ? "cross-collection"
98
+ : "same-collection";
99
+ const base = {
100
+ anchor: link.targetAnchor,
101
+ endColumn: link.endCol,
102
+ endLine: link.endLine,
103
+ linkType: link.linkType,
104
+ matchCount: link.resolved?.matchCount ?? 0,
105
+ matchRank: link.resolved?.matchRank ?? null,
106
+ normalizedTarget: link.targetRefNorm,
107
+ referenceKind: kind,
108
+ resolutionStatus: ambiguous
109
+ ? "ambiguous"
110
+ : outsideIndex
111
+ ? "outside-index"
112
+ : "unresolved",
113
+ resolvedScope,
114
+ };
115
+ // Candidate URIs are added while the detail stays within the evidence
116
+ // bound, so a long tie is reported as truncated rather than silently cut.
117
+ const candidates: CandidateEvidence = {
118
+ uris: [],
119
+ withheld: tied.length - visible.length,
120
+ truncated: false,
121
+ };
122
+ const render = (): string =>
123
+ JSON.stringify(
124
+ tied.length > 0
125
+ ? {
126
+ ...base,
127
+ candidateCount: tied.length,
128
+ candidates: candidates.uris,
129
+ ...(candidates.withheld > 0
130
+ ? { candidatesWithheld: candidates.withheld }
131
+ : {}),
132
+ ...(candidates.truncated ? { candidatesTruncated: true } : {}),
133
+ }
134
+ : base
135
+ );
136
+ for (const document of visible) {
137
+ candidates.uris.push(document.uri);
138
+ if (Array.from(render()).length > AUDIT_MAX_EVIDENCE_DETAIL_CHARS) {
139
+ candidates.uris.pop();
140
+ candidates.truncated = true;
141
+ break;
142
+ }
143
+ }
144
+ if (
145
+ candidates.truncated &&
146
+ Array.from(render()).length > AUDIT_MAX_EVIDENCE_DETAIL_CHARS
147
+ ) {
148
+ candidates.uris.pop();
149
+ }
150
+ if (candidates.truncated) onEvidenceTruncated();
151
+ if (outsideIndex) {
152
+ return {
153
+ subject: link.sourceUri,
154
+ location: lineLocation(link.startLine, link.startCol),
155
+ severity: "info",
156
+ message: `Link target exists outside the index: ${target}`,
157
+ evidence: [
158
+ {
159
+ kind: "outside-index-target",
160
+ summary: target,
161
+ uri: link.sourceUri,
162
+ path: link.sourceRelPath,
163
+ detail: render(),
164
+ },
165
+ ],
166
+ guidance: [
167
+ "No action needed; index the target's folder to add it to the link graph",
168
+ ],
169
+ };
170
+ }
36
171
  return {
37
172
  subject: link.sourceUri,
38
173
  location: lineLocation(link.startLine, link.startCol),
@@ -46,19 +181,15 @@ const linkFinding = (
46
181
  summary: target,
47
182
  uri: link.sourceUri,
48
183
  path: link.sourceRelPath,
49
- detail: JSON.stringify({
50
- anchor: link.targetAnchor,
51
- endColumn: link.endCol,
52
- endLine: link.endLine,
53
- linkType: link.linkType,
54
- matchCount: link.resolved?.matchCount ?? 0,
55
- matchRank: link.resolved?.matchRank ?? null,
56
- normalizedTarget: link.targetRefNorm,
57
- }),
184
+ detail: render(),
58
185
  },
59
186
  ],
60
187
  guidance: ambiguous
61
- ? ["Use an explicit collection-relative target path"]
188
+ ? [
189
+ tied.length > 0
190
+ ? "Use a longer path such as [[Folder/Note]] to pick one target"
191
+ : "Use an explicit collection-relative target path",
192
+ ]
62
193
  : ["Create the target or correct the local link"],
63
194
  };
64
195
  };
@@ -107,25 +238,46 @@ export const evaluateLinkAudit = (
107
238
  policy: AuditOrphanPolicy
108
239
  ): AuditRuleContribution[] => {
109
240
  const unresolved: AuditFindingDraft[] = [];
241
+ const outsideIndex: AuditFindingDraft[] = [];
110
242
  const ambiguous: AuditFindingDraft[] = [];
111
243
  const connected = new Set<number>();
112
244
  const auditedDocumentIds = new Set(
113
245
  snapshot.auditedDocumentIds ?? snapshot.documents.map(({ id }) => id)
114
246
  );
247
+ let evidenceTruncated = false;
248
+ const findingContext = {
249
+ documents: new Map(snapshot.documents.map((doc) => [doc.id, doc])),
250
+ scope: snapshot.scopeCollections
251
+ ? new Set(snapshot.scopeCollections)
252
+ : null,
253
+ };
254
+ const finding = (link: AuditLinkSnapshot["links"][number]) =>
255
+ linkFinding(link, findingContext, () => {
256
+ evidenceTruncated = true;
257
+ });
115
258
  for (const link of snapshot.links) {
116
259
  const sourceAudited = auditedDocumentIds.has(link.sourceId);
117
260
  if (!link.resolved) {
118
- if (sourceAudited) unresolved.push(linkFinding(link));
261
+ if (sourceAudited) {
262
+ (link.outsideIndex === true ? outsideIndex : unresolved).push(
263
+ finding(link)
264
+ );
265
+ }
119
266
  continue;
120
267
  }
121
- if (sourceAudited) connected.add(link.sourceId);
122
- if (auditedDocumentIds.has(link.resolved.targetId)) {
123
- connected.add(link.resolved.targetId);
268
+ // A tied workspace link is not a resolved link: it connects nothing.
269
+ if (isTraversableResolution(link.resolved)) {
270
+ if (sourceAudited) connected.add(link.sourceId);
271
+ if (auditedDocumentIds.has(link.resolved.targetId)) {
272
+ connected.add(link.resolved.targetId);
273
+ }
124
274
  }
125
275
  if (sourceAudited && link.resolved.matchCount > 1) {
126
- ambiguous.push(linkFinding(link));
276
+ ambiguous.push(finding(link));
127
277
  }
128
278
  }
279
+ const maxFindingsPerRule =
280
+ policy.maxFindingsPerRule ?? LINK_AUDIT_MAX_FINDINGS_PER_RULE;
129
281
  const roots = new Set(normalizedPrefixes(policy.rootUris));
130
282
  const ignorePrefixes = normalizedPrefixes(policy.ignorePathPrefixes);
131
283
  const mirroredIds = duplicateMirrorIds(
@@ -179,7 +331,7 @@ export const evaluateLinkAudit = (
179
331
  message: partial
180
332
  ? "Local target scan was truncated"
181
333
  : `${unresolved.length} unresolved or broken local links`,
182
- findings: boundedFindings(unresolved),
334
+ findings: boundedFindings(unresolved, maxFindingsPerRule),
183
335
  findingCount: unresolved.length,
184
336
  skipReason: partial ? "snapshot_truncated" : null,
185
337
  },
@@ -187,11 +339,12 @@ export const evaluateLinkAudit = (
187
339
  ...common,
188
340
  ruleId: "links.ambiguous-targets",
189
341
  category: "links",
342
+ evidenceTruncated,
190
343
  status: statusFor(ambiguous),
191
344
  message: partial
192
345
  ? "Ambiguous target scan was truncated"
193
346
  : `${ambiguous.length} ambiguous local links`,
194
- findings: boundedFindings(ambiguous),
347
+ findings: boundedFindings(ambiguous, maxFindingsPerRule),
195
348
  findingCount: ambiguous.length,
196
349
  skipReason: partial ? "snapshot_truncated" : null,
197
350
  },
@@ -203,10 +356,29 @@ export const evaluateLinkAudit = (
203
356
  message: partial
204
357
  ? "Orphan scan was truncated"
205
358
  : `${orphanFindings.length} policy-defined orphan documents`,
206
- findings: boundedFindings(orphanFindings),
359
+ findings: boundedFindings(orphanFindings, maxFindingsPerRule),
207
360
  findingCount: orphanFindings.length,
208
361
  skipReason: partial ? "snapshot_truncated" : null,
209
362
  },
363
+ {
364
+ // Informational: Obsidian resolves these links, so they never fail
365
+ // the audit. Info findings keep the rule passing.
366
+ ...common,
367
+ ruleId: "links.outside-index",
368
+ category: "links",
369
+ status: partial ? ("inconclusive" as const) : ("pass" as const),
370
+ message: [
371
+ partial
372
+ ? "Outside-index scan was truncated"
373
+ : `${outsideIndex.length} links point to existing files outside the index`,
374
+ ...(snapshot.outsideIndexDiagnostic
375
+ ? [snapshot.outsideIndexDiagnostic]
376
+ : []),
377
+ ].join("; "),
378
+ findings: boundedFindings(outsideIndex, maxFindingsPerRule),
379
+ findingCount: outsideIndex.length,
380
+ skipReason: partial ? "snapshot_truncated" : null,
381
+ },
210
382
  {
211
383
  ...common,
212
384
  ruleId: "links.parser-boundary",
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Existence-only classification of unresolved workspace wiki links whose
3
+ * target is a file inside the link workspace that is not an indexed document
4
+ * (an attachment, or a note in an unindexed or excluded folder). Obsidian
5
+ * resolves such links, so the link audit reports them as `outside-index`
6
+ * instead of unresolved. Only file names are listed: no file is opened,
7
+ * indexed, or returned, and no graph edge is created.
8
+ *
9
+ * @module src/core/audit-outside-index
10
+ */
11
+
12
+ import type { Database } from "bun:sqlite";
13
+
14
+ // node:fs/promises readdir/realpath/stat: directory enumeration and symlink
15
+ // resolution; Bun has no equivalent that can skip an unreadable folder
16
+ // instead of failing the scan.
17
+ import { readdir, realpath, stat } from "node:fs/promises";
18
+ // node:path join: platform path algebra; no Bun equivalent.
19
+ import { join } from "node:path";
20
+
21
+ import type { AuditLinkSnapshot } from "../store/sqlite/graph-link-resolver";
22
+
23
+ import {
24
+ createWorkspaceFileMatcher,
25
+ loadLinkWorkspaceMemberships,
26
+ } from "../store/sqlite/workspace-link-resolver";
27
+ import { pathContains, placeDocument } from "./link-workspace";
28
+
29
+ /** Upper bound of files listed per workspace; beyond it the listing is partial. */
30
+ export const WORKSPACE_FILE_LISTING_MAX_FILES = 200_000;
31
+
32
+ export interface WorkspaceFileListing {
33
+ /** Workspace-relative POSIX paths (NFC) of every non-hidden file. */
34
+ files: string[];
35
+ /** False when a folder could not be read or the file bound was reached. */
36
+ complete: boolean;
37
+ }
38
+
39
+ interface WorkspaceDirectoryEntry {
40
+ name: string;
41
+ isFile(): boolean;
42
+ isDirectory(): boolean;
43
+ isSymbolicLink(): boolean;
44
+ }
45
+
46
+ /** Filesystem reads the listing needs; injectable for tests. */
47
+ export interface WorkspaceFileSystem {
48
+ readDirectory(path: string): Promise<WorkspaceDirectoryEntry[]>;
49
+ realPath(path: string): Promise<string>;
50
+ isRegularFile(path: string): Promise<boolean>;
51
+ }
52
+
53
+ const nodeWorkspaceFileSystem: WorkspaceFileSystem = {
54
+ readDirectory: (path) => readdir(path, { withFileTypes: true }),
55
+ realPath: (path) => realpath(path),
56
+ isRegularFile: async (path) => (await stat(path)).isFile(),
57
+ };
58
+
59
+ /** A symlink target that does not exist (or loops) is not a file, not an error. */
60
+ const MISSING_TARGET_CODES = new Set(["ENOENT", "ENOTDIR", "ELOOP"]);
61
+
62
+ /**
63
+ * A symlink counts as a file only when it resolves to an existing regular
64
+ * file inside the workspace. Dangling links, links to folders and links that
65
+ * leave the workspace are not files. Other errors are reported as such.
66
+ */
67
+ const symlinkIsWorkspaceFile = async (
68
+ fs: WorkspaceFileSystem,
69
+ realRoot: string,
70
+ path: string
71
+ ): Promise<"file" | "not-file" | "error"> => {
72
+ try {
73
+ const target = await fs.realPath(path);
74
+ if (!pathContains(realRoot, target)) return "not-file";
75
+ return (await fs.isRegularFile(target)) ? "file" : "not-file";
76
+ } catch (cause) {
77
+ const code = (cause as NodeJS.ErrnoException | undefined)?.code;
78
+ return code !== undefined && MISSING_TARGET_CODES.has(code)
79
+ ? "not-file"
80
+ : "error";
81
+ }
82
+ };
83
+
84
+ /**
85
+ * List every file below a workspace root once. Hidden files and folders
86
+ * (`.obsidian`, `.trash`, `.git`) are skipped, as Obsidian does; symlinked
87
+ * folders are not descended. Filesystem calls use the names as stored on
88
+ * disk; only the returned paths are NFC-normalized for matching. An
89
+ * unreadable folder is skipped and marks the listing incomplete, so links
90
+ * into it stay unresolved.
91
+ */
92
+ export const listWorkspaceFiles = async (
93
+ root: string,
94
+ options: {
95
+ maxFiles?: number;
96
+ signal?: AbortSignal;
97
+ fileSystem?: WorkspaceFileSystem;
98
+ } = {}
99
+ ): Promise<WorkspaceFileListing> => {
100
+ const maxFiles = options.maxFiles ?? WORKSPACE_FILE_LISTING_MAX_FILES;
101
+ const fs = options.fileSystem ?? nodeWorkspaceFileSystem;
102
+ const files: string[] = [];
103
+ let complete = true;
104
+ let realRoot: string;
105
+ try {
106
+ realRoot = await fs.realPath(root);
107
+ } catch {
108
+ return { files, complete: false };
109
+ }
110
+ // Each folder keeps its on-disk segments; matching uses the NFC path.
111
+ const queue: string[][] = [[]];
112
+ for (let head = 0; head < queue.length; head += 1) {
113
+ options.signal?.throwIfAborted();
114
+ const segments = queue[head] as string[];
115
+ let entries;
116
+ try {
117
+ entries = await fs.readDirectory(join(root, ...segments));
118
+ } catch {
119
+ complete = false;
120
+ continue;
121
+ }
122
+ for (const entry of entries) {
123
+ if (entry.name.startsWith(".")) continue;
124
+ const entrySegments = [...segments, entry.name];
125
+ let isFile = entry.isFile();
126
+ if (entry.isDirectory()) {
127
+ queue.push(entrySegments);
128
+ continue;
129
+ }
130
+ if (entry.isSymbolicLink()) {
131
+ const kind = await symlinkIsWorkspaceFile(
132
+ fs,
133
+ realRoot,
134
+ join(root, ...entrySegments)
135
+ );
136
+ if (kind === "error") complete = false;
137
+ isFile = kind === "file";
138
+ }
139
+ if (!isFile) continue;
140
+ if (files.length >= maxFiles) return { files, complete: false };
141
+ files.push(entrySegments.join("/").normalize("NFC"));
142
+ }
143
+ }
144
+ return { files, complete };
145
+ };
146
+
147
+ /**
148
+ * Mark unresolved plain wiki links of audited documents inside a link
149
+ * workspace whose target exists as a workspace file. Each involved workspace
150
+ * is listed once per call. A failed or partial listing never marks a link
151
+ * that is not in it; the snapshot then carries one diagnostic.
152
+ */
153
+ export const markOutsideIndexLinks = async (
154
+ db: Database,
155
+ snapshot: AuditLinkSnapshot,
156
+ options: { maxFiles?: number; signal?: AbortSignal } = {}
157
+ ): Promise<AuditLinkSnapshot> => {
158
+ const audited = snapshot.auditedDocumentIds
159
+ ? new Set(snapshot.auditedDocumentIds)
160
+ : null;
161
+ const candidates = snapshot.links
162
+ .map((link, index) => ({ link, index }))
163
+ .filter(
164
+ ({ link }) =>
165
+ link.resolved === null &&
166
+ link.linkType === "wiki" &&
167
+ link.explicitCollection !== true &&
168
+ (audited === null || audited.has(link.sourceId))
169
+ );
170
+ if (candidates.length === 0) return snapshot;
171
+ const memberships = loadLinkWorkspaceMemberships(db);
172
+ const documentRelPaths = new Map(
173
+ snapshot.documents.map((document) => [document.id, document.relPath])
174
+ );
175
+ const placed = candidates.flatMap(({ link, index }) => {
176
+ const placement = placeDocument(
177
+ memberships.get(link.sourceCollection),
178
+ documentRelPaths.get(link.sourceId) ?? link.sourceRelPath
179
+ );
180
+ return placement.key === null
181
+ ? []
182
+ : [{ link, index, key: placement.key, sourcePath: placement.path }];
183
+ });
184
+ if (placed.length === 0) return snapshot;
185
+ const matchers = new Map<
186
+ string,
187
+ ReturnType<typeof createWorkspaceFileMatcher>
188
+ >();
189
+ const maxFiles = options.maxFiles ?? WORKSPACE_FILE_LISTING_MAX_FILES;
190
+ let incomplete = 0;
191
+ for (const key of new Set(placed.map((entry) => entry.key))) {
192
+ const listing = await listWorkspaceFiles(key, {
193
+ maxFiles,
194
+ signal: options.signal,
195
+ });
196
+ if (!listing.complete) incomplete += 1;
197
+ matchers.set(key, createWorkspaceFileMatcher(listing.files));
198
+ }
199
+ const links = [...snapshot.links];
200
+ for (const entry of placed) {
201
+ const matches = matchers.get(entry.key);
202
+ if (matches?.(entry.link.targetRefNorm, entry.sourcePath)) {
203
+ links[entry.index] = { ...entry.link, outsideIndex: true };
204
+ }
205
+ }
206
+ return {
207
+ ...snapshot,
208
+ links,
209
+ ...(incomplete > 0
210
+ ? {
211
+ outsideIndexDiagnostic: `The file listing of ${incomplete} link workspace${incomplete === 1 ? " was" : "s were"} incomplete (an unreadable folder or link, or more than ${maxFiles} files); links to files it missed stay unresolved`,
212
+ }
213
+ : {}),
214
+ };
215
+ };
@@ -11,6 +11,7 @@ import {
11
11
  validateDeclaredRecordProvenance,
12
12
  } from "./record-metadata";
13
13
 
14
+ /** Default per-rule cap; runs pass the effective `maxFindings` instead. */
14
15
  export const PROVENANCE_AUDIT_MAX_FINDINGS_PER_RULE = 1000;
15
16
 
16
17
  export interface AuditProvenanceDocument {
@@ -87,7 +88,7 @@ const memoryFinding = (
87
88
  */
88
89
  export const evaluateMemoryRecordAudit = (
89
90
  documents: readonly AuditProvenanceDocument[],
90
- options: { truncated?: boolean } = {}
91
+ options: { truncated?: boolean; maxFindingsPerRule?: number } = {}
91
92
  ): AuditRuleContribution => {
92
93
  const findings: AuditFindingDraft[] = [];
93
94
  let managedDocuments = 0;
@@ -129,7 +130,10 @@ export const evaluateMemoryRecordAudit = (
129
130
  : managedDocuments === 0
130
131
  ? "No memory-managed collections in scope"
131
132
  : `${managedDocuments} managed memory record(s) satisfy the contract`,
132
- findings: findings.slice(0, PROVENANCE_AUDIT_MAX_FINDINGS_PER_RULE),
133
+ findings: findings.slice(
134
+ 0,
135
+ options.maxFindingsPerRule ?? PROVENANCE_AUDIT_MAX_FINDINGS_PER_RULE
136
+ ),
133
137
  findingCount: findings.length,
134
138
  examinedCount: managedDocuments,
135
139
  skipReason: truncated
@@ -145,7 +149,7 @@ export const evaluateMemoryRecordAudit = (
145
149
  /** Missing provenance is completeness evidence, never a truth judgment. */
146
150
  export const evaluateProvenanceAudit = (
147
151
  documents: readonly AuditProvenanceDocument[],
148
- options: { truncated?: boolean } = {}
152
+ options: { truncated?: boolean; maxFindingsPerRule?: number } = {}
149
153
  ): AuditRuleContribution[] => {
150
154
  const captureFindings: AuditFindingDraft[] = [];
151
155
  const recordFindings: AuditFindingDraft[] = [];
@@ -217,7 +221,10 @@ export const evaluateProvenanceAudit = (
217
221
  : `${findings.length} declared provenance completeness issues`,
218
222
  findings: [...findings]
219
223
  .sort(compareAuditFindingDrafts)
220
- .slice(0, PROVENANCE_AUDIT_MAX_FINDINGS_PER_RULE),
224
+ .slice(
225
+ 0,
226
+ options.maxFindingsPerRule ?? PROVENANCE_AUDIT_MAX_FINDINGS_PER_RULE
227
+ ),
221
228
  findingCount: findings.length,
222
229
  examinedCount: documents.length,
223
230
  skipReason: truncated