@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 CHANGED
@@ -117,7 +117,7 @@ gno daemon --detach # headless indexing + resident MCP gateway
117
117
 
118
118
  <!-- public-truth:current-version -->
119
119
 
120
- > Current release: **v1.33.0** — see [CHANGELOG.md](./CHANGELOG.md)
120
+ > Current release: **v1.34.0** — see [CHANGELOG.md](./CHANGELOG.md)
121
121
 
122
122
  <!-- /public-truth -->
123
123
 
@@ -174,6 +174,10 @@ gno daemon --detach # headless indexing + resident MCP gateway
174
174
  `local_only`, `lan`, or `remote` boundary that follows mixed and derived
175
175
  evidence through resident serving, inference, publishing, exports, Capsules,
176
176
  and traces. Authentication never overrides policy.
177
+ - **Read-only integrity audits**: `gno audit` and MCP `gno_audit` inspect local
178
+ links, declared provenance, and source/index freshness offline. Stable,
179
+ bounded findings distinguish complete, partial, unavailable, and changing
180
+ evidence without repairing or persisting anything.
177
181
  - **Schema-lite content types**: optional `contentTypes` rules map configured
178
182
  frontmatter `type` values or path prefixes to canonical `contentType` metadata
179
183
  in JSON search/query results and can apply one bounded, explainable
@@ -945,7 +949,8 @@ graph TD
945
949
  | **Local LLM** | AI answers via llama.cpp, no API keys |
946
950
  | **Remote Inference** | Optional HTTP endpoints for embedding, reranking, expansion, and generation |
947
951
  | **Privacy First** | Fail-closed per-collection egress policy; no telemetry; explicit network use |
948
- | **MCP Server** | 10 automatic client targets; 25 read-only tools, 40 with writes enabled |
952
+ | **MCP Server** | 10 automatic client targets; 32 read-only tools, 50 with writes enabled |
953
+ | **Integrity Audits** | Offline link, declared-provenance, and freshness reports with stable IDs |
949
954
  | **Knowledge Delta** | Bounded metadata history, structural diffs, and dependency impact paths |
950
955
  | **Context Capsules** | Deterministic evidence bundles plus saved-file freshness reverification |
951
956
  | **Verified Ask** | Claim-by-claim evidence checks with explicit abstention |
@@ -313,6 +313,23 @@ at the remote service. For gno.sh, supported private links can be revoked or
313
313
  expired in Studio; public-space deletion is not yet self-service, so request
314
314
  takedown. Encrypted gno.sh shares are client-encrypted and never server-decrypted.
315
315
 
316
+ ## Read-Only Knowledge Integrity Audits
317
+
318
+ Use `gno audit [links|provenance|freshness|all] --json` or read-only MCP
319
+ `gno_audit` only when the user asks what needs attention in a workspace. These
320
+ offline audits inspect parsed local links, explicitly declared capture/logical-
321
+ record provenance, and observable source/index freshness. They never repair,
322
+ rewrite, persist findings, judge factual truth, or replace retrieval.
323
+
324
+ Treat exit `4` as a complete report with findings. Exit `5` or report status
325
+ `partial`/`changed_during_audit` means evidence is unavailable, inconclusive,
326
+ cancelled, truncated, or repeatedly changed—never healthy. Preserve stable
327
+ finding IDs and exact totals when summarizing bounded results. Age is only a
328
+ review signal when `maxAgeDays`/`--max-age-days` is explicitly supplied. Apply
329
+ collection/path/tag scope and orphan roots/ignore prefixes only from the user's
330
+ request. `gno egress-audit` is separate: it manages content-free transport-
331
+ policy receipts.
332
+
316
333
  ## MCP Retrieval Strategy
317
334
 
318
335
  For a long-lived client that supports Streamable HTTP, start one resident owner
@@ -377,6 +394,8 @@ Use narrower tools when the request tells you to:
377
394
  - `gno_search`: exact phrase, filename, identifier, stack trace, error text
378
395
  - `gno_vsearch`: conceptual similarity when exact wording differs
379
396
  - `gno_status`: stale results, missing embeddings, vector unavailable
397
+ - `gno_audit`: explicit offline workspace-integrity review; report findings and
398
+ partial evidence, never mutate or imply repairs
380
399
  - `gno_graph`: graph report/stats, hubs, isolates, unresolved links, edge confidence/audit, communities, unfamiliar corpus overview
