@graphorin/cli 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +3 -3
  3. package/dist/bin/graphorin.js +51 -13
  4. package/dist/bin/graphorin.js.map +1 -1
  5. package/dist/commands/audit.d.ts.map +1 -1
  6. package/dist/commands/audit.js +2 -1
  7. package/dist/commands/audit.js.map +1 -1
  8. package/dist/commands/consolidator.d.ts +56 -1
  9. package/dist/commands/consolidator.d.ts.map +1 -1
  10. package/dist/commands/consolidator.js +88 -2
  11. package/dist/commands/consolidator.js.map +1 -1
  12. package/dist/commands/doctor.d.ts.map +1 -1
  13. package/dist/commands/index.d.ts +4 -4
  14. package/dist/commands/index.js +4 -4
  15. package/dist/commands/init.d.ts +11 -4
  16. package/dist/commands/init.d.ts.map +1 -1
  17. package/dist/commands/init.js +15 -11
  18. package/dist/commands/init.js.map +1 -1
  19. package/dist/commands/memory.d.ts +26 -1
  20. package/dist/commands/memory.d.ts.map +1 -1
  21. package/dist/commands/memory.js +56 -3
  22. package/dist/commands/memory.js.map +1 -1
  23. package/dist/commands/pricing.d.ts +6 -0
  24. package/dist/commands/pricing.d.ts.map +1 -1
  25. package/dist/commands/pricing.js +5 -2
  26. package/dist/commands/pricing.js.map +1 -1
  27. package/dist/commands/secrets.d.ts.map +1 -1
  28. package/dist/commands/secrets.js +2 -2
  29. package/dist/commands/secrets.js.map +1 -1
  30. package/dist/commands/skills.d.ts.map +1 -1
  31. package/dist/commands/skills.js +1 -1
  32. package/dist/commands/skills.js.map +1 -1
  33. package/dist/commands/storage.d.ts +41 -1
  34. package/dist/commands/storage.d.ts.map +1 -1
  35. package/dist/commands/storage.js +75 -1
  36. package/dist/commands/storage.js.map +1 -1
  37. package/dist/commands/token.d.ts.map +1 -1
  38. package/dist/commands/token.js +2 -2
  39. package/dist/commands/token.js.map +1 -1
  40. package/dist/commands/tools-lint.js +1 -1
  41. package/dist/commands/tools-lint.js.map +1 -1
  42. package/dist/commands/traces.d.ts +14 -2
  43. package/dist/commands/traces.d.ts.map +1 -1
  44. package/dist/commands/traces.js +39 -22
  45. package/dist/commands/traces.js.map +1 -1
  46. package/dist/commands/triggers.d.ts.map +1 -1
  47. package/dist/commands/triggers.js +5 -2
  48. package/dist/commands/triggers.js.map +1 -1
  49. package/dist/index.d.ts +4 -4
  50. package/dist/index.js +4 -5
  51. package/dist/index.js.map +1 -1
  52. package/dist/internal/output.js +6 -0
  53. package/dist/internal/output.js.map +1 -1
  54. package/dist/internal/store-context.js +13 -2
  55. package/dist/internal/store-context.js.map +1 -1
  56. package/dist/package.js +1 -1
  57. package/dist/package.js.map +1 -1
  58. package/package.json +18 -14
  59. package/src/bin/graphorin.ts +1387 -0
  60. package/src/commands/audit.ts +256 -0
  61. package/src/commands/auth.ts +238 -0
  62. package/src/commands/consolidator.ts +382 -0
  63. package/src/commands/doctor.ts +253 -0
  64. package/src/commands/guard.ts +144 -0
  65. package/src/commands/index.ts +223 -0
  66. package/src/commands/init.ts +194 -0
  67. package/src/commands/memory.ts +1052 -0
  68. package/src/commands/migrate-config.ts +77 -0
  69. package/src/commands/migrate-export.ts +117 -0
  70. package/src/commands/migrate.ts +83 -0
  71. package/src/commands/pricing.ts +244 -0
  72. package/src/commands/secrets.ts +309 -0
  73. package/src/commands/skills.ts +272 -0
  74. package/src/commands/start.ts +180 -0
  75. package/src/commands/storage.ts +659 -0
  76. package/src/commands/telemetry.ts +91 -0
  77. package/src/commands/token.ts +361 -0
  78. package/src/commands/tools-lint.ts +430 -0
  79. package/src/commands/traces.ts +188 -0
  80. package/src/commands/triggers.ts +237 -0
  81. package/src/index.ts +30 -0
  82. package/src/internal/exit.ts +62 -0
  83. package/src/internal/load-config.ts +107 -0
  84. package/src/internal/offline.ts +81 -0
  85. package/src/internal/output.ts +146 -0
  86. package/src/internal/prompts.ts +58 -0
  87. package/src/internal/store-context.ts +165 -0
