@gmickel/gno 2.13.0 → 2.14.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.
@@ -126,11 +126,21 @@ export async function loadConfigFromPath(
126
126
  const result = ConfigSchema.safeParse(parsed);
127
127
 
128
128
  if (!result.success) {
129
+ // Collection `audit` mistakes name the setting; other issues keep the
130
+ // generic message.
131
+ const auditIssues = result.error.issues.filter((issue) =>
132
+ issue.path.includes("audit")
133
+ );
129
134
  return {
130
135
  ok: false,
131
136
  error: {
132
137
  code: "VALIDATION_ERROR",
133
- message: "Config validation failed",
138
+ message:
139
+ auditIssues.length > 0
140
+ ? `Config validation failed: ${auditIssues
141
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
142
+ .join("; ")}`
143
+ : "Config validation failed",
134
144
  issues: result.error.issues,
135
145
  },
136
146
  };
@@ -114,6 +114,74 @@ const COLLECTION_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
114
114
  /** Collection scope key pattern: name (1-64 chars) followed by colon */
115
115
  const COLLECTION_SCOPE_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}:$/;
116
116
 
117
+ /** Most globs one collection's `audit.paths` may list. */
118
+ export const COLLECTION_AUDIT_MAX_PATHS = 64;
119
+ const COLLECTION_AUDIT_MAX_GLOB_CHARS = 1024;
120
+
121
+ /**
122
+ * A path glob in `pattern` syntax: relative, no `..` segment, balanced
123
+ * `{}` / `[]`. Bun.Glob accepts nearly any string, so the shape is checked.
124
+ */
125
+ export function isValidAuditPathGlob(glob: string): boolean {
126
+ if (glob.trim() !== glob || glob.length === 0) return false;
127
+ if (glob.includes("\0") || glob.startsWith("/")) return false;
128
+ if (glob.split("/").includes("..")) return false;
129
+ let braces = 0;
130
+ let inClass = false;
131
+ for (const char of glob) {
132
+ if (inClass) {
133
+ if (char === "]") inClass = false;
134
+ continue;
135
+ }
136
+ if (char === "[") inClass = true;
137
+ else if (char === "{") braces += 1;
138
+ else if (char === "}") {
139
+ braces -= 1;
140
+ if (braces < 0) return false;
141
+ }
142
+ }
143
+ return braces === 0 && !inClass;
144
+ }
145
+
146
+ /**
147
+ * Opt-in, report-only audit rules for one collection (`gno audit links`).
148
+ * They never block indexing, retrieval or editing.
149
+ */
150
+ export const CollectionAuditSchema = z
151
+ .object({
152
+ /** Other collections this one may link to; its own is always allowed. */
153
+ linkCollections: z
154
+ .array(
155
+ z
156
+ .string()
157
+ .regex(
158
+ COLLECTION_NAME_REGEX,
159
+ "audit.linkCollections entries must be collection names"
160
+ )
161
+ )
162
+ .max(256)
163
+ .optional(),
164
+ /** `report` lists external URLs written in documents. Not egress. */
165
+ externalUrls: z.enum(["report", "ignore"]).optional(),
166
+ /** Each document path must match at least one glob. */
167
+ paths: z
168
+ .array(
169
+ z
170
+ .string()
171
+ .max(COLLECTION_AUDIT_MAX_GLOB_CHARS)
172
+ .refine(isValidAuditPathGlob, {
173
+ message:
174
+ "audit.paths entries must be relative globs without '..' and with balanced {} and []",
175
+ })
176
+ )
177
+ .min(1, "audit.paths must list at least one glob")
178
+ .max(COLLECTION_AUDIT_MAX_PATHS)
179
+ .optional(),
180
+ })
181
+ .strict();
182
+
183
+ export type CollectionAuditConfig = z.infer<typeof CollectionAuditSchema>;
184
+
117
185
  export const CollectionSchema = z.object({
118
186
  /** Unique collection identifier (lowercase) */
119
187
  name: z
@@ -184,6 +252,9 @@ export const CollectionSchema = z.object({
184
252
  */
185
253
  memoryManaged: z.boolean().optional(),
186
254
 
255
+ /** Opt-in report-only link and path rules for `gno audit links`. */
256
+ audit: CollectionAuditSchema.optional(),
257
+
187
258
  /** Optional per-collection model overrides */
188
259
  models: z
189
260
  .object({
@@ -616,7 +687,25 @@ export const ConfigSchema = z.object({
616
687
  editorUriTemplate: z.string().min(1).optional(),
617
688
 
618
689
  /** Collection definitions */
619
- collections: z.array(CollectionSchema).default([]),
690
+ collections: z
691
+ .array(CollectionSchema)
692
+ .superRefine((collections, ctx) => {
693
+ const names = new Set(collections.map(({ name }) => name));
694
+ for (const [index, collection] of collections.entries()) {
695
+ for (const [
696
+ position,
697
+ name,
698
+ ] of collection.audit?.linkCollections?.entries() ?? []) {
699
+ if (names.has(name)) continue;
700
+ ctx.addIssue({
701
+ code: "custom",
702
+ message: `Collection "${collection.name}" audit.linkCollections names unknown collection "${name}"`,
703
+ path: [index, "audit", "linkCollections", position],
704
+ });
705
+ }
706
+ }
707
+ })
708
+ .default([]),
620
709
 
621
710
  /** Context metadata */
622
711
  contexts: z.array(ContextSchema).default([]),
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Opt-in, report-only collection rules for the `links` audit category:
3
+ * `links.collection-policy`, `links.external-url` and `links.path-layout`.
4
+ * A rule is emitted only when an audited collection configures it.
5
+ */
6
+
7
+ import type { Collection, CollectionAuditConfig } from "../config/types";
8
+ import type { AuditLinkSnapshot } from "../store/sqlite/graph-link-resolver";
9
+ import type { DocumentRow } from "../store/types";
10
+ import type { AuditFindingDraft, AuditRuleContribution } from "./audit";
11
+
12
+ import { buildLineOffsets, offsetToPosition } from "../ingestion/position";
13
+ import { getExcludedRanges, rangeIntersectsExcluded } from "../ingestion/strip";
14
+ import { isTraversableResolution } from "../store/sqlite/graph-link-resolver";
15
+ import {
16
+ AUDIT_MAX_EVIDENCE_DETAIL_CHARS,
17
+ compareAuditCodeUnits,
18
+ compareAuditFindingDrafts,
19
+ } from "./audit";
20
+
21
+ export interface CollectionAuditRuleInput {
22
+ links: AuditLinkSnapshot;
23
+ /** Audited documents (the requested scope). */
24
+ documents: readonly DocumentRow[];
25
+ /** Configured collections. */
26
+ collections: readonly Collection[];
27
+ /** Collections the run audits; rules are emitted only for these. */
28
+ auditedCollections: readonly string[];
29
+ /** Indexed text by mirror hash, loaded only for `externalUrls: report`. */
30
+ contents: ReadonlyMap<string, string>;
31
+ truncated: boolean;
32
+ maxFindingsPerRule: number;
33
+ }
34
+
35
+ /** `scheme://` in prose or link syntax; the start must not continue a word. */
36
+ const URL_PATTERN = /(?<![a-z0-9+.-])([a-z][a-z0-9+.-]*):\/\/[^\s<>"'`]+/gi;
37
+ /** Local schemes are not external URLs. */
38
+ const LOCAL_URL_SCHEMES = new Set(["gno", "file"]);
39
+ const TRAILING_PUNCTUATION = /[.,;:!?*_~]$/;
40
+ const MAX_URL_EVIDENCE_CHARS = 400;
41
+
42
+ const clip = (value: string, max: number): string =>
43
+ value.length > max ? `${value.slice(0, max - 1)}…` : value;
44
+
45
+ const lineLocation = (line: number, column: number): string =>
46
+ `L${line}:C${column}`;
47
+
48
+ /** Trim sentence punctuation and closers that do not belong to the URL. */
49
+ const trimUrl = (raw: string): string => {
50
+ let url = raw;
51
+ for (;;) {
52
+ const last = url.at(-1);
53
+ if (last === undefined) return url;
54
+ if (TRAILING_PUNCTUATION.test(last)) {
55
+ url = url.slice(0, -1);
56
+ continue;
57
+ }
58
+ const pairs: Record<string, string> = { ")": "(", "]": "[", "}": "{" };
59
+ const opener = pairs[last];
60
+ if (
61
+ opener !== undefined &&
62
+ url.split(last).length > url.split(opener).length
63
+ ) {
64
+ url = url.slice(0, -1);
65
+ continue;
66
+ }
67
+ return url;
68
+ }
69
+ };
70
+
71
+ export interface ExternalUrlMatch {
72
+ url: string;
73
+ line: number;
74
+ column: number;
75
+ }
76
+
77
+ /** External URLs in indexed text, outside frontmatter, code and comments. */
78
+ export const findExternalUrls = (markdown: string): ExternalUrlMatch[] => {
79
+ const excluded = getExcludedRanges(markdown);
80
+ const lineOffsets = buildLineOffsets(markdown);
81
+ const matches: ExternalUrlMatch[] = [];
82
+ for (const match of markdown.matchAll(URL_PATTERN)) {
83
+ const scheme = match[1]?.toLowerCase() ?? "";
84
+ if (LOCAL_URL_SCHEMES.has(scheme)) continue;
85
+ const url = trimUrl(match[0]);
86
+ if (url.length <= scheme.length + 3) continue;
87
+ const start = match.index;
88
+ if (rangeIntersectsExcluded(start, start + url.length, excluded)) continue;
89
+ const position = offsetToPosition(start, lineOffsets);
90
+ matches.push({ url, line: position.line, column: position.col });
91
+ }
92
+ return matches;
93
+ };
94
+
95
+ const configuredFor = (
96
+ input: CollectionAuditRuleInput
97
+ ): Map<string, CollectionAuditConfig> => {
98
+ const audited = new Set(input.auditedCollections);
99
+ return new Map(
100
+ input.collections
101
+ .filter(({ name, audit }) => audit !== undefined && audited.has(name))
102
+ .map(({ name, audit }) => [name, audit as CollectionAuditConfig])
103
+ );
104
+ };
105
+
106
+ const documentPath = (document: DocumentRow): string =>
107
+ document.recordSourcePath ?? document.relPath;
108
+
109
+ const contribution = (
110
+ ruleId: string,
111
+ findings: AuditFindingDraft[],
112
+ input: CollectionAuditRuleInput,
113
+ examinedCount: number,
114
+ messages: { partial: string; done: string }
115
+ ): AuditRuleContribution => {
116
+ const partial = input.truncated;
117
+ return {
118
+ ruleId,
119
+ category: "links",
120
+ status: partial ? "inconclusive" : findings.length > 0 ? "fail" : "pass",
121
+ message: partial ? messages.partial : messages.done,
122
+ findings: [...findings]
123
+ .sort(compareAuditFindingDrafts)
124
+ .slice(0, input.maxFindingsPerRule),
125
+ findingCount: findings.length,
126
+ examinedCount,
127
+ durationMs: 0,
128
+ skipReason: partial ? "snapshot_truncated" : null,
129
+ };
130
+ };
131
+
132
+ const collectionPolicy = (
133
+ input: CollectionAuditRuleInput,
134
+ configured: ReadonlyMap<string, CollectionAuditConfig>
135
+ ): AuditRuleContribution | null => {
136
+ const allowedBySource = new Map<string, Set<string>>();
137
+ for (const [name, audit] of configured) {
138
+ if (audit.linkCollections === undefined) continue;
139
+ allowedBySource.set(name, new Set([name, ...audit.linkCollections]));
140
+ }
141
+ if (allowedBySource.size === 0) return null;
142
+ const audited = new Set(
143
+ input.links.auditedDocumentIds ?? input.links.documents.map(({ id }) => id)
144
+ );
145
+ const documents = new Map(input.links.documents.map((doc) => [doc.id, doc]));
146
+ const findings: AuditFindingDraft[] = [];
147
+ let examined = 0;
148
+ for (const link of input.links.links) {
149
+ const allowed = allowedBySource.get(link.sourceCollection);
150
+ if (!allowed || !audited.has(link.sourceId)) continue;
151
+ examined += 1;
152
+ // Unresolved and ambiguous links belong to the broken-link rules.
153
+ if (!link.resolved || !isTraversableResolution(link.resolved)) continue;
154
+ if (link.resolved.matchCount > 1) continue;
155
+ const target = documents.get(link.resolved.targetId);
156
+ if (!target || allowed.has(target.collection)) continue;
157
+ findings.push({
158
+ subject: link.sourceUri,
159
+ location: lineLocation(link.startLine, link.startCol),
160
+ severity: "warning",
161
+ message: `Link into collection "${target.collection}", which audit.linkCollections does not list: ${target.uri}`,
162
+ evidence: [
163
+ {
164
+ kind: "collection-policy",
165
+ summary: `${target.collection}:${target.uri}`,
166
+ uri: link.sourceUri,
167
+ path: link.sourceRelPath,
168
+ detail: clip(
169
+ JSON.stringify({
170
+ sourceCollection: link.sourceCollection,
171
+ targetCollection: target.collection,
172
+ target: target.uri,
173
+ linkType: link.linkType,
174
+ allowed: [...allowed].sort(compareAuditCodeUnits),
175
+ }),
176
+ AUDIT_MAX_EVIDENCE_DETAIL_CHARS
177
+ ),
178
+ },
179
+ ],
180
+ guidance: [
181
+ `Remove or change the link, or add "${target.collection}" to audit.linkCollections`,
182
+ ],
183
+ });
184
+ }
185
+ return contribution("links.collection-policy", findings, input, examined, {
186
+ partial: "Collection link policy scan was truncated",
187
+ done: `${findings.length} links into collections outside audit.linkCollections`,
188
+ });
189
+ };
190
+
191
+ const externalUrls = (
192
+ input: CollectionAuditRuleInput,
193
+ configured: ReadonlyMap<string, CollectionAuditConfig>
194
+ ): AuditRuleContribution | null => {
195
+ const reporting = new Set(
196
+ [...configured]
197
+ .filter(([, audit]) => audit.externalUrls === "report")
198
+ .map(([name]) => name)
199
+ );
200
+ if (reporting.size === 0) return null;
201
+ const findings: AuditFindingDraft[] = [];
202
+ let examined = 0;
203
+ let unchecked = 0;
204
+ for (const document of input.documents) {
205
+ if (!reporting.has(document.collection)) continue;
206
+ examined += 1;
207
+ const text = document.mirrorHash
208
+ ? input.contents.get(document.mirrorHash)
209
+ : undefined;
210
+ if (text === undefined) {
211
+ unchecked += 1;
212
+ findings.push({
213
+ subject: document.uri,
214
+ location: null,
215
+ severity: "warning",
216
+ message: "Indexed text is unavailable; external URLs were not checked",
217
+ evidence: [
218
+ {
219
+ kind: "external-url-unchecked",
220
+ summary: "unchecked",
221
+ uri: document.uri,
222
+ path: documentPath(document),
223
+ },
224
+ ],
225
+ guidance: ["Run gno update so the document has indexed text"],
226
+ });
227
+ continue;
228
+ }
229
+ for (const match of findExternalUrls(text)) {
230
+ findings.push({
231
+ subject: document.uri,
232
+ location: lineLocation(match.line, match.column),
233
+ severity: "warning",
234
+ message: `External URL in document text: ${clip(match.url, MAX_URL_EVIDENCE_CHARS)}`,
235
+ evidence: [
236
+ {
237
+ kind: "external-url",
238
+ summary: clip(match.url, MAX_URL_EVIDENCE_CHARS),
239
+ uri: document.uri,
240
+ path: documentPath(document),
241
+ detail: JSON.stringify({ line: match.line, column: match.column }),
242
+ },
243
+ ],
244
+ guidance: [
245
+ "Remove the URL, or set audit.externalUrls: ignore for this collection",
246
+ ],
247
+ });
248
+ }
249
+ }
250
+ const urls = findings.length - unchecked;
251
+ return contribution("links.external-url", findings, input, examined, {
252
+ partial: "External URL scan was truncated",
253
+ done:
254
+ unchecked > 0
255
+ ? `${urls} external URLs; ${unchecked} documents unchecked`
256
+ : `${urls} external URLs`,
257
+ });
258
+ };
259
+
260
+ const pathLayout = (
261
+ input: CollectionAuditRuleInput,
262
+ configured: ReadonlyMap<string, CollectionAuditConfig>
263
+ ): AuditRuleContribution | null => {
264
+ const globsByCollection = new Map<
265
+ string,
266
+ { patterns: string[]; globs: Bun.Glob[] }
267
+ >();
268
+ for (const [name, audit] of configured) {
269
+ if (audit.paths === undefined) continue;
270
+ globsByCollection.set(name, {
271
+ patterns: audit.paths,
272
+ globs: audit.paths.map((pattern) => new Bun.Glob(pattern)),
273
+ });
274
+ }
275
+ if (globsByCollection.size === 0) return null;
276
+ const findings: AuditFindingDraft[] = [];
277
+ let examined = 0;
278
+ for (const document of input.documents) {
279
+ const expected = globsByCollection.get(document.collection);
280
+ if (!expected) continue;
281
+ examined += 1;
282
+ const path = documentPath(document);
283
+ if (expected.globs.some((glob) => glob.match(path))) continue;
284
+ findings.push({
285
+ subject: document.uri,
286
+ location: null,
287
+ severity: "warning",
288
+ message: `Document path matches no audit.paths glob: ${path}`,
289
+ evidence: [
290
+ {
291
+ kind: "path-layout",
292
+ summary: path,
293
+ uri: document.uri,
294
+ path,
295
+ detail: clip(
296
+ JSON.stringify({ paths: expected.patterns }),
297
+ AUDIT_MAX_EVIDENCE_DETAIL_CHARS
298
+ ),
299
+ },
300
+ ],
301
+ guidance: ["Move the document, or add a matching glob to audit.paths"],
302
+ });
303
+ }
304
+ return contribution("links.path-layout", findings, input, examined, {
305
+ partial: "Path layout scan was truncated",
306
+ done: `${findings.length} documents outside the audit.paths layout`,
307
+ });
308
+ };
309
+
310
+ /** True when any audited collection reports external URLs. */
311
+ export const needsAuditContents = (
312
+ collections: readonly Collection[],
313
+ auditedCollections: readonly string[]
314
+ ): boolean => {
315
+ const audited = new Set(auditedCollections);
316
+ return collections.some(
317
+ ({ name, audit }) => audited.has(name) && audit?.externalUrls === "report"
318
+ );
319
+ };
320
+
321
+ export const evaluateCollectionAuditRules = (
322
+ input: CollectionAuditRuleInput
323
+ ): AuditRuleContribution[] => {
324
+ const configured = configuredFor(input);
325
+ if (configured.size === 0) return [];
326
+ return [
327
+ collectionPolicy(input, configured),
328
+ externalUrls(input, configured),
329
+ pathLayout(input, configured),
330
+ ].filter((rule): rule is AuditRuleContribution => rule !== null);
331
+ };
@@ -17,8 +17,10 @@ import {
17
17
  * 1.1: workspace-wide resolution and reference/resolution/scope evidence.
18
18
  * 1.2: `links.outside-index` for workspace files that are not indexed.
19
19
  * 1.3: relative Markdown links to such files are `outside-index` too.
20
+ * 1.4: opt-in collection rules from `audit` config (collection-policy,
21
+ * external-url, path-layout); unchanged without that config.
20
22
  */
21
- export const LINK_AUDIT_RULE_VERSION = "1.3" as const;
23
+ export const LINK_AUDIT_RULE_VERSION = "1.4" as const;
22
24
  /** Default per-rule cap; runs pass the effective `maxFindings` instead. */
23
25
  export const LINK_AUDIT_MAX_FINDINGS_PER_RULE = 1000;
24
26
 
@@ -30,6 +30,10 @@ import {
30
30
  resolveAuditMaxFindings,
31
31
  runAudit,
32
32
  } from "./audit";
33
+ import {
34
+ evaluateCollectionAuditRules,
35
+ needsAuditContents,
36
+ } from "./audit-collection-rules";
33
37
  import { evaluateFreshnessAudit } from "./audit-freshness";
34
38
  import { evaluateLinkAudit } from "./audit-links";
35
39
  import { markOutsideIndexLinks } from "./audit-outside-index";
@@ -81,10 +85,23 @@ interface WorkspaceDocumentSnapshot {
81
85
 
82
86
  interface WorkspaceSnapshot {
83
87
  documents: WorkspaceDocumentSnapshot[];
88
+ /** Every selected (audited) document row. */
89
+ selected: DocumentRow[];
90
+ /** Indexed text for collections with `audit.externalUrls: report`. */
91
+ contents: Map<string, string>;
84
92
  links: AuditLinkSnapshot;
85
93
  truncated: boolean;
86
94
  }
87
95
 
96
+ /** Collections an audit run covers: the collection filter, else all. */
97
+ const auditedCollectionNames = (
98
+ options: WorkspaceAuditOptions,
99
+ filters: { collections: string[] }
100
+ ): string[] =>
101
+ filters.collections.length > 0
102
+ ? filters.collections
103
+ : options.collections.map(({ name }) => name);
104
+
88
105
  const normalizeValues = (values: readonly string[] | undefined): string[] =>
89
106
  [...new Set((values ?? []).map((value) => value.normalize("NFC").trim()))]
90
107
  .filter(Boolean)
@@ -391,6 +408,26 @@ const emptyLinkSnapshot = (): AuditLinkSnapshot => ({
391
408
  },
392
409
  });
393
410
 
411
+ /** Indexed text for documents in collections that report external URLs. */
412
+ const loadAuditContents = async (
413
+ options: WorkspaceAuditOptions,
414
+ documents: readonly DocumentRow[]
415
+ ): Promise<Map<string, string>> => {
416
+ const reporting = new Set(
417
+ options.collections
418
+ .filter(({ audit }) => audit?.externalUrls === "report")
419
+ .map(({ name }) => name)
420
+ );
421
+ const hashes = documents
422
+ .filter(
423
+ ({ collection, mirrorHash }) => reporting.has(collection) && mirrorHash
424
+ )
425
+ .map(({ mirrorHash }) => mirrorHash as string);
426
+ const loaded = await options.store.getContentBatch(hashes);
427
+ if (!loaded.ok) throw new Error(loaded.error.message);
428
+ return loaded.value;
429
+ };
430
+
394
431
  const loadWorkspaceSnapshot = async (
395
432
  options: WorkspaceAuditOptions,
396
433
  filters: { collections: string[]; paths: string[]; tags: string[] }
@@ -437,8 +474,18 @@ const loadWorkspaceSnapshot = async (
437
474
  selected.truncated,
438
475
  filters.collections
439
476
  );
477
+ const contents =
478
+ options.categories.includes("links") &&
479
+ needsAuditContents(
480
+ options.collections,
481
+ auditedCollectionNames(options, filters)
482
+ )
483
+ ? await loadAuditContents(options, selected.documents)
484
+ : new Map<string, string>();
440
485
  return {
441
486
  documents: observed,
487
+ selected: selected.documents,
488
+ contents,
442
489
  links: options.categories.includes("links")
443
490
  ? await markOutsideIndexLinks(options.store.getRawDb(), links, {
444
491
  signal: options.signal,
@@ -514,13 +561,19 @@ const captureWorkspaceFingerprints = async (
514
561
  options.categories.includes("links") && selected.documents.length > 0
515
562
  ? captureAuditLinkSnapshot(options.store.getRawDb())
516
563
  : null;
564
+ // `audit` is covered only where present on an audited collection, the only
565
+ // place it changes the run, so other fingerprints stay unchanged.
566
+ const audited = new Set(auditedCollectionNames(options, filters));
517
567
  return {
518
568
  config: hashAuditCanonical({
519
- collections: options.collections.map(({ name, path, pattern }) => ({
520
- name,
521
- path,
522
- pattern,
523
- })),
569
+ collections: options.collections.map(
570
+ ({ name, path, pattern, audit }) => ({
571
+ name,
572
+ path,
573
+ pattern,
574
+ ...(audit === undefined || !audited.has(name) ? {} : { audit }),
575
+ })
576
+ ),
524
577
  filters,
525
578
  }),
526
579
  source: hashAuditCanonical({ sourceStats, total: selected.total }),
@@ -665,6 +718,17 @@ export const runWorkspaceAudit = async (
665
718
  rootUris: options.orphanRoots ?? [],
666
719
  ignorePathPrefixes: options.orphanIgnorePrefixes ?? [],
667
720
  maxFindingsPerRule,
721
+ }),
722
+ ...evaluateCollectionAuditRules({
723
+ links: snapshot.links,
724
+ documents: snapshot.selected,
725
+ collections: options.collections,
726
+ auditedCollections: auditedCollectionNames(options, filters),
727
+ contents: snapshot.contents,
728
+ truncated:
729
+ snapshot.links.truncated.documents ||
730
+ snapshot.links.truncated.links,
731
+ maxFindingsPerRule,
668
732
  })
669
733
  );
670
734
  await options.onProgress?.({
@@ -410,7 +410,10 @@ export class CollectionEgressPolicyService {
410
410
  return {
411
411
  ok: false,
412
412
  code: "VALIDATION",
413
- error: "Invalid collection egress scope",
413
+ error:
414
+ error.code === "EGRESS_SCOPE_LIMIT_EXCEEDED"
415
+ ? "Collection egress scope exceeds 128 collections; name the collections to check"
416
+ : "Invalid collection egress scope",
414
417
  };
415
418
  }
416
419
  if (!(error instanceof EgressDeniedError)) throw error;
@@ -13,7 +13,10 @@ import type { EgressLineage, EgressLineageInput } from "./egress-provenance";
13
13
 
14
14
  import { resolveConfiguredEgressPolicy } from "../config/types";
15
15
  import { EgressAuditService } from "./egress-audit";
16
- import { evaluateEgressPolicy } from "./egress-policy";
16
+ import {
17
+ collectionLimitExceededDecision,
18
+ evaluateEgressPolicy,
19
+ } from "./egress-policy";
17
20
  import {
18
21
  EgressProvenanceError,
19
22
  resolveEgressLineage,
@@ -58,8 +61,9 @@ const collectionLineageInputs = (
58
61
  "Collection policy scope must be a readable dense bounded array"
59
62
  );
60
63
  }
64
+ // The configuration is not bounded here; the resolved scope is.
61
65
  const { length } = collections;
62
- if (!Number.isSafeInteger(length) || length > 128) {
66
+ if (!Number.isSafeInteger(length)) {
63
67
  throw new EgressProvenanceError(
64
68
  "INVALID_EGRESS_LINEAGE",
65
69
  "Collection policy scope must be a readable dense bounded array"
@@ -100,11 +104,23 @@ const collectionLineageInputs = (
100
104
  }
101
105
  };
102
106
 
107
+ /** A scope over the lineage bound is a policy denial, never an internal error. */
103
108
  const resolveCollectionEgressLineage = (
104
109
  collections: readonly Collection[],
105
110
  names?: readonly string[]
106
- ): EgressLineage =>
107
- resolveEgressLineage(collectionLineageInputs(collections), names);
111
+ ): EgressLineage => {
112
+ try {
113
+ return resolveEgressLineage(collectionLineageInputs(collections), names);
114
+ } catch (error) {
115
+ if (
116
+ error instanceof EgressProvenanceError &&
117
+ error.code === "EGRESS_SCOPE_LIMIT_EXCEEDED"
118
+ ) {
119
+ throw new EgressDeniedError(collectionLimitExceededDecision());
120
+ }
121
+ throw error;
122
+ }
123
+ };
108
124
 
109
125
  export const collectionEgressStates = (
110
126
  collections: readonly Collection[],
@@ -420,6 +420,10 @@ function evaluateSnapshot(snapshot: EgressEvaluationSnapshot): EgressDecision {
420
420
  return decision(true, "REMOTE_POLICY_AUTHENTICATED", audit);
421
421
  }
422
422
 
423
+ /** The denial for a scope over the collection bound, naming no collection. */
424
+ export const collectionLimitExceededDecision = (): EgressDecision =>
425
+ decision(false, "COLLECTION_LIMIT_EXCEEDED", limitExceededAudit());
426
+
423
427
  export function evaluateEgressPolicy(input: unknown): EgressDecision {
424
428
  try {
425
429
  const result = snapshotInput(input);