@gmickel/gno 1.33.0 → 1.34.1

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.
@@ -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
+ };
@@ -10,6 +10,7 @@ import type { DownloadProgress, ModelType } from "../../../llm/types";
10
10
  import { getModelsCachePath } from "../../../app/constants";
11
11
  import { loadConfig } from "../../../config";
12
12
  import { ModelCache } from "../../../llm/cache";
13
+ import { isHttpRerankUri } from "../../../llm/httpRerank";
13
14
  import { getActivePreset } from "../../../llm/registry";
14
15
 
15
16
  // ─────────────────────────────────────────────────────────────────────────────
@@ -46,6 +47,7 @@ export interface ModelPullResult {
46
47
  error?: string;
47
48
  path?: string;
48
49
  skipped?: boolean;
50
+ skipReason?: "cached" | "external";
49
51
  }
50
52
 
51
53
  export interface ModelsPullResult {
@@ -117,6 +119,20 @@ export async function modelsPull(
117
119
  const uri =
118
120
  type === "expand" ? (preset.expand ?? preset.gen) : preset[type];
119
121
 
122
+ // HTTP rerankers are services, not model artifacts. They are loaded
123
+ // directly by LlmAdapter and must never enter the local model cache path.
124
+ if (type === "rerank" && isHttpRerankUri(uri)) {
125
+ results.push({
126
+ type,
127
+ uri,
128
+ ok: true,
129
+ skipped: true,
130
+ skipReason: "external",
131
+ });
132
+ skipped += 1;
133
+ continue;
134
+ }
135
+
120
136
  // Check if already cached (skip unless --force)
121
137
  if (!options.force) {
122
138
  const isCached = await cache.isCached(uri);
@@ -128,6 +144,7 @@ export async function modelsPull(
128
144
  ok: true,
129
145
  path: path ?? undefined,
130
146
  skipped: true,
147
+ skipReason: "cached",
131
148
  });
132
149
  skipped += 1;
133
150
  continue;
@@ -176,13 +193,17 @@ export async function modelsPull(
176
193
  */
177
194
  export function formatModelsPull(result: ModelsPullResult): string {
178
195
  const lines: string[] = [];
196
+ let externalSkipped = 0;
179
197
  const label = (type: ModelType) =>
180
198
  type === "gen" ? "answer" : type === "expand" ? "expand" : type;
181
199
 
182
200
  for (const r of result.results) {
183
201
  if (r.ok) {
184
202
  if (r.skipped) {
185
- lines.push(`${label(r.type)}: skipped (already cached)`);
203
+ if (r.skipReason === "external") externalSkipped += 1;
204
+ const reason =
205
+ r.skipReason === "external" ? "external endpoint" : "already cached";
206
+ lines.push(`${label(r.type)}: skipped (${reason})`);
186
207
  } else {
187
208
  lines.push(`${label(r.type)}: downloaded`);
188
209
  }
@@ -196,10 +217,18 @@ export function formatModelsPull(result: ModelsPullResult): string {
196
217
  lines.push(`${result.failed} model(s) failed to download.`);
197
218
  } else if (result.skipped === result.results.length) {
198
219
  lines.push("");
199
- lines.push("All models already cached. Use --force to re-download.");
220
+ lines.push(
221
+ externalSkipped > 0
222
+ ? "No model downloads needed."
223
+ : "All models already cached. Use --force to re-download."
224
+ );
200
225
  } else {
201
226
  lines.push("");
202
- lines.push("All models downloaded successfully.");
227
+ lines.push(
228
+ externalSkipped > 0
229
+ ? "All downloadable models downloaded successfully."
230
+ : "All models downloaded successfully."
231
+ );
203
232
  }
204
233
 
205
234
  return lines.join("\n");
package/src/cli/errors.ts CHANGED
@@ -9,7 +9,12 @@
9
9
  // Error Types
10
10
  // ─────────────────────────────────────────────────────────────────────────────
11
11
 
12
- export type CliErrorCode = "VALIDATION" | "RUNTIME" | "NOT_RUNNING";
12
+ export type CliErrorCode =
13
+ | "VALIDATION"
14
+ | "RUNTIME"
15
+ | "NOT_RUNNING"
16
+ | "AUDIT_FINDINGS"
17
+ | "AUDIT_PARTIAL";
13
18
 
14
19
  export interface CliErrorOptions {
15
20
  details?: Record<string, unknown>;
@@ -62,13 +67,15 @@ export class CliError extends Error {
62
67
  // Exit Codes
63
68
  // ─────────────────────────────────────────────────────────────────────────────
64
69
 
65
- export function exitCodeFor(err: CliError): 1 | 2 | 3 {
70
+ export function exitCodeFor(err: CliError): 1 | 2 | 3 | 4 | 5 {
66
71
  if (err.code === "VALIDATION") {
67
72
  return 1;
68
73
  }
69
74
  if (err.code === "NOT_RUNNING") {
70
75
  return 3;
71
76
  }
77
+ if (err.code === "AUDIT_FINDINGS") return 4;
78
+ if (err.code === "AUDIT_PARTIAL") return 5;
72
79
  return 2;
73
80
  }
74
81
 
@@ -1539,6 +1539,118 @@ function wireOnboardingCommands(program: Command): void {
1539
1539
  }
1540
1540
  );
1541
1541
 
1542
+ // audit - Read-only workspace integrity report
1543
+ program
1544
+ .command("audit [category]")
1545
+ .description("Run read-only workspace integrity audits")
1546
+ .option(
1547
+ "-c, --collection <name>",
1548
+ "collection scope (repeatable)",
1549
+ collectRepeatableValue,
1550
+ []
1551
+ )
1552
+ .option(
1553
+ "--path <prefix>",
1554
+ "collection-relative path prefix (repeatable)",
1555
+ collectRepeatableValue,
1556
+ []
1557
+ )
1558
+ .option(
1559
+ "--tag <tag>",
1560
+ "require tag (repeatable, AND semantics)",
1561
+ collectRepeatableValue,
1562
+ []
1563
+ )
1564
+ .option("--max-findings <count>", "maximum returned findings", Number)
1565
+ .option("--max-age-days <days>", "explicit age review signal", Number)
1566
+ .option(
1567
+ "--orphan-root <uri>",
1568
+ "URI excluded from orphan findings (repeatable)",
1569
+ collectRepeatableValue,
1570
+ []
1571
+ )
1572
+ .option(
1573
+ "--orphan-ignore-prefix <prefix>",
1574
+ "path prefix excluded from orphan findings (repeatable)",
1575
+ collectRepeatableValue,
1576
+ []
1577
+ )
1578
+ .option("--output <path>", "also write the rendered report to a file")
1579
+ .option("--no-progress", "disable progress on stderr")
1580
+ .option("--json", "JSON output")
1581
+ .action(
1582
+ async (
1583
+ category: string | undefined,
1584
+ cmdOpts: Record<string, unknown>
1585
+ ) => {
1586
+ const { audit, formatAuditReport, writeAuditReport } =
1587
+ await import("./commands/audit");
1588
+ const globals = getGlobals();
1589
+ const json = getFormat(cmdOpts) === "json";
1590
+ const showProgress =
1591
+ cmdOpts.progress !== false &&
1592
+ !globals.quiet &&
1593
+ !json &&
1594
+ process.stderr.isTTY;
1595
+ const controller = new AbortController();
1596
+ const abort = (): void => controller.abort();
1597
+ process.once("SIGINT", abort);
1598
+ try {
1599
+ const result = await audit({
1600
+ category,
1601
+ configPath: globals.config,
1602
+ indexName: globals.index,
1603
+ collections: cmdOpts.collection as string[],
1604
+ paths: cmdOpts.path as string[],
1605
+ tags: cmdOpts.tag as string[],
1606
+ maxFindings: cmdOpts.maxFindings as number | undefined,
1607
+ maxAgeDays: cmdOpts.maxAgeDays as number | undefined,
1608
+ orphanRoots: cmdOpts.orphanRoot as string[],
1609
+ orphanIgnorePrefixes: cmdOpts.orphanIgnorePrefix as string[],
1610
+ signal: controller.signal,
1611
+ onProgress: showProgress
1612
+ ? ({ phase, completed, total }) => {
1613
+ process.stderr.write(
1614
+ `\rAudit ${phase}: ${completed}/${Math.max(total, 1)}`
1615
+ );
1616
+ }
1617
+ : undefined,
1618
+ });
1619
+ if (!result.success) {
1620
+ throw new CliError(
1621
+ result.invalid ? "VALIDATION" : "RUNTIME",
1622
+ result.error
1623
+ );
1624
+ }
1625
+ const rendered = formatAuditReport(result.report, { json });
1626
+ process.stdout.write(`${rendered}\n`);
1627
+ if (cmdOpts.output) {
1628
+ await writeAuditReport(cmdOpts.output as string, result.report, {
1629
+ json,
1630
+ });
1631
+ }
1632
+ if (result.exitCode === 2) {
1633
+ throw new CliError("RUNTIME", "Audit runtime failure", {
1634
+ silent: true,
1635
+ });
1636
+ }
1637
+ if (result.exitCode === 4) {
1638
+ throw new CliError("AUDIT_FINDINGS", "Audit findings present", {
1639
+ silent: true,
1640
+ });
1641
+ }
1642
+ if (result.exitCode === 5) {
1643
+ throw new CliError("AUDIT_PARTIAL", "Audit evidence is partial", {
1644
+ silent: true,
1645
+ });
1646
+ }
1647
+ } finally {
1648
+ if (showProgress) process.stderr.write("\n");
1649
+ process.removeListener("SIGINT", abort);
1650
+ }
1651
+ }
1652
+ );
1653
+
1542
1654
  // status - Show index status
1543
1655
  program
1544
1656
  .command("status")