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.
@@ -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
+ }