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/README.md +126 -0
- package/bin/rubrkit.js +16 -0
- package/package.json +28 -0
- package/src/adapters.js +118 -0
- package/src/api.js +101 -0
- package/src/args.js +175 -0
- package/src/cli.js +93 -0
- package/src/config.js +169 -0
- package/src/errors.js +55 -0
- package/src/formats.js +222 -0
- package/src/index.d.ts +76 -0
- package/src/localChecks.js +680 -0
- package/src/manifest.js +118 -0
- package/src/pathSafety.js +62 -0
- package/src/prompts.js +149 -0
- package/src/pull.js +676 -0
- package/src/sdk.js +443 -0
- package/src/testingCli.js +431 -0
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { usageError } from './errors.js';
|
|
7
|
+
import { hashContent } from './manifest.js';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_MAX_TEXT_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
const IGNORED_DIRS = new Set(['.git', '.next', 'node_modules']);
|
|
11
|
+
const TEXT_EXTENSIONS = new Set([
|
|
12
|
+
'.md',
|
|
13
|
+
'.mdx',
|
|
14
|
+
'.txt',
|
|
15
|
+
'.rubr',
|
|
16
|
+
'.rubr_flow',
|
|
17
|
+
'.json',
|
|
18
|
+
'.yaml',
|
|
19
|
+
'.yml',
|
|
20
|
+
'.js',
|
|
21
|
+
'.jsx',
|
|
22
|
+
'.ts',
|
|
23
|
+
'.tsx',
|
|
24
|
+
]);
|
|
25
|
+
const RUBR_FLOW_EXTENSIONS = new Set(['.rubr', '.rubr_flow']);
|
|
26
|
+
const REQUIRED_RUBR_FLOW_BLOCKS = ['TASK', 'FLOW', 'OUTPUT'];
|
|
27
|
+
const VAGUE_PATTERN = /\b(as needed|if useful|if needed|appropriate|better|good|etc\.?)\b/i;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {{
|
|
31
|
+
* target: string | null,
|
|
32
|
+
* cwd?: string,
|
|
33
|
+
* command?: string,
|
|
34
|
+
* failUnder?: number | null,
|
|
35
|
+
* failOn?: string | null,
|
|
36
|
+
* fsImpl?: typeof fs
|
|
37
|
+
* }} params
|
|
38
|
+
*/
|
|
39
|
+
export async function runLocalChecks({ target, cwd = process.cwd(), command = 'validate', failUnder = null, failOn = null, fsImpl = fs }) {
|
|
40
|
+
if (!target) {
|
|
41
|
+
throw usageError(`The ${command} command needs a path or glob target for local checks.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const discovered = await discoverTargets({ target, cwd, fsImpl });
|
|
45
|
+
const files = [];
|
|
46
|
+
|
|
47
|
+
for (const filePath of discovered) {
|
|
48
|
+
files.push(await checkFile({ filePath, cwd, fsImpl }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const summary = summarizeFiles(files);
|
|
52
|
+
const gates = evaluateQualityGates({ summary, files, failUnder, failOn });
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
schemaVersion: 1,
|
|
56
|
+
command,
|
|
57
|
+
mode: 'local',
|
|
58
|
+
target,
|
|
59
|
+
generatedAt: new Date().toISOString(),
|
|
60
|
+
passed: summary.errorCount === 0 && gates.passed,
|
|
61
|
+
summary: {
|
|
62
|
+
...summary,
|
|
63
|
+
score: gates.score,
|
|
64
|
+
},
|
|
65
|
+
gates,
|
|
66
|
+
files,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {{ target: string, cwd: string, fsImpl: typeof fs }} params
|
|
72
|
+
*/
|
|
73
|
+
export async function discoverTargets({ target, cwd, fsImpl }) {
|
|
74
|
+
if (hasGlob(target)) {
|
|
75
|
+
const matches = await expandGlob({ pattern: target, cwd, fsImpl });
|
|
76
|
+
if (matches.length === 0) {
|
|
77
|
+
throw usageError(`No files matched "${target}".`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return matches;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const resolved = path.resolve(cwd, target);
|
|
84
|
+
let stats;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
stats = await fsImpl.stat(resolved);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw usageError(`Could not read target "${target}": ${error instanceof Error ? error.message : 'not found'}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (stats.isDirectory()) {
|
|
93
|
+
const files = await listTextFiles(resolved, fsImpl);
|
|
94
|
+
if (files.length === 0) {
|
|
95
|
+
throw usageError(`No supported text artifacts found under "${target}".`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return files;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!stats.isFile()) {
|
|
102
|
+
throw usageError(`Target "${target}" is not a file or directory.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return [resolved];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {{
|
|
110
|
+
* target: string,
|
|
111
|
+
* cwd?: string,
|
|
112
|
+
* fsImpl?: typeof fs,
|
|
113
|
+
* message?: string | null,
|
|
114
|
+
* }} params
|
|
115
|
+
*/
|
|
116
|
+
export async function collectArtifactFilesForUpload({ target, cwd = process.cwd(), fsImpl = fs, message = null }) {
|
|
117
|
+
const discovered = await discoverTargets({ target, cwd, fsImpl });
|
|
118
|
+
const files = [];
|
|
119
|
+
|
|
120
|
+
for (const filePath of discovered) {
|
|
121
|
+
const relativePath = slash(path.relative(cwd, filePath) || path.basename(filePath));
|
|
122
|
+
let content;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
content = await fsImpl.readFile(filePath, 'utf8');
|
|
126
|
+
} catch (error) {
|
|
127
|
+
throw usageError(`Could not read target "${relativePath}": ${error instanceof Error ? error.message : 'not found'}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
files.push({
|
|
131
|
+
path: relativePath,
|
|
132
|
+
content,
|
|
133
|
+
mediaType: mediaTypeForPath(filePath),
|
|
134
|
+
artifactType: inferArtifactType(filePath),
|
|
135
|
+
message,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return files;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {{ filePath: string, cwd: string, fsImpl: typeof fs }} params
|
|
144
|
+
*/
|
|
145
|
+
async function checkFile({ filePath, cwd, fsImpl }) {
|
|
146
|
+
const relativePath = slash(path.relative(cwd, filePath) || path.basename(filePath));
|
|
147
|
+
const issues = [];
|
|
148
|
+
const extension = getExtension(filePath);
|
|
149
|
+
const stats = await fsImpl.stat(filePath);
|
|
150
|
+
const artifactType = inferArtifactType(filePath);
|
|
151
|
+
let content = '';
|
|
152
|
+
|
|
153
|
+
if (stats.size > DEFAULT_MAX_TEXT_BYTES) {
|
|
154
|
+
issues.push(createIssue({
|
|
155
|
+
severity: 'error',
|
|
156
|
+
level: 'high',
|
|
157
|
+
code: 'file_too_large',
|
|
158
|
+
message: `File is ${stats.size} bytes, above the ${DEFAULT_MAX_TEXT_BYTES} byte local-check limit.`,
|
|
159
|
+
fix: 'Split the artifact or run a remote Rubrkit audit against a stored artifact bundle.',
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
return finalizeFile({ relativePath, stats, artifactType, contentHash: null, issues });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
content = await fsImpl.readFile(filePath, 'utf8');
|
|
167
|
+
} catch (error) {
|
|
168
|
+
issues.push(createIssue({
|
|
169
|
+
severity: 'error',
|
|
170
|
+
level: 'high',
|
|
171
|
+
code: 'file_read_failed',
|
|
172
|
+
message: error instanceof Error ? error.message : 'Could not read file.',
|
|
173
|
+
fix: 'Confirm the artifact is a readable UTF-8 text file.',
|
|
174
|
+
}));
|
|
175
|
+
return finalizeFile({ relativePath, stats, artifactType, contentHash: null, issues });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!content.trim()) {
|
|
179
|
+
issues.push(createIssue({
|
|
180
|
+
severity: 'error',
|
|
181
|
+
level: 'high',
|
|
182
|
+
code: 'empty_artifact',
|
|
183
|
+
message: 'Artifact is empty.',
|
|
184
|
+
fix: 'Add the instruction artifact content before testing it.',
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!TEXT_EXTENSIONS.has(extension)) {
|
|
189
|
+
issues.push(createIssue({
|
|
190
|
+
severity: 'warning',
|
|
191
|
+
level: 'medium',
|
|
192
|
+
code: 'unknown_text_type',
|
|
193
|
+
message: 'Artifact extension is not one of Rubrkit local validation known text types.',
|
|
194
|
+
fix: 'Use a known text extension or run a remote audit after uploading the artifact bundle.',
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (artifactType === 'rubr_flow' || looksLikeRubrFlow(content)) {
|
|
199
|
+
issues.push(...validateRubrFlowStructure(content, relativePath));
|
|
200
|
+
} else {
|
|
201
|
+
issues.push(...runGenericInstructionChecks(content));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return finalizeFile({ relativePath, stats, artifactType, contentHash: hashContent(content), issues });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* @param {{
|
|
209
|
+
* relativePath: string,
|
|
210
|
+
* stats: import('node:fs').Stats,
|
|
211
|
+
* artifactType: string,
|
|
212
|
+
* contentHash: string | null,
|
|
213
|
+
* issues: Array<Record<string, any>>
|
|
214
|
+
* }} params
|
|
215
|
+
*/
|
|
216
|
+
function finalizeFile({ relativePath, stats, artifactType, contentHash, issues }) {
|
|
217
|
+
const errorCount = issues.filter(issue => issue.severity === 'error').length;
|
|
218
|
+
const warningCount = issues.filter(issue => issue.severity === 'warning').length;
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
path: relativePath,
|
|
222
|
+
artifactType,
|
|
223
|
+
sizeBytes: stats.size,
|
|
224
|
+
contentHash,
|
|
225
|
+
passed: errorCount === 0,
|
|
226
|
+
errorCount,
|
|
227
|
+
warningCount,
|
|
228
|
+
issues,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @param {string} source
|
|
234
|
+
* @param {string} sourceName
|
|
235
|
+
*/
|
|
236
|
+
function validateRubrFlowStructure(source, sourceName) {
|
|
237
|
+
const normalized = normalizeRubrFlowSource(source);
|
|
238
|
+
const lines = normalized.split('\n');
|
|
239
|
+
const issues = [];
|
|
240
|
+
const blocks = new Map();
|
|
241
|
+
let firstMeaningfulLine = 0;
|
|
242
|
+
let currentBlock = '';
|
|
243
|
+
let flowChildren = 0;
|
|
244
|
+
let outputChildren = 0;
|
|
245
|
+
let hasStopOrPass = false;
|
|
246
|
+
|
|
247
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
248
|
+
const lineNumber = index + 1;
|
|
249
|
+
const raw = lines[index];
|
|
250
|
+
const trimmed = raw.trim();
|
|
251
|
+
|
|
252
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const indent = raw.match(/^ */)?.[0].length ?? 0;
|
|
257
|
+
const token = trimmed.match(/^[^\s]+/)?.[0] ?? '';
|
|
258
|
+
const upperToken = token.toUpperCase();
|
|
259
|
+
|
|
260
|
+
if (!firstMeaningfulLine) {
|
|
261
|
+
firstMeaningfulLine = lineNumber;
|
|
262
|
+
if (upperToken !== 'TASK') {
|
|
263
|
+
issues.push(createIssue({
|
|
264
|
+
severity: 'error',
|
|
265
|
+
level: 'high',
|
|
266
|
+
code: 'task_must_be_first',
|
|
267
|
+
message: `${sourceName} should start with TASK.`,
|
|
268
|
+
fix: 'Move the objective to the first meaningful line as TASK "Short imperative name".',
|
|
269
|
+
line: lineNumber,
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (raw.includes('\t')) {
|
|
275
|
+
issues.push(createIssue({
|
|
276
|
+
severity: 'error',
|
|
277
|
+
level: 'high',
|
|
278
|
+
code: 'tabs_not_allowed',
|
|
279
|
+
message: 'Indentation should use spaces, not tabs.',
|
|
280
|
+
fix: 'Replace tabs with 2-space indentation.',
|
|
281
|
+
line: lineNumber,
|
|
282
|
+
}));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (indent % 2 !== 0) {
|
|
286
|
+
issues.push(createIssue({
|
|
287
|
+
severity: 'error',
|
|
288
|
+
level: 'high',
|
|
289
|
+
code: 'invalid_indentation',
|
|
290
|
+
message: 'Nested statements should use 2-space indentation.',
|
|
291
|
+
fix: 'Adjust the leading spaces to a multiple of 2.',
|
|
292
|
+
line: lineNumber,
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (indent === 0) {
|
|
297
|
+
currentBlock = upperToken;
|
|
298
|
+
if (REQUIRED_RUBR_FLOW_BLOCKS.includes(upperToken) || ['VERIFY', 'RULES', 'CONTEXT', 'INPUTS', 'TOOLS', 'STATE'].includes(upperToken)) {
|
|
299
|
+
blocks.set(upperToken, lineNumber);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (upperToken === 'TASK' && !/^TASK\s+"[^"]+"\s*$/.test(trimmed)) {
|
|
303
|
+
issues.push(createIssue({
|
|
304
|
+
severity: 'error',
|
|
305
|
+
level: 'high',
|
|
306
|
+
code: 'invalid_task_statement',
|
|
307
|
+
message: 'TASK should be a single quoted objective.',
|
|
308
|
+
fix: 'Use a statement like TASK "Review release instructions".',
|
|
309
|
+
line: lineNumber,
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (currentBlock === 'FLOW') {
|
|
317
|
+
flowChildren += 1;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (currentBlock === 'OUTPUT') {
|
|
321
|
+
outputChildren += 1;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (/^(STOP|PASS)\b/.test(trimmed)) {
|
|
325
|
+
hasStopOrPass = true;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
for (const block of REQUIRED_RUBR_FLOW_BLOCKS) {
|
|
330
|
+
if (!blocks.has(block)) {
|
|
331
|
+
issues.push(createIssue({
|
|
332
|
+
severity: 'error',
|
|
333
|
+
level: 'high',
|
|
334
|
+
code: 'missing_required_block',
|
|
335
|
+
message: `Missing required ${block} block.`,
|
|
336
|
+
fix: `Add ${block}${block === 'TASK' ? ' "Short imperative name"' : ''}.`,
|
|
337
|
+
line: firstMeaningfulLine || 1,
|
|
338
|
+
}));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (blocks.has('FLOW') && flowChildren === 0) {
|
|
343
|
+
issues.push(createIssue({
|
|
344
|
+
severity: 'error',
|
|
345
|
+
level: 'high',
|
|
346
|
+
code: 'empty_flow_block',
|
|
347
|
+
message: 'FLOW is present but empty.',
|
|
348
|
+
fix: 'Add indented executable steps under FLOW.',
|
|
349
|
+
line: blocks.get('FLOW') ?? 1,
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (blocks.has('OUTPUT') && outputChildren === 0) {
|
|
354
|
+
issues.push(createIssue({
|
|
355
|
+
severity: 'error',
|
|
356
|
+
level: 'high',
|
|
357
|
+
code: 'empty_output_block',
|
|
358
|
+
message: 'OUTPUT is present but empty.',
|
|
359
|
+
fix: 'Add named output fields under OUTPUT.',
|
|
360
|
+
line: blocks.get('OUTPUT') ?? 1,
|
|
361
|
+
}));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!blocks.has('VERIFY')) {
|
|
365
|
+
issues.push(createIssue({
|
|
366
|
+
severity: 'warning',
|
|
367
|
+
level: 'medium',
|
|
368
|
+
code: 'missing_verify_block',
|
|
369
|
+
message: 'Missing recommended VERIFY block.',
|
|
370
|
+
fix: 'Add checks that prove the procedure is complete.',
|
|
371
|
+
line: firstMeaningfulLine || 1,
|
|
372
|
+
}));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (!hasStopOrPass) {
|
|
376
|
+
issues.push(createIssue({
|
|
377
|
+
severity: 'warning',
|
|
378
|
+
level: 'medium',
|
|
379
|
+
code: 'missing_stop_condition',
|
|
380
|
+
message: 'No explicit STOP or PASS condition was found.',
|
|
381
|
+
fix: 'Add STOP WHEN or PASS WHEN so an agent knows when the procedure is done.',
|
|
382
|
+
line: firstMeaningfulLine || 1,
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return issues;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* @param {string} source
|
|
391
|
+
*/
|
|
392
|
+
function runGenericInstructionChecks(source) {
|
|
393
|
+
const issues = [];
|
|
394
|
+
const lower = source.toLowerCase();
|
|
395
|
+
|
|
396
|
+
if (!/\b(output|respond|return|deliverable|format)\b/.test(lower)) {
|
|
397
|
+
issues.push(createIssue({
|
|
398
|
+
severity: 'warning',
|
|
399
|
+
level: 'medium',
|
|
400
|
+
code: 'missing_output_contract',
|
|
401
|
+
message: 'Artifact does not appear to declare an output contract.',
|
|
402
|
+
fix: 'Name the expected output, format, and required fields.',
|
|
403
|
+
}));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (!/\b(verify|test|check|acceptance|done when|pass)\b/.test(lower)) {
|
|
407
|
+
issues.push(createIssue({
|
|
408
|
+
severity: 'warning',
|
|
409
|
+
level: 'medium',
|
|
410
|
+
code: 'missing_verification',
|
|
411
|
+
message: 'Artifact does not appear to include verification or acceptance criteria.',
|
|
412
|
+
fix: 'Add concrete checks that prove the instruction was followed.',
|
|
413
|
+
}));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (VAGUE_PATTERN.test(source)) {
|
|
417
|
+
issues.push(createIssue({
|
|
418
|
+
severity: 'warning',
|
|
419
|
+
level: 'medium',
|
|
420
|
+
code: 'vague_instruction_language',
|
|
421
|
+
message: 'Artifact contains language that can weaken repeatability.',
|
|
422
|
+
fix: 'Replace subjective or open-ended phrasing with observable conditions.',
|
|
423
|
+
}));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return issues;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* @param {string} source
|
|
431
|
+
*/
|
|
432
|
+
function normalizeRubrFlowSource(source) {
|
|
433
|
+
const withoutBom = source.replace(/^\uFEFF/, '');
|
|
434
|
+
const trimmed = withoutBom.trim();
|
|
435
|
+
const fenced = trimmed.match(/^```(?:rubr_flow|rubr-flow|text|txt)?\s*\n([\s\S]*?)\n```$/i);
|
|
436
|
+
|
|
437
|
+
return (fenced?.[1] ?? withoutBom).replace(/\r\n?/g, '\n');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* @param {string} source
|
|
442
|
+
*/
|
|
443
|
+
function looksLikeRubrFlow(source) {
|
|
444
|
+
const normalized = normalizeRubrFlowSource(source);
|
|
445
|
+
return /^\s*TASK\b/m.test(normalized) && /^\s*FLOW\b/m.test(normalized);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* @param {{ severity: 'error' | 'warning', level: 'high' | 'medium' | 'low', code: string, message: string, fix: string, line?: number, column?: number }} issue
|
|
450
|
+
*/
|
|
451
|
+
function createIssue({ severity, level, code, message, fix, line = null, column = null }) {
|
|
452
|
+
return { severity, level, code, message, fix, line, column };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* @param {Array<Record<string, any>>} files
|
|
457
|
+
*/
|
|
458
|
+
function summarizeFiles(files) {
|
|
459
|
+
const errorCount = files.reduce((sum, file) => sum + file.errorCount, 0);
|
|
460
|
+
const warningCount = files.reduce((sum, file) => sum + file.warningCount, 0);
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
fileCount: files.length,
|
|
464
|
+
passedFiles: files.filter(file => file.passed).length,
|
|
465
|
+
failedFiles: files.filter(file => !file.passed).length,
|
|
466
|
+
errorCount,
|
|
467
|
+
warningCount,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* @param {{ summary: Record<string, number>, files: Array<Record<string, any>>, failUnder?: number | null, failOn?: string | null }} params
|
|
473
|
+
*/
|
|
474
|
+
function evaluateQualityGates({ summary, files, failUnder = null, failOn = null }) {
|
|
475
|
+
const score = Math.max(0, Math.min(100, 100 - summary.errorCount * 30 - summary.warningCount * 5));
|
|
476
|
+
const failures = [];
|
|
477
|
+
|
|
478
|
+
if (typeof failUnder === 'number' && score < failUnder) {
|
|
479
|
+
failures.push({
|
|
480
|
+
code: 'score_below_threshold',
|
|
481
|
+
message: `Score ${score} is below --fail-under ${failUnder}.`,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (failOn) {
|
|
486
|
+
const matching = files.flatMap(file => file.issues).filter(issue => issueMatchesFailOn(issue, failOn));
|
|
487
|
+
|
|
488
|
+
if (matching.length > 0) {
|
|
489
|
+
failures.push({
|
|
490
|
+
code: 'fail_on_threshold',
|
|
491
|
+
message: `${matching.length} issue${matching.length === 1 ? '' : 's'} matched --fail-on ${failOn}.`,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
passed: failures.length === 0,
|
|
498
|
+
score,
|
|
499
|
+
failUnder,
|
|
500
|
+
failOn,
|
|
501
|
+
failures,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* @param {Record<string, any>} issue
|
|
507
|
+
* @param {string} failOn
|
|
508
|
+
*/
|
|
509
|
+
function issueMatchesFailOn(issue, failOn) {
|
|
510
|
+
if (failOn === 'critical') {
|
|
511
|
+
return issue.level === 'critical';
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (failOn === 'high') {
|
|
515
|
+
return ['critical', 'high'].includes(issue.level);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (failOn === 'medium') {
|
|
519
|
+
return ['critical', 'high', 'medium'].includes(issue.level);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return ['critical', 'high', 'medium', 'low'].includes(issue.level);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* @param {{ pattern: string, cwd: string, fsImpl: typeof fs }} params
|
|
527
|
+
*/
|
|
528
|
+
async function expandGlob({ pattern, cwd, fsImpl }) {
|
|
529
|
+
const absolutePattern = slash(path.resolve(cwd, pattern));
|
|
530
|
+
const root = findStaticRoot(absolutePattern);
|
|
531
|
+
const regex = globToRegExp(absolutePattern);
|
|
532
|
+
const candidates = await listTextFiles(root, fsImpl);
|
|
533
|
+
|
|
534
|
+
return candidates.filter(candidate => regex.test(slash(candidate))).sort();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* @param {string} root
|
|
539
|
+
* @param {typeof fs} fsImpl
|
|
540
|
+
*/
|
|
541
|
+
async function listTextFiles(root, fsImpl) {
|
|
542
|
+
const entries = await fsImpl.readdir(root, { withFileTypes: true });
|
|
543
|
+
const files = [];
|
|
544
|
+
|
|
545
|
+
for (const entry of entries) {
|
|
546
|
+
if (IGNORED_DIRS.has(entry.name)) {
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const fullPath = path.join(root, entry.name);
|
|
551
|
+
|
|
552
|
+
if (entry.isDirectory()) {
|
|
553
|
+
files.push(...(await listTextFiles(fullPath, fsImpl)));
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (entry.isFile() && TEXT_EXTENSIONS.has(getExtension(fullPath))) {
|
|
558
|
+
files.push(fullPath);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return files.sort();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* @param {string} pattern
|
|
567
|
+
*/
|
|
568
|
+
function hasGlob(pattern) {
|
|
569
|
+
return /[*?[\]]/.test(pattern);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* @param {string} absolutePattern
|
|
574
|
+
*/
|
|
575
|
+
function findStaticRoot(absolutePattern) {
|
|
576
|
+
const marker = absolutePattern.search(/[*?[\]]/);
|
|
577
|
+
const prefix = marker === -1 ? absolutePattern : absolutePattern.slice(0, marker);
|
|
578
|
+
const normalized = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
|
|
579
|
+
const root = normalized.includes('/') ? normalized.slice(0, normalized.lastIndexOf('/')) : normalized;
|
|
580
|
+
|
|
581
|
+
return path.resolve(root || '.');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* @param {string} pattern
|
|
586
|
+
*/
|
|
587
|
+
function globToRegExp(pattern) {
|
|
588
|
+
let source = '';
|
|
589
|
+
|
|
590
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
591
|
+
const char = pattern[index];
|
|
592
|
+
const next = pattern[index + 1];
|
|
593
|
+
|
|
594
|
+
if (char === '*' && next === '*') {
|
|
595
|
+
source += '.*';
|
|
596
|
+
index += 1;
|
|
597
|
+
} else if (char === '*') {
|
|
598
|
+
source += '[^/]*';
|
|
599
|
+
} else if (char === '?') {
|
|
600
|
+
source += '[^/]';
|
|
601
|
+
} else {
|
|
602
|
+
source += escapeRegExp(char);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return new RegExp(`^${source}$`);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* @param {string} value
|
|
611
|
+
*/
|
|
612
|
+
function escapeRegExp(value) {
|
|
613
|
+
return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* @param {string} filePath
|
|
618
|
+
*/
|
|
619
|
+
function getExtension(filePath) {
|
|
620
|
+
if (filePath.toLowerCase().endsWith('.rubr_flow')) {
|
|
621
|
+
return '.rubr_flow';
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return path.extname(filePath).toLowerCase();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* @param {string} filePath
|
|
629
|
+
*/
|
|
630
|
+
function inferArtifactType(filePath) {
|
|
631
|
+
const ext = getExtension(filePath);
|
|
632
|
+
|
|
633
|
+
if (RUBR_FLOW_EXTENSIONS.has(ext)) {
|
|
634
|
+
return 'rubr_flow';
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (ext === '.md' || ext === '.mdx') {
|
|
638
|
+
return 'markdown';
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (ext === '.json') {
|
|
642
|
+
return 'json';
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (ext === '.yaml' || ext === '.yml') {
|
|
646
|
+
return 'yaml';
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (ext === '.js' || ext === '.jsx') {
|
|
650
|
+
return 'javascript';
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (ext === '.ts' || ext === '.tsx') {
|
|
654
|
+
return 'typescript';
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return 'text';
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* @param {string} filePath
|
|
662
|
+
*/
|
|
663
|
+
function mediaTypeForPath(filePath) {
|
|
664
|
+
const ext = getExtension(filePath);
|
|
665
|
+
|
|
666
|
+
if (ext === '.md' || ext === '.mdx') return 'text/markdown';
|
|
667
|
+
if (ext === '.json') return 'application/json';
|
|
668
|
+
if (ext === '.yaml' || ext === '.yml') return 'application/yaml';
|
|
669
|
+
if (ext === '.js' || ext === '.jsx') return 'application/javascript';
|
|
670
|
+
if (ext === '.ts' || ext === '.tsx') return 'application/typescript';
|
|
671
|
+
|
|
672
|
+
return 'text/plain';
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* @param {string} value
|
|
677
|
+
*/
|
|
678
|
+
function slash(value) {
|
|
679
|
+
return value.replace(/\\/g, '/');
|
|
680
|
+
}
|