@gmickel/gno 1.33.0 → 1.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/assets/skill/SKILL.md +19 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.33.0.zip → gno-browser-clipper-v1.34.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.34.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +44 -0
- package/spec/mcp.md +21 -0
- package/spec/output-schemas/audit-report.schema.json +284 -0
- package/src/cli/commands/audit.ts +231 -0
- package/src/cli/errors.ts +9 -2
- package/src/cli/program.ts +112 -0
- package/src/core/audit-contract.ts +296 -0
- package/src/core/audit-freshness.ts +233 -0
- package/src/core/audit-links.ts +222 -0
- package/src/core/audit-provenance.ts +154 -0
- package/src/core/audit-report.ts +318 -0
- package/src/core/audit-workspace.ts +678 -0
- package/src/core/audit.ts +569 -0
- package/src/core/capture.ts +196 -3
- package/src/core/document-capabilities.ts +9 -8
- package/src/core/record-metadata.ts +33 -0
- package/src/mcp/http-egress.ts +8 -0
- package/src/mcp/tools/audit.ts +97 -0
- package/src/mcp/tools/index.ts +13 -0
- package/src/store/sqlite/adapter.ts +82 -0
- package/src/store/sqlite/graph-link-bulk-resolver.ts +191 -0
- package/src/store/sqlite/graph-link-resolver.ts +241 -2
- package/browser-extension/artifacts/gno-browser-clipper-v1.33.0.zip.sha256 +0 -1
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
/** Real read-only workspace snapshot adapter shared by CLI and MCP. */
|
|
2
|
+
|
|
3
|
+
// node:path has no Bun equivalent.
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import type { Collection, Config } from "../config/types";
|
|
7
|
+
import type { SqliteAdapter } from "../store/sqlite/adapter";
|
|
8
|
+
import type { DocumentRow } from "../store/types";
|
|
9
|
+
import type {
|
|
10
|
+
AuditCategory,
|
|
11
|
+
AuditFingerprints,
|
|
12
|
+
AuditRunResult,
|
|
13
|
+
AuditScope,
|
|
14
|
+
} from "./audit";
|
|
15
|
+
import type {
|
|
16
|
+
AuditFreshnessDocument,
|
|
17
|
+
AuditFreshnessOptions,
|
|
18
|
+
} from "./audit-freshness";
|
|
19
|
+
import type { AuditProvenanceDocument } from "./audit-provenance";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
captureAuditLinkSnapshot,
|
|
23
|
+
type AuditLinkSnapshot,
|
|
24
|
+
} from "../store/sqlite/graph-link-resolver";
|
|
25
|
+
import {
|
|
26
|
+
AUDIT_RULE_SET_VERSION,
|
|
27
|
+
canonicalAuditJson,
|
|
28
|
+
hashAuditCanonical,
|
|
29
|
+
runAudit,
|
|
30
|
+
} from "./audit";
|
|
31
|
+
import { evaluateFreshnessAudit } from "./audit-freshness";
|
|
32
|
+
import { evaluateLinkAudit } from "./audit-links";
|
|
33
|
+
import { evaluateProvenanceAudit } from "./audit-provenance";
|
|
34
|
+
import {
|
|
35
|
+
extractCaptureSourceFromFrontmatter,
|
|
36
|
+
hasDeclaredCaptureSource,
|
|
37
|
+
} from "./capture";
|
|
38
|
+
import { MARKDOWN_SOURCE_EXTENSIONS } from "./document-capabilities";
|
|
39
|
+
import { normalizeTag } from "./tags";
|
|
40
|
+
import { normalizeCollectionName } from "./validation";
|
|
41
|
+
|
|
42
|
+
export const AUDIT_WORKSPACE_MAX_DOCUMENTS = 10_000;
|
|
43
|
+
const AUDIT_PATH_FILTER_MAX_CHARS = 2048;
|
|
44
|
+
const AUDIT_SOURCE_CONCURRENCY = 16;
|
|
45
|
+
const AUDIT_FRONTMATTER_BYTES = 64 * 1024;
|
|
46
|
+
const FRONTMATTER_OPEN = /^---\r?\n/;
|
|
47
|
+
const FRONTMATTER_COMPLETE = /^---\r?\n[\s\S]*?(?:\r?\n)?---(?:\r?\n|$)/;
|
|
48
|
+
|
|
49
|
+
export interface WorkspaceAuditOptions {
|
|
50
|
+
store: SqliteAdapter;
|
|
51
|
+
config: Config;
|
|
52
|
+
collections: readonly Collection[];
|
|
53
|
+
indexName: string;
|
|
54
|
+
categories: readonly AuditCategory[];
|
|
55
|
+
collectionFilters?: readonly string[];
|
|
56
|
+
pathFilters?: readonly string[];
|
|
57
|
+
tagFilters?: readonly string[];
|
|
58
|
+
maxFindings?: number;
|
|
59
|
+
agePolicy?: AuditFreshnessOptions["agePolicy"];
|
|
60
|
+
orphanRoots?: readonly string[];
|
|
61
|
+
orphanIgnorePrefixes?: readonly string[];
|
|
62
|
+
signal?: AbortSignal;
|
|
63
|
+
now?: Date;
|
|
64
|
+
onProgress?: (progress: WorkspaceAuditProgress) => void | Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface WorkspaceAuditProgress {
|
|
68
|
+
phase: "snapshot" | "rules" | "complete";
|
|
69
|
+
completed: number;
|
|
70
|
+
total: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface WorkspaceDocumentSnapshot {
|
|
74
|
+
document: DocumentRow;
|
|
75
|
+
provenance: AuditProvenanceDocument;
|
|
76
|
+
freshness: AuditFreshnessDocument;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface WorkspaceSnapshot {
|
|
80
|
+
documents: WorkspaceDocumentSnapshot[];
|
|
81
|
+
links: AuditLinkSnapshot;
|
|
82
|
+
truncated: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const normalizeValues = (values: readonly string[] | undefined): string[] =>
|
|
86
|
+
[...new Set((values ?? []).map((value) => value.normalize("NFC").trim()))]
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.sort();
|
|
89
|
+
|
|
90
|
+
const collectionPathMap = (
|
|
91
|
+
collections: readonly Collection[]
|
|
92
|
+
): Map<string, string> =>
|
|
93
|
+
new Map(collections.map((collection) => [collection.name, collection.path]));
|
|
94
|
+
|
|
95
|
+
const selectDocuments = async (
|
|
96
|
+
store: SqliteAdapter,
|
|
97
|
+
options: {
|
|
98
|
+
collections: readonly string[];
|
|
99
|
+
paths: readonly string[];
|
|
100
|
+
tags: readonly string[];
|
|
101
|
+
}
|
|
102
|
+
): Promise<{ documents: DocumentRow[]; total: number; truncated: boolean }> => {
|
|
103
|
+
const listed = await store.listDocumentsForAudit({
|
|
104
|
+
collections: options.collections,
|
|
105
|
+
pathPrefixes: options.paths,
|
|
106
|
+
tags: options.tags,
|
|
107
|
+
limit: AUDIT_WORKSPACE_MAX_DOCUMENTS,
|
|
108
|
+
});
|
|
109
|
+
if (!listed.ok) throw new Error(listed.error.message);
|
|
110
|
+
return {
|
|
111
|
+
documents: listed.value.documents,
|
|
112
|
+
total: listed.value.total,
|
|
113
|
+
truncated: listed.value.total > AUDIT_WORKSPACE_MAX_DOCUMENTS,
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const hashBlob = async (file: Blob, signal?: AbortSignal): Promise<string> => {
|
|
118
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
119
|
+
signal?.throwIfAborted();
|
|
120
|
+
for await (const chunk of file.stream()) {
|
|
121
|
+
signal?.throwIfAborted();
|
|
122
|
+
hasher.update(chunk);
|
|
123
|
+
}
|
|
124
|
+
return hasher.digest("hex");
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const mapConcurrent = async <T, R>(
|
|
128
|
+
values: readonly T[],
|
|
129
|
+
mapper: (value: T) => Promise<R>
|
|
130
|
+
): Promise<R[]> => {
|
|
131
|
+
const output = Array.from({ length: values.length }, () => undefined as R);
|
|
132
|
+
let cursor = 0;
|
|
133
|
+
const worker = async (): Promise<void> => {
|
|
134
|
+
while (cursor < values.length) {
|
|
135
|
+
const index = cursor;
|
|
136
|
+
cursor += 1;
|
|
137
|
+
const value = values[index];
|
|
138
|
+
if (value !== undefined) output[index] = await mapper(value);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
await Promise.all(
|
|
142
|
+
Array.from(
|
|
143
|
+
{ length: Math.min(AUDIT_SOURCE_CONCURRENCY, values.length) },
|
|
144
|
+
worker
|
|
145
|
+
)
|
|
146
|
+
);
|
|
147
|
+
return output;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export interface AuditPhysicalSourceDescriptor {
|
|
151
|
+
key: string;
|
|
152
|
+
collection: string;
|
|
153
|
+
relPath: string;
|
|
154
|
+
markdownSource: boolean;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Collapse logical records that share one physical export/container. */
|
|
158
|
+
export const groupAuditPhysicalSources = (
|
|
159
|
+
documents: readonly DocumentRow[]
|
|
160
|
+
): AuditPhysicalSourceDescriptor[] => {
|
|
161
|
+
const grouped = new Map<string, AuditPhysicalSourceDescriptor>();
|
|
162
|
+
for (const document of documents) {
|
|
163
|
+
const recordSourcePath = document.recordSourcePath?.trim();
|
|
164
|
+
const relPath =
|
|
165
|
+
document.recordKey != null && recordSourcePath
|
|
166
|
+
? recordSourcePath
|
|
167
|
+
: document.relPath;
|
|
168
|
+
const key = JSON.stringify([document.collection, relPath]);
|
|
169
|
+
const markdownSource = MARKDOWN_SOURCE_EXTENSIONS.has(
|
|
170
|
+
document.sourceExt.toLowerCase()
|
|
171
|
+
);
|
|
172
|
+
const existing = grouped.get(key);
|
|
173
|
+
if (existing) {
|
|
174
|
+
existing.markdownSource ||= markdownSource;
|
|
175
|
+
} else {
|
|
176
|
+
grouped.set(key, {
|
|
177
|
+
key,
|
|
178
|
+
collection: document.collection,
|
|
179
|
+
relPath,
|
|
180
|
+
markdownSource,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return [...grouped.values()].sort((left, right) =>
|
|
185
|
+
left.key < right.key ? -1 : left.key > right.key ? 1 : 0
|
|
186
|
+
);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const observeDocument = async (
|
|
190
|
+
document: DocumentRow,
|
|
191
|
+
roots: ReadonlyMap<string, string>,
|
|
192
|
+
readFrontmatter: boolean,
|
|
193
|
+
inspectFreshness: boolean,
|
|
194
|
+
signal?: AbortSignal
|
|
195
|
+
): Promise<WorkspaceDocumentSnapshot> => {
|
|
196
|
+
const markdownSource = MARKDOWN_SOURCE_EXTENSIONS.has(
|
|
197
|
+
document.sourceExt.toLowerCase()
|
|
198
|
+
);
|
|
199
|
+
const root = roots.get(document.collection);
|
|
200
|
+
if (!root) {
|
|
201
|
+
return {
|
|
202
|
+
document,
|
|
203
|
+
provenance: {
|
|
204
|
+
uri: document.uri,
|
|
205
|
+
relPath: document.relPath,
|
|
206
|
+
sourceState: "unreadable",
|
|
207
|
+
captureSourceSupported: markdownSource,
|
|
208
|
+
captureSourceDeclared: false,
|
|
209
|
+
record: document,
|
|
210
|
+
},
|
|
211
|
+
freshness: {
|
|
212
|
+
uri: document.uri,
|
|
213
|
+
relPath: document.relPath,
|
|
214
|
+
contentType: document.contentType ?? null,
|
|
215
|
+
indexedSourceHash: document.sourceHash,
|
|
216
|
+
indexedSourceMtime: document.sourceMtime,
|
|
217
|
+
indexedAt: document.indexedAt ?? null,
|
|
218
|
+
lastErrorCode: document.lastErrorCode,
|
|
219
|
+
source: { state: "unreadable", hash: null, mtime: null },
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const recordSourcePath = document.recordSourcePath?.trim();
|
|
224
|
+
const logicalRecord =
|
|
225
|
+
document.recordKey != null &&
|
|
226
|
+
recordSourcePath !== undefined &&
|
|
227
|
+
recordSourcePath.length > 0;
|
|
228
|
+
const file = Bun.file(
|
|
229
|
+
join(root, logicalRecord ? recordSourcePath : document.relPath)
|
|
230
|
+
);
|
|
231
|
+
try {
|
|
232
|
+
if (!(await file.exists())) {
|
|
233
|
+
return {
|
|
234
|
+
document,
|
|
235
|
+
provenance: {
|
|
236
|
+
uri: document.uri,
|
|
237
|
+
relPath: document.relPath,
|
|
238
|
+
sourceState: "missing",
|
|
239
|
+
captureSourceSupported: markdownSource,
|
|
240
|
+
captureSourceDeclared: false,
|
|
241
|
+
record: document,
|
|
242
|
+
},
|
|
243
|
+
freshness: {
|
|
244
|
+
uri: document.uri,
|
|
245
|
+
relPath: document.relPath,
|
|
246
|
+
contentType: document.contentType ?? null,
|
|
247
|
+
indexedSourceHash: document.sourceHash,
|
|
248
|
+
indexedSourceMtime: document.sourceMtime,
|
|
249
|
+
indexedAt: document.indexedAt ?? null,
|
|
250
|
+
lastErrorCode: document.lastErrorCode,
|
|
251
|
+
source: { state: "missing", hash: null, mtime: null },
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const beforeMtime = file.lastModified;
|
|
256
|
+
const beforeSize = file.size;
|
|
257
|
+
// Freshness must hash readable bytes even when size/mtime still match the
|
|
258
|
+
// indexed metadata — metadata-preserving restores can drift without a
|
|
259
|
+
// stat change.
|
|
260
|
+
const observedHash =
|
|
261
|
+
inspectFreshness && !logicalRecord
|
|
262
|
+
? await hashBlob(file, signal)
|
|
263
|
+
: inspectFreshness
|
|
264
|
+
? null
|
|
265
|
+
: document.sourceHash;
|
|
266
|
+
const frontmatter =
|
|
267
|
+
readFrontmatter && markdownSource
|
|
268
|
+
? await file.slice(0, AUDIT_FRONTMATTER_BYTES).text()
|
|
269
|
+
: "";
|
|
270
|
+
const incompleteFrontmatter =
|
|
271
|
+
readFrontmatter &&
|
|
272
|
+
markdownSource &&
|
|
273
|
+
FRONTMATTER_OPEN.test(frontmatter) &&
|
|
274
|
+
!FRONTMATTER_COMPLETE.test(frontmatter);
|
|
275
|
+
const afterMtime = file.lastModified;
|
|
276
|
+
const afterSize = file.size;
|
|
277
|
+
return {
|
|
278
|
+
document,
|
|
279
|
+
provenance: {
|
|
280
|
+
uri: document.uri,
|
|
281
|
+
relPath: document.relPath,
|
|
282
|
+
sourceState: incompleteFrontmatter ? "unreadable" : "readable",
|
|
283
|
+
captureSourceSupported: markdownSource,
|
|
284
|
+
captureSourceDeclared:
|
|
285
|
+
!incompleteFrontmatter && hasDeclaredCaptureSource(frontmatter),
|
|
286
|
+
captureSource: incompleteFrontmatter
|
|
287
|
+
? undefined
|
|
288
|
+
: extractCaptureSourceFromFrontmatter(frontmatter),
|
|
289
|
+
record: document,
|
|
290
|
+
},
|
|
291
|
+
freshness: {
|
|
292
|
+
uri: document.uri,
|
|
293
|
+
relPath: document.relPath,
|
|
294
|
+
contentType: document.contentType ?? null,
|
|
295
|
+
indexedSourceHash: document.sourceHash,
|
|
296
|
+
indexedSourceMtime: document.sourceMtime,
|
|
297
|
+
indexedAt: document.indexedAt ?? null,
|
|
298
|
+
lastErrorCode: document.lastErrorCode,
|
|
299
|
+
source: {
|
|
300
|
+
state: "readable",
|
|
301
|
+
hash: observedHash,
|
|
302
|
+
mtime: new Date(afterMtime).toISOString(),
|
|
303
|
+
byteComparable: !logicalRecord,
|
|
304
|
+
changedDuringRead:
|
|
305
|
+
beforeMtime !== afterMtime || beforeSize !== afterSize,
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
} catch (cause) {
|
|
310
|
+
if (signal?.aborted) throw cause;
|
|
311
|
+
return {
|
|
312
|
+
document,
|
|
313
|
+
provenance: {
|
|
314
|
+
uri: document.uri,
|
|
315
|
+
relPath: document.relPath,
|
|
316
|
+
sourceState: "unreadable",
|
|
317
|
+
captureSourceSupported: markdownSource,
|
|
318
|
+
captureSourceDeclared: false,
|
|
319
|
+
record: document,
|
|
320
|
+
},
|
|
321
|
+
freshness: {
|
|
322
|
+
uri: document.uri,
|
|
323
|
+
relPath: document.relPath,
|
|
324
|
+
contentType: document.contentType ?? null,
|
|
325
|
+
indexedSourceHash: document.sourceHash,
|
|
326
|
+
indexedSourceMtime: document.sourceMtime,
|
|
327
|
+
indexedAt: document.indexedAt ?? null,
|
|
328
|
+
lastErrorCode: document.lastErrorCode,
|
|
329
|
+
source: { state: "unreadable", hash: null, mtime: null },
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const filterLinkSnapshot = (
|
|
336
|
+
snapshot: AuditLinkSnapshot,
|
|
337
|
+
selectedIds: ReadonlySet<number>,
|
|
338
|
+
selectedDocuments: readonly DocumentRow[],
|
|
339
|
+
selectionTruncated: boolean
|
|
340
|
+
): AuditLinkSnapshot => {
|
|
341
|
+
const links = snapshot.links.filter(
|
|
342
|
+
(link) =>
|
|
343
|
+
selectedIds.has(link.sourceId) ||
|
|
344
|
+
(link.resolved !== null && selectedIds.has(link.resolved.targetId))
|
|
345
|
+
);
|
|
346
|
+
const outgoingTotal = snapshot.links.filter((link) =>
|
|
347
|
+
selectedIds.has(link.sourceId)
|
|
348
|
+
).length;
|
|
349
|
+
return {
|
|
350
|
+
...snapshot,
|
|
351
|
+
// Preserve graph-wide documents as duplicate-mirror evidence while the
|
|
352
|
+
// explicit id set prevents findings outside the requested audit scope.
|
|
353
|
+
auditedDocumentIds: [...selectedIds],
|
|
354
|
+
links,
|
|
355
|
+
totals: { documents: selectedDocuments.length, links: outgoingTotal },
|
|
356
|
+
truncated: {
|
|
357
|
+
documents: selectionTruncated || snapshot.truncated.documents,
|
|
358
|
+
links: snapshot.truncated.links,
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const emptyLinkSnapshot = (): AuditLinkSnapshot => ({
|
|
364
|
+
documents: [],
|
|
365
|
+
links: [],
|
|
366
|
+
totals: { documents: 0, links: 0 },
|
|
367
|
+
truncated: { documents: false, links: false },
|
|
368
|
+
metrics: {
|
|
369
|
+
documentRowsExamined: 0,
|
|
370
|
+
linkRowsExamined: 0,
|
|
371
|
+
uniqueTargetsResolved: 0,
|
|
372
|
+
batchedResolution: true,
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const loadWorkspaceSnapshot = async (
|
|
377
|
+
options: WorkspaceAuditOptions,
|
|
378
|
+
filters: { collections: string[]; paths: string[]; tags: string[] }
|
|
379
|
+
): Promise<WorkspaceSnapshot> => {
|
|
380
|
+
const selected = await selectDocuments(options.store, filters);
|
|
381
|
+
const roots = collectionPathMap(options.collections);
|
|
382
|
+
const needsSourceObservation =
|
|
383
|
+
options.categories.includes("provenance") ||
|
|
384
|
+
options.categories.includes("freshness");
|
|
385
|
+
const observed = needsSourceObservation
|
|
386
|
+
? await mapConcurrent(selected.documents, (document) =>
|
|
387
|
+
observeDocument(
|
|
388
|
+
document,
|
|
389
|
+
roots,
|
|
390
|
+
options.categories.includes("provenance"),
|
|
391
|
+
options.categories.includes("freshness"),
|
|
392
|
+
options.signal
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
: [];
|
|
396
|
+
await options.onProgress?.({
|
|
397
|
+
phase: "snapshot",
|
|
398
|
+
completed: selected.documents.length,
|
|
399
|
+
total: selected.total,
|
|
400
|
+
});
|
|
401
|
+
// Capture the bounded graph before narrowing link scope so incoming edges
|
|
402
|
+
// from outside a filter still prevent false orphans. Source-only audits do
|
|
403
|
+
// not touch the unrelated graph.
|
|
404
|
+
const rawLinks =
|
|
405
|
+
options.categories.includes("links") && selected.documents.length > 0
|
|
406
|
+
? captureAuditLinkSnapshot(options.store.getRawDb())
|
|
407
|
+
: emptyLinkSnapshot();
|
|
408
|
+
const selectedIds = new Set(selected.documents.map(({ id }) => id));
|
|
409
|
+
return {
|
|
410
|
+
documents: observed,
|
|
411
|
+
links: filterLinkSnapshot(
|
|
412
|
+
rawLinks,
|
|
413
|
+
selectedIds,
|
|
414
|
+
selected.documents,
|
|
415
|
+
selected.truncated
|
|
416
|
+
),
|
|
417
|
+
truncated: selected.truncated,
|
|
418
|
+
};
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const captureWorkspaceFingerprints = async (
|
|
422
|
+
options: WorkspaceAuditOptions,
|
|
423
|
+
filters: { collections: string[]; paths: string[]; tags: string[] }
|
|
424
|
+
): Promise<AuditFingerprints> => {
|
|
425
|
+
options.signal?.throwIfAborted();
|
|
426
|
+
const selected = await selectDocuments(options.store, filters);
|
|
427
|
+
const needsSourceFingerprint =
|
|
428
|
+
options.categories.includes("provenance") ||
|
|
429
|
+
options.categories.includes("freshness");
|
|
430
|
+
const inspectFreshness = options.categories.includes("freshness");
|
|
431
|
+
const inspectProvenance = options.categories.includes("provenance");
|
|
432
|
+
const roots = collectionPathMap(options.collections);
|
|
433
|
+
const sourceStats = needsSourceFingerprint
|
|
434
|
+
? await mapConcurrent(
|
|
435
|
+
groupAuditPhysicalSources(selected.documents),
|
|
436
|
+
async (source) => {
|
|
437
|
+
const root = roots.get(source.collection);
|
|
438
|
+
if (!root)
|
|
439
|
+
return {
|
|
440
|
+
collection: source.collection,
|
|
441
|
+
path: source.relPath,
|
|
442
|
+
state: "unavailable",
|
|
443
|
+
};
|
|
444
|
+
const file = Bun.file(join(root, source.relPath));
|
|
445
|
+
try {
|
|
446
|
+
const exists = await file.exists();
|
|
447
|
+
if (!exists)
|
|
448
|
+
return {
|
|
449
|
+
collection: source.collection,
|
|
450
|
+
path: source.relPath,
|
|
451
|
+
state: "missing",
|
|
452
|
+
};
|
|
453
|
+
const hash = inspectFreshness
|
|
454
|
+
? await hashBlob(file, options.signal)
|
|
455
|
+
: inspectProvenance && source.markdownSource
|
|
456
|
+
? await hashBlob(
|
|
457
|
+
file.slice(0, AUDIT_FRONTMATTER_BYTES),
|
|
458
|
+
options.signal
|
|
459
|
+
)
|
|
460
|
+
: null;
|
|
461
|
+
return {
|
|
462
|
+
collection: source.collection,
|
|
463
|
+
path: source.relPath,
|
|
464
|
+
state: "readable",
|
|
465
|
+
size: file.size,
|
|
466
|
+
mtime: file.lastModified,
|
|
467
|
+
hash,
|
|
468
|
+
};
|
|
469
|
+
} catch (cause) {
|
|
470
|
+
if (options.signal?.aborted) throw cause;
|
|
471
|
+
return {
|
|
472
|
+
collection: source.collection,
|
|
473
|
+
path: source.relPath,
|
|
474
|
+
state: "unavailable",
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
)
|
|
479
|
+
: [];
|
|
480
|
+
// Link audits fingerprint the same unscoped bounded graph capture the rules
|
|
481
|
+
// use, so concurrent doc_links / resolution changes retry or report
|
|
482
|
+
// changed_during_audit even when document revision fields are unchanged.
|
|
483
|
+
const linkGraph =
|
|
484
|
+
options.categories.includes("links") && selected.documents.length > 0
|
|
485
|
+
? captureAuditLinkSnapshot(options.store.getRawDb())
|
|
486
|
+
: null;
|
|
487
|
+
return {
|
|
488
|
+
config: hashAuditCanonical({
|
|
489
|
+
collections: options.collections.map(({ name, path, pattern }) => ({
|
|
490
|
+
name,
|
|
491
|
+
path,
|
|
492
|
+
pattern,
|
|
493
|
+
})),
|
|
494
|
+
filters,
|
|
495
|
+
}),
|
|
496
|
+
source: hashAuditCanonical({ sourceStats, total: selected.total }),
|
|
497
|
+
index: hashAuditCanonical({
|
|
498
|
+
documents: selected.documents.map((document) => ({
|
|
499
|
+
uri: document.uri,
|
|
500
|
+
relPath: document.relPath,
|
|
501
|
+
sourceExt: document.sourceExt,
|
|
502
|
+
sourceHash: document.sourceHash,
|
|
503
|
+
sourceMtime: document.sourceMtime,
|
|
504
|
+
contentType: document.contentType ?? null,
|
|
505
|
+
indexedAt: document.indexedAt ?? null,
|
|
506
|
+
lastErrorCode: document.lastErrorCode,
|
|
507
|
+
converterId: document.converterId,
|
|
508
|
+
converterVersion: document.converterVersion,
|
|
509
|
+
recordKey: document.recordKey ?? null,
|
|
510
|
+
recordSourcePath: document.recordSourcePath ?? null,
|
|
511
|
+
recordSourceLocator: document.recordSourceLocator ?? null,
|
|
512
|
+
recordAdapterFingerprint: document.recordAdapterFingerprint ?? null,
|
|
513
|
+
recordMetadata: document.recordMetadata ?? null,
|
|
514
|
+
recordAnchors: document.recordAnchors ?? null,
|
|
515
|
+
})),
|
|
516
|
+
total: selected.total,
|
|
517
|
+
linkGraph,
|
|
518
|
+
}),
|
|
519
|
+
rules: hashAuditCanonical({
|
|
520
|
+
ruleSet: AUDIT_RULE_SET_VERSION,
|
|
521
|
+
categories: options.categories,
|
|
522
|
+
agePolicy: options.agePolicy ?? null,
|
|
523
|
+
orphanRoots: normalizeValues(options.orphanRoots),
|
|
524
|
+
orphanIgnorePrefixes: normalizeValues(options.orphanIgnorePrefixes),
|
|
525
|
+
}),
|
|
526
|
+
};
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
export const runWorkspaceAudit = async (
|
|
530
|
+
options: WorkspaceAuditOptions
|
|
531
|
+
): Promise<AuditRunResult> => {
|
|
532
|
+
const rawPaths = options.pathFilters ?? [];
|
|
533
|
+
const normalizedPathInputs = rawPaths.map((path) =>
|
|
534
|
+
path.normalize("NFC").trim()
|
|
535
|
+
);
|
|
536
|
+
if (normalizedPathInputs.some((path) => path.length === 0)) {
|
|
537
|
+
return {
|
|
538
|
+
ok: false,
|
|
539
|
+
exit: "invalid",
|
|
540
|
+
error: "path filters must not be empty or whitespace-only",
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
if (
|
|
544
|
+
normalizedPathInputs.some(
|
|
545
|
+
(path) => path.length > AUDIT_PATH_FILTER_MAX_CHARS
|
|
546
|
+
)
|
|
547
|
+
) {
|
|
548
|
+
return {
|
|
549
|
+
ok: false,
|
|
550
|
+
exit: "invalid",
|
|
551
|
+
error: `path filters must be at most ${AUDIT_PATH_FILTER_MAX_CHARS} characters`,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
const normalizedPaths = normalizeValues(normalizedPathInputs).map((path) =>
|
|
555
|
+
path.replace(/^\/+|\/+$/g, "")
|
|
556
|
+
);
|
|
557
|
+
const filters = {
|
|
558
|
+
collections: normalizeValues(options.collectionFilters).map(
|
|
559
|
+
normalizeCollectionName
|
|
560
|
+
),
|
|
561
|
+
// A root-like prefix means the whole selected collection. Remove it from
|
|
562
|
+
// both selection and reported scope instead of producing an empty match.
|
|
563
|
+
paths: normalizedPaths.includes("") ? [] : normalizedPaths,
|
|
564
|
+
tags: normalizeValues(options.tagFilters).map(normalizeTag),
|
|
565
|
+
};
|
|
566
|
+
const scope: AuditScope = {
|
|
567
|
+
categories: [...options.categories],
|
|
568
|
+
collections: filters.collections,
|
|
569
|
+
paths: filters.paths,
|
|
570
|
+
tags: filters.tags,
|
|
571
|
+
indexName: options.indexName,
|
|
572
|
+
};
|
|
573
|
+
await options.onProgress?.({ phase: "snapshot", completed: 0, total: 1 });
|
|
574
|
+
const snapshots = new Map<number, Promise<WorkspaceSnapshot>>();
|
|
575
|
+
const snapshotFor = (attempt: number): Promise<WorkspaceSnapshot> => {
|
|
576
|
+
const existing = snapshots.get(attempt);
|
|
577
|
+
if (existing) return existing;
|
|
578
|
+
const pending = loadWorkspaceSnapshot(options, filters);
|
|
579
|
+
snapshots.set(attempt, pending);
|
|
580
|
+
return pending;
|
|
581
|
+
};
|
|
582
|
+
const result = await runAudit({
|
|
583
|
+
scope,
|
|
584
|
+
capabilities: {
|
|
585
|
+
indexReadable: true,
|
|
586
|
+
sourcesReadable: true,
|
|
587
|
+
linksGraphAvailable: true,
|
|
588
|
+
provenanceSchemaAvailable: true,
|
|
589
|
+
offline: true,
|
|
590
|
+
llmDisabled: true,
|
|
591
|
+
},
|
|
592
|
+
captureFingerprints: () => captureWorkspaceFingerprints(options, filters),
|
|
593
|
+
signal: options.signal,
|
|
594
|
+
maxFindings: options.maxFindings,
|
|
595
|
+
rules: [
|
|
596
|
+
async ({ attempt }) => {
|
|
597
|
+
if (options.signal?.aborted) {
|
|
598
|
+
return {
|
|
599
|
+
ruleId: "audit.cancelled",
|
|
600
|
+
category: options.categories[0] ?? "links",
|
|
601
|
+
status: "inconclusive",
|
|
602
|
+
message: "Audit was cancelled",
|
|
603
|
+
findings: [],
|
|
604
|
+
findingCount: 0,
|
|
605
|
+
skipReason: "cancelled",
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
const snapshot = await snapshotFor(attempt);
|
|
609
|
+
if (options.signal?.aborted) {
|
|
610
|
+
return {
|
|
611
|
+
ruleId: "audit.cancelled",
|
|
612
|
+
category: options.categories[0] ?? "links",
|
|
613
|
+
status: "inconclusive",
|
|
614
|
+
message: "Audit was cancelled",
|
|
615
|
+
findings: [],
|
|
616
|
+
findingCount: 0,
|
|
617
|
+
skipReason: "cancelled",
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
const contributions = [];
|
|
621
|
+
await options.onProgress?.({
|
|
622
|
+
phase: "rules",
|
|
623
|
+
completed: 0,
|
|
624
|
+
total: options.categories.length,
|
|
625
|
+
});
|
|
626
|
+
if (options.categories.includes("links")) {
|
|
627
|
+
contributions.push(
|
|
628
|
+
...evaluateLinkAudit(snapshot.links, {
|
|
629
|
+
rootUris: options.orphanRoots ?? [],
|
|
630
|
+
ignorePathPrefixes: options.orphanIgnorePrefixes ?? [],
|
|
631
|
+
})
|
|
632
|
+
);
|
|
633
|
+
await options.onProgress?.({
|
|
634
|
+
phase: "rules",
|
|
635
|
+
completed: 1,
|
|
636
|
+
total: options.categories.length,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
if (options.categories.includes("provenance")) {
|
|
640
|
+
contributions.push(
|
|
641
|
+
...evaluateProvenanceAudit(
|
|
642
|
+
snapshot.documents.map(({ provenance }) => provenance),
|
|
643
|
+
{ truncated: snapshot.truncated }
|
|
644
|
+
)
|
|
645
|
+
);
|
|
646
|
+
await options.onProgress?.({
|
|
647
|
+
phase: "rules",
|
|
648
|
+
completed: Number(options.categories.includes("links")) + 1,
|
|
649
|
+
total: options.categories.length,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
if (options.categories.includes("freshness")) {
|
|
653
|
+
contributions.push(
|
|
654
|
+
...evaluateFreshnessAudit(
|
|
655
|
+
snapshot.documents.map(({ freshness }) => freshness),
|
|
656
|
+
{
|
|
657
|
+
now: options.now ?? new Date(),
|
|
658
|
+
agePolicy: options.agePolicy,
|
|
659
|
+
truncated: snapshot.truncated,
|
|
660
|
+
}
|
|
661
|
+
)
|
|
662
|
+
);
|
|
663
|
+
await options.onProgress?.({
|
|
664
|
+
phase: "rules",
|
|
665
|
+
completed: options.categories.length,
|
|
666
|
+
total: options.categories.length,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
return contributions;
|
|
670
|
+
},
|
|
671
|
+
],
|
|
672
|
+
});
|
|
673
|
+
await options.onProgress?.({ phase: "complete", completed: 1, total: 1 });
|
|
674
|
+
return result;
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
export const auditReportBytes = (result: AuditRunResult): number =>
|
|
678
|
+
new TextEncoder().encode(canonicalAuditJson(result)).byteLength;
|