@gmickel/gno 2.7.1 → 2.8.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 (76) hide show
  1. package/README.md +3 -2
  2. package/assets/skill/SKILL.md +8 -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/spa-production.json.gz +0 -0
  7. package/browser-extension/artifacts/{gno-browser-clipper-v2.7.1.zip → gno-browser-clipper-v2.8.0.zip} +0 -0
  8. package/browser-extension/artifacts/gno-browser-clipper-v2.8.0.zip.sha256 +1 -0
  9. package/browser-extension/dist/manifest.json +1 -1
  10. package/package.json +1 -1
  11. package/spec/cli.md +48 -6
  12. package/spec/db/schema.sql +0 -1
  13. package/spec/mcp.md +18 -3
  14. package/spec/output-schemas/audit-report.schema.json +18 -4
  15. package/spec/output-schemas/backlinks.schema.json +4 -0
  16. package/spec/output-schemas/collection-list.schema.json +13 -0
  17. package/spec/output-schemas/graph.schema.json +2 -0
  18. package/spec/output-schemas/links-list.schema.json +4 -0
  19. package/spec/output-schemas/status.schema.json +15 -0
  20. package/src/cli/commands/audit.ts +23 -4
  21. package/src/cli/commands/collection/list.ts +39 -5
  22. package/src/cli/commands/embed.ts +3 -3
  23. package/src/cli/commands/links.ts +34 -131
  24. package/src/cli/commands/shared.ts +7 -0
  25. package/src/cli/commands/status.ts +5 -0
  26. package/src/cli/program.ts +12 -2
  27. package/src/config/loader.ts +43 -0
  28. package/src/config/types.ts +8 -0
  29. package/src/core/audit-contract.ts +16 -4
  30. package/src/core/audit-freshness.ts +11 -1
  31. package/src/core/audit-links.ts +145 -25
  32. package/src/core/audit-provenance.ts +11 -4
  33. package/src/core/audit-workspace.ts +19 -4
  34. package/src/core/audit.ts +67 -15
  35. package/src/core/context-compiler.ts +3 -0
  36. package/src/core/context-evidence.ts +11 -0
  37. package/src/core/graph-edge-confidence.ts +23 -1
  38. package/src/core/host-paths.ts +1 -0
  39. package/src/core/knowledge-impact.ts +28 -0
  40. package/src/core/link-workspace.ts +324 -0
  41. package/src/core/retrieval-replay-candidate.ts +6 -0
  42. package/src/core/retrieval-trace-request.ts +3 -0
  43. package/src/index.ts +14 -1
  44. package/src/ingestion/graph-reconciliation.ts +77 -15
  45. package/src/ingestion/source-availability/darwin-path.ts +9 -3
  46. package/src/ingestion/sync.ts +22 -1
  47. package/src/ingestion/types.ts +14 -0
  48. package/src/llm/inference-scope.ts +4 -3
  49. package/src/mcp/http-egress.ts +42 -3
  50. package/src/mcp/tools/audit.ts +11 -2
  51. package/src/mcp/tools/changes.ts +1 -0
  52. package/src/mcp/tools/links.ts +3 -0
  53. package/src/mcp/tools/sessions.ts +33 -4
  54. package/src/mcp/tools/status.ts +3 -0
  55. package/src/pipeline/expansion.ts +19 -31
  56. package/src/pipeline/graph-retrieval.ts +22 -2
  57. package/src/pipeline/hybrid.ts +1 -1
  58. package/src/pipeline/types.ts +6 -3
  59. package/src/serve/findings-pass.ts +1 -1
  60. package/src/serve/public/pages/GraphView.tsx +2 -0
  61. package/src/serve/routes/changes.ts +6 -1
  62. package/src/serve/routes/links.ts +13 -0
  63. package/src/serve/routes/sessions.ts +41 -53
  64. package/src/sessions/config-refresh.ts +111 -0
  65. package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
  66. package/src/store/migrations/034-collection-link-workspace.ts +47 -0
  67. package/src/store/migrations/index.ts +4 -0
  68. package/src/store/sqlite/adapter.ts +390 -231
  69. package/src/store/sqlite/eligibility.ts +8 -2
  70. package/src/store/sqlite/graph-link-resolver.ts +252 -5
  71. package/src/store/sqlite/graph-neighbors.ts +147 -40
  72. package/src/store/sqlite/graph-reference-state.ts +13 -2
  73. package/src/store/sqlite/workspace-link-resolver.ts +654 -0
  74. package/src/store/types.ts +49 -3
  75. package/src/store/vector/stats.ts +1 -1
  76. package/browser-extension/artifacts/gno-browser-clipper-v2.7.1.zip.sha256 +0 -1
