@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.
- package/CHANGELOG.md +63 -0
- package/README.md +3 -3
- package/dist/bin/graphorin.js +51 -13
- package/dist/bin/graphorin.js.map +1 -1
- package/dist/commands/audit.d.ts.map +1 -1
- package/dist/commands/audit.js +2 -1
- package/dist/commands/audit.js.map +1 -1
- package/dist/commands/consolidator.d.ts +56 -1
- package/dist/commands/consolidator.d.ts.map +1 -1
- package/dist/commands/consolidator.js +88 -2
- package/dist/commands/consolidator.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/index.d.ts +4 -4
- package/dist/commands/index.js +4 -4
- package/dist/commands/init.d.ts +11 -4
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +15 -11
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/memory.d.ts +26 -1
- package/dist/commands/memory.d.ts.map +1 -1
- package/dist/commands/memory.js +56 -3
- package/dist/commands/memory.js.map +1 -1
- package/dist/commands/pricing.d.ts +6 -0
- package/dist/commands/pricing.d.ts.map +1 -1
- package/dist/commands/pricing.js +5 -2
- package/dist/commands/pricing.js.map +1 -1
- package/dist/commands/secrets.d.ts.map +1 -1
- package/dist/commands/secrets.js +2 -2
- package/dist/commands/secrets.js.map +1 -1
- package/dist/commands/skills.d.ts.map +1 -1
- package/dist/commands/skills.js +1 -1
- package/dist/commands/skills.js.map +1 -1
- package/dist/commands/storage.d.ts +41 -1
- package/dist/commands/storage.d.ts.map +1 -1
- package/dist/commands/storage.js +75 -1
- package/dist/commands/storage.js.map +1 -1
- package/dist/commands/token.d.ts.map +1 -1
- package/dist/commands/token.js +2 -2
- package/dist/commands/token.js.map +1 -1
- package/dist/commands/tools-lint.js +1 -1
- package/dist/commands/tools-lint.js.map +1 -1
- package/dist/commands/traces.d.ts +14 -2
- package/dist/commands/traces.d.ts.map +1 -1
- package/dist/commands/traces.js +39 -22
- package/dist/commands/traces.js.map +1 -1
- package/dist/commands/triggers.d.ts.map +1 -1
- package/dist/commands/triggers.js +5 -2
- package/dist/commands/triggers.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -5
- package/dist/index.js.map +1 -1
- package/dist/internal/output.js +6 -0
- package/dist/internal/output.js.map +1 -1
- package/dist/internal/store-context.js +13 -2
- package/dist/internal/store-context.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/package.json +18 -14
- package/src/bin/graphorin.ts +1387 -0
- package/src/commands/audit.ts +256 -0
- package/src/commands/auth.ts +238 -0
- package/src/commands/consolidator.ts +382 -0
- package/src/commands/doctor.ts +253 -0
- package/src/commands/guard.ts +144 -0
- package/src/commands/index.ts +223 -0
- package/src/commands/init.ts +194 -0
- package/src/commands/memory.ts +1052 -0
- package/src/commands/migrate-config.ts +77 -0
- package/src/commands/migrate-export.ts +117 -0
- package/src/commands/migrate.ts +83 -0
- package/src/commands/pricing.ts +244 -0
- package/src/commands/secrets.ts +309 -0
- package/src/commands/skills.ts +272 -0
- package/src/commands/start.ts +180 -0
- package/src/commands/storage.ts +659 -0
- package/src/commands/telemetry.ts +91 -0
- package/src/commands/token.ts +361 -0
- package/src/commands/tools-lint.ts +430 -0
- package/src/commands/traces.ts +188 -0
- package/src/commands/triggers.ts +237 -0
- package/src/index.ts +30 -0
- package/src/internal/exit.ts +62 -0
- package/src/internal/load-config.ts +107 -0
- package/src/internal/offline.ts +81 -0
- package/src/internal/output.ts +146 -0
- package/src/internal/prompts.ts +58 -0
- 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
|
+
}
|