@@ -0,0 +1,430 @@
1
+ /**
2
+ * `graphorin tools lint` - RB-49 / suggested DEC-165.
3
+ *
4
+ * Discovers every `tool({...})` registration in the operator's project
5
+ * via a text-based static scan (no runtime probe; no `tsc` invocation), runs
6
+ * the three RB-49 rules from `@graphorin/eslint-plugin`, computes the
7
+ * per-tool grader score (40 + 30 + 30 = 100 points), aggregates a
8
+ * structured report, and exits `1` when any tool falls below the
9
+ * `--threshold` (default `60`).
10
+ *
11
+ * ## Surface
12
+ *
13
+ * graphorin tools lint [--config <path>] [--threshold <n>]
14
+ * [--format <text|json>] [--source <pattern>]
15
+ *
16
+ * ## Grader rubric (RB-49 calibration - 40 + 30 + 30 = 100 points)
17
+ *
18
+ * - **description axis (0..40):** 0 if missing / placeholder / shorter
19
+ * than 20 chars; 16 if length >= 20; 24 if length >= 30; 32 if
20
+ * length >= 50; 40 if length >= 80.
21
+ * - **examples axis (0..30):** 0 if no examples or > 5; 12 base for
22
+ * the first example, +6 per additional, cap at 30. Subtract 6 per
23
+ * PII finding (cap at 0).
24
+ * - **parameter naming axis (0..30):** 30 base, deducted per finding.
25
+ * -30/N for ambiguous names; -10/N for numeric suffixes.
26
+ *
27
+ * ## Exit codes
28
+ *
29
+ * - `0` - every tool meets or exceeds the threshold.
30
+ * - `1` - at least one tool falls below the threshold.
31
+ * - `2` - invocation could not start (config missing, walker failed).
32
+ *
33
+ * The CLI re-uses the rule modules from `@graphorin/eslint-plugin` so
34
+ * the rule logic has a single source of truth - the per-tool grader,
35
+ * threshold gate, and report formatting are the only logic that lives
36
+ * in the CLI.
37
+ *
38
+ * @packageDocumentation
39
+ */
40
+
41
+ import { readFile, stat } from 'node:fs/promises';
42
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
43
+ import process from 'node:process';
44
+
45
+ import {
46
+ type DiscoveredTool,
47
+ discoverToolCallsInSource,
48
+ gradeTool,
49
+ type LintFinding,
50
+ type LintFindingKind,
51
+ runToolRules,
52
+ type ToolGraderScore,
53
+ } from '@graphorin/eslint-plugin';
54
+
55
+ import { EXIT_CODES } from '../internal/exit.js';
56
+ import {
57
+ brand,
58
+ type CommonOutputOptions,
59
+ defaultJsonSink,
60
+ defaultPrintSink,
61
+ statusMarker,
62
+ } from '../internal/output.js';
63
+
64
+ /**
65
+ * Counter event emitted per below-threshold tool per invocation. The
66
+ * CLI exposes a configurable sink so observability pipelines (Phase
67
+ * 04) can wire the counter into Prometheus / OTLP without touching
68
+ * the CLI runtime. Default: no-op.
69
+ *
70
+ * Mirrors the `tool.lint.threshold.violations.total{toolName,score,
71
+ * threshold}` contract documented in RB-49 § Counter emission.
72
+ *
73
+ * @stable
74
+ */
75
+ export interface ToolsLintThresholdViolation {
76
+ readonly toolName: string;
77
+ readonly score: number;
78
+ readonly threshold: number;
79
+ }
80
+
81
+ /** @stable */
82
+ export type ToolsLintCounterSink = (event: ToolsLintThresholdViolation) => void;
83
+
84
+ /** @stable */
85
+ export interface ToolsLintOptions extends CommonOutputOptions {
86
+ /** Optional path to a `tsconfig.json` whose `include` overrides the file glob. */
87
+ readonly config?: string;
88
+ /** Minimum acceptable per-tool score. Default `60`. */
89
+ readonly threshold?: number;
90
+ /** Output format. Default `'text'`. */
91
+ readonly format?: 'text' | 'json';
92
+ /** Optional override of the file glob pattern. */
93
+ readonly source?: string;
94
+ /** Override `cwd`. Default `process.cwd()`. */
95
+ readonly cwd?: string;
96
+ /**
97
+ * Test seam - supply a list of `(file, source)` pairs directly so
98
+ * the test does not need to fish around the filesystem.
99
+ */
100
+ readonly inlineSources?: ReadonlyArray<{ readonly file: string; readonly source: string }>;
101
+ /**
102
+ * Optional sink for the `tool.lint.threshold.violations.total`
103
+ * counter (RB-49). The CLI calls this once per below-threshold tool
104
+ * per invocation. Default: no-op.
105
+ */
106
+ readonly counterSink?: ToolsLintCounterSink;
107
+ }
108
+
109
+ /**
110
+ * Documented JSON shape emitted on `--format json`. The shape is
111
+ * stable + forward-compatible with the `@eslint/mcp` industry
112
+ * direction (per-finding `rule` / `severity` / `message` / `location`
113
+ * mirror the ESLint LSP convention).
114
+ *
115
+ * @stable
116
+ */
117
+ export interface ToolsLintReport {
118
+ readonly summary: {
119
+ readonly totalTools: number;
120
+ readonly totalFindings: number;
121
+ readonly threshold: number;
122
+ readonly passed: number;
123
+ readonly failed: number;
124
+ };
125
+ readonly tools: ReadonlyArray<ToolsLintReportTool>;
126
+ }
127
+
128
+ /** @stable */
129
+ export interface ToolsLintReportTool {
130
+ readonly name: string;
131
+ readonly source: string;
132
+ readonly score: number;
133
+ readonly axes: {
134
+ readonly description: number;
135
+ readonly examples: number;
136
+ readonly parameterNaming: number;
137
+ };
138
+ readonly findings: ReadonlyArray<ToolsLintReportFinding>;
139
+ }
140
+
141
+ /** @stable */
142
+ export interface ToolsLintReportFinding {
143
+ readonly rule: LintFinding['rule'];
144
+ readonly kind: LintFindingKind;
145
+ readonly severity: LintFinding['severity'];
146
+ readonly message: string;
147
+ readonly location: { readonly file: string; readonly line: number };
148
+ readonly hint?: string;
149
+ readonly matchedPattern?: string;
150
+ }
151
+
152
+ /** Default file glob honoured when no `--source` / `--config` is supplied. */
153
+ const DEFAULT_GLOB = 'src/**/*.{ts,tsx}';
154
+
155
+ /**
156
+ * Run the discovery + grader pipeline. Returns the structured report
157
+ * the CLI emits to stdout.
158
+ *
159
+ * @stable
160
+ */
161
+ export async function runToolsLint(options: ToolsLintOptions = {}): Promise<ToolsLintReport> {
162
+ const cwd = options.cwd ?? process.cwd();
163
+ const threshold = options.threshold ?? 60;
164
+ const format = options.format ?? 'text';
165
+
166
+ const sources = await collectSources(cwd, options);
167
+ const tools: DiscoveredTool[] = [];
168
+ for (const { file, source } of sources) {
169
+ tools.push(...discoverToolCallsInSource(file, source));
170
+ }
171
+
172
+ const scored: ToolGraderScore[] = [];
173
+ let failed = 0;
174
+ let passed = 0;
175
+ let totalFindings = 0;
176
+ const violations: ToolsLintThresholdViolation[] = [];
177
+ for (const tool of tools) {
178
+ const findings = runToolRules(tool);
179
+ const score = gradeTool(tool, findings);
180
+ scored.push(score);
181
+ totalFindings += findings.length;
182
+ if (score.score < threshold) {
183
+ failed += 1;
184
+ violations.push(
185
+ Object.freeze({
186
+ toolName: score.toolName,
187
+ score: score.score,
188
+ threshold,
189
+ }),
190
+ );
191
+ } else {
192
+ passed += 1;
193
+ }
194
+ }
195
+
196
+ // Emit the documented counter exactly once per below-threshold tool
197
+ // per invocation. The default sink is a no-op so non-observability
198
+ // hosts pay nothing; the standalone server's tracer wires its own
199
+ // counter pipeline through `--counter-sink` in v0.2.
200
+ const counterSink = options.counterSink ?? noopCounterSink;
201
+ for (const v of violations) counterSink(v);
202
+
203
+ const report: ToolsLintReport = Object.freeze({
204
+ summary: Object.freeze({
205
+ totalTools: scored.length,
206
+ totalFindings,
207
+ threshold,
208
+ passed,
209
+ failed,
210
+ }),
211
+ tools: Object.freeze(scored.map(toReportTool)),
212
+ });
213
+
214
+ if (format === 'json') {
215
+ const sink = options.jsonPrint ?? defaultJsonSink;
216
+ sink(report);
217
+ } else {
218
+ emitTextReport(report, options);
219
+ }
220
+
221
+ if (failed > 0) {
222
+ process.exitCode = EXIT_CODES.RECOVERABLE_FAILURE;
223
+ }
224
+
225
+ return report;
226
+ }
227
+
228
+ function noopCounterSink(_event: ToolsLintThresholdViolation): void {
229
+ /* no-op */
230
+ }
231
+
232
+ function toReportTool(score: ToolGraderScore): ToolsLintReportTool {
233
+ return Object.freeze({
234
+ name: score.toolName,
235
+ source: `${score.file}:${score.line}`,
236
+ score: score.score,
237
+ axes: score.axes,
238
+ findings: Object.freeze(score.findings.map(toReportFinding)),
239
+ });
240
+ }
241
+
242
+ function toReportFinding(finding: LintFinding): ToolsLintReportFinding {
243
+ return Object.freeze({
244
+ rule: finding.rule,
245
+ kind: finding.kind,
246
+ severity: finding.severity,
247
+ message: finding.message,
248
+ location: Object.freeze({ file: finding.file, line: finding.line }),
249
+ ...(finding.hint !== undefined ? { hint: finding.hint } : {}),
250
+ ...(finding.matchedPattern !== undefined ? { matchedPattern: finding.matchedPattern } : {}),
251
+ });
252
+ }
253
+
254
+ function emitTextReport(report: ToolsLintReport, options: ToolsLintOptions): void {
255
+ const print = options.print ?? defaultPrintSink;
256
+ if (report.tools.length === 0) {
257
+ print(brand('no tool({...}) registrations were discovered.'));
258
+ return;
259
+ }
260
+ for (const tool of report.tools) {
261
+ const ok = tool.score >= report.summary.threshold;
262
+ const mark = ok ? statusMarker('ok') : statusMarker('fail');
263
+ const flag = ok ? '' : ' (BELOW THRESHOLD)';
264
+ print(brand(`${mark} ${tool.name} (score=${tool.score}/100)${flag}`));
265
+ print(` source: ${tool.source}`);
266
+ print(
267
+ ` axes: description=${tool.axes.description}, examples=${tool.axes.examples}, parameterNaming=${tool.axes.parameterNaming}`,
268
+ );
269
+ if (tool.findings.length === 0) {
270
+ print(` findings: none`);
271
+ } else {
272
+ print(` findings:`);
273
+ for (const f of tool.findings) {
274
+ print(
275
+ ` [${f.severity}] ${f.rule}: ${f.message} (${f.location.file}:${f.location.line})`,
276
+ );
277
+ }
278
+ }
279
+ }
280
+ const tag = report.summary.failed === 0 ? statusMarker('ok') : statusMarker('fail');
281
+ print(
282
+ brand(
283
+ `${tag} summary: ${report.summary.totalTools} tool(s), ${report.summary.totalFindings} finding(s), threshold=${report.summary.threshold}, passed=${report.summary.passed}, failed=${report.summary.failed}`,
284
+ ),
285
+ );
286
+ }
287
+
288
+ async function collectSources(
289
+ cwd: string,
290
+ options: ToolsLintOptions,
291
+ ): Promise<ReadonlyArray<{ readonly file: string; readonly source: string }>> {
292
+ if (options.inlineSources !== undefined) return options.inlineSources;
293
+
294
+ const glob = options.source ?? (await loadIncludeGlob(cwd, options.config)) ?? DEFAULT_GLOB;
295
+ const files = await walkGlob(cwd, glob);
296
+ const out: Array<{ file: string; source: string }> = [];
297
+ for (const file of files) {
298
+ try {
299
+ const source = await readFile(file, 'utf8');
300
+ out.push({ file, source });
301
+ } catch {
302
+ // Best-effort - skip unreadable files.
303
+ }
304
+ }
305
+ return out;
306
+ }
307
+
308
+ async function loadIncludeGlob(
309
+ cwd: string,
310
+ configPath: string | undefined,
311
+ ): Promise<string | undefined> {
312
+ if (configPath === undefined) return undefined;
313
+ const abs = isAbsolute(configPath) ? configPath : resolve(cwd, configPath);
314
+ let raw: string;
315
+ try {
316
+ raw = await readFile(abs, 'utf8');
317
+ } catch {
318
+ return undefined;
319
+ }
320
+ let parsed: { readonly include?: ReadonlyArray<string> };
321
+ try {
322
+ parsed = JSON.parse(stripJsonComments(raw)) as { readonly include?: ReadonlyArray<string> };
323
+ } catch {
324
+ return undefined;
325
+ }
326
+ return parsed.include?.[0];
327
+ }
328
+
329
+ function stripJsonComments(raw: string): string {
330
+ // Lightweight block-comment + line-comment stripper. tsconfig.json
331
+ // commonly carries `//` comments which JSON.parse rejects.
332
+ return raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
333
+ }
334
+
335
+ async function walkGlob(cwd: string, pattern: string): Promise<string[]> {
336
+ const root = resolveGlobRoot(cwd, pattern);
337
+ const matcher = compileGlob(pattern);
338
+ const out: string[] = [];
339
+ await walkDir(root, async (file) => {
340
+ const rel = relative(cwd, file);
341
+ if (!matcher(rel)) return;
342
+ out.push(file);
343
+ });
344
+ return out.sort();
345
+ }
346
+
347
+ function resolveGlobRoot(cwd: string, pattern: string): string {
348
+ const idx = pattern.indexOf('*');
349
+ if (idx < 0) return resolve(cwd, pattern);
350
+ const prefix = pattern.slice(0, idx);
351
+ return resolve(cwd, prefix);
352
+ }
353
+
354
+ function compileGlob(pattern: string): (rel: string) => boolean {
355
+ // The translation is intentionally done in three phases so the
356
+ // brace-expansion output (which includes literal `(?:...)`) does not
357
+ // collide with the `?` glob translation:
358
+ //
359
+ // 1. Tokenize the four glob constructs into ASCII placeholders
360
+ // that are guaranteed not to appear in any host project's
361
+ // filename.
362
+ // 2. Escape every regex meta-character on the remaining literal
363
+ // text.
364
+ // 3. Substitute the placeholders for the equivalent regex
365
+ // fragments (with `(?:...)` for brace expansion).
366
+ if (pattern.length > 4096) {
367
+ throw new Error(`compileGlob: pattern is too long (${pattern.length} > 4096 chars).`);
368
+ }
369
+ const BRACE_OPEN = '\u0001';
370
+ const BRACE_CLOSE = '\u0002';
371
+ const BRACE_SEP = '\u0003';
372
+ const GLOBSTAR = '\u0004';
373
+ const STAR = '\u0005';
374
+ const QUESTION = '\u0006';
375
+
376
+ // Phase 1: tokenize.
377
+ let tokenized = pattern.replace(
378
+ /\{([^}]+)\}/g,
379
+ (_m, group: string) => `${BRACE_OPEN}${group.split(',').join(BRACE_SEP)}${BRACE_CLOSE}`,
380
+ );
381
+ tokenized = tokenized.replace(/\*\*/g, GLOBSTAR).replace(/\*/g, STAR).replace(/\?/g, QUESTION);
382
+
383
+ // Phase 2: escape every remaining regex meta-char on the literal
384
+ // text. The placeholders are non-printable and never collide.
385
+ const escaped = tokenized.replace(/[.+^${}()|[\]\\]/g, '\\$&');
386
+
387
+ // Phase 3: substitute the placeholders for the regex fragments.
388
+ const translated = escaped
389
+ .replace(new RegExp(BRACE_OPEN, 'g'), '(?:')
390
+ .replace(new RegExp(BRACE_CLOSE, 'g'), ')')
391
+ .replace(new RegExp(BRACE_SEP, 'g'), '|')
392
+ .replace(new RegExp(GLOBSTAR, 'g'), '.*')
393
+ .replace(new RegExp(STAR, 'g'), '[^/]*')
394
+ .replace(new RegExp(QUESTION, 'g'), '[^/]');
395
+
396
+ const re = new RegExp(`^${translated}$`);
397
+ return (rel: string) => {
398
+ const normalized = rel.split(sep).join('/');
399
+ return re.test(normalized);
400
+ };
401
+ }
402
+
403
+ async function walkDir(root: string, visit: (file: string) => Promise<void>): Promise<void> {
404
+ let s: import('node:fs').Stats;
405
+ try {
406
+ s = await stat(root);
407
+ } catch {
408
+ return;
409
+ }
410
+ if (!s.isDirectory()) {
411
+ if (s.isFile()) await visit(root);
412
+ return;
413
+ }
414
+ const queue: string[] = [root];
415
+ while (queue.length > 0) {
416
+ const dir = queue.shift() as string;
417
+ let entries: import('node:fs').Dirent[];
418
+ try {
419
+ entries = await (await import('node:fs/promises')).readdir(dir, { withFileTypes: true });
420
+ } catch {
421
+ continue;
422
+ }
423
+ for (const entry of entries) {
424
+ if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue;
425
+ const next = `${dir}${sep}${entry.name}`;
426
+ if (entry.isDirectory()) queue.push(next);
427
+ else if (entry.isFile()) await visit(next);
428
+ }
429
+ }
430
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `graphorin traces` - operate on persisted spans.
3
+ *
4
+ * Surface (per Phase 15 § Traces):
5
+ *
6
+ * - `graphorin traces status` - count spans + report time range.
7
+ * - `graphorin traces prune --before <date>` - manual retention
8
+ * enforcement.
9
+ *
10
+ * W-007: the spans live in the `spans` table (migration 024, written by
11
+ * the `createSqliteSpanExporter` from `@graphorin/store-sqlite`). The
12
+ * command previously targeted a `traces` table that no migration or
13
+ * runtime ever created, making it a permanent no-op - an operator who
14
+ * put `traces prune` in cron believed retention was handled while
15
+ * `spans` grew without bound. This command is read + delete only; the
16
+ * span producer is the running observability exporter.
17
+ *
18
+ * @packageDocumentation
19
+ */
20
+
21
+ import { pruneSpans } from '@graphorin/store-sqlite';
22
+ import {
23
+ brand,
24
+ type CommonOutputOptions,
25
+ defaultPrintSink,
26
+ emitReport,
27
+ statusMarker,
28
+ } from '../internal/output.js';
29
+ import { openStoreContext } from '../internal/store-context.js';
30
+
31
+ /** @stable */
32
+ export interface TracesCommonOptions extends CommonOutputOptions {
33
+ readonly config?: string;
34
+ }
35
+
36
+ /**
37
+ * Field names predate the spans retarget (they said `StartedAt` when the
38
+ * command aimed at the phantom `traces` table) and are kept for JSON
39
+ * output stability; values now come from `spans.start_unix_nano`.
40
+ *
41
+ * @stable
42
+ */
43
+ export interface TracesStatusResult {
44
+ readonly tableExists: boolean;
45
+ readonly rows: number;
46
+ readonly oldestStartedAt?: string;
47
+ readonly newestStartedAt?: string;
48
+ }
49
+
50
+ /** @stable */
51
+ export async function runTracesStatus(
52
+ options: TracesCommonOptions = {},
53
+ ): Promise<TracesStatusResult> {
54
+ const ctx = await openStoreContext({
55
+ // W-068: read-only command - never auto-migrate a live database.
56
+ migrationPolicy: 'check',
57
+ ...(options.config !== undefined ? { config: options.config } : {}),
58
+ });
59
+ try {
60
+ const conn = ctx.store.connection;
61
+ // After init() the migrations guarantee the table; the guard stays
62
+ // for databases opened without init.
63
+ const exists = tableExists(conn, 'spans');
64
+ let out: TracesStatusResult;
65
+ if (exists) {
66
+ const oldest = pickIso(conn, 'SELECT MIN(start_unix_nano) AS nano FROM spans');
67
+ const newest = pickIso(conn, 'SELECT MAX(start_unix_nano) AS nano FROM spans');
68
+ out = Object.freeze({
69
+ tableExists: true,
70
+ rows: numericCount(conn, 'SELECT COUNT(*) AS n FROM spans'),
71
+ ...(oldest !== undefined ? { oldestStartedAt: oldest } : {}),
72
+ ...(newest !== undefined ? { newestStartedAt: newest } : {}),
73
+ });
74
+ } else {
75
+ out = Object.freeze({ tableExists: false, rows: 0 });
76
+ }
77
+ emitReport(options, out, () => {
78
+ const print = options.print ?? defaultPrintSink;
79
+ if (!out.tableExists) {
80
+ print(brand('spans table not found - run `graphorin migrate` to initialize the schema.'));
81
+ return;
82
+ }
83
+ print(
84
+ brand(
85
+ `spans: ${out.rows} row(s) (oldest=${out.oldestStartedAt ?? '-'}, newest=${out.newestStartedAt ?? '-'})`,
86
+ ),
87
+ );
88
+ });
89
+ return out;
90
+ } finally {
91
+ await ctx.close();
92
+ }
93
+ }
94
+
95
+ /** @stable */
96
+ export interface TracesPruneOptions extends TracesCommonOptions {
97
+ /** ISO date / epoch ms cutoff. Required so the helper never silently
98
+ * empties the table. */
99
+ readonly before: string;
100
+ }
101
+
102
+ /** @stable */
103
+ export interface TracesPruneResult {
104
+ readonly removed: number;
105
+ readonly cutoff: string;
106
+ }
107
+
108
+ /**
109
+ * Delete spans that FINISHED before the cutoff (see `pruneSpans` in
110
+ * `@graphorin/store-sqlite` - the ms-to-ns conversion and the strict
111
+ * `<` boundary live there, backed by the `idx_spans_end` index).
112
+ *
113
+ * @stable
114
+ */
115
+ export async function runTracesPrune(options: TracesPruneOptions): Promise<TracesPruneResult> {
116
+ const cutoffMs = parseCutoff(options.before);
117
+ const ctx = await openStoreContext({
118
+ // W-068: read-only command - never auto-migrate a live database.
119
+ migrationPolicy: 'check',
120
+ ...(options.config !== undefined ? { config: options.config } : {}),
121
+ });
122
+ try {
123
+ if (!tableExists(ctx.store.connection, 'spans')) {
124
+ const out: TracesPruneResult = Object.freeze({
125
+ removed: 0,
126
+ cutoff: new Date(cutoffMs).toISOString(),
127
+ });
128
+ emitReport(options, out, () => {
129
+ const print = options.print ?? defaultPrintSink;
130
+ print(brand(`spans table not found; nothing to prune.`));
131
+ });
132
+ return out;
133
+ }
134
+ const removed = pruneSpans(ctx.store.connection, { beforeEpochMs: cutoffMs });
135
+ const out: TracesPruneResult = Object.freeze({
136
+ removed,
137
+ cutoff: new Date(cutoffMs).toISOString(),
138
+ });
139
+ emitReport(options, out, () => {
140
+ const print = options.print ?? defaultPrintSink;
141
+ const mark = out.removed > 0 ? statusMarker('ok') : statusMarker('info');
142
+ print(brand(`${mark} pruned ${out.removed} span row(s) (cutoff=${out.cutoff}).`));
143
+ });
144
+ return out;
145
+ } finally {
146
+ await ctx.close();
147
+ }
148
+ }
149
+
150
+ function tableExists(
151
+ conn: { get: <T = unknown>(s: string, p?: ReadonlyArray<unknown>) => T | undefined },
152
+ name: string,
153
+ ): boolean {
154
+ const row = conn.get<{ name: string }>(
155
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
156
+ [name],
157
+ );
158
+ return row !== undefined;
159
+ }
160
+
161
+ function numericCount(
162
+ conn: { get: <T = unknown>(s: string) => T | undefined },
163
+ sql: string,
164
+ ): number {
165
+ const row = conn.get<{ n: number }>(sql);
166
+ return typeof row?.n === 'number' ? row.n : 0;
167
+ }
168
+
169
+ function pickIso(
170
+ conn: { get: <T = unknown>(s: string) => T | undefined },
171
+ sql: string,
172
+ ): string | undefined {
173
+ const row = conn.get<{ nano: number | null }>(sql);
174
+ if (row === undefined || row.nano === null) return undefined;
175
+ return new Date(Math.floor(row.nano / 1e6)).toISOString();
176
+ }
177
+
178
+ function parseCutoff(input: string): number {
179
+ const numeric = Number(input);
180
+ if (Number.isFinite(numeric) && numeric > 0) return numeric;
181
+ const ms = Date.parse(input);
182
+ if (!Number.isFinite(ms)) {
183
+ throw new Error(
184
+ `[graphorin/cli] --before '${input}' is not a valid ISO date or epoch-ms value.`,
185
+ );
186
+ }
187
+ return ms;
188
+ }