@gmickel/gno 2.7.0 → 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 (99) hide show
  1. package/README.md +26 -30
  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.0.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 +2 -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 +15 -2
  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 +27 -16
  20. package/src/cli/commands/ask.ts +31 -12
  21. package/src/cli/commands/audit.ts +23 -4
  22. package/src/cli/commands/collection/list.ts +39 -5
  23. package/src/cli/commands/embed.ts +3 -3
  24. package/src/cli/commands/links.ts +34 -131
  25. package/src/cli/commands/ls.ts +6 -1
  26. package/src/cli/commands/shared.ts +7 -0
  27. package/src/cli/commands/status.ts +5 -0
  28. package/src/cli/program.ts +12 -2
  29. package/src/config/loader.ts +43 -0
  30. package/src/config/types.ts +8 -0
  31. package/src/core/audit-contract.ts +16 -4
  32. package/src/core/audit-freshness.ts +11 -1
  33. package/src/core/audit-links.ts +145 -25
  34. package/src/core/audit-provenance.ts +11 -4
  35. package/src/core/audit-workspace.ts +19 -4
  36. package/src/core/audit.ts +67 -15
  37. package/src/core/context-compiler.ts +3 -0
  38. package/src/core/context-evidence.ts +11 -0
  39. package/src/core/graph-edge-confidence.ts +23 -1
  40. package/src/core/host-paths.ts +24 -5
  41. package/src/core/knowledge-impact.ts +28 -0
  42. package/src/core/link-workspace.ts +324 -0
  43. package/src/core/request-receipts.ts +63 -9
  44. package/src/core/retrieval-replay-candidate.ts +6 -0
  45. package/src/core/retrieval-trace-request.ts +3 -0
  46. package/src/core/windows-private-path.ts +136 -1
  47. package/src/embed/backlog.ts +22 -10
  48. package/src/embed/variant-backlog.ts +34 -9
  49. package/src/index.ts +14 -1
  50. package/src/ingestion/graph-reconciliation.ts +77 -15
  51. package/src/ingestion/source-availability/darwin-path.ts +9 -3
  52. package/src/ingestion/sync.ts +22 -1
  53. package/src/ingestion/types.ts +14 -0
  54. package/src/llm/inference-scope.ts +19 -0
  55. package/src/mcp/http-egress.ts +42 -3
  56. package/src/mcp/tools/audit.ts +11 -2
  57. package/src/mcp/tools/changes.ts +1 -0
  58. package/src/mcp/tools/links.ts +3 -0
  59. package/src/mcp/tools/sessions.ts +33 -4
  60. package/src/mcp/tools/status.ts +22 -6
  61. package/src/pipeline/expansion.ts +19 -31
  62. package/src/pipeline/graph-retrieval.ts +22 -2
  63. package/src/pipeline/hybrid.ts +1 -1
  64. package/src/pipeline/types.ts +6 -3
  65. package/src/serve/embed-scheduler.ts +2 -2
  66. package/src/serve/findings-pass.ts +1 -1
  67. package/src/serve/host-path-redaction.ts +51 -14
  68. package/src/serve/public/components/BootstrapStatus.tsx +5 -3
  69. package/src/serve/public/components/CaptureModal.tsx +1 -1
  70. package/src/serve/public/components/CollectionModelDialog.tsx +16 -13
  71. package/src/serve/public/components/CollectionsEmptyState.tsx +5 -3
  72. package/src/serve/public/components/FirstRunWizard.tsx +4 -2
  73. package/src/serve/public/components/sessions/SourcesPanel.tsx +77 -60
  74. package/src/serve/public/pages/Collections.tsx +16 -13
  75. package/src/serve/public/pages/Connectors.tsx +7 -4
  76. package/src/serve/public/pages/Dashboard.tsx +8 -6
  77. package/src/serve/public/pages/GraphView.tsx +2 -0
  78. package/src/serve/resident-runtime.ts +7 -0
  79. package/src/serve/routes/changes.ts +6 -1
  80. package/src/serve/routes/links.ts +13 -0
  81. package/src/serve/routes/sessions.ts +81 -37
  82. package/src/serve/server.ts +5 -3
  83. package/src/serve/status-model.ts +10 -6
  84. package/src/serve/status.ts +2 -7
  85. package/src/sessions/config-refresh.ts +111 -0
  86. package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
  87. package/src/store/migrations/034-collection-link-workspace.ts +47 -0
  88. package/src/store/migrations/index.ts +4 -0
  89. package/src/store/sqlite/adapter.ts +392 -233
  90. package/src/store/sqlite/change-journal-store.ts +1 -1
  91. package/src/store/sqlite/eligibility.ts +8 -2
  92. package/src/store/sqlite/graph-link-resolver.ts +252 -5
  93. package/src/store/sqlite/graph-neighbors.ts +147 -40
  94. package/src/store/sqlite/graph-reference-state.ts +13 -2
  95. package/src/store/sqlite/legacy-vector-ownership.ts +2 -1
  96. package/src/store/sqlite/workspace-link-resolver.ts +654 -0
  97. package/src/store/types.ts +49 -3
  98. package/src/store/vector/stats.ts +1 -1
  99. package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.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,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,