rubrkit 0.1.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/src/config.js ADDED
@@ -0,0 +1,169 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { usageError } from './errors.js';
5
+
6
+ export const DEFAULT_API_URL = 'https://rubrkit.com/api/v1';
7
+
8
+ const CONFIG_FIELDS = new Set([
9
+ 'apiUrl',
10
+ 'destination',
11
+ 'agent',
12
+ 'artifactBundle',
13
+ 'artifact',
14
+ 'rubric',
15
+ 'format',
16
+ 'output',
17
+ 'failUnder',
18
+ 'failOn',
19
+ 'all',
20
+ 'yes',
21
+ 'dryRun',
22
+ 'force',
23
+ 'prune',
24
+ 'updateOnly',
25
+ 'ci',
26
+ 'local',
27
+ 'remote',
28
+ 'noAi',
29
+ 'watch',
30
+ 'changed',
31
+ ]);
32
+
33
+ /**
34
+ * @param {{
35
+ * parsed: ReturnType<import('./args.js').parseArgs>,
36
+ * env?: Record<string, string | undefined>,
37
+ * cwd?: string,
38
+ * fsImpl?: Pick<typeof fs, 'readFile' | 'stat'>,
39
+ * }} params
40
+ */
41
+ export async function resolveConfig({ parsed, env = process.env, cwd = process.cwd(), fsImpl = fs }) {
42
+ const config = await loadConfig({ configPath: stringOption(parsed.options.config), cwd, fsImpl });
43
+ const options = parsed.options;
44
+
45
+ return {
46
+ command: parsed.command,
47
+ selector: parsed.selector,
48
+ apiKey: stringOption(options['api-key']) ?? env.RUBRKIT_API_KEY ?? null,
49
+ apiUrl: normalizeApiUrl(stringOption(options['api-url']) ?? env.RUBRKIT_API_URL ?? stringOption(config.apiUrl) ?? DEFAULT_API_URL),
50
+ destination: stringOption(options.destination) ?? stringOption(config.destination) ?? cwd,
51
+ agent: stringOption(options.agent) ?? stringOption(config.agent) ?? 'auto',
52
+ artifactBundle: stringOption(options['artifact-bundle']) ?? stringOption(config.artifactBundle) ?? null,
53
+ artifact: stringOption(options.artifact) ?? stringOption(config.artifact) ?? null,
54
+ rubric: stringOption(options.rubric) ?? stringOption(config.rubric) ?? null,
55
+ format: stringOption(options.format) ?? stringOption(config.format) ?? 'text',
56
+ output: stringOption(options.output) ?? stringOption(config.output) ?? null,
57
+ failUnder: numberOption(options['fail-under']) ?? numberOption(config.failUnder),
58
+ failOn: stringOption(options['fail-on']) ?? stringOption(config.failOn) ?? null,
59
+ all: booleanOption(options.all) || booleanOption(config.all),
60
+ yes: booleanOption(options.yes) || booleanOption(config.yes),
61
+ dryRun: booleanOption(options['dry-run']) || booleanOption(config.dryRun),
62
+ force: booleanOption(options.force) || booleanOption(config.force),
63
+ prune: booleanOption(options.prune) || booleanOption(config.prune),
64
+ updateOnly: booleanOption(options['update-only']) || booleanOption(config.updateOnly),
65
+ ci: booleanOption(options.ci) || booleanOption(config.ci),
66
+ local: booleanOption(options.local) || booleanOption(config.local),
67
+ remote: booleanOption(options.remote) || booleanOption(config.remote),
68
+ noAi: booleanOption(options['no-ai']) || booleanOption(config.noAi),
69
+ watch: booleanOption(options.watch) || booleanOption(config.watch),
70
+ changed: booleanOption(options.changed) || booleanOption(config.changed),
71
+ configPath: stringOption(options.config) ?? null,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * @param {{ configPath?: string | null, cwd: string, fsImpl: Pick<typeof fs, 'readFile' | 'stat'> }} params
77
+ */
78
+ async function loadConfig({ configPath, cwd, fsImpl }) {
79
+ const candidate = configPath ? path.resolve(cwd, configPath) : path.join(cwd, '.rubrkit', 'config.json');
80
+
81
+ try {
82
+ if (!configPath) {
83
+ await fsImpl.stat(candidate);
84
+ }
85
+ } catch (error) {
86
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
87
+ return {};
88
+ }
89
+
90
+ throw error;
91
+ }
92
+
93
+ let parsed;
94
+
95
+ try {
96
+ parsed = JSON.parse(await fsImpl.readFile(candidate, 'utf8'));
97
+ } catch (error) {
98
+ throw usageError(`Could not read Rubrkit config at ${candidate}: ${error instanceof Error ? error.message : 'invalid JSON'}`);
99
+ }
100
+
101
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
102
+ throw usageError('Rubrkit config must be a JSON object.');
103
+ }
104
+
105
+ if ('apiKey' in parsed || 'api-key' in parsed || 'RUBRKIT_API_KEY' in parsed) {
106
+ throw usageError('Do not store API keys in Rubrkit config files. Use RUBRKIT_API_KEY or --api-key instead.');
107
+ }
108
+
109
+ for (const field of Object.keys(parsed)) {
110
+ if (!CONFIG_FIELDS.has(field)) {
111
+ throw usageError(`Unknown Rubrkit config field "${field}".`);
112
+ }
113
+ }
114
+
115
+ return parsed;
116
+ }
117
+
118
+ /**
119
+ * @param {unknown} value
120
+ */
121
+ function stringOption(value) {
122
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
123
+ }
124
+
125
+ /**
126
+ * @param {unknown} value
127
+ */
128
+ function booleanOption(value) {
129
+ return value === true;
130
+ }
131
+
132
+ /**
133
+ * @param {unknown} value
134
+ */
135
+ function numberOption(value) {
136
+ if (value === undefined || value === null || value === false) {
137
+ return null;
138
+ }
139
+
140
+ const parsed = Number(value);
141
+ return Number.isFinite(parsed) ? parsed : null;
142
+ }
143
+
144
+ /**
145
+ * @param {string} value
146
+ */
147
+ export function normalizeApiUrl(value) {
148
+ let parsed;
149
+
150
+ try {
151
+ parsed = new URL(value);
152
+ } catch {
153
+ throw usageError(`Invalid Rubrkit API URL "${value}".`);
154
+ }
155
+
156
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
157
+ throw usageError('Rubrkit API URL must use http or https.');
158
+ }
159
+
160
+ if (parsed.pathname === '/' || parsed.pathname === '') {
161
+ parsed.pathname = '/api/v1';
162
+ }
163
+
164
+ parsed.pathname = parsed.pathname.replace(/\/+$/, '');
165
+ parsed.search = '';
166
+ parsed.hash = '';
167
+
168
+ return parsed.toString().replace(/\/$/, '');
169
+ }
package/src/errors.js ADDED
@@ -0,0 +1,55 @@
1
+ export class RubrkitCliError extends Error {
2
+ /**
3
+ * @param {string} message
4
+ * @param {{ code?: string, exitCode?: number, details?: unknown }} [options]
5
+ */
6
+ constructor(message, { code = 'rubrkit_cli_error', exitCode = 1, details = undefined } = {}) {
7
+ super(message);
8
+ this.name = 'RubrkitCliError';
9
+ this.code = code;
10
+ this.exitCode = exitCode;
11
+ this.details = details;
12
+ }
13
+ }
14
+
15
+ export const EXIT_CODES = {
16
+ checksFailed: 1,
17
+ usage: 2,
18
+ auth: 3,
19
+ blocked: 4,
20
+ network: 5,
21
+ };
22
+
23
+ /**
24
+ * @param {string} message
25
+ */
26
+ export function usageError(message) {
27
+ return new RubrkitCliError(message, { code: 'invalid_cli_usage', exitCode: EXIT_CODES.usage });
28
+ }
29
+
30
+ /**
31
+ * @param {string} message
32
+ */
33
+ export function authError(message) {
34
+ return new RubrkitCliError(message, { code: 'missing_api_key', exitCode: EXIT_CODES.auth });
35
+ }
36
+
37
+ /**
38
+ * @param {string | null | undefined} code
39
+ * @param {number} [status]
40
+ */
41
+ export function exitCodeForApiFailure(code, status = 0) {
42
+ if (status === 401 || status === 403) {
43
+ return EXIT_CODES.auth;
44
+ }
45
+
46
+ if (['credit_limit_exceeded', 'usage_limit_exceeded', 'ai_circuit_breaker_open'].includes(String(code))) {
47
+ return EXIT_CODES.blocked;
48
+ }
49
+
50
+ if (status >= 500 || status === 429 || status === 0) {
51
+ return EXIT_CODES.network;
52
+ }
53
+
54
+ return EXIT_CODES.checksFailed;
55
+ }
package/src/formats.js ADDED
@@ -0,0 +1,222 @@
1
+ // @ts-check
2
+
3
+ import fs from 'node:fs/promises';
4
+
5
+ /**
6
+ * @param {{ payload: Record<string, any>, format?: string, output?: string | null, stdout: NodeJS.WritableStream, fsImpl?: typeof fs }} params
7
+ */
8
+ export async function writeFormattedResult({ payload, format = 'text', output = null, stdout, fsImpl = fs }) {
9
+ const rendered = renderResult(payload, format);
10
+
11
+ if (output) {
12
+ await fsImpl.writeFile(output, rendered, 'utf8');
13
+ if (format !== 'json') {
14
+ stdout.write(`Wrote Rubrkit ${format} report to ${output}\n`);
15
+ }
16
+ return;
17
+ }
18
+
19
+ stdout.write(rendered);
20
+ if (!rendered.endsWith('\n')) {
21
+ stdout.write('\n');
22
+ }
23
+ }
24
+
25
+ /**
26
+ * @param {Record<string, any>} payload
27
+ * @param {string} format
28
+ */
29
+ export function renderResult(payload, format = 'text') {
30
+ if (format === 'json') {
31
+ return `${JSON.stringify(payload, null, 2)}\n`;
32
+ }
33
+
34
+ if (format === 'junit') {
35
+ return renderJUnit(payload);
36
+ }
37
+
38
+ return renderText(payload);
39
+ }
40
+
41
+ /**
42
+ * @param {Record<string, any>} payload
43
+ */
44
+ function renderText(payload) {
45
+ if (payload.mode === 'remote-dry-run') {
46
+ return [
47
+ `Rubrkit ${payload.command} remote dry run`,
48
+ `Target: ${payload.target ?? '(none)'}`,
49
+ `Endpoint: ${payload.endpoint}`,
50
+ `Method: ${payload.method}`,
51
+ `Polls job: ${payload.pollsJob ? 'yes' : 'no'}`,
52
+ payload.requiresApiKey ? 'Requires API key: yes' : 'Requires API key: no',
53
+ ].join('\n') + '\n';
54
+ }
55
+
56
+ if (payload.mode === 'remote') {
57
+ const job = payload.job ?? payload.data?.job ?? payload.data ?? {};
58
+ const state = job.state ?? 'unknown';
59
+ const score = scoreFromPayload(payload);
60
+ const lines = [
61
+ `Rubrkit ${payload.command} remote ${state}`,
62
+ `Job: ${job.id ?? payload.jobId ?? '(unknown)'}`,
63
+ ];
64
+
65
+ if (job.phase || job.message) {
66
+ lines.push(`Latest: ${[job.phase, job.message].filter(Boolean).join(' - ')}`);
67
+ }
68
+
69
+ if (typeof score === 'number') {
70
+ lines.push(`Score: ${score}`);
71
+ }
72
+
73
+ if (job.error?.message) {
74
+ lines.push(`Error: ${job.error.code ?? 'job_failed'} - ${job.error.message}`);
75
+ }
76
+
77
+ return `${lines.join('\n')}\n`;
78
+ }
79
+
80
+ if (payload.mode === 'report') {
81
+ const job = payload.job ?? payload.data?.job ?? payload;
82
+ return [
83
+ `Rubrkit report ${job.state ?? 'unknown'}`,
84
+ `Job: ${job.id ?? payload.id ?? '(unknown)'}`,
85
+ `Kind: ${job.kind ?? '(unknown)'}`,
86
+ `Latest: ${[job.phase, job.message].filter(Boolean).join(' - ') || '(none)'}`,
87
+ ].join('\n') + '\n';
88
+ }
89
+
90
+ const summary = payload.summary ?? {};
91
+ const status = payload.passed ? 'PASS' : 'FAIL';
92
+ const lines = [
93
+ `Rubrkit ${payload.command ?? 'validate'} ${payload.mode ?? 'local'}`,
94
+ `${status} ${payload.target ?? ''} (${summary.fileCount ?? 0} files, ${summary.errorCount ?? 0} errors, ${summary.warningCount ?? 0} warnings, score ${summary.score ?? 0})`,
95
+ ];
96
+
97
+ for (const file of payload.files ?? []) {
98
+ lines.push(`- ${file.passed ? 'PASS' : 'FAIL'} ${file.path}`);
99
+
100
+ for (const issue of file.issues ?? []) {
101
+ const location = issue.line ? ` line ${issue.line}${issue.column ? `:${issue.column}` : ''}` : '';
102
+ lines.push(` ${issue.severity.toUpperCase()} ${issue.code}${location}: ${issue.message}`);
103
+ lines.push(` fix: ${issue.fix}`);
104
+ }
105
+ }
106
+
107
+ for (const gate of payload.gates?.failures ?? []) {
108
+ lines.push(`Gate ${gate.code}: ${gate.message}`);
109
+ }
110
+
111
+ return `${lines.join('\n')}\n`;
112
+ }
113
+
114
+ /**
115
+ * @param {Record<string, any>} payload
116
+ */
117
+ function renderJUnit(payload) {
118
+ const files = Array.isArray(payload.files) ? payload.files : [];
119
+ const tests = files.length || 1;
120
+ const failures = files.reduce((sum, file) => sum + Number(file.errorCount ?? 0), 0);
121
+ const warnings = files.reduce((sum, file) => sum + Number(file.warningCount ?? 0), 0);
122
+ const name = `rubrkit.${payload.command ?? 'test'}.${payload.mode ?? 'local'}`;
123
+ const testcases = files.length > 0 ? files.map(renderFileTestCase).join('\n') : renderRemoteTestCase(payload);
124
+
125
+ return [
126
+ '<?xml version="1.0" encoding="UTF-8"?>',
127
+ `<testsuite name="${xml(name)}" tests="${tests}" failures="${failures}" errors="0" skipped="0">`,
128
+ warnings > 0 ? ` <system-out>${xml(`${warnings} Rubrkit warning${warnings === 1 ? '' : 's'}`)}</system-out>` : '',
129
+ testcases,
130
+ '</testsuite>',
131
+ '',
132
+ ].filter(line => line !== '').join('\n');
133
+ }
134
+
135
+ /**
136
+ * @param {Record<string, any>} file
137
+ */
138
+ function renderFileTestCase(file) {
139
+ const failures = (file.issues ?? []).filter(issue => issue.severity === 'error');
140
+ const warnings = (file.issues ?? []).filter(issue => issue.severity === 'warning');
141
+ const body = [];
142
+
143
+ if (failures.length > 0) {
144
+ body.push(
145
+ ` <failure message="${xml(`${failures.length} Rubrkit validation error${failures.length === 1 ? '' : 's'}`)}">${xml(
146
+ failures.map(formatIssue).join('\n'),
147
+ )}</failure>`,
148
+ );
149
+ }
150
+
151
+ if (warnings.length > 0) {
152
+ body.push(` <system-out>${xml(warnings.map(formatIssue).join('\n'))}</system-out>`);
153
+ }
154
+
155
+ if (body.length === 0) {
156
+ return ` <testcase name="${xml(file.path)}" classname="RubrkitLocalChecks" />`;
157
+ }
158
+
159
+ return [` <testcase name="${xml(file.path)}" classname="RubrkitLocalChecks">`, ...body, ' </testcase>'].join('\n');
160
+ }
161
+
162
+ /**
163
+ * @param {Record<string, any>} payload
164
+ */
165
+ function renderRemoteTestCase(payload) {
166
+ const job = payload.job ?? {};
167
+ const failed = ['failed', 'cancelled', 'paused'].includes(job.state);
168
+
169
+ if (!failed) {
170
+ return ` <testcase name="${xml(job.id ?? payload.jobId ?? 'remote-job')}" classname="RubrkitRemoteJob" />`;
171
+ }
172
+
173
+ return [
174
+ ` <testcase name="${xml(job.id ?? payload.jobId ?? 'remote-job')}" classname="RubrkitRemoteJob">`,
175
+ ` <failure message="${xml(job.error?.message ?? 'Remote Rubrkit job did not succeed.')}">${xml(JSON.stringify(job.error ?? {}, null, 2))}</failure>`,
176
+ ' </testcase>',
177
+ ].join('\n');
178
+ }
179
+
180
+ /**
181
+ * @param {Record<string, any>} issue
182
+ */
183
+ function formatIssue(issue) {
184
+ const location = issue.line ? `line ${issue.line}${issue.column ? `:${issue.column}` : ''}` : 'file';
185
+ return `${location} ${issue.severity?.toUpperCase?.() ?? 'ISSUE'} ${issue.code}: ${issue.message}\nfix: ${issue.fix}`;
186
+ }
187
+
188
+ /**
189
+ * @param {string} value
190
+ */
191
+ function xml(value) {
192
+ return String(value)
193
+ .replace(/&/g, '&amp;')
194
+ .replace(/</g, '&lt;')
195
+ .replace(/>/g, '&gt;')
196
+ .replace(/"/g, '&quot;')
197
+ .replace(/'/g, '&apos;');
198
+ }
199
+
200
+ /**
201
+ * @param {Record<string, any>} payload
202
+ */
203
+ export function scoreFromPayload(payload) {
204
+ const candidates = [
205
+ payload.summary?.score,
206
+ payload.job?.result?.overallScore,
207
+ payload.job?.result?.score,
208
+ payload.started?.auditRun?.result?.overallScore,
209
+ payload.started?.auditRun?.summary?.overallScore,
210
+ payload.started?.evalRun?.summary?.score,
211
+ payload.started?.result?.overallScore,
212
+ ];
213
+
214
+ for (const candidate of candidates) {
215
+ const value = Number(candidate);
216
+ if (Number.isFinite(value)) {
217
+ return value;
218
+ }
219
+ }
220
+
221
+ return null;
222
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,76 @@
1
+ export interface RubrkitOptions {
2
+ apiKey?: string | null;
3
+ apiUrl?: string;
4
+ fetchImpl?: typeof fetch;
5
+ userAgent?: string;
6
+ retryReads?: number;
7
+ }
8
+
9
+ export interface JobWaitOptions {
10
+ intervalMs?: number;
11
+ timeoutMs?: number;
12
+ signal?: AbortSignal;
13
+ onProgress?: (job: Record<string, unknown>) => void;
14
+ }
15
+
16
+ export class RubrkitError extends Error {
17
+ code: string;
18
+ details?: unknown;
19
+ }
20
+
21
+ export class RubrkitApiError extends RubrkitError {
22
+ status: number;
23
+ requestId: string | null;
24
+ }
25
+
26
+ export class RubrkitNetworkError extends RubrkitError {}
27
+
28
+ export class Rubrkit {
29
+ constructor(options?: RubrkitOptions);
30
+
31
+ me: {
32
+ get(): Promise<Record<string, unknown>>;
33
+ };
34
+
35
+ artifactBundles: {
36
+ list(params?: Record<string, unknown>): Promise<Record<string, unknown>>;
37
+ create(params?: Record<string, unknown>): Promise<Record<string, unknown>>;
38
+ get(id: string): Promise<Record<string, unknown>>;
39
+ };
40
+
41
+ artifacts: {
42
+ list(params: { artifactBundleId: string } & Record<string, unknown>): Promise<Record<string, unknown>>;
43
+ pull(params: { artifactBundleId: string; artifactId?: string; fileId?: string } & Record<string, unknown>): Promise<Record<string, unknown>>;
44
+ upload(params: { artifactBundleId: string; files: Array<Record<string, unknown>> } & Record<string, unknown>): Promise<Record<string, unknown>>;
45
+ test(params?: Record<string, unknown>): Promise<Record<string, unknown>>;
46
+ };
47
+
48
+ audits: {
49
+ run(params: { artifactBundleId: string } & Record<string, unknown>): Promise<Record<string, unknown>>;
50
+ };
51
+
52
+ evals: {
53
+ run(params: { artifactBundleId: string } & Record<string, unknown>): Promise<Record<string, unknown>>;
54
+ };
55
+
56
+ rubrFlow: {
57
+ convert(params: { artifactBundleId: string } & Record<string, unknown>): Promise<Record<string, unknown>>;
58
+ };
59
+
60
+ jobs: {
61
+ get(id: string): Promise<Record<string, unknown>>;
62
+ wait(id: string, options?: JobWaitOptions): Promise<Record<string, unknown>>;
63
+ };
64
+
65
+ proofReports: {
66
+ get(id: string, options: { artifactBundleId: string }): Promise<Record<string, unknown>>;
67
+ get(params: { artifactBundleId: string; proofReportId: string }): Promise<Record<string, unknown>>;
68
+ };
69
+
70
+ request(path: string, options?: {
71
+ method?: string;
72
+ query?: Record<string, unknown>;
73
+ body?: unknown;
74
+ signal?: AbortSignal;
75
+ }): Promise<Record<string, unknown>>;
76
+ }