381
400
  - `gno_graph_query`: bounded typed-edge traversal from a known document
382
401
  - `gno_graph_neighbors`: relationship/corpus-navigation questions around a known document
@@ -0,0 +1 @@
1
+ 0acc125d848721bdced540fe49412ef8c83df5d4ff3ba086076c46ed9d441998 gno-browser-clipper-v1.34.0.zip
@@ -21,5 +21,5 @@
21
21
  "content_security_policy": {
22
22
  "extension_pages": "script-src 'self'; object-src 'none'; connect-src http://127.0.0.1:*"
23
23
  },
24
- "version": "1.33.0"
24
+ "version": "1.34.0"
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "1.33.0",
3
+ "version": "1.34.0",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
package/spec/cli.md CHANGED
@@ -692,6 +692,50 @@ projection.
692
692
  content-free decision, lineage, partial disclosure, audit metadata, and
693
693
  remediation contract.
694
694
 
695
+ ### gno audit
696
+
697
+ Read-only, offline knowledge-integrity audits. This command is distinct from
698
+ `gno egress-audit`: it examines workspace links, declared provenance
699
+ requirements, and source/index freshness without persisting findings or
700
+ modifying notes, configuration, index rows, graph edges, or daemon state.
701
+
702
+ ```bash
703
+ gno audit [links|provenance|freshness|all] [--collection <name>...] \
704
+ [--path <prefix>...] [--tag <tag>...] [--max-findings <n>] \
705
+ [--max-age-days <days>] [--orphan-root <uri>...] \
706
+ [--orphan-ignore-prefix <prefix>...] [--no-progress] \
707
+ [--json] [--output <path>]
708
+ ```
709
+
710
+ The default category is `all`; `--max-findings` defaults to 100 and is bounded
711
+ to 1–1000. Truncation limits returned findings but preserves exact totals.
712
+ `--output` writes only the requested report artifact with local file
713
+ permissions. Human output renders the same report represented by
714
+ `audit-report.schema.json`.
715
+ When attached to a terminal, human mode reports snapshot/rule progress on
716
+ stderr; `--no-progress`, `--quiet`, and JSON mode suppress it. `Ctrl-C` returns
717
+ the bounded report gathered so far with partial status and exit 5. Freshness
718
+ age is reported only when `--max-age-days` supplies an explicit policy.
719
+
720
+ Rule statuses are `pass`, `fail`, `skip`, `unavailable`, and `inconclusive`.
721
+ Report statuses are `complete`, `partial`, `changed_during_audit`, and `failed`.
722
+ A skipped, unavailable, inconclusive, or changing check is never rendered as
723
+ healthy.
724
+
725
+ **Exit Codes:**
726
+
727
+ - `0` — complete and clean
728
+ - `1` — invalid input
729
+ - `2` — runtime failure
730
+ - `4` — complete report with findings
731
+ - `5` — partial, inconclusive, unavailable, or changed-during-audit evidence
732
+
733
+ The JSON contract is versioned as `gno://schemas/audit-report@1.0`. Finding IDs
734
+ are stable SHA-256 identities derived from rule, normalized subject/location,
735
+ and load-bearing evidence. Wall-clock timing and traversal order do not affect
736
+ identity. A source/index fingerprint change causes one bounded retry and then
737
+ `changed_during_audit`; it can never produce a clean exit.
738
+
695
739
  ### gno egress-audit
696
740
 
697
741
  Manage local content-free policy decision receipts:
package/spec/mcp.md CHANGED
@@ -152,6 +152,27 @@ Collection names are case-insensitive on input and normalized to lowercase in re
152
152
 
153
153
  ## Tools
154
154
 
