lacuna-cli 0.1.8 → 0.2.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 +94 -11
- package/dist/agent/context.d.ts +1 -0
- package/dist/agent/context.d.ts.map +1 -1
- package/dist/agent/context.js +141 -6
- package/dist/agent/context.js.map +1 -1
- package/dist/agent/fix-loop.d.ts +1 -0
- package/dist/agent/fix-loop.d.ts.map +1 -1
- package/dist/agent/fix-loop.js +245 -40
- package/dist/agent/fix-loop.js.map +1 -1
- package/dist/agent/generator.d.ts +7 -0
- package/dist/agent/generator.d.ts.map +1 -1
- package/dist/agent/generator.js +205 -35
- package/dist/agent/generator.js.map +1 -1
- package/dist/agent/loop.d.ts +1 -1
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +154 -11
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts/index.d.ts +4 -1
- package/dist/agent/prompts/index.d.ts.map +1 -1
- package/dist/agent/prompts/index.js +182 -37
- package/dist/agent/prompts/index.js.map +1 -1
- package/dist/agent/prompts/react-native.d.ts.map +1 -1
- package/dist/agent/prompts/react-native.js +15 -4
- package/dist/agent/prompts/react-native.js.map +1 -1
- package/dist/agent/prompts/react.js +1 -1
- package/dist/agent/prompts/runners/vitest.js +1 -1
- package/dist/commands/analyze.d.ts.map +1 -1
- package/dist/commands/analyze.js +4 -0
- package/dist/commands/analyze.js.map +1 -1
- package/dist/commands/fix.d.ts +1 -0
- package/dist/commands/fix.d.ts.map +1 -1
- package/dist/commands/fix.js +24 -5
- package/dist/commands/fix.js.map +1 -1
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +10 -0
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +6 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +4 -0
- package/dist/commands/run.js.map +1 -1
- package/dist/lib/config.d.ts +10 -2
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +43 -21
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/extract-error.d.ts.map +1 -1
- package/dist/lib/extract-error.js +8 -2
- package/dist/lib/extract-error.js.map +1 -1
- package/dist/lib/reporter.d.ts.map +1 -1
- package/dist/lib/reporter.js +11 -1
- package/dist/lib/reporter.js.map +1 -1
- package/dist/lib/typecheck.d.ts +1 -0
- package/dist/lib/typecheck.d.ts.map +1 -1
- package/dist/lib/typecheck.js +119 -7
- package/dist/lib/typecheck.js.map +1 -1
- package/dist/lib/validate.d.ts +37 -1
- package/dist/lib/validate.d.ts.map +1 -1
- package/dist/lib/validate.js +659 -11
- package/dist/lib/validate.js.map +1 -1
- package/lacuna.schema.json +150 -0
- package/package.json +14 -7
- package/oclif.manifest.json +0 -309
package/dist/lib/validate.js
CHANGED
|
@@ -13,11 +13,41 @@ export function hasTestFunctions(code) {
|
|
|
13
13
|
const stripped = stripNonCode(code);
|
|
14
14
|
return /\b(?:it|test)\s*(?:\.(?:each|concurrent|skip|only))?\s*\(/.test(stripped);
|
|
15
15
|
}
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
export function
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
// Returns true when the code contains placeholder test bodies — e.g. `{ // body }`.
|
|
17
|
+
// A placeholder passes vitest (no assertions = no failures) but produces zero value.
|
|
18
|
+
export function hasPlaceholderBodies(code) {
|
|
19
|
+
// Match an opening brace, optional whitespace/newline, a // comment that looks like
|
|
20
|
+
// a placeholder, then closing brace. Catches: { // body }, { // TODO }, { // implement }.
|
|
21
|
+
return /\{\s*\/\/\s*(body|todo|implement(?:ation)?|placeholder|stub|fill\s*in|your\s*code)\s*\}/i.test(code);
|
|
22
|
+
}
|
|
23
|
+
// Returns true when the runner output shows that zero tests were collected.
|
|
24
|
+
// Distinct from hasTestFunctions (static check) — this checks actual runtime collection.
|
|
25
|
+
// Handles:
|
|
26
|
+
// - Vitest summary "Tests 0 total" or "Tests no tests"
|
|
27
|
+
// - Jest summary "Tests: 0 total"
|
|
28
|
+
// - Common "no tests found" / "found 0 tests" messages
|
|
29
|
+
// NOTE: Vitest per-file listing lines like "foo.test.ts (0 test)" are NOT zero-test
|
|
30
|
+
// signals — that interim count updates as tests resolve; the summary line is authoritative.
|
|
31
|
+
// We do NOT match bare "0 test" (with word boundary) because of that false-positive.
|
|
32
|
+
//
|
|
33
|
+
// AUTHORITATIVE COUNT GUARD: if the summary reports ANY passed or failed test, tests WERE
|
|
34
|
+
// collected — even if a failing test's name or assertion message happens to contain the
|
|
35
|
+
// phrase "no tests found" / "found 0 tests". Those substrings are unanchored and would
|
|
36
|
+
// otherwise false-positive on a run like "11 failed | 17 passed (28)". The pass/fail counts
|
|
37
|
+
// come from the authoritative Tests summary line, so they override the substring match.
|
|
38
|
+
export function isZeroTestsOutput(raw) {
|
|
39
|
+
if (parsePassCount(raw) > 0 || parseFailCount(raw) > 0)
|
|
40
|
+
return false;
|
|
41
|
+
return /Tests:?\s+(?:0\s+total|no tests)\b|no tests? found|found 0 tests/i.test(raw);
|
|
42
|
+
}
|
|
43
|
+
// If the runner output indicates "no tests found", replace it with a clear instruction
|
|
44
|
+
// so the AI knows exactly what went wrong. The zero-tests decision is made on `rawOutput`
|
|
45
|
+
// (the full runner output, which still carries the authoritative Tests summary line);
|
|
46
|
+
// `extracted` is the already-trimmed failure text that gets returned/appended. Callers that
|
|
47
|
+
// only have one string can omit `rawOutput` — it defaults to `extracted`.
|
|
48
|
+
export function enrichNoTestsError(extracted, rawOutput = extracted) {
|
|
49
|
+
if (!isZeroTestsOutput(rawOutput))
|
|
50
|
+
return extracted;
|
|
21
51
|
return ('ERROR: Vitest found 0 tests in this file. The file ran but had nothing to execute.\n\n' +
|
|
22
52
|
'This means one of:\n' +
|
|
23
53
|
' 1. You wrote only imports, types, or describe() blocks with no it()/test() inside\n' +
|
|
@@ -29,12 +59,7 @@ export function enrichNoTestsError(output) {
|
|
|
29
59
|
' })\n\n' +
|
|
30
60
|
'DO NOT wrap tests inside a function. Put them directly inside describe() or at the top level.\n\n' +
|
|
31
61
|
'Original runner output:\n' +
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
// Returns true when the runner output shows that zero tests were collected.
|
|
35
|
-
// Distinct from hasTestFunctions (static check) — this checks actual runtime collection.
|
|
36
|
-
export function isZeroTestsOutput(raw) {
|
|
37
|
-
return /Tests:\s+0\s+total|no tests found|found 0 tests/i.test(raw);
|
|
62
|
+
extracted);
|
|
38
63
|
}
|
|
39
64
|
// Extracts the number of passing tests from the runner summary footer.
|
|
40
65
|
// Targets the "Tests N failed | M passed (total)" line specifically to avoid
|
|
@@ -48,6 +73,14 @@ export function parsePassCount(output) {
|
|
|
48
73
|
const m = output.match(/(\d+)\s+passed/);
|
|
49
74
|
return m ? parseInt(m[1], 10) : 0;
|
|
50
75
|
}
|
|
76
|
+
// Extracts the number of failing tests from the runner summary footer.
|
|
77
|
+
// Anchored to the "Tests N failed | M passed" line specifically — the word "failed"
|
|
78
|
+
// appears in too many noise lines (per-test FAIL markers, stack frames) to match loosely.
|
|
79
|
+
// Used alongside parsePassCount to prove tests were actually collected.
|
|
80
|
+
export function parseFailCount(output) {
|
|
81
|
+
const summaryLine = output.match(/^\s*Tests\b[^\n]*?(\d+)\s+failed/m);
|
|
82
|
+
return summaryLine ? parseInt(summaryLine[1], 10) : 0;
|
|
83
|
+
}
|
|
51
84
|
// Strips leading prose/thinking lines from generated code output.
|
|
52
85
|
// When a model bleeds reasoning into <code_output>, the file starts with fragments
|
|
53
86
|
// like ", nothing else." or "I'll write the test now." before the real code begins.
|
|
@@ -394,4 +427,619 @@ export function buildRegressionMessage(initialError, currentError, baselinePass,
|
|
|
394
427
|
`Do NOT modify tests that were already passing.\n` +
|
|
395
428
|
`ONLY fix the test that was originally failing.`);
|
|
396
429
|
}
|
|
430
|
+
// Parses the model's patch output into a list of PatchOperation objects.
|
|
431
|
+
//
|
|
432
|
+
// Most operations have the form:
|
|
433
|
+
// // @@@ TYPE: "anchor"
|
|
434
|
+
// <content lines>
|
|
435
|
+
// // @@@ END
|
|
436
|
+
//
|
|
437
|
+
// REPLACE is different — it uses a WITH delimiter instead of an inline anchor:
|
|
438
|
+
// // @@@ REPLACE:
|
|
439
|
+
// <exact existing text to find, verbatim>
|
|
440
|
+
// // @@@ WITH:
|
|
441
|
+
// <replacement text>
|
|
442
|
+
// // @@@ END
|
|
443
|
+
export function parsePatch(patchOutput) {
|
|
444
|
+
const ops = [];
|
|
445
|
+
const lines = patchOutput.split('\n');
|
|
446
|
+
// Capture the anchor either quoted ("name") or unquoted (name).
|
|
447
|
+
// Group 2 = quoted text, group 3 = unquoted text. DeepSeek and other models
|
|
448
|
+
// often drop the double-quotes, so we accept both forms.
|
|
449
|
+
const headerRe = /^\/\/ @@@ (REPLACE_TEST|DELETE_TEST|ADD_AFTER_DESCRIBE|ADD_IMPORT|ADD_AFTER_IMPORTS|REPLACE):\s*(?:"([^"]*)"|(.*\S))?/;
|
|
450
|
+
const withRe = /^\/\/ @@@ WITH:\s*$/;
|
|
451
|
+
const endRe = /^\/\/ @@@ END\s*$/;
|
|
452
|
+
let i = 0;
|
|
453
|
+
while (i < lines.length) {
|
|
454
|
+
const m = headerRe.exec(lines[i]);
|
|
455
|
+
if (!m) {
|
|
456
|
+
i++;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
const type = m[1];
|
|
460
|
+
i++;
|
|
461
|
+
if (type === 'REPLACE') {
|
|
462
|
+
// Read old text until // @@@ WITH:
|
|
463
|
+
const oldLines = [];
|
|
464
|
+
while (i < lines.length && !withRe.test(lines[i]) && !endRe.test(lines[i])) {
|
|
465
|
+
oldLines.push(lines[i]);
|
|
466
|
+
i++;
|
|
467
|
+
}
|
|
468
|
+
if (!withRe.test(lines[i] ?? '')) {
|
|
469
|
+
i++;
|
|
470
|
+
continue;
|
|
471
|
+
} // malformed — skip
|
|
472
|
+
i++; // skip // @@@ WITH:
|
|
473
|
+
const newLines = [];
|
|
474
|
+
while (i < lines.length && !endRe.test(lines[i])) {
|
|
475
|
+
newLines.push(lines[i]);
|
|
476
|
+
i++;
|
|
477
|
+
}
|
|
478
|
+
i++; // skip // @@@ END
|
|
479
|
+
let anchor = oldLines.join('\n');
|
|
480
|
+
let content = newLines.join('\n');
|
|
481
|
+
if (anchor.startsWith('\n'))
|
|
482
|
+
anchor = anchor.slice(1);
|
|
483
|
+
if (anchor.endsWith('\n'))
|
|
484
|
+
anchor = anchor.slice(0, -1);
|
|
485
|
+
if (content.startsWith('\n'))
|
|
486
|
+
content = content.slice(1);
|
|
487
|
+
if (content.endsWith('\n'))
|
|
488
|
+
content = content.slice(0, -1);
|
|
489
|
+
ops.push({ type, anchor, content });
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
const anchor = (m[2] ?? m[3] ?? '').trim(); // quoted OR unquoted; ADD_IMPORT/ADD_AFTER_IMPORTS have no anchor
|
|
493
|
+
const contentLines = [];
|
|
494
|
+
while (i < lines.length && !endRe.test(lines[i])) {
|
|
495
|
+
contentLines.push(lines[i]);
|
|
496
|
+
i++;
|
|
497
|
+
}
|
|
498
|
+
i++; // skip // @@@ END
|
|
499
|
+
let content = contentLines.join('\n');
|
|
500
|
+
if (content.startsWith('\n'))
|
|
501
|
+
content = content.slice(1);
|
|
502
|
+
if (content.endsWith('\n'))
|
|
503
|
+
content = content.slice(0, -1);
|
|
504
|
+
ops.push({ type, anchor, content });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return ops;
|
|
508
|
+
}
|
|
509
|
+
// Finds the start and end character positions of `anchor` within `code`.
|
|
510
|
+
// First tries exact match; if that fails, tries a line-by-line match that
|
|
511
|
+
// trims trailing whitespace from each line (handles trailing spaces and CRLF files).
|
|
512
|
+
// Returns the range in the ORIGINAL (un-normalized) code so the replacement is clean.
|
|
513
|
+
function findAnchorRange(code, anchor) {
|
|
514
|
+
// Fast path: exact match
|
|
515
|
+
const exactIdx = code.indexOf(anchor);
|
|
516
|
+
if (exactIdx !== -1)
|
|
517
|
+
return { start: exactIdx, end: exactIdx + anchor.length };
|
|
518
|
+
// Fallback: trim trailing whitespace (including \r) on every line and re-compare.
|
|
519
|
+
// Handles trailing spaces left by editors and CRLF files (\r stripped by trimEnd).
|
|
520
|
+
const anchorLines = anchor.split('\n').map(l => l.trimEnd());
|
|
521
|
+
const codeLines = code.split('\n');
|
|
522
|
+
const n = anchorLines.length;
|
|
523
|
+
if (n === 0)
|
|
524
|
+
return null;
|
|
525
|
+
// Precompute byte offset of each line start — O(N) once, avoids O(N²) inner accumulation.
|
|
526
|
+
const lineStart = new Array(codeLines.length + 1);
|
|
527
|
+
lineStart[0] = 0;
|
|
528
|
+
for (let k = 0; k < codeLines.length; k++) {
|
|
529
|
+
lineStart[k + 1] = lineStart[k] + codeLines[k].length + 1; // +1 for the \n separator
|
|
530
|
+
}
|
|
531
|
+
for (let i = 0; i <= codeLines.length - n; i++) {
|
|
532
|
+
if (codeLines[i].trimEnd() !== anchorLines[0])
|
|
533
|
+
continue;
|
|
534
|
+
let match = true;
|
|
535
|
+
for (let j = 1; j < n; j++) {
|
|
536
|
+
if (codeLines[i + j].trimEnd() !== anchorLines[j]) {
|
|
537
|
+
match = false;
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (!match)
|
|
542
|
+
continue;
|
|
543
|
+
const start = lineStart[i];
|
|
544
|
+
// end = start of line after the match minus the \n, i.e. the span of the matched lines joined
|
|
545
|
+
const end = start + codeLines.slice(i, i + n).join('\n').length;
|
|
546
|
+
return { start, end };
|
|
547
|
+
}
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
// Finds the end of an it()/test()/describe() call starting at `startIdx` in `code`.
|
|
551
|
+
// `startIdx` must point to the opening `(` of the call.
|
|
552
|
+
// Returns the index just past the closing `)` (and optional `;`), or -1 on failure.
|
|
553
|
+
//
|
|
554
|
+
// Strategy: skip the string argument(s), find the function body `{`, track brace depth
|
|
555
|
+
// until it returns to 0, then consume the closing `)` and optional `;`.
|
|
556
|
+
// We do a simplified scan that handles string literals and template literals to avoid
|
|
557
|
+
// false brace counts inside quoted text.
|
|
558
|
+
function findCallEnd(code, startIdx) {
|
|
559
|
+
let i = startIdx; // points at the `(` of the call
|
|
560
|
+
let parenDepth = 0;
|
|
561
|
+
let braceDepth = 0;
|
|
562
|
+
let foundBrace = false;
|
|
563
|
+
while (i < code.length) {
|
|
564
|
+
const ch = code[i];
|
|
565
|
+
// Skip string literals to avoid false brace/paren counts inside strings
|
|
566
|
+
if (ch === '"' || ch === "'") {
|
|
567
|
+
const q = ch;
|
|
568
|
+
i++;
|
|
569
|
+
while (i < code.length) {
|
|
570
|
+
if (code[i] === '\\') {
|
|
571
|
+
i += 2;
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
if (code[i] === q) {
|
|
575
|
+
i++;
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
i++;
|
|
579
|
+
}
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (ch === '`') {
|
|
583
|
+
i++;
|
|
584
|
+
while (i < code.length) {
|
|
585
|
+
if (code[i] === '\\') {
|
|
586
|
+
i += 2;
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (code[i] === '`') {
|
|
590
|
+
i++;
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
// Skip ${...} expressions inside template literals (simplified: track braces)
|
|
594
|
+
if (code[i] === '$' && code[i + 1] === '{') {
|
|
595
|
+
i += 2;
|
|
596
|
+
let tDepth = 1;
|
|
597
|
+
while (i < code.length && tDepth > 0) {
|
|
598
|
+
if (code[i] === '{')
|
|
599
|
+
tDepth++;
|
|
600
|
+
else if (code[i] === '}')
|
|
601
|
+
tDepth--;
|
|
602
|
+
i++;
|
|
603
|
+
}
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
i++;
|
|
607
|
+
}
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (ch === '{') {
|
|
611
|
+
foundBrace = true;
|
|
612
|
+
braceDepth++;
|
|
613
|
+
i++;
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
if (ch === '}') {
|
|
617
|
+
if (foundBrace) {
|
|
618
|
+
braceDepth--;
|
|
619
|
+
if (braceDepth === 0) {
|
|
620
|
+
// We've closed the function body. Now consume the closing `)` and optional `;`
|
|
621
|
+
i++; // move past `}`
|
|
622
|
+
// skip whitespace/newlines
|
|
623
|
+
while (i < code.length && (code[i] === ' ' || code[i] === '\t' || code[i] === '\n' || code[i] === '\r'))
|
|
624
|
+
i++;
|
|
625
|
+
if (i < code.length && code[i] === ')') {
|
|
626
|
+
i++; // consume `)`
|
|
627
|
+
if (i < code.length && code[i] === ';')
|
|
628
|
+
i++; // consume optional `;`
|
|
629
|
+
}
|
|
630
|
+
return i;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
i++;
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (!foundBrace) {
|
|
637
|
+
// Before the opening brace we still count parens to handle nested calls in args
|
|
638
|
+
if (ch === '(')
|
|
639
|
+
parenDepth++;
|
|
640
|
+
else if (ch === ')') {
|
|
641
|
+
parenDepth--;
|
|
642
|
+
// If we hit -1 depth without ever finding a brace this is a call with no body (unlikely for tests)
|
|
643
|
+
if (parenDepth < 0)
|
|
644
|
+
return -1;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
i++;
|
|
648
|
+
}
|
|
649
|
+
return -1;
|
|
650
|
+
}
|
|
651
|
+
// Applies a list of PatchOperation objects to `existingCode` in order.
|
|
652
|
+
// Returns the modified string, or null if any anchor cannot be located.
|
|
653
|
+
export function applyPatch(existingCode, ops) {
|
|
654
|
+
let code = existingCode;
|
|
655
|
+
for (const op of ops) {
|
|
656
|
+
if (op.type === 'REPLACE') {
|
|
657
|
+
// General text replacement — same mechanism as the Edit tool.
|
|
658
|
+
// anchor = exact old text, content = replacement. First occurrence only.
|
|
659
|
+
// Falls back to trailing-whitespace-normalized line matching so that minor
|
|
660
|
+
// formatting differences (trailing spaces, CRLF files) don't cause failures.
|
|
661
|
+
const range = findAnchorRange(code, op.anchor);
|
|
662
|
+
if (!range)
|
|
663
|
+
return null;
|
|
664
|
+
code = code.slice(0, range.start) + op.content + code.slice(range.end);
|
|
665
|
+
}
|
|
666
|
+
else if (op.type === 'REPLACE_TEST' || op.type === 'DELETE_TEST') {
|
|
667
|
+
const anchor = op.anchor;
|
|
668
|
+
// Try all four quote/keyword combos
|
|
669
|
+
const candidates = [
|
|
670
|
+
`it("${anchor}"`,
|
|
671
|
+
`it('${anchor}'`,
|
|
672
|
+
`test("${anchor}"`,
|
|
673
|
+
`test('${anchor}'`,
|
|
674
|
+
];
|
|
675
|
+
let foundIdx = -1;
|
|
676
|
+
for (const c of candidates) {
|
|
677
|
+
const idx = code.indexOf(c);
|
|
678
|
+
if (idx !== -1) {
|
|
679
|
+
foundIdx = idx;
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if (foundIdx === -1)
|
|
684
|
+
return null;
|
|
685
|
+
// Find the opening `(` of the call — it's right after `it` or `test`
|
|
686
|
+
const parenIdx = code.indexOf('(', foundIdx);
|
|
687
|
+
if (parenIdx === -1)
|
|
688
|
+
return null;
|
|
689
|
+
const callEnd = findCallEnd(code, parenIdx);
|
|
690
|
+
if (callEnd === -1)
|
|
691
|
+
return null;
|
|
692
|
+
if (op.type === 'REPLACE_TEST') {
|
|
693
|
+
code = code.slice(0, foundIdx) + op.content + code.slice(callEnd);
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
// DELETE_TEST: also remove an immediately preceding blank line
|
|
697
|
+
let removeStart = foundIdx;
|
|
698
|
+
if (removeStart > 0 && code[removeStart - 1] === '\n') {
|
|
699
|
+
// Check if the line before is blank
|
|
700
|
+
const prevNewline = code.lastIndexOf('\n', removeStart - 2);
|
|
701
|
+
const prevLine = code.slice(prevNewline + 1, removeStart - 1);
|
|
702
|
+
if (prevLine.trim() === '')
|
|
703
|
+
removeStart = prevNewline + 1;
|
|
704
|
+
}
|
|
705
|
+
code = code.slice(0, removeStart) + code.slice(callEnd);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
else if (op.type === 'ADD_AFTER_DESCRIBE') {
|
|
709
|
+
const anchor = op.anchor;
|
|
710
|
+
const candidates = [
|
|
711
|
+
`describe("${anchor}"`,
|
|
712
|
+
`describe('${anchor}'`,
|
|
713
|
+
];
|
|
714
|
+
let foundIdx = -1;
|
|
715
|
+
for (const c of candidates) {
|
|
716
|
+
const idx = code.indexOf(c);
|
|
717
|
+
if (idx !== -1) {
|
|
718
|
+
foundIdx = idx;
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (foundIdx === -1)
|
|
723
|
+
return null;
|
|
724
|
+
// Find the opening `(` of the describe call
|
|
725
|
+
const parenIdx = code.indexOf('(', foundIdx);
|
|
726
|
+
if (parenIdx === -1)
|
|
727
|
+
return null;
|
|
728
|
+
// Walk from parenIdx to find the LAST closing `})` of the describe block.
|
|
729
|
+
// We track brace depth from the first `{` we encounter inside the describe args.
|
|
730
|
+
let i = parenIdx;
|
|
731
|
+
let braceDepth = 0;
|
|
732
|
+
let lastClosePos = -1; // position of the `}` that closes the describe body
|
|
733
|
+
// Skip string literal for the describe name argument
|
|
734
|
+
// The describe call looks like: describe("name", () => { ... })
|
|
735
|
+
// We need to find the function body brace
|
|
736
|
+
let foundBrace = false;
|
|
737
|
+
while (i < code.length) {
|
|
738
|
+
const ch = code[i];
|
|
739
|
+
// Skip string literals
|
|
740
|
+
if (ch === '"' || ch === "'") {
|
|
741
|
+
const q = ch;
|
|
742
|
+
i++;
|
|
743
|
+
while (i < code.length) {
|
|
744
|
+
if (code[i] === '\\') {
|
|
745
|
+
i += 2;
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
if (code[i] === q) {
|
|
749
|
+
i++;
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
i++;
|
|
753
|
+
}
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
if (ch === '`') {
|
|
757
|
+
i++;
|
|
758
|
+
while (i < code.length) {
|
|
759
|
+
if (code[i] === '\\') {
|
|
760
|
+
i += 2;
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (code[i] === '`') {
|
|
764
|
+
i++;
|
|
765
|
+
break;
|
|
766
|
+
}
|
|
767
|
+
if (code[i] === '$' && code[i + 1] === '{') {
|
|
768
|
+
i += 2;
|
|
769
|
+
let tDepth = 1;
|
|
770
|
+
while (i < code.length && tDepth > 0) {
|
|
771
|
+
if (code[i] === '{')
|
|
772
|
+
tDepth++;
|
|
773
|
+
else if (code[i] === '}')
|
|
774
|
+
tDepth--;
|
|
775
|
+
i++;
|
|
776
|
+
}
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
i++;
|
|
780
|
+
}
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
if (ch === '{') {
|
|
784
|
+
foundBrace = true;
|
|
785
|
+
braceDepth++;
|
|
786
|
+
i++;
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (ch === '}') {
|
|
790
|
+
if (foundBrace) {
|
|
791
|
+
braceDepth--;
|
|
792
|
+
if (braceDepth === 0) {
|
|
793
|
+
lastClosePos = i;
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
i++;
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
i++;
|
|
801
|
+
}
|
|
802
|
+
if (lastClosePos === -1)
|
|
803
|
+
return null;
|
|
804
|
+
// Insert content immediately before the closing `}`
|
|
805
|
+
// Add a newline after content so the `}` is on its own line
|
|
806
|
+
const insertion = '\n' + op.content + '\n';
|
|
807
|
+
code = code.slice(0, lastClosePos) + insertion + code.slice(lastClosePos);
|
|
808
|
+
}
|
|
809
|
+
else if (op.type === 'ADD_IMPORT') {
|
|
810
|
+
// Find the last `import ` line in the file
|
|
811
|
+
const lines = code.split('\n');
|
|
812
|
+
let lastImportLineIdx = -1;
|
|
813
|
+
for (let idx = 0; idx < lines.length; idx++) {
|
|
814
|
+
if (/^\s*import\s/.test(lines[idx]))
|
|
815
|
+
lastImportLineIdx = idx;
|
|
816
|
+
}
|
|
817
|
+
const importLines = op.content.split('\n');
|
|
818
|
+
if (lastImportLineIdx === -1) {
|
|
819
|
+
lines.unshift(...importLines);
|
|
820
|
+
}
|
|
821
|
+
else {
|
|
822
|
+
lines.splice(lastImportLineIdx + 1, 0, ...importLines);
|
|
823
|
+
}
|
|
824
|
+
code = lines.join('\n');
|
|
825
|
+
}
|
|
826
|
+
else if (op.type === 'ADD_AFTER_IMPORTS') {
|
|
827
|
+
// Like ADD_IMPORT but inserts a blank line before the block — for vi.mock() calls
|
|
828
|
+
// and other module-level statements that follow imports
|
|
829
|
+
const lines = code.split('\n');
|
|
830
|
+
let lastImportLineIdx = -1;
|
|
831
|
+
for (let idx = 0; idx < lines.length; idx++) {
|
|
832
|
+
if (/^\s*import\s/.test(lines[idx]))
|
|
833
|
+
lastImportLineIdx = idx;
|
|
834
|
+
}
|
|
835
|
+
const contentLines = ['', ...op.content.split('\n')];
|
|
836
|
+
if (lastImportLineIdx === -1) {
|
|
837
|
+
lines.unshift(...contentLines);
|
|
838
|
+
}
|
|
839
|
+
else {
|
|
840
|
+
lines.splice(lastImportLineIdx + 1, 0, ...contentLines);
|
|
841
|
+
}
|
|
842
|
+
code = lines.join('\n');
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
// Collapse gaps left by DELETE_TEST: runs of 3+ newlines (2+ consecutive blank lines)
|
|
846
|
+
// down to exactly 2 newlines (1 blank line). Safe — never affects content.
|
|
847
|
+
code = code.replace(/\n{3,}/g, '\n\n');
|
|
848
|
+
// Remove describe blocks that became empty shells (no it/test calls anywhere inside).
|
|
849
|
+
// Repeat until stable — outer empties are caught after inner empties are removed.
|
|
850
|
+
code = removeEmptyDescribeBlocks(code);
|
|
851
|
+
return code;
|
|
852
|
+
}
|
|
853
|
+
// Removes describe() blocks whose body contains no it() or test() calls at any depth.
|
|
854
|
+
// Iterates until stable to handle nested empty blocks (inner removed first, then outer).
|
|
855
|
+
function removeEmptyDescribeBlocks(code) {
|
|
856
|
+
let prev = '';
|
|
857
|
+
while (code !== prev) {
|
|
858
|
+
prev = code;
|
|
859
|
+
const lines = code.split('\n');
|
|
860
|
+
const out = [];
|
|
861
|
+
let i = 0;
|
|
862
|
+
while (i < lines.length) {
|
|
863
|
+
const line = lines[i];
|
|
864
|
+
if (/^\s*describe\s*\(/.test(line) && line.trimEnd().endsWith('{')) {
|
|
865
|
+
// Collect the full block by tracking brace depth.
|
|
866
|
+
// Note: braces in string literals may cause a false depth count, but that
|
|
867
|
+
// only risks keeping a block we should remove — never deleting a live one,
|
|
868
|
+
// because we require it()/test() to be absent in ALL collected lines.
|
|
869
|
+
let depth = 1;
|
|
870
|
+
let j = i + 1;
|
|
871
|
+
const bodyLines = [];
|
|
872
|
+
while (j < lines.length && depth > 0) {
|
|
873
|
+
const l = lines[j];
|
|
874
|
+
for (const ch of l) {
|
|
875
|
+
if (ch === '{')
|
|
876
|
+
depth++;
|
|
877
|
+
else if (ch === '}')
|
|
878
|
+
depth--;
|
|
879
|
+
}
|
|
880
|
+
if (depth > 0)
|
|
881
|
+
bodyLines.push(l);
|
|
882
|
+
j++;
|
|
883
|
+
}
|
|
884
|
+
const hasTests = bodyLines.some(l => /\b(?:it|test)\s*\(/.test(l));
|
|
885
|
+
if (!hasTests) {
|
|
886
|
+
// Skip the whole block (opening line through closing line)
|
|
887
|
+
i = j;
|
|
888
|
+
// Consume a trailing blank line so deletions don't stack up
|
|
889
|
+
if (i < lines.length && !lines[i].trim())
|
|
890
|
+
i++;
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
out.push(line);
|
|
895
|
+
i++;
|
|
896
|
+
}
|
|
897
|
+
code = out.join('\n');
|
|
898
|
+
}
|
|
899
|
+
return code;
|
|
900
|
+
}
|
|
901
|
+
// Convenience wrapper: parses then applies. Returns null if no ops parsed or apply fails.
|
|
902
|
+
export function tryApplyPatch(existingCode, patchOutput) {
|
|
903
|
+
const ops = parsePatch(patchOutput);
|
|
904
|
+
if (ops.length === 0)
|
|
905
|
+
return null;
|
|
906
|
+
return applyPatch(existingCode, ops);
|
|
907
|
+
}
|
|
908
|
+
// Like tryApplyPatch but surfaces which operation failed, so callers can build
|
|
909
|
+
// a useful error message pointing the model at the exact anchor that didn't match.
|
|
910
|
+
export function tryApplyPatchWithDiag(existingCode, patchOutput) {
|
|
911
|
+
const ops = parsePatch(patchOutput);
|
|
912
|
+
if (ops.length === 0)
|
|
913
|
+
return { ok: false, failedOp: null, opsCount: 0 };
|
|
914
|
+
let code = existingCode;
|
|
915
|
+
for (const op of ops) {
|
|
916
|
+
const result = applyPatch(code, [op]);
|
|
917
|
+
if (result === null)
|
|
918
|
+
return { ok: false, failedOp: op, opsCount: ops.length };
|
|
919
|
+
code = result;
|
|
920
|
+
}
|
|
921
|
+
return { ok: true, result: code };
|
|
922
|
+
}
|
|
923
|
+
export function parseMocksPatch(patchOutput) {
|
|
924
|
+
const ops = [];
|
|
925
|
+
const lines = patchOutput.split('\n');
|
|
926
|
+
const headerRe = /^\/\/ @@@ (REPLACE|APPEND_EXPORT|ADD_TO_BEFOREEACH):\s*$/;
|
|
927
|
+
const withRe = /^\/\/ @@@ WITH:\s*$/;
|
|
928
|
+
const endRe = /^\/\/ @@@ END\s*$/;
|
|
929
|
+
let i = 0;
|
|
930
|
+
while (i < lines.length) {
|
|
931
|
+
const m = headerRe.exec(lines[i]);
|
|
932
|
+
if (!m) {
|
|
933
|
+
i++;
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
const type = m[1];
|
|
937
|
+
i++;
|
|
938
|
+
if (type === 'REPLACE') {
|
|
939
|
+
const oldLines = [];
|
|
940
|
+
while (i < lines.length && !withRe.test(lines[i]) && !endRe.test(lines[i])) {
|
|
941
|
+
oldLines.push(lines[i]);
|
|
942
|
+
i++;
|
|
943
|
+
}
|
|
944
|
+
if (!withRe.test(lines[i] ?? '')) {
|
|
945
|
+
i++;
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
i++; // skip // @@@ WITH:
|
|
949
|
+
const newLines = [];
|
|
950
|
+
while (i < lines.length && !endRe.test(lines[i])) {
|
|
951
|
+
newLines.push(lines[i]);
|
|
952
|
+
i++;
|
|
953
|
+
}
|
|
954
|
+
i++; // skip // @@@ END
|
|
955
|
+
let oldText = oldLines.join('\n');
|
|
956
|
+
let newText = newLines.join('\n');
|
|
957
|
+
if (oldText.startsWith('\n'))
|
|
958
|
+
oldText = oldText.slice(1);
|
|
959
|
+
if (oldText.endsWith('\n'))
|
|
960
|
+
oldText = oldText.slice(0, -1);
|
|
961
|
+
if (newText.startsWith('\n'))
|
|
962
|
+
newText = newText.slice(1);
|
|
963
|
+
if (newText.endsWith('\n'))
|
|
964
|
+
newText = newText.slice(0, -1);
|
|
965
|
+
ops.push({ type, oldText, newText });
|
|
966
|
+
}
|
|
967
|
+
else {
|
|
968
|
+
// APPEND_EXPORT and ADD_TO_BEFOREEACH — just content, no WITH: block
|
|
969
|
+
const contentLines = [];
|
|
970
|
+
while (i < lines.length && !endRe.test(lines[i])) {
|
|
971
|
+
contentLines.push(lines[i]);
|
|
972
|
+
i++;
|
|
973
|
+
}
|
|
974
|
+
i++; // skip // @@@ END
|
|
975
|
+
let newText = contentLines.join('\n');
|
|
976
|
+
if (newText.startsWith('\n'))
|
|
977
|
+
newText = newText.slice(1);
|
|
978
|
+
if (newText.endsWith('\n'))
|
|
979
|
+
newText = newText.slice(0, -1);
|
|
980
|
+
ops.push({ type, oldText: '', newText });
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return ops;
|
|
984
|
+
}
|
|
985
|
+
export function applyMocksPatch(existing, ops) {
|
|
986
|
+
let code = existing;
|
|
987
|
+
const failedOps = [];
|
|
988
|
+
for (const op of ops) {
|
|
989
|
+
if (op.type === 'REPLACE') {
|
|
990
|
+
const range = findAnchorRange(code, op.oldText);
|
|
991
|
+
if (!range) {
|
|
992
|
+
failedOps.push(op);
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
code = code.slice(0, range.start) + op.newText + code.slice(range.end);
|
|
996
|
+
}
|
|
997
|
+
else if (op.type === 'APPEND_EXPORT') {
|
|
998
|
+
// Insert before the last beforeEach block, or at end of file if none
|
|
999
|
+
const beforeEachIdx = code.lastIndexOf('\nbeforeEach(');
|
|
1000
|
+
if (beforeEachIdx !== -1) {
|
|
1001
|
+
code = code.slice(0, beforeEachIdx) + '\n\n' + op.newText.trim() + code.slice(beforeEachIdx);
|
|
1002
|
+
}
|
|
1003
|
+
else {
|
|
1004
|
+
code = code.trimEnd() + '\n\n' + op.newText.trim();
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
else if (op.type === 'ADD_TO_BEFOREEACH') {
|
|
1008
|
+
// Find the last beforeEach and insert before its closing brace
|
|
1009
|
+
const beIdx = code.lastIndexOf('\nbeforeEach(');
|
|
1010
|
+
if (beIdx === -1) {
|
|
1011
|
+
failedOps.push(op);
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
// Find the closing }) of that beforeEach by tracking brace depth
|
|
1015
|
+
let depth = 0;
|
|
1016
|
+
let closeIdx = -1;
|
|
1017
|
+
for (let i = beIdx + 1; i < code.length; i++) {
|
|
1018
|
+
if (code[i] === '{')
|
|
1019
|
+
depth++;
|
|
1020
|
+
else if (code[i] === '}') {
|
|
1021
|
+
depth--;
|
|
1022
|
+
if (depth === 0) {
|
|
1023
|
+
closeIdx = i;
|
|
1024
|
+
break;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
if (closeIdx === -1) {
|
|
1029
|
+
failedOps.push(op);
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
const indent = ' ';
|
|
1033
|
+
const lines = op.newText.trim().split('\n').map(l => indent + l).join('\n');
|
|
1034
|
+
code = code.slice(0, closeIdx) + '\n' + lines + '\n' + code.slice(closeIdx);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
return { result: code, failedOps };
|
|
1038
|
+
}
|
|
1039
|
+
export function tryApplyMocksPatch(existing, patchOutput) {
|
|
1040
|
+
const ops = parseMocksPatch(patchOutput);
|
|
1041
|
+
if (ops.length === 0)
|
|
1042
|
+
return null;
|
|
1043
|
+
return applyMocksPatch(existing, ops);
|
|
1044
|
+
}
|
|
397
1045
|
//# sourceMappingURL=validate.js.map
|