@mainahq/core 0.5.0 → 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/package.json +1 -1
- package/src/cloud/__tests__/client.test.ts +411 -0
- package/src/cloud/auth.ts +18 -6
- package/src/cloud/client.ts +114 -0
- package/src/cloud/types.ts +95 -1
- package/src/feedback/__tests__/sync.test.ts +104 -0
- package/src/feedback/sync.ts +54 -0
- package/src/index.ts +22 -12
- package/src/language/__tests__/__fixtures__/detect/composer.lock +1 -0
- package/src/verify/__tests__/types.test.ts +129 -0
- package/src/verify/types.ts +17 -0
package/package.json
CHANGED
|
@@ -250,4 +250,415 @@ describe("createCloudClient", () => {
|
|
|
250
250
|
expect(result.error).toContain("Network unreachable");
|
|
251
251
|
}
|
|
252
252
|
});
|
|
253
|
+
|
|
254
|
+
// ── submitVerify ─────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
test("submitVerify sends diff, repo, and base_branch", async () => {
|
|
257
|
+
mockFetch.mockImplementation(() =>
|
|
258
|
+
Promise.resolve(jsonResponse({ data: { jobId: "job-abc-123" } })),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const client = setupClient();
|
|
262
|
+
const result = await client.submitVerify({
|
|
263
|
+
diff: "--- a/file.ts\n+++ b/file.ts\n@@ -1 +1 @@\n-old\n+new",
|
|
264
|
+
repo: "acme/app",
|
|
265
|
+
baseBranch: "main",
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
expect(result.ok).toBe(true);
|
|
269
|
+
if (result.ok) {
|
|
270
|
+
expect(result.value.jobId).toBe("job-abc-123");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const call = mockFetch.mock.calls[0] as unknown[];
|
|
274
|
+
const url = call[0] as string;
|
|
275
|
+
const requestInit = call[1] as RequestInit;
|
|
276
|
+
expect(url).toBe("https://api.test.maina.dev/verify");
|
|
277
|
+
expect(requestInit.method).toBe("POST");
|
|
278
|
+
const body = JSON.parse(requestInit.body as string);
|
|
279
|
+
expect(body.diff).toContain("+new");
|
|
280
|
+
expect(body.repo).toBe("acme/app");
|
|
281
|
+
expect(body.base_branch).toBe("main");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("submitVerify omits base_branch when not provided", async () => {
|
|
285
|
+
mockFetch.mockImplementation(() =>
|
|
286
|
+
Promise.resolve(jsonResponse({ data: { jobId: "job-def-456" } })),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const client = setupClient();
|
|
290
|
+
await client.submitVerify({
|
|
291
|
+
diff: "some diff",
|
|
292
|
+
repo: "acme/app",
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const call = mockFetch.mock.calls[0] as unknown[];
|
|
296
|
+
const requestInit = call[1] as RequestInit;
|
|
297
|
+
const body = JSON.parse(requestInit.body as string);
|
|
298
|
+
expect(body.base_branch).toBeUndefined();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("submitVerify returns error on 401", async () => {
|
|
302
|
+
mockFetch.mockImplementation(() =>
|
|
303
|
+
Promise.resolve(jsonResponse({ error: "Unauthorized" }, 401)),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const client = setupClient();
|
|
307
|
+
const result = await client.submitVerify({
|
|
308
|
+
diff: "diff",
|
|
309
|
+
repo: "acme/app",
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(result.ok).toBe(false);
|
|
313
|
+
if (!result.ok) {
|
|
314
|
+
expect(result.error).toBe("Unauthorized");
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// ── getVerifyStatus ──────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
test("getVerifyStatus returns queued status", async () => {
|
|
321
|
+
mockFetch.mockImplementation(() =>
|
|
322
|
+
Promise.resolve(
|
|
323
|
+
jsonResponse({
|
|
324
|
+
data: { status: "queued", currentStep: "Waiting in queue" },
|
|
325
|
+
}),
|
|
326
|
+
),
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const client = setupClient();
|
|
330
|
+
const result = await client.getVerifyStatus("job-abc-123");
|
|
331
|
+
|
|
332
|
+
expect(result.ok).toBe(true);
|
|
333
|
+
if (result.ok) {
|
|
334
|
+
expect(result.value.status).toBe("queued");
|
|
335
|
+
expect(result.value.currentStep).toBe("Waiting in queue");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const call = mockFetch.mock.calls[0] as unknown[];
|
|
339
|
+
const url = call[0] as string;
|
|
340
|
+
expect(url).toBe("https://api.test.maina.dev/verify/job-abc-123/status");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("getVerifyStatus returns running status", async () => {
|
|
344
|
+
mockFetch.mockImplementation(() =>
|
|
345
|
+
Promise.resolve(
|
|
346
|
+
jsonResponse({
|
|
347
|
+
data: {
|
|
348
|
+
status: "running",
|
|
349
|
+
currentStep: "Running Biome lint",
|
|
350
|
+
},
|
|
351
|
+
}),
|
|
352
|
+
),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const client = setupClient();
|
|
356
|
+
const result = await client.getVerifyStatus("job-abc-123");
|
|
357
|
+
|
|
358
|
+
expect(result.ok).toBe(true);
|
|
359
|
+
if (result.ok) {
|
|
360
|
+
expect(result.value.status).toBe("running");
|
|
361
|
+
expect(result.value.currentStep).toBe("Running Biome lint");
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("getVerifyStatus returns error on 404", async () => {
|
|
366
|
+
mockFetch.mockImplementation(() =>
|
|
367
|
+
Promise.resolve(jsonResponse({ error: "Job not found" }, 404)),
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const client = setupClient();
|
|
371
|
+
const result = await client.getVerifyStatus("nonexistent");
|
|
372
|
+
|
|
373
|
+
expect(result.ok).toBe(false);
|
|
374
|
+
if (!result.ok) {
|
|
375
|
+
expect(result.error).toBe("Job not found");
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("getVerifyStatus retries on 500", async () => {
|
|
380
|
+
let attempts = 0;
|
|
381
|
+
mockFetch.mockImplementation(() => {
|
|
382
|
+
attempts++;
|
|
383
|
+
if (attempts === 1) {
|
|
384
|
+
return Promise.resolve(jsonResponse({ error: "Internal error" }, 500));
|
|
385
|
+
}
|
|
386
|
+
return Promise.resolve(
|
|
387
|
+
jsonResponse({
|
|
388
|
+
data: { status: "done", currentStep: "Complete" },
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const client = setupClient({ maxRetries: 2 });
|
|
394
|
+
const result = await client.getVerifyStatus("job-abc-123");
|
|
395
|
+
|
|
396
|
+
expect(result.ok).toBe(true);
|
|
397
|
+
expect(attempts).toBe(2);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// ── getVerifyResult ──────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
test("getVerifyResult returns passing result", async () => {
|
|
403
|
+
const verifyResult = {
|
|
404
|
+
id: "job-abc-123",
|
|
405
|
+
status: "done",
|
|
406
|
+
passed: true,
|
|
407
|
+
findings: [],
|
|
408
|
+
findingsErrors: 0,
|
|
409
|
+
findingsWarnings: 0,
|
|
410
|
+
proofKey: "proof-xyz-789",
|
|
411
|
+
durationMs: 4523,
|
|
412
|
+
};
|
|
413
|
+
mockFetch.mockImplementation(() =>
|
|
414
|
+
Promise.resolve(jsonResponse({ data: verifyResult })),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
const client = setupClient();
|
|
418
|
+
const result = await client.getVerifyResult("job-abc-123");
|
|
419
|
+
|
|
420
|
+
expect(result.ok).toBe(true);
|
|
421
|
+
if (result.ok) {
|
|
422
|
+
expect(result.value.id).toBe("job-abc-123");
|
|
423
|
+
expect(result.value.passed).toBe(true);
|
|
424
|
+
expect(result.value.findings).toHaveLength(0);
|
|
425
|
+
expect(result.value.proofKey).toBe("proof-xyz-789");
|
|
426
|
+
expect(result.value.durationMs).toBe(4523);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const call = mockFetch.mock.calls[0] as unknown[];
|
|
430
|
+
const url = call[0] as string;
|
|
431
|
+
expect(url).toBe("https://api.test.maina.dev/verify/job-abc-123");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("getVerifyResult returns failing result with findings", async () => {
|
|
435
|
+
const verifyResult = {
|
|
436
|
+
id: "job-fail-456",
|
|
437
|
+
status: "failed",
|
|
438
|
+
passed: false,
|
|
439
|
+
findings: [
|
|
440
|
+
{
|
|
441
|
+
tool: "biome",
|
|
442
|
+
file: "src/index.ts",
|
|
443
|
+
line: 42,
|
|
444
|
+
message: "Unexpected console.log",
|
|
445
|
+
severity: "error",
|
|
446
|
+
ruleId: "no-console",
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
tool: "semgrep",
|
|
450
|
+
file: "src/utils.ts",
|
|
451
|
+
line: 10,
|
|
452
|
+
message: "Potential SQL injection",
|
|
453
|
+
severity: "warning",
|
|
454
|
+
},
|
|
455
|
+
],
|
|
456
|
+
findingsErrors: 1,
|
|
457
|
+
findingsWarnings: 1,
|
|
458
|
+
proofKey: null,
|
|
459
|
+
durationMs: 3200,
|
|
460
|
+
};
|
|
461
|
+
mockFetch.mockImplementation(() =>
|
|
462
|
+
Promise.resolve(jsonResponse({ data: verifyResult })),
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
const client = setupClient();
|
|
466
|
+
const result = await client.getVerifyResult("job-fail-456");
|
|
467
|
+
|
|
468
|
+
expect(result.ok).toBe(true);
|
|
469
|
+
if (result.ok) {
|
|
470
|
+
expect(result.value.passed).toBe(false);
|
|
471
|
+
expect(result.value.findings).toHaveLength(2);
|
|
472
|
+
expect(result.value.findings[0]?.tool).toBe("biome");
|
|
473
|
+
expect(result.value.findings[0]?.severity).toBe("error");
|
|
474
|
+
expect(result.value.findings[0]?.ruleId).toBe("no-console");
|
|
475
|
+
expect(result.value.findings[1]?.tool).toBe("semgrep");
|
|
476
|
+
expect(result.value.findings[1]?.ruleId).toBeUndefined();
|
|
477
|
+
expect(result.value.findingsErrors).toBe(1);
|
|
478
|
+
expect(result.value.findingsWarnings).toBe(1);
|
|
479
|
+
expect(result.value.proofKey).toBeNull();
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("getVerifyResult returns error on 404", async () => {
|
|
484
|
+
mockFetch.mockImplementation(() =>
|
|
485
|
+
Promise.resolve(jsonResponse({ error: "Job not found" }, 404)),
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
const client = setupClient();
|
|
489
|
+
const result = await client.getVerifyResult("nonexistent");
|
|
490
|
+
|
|
491
|
+
expect(result.ok).toBe(false);
|
|
492
|
+
if (!result.ok) {
|
|
493
|
+
expect(result.error).toBe("Job not found");
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// ── postFeedbackBatch ─────────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
test("postFeedbackBatch sends events in snake_case", async () => {
|
|
500
|
+
mockFetch.mockImplementation(() =>
|
|
501
|
+
Promise.resolve(jsonResponse({ data: { received: 3 } })),
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
const client = setupClient();
|
|
505
|
+
const result = await client.postFeedbackBatch([
|
|
506
|
+
{
|
|
507
|
+
promptHash: "hash-1",
|
|
508
|
+
command: "commit",
|
|
509
|
+
accepted: true,
|
|
510
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
promptHash: "hash-2",
|
|
514
|
+
command: "review",
|
|
515
|
+
accepted: false,
|
|
516
|
+
context: "user edited",
|
|
517
|
+
diffHash: "diff-abc",
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
promptHash: "hash-3",
|
|
521
|
+
command: "fix",
|
|
522
|
+
accepted: true,
|
|
523
|
+
},
|
|
524
|
+
]);
|
|
525
|
+
|
|
526
|
+
expect(result.ok).toBe(true);
|
|
527
|
+
if (result.ok) {
|
|
528
|
+
expect(result.value.received).toBe(3);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const call = mockFetch.mock.calls[0] as unknown[];
|
|
532
|
+
const url = call[0] as string;
|
|
533
|
+
expect(url).toBe("https://api.test.maina.dev/feedback/batch");
|
|
534
|
+
|
|
535
|
+
const requestInit = call[1] as RequestInit;
|
|
536
|
+
expect(requestInit.method).toBe("POST");
|
|
537
|
+
|
|
538
|
+
const body = JSON.parse(requestInit.body as string);
|
|
539
|
+
expect(body.events).toHaveLength(3);
|
|
540
|
+
// Verify snake_case mapping
|
|
541
|
+
expect(body.events[0].prompt_hash).toBe("hash-1");
|
|
542
|
+
expect(body.events[0].command).toBe("commit");
|
|
543
|
+
expect(body.events[0].accepted).toBe(true);
|
|
544
|
+
expect(body.events[0].timestamp).toBe("2026-01-01T00:00:00Z");
|
|
545
|
+
expect(body.events[1].diff_hash).toBe("diff-abc");
|
|
546
|
+
expect(body.events[1].context).toBe("user edited");
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("postFeedbackBatch returns error on failure", async () => {
|
|
550
|
+
mockFetch.mockImplementation(() =>
|
|
551
|
+
Promise.resolve(jsonResponse({ error: "Unauthorized" }, 401)),
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
const client = setupClient();
|
|
555
|
+
const result = await client.postFeedbackBatch([]);
|
|
556
|
+
|
|
557
|
+
expect(result.ok).toBe(false);
|
|
558
|
+
if (!result.ok) {
|
|
559
|
+
expect(result.error).toBe("Unauthorized");
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// ── getFeedbackImprovements ──────────────────────────────────────────
|
|
564
|
+
|
|
565
|
+
test("getFeedbackImprovements returns improvements with camelCase mapping", async () => {
|
|
566
|
+
mockFetch.mockImplementation(() =>
|
|
567
|
+
Promise.resolve(
|
|
568
|
+
jsonResponse({
|
|
569
|
+
data: {
|
|
570
|
+
improvements: [
|
|
571
|
+
{
|
|
572
|
+
command: "commit",
|
|
573
|
+
prompt_hash: "hash-abc",
|
|
574
|
+
samples: 50,
|
|
575
|
+
accept_rate: 0.85,
|
|
576
|
+
status: "healthy",
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
command: "review",
|
|
580
|
+
prompt_hash: "hash-def",
|
|
581
|
+
samples: 30,
|
|
582
|
+
accept_rate: 0.4,
|
|
583
|
+
status: "needs_improvement",
|
|
584
|
+
},
|
|
585
|
+
],
|
|
586
|
+
team_totals: {
|
|
587
|
+
total_events: 200,
|
|
588
|
+
accept_rate: 0.72,
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
}),
|
|
592
|
+
),
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
const client = setupClient();
|
|
596
|
+
const result = await client.getFeedbackImprovements();
|
|
597
|
+
|
|
598
|
+
expect(result.ok).toBe(true);
|
|
599
|
+
if (result.ok) {
|
|
600
|
+
expect(result.value.improvements).toHaveLength(2);
|
|
601
|
+
expect(result.value.improvements[0]?.command).toBe("commit");
|
|
602
|
+
expect(result.value.improvements[0]?.promptHash).toBe("hash-abc");
|
|
603
|
+
expect(result.value.improvements[0]?.samples).toBe(50);
|
|
604
|
+
expect(result.value.improvements[0]?.acceptRate).toBe(0.85);
|
|
605
|
+
expect(result.value.improvements[0]?.status).toBe("healthy");
|
|
606
|
+
expect(result.value.improvements[1]?.status).toBe("needs_improvement");
|
|
607
|
+
expect(result.value.teamTotals.totalEvents).toBe(200);
|
|
608
|
+
expect(result.value.teamTotals.acceptRate).toBe(0.72);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const call = mockFetch.mock.calls[0] as unknown[];
|
|
612
|
+
const url = call[0] as string;
|
|
613
|
+
expect(url).toBe("https://api.test.maina.dev/feedback/improvements");
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("getFeedbackImprovements handles camelCase response", async () => {
|
|
617
|
+
mockFetch.mockImplementation(() =>
|
|
618
|
+
Promise.resolve(
|
|
619
|
+
jsonResponse({
|
|
620
|
+
data: {
|
|
621
|
+
improvements: [
|
|
622
|
+
{
|
|
623
|
+
command: "fix",
|
|
624
|
+
promptHash: "hash-ghi",
|
|
625
|
+
samples: 10,
|
|
626
|
+
acceptRate: 0.95,
|
|
627
|
+
status: "excellent",
|
|
628
|
+
},
|
|
629
|
+
],
|
|
630
|
+
teamTotals: {
|
|
631
|
+
totalEvents: 100,
|
|
632
|
+
acceptRate: 0.9,
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
}),
|
|
636
|
+
),
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
const client = setupClient();
|
|
640
|
+
const result = await client.getFeedbackImprovements();
|
|
641
|
+
|
|
642
|
+
expect(result.ok).toBe(true);
|
|
643
|
+
if (result.ok) {
|
|
644
|
+
expect(result.value.improvements[0]?.promptHash).toBe("hash-ghi");
|
|
645
|
+
expect(result.value.improvements[0]?.acceptRate).toBe(0.95);
|
|
646
|
+
expect(result.value.teamTotals.totalEvents).toBe(100);
|
|
647
|
+
expect(result.value.teamTotals.acceptRate).toBe(0.9);
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("getFeedbackImprovements returns error on failure", async () => {
|
|
652
|
+
mockFetch.mockImplementation(() =>
|
|
653
|
+
Promise.resolve(jsonResponse({ error: "Forbidden" }, 403)),
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
const client = setupClient();
|
|
657
|
+
const result = await client.getFeedbackImprovements();
|
|
658
|
+
|
|
659
|
+
expect(result.ok).toBe(false);
|
|
660
|
+
if (!result.ok) {
|
|
661
|
+
expect(result.error).toBe("Forbidden");
|
|
662
|
+
}
|
|
663
|
+
});
|
|
253
664
|
});
|
package/src/cloud/auth.ts
CHANGED
|
@@ -144,7 +144,7 @@ export async function startDeviceFlow(
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
const body = (await response.json()) as {
|
|
147
|
-
data?:
|
|
147
|
+
data?: Record<string, unknown>;
|
|
148
148
|
error?: string;
|
|
149
149
|
};
|
|
150
150
|
if (body.error) {
|
|
@@ -153,7 +153,14 @@ export async function startDeviceFlow(
|
|
|
153
153
|
if (!body.data) {
|
|
154
154
|
return err("Invalid response: missing data");
|
|
155
155
|
}
|
|
156
|
-
|
|
156
|
+
const d = body.data;
|
|
157
|
+
return ok({
|
|
158
|
+
userCode: (d.userCode ?? d.user_code) as string,
|
|
159
|
+
deviceCode: (d.deviceCode ?? d.device_code) as string,
|
|
160
|
+
verificationUri: (d.verificationUri ?? d.verification_uri) as string,
|
|
161
|
+
interval: (d.interval ?? 5) as number,
|
|
162
|
+
expiresIn: (d.expiresIn ?? d.expires_in ?? 900) as number,
|
|
163
|
+
});
|
|
157
164
|
} catch (e) {
|
|
158
165
|
return err(
|
|
159
166
|
`Device flow request failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -188,8 +195,8 @@ export async function pollForToken(
|
|
|
188
195
|
Accept: "application/json",
|
|
189
196
|
},
|
|
190
197
|
body: JSON.stringify({
|
|
191
|
-
deviceCode,
|
|
192
|
-
|
|
198
|
+
device_code: deviceCode,
|
|
199
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
193
200
|
}),
|
|
194
201
|
});
|
|
195
202
|
|
|
@@ -204,7 +211,7 @@ export async function pollForToken(
|
|
|
204
211
|
}
|
|
205
212
|
|
|
206
213
|
const body = (await response.json()) as {
|
|
207
|
-
data?:
|
|
214
|
+
data?: Record<string, unknown>;
|
|
208
215
|
error?: string;
|
|
209
216
|
};
|
|
210
217
|
if (body.error) {
|
|
@@ -217,7 +224,12 @@ export async function pollForToken(
|
|
|
217
224
|
if (!body.data) {
|
|
218
225
|
return err("Invalid token response: missing data");
|
|
219
226
|
}
|
|
220
|
-
|
|
227
|
+
const d = body.data;
|
|
228
|
+
return ok({
|
|
229
|
+
accessToken: (d.accessToken ?? d.access_token) as string,
|
|
230
|
+
refreshToken: (d.refreshToken ?? d.refresh_token) as string | undefined,
|
|
231
|
+
expiresIn: (d.expiresIn ?? d.expires_in ?? 0) as number,
|
|
232
|
+
});
|
|
221
233
|
} catch (e) {
|
|
222
234
|
// Network errors during polling are transient — keep trying
|
|
223
235
|
if (Date.now() >= deadline) {
|
package/src/cloud/client.ts
CHANGED
|
@@ -10,9 +10,15 @@ import type {
|
|
|
10
10
|
ApiResponse,
|
|
11
11
|
CloudConfig,
|
|
12
12
|
CloudFeedbackPayload,
|
|
13
|
+
CloudPromptImprovement,
|
|
14
|
+
FeedbackEvent,
|
|
15
|
+
FeedbackImprovementsResponse,
|
|
13
16
|
PromptRecord,
|
|
17
|
+
SubmitVerifyPayload,
|
|
14
18
|
TeamInfo,
|
|
15
19
|
TeamMember,
|
|
20
|
+
VerifyResultResponse,
|
|
21
|
+
VerifyStatusResponse,
|
|
16
22
|
} from "./types";
|
|
17
23
|
|
|
18
24
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
@@ -70,6 +76,27 @@ export interface CloudClient {
|
|
|
70
76
|
postFeedback(
|
|
71
77
|
payload: CloudFeedbackPayload,
|
|
72
78
|
): Promise<Result<{ recorded: boolean }, string>>;
|
|
79
|
+
|
|
80
|
+
/** Upload a batch of feedback events to the cloud. */
|
|
81
|
+
postFeedbackBatch(
|
|
82
|
+
events: FeedbackEvent[],
|
|
83
|
+
): Promise<Result<{ received: number }, string>>;
|
|
84
|
+
|
|
85
|
+
/** Fetch feedback-based improvement suggestions from the cloud. */
|
|
86
|
+
getFeedbackImprovements(): Promise<
|
|
87
|
+
Result<FeedbackImprovementsResponse, string>
|
|
88
|
+
>;
|
|
89
|
+
|
|
90
|
+
/** Submit a diff for cloud verification. */
|
|
91
|
+
submitVerify(
|
|
92
|
+
payload: SubmitVerifyPayload,
|
|
93
|
+
): Promise<Result<{ jobId: string }, string>>;
|
|
94
|
+
|
|
95
|
+
/** Poll the status of a verification job. */
|
|
96
|
+
getVerifyStatus(jobId: string): Promise<Result<VerifyStatusResponse, string>>;
|
|
97
|
+
|
|
98
|
+
/** Retrieve the full result of a completed verification job. */
|
|
99
|
+
getVerifyResult(jobId: string): Promise<Result<VerifyResultResponse, string>>;
|
|
73
100
|
}
|
|
74
101
|
|
|
75
102
|
/**
|
|
@@ -186,5 +213,92 @@ export function createCloudClient(config: CloudConfig): CloudClient {
|
|
|
186
213
|
|
|
187
214
|
postFeedback: (payload) =>
|
|
188
215
|
request<{ recorded: boolean }>("POST", "/feedback", payload),
|
|
216
|
+
|
|
217
|
+
postFeedbackBatch: async (events) => {
|
|
218
|
+
// Map camelCase → snake_case for cloud API
|
|
219
|
+
const snakeEvents = events.map((e) => ({
|
|
220
|
+
prompt_hash: e.promptHash,
|
|
221
|
+
command: e.command,
|
|
222
|
+
accepted: e.accepted,
|
|
223
|
+
context: e.context,
|
|
224
|
+
diff_hash: e.diffHash,
|
|
225
|
+
timestamp: e.timestamp,
|
|
226
|
+
}));
|
|
227
|
+
return request<{ received: number }>("POST", "/feedback/batch", {
|
|
228
|
+
events: snakeEvents,
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
getFeedbackImprovements: async () => {
|
|
233
|
+
// biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
|
|
234
|
+
const result = await request<any>("GET", "/feedback/improvements");
|
|
235
|
+
if (!result.ok) return result;
|
|
236
|
+
const d = result.value;
|
|
237
|
+
const rawImprovements = d.improvements ?? [];
|
|
238
|
+
const improvements: CloudPromptImprovement[] = rawImprovements.map(
|
|
239
|
+
// biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
|
|
240
|
+
(i: any) => ({
|
|
241
|
+
command: i.command,
|
|
242
|
+
promptHash: i.promptHash ?? i.prompt_hash,
|
|
243
|
+
samples: i.samples,
|
|
244
|
+
acceptRate: i.acceptRate ?? i.accept_rate,
|
|
245
|
+
status: i.status,
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
const totals = d.teamTotals ?? d.team_totals ?? {};
|
|
249
|
+
return ok({
|
|
250
|
+
improvements,
|
|
251
|
+
teamTotals: {
|
|
252
|
+
totalEvents: totals.totalEvents ?? totals.total_events ?? 0,
|
|
253
|
+
acceptRate: totals.acceptRate ?? totals.accept_rate ?? 0,
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
submitVerify: async (payload) => {
|
|
259
|
+
// biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
|
|
260
|
+
const result = await request<any>("POST", "/verify", {
|
|
261
|
+
diff: payload.diff,
|
|
262
|
+
repo: payload.repo,
|
|
263
|
+
base_branch: payload.baseBranch,
|
|
264
|
+
});
|
|
265
|
+
if (!result.ok) return result;
|
|
266
|
+
const d = result.value;
|
|
267
|
+
return ok({ jobId: d.jobId ?? d.job_id });
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
getVerifyStatus: async (jobId) => {
|
|
271
|
+
// biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
|
|
272
|
+
const result = await request<any>("GET", `/verify/${jobId}/status`);
|
|
273
|
+
if (!result.ok) return result;
|
|
274
|
+
const d = result.value;
|
|
275
|
+
return ok({
|
|
276
|
+
status: d.status,
|
|
277
|
+
currentStep: d.currentStep ?? d.current_step ?? d.step ?? "",
|
|
278
|
+
});
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
getVerifyResult: async (jobId) => {
|
|
282
|
+
// biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
|
|
283
|
+
const result = await request<any>("GET", `/verify/${jobId}`);
|
|
284
|
+
if (!result.ok) return result;
|
|
285
|
+
const d = result.value;
|
|
286
|
+
const items = d.findings?.items ?? d.findings ?? [];
|
|
287
|
+
return ok({
|
|
288
|
+
id: d.id,
|
|
289
|
+
status: d.status,
|
|
290
|
+
passed: d.passed,
|
|
291
|
+
findings: items,
|
|
292
|
+
findingsErrors:
|
|
293
|
+
d.findingsErrors ?? d.findings_errors ?? d.findings?.errors ?? 0,
|
|
294
|
+
findingsWarnings:
|
|
295
|
+
d.findingsWarnings ??
|
|
296
|
+
d.findings_warnings ??
|
|
297
|
+
d.findings?.warnings ??
|
|
298
|
+
0,
|
|
299
|
+
proofKey: d.proofKey ?? d.proof_key ?? d.proof_url ?? null,
|
|
300
|
+
durationMs: d.durationMs ?? d.duration_ms ?? 0,
|
|
301
|
+
});
|
|
302
|
+
},
|
|
189
303
|
};
|
|
190
304
|
}
|
package/src/cloud/types.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// ── Configuration ───────────────────────────────────────────────────────────
|
|
9
9
|
|
|
10
10
|
export interface CloudConfig {
|
|
11
|
-
/** Base URL of the maina cloud API (e.g. "https://api.
|
|
11
|
+
/** Base URL of the maina cloud API (e.g. "https://api.mainahq.com"). */
|
|
12
12
|
baseUrl: string;
|
|
13
13
|
/** Bearer token for authenticated requests. */
|
|
14
14
|
token?: string;
|
|
@@ -90,6 +90,58 @@ export interface ApiResponse<T> {
|
|
|
90
90
|
meta?: Record<string, unknown>;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// ── Verify ─────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
export interface SubmitVerifyPayload {
|
|
96
|
+
/** Diff content to verify. */
|
|
97
|
+
diff: string;
|
|
98
|
+
/** Repository identifier (e.g. "owner/repo"). */
|
|
99
|
+
repo: string;
|
|
100
|
+
/** Base branch to compare against. */
|
|
101
|
+
baseBranch?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface VerifyStatusResponse {
|
|
105
|
+
/** Current job status. */
|
|
106
|
+
status: "queued" | "running" | "done" | "failed";
|
|
107
|
+
/** Human-readable description of the current step. */
|
|
108
|
+
currentStep: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface VerifyFinding {
|
|
112
|
+
/** Tool that produced the finding (e.g. "biome", "semgrep"). */
|
|
113
|
+
tool: string;
|
|
114
|
+
/** File path relative to repository root. */
|
|
115
|
+
file: string;
|
|
116
|
+
/** Line number in the file. */
|
|
117
|
+
line: number;
|
|
118
|
+
/** Finding description. */
|
|
119
|
+
message: string;
|
|
120
|
+
/** Severity level. */
|
|
121
|
+
severity: "error" | "warning" | "info";
|
|
122
|
+
/** Optional rule identifier. */
|
|
123
|
+
ruleId?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface VerifyResultResponse {
|
|
127
|
+
/** Job identifier. */
|
|
128
|
+
id: string;
|
|
129
|
+
/** Final job status. */
|
|
130
|
+
status: "done" | "failed";
|
|
131
|
+
/** Whether all checks passed. */
|
|
132
|
+
passed: boolean;
|
|
133
|
+
/** Array of individual findings from verification tools. */
|
|
134
|
+
findings: VerifyFinding[];
|
|
135
|
+
/** Count of error-level findings. */
|
|
136
|
+
findingsErrors: number;
|
|
137
|
+
/** Count of warning-level findings. */
|
|
138
|
+
findingsWarnings: number;
|
|
139
|
+
/** Proof key for passing verification (null when failed). */
|
|
140
|
+
proofKey: string | null;
|
|
141
|
+
/** Total verification duration in milliseconds. */
|
|
142
|
+
durationMs: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
93
145
|
// ── Feedback ────────────────────────────────────────────────────────────────
|
|
94
146
|
|
|
95
147
|
export interface CloudFeedbackPayload {
|
|
@@ -104,3 +156,45 @@ export interface CloudFeedbackPayload {
|
|
|
104
156
|
/** Optional context about the feedback. */
|
|
105
157
|
context?: string;
|
|
106
158
|
}
|
|
159
|
+
|
|
160
|
+
// ── Feedback Batch (learn --cloud) ─────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
export interface FeedbackEvent {
|
|
163
|
+
/** Prompt hash the feedback refers to. */
|
|
164
|
+
promptHash: string;
|
|
165
|
+
/** Command that generated the output. */
|
|
166
|
+
command: string;
|
|
167
|
+
/** Whether the user accepted the output. */
|
|
168
|
+
accepted: boolean;
|
|
169
|
+
/** Optional context about the feedback. */
|
|
170
|
+
context?: string;
|
|
171
|
+
/** Optional diff hash for traceability. */
|
|
172
|
+
diffHash?: string;
|
|
173
|
+
/** ISO-8601 timestamp. */
|
|
174
|
+
timestamp?: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface FeedbackBatchPayload {
|
|
178
|
+
/** Array of feedback events to upload. */
|
|
179
|
+
events: FeedbackEvent[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface CloudPromptImprovement {
|
|
183
|
+
/** Command the improvement applies to. */
|
|
184
|
+
command: string;
|
|
185
|
+
/** Prompt hash that was analysed. */
|
|
186
|
+
promptHash: string;
|
|
187
|
+
/** Number of feedback samples analysed. */
|
|
188
|
+
samples: number;
|
|
189
|
+
/** Accept rate (0–1). */
|
|
190
|
+
acceptRate: number;
|
|
191
|
+
/** Health status based on accept rate. */
|
|
192
|
+
status: "needs_improvement" | "healthy" | "excellent";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface FeedbackImprovementsResponse {
|
|
196
|
+
/** Per-command improvement assessments. */
|
|
197
|
+
improvements: CloudPromptImprovement[];
|
|
198
|
+
/** Aggregated team-wide feedback totals. */
|
|
199
|
+
teamTotals: { totalEvents: number; acceptRate: number };
|
|
200
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { recordOutcome } from "../../prompts/engine";
|
|
5
|
+
import { exportFeedbackForCloud } from "../sync";
|
|
6
|
+
|
|
7
|
+
let tmpDir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tmpDir = join(
|
|
11
|
+
import.meta.dir,
|
|
12
|
+
`tmp-sync-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
13
|
+
);
|
|
14
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
try {
|
|
19
|
+
const { rmSync } = require("node:fs");
|
|
20
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
21
|
+
} catch {
|
|
22
|
+
// ignore
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("exportFeedbackForCloud", () => {
|
|
27
|
+
test("returns empty array when no feedback exists", () => {
|
|
28
|
+
const events = exportFeedbackForCloud(tmpDir);
|
|
29
|
+
expect(events).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("exports accepted feedback events", () => {
|
|
33
|
+
recordOutcome(tmpDir, "hash-abc", {
|
|
34
|
+
accepted: true,
|
|
35
|
+
command: "commit",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const events = exportFeedbackForCloud(tmpDir);
|
|
39
|
+
|
|
40
|
+
expect(events).toHaveLength(1);
|
|
41
|
+
expect(events[0]?.promptHash).toBe("hash-abc");
|
|
42
|
+
expect(events[0]?.command).toBe("commit");
|
|
43
|
+
expect(events[0]?.accepted).toBe(true);
|
|
44
|
+
expect(events[0]?.timestamp).toBeDefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("exports rejected feedback events", () => {
|
|
48
|
+
recordOutcome(tmpDir, "hash-def", {
|
|
49
|
+
accepted: false,
|
|
50
|
+
command: "review",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const events = exportFeedbackForCloud(tmpDir);
|
|
54
|
+
|
|
55
|
+
expect(events).toHaveLength(1);
|
|
56
|
+
expect(events[0]?.accepted).toBe(false);
|
|
57
|
+
expect(events[0]?.command).toBe("review");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("exports context when present", () => {
|
|
61
|
+
recordOutcome(tmpDir, "hash-ctx", {
|
|
62
|
+
accepted: true,
|
|
63
|
+
command: "commit",
|
|
64
|
+
context: "user edited the message",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const events = exportFeedbackForCloud(tmpDir);
|
|
68
|
+
|
|
69
|
+
expect(events).toHaveLength(1);
|
|
70
|
+
expect(events[0]?.context).toBe("user edited the message");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("omits context when not present", () => {
|
|
74
|
+
recordOutcome(tmpDir, "hash-no-ctx", {
|
|
75
|
+
accepted: true,
|
|
76
|
+
command: "commit",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const events = exportFeedbackForCloud(tmpDir);
|
|
80
|
+
|
|
81
|
+
expect(events).toHaveLength(1);
|
|
82
|
+
expect(events[0]?.context).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("exports multiple events in chronological order", () => {
|
|
86
|
+
recordOutcome(tmpDir, "hash-1", { accepted: true, command: "commit" });
|
|
87
|
+
recordOutcome(tmpDir, "hash-2", { accepted: false, command: "review" });
|
|
88
|
+
recordOutcome(tmpDir, "hash-3", { accepted: true, command: "fix" });
|
|
89
|
+
|
|
90
|
+
const events = exportFeedbackForCloud(tmpDir);
|
|
91
|
+
|
|
92
|
+
expect(events).toHaveLength(3);
|
|
93
|
+
expect(events[0]?.promptHash).toBe("hash-1");
|
|
94
|
+
expect(events[1]?.promptHash).toBe("hash-2");
|
|
95
|
+
expect(events[2]?.promptHash).toBe("hash-3");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("returns empty array on invalid db path", () => {
|
|
99
|
+
const events = exportFeedbackForCloud(
|
|
100
|
+
"/nonexistent/path/that/does/not/exist",
|
|
101
|
+
);
|
|
102
|
+
expect(events).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feedback sync — exports local feedback records for cloud upload.
|
|
3
|
+
*
|
|
4
|
+
* Reads from the local SQLite feedback.db and maps records to the
|
|
5
|
+
* cloud-compatible FeedbackEvent format for batch upload.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FeedbackEvent } from "../cloud/types";
|
|
9
|
+
import { getFeedbackDb } from "../db/index";
|
|
10
|
+
|
|
11
|
+
/** Raw row shape from the feedback table. */
|
|
12
|
+
interface FeedbackRow {
|
|
13
|
+
prompt_hash: string;
|
|
14
|
+
command: string;
|
|
15
|
+
accepted: number;
|
|
16
|
+
context: string | null;
|
|
17
|
+
created_at: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Export all local feedback records in the cloud-compatible format.
|
|
22
|
+
*
|
|
23
|
+
* Reads from the feedback table in the SQLite database at `mainaDir/feedback.db`
|
|
24
|
+
* and maps each row to a `FeedbackEvent` object ready for batch upload.
|
|
25
|
+
*/
|
|
26
|
+
export function exportFeedbackForCloud(mainaDir: string): FeedbackEvent[] {
|
|
27
|
+
const dbResult = getFeedbackDb(mainaDir);
|
|
28
|
+
if (!dbResult.ok) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { db } = dbResult.value;
|
|
33
|
+
|
|
34
|
+
const rows = db
|
|
35
|
+
.query(
|
|
36
|
+
"SELECT prompt_hash, command, accepted, context, created_at FROM feedback ORDER BY created_at ASC",
|
|
37
|
+
)
|
|
38
|
+
.all() as FeedbackRow[];
|
|
39
|
+
|
|
40
|
+
return rows.map((row) => {
|
|
41
|
+
const event: FeedbackEvent = {
|
|
42
|
+
promptHash: row.prompt_hash,
|
|
43
|
+
command: row.command,
|
|
44
|
+
accepted: row.accepted === 1,
|
|
45
|
+
timestamp: row.created_at,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (row.context) {
|
|
49
|
+
event.context = row.context;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return event;
|
|
53
|
+
});
|
|
54
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -64,11 +64,19 @@ export type {
|
|
|
64
64
|
ApiResponse,
|
|
65
65
|
CloudConfig,
|
|
66
66
|
CloudFeedbackPayload,
|
|
67
|
+
CloudPromptImprovement,
|
|
67
68
|
DeviceCodeResponse,
|
|
69
|
+
FeedbackBatchPayload,
|
|
70
|
+
FeedbackEvent,
|
|
71
|
+
FeedbackImprovementsResponse,
|
|
68
72
|
PromptRecord,
|
|
73
|
+
SubmitVerifyPayload,
|
|
69
74
|
TeamInfo,
|
|
70
75
|
TeamMember,
|
|
71
76
|
TokenResponse,
|
|
77
|
+
VerifyFinding,
|
|
78
|
+
VerifyResultResponse,
|
|
79
|
+
VerifyStatusResponse,
|
|
72
80
|
} from "./cloud/types";
|
|
73
81
|
// Config
|
|
74
82
|
export { getApiKey, isHostMode, shouldDelegateToHost } from "./config/index";
|
|
@@ -164,6 +172,7 @@ export {
|
|
|
164
172
|
type RulePreference,
|
|
165
173
|
savePreferences,
|
|
166
174
|
} from "./feedback/preferences";
|
|
175
|
+
export { exportFeedbackForCloud } from "./feedback/sync";
|
|
167
176
|
export {
|
|
168
177
|
analyzeWorkflowTrace,
|
|
169
178
|
type PromptImprovement,
|
|
@@ -306,16 +315,12 @@ export {
|
|
|
306
315
|
runCoverage,
|
|
307
316
|
} from "./verify/coverage";
|
|
308
317
|
export {
|
|
309
|
-
type DetectedTool,
|
|
310
318
|
detectTool,
|
|
311
319
|
detectTools,
|
|
312
320
|
isToolAvailable,
|
|
313
321
|
TOOL_REGISTRY,
|
|
314
|
-
type ToolName,
|
|
315
322
|
} from "./verify/detect";
|
|
316
323
|
export {
|
|
317
|
-
type DiffFilterResult,
|
|
318
|
-
type Finding,
|
|
319
324
|
filterByDiff,
|
|
320
325
|
filterByDiffWithMap,
|
|
321
326
|
parseChangedLines,
|
|
@@ -343,12 +348,7 @@ export {
|
|
|
343
348
|
runMutation,
|
|
344
349
|
} from "./verify/mutation";
|
|
345
350
|
// Verify — Pipeline
|
|
346
|
-
export {
|
|
347
|
-
type PipelineOptions,
|
|
348
|
-
type PipelineResult,
|
|
349
|
-
runPipeline,
|
|
350
|
-
type ToolReport,
|
|
351
|
-
} from "./verify/pipeline";
|
|
351
|
+
export { runPipeline } from "./verify/pipeline";
|
|
352
352
|
// Verify — Proof
|
|
353
353
|
export {
|
|
354
354
|
formatVerificationProof,
|
|
@@ -376,12 +376,22 @@ export {
|
|
|
376
376
|
} from "./verify/sonar";
|
|
377
377
|
export {
|
|
378
378
|
parseBiomeOutput,
|
|
379
|
-
type SyntaxDiagnostic,
|
|
380
|
-
type SyntaxGuardResult,
|
|
381
379
|
syntaxGuard,
|
|
382
380
|
} from "./verify/syntax-guard";
|
|
383
381
|
// Verify — Typecheck + Consistency (built-in checks)
|
|
384
382
|
export { runTypecheck, type TypecheckResult } from "./verify/typecheck";
|
|
383
|
+
// Verify — Public Type Surface (consolidated for external consumers like maina-cloud)
|
|
384
|
+
export type {
|
|
385
|
+
DetectedTool,
|
|
386
|
+
DiffFilterResult,
|
|
387
|
+
Finding,
|
|
388
|
+
PipelineOptions,
|
|
389
|
+
PipelineResult,
|
|
390
|
+
SyntaxDiagnostic,
|
|
391
|
+
SyntaxGuardResult,
|
|
392
|
+
ToolName,
|
|
393
|
+
ToolReport,
|
|
394
|
+
} from "./verify/types";
|
|
385
395
|
// Verify — Visual
|
|
386
396
|
export {
|
|
387
397
|
captureScreenshot,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"packages": []}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
DetectedTool,
|
|
4
|
+
Finding,
|
|
5
|
+
PipelineOptions,
|
|
6
|
+
PipelineResult,
|
|
7
|
+
ToolReport,
|
|
8
|
+
} from "../types";
|
|
9
|
+
|
|
10
|
+
// ─── Verify Type Exports ────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
describe("verify/types re-exports", () => {
|
|
13
|
+
test("Finding type has expected shape", () => {
|
|
14
|
+
const finding: Finding = {
|
|
15
|
+
tool: "semgrep",
|
|
16
|
+
file: "src/index.ts",
|
|
17
|
+
line: 42,
|
|
18
|
+
message: "unused variable",
|
|
19
|
+
severity: "warning",
|
|
20
|
+
};
|
|
21
|
+
expect(finding.tool).toBe("semgrep");
|
|
22
|
+
expect(finding.file).toBe("src/index.ts");
|
|
23
|
+
expect(finding.line).toBe(42);
|
|
24
|
+
expect(finding.message).toBe("unused variable");
|
|
25
|
+
expect(finding.severity).toBe("warning");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("Finding type supports optional fields", () => {
|
|
29
|
+
const finding: Finding = {
|
|
30
|
+
tool: "eslint",
|
|
31
|
+
file: "app.ts",
|
|
32
|
+
line: 10,
|
|
33
|
+
column: 5,
|
|
34
|
+
message: "no-unused-vars",
|
|
35
|
+
severity: "error",
|
|
36
|
+
ruleId: "no-unused-vars",
|
|
37
|
+
};
|
|
38
|
+
expect(finding.column).toBe(5);
|
|
39
|
+
expect(finding.ruleId).toBe("no-unused-vars");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("ToolReport type has expected shape", () => {
|
|
43
|
+
const report: ToolReport = {
|
|
44
|
+
tool: "trivy",
|
|
45
|
+
findings: [],
|
|
46
|
+
skipped: false,
|
|
47
|
+
duration: 123,
|
|
48
|
+
};
|
|
49
|
+
expect(report.tool).toBe("trivy");
|
|
50
|
+
expect(report.findings).toEqual([]);
|
|
51
|
+
expect(report.skipped).toBe(false);
|
|
52
|
+
expect(report.duration).toBe(123);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("PipelineResult type has expected shape", () => {
|
|
56
|
+
const result: PipelineResult = {
|
|
57
|
+
passed: true,
|
|
58
|
+
syntaxPassed: true,
|
|
59
|
+
tools: [],
|
|
60
|
+
findings: [],
|
|
61
|
+
hiddenCount: 0,
|
|
62
|
+
detectedTools: [],
|
|
63
|
+
duration: 500,
|
|
64
|
+
cacheHits: 2,
|
|
65
|
+
cacheMisses: 1,
|
|
66
|
+
};
|
|
67
|
+
expect(result.passed).toBe(true);
|
|
68
|
+
expect(result.syntaxPassed).toBe(true);
|
|
69
|
+
expect(result.tools).toEqual([]);
|
|
70
|
+
expect(result.findings).toEqual([]);
|
|
71
|
+
expect(result.hiddenCount).toBe(0);
|
|
72
|
+
expect(result.detectedTools).toEqual([]);
|
|
73
|
+
expect(result.duration).toBe(500);
|
|
74
|
+
expect(result.cacheHits).toBe(2);
|
|
75
|
+
expect(result.cacheMisses).toBe(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("PipelineOptions type has expected shape", () => {
|
|
79
|
+
const opts: PipelineOptions = {
|
|
80
|
+
files: ["src/app.ts"],
|
|
81
|
+
baseBranch: "main",
|
|
82
|
+
diffOnly: true,
|
|
83
|
+
deep: false,
|
|
84
|
+
cwd: "/tmp",
|
|
85
|
+
mainaDir: ".maina",
|
|
86
|
+
languages: ["typescript"],
|
|
87
|
+
};
|
|
88
|
+
expect(opts.files).toEqual(["src/app.ts"]);
|
|
89
|
+
expect(opts.baseBranch).toBe("main");
|
|
90
|
+
expect(opts.diffOnly).toBe(true);
|
|
91
|
+
expect(opts.deep).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("DetectedTool type has expected shape", () => {
|
|
95
|
+
const tool: DetectedTool = {
|
|
96
|
+
name: "biome",
|
|
97
|
+
command: "biome",
|
|
98
|
+
version: "1.5.0",
|
|
99
|
+
available: true,
|
|
100
|
+
};
|
|
101
|
+
expect(tool.name).toBe("biome");
|
|
102
|
+
expect(tool.command).toBe("biome");
|
|
103
|
+
expect(tool.version).toBe("1.5.0");
|
|
104
|
+
expect(tool.available).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("DetectedTool supports null version", () => {
|
|
108
|
+
const tool: DetectedTool = {
|
|
109
|
+
name: "semgrep",
|
|
110
|
+
command: "semgrep",
|
|
111
|
+
version: null,
|
|
112
|
+
available: false,
|
|
113
|
+
};
|
|
114
|
+
expect(tool.version).toBeNull();
|
|
115
|
+
expect(tool.available).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ─── Verify types re-exported from @mainahq/core index ──────────────────────
|
|
120
|
+
|
|
121
|
+
describe("verify types from core index", () => {
|
|
122
|
+
test("types are importable from core index", async () => {
|
|
123
|
+
const coreIndex = await import("../../index");
|
|
124
|
+
// The module should export these — we verify by checking the module loaded
|
|
125
|
+
// Type-only exports don't appear at runtime, but the module must resolve
|
|
126
|
+
expect(coreIndex).toBeDefined();
|
|
127
|
+
expect(typeof coreIndex.VERSION).toBe("string");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify Engine — Public Type Exports.
|
|
3
|
+
*
|
|
4
|
+
* Consolidated re-exports of all verify types for the public API surface.
|
|
5
|
+
* Consumers (e.g. maina-cloud) import from here or from @mainahq/core.
|
|
6
|
+
* Types are NOT duplicated — each re-exports from its source module.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// DetectedTool, ToolName from detect
|
|
10
|
+
export type { DetectedTool, ToolName } from "./detect";
|
|
11
|
+
// Finding + DiffFilterResult from diff-filter
|
|
12
|
+
export type { DiffFilterResult, Finding } from "./diff-filter";
|
|
13
|
+
// PipelineResult, PipelineOptions, ToolReport from pipeline
|
|
14
|
+
export type { PipelineOptions, PipelineResult, ToolReport } from "./pipeline";
|
|
15
|
+
|
|
16
|
+
// SyntaxDiagnostic, SyntaxGuardResult from syntax-guard (used in PipelineResult)
|
|
17
|
+
export type { SyntaxDiagnostic, SyntaxGuardResult } from "./syntax-guard";
|