155
+ ### Knowledge integrity audit contract
156
+
157
+ `gno_audit` is a read-only, offline tool that returns the same
158
+ `gno://schemas/audit-report@1.0` report as `gno audit`. Its closed input accepts
159
+ `category` (`links`, `provenance`, `freshness`, or `all`), collection/path/tag
160
+ filters, and `maxFindings` (1–1000). `maxAgeDays`, `orphanRoots`, and
161
+ `orphanIgnorePrefixes` are optional explicit policy inputs. Request
162
+ cancellation returns a partial report rather than a false clean result. The
163
+ tool is annotated with
164
+ `readOnlyHint: true`; the implementation must additionally prove that it never
165
+ modifies source files, config, index rows, graph edges, daemon state, or a
166
+ persisted audit baseline.
167
+
168
+ Rules report `pass`, `fail`, `skip`, `unavailable`, or `inconclusive`; reports
169
+ are `complete`, `partial`, `changed_during_audit`, or `failed`. Unavailable or
170
+ changing evidence never appears healthy. Stable finding IDs are derived from
171
+ rule, normalized subject/location, and evidence fingerprint. Responses are
172
+ canonically ordered and bounded while retaining exact pre-truncation totals.
173
+ This knowledge audit is distinct from the content-free `gno_egress_audit_*`
174
+ receipt-management tools.
175
+
155
176
  ### Agent Retrieval Playbook
156
177
 
157
178
  - Prefer `gno_context` when the agent needs a complete, bounded evidence handoff