@@ -6,9 +6,16 @@ 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
+ /** 1.1: workspace-wide resolution and reference/resolution/scope evidence. */
17
+ export const LINK_AUDIT_RULE_VERSION = "1.1" as const;
18
+ /** Default per-rule cap; runs pass the effective `maxFindings` instead. */
12
19
  export const LINK_AUDIT_MAX_FINDINGS_PER_RULE = 1000;
13
20
 
14
21
  export interface AuditOrphanPolicy {
@@ -16,23 +23,123 @@ export interface AuditOrphanPolicy {
16
23
  ignorePathPrefixes: readonly string[];
17
24
  /** Mirrored duplicate rows are excluded from orphan claims by default. */
18
25
  ignoreMirrorDuplicates?: boolean;
26
+ /** Effective per-rule cap; defaults to LINK_AUDIT_MAX_FINDINGS_PER_RULE. */
27
+ maxFindingsPerRule?: number;
19
28
  }
20
29
 
21
30
  const boundedFindings = (
22
- findings: readonly AuditFindingDraft[]
31
+ findings: readonly AuditFindingDraft[],
32
+ maxFindingsPerRule: number
23
33
  ): AuditFindingDraft[] =>
24
- [...findings]
25
- .sort(compareAuditFindingDrafts)
26
- .slice(0, LINK_AUDIT_MAX_FINDINGS_PER_RULE);
34
+ [...findings].sort(compareAuditFindingDrafts).slice(0, maxFindingsPerRule);
27
35
 
28
36
  const lineLocation = (line: number, column: number): string =>
29
37
  `L${line}:C${column}`;
30
38
 
31
- const linkFinding = (
39
+ type LinkReferenceKind =
40
+ | "wiki-name"
41
+ | "wiki-path"
42
+ | "explicit-collection"
43
+ | "markdown";
44
+
45
+ const referenceKind = (
32
46
  link: AuditLinkSnapshot["links"][number]
47
+ ): LinkReferenceKind =>
48
+ link.linkType === "markdown"
49
+ ? "markdown"
50
+ : link.explicitCollection === true
51
+ ? "explicit-collection"
52
+ : link.targetRef.includes("/")
53
+ ? "wiki-path"
54
+ : "wiki-name";
55
+
56
+ interface CandidateEvidence {
57
+ /** Canonical-path-ordered tied candidates visible in the audit scope. */
58
+ uris: string[];
59
+ /** Candidates outside the requested collection scope (identity withheld). */
60
+ withheld: number;
61
+ truncated: boolean;
62
+ }
63
+
64
+ const linkFinding = (
65
+ link: AuditLinkSnapshot["links"][number],
66
+ context: {
67
+ documents: ReadonlyMap<number, AuditLinkSnapshotDocument>;
68
+ scope: ReadonlySet<string> | null;
69
+ },
70
+ onEvidenceTruncated: () => void
33
71
  ): AuditFindingDraft => {
34
72
  const ambiguous = (link.resolved?.matchCount ?? 0) > 1;
35
73
  const target = `${link.targetCollection}:${link.targetRef}`;
74
+ const kind = referenceKind(link);
75
+ const tied = (link.resolved?.candidates ?? []).map((candidate) =>
76
+ context.documents.get(candidate.id)
77
+ );
78
+ const visible = tied.filter(
79
+ (document): document is AuditLinkSnapshotDocument =>
80
+ document !== undefined &&
81
+ (context.scope === null || context.scope.has(document.collection))
82
+ );
83
+ // Scope describes every tied candidate; withheld ones are only counted.
84
+ const resolvedScope = !ambiguous
85
+ ? null
86
+ : link.explicitCollection === true
87
+ ? "explicit-collection"
88
+ : tied.some(
89
+ (document) =>
90
+ document !== undefined &&
91
+ document.collection !== link.sourceCollection
92
+ )
93
+ ? "cross-collection"
94
+ : "same-collection";
95
+ const base = {
96
+ anchor: link.targetAnchor,
97
+ endColumn: link.endCol,
98
+ endLine: link.endLine,
99
+ linkType: link.linkType,
100
+ matchCount: link.resolved?.matchCount ?? 0,
101
+ matchRank: link.resolved?.matchRank ?? null,
102
+ normalizedTarget: link.targetRefNorm,
103
+ referenceKind: kind,
104
+ resolutionStatus: ambiguous ? "ambiguous" : "unresolved",
105
+ resolvedScope,
106
+ };
107
+ // Candidate URIs are added while the detail stays within the evidence
108
+ // bound, so a long tie is reported as truncated rather than silently cut.
109
+ const candidates: CandidateEvidence = {
110
+ uris: [],
111
+ withheld: tied.length - visible.length,
112
+ truncated: false,
113
+ };
114
+ const render = (): string =>
115
+ JSON.stringify(
116
+ tied.length > 0
117
+ ? {
118
+ ...base,
119
+ candidateCount: tied.length,
120
+ candidates: candidates.uris,
121
+ ...(candidates.withheld > 0
122
+ ? { candidatesWithheld: candidates.withheld }
123
+ : {}),
124
+ ...(candidates.truncated ? { candidatesTruncated: true } : {}),
125
+ }
126
+ : base
127
+ );
128
+ for (const document of visible) {
129
+ candidates.uris.push(document.uri);
130
+ if (Array.from(render()).length > AUDIT_MAX_EVIDENCE_DETAIL_CHARS) {
131
+ candidates.uris.pop();
132
+ candidates.truncated = true;
133
+ break;
134
+ }
135
+ }
136
+ if (
137
+ candidates.truncated &&
138
+ Array.from(render()).length > AUDIT_MAX_EVIDENCE_DETAIL_CHARS
139
+ ) {
140
+ candidates.uris.pop();
141
+ }
142
+ if (candidates.truncated) onEvidenceTruncated();
36
143
  return {
37
144
  subject: link.sourceUri,
38
145
  location: lineLocation(link.startLine, link.startCol),
@@ -46,19 +153,15 @@ const linkFinding = (
46
153
  summary: target,
47
154
  uri: link.sourceUri,
48
155
  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
- }),
156
+ detail: render(),
58
157
  },
59
158
  ],
60
159
  guidance: ambiguous
61
- ? ["Use an explicit collection-relative target path"]
160
+ ? [
161
+ tied.length > 0
162
+ ? "Use a longer path such as [[Folder/Note]] to pick one target"
163
+ : "Use an explicit collection-relative target path",
164
+ ]
62
165
  : ["Create the target or correct the local link"],
63
166
  };
