jules-orchestrator-kit 0.42.0 → 0.51.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/.agent/prompts/A11y.md +23 -0
- package/.agent/prompts/Alchemist.md +23 -0
- package/.agent/prompts/Scribe.md +23 -0
- package/.agent/prompts/Spectator.md +24 -0
- package/JULES_RULES_TEMPLATE.md +10 -8
- package/README.md +8 -3
- package/bin/agentctl.mjs +284 -1
- package/index.mjs +26 -1
- package/package.json +1 -1
- package/scripts/asset-integrity-check.mjs +1 -1
- package/scripts/rules-lint.mjs +1 -1
- package/src/assertions.mjs +244 -0
- package/src/config.mjs +25 -0
- package/src/coverage.mjs +328 -0
- package/src/engine.mjs +20 -4
- package/src/git.mjs +131 -9
- package/src/mutation.mjs +739 -0
- package/src/ops/checkpoint.mjs +31 -3
- package/src/perf.mjs +92 -0
- package/src/provider.mjs +10 -1
- package/src/remediation.mjs +154 -1
- package/src/rules-budget.mjs +8 -2
- package/src/scaffold.mjs +1 -1
- package/src/security.mjs +286 -28
- package/src/stability.mjs +86 -0
- package/src/web-templates.mjs +172 -0
- package/src/asset_integrity.mjs +0 -1
- package/src/execution_envelope.mjs +0 -1
- package/src/rules_budget.mjs +0 -1
package/src/assertions.mjs
CHANGED
|
@@ -3,6 +3,12 @@ import { resolve, relative, join, sep } from "node:path";
|
|
|
3
3
|
import { gzipSync } from "node:zlib";
|
|
4
4
|
import { checkAssetIntegrity } from "./asset-integrity.mjs";
|
|
5
5
|
import { checkRulesBudget } from "./rules-budget.mjs";
|
|
6
|
+
import { runMutationTest } from "./mutation.mjs";
|
|
7
|
+
import { checkTestTampering } from "./security.mjs";
|
|
8
|
+
import { runV8Coverage, calculateDiffCoverage } from "./coverage.mjs";
|
|
9
|
+
import { runStabilityProbe } from "./stability.mjs";
|
|
10
|
+
import { measureEventLoopDelay } from "./perf.mjs";
|
|
11
|
+
import { diffText } from "./git.mjs";
|
|
6
12
|
|
|
7
13
|
/**
|
|
8
14
|
* Normalizes path to POSIX slashes.
|
|
@@ -487,6 +493,204 @@ export function assertFileExists(config = {}, root = process.cwd()) {
|
|
|
487
493
|
};
|
|
488
494
|
}
|
|
489
495
|
|
|
496
|
+
/**
|
|
497
|
+
* Asserts that mutation testing score meets minimum threshold.
|
|
498
|
+
*/
|
|
499
|
+
export function assertMutation(config = {}, root = process.cwd()) {
|
|
500
|
+
const minScore = typeof config.minScore === "number" ? config.minScore : 80;
|
|
501
|
+
const maxMutants = typeof config.maxMutants === "number" ? config.maxMutants : 20;
|
|
502
|
+
const testCmd = config.testCmd || config.cmd || "npm test";
|
|
503
|
+
|
|
504
|
+
const report = runMutationTest({
|
|
505
|
+
root,
|
|
506
|
+
minScore,
|
|
507
|
+
maxMutants,
|
|
508
|
+
testCmd,
|
|
509
|
+
base: config.base || "main",
|
|
510
|
+
mode: config.mode || "working-tree",
|
|
511
|
+
files: Array.isArray(config.files) ? config.files : config.file ? [config.file] : undefined,
|
|
512
|
+
executor: config.executor,
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const diagnostics = [];
|
|
516
|
+
if (!report.ok) {
|
|
517
|
+
diagnostics.push(
|
|
518
|
+
`Mutation score ${report.mutationScore}% below required threshold of ${minScore}% (${report.killedMutants}/${report.totalMutants} killed, ${report.survivedMutants} survived).`
|
|
519
|
+
);
|
|
520
|
+
for (const survivor of report.survivors) {
|
|
521
|
+
diagnostics.push(` - Survivor in ${survivor.mutant.file}:${survivor.mutant.line}: "${survivor.mutant.mutatedLine.trim()}" (${survivor.mutant.description})`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
ok: report.ok,
|
|
527
|
+
mutationScore: report.mutationScore,
|
|
528
|
+
minScore: report.minScore,
|
|
529
|
+
totalMutants: report.totalMutants,
|
|
530
|
+
killedMutants: report.killedMutants,
|
|
531
|
+
survivedMutants: report.survivedMutants,
|
|
532
|
+
diagnostics,
|
|
533
|
+
metrics: {
|
|
534
|
+
mutationScore: report.mutationScore,
|
|
535
|
+
minScore: report.minScore,
|
|
536
|
+
totalMutants: report.totalMutants,
|
|
537
|
+
killedMutants: report.killedMutants,
|
|
538
|
+
survivedMutants: report.survivedMutants,
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Asserts that no test file assertion weakening or skip injections occurred.
|
|
545
|
+
*/
|
|
546
|
+
export function assertTestIntegrity(config = {}, root = process.cwd()) {
|
|
547
|
+
let diffStr = config.diffStr;
|
|
548
|
+
if (!diffStr) {
|
|
549
|
+
try {
|
|
550
|
+
diffStr = diffText(root, config.base || "main", config.mode || "working-tree");
|
|
551
|
+
} catch (_) {
|
|
552
|
+
diffStr = "";
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const res = checkTestTampering(diffStr, config);
|
|
557
|
+
const diagnostics = (res.violations || []).map((v) => v.reason);
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
ok: res.ok,
|
|
561
|
+
violations: res.violations || [],
|
|
562
|
+
diagnostics,
|
|
563
|
+
metrics: {
|
|
564
|
+
violationCount: res.violations?.length || 0,
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Asserts that newly added implementation lines meet the minimum diff coverage percentage.
|
|
571
|
+
*/
|
|
572
|
+
export function assertDiffCoverage(config = {}, root = process.cwd()) {
|
|
573
|
+
let diffStr = config.diffStr;
|
|
574
|
+
if (!diffStr) {
|
|
575
|
+
try {
|
|
576
|
+
diffStr = diffText(root, config.base || "main", config.mode || "working-tree");
|
|
577
|
+
} catch (_) {
|
|
578
|
+
diffStr = "";
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const covRes = runV8Coverage(config.cmd || config.testCmd, {
|
|
583
|
+
root,
|
|
584
|
+
timeoutMs: config.timeoutMs,
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
const diffCov = calculateDiffCoverage(covRes.coverageByFile, diffStr, {
|
|
588
|
+
root,
|
|
589
|
+
minCoverage: typeof config.minCoverage === "number" ? config.minCoverage : (typeof config.min === "number" ? config.min : 100),
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
const diagnostics = [];
|
|
593
|
+
if (!diffCov.ok) {
|
|
594
|
+
diagnostics.push(diffCov.summary);
|
|
595
|
+
for (const [file, missed] of Object.entries(diffCov.missedByFile)) {
|
|
596
|
+
diagnostics.push(`Uncovered added lines in ${file}: lines ${missed.join(", ")}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
ok: diffCov.ok && covRes.ok,
|
|
602
|
+
score: diffCov.score,
|
|
603
|
+
minCoverage: diffCov.minCoverage,
|
|
604
|
+
totalLines: diffCov.totalLines,
|
|
605
|
+
coveredLines: diffCov.coveredLines,
|
|
606
|
+
missedLines: diffCov.missedLines,
|
|
607
|
+
missedByFile: diffCov.missedByFile,
|
|
608
|
+
diagnostics,
|
|
609
|
+
metrics: {
|
|
610
|
+
diffCoverageScore: diffCov.score,
|
|
611
|
+
uncoveredLinesCount: diffCov.missedLines,
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Asserts that a test suite executes deterministically across multiple consecutive repetitions.
|
|
618
|
+
*/
|
|
619
|
+
export function assertTestStability(config = {}, root = process.cwd()) {
|
|
620
|
+
const repeat = config.repeat || config.iterations || 5;
|
|
621
|
+
const minPassRate = typeof config.minPassRate === "number" ? config.minPassRate : 1.0;
|
|
622
|
+
|
|
623
|
+
const probe = runStabilityProbe(config.cmd || config.testCmd, {
|
|
624
|
+
root,
|
|
625
|
+
repeat,
|
|
626
|
+
minPassRate,
|
|
627
|
+
timeoutMs: config.timeoutMs,
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
const diagnostics = [];
|
|
631
|
+
if (!probe.ok) {
|
|
632
|
+
diagnostics.push(probe.summary);
|
|
633
|
+
const failedRuns = probe.runs.filter((r) => !r.pass);
|
|
634
|
+
for (const r of failedRuns) {
|
|
635
|
+
diagnostics.push(`Iteration ${r.iteration} failed (exit ${r.exitCode})`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return {
|
|
640
|
+
ok: probe.ok,
|
|
641
|
+
repeat: probe.repeat,
|
|
642
|
+
passes: probe.passes,
|
|
643
|
+
failures: probe.failures,
|
|
644
|
+
passRate: probe.passRate,
|
|
645
|
+
oscillation: probe.oscillation,
|
|
646
|
+
diagnostics,
|
|
647
|
+
metrics: {
|
|
648
|
+
passRate: probe.passRate,
|
|
649
|
+
oscillation: probe.oscillation,
|
|
650
|
+
failuresCount: probe.failures,
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Asserts that Node.js Event Loop delay remains within acceptable thresholds during execution.
|
|
657
|
+
*/
|
|
658
|
+
export function assertEventLoopLag(config = {}, root = process.cwd()) {
|
|
659
|
+
const maxDelayMs = config.maxDelayMs || config.maxMs || config.thresholdMs || 50;
|
|
660
|
+
const resolution = config.resolution || 10;
|
|
661
|
+
|
|
662
|
+
const res = measureEventLoopDelay(config.cmd || config.testCmd, {
|
|
663
|
+
root,
|
|
664
|
+
maxDelayMs,
|
|
665
|
+
resolution,
|
|
666
|
+
timeoutMs: config.timeoutMs,
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
const diagnostics = [];
|
|
670
|
+
if (!res.ok) {
|
|
671
|
+
diagnostics.push(res.summary);
|
|
672
|
+
if (res.exitCode !== 0) {
|
|
673
|
+
diagnostics.push(`Command failed with exit code ${res.exitCode}`);
|
|
674
|
+
} else {
|
|
675
|
+
diagnostics.push(`Event loop delay exceeded threshold: p99=${res.p99Ms}ms (max allowed: ${maxDelayMs}ms)`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return {
|
|
680
|
+
ok: res.ok,
|
|
681
|
+
maxMs: res.maxMs,
|
|
682
|
+
p99Ms: res.p99Ms,
|
|
683
|
+
meanMs: res.meanMs,
|
|
684
|
+
thresholdMs: maxDelayMs,
|
|
685
|
+
diagnostics,
|
|
686
|
+
metrics: {
|
|
687
|
+
eventLoopP99Ms: res.p99Ms,
|
|
688
|
+
eventLoopMaxMs: res.maxMs,
|
|
689
|
+
eventLoopMeanMs: res.meanMs,
|
|
690
|
+
},
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
490
694
|
/**
|
|
491
695
|
* Dispatches and executes an assertion primitive.
|
|
492
696
|
*/
|
|
@@ -543,6 +747,46 @@ export function runAssertion(stage = {}, root = process.cwd()) {
|
|
|
543
747
|
break;
|
|
544
748
|
}
|
|
545
749
|
|
|
750
|
+
case "mutation":
|
|
751
|
+
case "mutation-testing":
|
|
752
|
+
case "mutation-score":
|
|
753
|
+
case "mutation_score":
|
|
754
|
+
res = assertMutation(stage, root);
|
|
755
|
+
break;
|
|
756
|
+
|
|
757
|
+
case "test-integrity":
|
|
758
|
+
case "anti-tamper":
|
|
759
|
+
case "test-tamper":
|
|
760
|
+
case "test_integrity":
|
|
761
|
+
res = assertTestIntegrity(stage, root);
|
|
762
|
+
break;
|
|
763
|
+
|
|
764
|
+
case "diff-coverage":
|
|
765
|
+
case "diff_coverage":
|
|
766
|
+
case "coverage":
|
|
767
|
+
case "patch-coverage":
|
|
768
|
+
case "patch_coverage":
|
|
769
|
+
case "v8-coverage":
|
|
770
|
+
res = assertDiffCoverage(stage, root);
|
|
771
|
+
break;
|
|
772
|
+
|
|
773
|
+
case "test-stability":
|
|
774
|
+
case "test_stability":
|
|
775
|
+
case "stability":
|
|
776
|
+
case "flaky-probe":
|
|
777
|
+
case "stability-probe":
|
|
778
|
+
res = assertTestStability(stage, root);
|
|
779
|
+
break;
|
|
780
|
+
|
|
781
|
+
case "event-loop-lag":
|
|
782
|
+
case "event-loop-delay":
|
|
783
|
+
case "event_loop_lag":
|
|
784
|
+
case "event_loop_delay":
|
|
785
|
+
case "perf-lag":
|
|
786
|
+
case "perf_lag":
|
|
787
|
+
res = assertEventLoopLag(stage, root);
|
|
788
|
+
break;
|
|
789
|
+
|
|
546
790
|
case "exists":
|
|
547
791
|
case "file-exists":
|
|
548
792
|
case "not-exists": {
|
package/src/config.mjs
CHANGED
|
@@ -79,6 +79,31 @@ export function normalizePath(p) {
|
|
|
79
79
|
return p.split(sep).join("/").replace(/\\/g, "/");
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* True when a path is a Windows absolute path and therefore can never name a
|
|
84
|
+
* file inside a repository checked out to a normal relative location.
|
|
85
|
+
*
|
|
86
|
+
* Covered spellings (after `normalizePath` folds `\` to `/`):
|
|
87
|
+
*
|
|
88
|
+
* - Drive-qualified: `C:/...`, `C:\...`
|
|
89
|
+
* - Drive-relative: `C:foo` — on Windows this resolves against the *current
|
|
90
|
+
* directory of drive C:*, which is never the repository root and so is an
|
|
91
|
+
* escape even without a leading slash.
|
|
92
|
+
* - UNC: `//server/share`, `\\server\share`
|
|
93
|
+
*
|
|
94
|
+
* A POSIX absolute path (`/etc/passwd`) is intentionally NOT matched here:
|
|
95
|
+
* the existing `startsWith("/")` check already covers it, and the two spellings
|
|
96
|
+
* need to stay distinguishable in messages.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} p
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
export function isWindowsAbsolutePath(p) {
|
|
102
|
+
if (!p || typeof p !== "string") return false;
|
|
103
|
+
const norm = normalizePath(p);
|
|
104
|
+
return /^[A-Za-z]:/.test(norm) || norm.startsWith("//");
|
|
105
|
+
}
|
|
106
|
+
|
|
82
107
|
/**
|
|
83
108
|
* Reduces a repo-relative path to a canonical form for pattern matching:
|
|
84
109
|
* separators normalised, duplicate slashes collapsed, `.` segments dropped,
|
package/src/coverage.mjs
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, mkdtempSync, rmSync, existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { join, resolve, relative, isAbsolute } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
import { resolveVerify } from "./config.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Checks if a file path should be excluded from diff coverage enforcement.
|
|
10
|
+
* Excludes test files, configs, documentation, and agent rules.
|
|
11
|
+
* @param {string} filePath
|
|
12
|
+
* @returns {boolean}
|
|
13
|
+
*/
|
|
14
|
+
export function isExcludedFromCoverage(filePath = "") {
|
|
15
|
+
if (!filePath) return true;
|
|
16
|
+
const norm = filePath.replace(/\\/g, "/").toLowerCase();
|
|
17
|
+
if (
|
|
18
|
+
norm.startsWith(".agent/") ||
|
|
19
|
+
norm.startsWith(".github/") ||
|
|
20
|
+
norm.startsWith(".git/") ||
|
|
21
|
+
norm.startsWith("test/") ||
|
|
22
|
+
norm.startsWith("tests/") ||
|
|
23
|
+
norm.includes("/__tests__/") ||
|
|
24
|
+
norm.includes(".test.") ||
|
|
25
|
+
norm.includes(".spec.") ||
|
|
26
|
+
norm.includes("_test.") ||
|
|
27
|
+
norm.endsWith(".md") ||
|
|
28
|
+
norm.endsWith(".json") ||
|
|
29
|
+
norm.endsWith(".yml") ||
|
|
30
|
+
norm.endsWith(".yaml") ||
|
|
31
|
+
norm.endsWith(".lock")
|
|
32
|
+
) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Maps V8 function range offsets to 1-indexed line hit counts.
|
|
40
|
+
* @param {string} sourceContent - Full text of source file
|
|
41
|
+
* @param {Array<object>} v8Functions - Functions array from V8 coverage JSON
|
|
42
|
+
* @returns {Map<number, number>} Map of lineNo -> hitCount
|
|
43
|
+
*/
|
|
44
|
+
export function mapV8RangesToLines(sourceContent = "", v8Functions = []) {
|
|
45
|
+
if (!sourceContent || !Array.isArray(v8Functions) || v8Functions.length === 0) {
|
|
46
|
+
return new Map();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const lines = sourceContent.split("\n");
|
|
50
|
+
const lineRanges = [];
|
|
51
|
+
let currentOffset = 0;
|
|
52
|
+
for (let i = 0; i < lines.length; i++) {
|
|
53
|
+
const start = currentOffset;
|
|
54
|
+
const end = currentOffset + lines[i].length;
|
|
55
|
+
lineRanges.push({ lineNo: i + 1, start, end, text: lines[i] });
|
|
56
|
+
currentOffset = end + 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const allRanges = [];
|
|
60
|
+
for (const fn of v8Functions) {
|
|
61
|
+
if (Array.isArray(fn.ranges)) {
|
|
62
|
+
for (const r of fn.ranges) {
|
|
63
|
+
allRanges.push(r);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Sort ranges: broader outer ranges first
|
|
69
|
+
allRanges.sort((a, b) => {
|
|
70
|
+
if (a.startOffset !== b.startOffset) return a.startOffset - b.startOffset;
|
|
71
|
+
return b.endOffset - a.endOffset;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const lineHits = new Map();
|
|
75
|
+
|
|
76
|
+
for (const { lineNo, start, text } of lineRanges) {
|
|
77
|
+
const trimmed = text.trim();
|
|
78
|
+
if (
|
|
79
|
+
!trimmed ||
|
|
80
|
+
trimmed.startsWith("//") ||
|
|
81
|
+
trimmed.startsWith("/*") ||
|
|
82
|
+
trimmed.startsWith("*") ||
|
|
83
|
+
trimmed.startsWith("import ") ||
|
|
84
|
+
trimmed.startsWith("export default {") ||
|
|
85
|
+
trimmed === "};" ||
|
|
86
|
+
trimmed === "}"
|
|
87
|
+
) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const nonWsStart = start + text.search(/\S/);
|
|
92
|
+
const nonWsEnd = start + text.trimEnd().length;
|
|
93
|
+
|
|
94
|
+
let count = 0;
|
|
95
|
+
let matched = false;
|
|
96
|
+
|
|
97
|
+
for (const r of allRanges) {
|
|
98
|
+
if (r.startOffset <= nonWsStart && nonWsEnd <= r.endOffset) {
|
|
99
|
+
count = r.count;
|
|
100
|
+
matched = true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (matched) {
|
|
105
|
+
lineHits.set(lineNo, count);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return lineHits;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Spawns the verification test command with NODE_V8_COVERAGE and collects raw coverage maps.
|
|
114
|
+
* @param {string} [testCmd] - Command to execute (defaults to package test cmd)
|
|
115
|
+
* @param {object} [options]
|
|
116
|
+
* @param {string} [options.root=process.cwd()]
|
|
117
|
+
* @param {number} [options.timeoutMs=60000]
|
|
118
|
+
* @returns {object} { ok, coverageFiles, coverageByFile, stdout, stderr, exitCode }
|
|
119
|
+
*/
|
|
120
|
+
export function runV8Coverage(testCmd, options = {}) {
|
|
121
|
+
const root = options.root || process.cwd();
|
|
122
|
+
const cmd = testCmd || resolveVerify(root).testCmd || "npm test";
|
|
123
|
+
const timeoutMs = options.timeoutMs || 60000;
|
|
124
|
+
|
|
125
|
+
const tempCoverageDir = mkdtempSync(join(tmpdir(), "jules-v8-cov-"));
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const env = {
|
|
129
|
+
...process.env,
|
|
130
|
+
NODE_V8_COVERAGE: tempCoverageDir,
|
|
131
|
+
};
|
|
132
|
+
// Strip child test runner flags to avoid collisions
|
|
133
|
+
for (const k of Object.keys(env)) {
|
|
134
|
+
if (k.startsWith("NODE_TEST_") || k.startsWith("NODE_CHANNEL_")) {
|
|
135
|
+
delete env[k];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let stdout = "";
|
|
140
|
+
let stderr = "";
|
|
141
|
+
let exitCode = 0;
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
stdout = execSync(cmd, {
|
|
145
|
+
cwd: root,
|
|
146
|
+
env,
|
|
147
|
+
timeout: timeoutMs,
|
|
148
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
149
|
+
encoding: "utf-8",
|
|
150
|
+
});
|
|
151
|
+
} catch (err) {
|
|
152
|
+
exitCode = err.status || 1;
|
|
153
|
+
stdout = err.stdout ? String(err.stdout) : "";
|
|
154
|
+
stderr = err.stderr ? String(err.stderr) : err.message;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const coverageByFile = new Map();
|
|
158
|
+
const files = existsSync(tempCoverageDir) ? readdirSync(tempCoverageDir) : [];
|
|
159
|
+
|
|
160
|
+
for (const file of files) {
|
|
161
|
+
if (!file.endsWith(".json")) continue;
|
|
162
|
+
const fullPath = join(tempCoverageDir, file);
|
|
163
|
+
try {
|
|
164
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
165
|
+
const parsed = JSON.parse(content);
|
|
166
|
+
if (Array.isArray(parsed.result)) {
|
|
167
|
+
for (const item of parsed.result) {
|
|
168
|
+
if (!item.url || item.url.startsWith("node:")) continue;
|
|
169
|
+
let relPath = item.url;
|
|
170
|
+
if (relPath.startsWith("file://")) {
|
|
171
|
+
try {
|
|
172
|
+
relPath = fileURLToPath(item.url);
|
|
173
|
+
} catch (_) {
|
|
174
|
+
relPath = relPath.replace(/^file:\/\//, "");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let resolvedRoot = root;
|
|
179
|
+
try {
|
|
180
|
+
if (existsSync(root)) resolvedRoot = realpathSync(root);
|
|
181
|
+
} catch (_) {}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
if (existsSync(relPath)) relPath = realpathSync(relPath);
|
|
185
|
+
} catch (_) {}
|
|
186
|
+
|
|
187
|
+
if (isAbsolute(relPath)) {
|
|
188
|
+
relPath = relative(resolvedRoot, relPath);
|
|
189
|
+
}
|
|
190
|
+
relPath = relPath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
191
|
+
|
|
192
|
+
if (!isExcludedFromCoverage(relPath)) {
|
|
193
|
+
if (!coverageByFile.has(relPath)) {
|
|
194
|
+
coverageByFile.set(relPath, []);
|
|
195
|
+
}
|
|
196
|
+
coverageByFile.get(relPath).push(...(item.functions || []));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch (_) {}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
ok: exitCode === 0,
|
|
205
|
+
exitCode,
|
|
206
|
+
stdout,
|
|
207
|
+
stderr,
|
|
208
|
+
coverageFiles: files.length,
|
|
209
|
+
coverageByFile,
|
|
210
|
+
};
|
|
211
|
+
} finally {
|
|
212
|
+
try {
|
|
213
|
+
rmSync(tempCoverageDir, { recursive: true, force: true });
|
|
214
|
+
} catch (_) {}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Parses diff text to extract added line numbers for each modified file.
|
|
220
|
+
* @param {string} diffStr
|
|
221
|
+
* @returns {Map<string, number[]>} Map of filePath -> added line numbers
|
|
222
|
+
*/
|
|
223
|
+
export function extractAddedLinesFromDiff(diffStr = "") {
|
|
224
|
+
const fileAddedLines = new Map();
|
|
225
|
+
if (!diffStr) return fileAddedLines;
|
|
226
|
+
|
|
227
|
+
let currentFile = null;
|
|
228
|
+
let currentLineNo = null;
|
|
229
|
+
|
|
230
|
+
const lines = diffStr.split("\n");
|
|
231
|
+
for (let i = 0; i < lines.length; i++) {
|
|
232
|
+
const line = lines[i];
|
|
233
|
+
|
|
234
|
+
if ((line.startsWith("+++ ") || line.startsWith("+++ b/") || line.startsWith("+++ /dev/null")) && !line.startsWith("++++")) {
|
|
235
|
+
const target = line.slice(3).split("\t")[0].trim().replace(/^b\//, "");
|
|
236
|
+
currentFile = target && target !== "/dev/null" ? target : null;
|
|
237
|
+
currentLineNo = null;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const hunkMatch = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line);
|
|
242
|
+
if (hunkMatch) {
|
|
243
|
+
currentLineNo = parseInt(hunkMatch[1], 10);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (currentLineNo === null || !currentFile) continue;
|
|
248
|
+
|
|
249
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
250
|
+
if (!isExcludedFromCoverage(currentFile)) {
|
|
251
|
+
if (!fileAddedLines.has(currentFile)) {
|
|
252
|
+
fileAddedLines.set(currentFile, []);
|
|
253
|
+
}
|
|
254
|
+
fileAddedLines.get(currentFile).push(currentLineNo);
|
|
255
|
+
}
|
|
256
|
+
currentLineNo++;
|
|
257
|
+
} else if (!line.startsWith("-") && !line.startsWith("\\")) {
|
|
258
|
+
currentLineNo++;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return fileAddedLines;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Calculates diff coverage percentage by cross-referencing V8 coverage against added lines in the git diff.
|
|
267
|
+
* @param {Map<string, Array<object>>} coverageByFile - Map of relative file path -> V8 function arrays
|
|
268
|
+
* @param {string} diffStr - Unified git diff
|
|
269
|
+
* @param {object} [options]
|
|
270
|
+
* @param {string} [options.root=process.cwd()]
|
|
271
|
+
* @param {number} [options.minCoverage=100] - Expected minimum coverage percentage (0-100)
|
|
272
|
+
* @returns {object} { ok, score, totalLines, coveredLines, missedLines, missedByFile, summary }
|
|
273
|
+
*/
|
|
274
|
+
export function calculateDiffCoverage(coverageByFile, diffStr = "", options = {}) {
|
|
275
|
+
const root = options.root || process.cwd();
|
|
276
|
+
const minCoverage = typeof options.minCoverage === "number" ? options.minCoverage : 100;
|
|
277
|
+
const addedLinesMap = extractAddedLinesFromDiff(diffStr);
|
|
278
|
+
|
|
279
|
+
let totalLines = 0;
|
|
280
|
+
let coveredLines = 0;
|
|
281
|
+
const missedByFile = {};
|
|
282
|
+
|
|
283
|
+
for (const [file, addedLines] of addedLinesMap.entries()) {
|
|
284
|
+
const absPath = resolve(root, file);
|
|
285
|
+
if (!existsSync(absPath)) continue;
|
|
286
|
+
|
|
287
|
+
let sourceContent = "";
|
|
288
|
+
try {
|
|
289
|
+
sourceContent = readFileSync(absPath, "utf-8");
|
|
290
|
+
} catch (_) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const v8Functions = coverageByFile.get(file) || [];
|
|
295
|
+
const lineHits = mapV8RangesToLines(sourceContent, v8Functions);
|
|
296
|
+
|
|
297
|
+
const missed = [];
|
|
298
|
+
for (const lineNo of addedLines) {
|
|
299
|
+
if (lineHits.has(lineNo)) {
|
|
300
|
+
totalLines++;
|
|
301
|
+
const count = lineHits.get(lineNo);
|
|
302
|
+
if (count > 0) {
|
|
303
|
+
coveredLines++;
|
|
304
|
+
} else {
|
|
305
|
+
missed.push(lineNo);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (missed.length > 0) {
|
|
311
|
+
missedByFile[file] = missed;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const score = totalLines > 0 ? Math.round((coveredLines / totalLines) * 10000) / 100 : 100;
|
|
316
|
+
const ok = score >= minCoverage;
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
ok,
|
|
320
|
+
score,
|
|
321
|
+
minCoverage,
|
|
322
|
+
totalLines,
|
|
323
|
+
coveredLines,
|
|
324
|
+
missedLines: totalLines - coveredLines,
|
|
325
|
+
missedByFile,
|
|
326
|
+
summary: `Diff Coverage: ${score}% (${coveredLines}/${totalLines} added executable lines covered, min: ${minCoverage}%)`,
|
|
327
|
+
};
|
|
328
|
+
}
|
package/src/engine.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { appendTelemetry as appendTelemetryUnsafe } from "./telemetry.mjs";
|
|
|
14
14
|
|
|
15
15
|
import { spawn } from "node:child_process";
|
|
16
16
|
import { resolveAffectedTests, executeQueueDag } from "./dag-engine.mjs";
|
|
17
|
-
import { recordRemediation, queryRemediations, harvestFailureRecord, hydrateMemory } from "./remediation.mjs";
|
|
17
|
+
import { recordRemediation, queryRemediations, harvestFailureRecord, hydrateMemory, createWhackAMoleDetector } from "./remediation.mjs";
|
|
18
18
|
import { hydratePrompt, harvestFailure } from "./memory.mjs";
|
|
19
19
|
import { resolveRolePrompt } from "./role-resolver.mjs";
|
|
20
20
|
|
|
@@ -593,17 +593,23 @@ export async function repair(failure, opts = {}) {
|
|
|
593
593
|
threshold: 2,
|
|
594
594
|
cooldownMs: 60000,
|
|
595
595
|
});
|
|
596
|
+
const whackAMole = createWhackAMoleDetector({
|
|
597
|
+
maxHistory: Math.max(maxRetries + 2, 8),
|
|
598
|
+
threshold: 2,
|
|
599
|
+
});
|
|
596
600
|
|
|
597
601
|
let currentFailure = failure;
|
|
598
602
|
const initialFingerprint = fingerprintFailureState(currentFailure, root);
|
|
599
603
|
breaker.observe(initialFingerprint);
|
|
604
|
+
whackAMole.recordTestOutcome(currentFailure.stderr || currentFailure.message);
|
|
605
|
+
let extraPromptDirective = null;
|
|
600
606
|
|
|
601
607
|
for (let n = 1; n <= maxRetries; n++) {
|
|
602
608
|
if (progressBus && progressToken) {
|
|
603
609
|
progressBus.reportProgress(progressToken, Math.round((n / maxRetries) * 100), 100, `OODA Repair attempt ${n}/${maxRetries}`);
|
|
604
610
|
}
|
|
605
611
|
|
|
606
|
-
const repairPrompt = buildRepairPrompt(currentFailure, n, config);
|
|
612
|
+
const repairPrompt = buildRepairPrompt(currentFailure, n, config, extraPromptDirective);
|
|
607
613
|
let session;
|
|
608
614
|
try {
|
|
609
615
|
session = await withBudget(
|
|
@@ -657,6 +663,7 @@ export async function repair(failure, opts = {}) {
|
|
|
657
663
|
const gateRes = await gate({ root, config, fix: false, progressBus, progressToken });
|
|
658
664
|
if (gateRes.ok) {
|
|
659
665
|
breaker.reset();
|
|
666
|
+
whackAMole.reset();
|
|
660
667
|
recordRemediation(root, {
|
|
661
668
|
fingerprint: initialFingerprint,
|
|
662
669
|
symptom: currentFailure.stderr || currentFailure.message || "Failure resolved by OODA repair",
|
|
@@ -669,6 +676,12 @@ export async function repair(failure, opts = {}) {
|
|
|
669
676
|
currentFailure = gateRes.phases.find((p) => p.phase === "verify")?.testResult || failure;
|
|
670
677
|
const currentFingerprint = fingerprintFailureState(currentFailure, root);
|
|
671
678
|
|
|
679
|
+
// Whack-a-Mole test-oscillation check
|
|
680
|
+
const whackCheck = whackAMole.recordTestOutcome(currentFailure.stderr || currentFailure.message);
|
|
681
|
+
if (whackCheck.whackAMole) {
|
|
682
|
+
extraPromptDirective = whackCheck.promptDirective;
|
|
683
|
+
}
|
|
684
|
+
|
|
672
685
|
const check = breaker.observe(currentFingerprint);
|
|
673
686
|
if (check.tripped) {
|
|
674
687
|
harvestFailureRecord(root, {
|
|
@@ -800,13 +813,16 @@ export async function pollSessionState(provider, session, opts = {}) {
|
|
|
800
813
|
return { ...session, status: String(session.status || "COMPLETED").toUpperCase() };
|
|
801
814
|
}
|
|
802
815
|
|
|
803
|
-
function buildRepairPrompt(failure, attempt, _config) {
|
|
816
|
+
function buildRepairPrompt(failure, attempt, _config, extraPromptDirective = null) {
|
|
804
817
|
const cleanStderr = redactSecrets(failure.stderr || failure.stdout || "Unknown Error");
|
|
805
818
|
|
|
806
819
|
let escalationStrategy = "DIRECT_REPAIR";
|
|
807
820
|
let escalationDirective = "1. Ground your fix strictly in the error log below. Do NOT guess file contents or function signatures.";
|
|
808
821
|
|
|
809
|
-
if (
|
|
822
|
+
if (extraPromptDirective) {
|
|
823
|
+
escalationStrategy = "WHACK_A_MOLE_PIVOT";
|
|
824
|
+
escalationDirective = `1. ARCHITECTURAL PIVOT: ${extraPromptDirective}`;
|
|
825
|
+
} else if (attempt === 2) {
|
|
810
826
|
escalationStrategy = "DIAGNOSTIC_ANALYSIS";
|
|
811
827
|
escalationDirective = "1. DIAGNOSE FIRST: Two attempts have failed. Analyze if the root cause is in a different file or component than where the previous patch was applied.";
|
|
812
828
|
} else if (attempt >= 3) {
|