@@ -0,0 +1,284 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "gno://schemas/audit-report@1.0",
4
+ "title": "GNO Knowledge Integrity Audit Report",
5
+ "description": "Deterministic read-only audit report for links, provenance, and freshness. Not egress-audit receipts.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "schemaVersion",
10
+ "ruleSetVersion",
11
+ "status",
12
+ "scope",
13
+ "capabilities",
14
+ "fingerprints",
15
+ "versions",
16
+ "startedAt",
17
+ "completedAt",
18
+ "durationMs",
19
+ "rules",
20
+ "findings",
21
+ "counts",
22
+ "truncation",
23
+ "timing"
24
+ ],
25
+ "properties": {
26
+ "schemaVersion": { "const": "1.0" },
27
+ "ruleSetVersion": { "const": "1.0" },
28
+ "status": {
29
+ "enum": ["complete", "partial", "changed_during_audit", "failed"]
30
+ },
31
+ "scope": { "$ref": "#/definitions/scope" },
32
+ "capabilities": { "$ref": "#/definitions/capabilities" },
33
+ "fingerprints": { "$ref": "#/definitions/fingerprints" },
34
+ "versions": { "$ref": "#/definitions/versions" },
35
+ "startedAt": { "type": "string", "format": "date-time" },
36
+ "completedAt": { "type": "string", "format": "date-time" },
37
+ "durationMs": { "type": "integer", "minimum": 0 },
38
+ "rules": {
39
+ "type": "array",
40
+ "maxItems": 256,
41
+ "items": { "$ref": "#/definitions/ruleResult" }
42
+ },
43
+ "findings": {
44
+ "type": "array",
45
+ "maxItems": 1000,
46
+ "items": { "$ref": "#/definitions/finding" }
47
+ },
48
+ "counts": { "$ref": "#/definitions/counts" },
49
+ "truncation": { "$ref": "#/definitions/truncation" },
50
+ "timing": { "$ref": "#/definitions/timing" }
51
+ },
52
+ "definitions": {
53
+ "sha256": {
54
+ "type": "string",
55
+ "pattern": "^[a-f0-9]{64}$"
56
+ },
57
+ "category": {
58
+ "enum": ["links", "provenance", "freshness"]
59
+ },
60
+ "ruleStatus": {
61
+ "enum": ["pass", "fail", "skip", "unavailable", "inconclusive"]
62
+ },
63
+ "severity": {
64
+ "enum": ["error", "warning", "info"]
65
+ },
66
+ "scope": {
67
+ "type": "object",
68
+ "additionalProperties": false,
69
+ "required": ["categories", "collections", "paths", "tags", "indexName"],
70
+ "properties": {
71
+ "categories": {
72
+ "type": "array",
73
+ "minItems": 1,
74
+ "maxItems": 3,
75
+ "items": { "$ref": "#/definitions/category" },
76
+ "uniqueItems": true
77
+ },
78
+ "collections": {
79
+ "type": "array",
80
+ "maxItems": 256,
81
+ "items": { "type": "string", "minLength": 1, "maxLength": 256 }
82
+ },
83
+ "paths": {
84
+ "type": "array",
85
+ "maxItems": 256,
86
+ "items": { "type": "string", "minLength": 1, "maxLength": 2048 }
87
+ },
88
+ "tags": {
89
+ "type": "array",
90
+ "maxItems": 256,
91
+ "items": { "type": "string", "minLength": 1, "maxLength": 256 }
92
+ },
93
+ "indexName": { "type": "string", "minLength": 1, "maxLength": 64 }
94
+ }
95
+ },
96
+ "capabilities": {
97
+ "type": "object",
98
+ "additionalProperties": false,
99
+ "required": [
100
+ "indexReadable",
101
+ "sourcesReadable",
102
+ "linksGraphAvailable",
103
+ "provenanceSchemaAvailable",
104
+ "offline",
105
+ "llmDisabled"
106
+ ],
107
+ "properties": {
108
+ "indexReadable": { "type": "boolean" },
109
+ "sourcesReadable": { "type": "boolean" },
110
+ "linksGraphAvailable": { "type": "boolean" },
111
+ "provenanceSchemaAvailable": { "type": "boolean" },
112
+ "offline": { "const": true },
113
+ "llmDisabled": { "const": true }
114
+ }
115
+ },
116
+ "fingerprints": {
117
+ "type": "object",
118
+ "additionalProperties": false,
119
+ "required": ["config", "source", "index", "rules"],
120
+ "properties": {
121
+ "config": { "type": "string", "minLength": 0, "maxLength": 128 },
122
+ "source": { "type": "string", "minLength": 0, "maxLength": 128 },
123
+ "index": { "type": "string", "minLength": 0, "maxLength": 128 },
124
+ "rules": { "type": "string", "minLength": 0, "maxLength": 128 }
125
+ }
126
+ },
127
+ "versions": {
128
+ "type": "object",
129
+ "additionalProperties": false,
130
+ "required": ["gno", "schema", "ruleSet"],
131
+ "properties": {
132
+ "gno": { "type": "string", "minLength": 1, "maxLength": 64 },
133
+ "schema": { "const": "1.0" },
134
+ "ruleSet": { "const": "1.0" }
135
+ }
136
+ },
137
+ "evidence": {
138
+ "type": "object",
139
+ "additionalProperties": false,
140
+ "required": ["kind", "summary"],
141
+ "properties": {
142
+ "kind": { "type": "string", "minLength": 1, "maxLength": 128 },
143
+ "summary": { "type": "string", "minLength": 1, "maxLength": 512 },
144
+ "uri": { "type": "string", "minLength": 1, "maxLength": 2048 },
145
+ "path": { "type": "string", "minLength": 1, "maxLength": 2048 },
146
+ "detail": { "type": "string", "minLength": 1, "maxLength": 512 }
147
+ }
148
+ },
149
+ "finding": {
150
+ "type": "object",
151
+ "additionalProperties": false,
152
+ "required": [
153
+ "id",
154
+ "ruleId",
155
+ "category",
156
+ "severity",
157
+ "subject",
158
+ "location",
159
+ "message",
160
+ "evidence",
161
+ "guidance",
162
+ "evidenceFingerprint"
163
+ ],
164
+ "properties": {
165
+ "id": { "$ref": "#/definitions/sha256" },
166
+ "ruleId": { "type": "string", "minLength": 1, "maxLength": 128 },
167
+ "category": { "$ref": "#/definitions/category" },
168
+ "severity": { "$ref": "#/definitions/severity" },
169
+ "subject": { "type": "string", "minLength": 1, "maxLength": 2048 },
170
+ "location": { "type": ["string", "null"], "maxLength": 2048 },
171
+ "message": { "type": "string", "minLength": 1, "maxLength": 512 },
172
+ "evidence": {
173
+ "type": "array",
174
+ "maxItems": 8,
175
+ "items": { "$ref": "#/definitions/evidence" }
176
+ },
177
+ "guidance": {
178
+ "type": "array",
179
+ "maxItems": 4,
180
+ "items": { "type": "string", "minLength": 1, "maxLength": 256 }
181
+ },
182
+ "evidenceFingerprint": { "$ref": "#/definitions/sha256" }
183
+ }
184
+ },
185
+ "ruleResult": {
186
+ "type": "object",
187
+ "additionalProperties": false,
188
+ "required": [
189
+ "ruleId",
190
+ "category",
191
+ "status",
192
+ "message",
193
+ "findings",
194
+ "findingCount",
195
+ "examinedCount",
196
+ "durationMs",
197
+ "skipReason"
198
+ ],
199
+ "properties": {
200
+ "ruleId": { "type": "string", "minLength": 1, "maxLength": 128 },
201
+ "category": { "$ref": "#/definitions/category" },
202
+ "status": { "$ref": "#/definitions/ruleStatus" },
203
+ "message": { "type": "string", "minLength": 1, "maxLength": 512 },
204
+ "findings": {
205
+ "type": "array",
206
+ "maxItems": 1000,
207
+ "items": { "$ref": "#/definitions/finding" }
208
+ },
209
+ "findingCount": { "type": "integer", "minimum": 0 },
210
+ "examinedCount": { "type": "integer", "minimum": 0 },
211
+ "durationMs": { "type": "integer", "minimum": 0 },
212
+ "skipReason": { "type": ["string", "null"], "maxLength": 512 }
213
+ }
214
+ },
215
+ "counts": {
216
+ "type": "object",
217
+ "additionalProperties": false,
218
+ "required": ["rules", "findings", "examined"],
219
+ "properties": {
220
+ "rules": {
221
+ "type": "object",
222
+ "additionalProperties": false,
223
+ "required": [
224
+ "pass",
225
+ "fail",
226
+ "skip",
227
+ "unavailable",
228
+ "inconclusive",
229
+ "total"
230
+ ],
231
+ "properties": {
232
+ "pass": { "type": "integer", "minimum": 0 },
233
+ "fail": { "type": "integer", "minimum": 0 },
234
+ "skip": { "type": "integer", "minimum": 0 },
235
+ "unavailable": { "type": "integer", "minimum": 0 },
236
+ "inconclusive": { "type": "integer", "minimum": 0 },
237
+ "total": { "type": "integer", "minimum": 0 }
238
+ }
239
+ },
240
+ "findings": {
241
+ "type": "object",
242
+ "additionalProperties": false,
243
+ "required": ["total", "returned", "truncated"],
244
+ "properties": {
245
+ "total": { "type": "integer", "minimum": 0 },
246
+ "returned": { "type": "integer", "minimum": 0, "maximum": 1000 },
247
+ "truncated": { "type": "boolean" }
248
+ }
249
+ },
250
+ "examined": {
251
+ "type": "object",
252
+ "additionalProperties": false,
253
+ "required": ["documents"],
254
+ "properties": {
255
+ "documents": { "type": "integer", "minimum": 0 }
256
+ }
257
+ }
258
+ }
259
+ },
260
+ "truncation": {
261
+ "type": "object",
262
+ "additionalProperties": false,
263
+ "required": ["findingsTruncated", "maxFindings"],
264
+ "properties": {
265
+ "findingsTruncated": { "type": "boolean" },
266
+ "maxFindings": {
267
+ "type": "integer",
268
+ "minimum": 1,
269
+ "maximum": 1000
270
+ }
271
+ }
272
+ },
273
+ "timing": {
274
+ "type": "object",
275
+ "additionalProperties": false,
276
+ "required": ["snapshotMs", "rulesMs", "totalMs"],
277
+ "properties": {
278
+ "snapshotMs": { "type": "integer", "minimum": 0 },
279
+ "rulesMs": { "type": "integer", "minimum": 0 },
280
+ "totalMs": { "type": "integer", "minimum": 0 }
281
+ }
282
+ }
283
+ }
284
+ }
@@ -0,0 +1,231 @@
1
+ /** Read-only knowledge-integrity audit CLI adapter. */
2
+
3
+ // node:fs/promises provides atomic filesystem structure operations with no Bun equivalent.
4
+ import { chmod, mkdtemp, rename, rmdir, unlink } from "node:fs/promises";
5
+ import { basename, dirname, join } from "node:path";
6
+
7
+ import type {
8
+ AuditCategory,
9
+ AuditReport,
10
+ AuditRunResult,
11
+ } from "../../core/audit";
12
+ import type { WorkspaceAuditProgress } from "../../core/audit-workspace";
13
+
14
+ import { getIndexDbPath } from "../../app/constants";
15
+ import { loadConfig } from "../../config";
16
+ import {
17
+ auditExitCode,
18
+ AUDIT_CATEGORIES,
19
+ serializeAuditReportCanonical,
20
+ } from "../../core/audit";
21
+ import { runWorkspaceAudit } from "../../core/audit-workspace";
22
+ import { normalizeTag, validateTag } from "../../core/tags";
23
+ import { normalizeCollectionName } from "../../core/validation";
24
+ import { SqliteAdapter } from "../../store/sqlite/adapter";
25
+
26
+ export interface AuditCommandOptions {
27
+ category?: string;
28
+ configPath?: string;
29
+ indexName?: string;
30
+ collections?: string[];
31
+ paths?: string[];
32
+ tags?: string[];
33
+ maxFindings?: number;
34
+ maxAgeDays?: number;
35
+ orphanRoots?: string[];
36
+ orphanIgnorePrefixes?: string[];
37
+ signal?: AbortSignal;
38
+ onProgress?: (progress: WorkspaceAuditProgress) => void | Promise<void>;
39
+ }
40
+
41
+ export type AuditCommandResult =
42
+ | { success: true; report: AuditReport; exitCode: number }
43
+ | { success: false; error: string; invalid: boolean };
44
+
45
+ const resolveCategories = (
46
+ value: string | undefined
47
+ ): AuditCategory[] | null => {
48
+ const normalized = (value ?? "all").trim().toLowerCase();
49
+ if (normalized === "all") return [...AUDIT_CATEGORIES];
50
+ return (AUDIT_CATEGORIES as readonly string[]).includes(normalized)
51
+ ? [normalized as AuditCategory]
52
+ : null;
53
+ };
54
+
55
+ const invalidPositiveInteger = (value: number | undefined): boolean =>
56
+ value !== undefined && (!Number.isSafeInteger(value) || value < 1);
57
+
58
+ const AUDIT_SCOPE_FILTER_MAX_ITEMS = 256;
59
+
60
+ const oversizedScopeFilter = (
61
+ options: AuditCommandOptions
62
+ ): "collections" | "paths" | "tags" | null => {
63
+ for (const name of ["collections", "paths", "tags"] as const) {
64
+ if ((options[name]?.length ?? 0) > AUDIT_SCOPE_FILTER_MAX_ITEMS) {
65
+ return name;
66
+ }
67
+ }
68
+ return null;
69
+ };
70
+
71
+ export const audit = async (
72
+ options: AuditCommandOptions
73
+ ): Promise<AuditCommandResult> => {
74
+ const categories = resolveCategories(options.category);
75
+ if (!categories) {
76
+ return {
77
+ success: false,
78
+ invalid: true,
79
+ error: "category must be links, provenance, freshness, or all",
80
+ };
81
+ }
82
+ if (invalidPositiveInteger(options.maxFindings)) {
83
+ return {
84
+ success: false,
85
+ invalid: true,
86
+ error: "maxFindings must be a positive integer",
87
+ };
88
+ }
89
+ if (invalidPositiveInteger(options.maxAgeDays)) {
90
+ return {
91
+ success: false,
92
+ invalid: true,
93
+ error: "maxAgeDays must be a positive integer",
94
+ };
95
+ }
96
+ const oversizedFilter = oversizedScopeFilter(options);
97
+ if (oversizedFilter) {
98
+ return {
99
+ success: false,
100
+ invalid: true,
101
+ error: `${oversizedFilter} must contain at most ${AUDIT_SCOPE_FILTER_MAX_ITEMS} values`,
102
+ };
103
+ }
104
+ const configResult = await loadConfig(options.configPath);
105
+ if (!configResult.ok) {
106
+ return { success: false, invalid: true, error: configResult.error.message };
107
+ }
108
+ const config = configResult.value;
109
+ const requestedCollections = (options.collections ?? []).map(
110
+ normalizeCollectionName
111
+ );
112
+ const requestedTags = (options.tags ?? []).map(normalizeTag);
113
+ const invalidTag = requestedTags.find((tag) => !validateTag(tag));
114
+ if (invalidTag) {
115
+ return {
116
+ success: false,
117
+ invalid: true,
118
+ error: `Invalid tag: "${invalidTag}"`,
119
+ };
120
+ }
121
+ const knownCollections = new Set(
122
+ config.collections.map((collection) => collection.name)
123
+ );
124
+ const missingCollection = requestedCollections.find(
125
+ (collection) => !knownCollections.has(collection)
126
+ );
127
+ if (missingCollection) {
128
+ return {
129
+ success: false,
130
+ invalid: true,
131
+ error: `Collection not found: ${missingCollection}`,
132
+ };
133
+ }
134
+ const dbPath = getIndexDbPath(options.indexName);
135
+ if (!(await Bun.file(dbPath).exists())) {
136
+ return {
137
+ success: false,
138
+ invalid: false,
139
+ error: `Index database not found: ${dbPath}. Run gno index first.`,
140
+ };
141
+ }
142
+ const store = new SqliteAdapter();
143
+ const opened = store.openReadOnly(dbPath);
144
+ if (!opened.ok) {
145
+ return { success: false, invalid: false, error: opened.error.message };
146
+ }
147
+ try {
148
+ const result: AuditRunResult = await runWorkspaceAudit({
149
+ store,
150
+ config,
151
+ collections: config.collections,
152
+ indexName: options.indexName ?? "default",
153
+ categories,
154
+ collectionFilters: requestedCollections,
155
+ pathFilters: options.paths,
156
+ tagFilters: requestedTags,
157
+ maxFindings: options.maxFindings,
158
+ agePolicy:
159
+ options.maxAgeDays === undefined
160
+ ? undefined
161
+ : { maxAgeDays: options.maxAgeDays },
162
+ orphanRoots: options.orphanRoots,
163
+ orphanIgnorePrefixes: options.orphanIgnorePrefixes,
164
+ signal: options.signal,
165
+ onProgress: options.onProgress,
166
+ });
167
+ if (!result.ok) {
168
+ return { success: false, invalid: true, error: result.error };
169
+ }
170
+ return {
171
+ success: true,
172
+ report: result.report,
173
+ exitCode: auditExitCode(result.exit),
174
+ };
175
+ } finally {
176
+ await store.close();
177
+ }
178
+ };
179
+
180
+ export const formatAuditReport = (
181
+ report: AuditReport,
182
+ options: { json?: boolean } = {}
183
+ ): string => {
184
+ if (options.json) return serializeAuditReportCanonical(report);
185
+ const lines = [
186
+ `Audit: ${report.status}`,
187
+ `Categories: ${report.scope.categories.join(", ")}`,
188
+ `Rules: ${report.counts.rules.total} (${report.counts.rules.fail} failed, ${report.counts.rules.unavailable} unavailable, ${report.counts.rules.inconclusive} inconclusive)`,
189
+ `Findings: ${report.counts.findings.total}${report.counts.findings.truncated ? ` (${report.counts.findings.returned} shown)` : ""}`,
190
+ `Examined: ${report.counts.examined.documents} document/rule observations`,
191
+ `Duration: ${report.durationMs}ms`,
192
+ ];
193
+ for (const finding of report.findings) {
194
+ const location = finding.location ? ` ${finding.location}` : "";
195
+ lines.push(
196
+ `- [${finding.severity}] ${finding.ruleId}: ${finding.subject}${location} — ${finding.message}`
197
+ );
198
+ }
199
+ for (const rule of report.rules) {
200
+ if (
201
+ rule.status === "skip" ||
202
+ rule.status === "unavailable" ||
203
+ rule.status === "inconclusive"
204
+ ) {
205
+ lines.push(`- [${rule.status}] ${rule.ruleId}: ${rule.message}`);
206
+ }
207
+ }
208
+ return lines.join("\n");
209
+ };
210
+
211
+ export const writeAuditReport = async (
212
+ path: string,
213
+ report: AuditReport,
214
+ options: { json?: boolean } = {}
215
+ ): Promise<void> => {
216
+ const temporaryDirectory = await mkdtemp(
217
+ join(dirname(path), `.${basename(path)}-`)
218
+ );
219
+ const temporaryPath = join(temporaryDirectory, "report");
220
+ try {
221
+ await Bun.write(temporaryPath, `${formatAuditReport(report, options)}\n`, {
222
+ createPath: false,
223
+ mode: 0o600,
224
+ });
225
+ await chmod(temporaryPath, 0o600);
226
+ await rename(temporaryPath, path);
227
+ } finally {
228
+ await unlink(temporaryPath).catch(() => undefined);
229
+ await rmdir(temporaryDirectory).catch(() => undefined);
230
+ }
231
+ };