64
167
  };
@@ -112,20 +215,36 @@ export const evaluateLinkAudit = (
112
215
  const auditedDocumentIds = new Set(
113
216
  snapshot.auditedDocumentIds ?? snapshot.documents.map(({ id }) => id)
114
217
  );
218
+ let evidenceTruncated = false;
219
+ const findingContext = {
220
+ documents: new Map(snapshot.documents.map((doc) => [doc.id, doc])),
221
+ scope: snapshot.scopeCollections
222
+ ? new Set(snapshot.scopeCollections)
223
+ : null,
224
+ };
225
+ const finding = (link: AuditLinkSnapshot["links"][number]) =>
226
+ linkFinding(link, findingContext, () => {
227
+ evidenceTruncated = true;
228
+ });
115
229
  for (const link of snapshot.links) {
116
230
  const sourceAudited = auditedDocumentIds.has(link.sourceId);
117
231
  if (!link.resolved) {
118
- if (sourceAudited) unresolved.push(linkFinding(link));
232
+ if (sourceAudited) unresolved.push(finding(link));
119
233
  continue;
120
234
  }
121
- if (sourceAudited) connected.add(link.sourceId);
122
- if (auditedDocumentIds.has(link.resolved.targetId)) {
123
- connected.add(link.resolved.targetId);
235
+ // A tied workspace link is not a resolved link: it connects nothing.
236
+ if (isTraversableResolution(link.resolved)) {
237
+ if (sourceAudited) connected.add(link.sourceId);
238
+ if (auditedDocumentIds.has(link.resolved.targetId)) {
239
+ connected.add(link.resolved.targetId);
240
+ }
124
241
  }
125
242
  if (sourceAudited && link.resolved.matchCount > 1) {
126
- ambiguous.push(linkFinding(link));
243
+ ambiguous.push(finding(link));
127
244
  }
128
245
  }
246
+ const maxFindingsPerRule =
247
+ policy.maxFindingsPerRule ?? LINK_AUDIT_MAX_FINDINGS_PER_RULE;
129
248
  const roots = new Set(normalizedPrefixes(policy.rootUris));
130
249
  const ignorePrefixes = normalizedPrefixes(policy.ignorePathPrefixes);
131
250
  const mirroredIds = duplicateMirrorIds(
@@ -179,7 +298,7 @@ export const evaluateLinkAudit = (
179
298
  message: partial
180
299
  ? "Local target scan was truncated"
181
300
  : `${unresolved.length} unresolved or broken local links`,
182
- findings: boundedFindings(unresolved),
301
+ findings: boundedFindings(unresolved, maxFindingsPerRule),
183
302
  findingCount: unresolved.length,
184
303
  skipReason: partial ? "snapshot_truncated" : null,
185
304
  },
@@ -187,11 +306,12 @@ export const evaluateLinkAudit = (
187
306
  ...common,
188
307
  ruleId: "links.ambiguous-targets",
189
308
  category: "links",
309
+ evidenceTruncated,
190
310
  status: statusFor(ambiguous),
191
311
  message: partial
192
312
  ? "Ambiguous target scan was truncated"
193
313
  : `${ambiguous.length} ambiguous local links`,
194
- findings: boundedFindings(ambiguous),
314
+ findings: boundedFindings(ambiguous, maxFindingsPerRule),
195
315
  findingCount: ambiguous.length,
196
316
  skipReason: partial ? "snapshot_truncated" : null,
197
317
  },
@@ -203,7 +323,7 @@ export const evaluateLinkAudit = (
203
323
  message: partial
204
324
  ? "Orphan scan was truncated"
205
325
  : `${orphanFindings.length} policy-defined orphan documents`,
206
- findings: boundedFindings(orphanFindings),
326
+ findings: boundedFindings(orphanFindings, maxFindingsPerRule),
207
327
  findingCount: orphanFindings.length,
208
328
  skipReason: partial ? "snapshot_truncated" : null,
209
329
  },
@@ -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
@@ -9,6 +9,7 @@ import type { DocumentRow } from "../store/types";
9
9
  import type {
10
10
  AuditCategory,
11
11
  AuditFingerprints,
12
+ AuditMaxFindings,
12
13
  AuditRunResult,
13
14
  AuditScope,
14
15
  } from "./audit";
@@ -26,6 +27,7 @@ import {
26
27
  AUDIT_RULE_SET_VERSION,
27
28
  canonicalAuditJson,
28
29
  hashAuditCanonical,
30
+ resolveAuditMaxFindings,
29
31
  runAudit,
30
32
  } from "./audit";
31
33
  import { evaluateFreshnessAudit } from "./audit-freshness";
@@ -55,7 +57,7 @@ export interface WorkspaceAuditOptions {
55
57
  collectionFilters?: readonly string[];
56
58
  pathFilters?: readonly string[];
57
59
  tagFilters?: readonly string[];
58
- maxFindings?: number;
60
+ maxFindings?: AuditMaxFindings;
59
61
  agePolicy?: AuditFreshnessOptions["agePolicy"];
60
62
  orphanRoots?: readonly string[];
61
63
  orphanIgnorePrefixes?: readonly string[];
@@ -347,7 +349,8 @@ const filterLinkSnapshot = (
347
349
  snapshot: AuditLinkSnapshot,
348
350
  selectedIds: ReadonlySet<number>,
349
351
  selectedDocuments: readonly DocumentRow[],
350
- selectionTruncated: boolean
352
+ selectionTruncated: boolean,
353
+ scopeCollections: readonly string[]
351
354
  ): AuditLinkSnapshot => {
352
355
  const links = snapshot.links.filter(
353
356
  (link) =>
@@ -362,6 +365,9 @@ const filterLinkSnapshot = (
362
365
  // Preserve graph-wide documents as duplicate-mirror evidence while the
363
366
  // explicit id set prevents findings outside the requested audit scope.
364
367
  auditedDocumentIds: [...selectedIds],
368
+ ...(scopeCollections.length > 0
369
+ ? { scopeCollections: [...scopeCollections] }
370
+ : {}),
365
371
  links,
366
372
  totals: { documents: selectedDocuments.length, links: outgoingTotal },
367
373
  truncated: {
@@ -429,7 +435,8 @@ const loadWorkspaceSnapshot = async (
429
435
  rawLinks,
430
436
  selectedIds,
431
437
  selected.documents,
432
- selected.truncated
438
+ selected.truncated,
439
+ filters.collections
433
440
  ),
434
441
  truncated: selected.truncated,
435
442
  };
@@ -596,6 +603,12 @@ export const runWorkspaceAudit = async (
596
603
  snapshots.set(attempt, pending);
597
604
  return pending;
598
605
  };
606
+ // Per-family caps follow the effective report cap; runAudit rejects an
607
+ // invalid value before any rule runs.
608
+ const resolvedMaxFindings = resolveAuditMaxFindings(options.maxFindings);
609
+ const maxFindingsPerRule = resolvedMaxFindings.ok
610
+ ? resolvedMaxFindings.limit
611
+ : 0;
599
612
  const result = await runAudit({
600
613
  scope,
601
614
  capabilities: {
@@ -645,6 +658,7 @@ export const runWorkspaceAudit = async (
645
658
  ...evaluateLinkAudit(snapshot.links, {
646
659
  rootUris: options.orphanRoots ?? [],
647
660
  ignorePathPrefixes: options.orphanIgnorePrefixes ?? [],
661
+ maxFindingsPerRule,
648
662
  })
649
663
  );
650
664
  await options.onProgress?.({
@@ -657,7 +671,7 @@ export const runWorkspaceAudit = async (
657
671
  contributions.push(
658
672
  ...evaluateProvenanceAudit(
659
673
  snapshot.documents.map(({ provenance }) => provenance),
660
- { truncated: snapshot.truncated }
674
+ { truncated: snapshot.truncated, maxFindingsPerRule }
661
675
  )
662
676
  );
663
677
  await options.onProgress?.({
@@ -674,6 +688,7 @@ export const runWorkspaceAudit = async (
674
688
  now: options.now ?? new Date(),
675
689
  agePolicy: options.agePolicy,
676
690
  truncated: snapshot.truncated,
691
+ maxFindingsPerRule,
677
692
  }
678
693
  )
679
694
  );
package/src/core/audit.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  AuditFinding,
7
7
  AuditFingerprints,
8
8
  AuditForbiddenStoreMethod,
9
+ AuditMaxFindings,
9
10
  AuditReport,
10
11
  AuditReportStatus,
11
12
  AuditRuleContribution,
@@ -24,6 +25,9 @@ import {
24
25
  AUDIT_DEFAULT_MAX_FINDINGS,
25
26
  AUDIT_FORBIDDEN_STORE_METHODS,
26
27
  AUDIT_MAX_CODE_CHARS,
28
+ AUDIT_MAX_EVIDENCE_DETAIL_CHARS,
29
+ AUDIT_MAX_EVIDENCE_PER_FINDING,
30
+ AUDIT_MAX_FINDINGS_ALL,
27
31
  AUDIT_MAX_FINDINGS_LIMIT,
28
32
  AUDIT_MAX_IDENTIFIER_CHARS,
29
33
  AUDIT_MAX_MESSAGE_CHARS,
@@ -127,23 +131,61 @@ export const normalizeAuditScope = (
127
131
  };
128
132
  };
129
133
 
130
- const resolveMaxFindings = (
131
- value: number | undefined
132
- ): { ok: true; maxFindings: number } | { ok: false; error: string } => {
134
+ export const AUDIT_MAX_FINDINGS_RANGE_MESSAGE = `maxFindings must be an integer between 1 and ${AUDIT_MAX_FINDINGS_LIMIT}, or "${AUDIT_MAX_FINDINGS_ALL}"`;
135
+
136
+ /**
137
+ * Validate `maxFindings`. `limit` is the effective numeric cap (Infinity for
138
+ * `all`); `reported` is the value echoed in the report's truncation block.
139
+ */
140
+ export const resolveAuditMaxFindings = (
141
+ value: AuditMaxFindings | undefined
142
+ ):
143
+ | { ok: true; limit: number; reported: AuditMaxFindings }
144
+ | { ok: false; error: string } => {
145
+ if (value === AUDIT_MAX_FINDINGS_ALL) {
146
+ return {
147
+ ok: true,
148
+ limit: Number.POSITIVE_INFINITY,
149
+ reported: AUDIT_MAX_FINDINGS_ALL,
150
+ };
151
+ }
133
152
  const maxFindings = value ?? AUDIT_DEFAULT_MAX_FINDINGS;
134
153
  if (
135
154
  !Number.isSafeInteger(maxFindings) ||
136
155
  maxFindings < 1 ||
137
156
  maxFindings > AUDIT_MAX_FINDINGS_LIMIT
138
157
  ) {
139
- return {
140
- ok: false,
141
- error: `maxFindings must be an integer between 1 and ${AUDIT_MAX_FINDINGS_LIMIT}`,
142
- };
158
+ return { ok: false, error: AUDIT_MAX_FINDINGS_RANGE_MESSAGE };
159
+ }
160
+ return { ok: true, limit: maxFindings, reported: maxFindings };
161
+ };
162
+
163
+ /** Parse a CLI `--max-findings` value: a positive integer or `all`. */
164
+ export const parseAuditMaxFindingsInput = (
165
+ raw: string
166
+ ): AuditMaxFindings | undefined => {
167
+ const value = raw.trim();
168
+ if (value.toLowerCase() === AUDIT_MAX_FINDINGS_ALL) {
169
+ return AUDIT_MAX_FINDINGS_ALL;
143
170
  }
144
- return { ok: true, maxFindings };
171
+ if (!/^\d+$/.test(value)) return undefined;
172
+ const parsed = Number(value);
173
+ return resolveAuditMaxFindings(parsed).ok ? parsed : undefined;
145
174
  };
146
175
 
176
+ const draftEvidenceTruncated = (contribution: AuditRuleContribution): boolean =>
177
+ contribution.evidenceTruncated === true ||
178
+ (contribution.findings ?? []).some(
179
+ (draft) =>
180
+ draft.evidence.length > AUDIT_MAX_EVIDENCE_PER_FINDING ||
181
+ draft.evidence.some(
182
+ (item) =>
183
+ item.detail !== undefined &&
184
+ Array.from(normalizeAuditText(item.detail)).length >
185
+ AUDIT_MAX_EVIDENCE_DETAIL_CHARS
186
+ )
187
+ );
188
+
147
189
  const fingerprintsEqual = (
148
190
  left: AuditFingerprints,
149
191
  right: AuditFingerprints
@@ -231,7 +273,8 @@ const buildReportFromRules = (input: {
231
273
  versions: AuditVersions;
232
274
  rules: AuditRuleResult[];
233
275
  status: AuditReportStatus;
234
- maxFindings: number;
276
+ maxFindings: { limit: number; reported: AuditMaxFindings };
277
+ evidenceTruncated: boolean;
235
278
  startedAt: string;
236
279
  completedAt: string;
237
280
  snapshotMs: number;
@@ -261,9 +304,9 @@ const buildReportFromRules = (input: {
261
304
  (sum, rule) => sum + rule.findingCount,
262
305
  0
263
306
  );
264
- const truncated = exactFindingCount > input.maxFindings;
307
+ const truncated = exactFindingCount > input.maxFindings.limit;
265
308
  const returnedFindings = truncated
266
- ? uniqueFindings.slice(0, input.maxFindings)
309
+ ? uniqueFindings.slice(0, input.maxFindings.limit)
267
310
  : uniqueFindings;
268
311
  const returnedFindingIds = new Set(
269
312
  returnedFindings.map((finding) => finding.id)
@@ -308,7 +351,11 @@ const buildReportFromRules = (input: {
308
351
  },
309
352
  truncation: {
310
353
  findingsTruncated: truncated,
311
- maxFindings: input.maxFindings,
354
+ maxFindings: input.maxFindings.reported,
355
+ snapshotTruncated: rules.some(
356
+ (rule) => rule.skipReason === "snapshot_truncated"
357
+ ),
358
+ evidenceTruncated: input.evidenceTruncated,
312
359
  },
313
360
  timing: {
314
361
  snapshotMs: Math.max(0, Math.round(input.snapshotMs)),
@@ -329,7 +376,7 @@ export async function runAudit(input: AuditRunInput): Promise<AuditRunResult> {
329
376
  if (!scopeResult.ok) {
330
377
  return { ok: false, exit: "invalid", error: scopeResult.error };
331
378
  }
332
- const maxFindingsResult = resolveMaxFindings(input.maxFindings);
379
+ const maxFindingsResult = resolveAuditMaxFindings(input.maxFindings);
333
380
  if (!maxFindingsResult.ok) {
334
381
  return { ok: false, exit: "invalid", error: maxFindingsResult.error };
335
382
  }
@@ -355,6 +402,7 @@ export async function runAudit(input: AuditRunInput): Promise<AuditRunResult> {
355
402
  let snapshotMs = 0;
356
403
  let rulesMs = 0;
357
404
  let lastRules: AuditRuleResult[] = [];
405
+ let lastEvidenceTruncated = false;
358
406
  let lastFingerprints: AuditFingerprints | null = null;
359
407
  let snapshotChanged = false;
360
408
  let failed = false;
@@ -405,6 +453,7 @@ export async function runAudit(input: AuditRunInput): Promise<AuditRunResult> {
405
453
  try {
406
454
  const contributions = await collectContributions(input.rules, ruleCtx);
407
455
  lastRules = contributions.map(materializeRule);
456
+ lastEvidenceTruncated = contributions.some(draftEvidenceTruncated);
408
457
  } catch (cause) {
409
458
  if (input.signal?.aborted) {
410
459
  lastRules = [cancellationRule()];
@@ -453,6 +502,7 @@ export async function runAudit(input: AuditRunInput): Promise<AuditRunResult> {
453
502
  }
454
503
  // Discard rule results from a drifted attempt; retry with a fresh snapshot.
455
504
  lastRules = [];
505
+ lastEvidenceTruncated = false;
456
506
  }
457
507
 
458
508
  const completedAt = clock();
@@ -484,7 +534,8 @@ export async function runAudit(input: AuditRunInput): Promise<AuditRunResult> {
484
534
  },
485
535
  ],
486
536
  status: "failed",
487
- maxFindings: maxFindingsResult.maxFindings,
537
+ maxFindings: maxFindingsResult,
538
+ evidenceTruncated: false,
488
539
  startedAt: runStartedAt.toISOString(),
489
540
  completedAt: completedAt.toISOString(),
490
541
  snapshotMs,
@@ -531,7 +582,8 @@ export async function runAudit(input: AuditRunInput): Promise<AuditRunResult> {
531
582
  rules: rulesForReport,
532
583
  snapshotChanged: false,
533
584
  }),
534
- maxFindings: maxFindingsResult.maxFindings,
585
+ maxFindings: maxFindingsResult,
586
+ evidenceTruncated: lastEvidenceTruncated,
535
587
  startedAt: runStartedAt.toISOString(),
536
588
  completedAt: completedAt.toISOString(),
537
589
  snapshotMs,
@@ -355,6 +355,9 @@ export const planContextEvidence = async <T, P>(
355
355
  await deps.retrieve({
356
356
  query,
357
357
  collection,
358
+ // The primary search is partitioned per collection; graph expansion
359
+ // may bridge between the requested collections, never beyond them.
360
+ ...(collections.length > 0 ? { graphCollections: collections } : {}),
358
361
  noExpand: true,
359
362
  queryModes,
360
363
  tagsAll: input.tagsAll,
@@ -40,6 +40,7 @@ import {
40
40
  import { planContextEvidence } from "./context-compiler";
41
41
  import { projectContextEvidenceMetadata } from "./context-evidence-metadata";
42
42
  import { createEgressLineage, resolveEgressLineage } from "./egress-provenance";
43
+ import { linkResolutionFingerprintInput } from "./link-workspace";
43
44
  import { projectRecordEvidenceMetadata } from "./record-metadata";
44
45
  import {
45
46
  extractInclusiveLines,
@@ -205,6 +206,13 @@ const canonicalIndexSnapshot = (
205
206
  ),
206
207
  });
207
208
 
209
+ const linkResolutionEntry = (
210
+ rows: Parameters<typeof linkResolutionFingerprintInput>[0]
211
+ ): { linkResolution?: ReturnType<typeof linkResolutionFingerprintInput> } => {
212
+ const linkResolution = linkResolutionFingerprintInput(rows);
213
+ return linkResolution ? { linkResolution } : {};
214
+ };
215
+
208
216
  /** Capture one strict, content-free index/context snapshot before or after work. */
209
217
  export const captureContextEvidenceSnapshot = async (
210
218
  store: ContextEvidenceStore,
@@ -284,6 +292,9 @@ export const captureContextEvidenceSnapshot = async (
284
292
  indexFingerprint: hashJson({
285
293
  snapshots: indexSnapshots,
286
294
  egressLineage,
295
+ // Link workspace membership decides graph neighbours; a membership or
296
+ // resolver change is index drift. Absent without any workspace.
297
+ ...linkResolutionEntry(collectionRows),
287
298
  }),
288
299
  };
289
300
  };
@@ -17,8 +17,30 @@ export const GRAPH_EDGE_CONFIDENCE_RANK: Record<GraphEdgeConfidence, number> = {
17
17
  export function classifyResolvedGraphEdge(
18
18
  linkType: "wiki" | "markdown",
19
19
  matchRank: number | null,
20
- matchCount: number | null
20
+ matchCount: number | null,
21
+ /** Workspace resolution reason; semantic, independent of legacy ranks. */
22
+ reason?: string
21
23
  ): { confidence: GraphEdgeConfidence; audit: GraphEdgeAudit } {
24
+ if (linkType === "wiki" && reason !== undefined && reason !== "title") {
25
+ switch (reason) {
26
+ case "workspace-path":
27
+ case "collection-path":
28
+ return {
29
+ confidence: "explicit",
30
+ audit: { resolution: "exact-path", matchCount: 1 },
31
+ };
32
+ case "exact-name":
33
+ return {
34
+ confidence: "explicit",
35
+ audit: { resolution: "exact-name", matchCount: 1 },
36
+ };
37
+ default:
38
+ return {
39
+ confidence: "inferred",
40
+ audit: { resolution: "tie-break", matchCount: 1 },
41
+ };
42
+ }
43
+ }
22
44
  if (linkType === "markdown") {
23
45
  return {
24
46
  confidence: "explicit",
@@ -22,6 +22,7 @@ export const OWNER_CONFIG_PATH_FIELDS: ReadonlySet<string> = new Set([
22
22
  "configPath",
23
23
  "dbPath",
24
24
  "path",
25
+ "workspaceRoot",
25
26
  ]);
26
27
 
27
28
  const isPlainObject = (value: object): boolean => {
@@ -41,6 +41,12 @@ export interface KnowledgeImpactResult {
41
41
  }
42
42
 
43
43
  export interface KnowledgeImpactInput {
44
+ /**
45
+ * Collection scope: only documents in these collections are traversed or
46
+ * returned, and a document outside them is never used as a bridge.
47
+ * Omitted or empty means every indexed collection.
48
+ */
49
+ collections?: string[];
44
50
  maxDepth?: number;
45
51
  maxNodes?: number;
46
52
  maxEdges?: number;
@@ -118,8 +124,30 @@ export async function analyzeKnowledgeImpact(
118
124
  isValidation: true,
119
125
  };
120
126
  }
127
+ const scope = [...new Set(input.collections ?? [])].sort();
128
+ if (scope.length > 0) {
129
+ const known = await store.getCollections();
130
+ if (!known.ok) return { success: false, error: known.error.message };
131
+ const names = new Set(known.value.map((row) => row.name));
132
+ const missing = scope.find((name) => !names.has(name));
133
+ if (missing) {
134
+ return {
135
+ success: false,
136
+ error: `Collection not found: ${missing}`,
137
+ isValidation: true,
138
+ };
139
+ }
140
+ if (!scope.includes(resolved.doc.collection)) {
141
+ return {
142
+ success: false,
143
+ error: `Document is outside the requested collections: ${ref}`,
144
+ isValidation: true,
145
+ };
146
+ }
147
+ }
121
148
  const traversal = await store.queryGraphTraversal(resolved.doc.id, {
122
149
  direction: "in",
150
+ ...(scope.length > 0 ? { collections: scope } : {}),
123
151
  maxDepth: values.maxDepth,
124
152
  maxNodes: values.maxNodes,
125
153
  frontierLimit: values.frontierLimit,