minovative-mind-cli 2.6.3 → 2.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/README.md +3 -3
- package/dist/services/agent-tools.js +13 -2
- package/dist/services/agent.d.ts +3 -13
- package/dist/services/agent.js +82 -34
- package/dist/services/ai.d.ts +4 -0
- package/dist/services/ai.js +36 -12
- package/dist/services/orchestration/messageBus.d.ts +1 -0
- package/dist/services/orchestration/messageBus.js +5 -2
- package/dist/services/verificationService.d.ts +42 -0
- package/dist/services/verificationService.js +235 -0
- package/dist/utils/analysisRunner.d.ts +20 -0
- package/dist/utils/analysisRunner.js +117 -0
- package/dist/utils/atomicWrite.js +2 -1
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ and hope for the best.
|
|
|
7
7
|
|
|
8
8
|
This CLI does the opposite — it uses a custom built agentic system called,
|
|
9
9
|
**Precision-Context Verification (PCV)** engine to feed lightweight Flash models
|
|
10
|
-
exactly the right context, execute code, verify
|
|
10
|
+
exactly the right context, execute code, verify compilation, execute Property-Based Testing (PBT) suites and diff-scoped mutation audits, and self-correct (if it even needs to), until
|
|
11
11
|
the build/performance metrics are green.
|
|
12
12
|
|
|
13
13
|
> The result: **Genuine Pro reasoning accuracy at Flash-level speed and efficiency.**
|
|
@@ -142,7 +142,7 @@ By prefixing file paths with `@alias/` (e.g., `@backend/src/api.ts` and `@fronte
|
|
|
142
142
|
|
|
143
143
|
Minovative Mind CLI fundamentally supports **ALL programming languages** for chat, code generation, planning, and execution, as it relies on Gemini's vast training data.
|
|
144
144
|
|
|
145
|
-
However, the PCV engine features deep, context-aware analysis across **12 major programming language families**. Our core advanced engines—**Smart Dependency Tracing**, **Performance Auditing** (across 9 language families), and **Ephemeral Analysis Scripts**—provide tailored support depending on the language's syntax and runtime model:
|
|
145
|
+
However, the PCV engine features deep, context-aware analysis across **12 major programming language families**. Our core advanced engines—**Smart Dependency Tracing**, **Property-Based & Mutation Verification**, **Performance Auditing** (across 9 language families), and **Ephemeral Analysis Scripts**—provide tailored support depending on the language's syntax and runtime model:
|
|
146
146
|
|
|
147
147
|
| Language Family | Supported Extensions | Dependency Tracing | Performance Auditing | Ephemeral Analysis |
|
|
148
148
|
| :--------------------------- | :------------------------------------------- | :----------------: | :------------------: | :----------------: |
|
|
@@ -165,7 +165,7 @@ _⚠️\*Support is limited or requires custom local system tooling/environment
|
|
|
165
165
|
|
|
166
166
|
1. **Dependency Tracing:** Fully supported across language families. It uses lightning-fast static regex pattern matching to resolve local import structures and determine the "blast radius" of code changes without requiring local compilations.
|
|
167
167
|
2. **Performance Auditing:** Executed natively across 9 major language families (JavaScript/TypeScript, Python, Go, Rust, PHP, C#, Java, C/C++, and Ruby). It uses zero-dependency, ultra-fast (<50ms) language-aware regex heuristics with comment/string stripping and line-preserving offset tracking to detect severe runtime anti-patterns (such as unbounded loops, synchronous I/O, chained array allocations, unnecessary allocations, and unclosed resources).
|
|
168
|
-
3. **Ephemeral Analysis
|
|
168
|
+
3. **Ephemeral Analysis & Property-Based Probing:** Runs temporary files in sandbox directories to parse and extract local symbols, execute ephemeral Property-Based Testing (PBT) probes to discover and shrink counterexamples, perform infrastructure probing (detecting available runtimes, checking ports, identifying project type), post-change validation (importing modified modules and asserting expected behavior), and lightweight ML-powered analysis (e.g., TF-IDF file ranking, anomaly detection, Big-O estimation). Defaults to Node.js as a safe baseline, but natively leverages host-installed runtimes (Python, Rust, Go compilers) and standard libraries whenever available in the workspace.
|
|
169
169
|
|
|
170
170
|
---
|
|
171
171
|
|
|
@@ -1413,12 +1413,23 @@ const currentTasksByAgent = new Map();
|
|
|
1413
1413
|
* @returns A promise resolving to a {@link ToolResult}.
|
|
1414
1414
|
*/
|
|
1415
1415
|
export async function executeTool(workspaceRoot, toolName, args, abortSignal) {
|
|
1416
|
+
// Normalize common LLM tool alias hallucinations to registered tool names
|
|
1417
|
+
let normalizedToolName = toolName;
|
|
1418
|
+
if (normalizedToolName === 'search' || normalizedToolName === 'web_search') {
|
|
1419
|
+
normalizedToolName = (args.query || args.searchQuery || args.url) ? 'perform_web_search' : 'grep_search';
|
|
1420
|
+
}
|
|
1421
|
+
else if (normalizedToolName === 'grep' || normalizedToolName === 'search_codebase') {
|
|
1422
|
+
normalizedToolName = 'grep_search';
|
|
1423
|
+
}
|
|
1424
|
+
else if (normalizedToolName === 'finish_investigation') {
|
|
1425
|
+
normalizedToolName = 'finish_task';
|
|
1426
|
+
}
|
|
1416
1427
|
// ─── Multi-Workspace Path Resolution ─────────────────────────────
|
|
1417
1428
|
// Intercept @alias/ prefixed paths and swap workspaceRoot + relative path
|
|
1418
1429
|
// before dispatching to the underlying tool functions (which remain unchanged).
|
|
1419
|
-
const { effectiveRoot, resolvedArgs } = resolveWorkspaceArgs(workspaceRoot,
|
|
1430
|
+
const { effectiveRoot, resolvedArgs } = resolveWorkspaceArgs(workspaceRoot, normalizedToolName, args);
|
|
1420
1431
|
let result;
|
|
1421
|
-
switch (
|
|
1432
|
+
switch (normalizedToolName) {
|
|
1422
1433
|
case 'perform_web_search': {
|
|
1423
1434
|
const { createWebSearchAgentSession } = await import('./ai.js');
|
|
1424
1435
|
const webSession = createWebSearchAgentSession();
|
package/dist/services/agent.d.ts
CHANGED
|
@@ -112,24 +112,14 @@ export declare function executeSingleTurn(workspaceRoot: string, userInput: stri
|
|
|
112
112
|
} | void>;
|
|
113
113
|
/**
|
|
114
114
|
* Raw chat history entry count threshold to trigger chat history summarization.
|
|
115
|
-
*
|
|
116
|
-
*/
|
|
117
|
-
export declare const HISTORY_SUMMARIZATION_THRESHOLD = 12;
|
|
118
|
-
/**
|
|
119
|
-
* Checks and summarizes older chat history entries if the active session's history
|
|
120
|
-
* exceeds HISTORY_SUMMARIZATION_THRESHOLD entries.
|
|
121
|
-
*
|
|
122
|
-
* Preserves the most recent 4 entries (2 turns) intact for conversational continuity,
|
|
123
|
-
* replacing all preceding turns with a single summarized pair in the chat history.
|
|
124
|
-
*
|
|
125
|
-
* @param chat - The active ProxyChatSession instance.
|
|
126
|
-
* @returns True if history was summarized and updated; false otherwise.
|
|
115
|
+
* 50 entries = 25 conversational user/model turns.
|
|
127
116
|
*/
|
|
117
|
+
export declare const HISTORY_SUMMARIZATION_THRESHOLD = 50;
|
|
128
118
|
/**
|
|
129
119
|
* Summarizes older chat history entries if the active session's history
|
|
130
120
|
* exceeds {@link HISTORY_SUMMARIZATION_THRESHOLD} entries.
|
|
131
121
|
*
|
|
132
|
-
* Preserves the most recent
|
|
122
|
+
* Preserves the most recent 20 entries (10 turns) intact for conversational continuity,
|
|
133
123
|
* replacing all preceding turns with a single summarized pair in the chat history.
|
|
134
124
|
*
|
|
135
125
|
* @param chat - The active ProxyChatSession instance.
|
package/dist/services/agent.js
CHANGED
|
@@ -152,7 +152,7 @@ export async function startAgentLoop(workspaceRoot, version) {
|
|
|
152
152
|
process.stdout.write('\n\n\n\x1b[3A');
|
|
153
153
|
console.log(pc.dim('\nType your coding request below. Type "exit" or "quit" to leave.\n'));
|
|
154
154
|
while (true) {
|
|
155
|
-
if (chat.
|
|
155
|
+
if (chat.getFullHistory().length === 0 && chatSessionState.title !== '') {
|
|
156
156
|
chatSessionState.id = crypto.randomUUID();
|
|
157
157
|
chatSessionState.title = '';
|
|
158
158
|
chatSessionState.totalTokens = 0;
|
|
@@ -466,19 +466,47 @@ export async function executeSingleTurn(workspaceRoot, userInput, chat, inputHan
|
|
|
466
466
|
const orchestrator = new Orchestrator(workspaceRoot, chatSessionState.id, inputHandler);
|
|
467
467
|
const handledByOrchestrator = await orchestrator.runOrchestration(finalInput, dynamicSystemInstruction, ac.signal);
|
|
468
468
|
if (typeof handledByOrchestrator === 'string') {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
469
|
+
const isStopped = handledByOrchestrator.includes('Generation stopped') ||
|
|
470
|
+
handledByOrchestrator.includes('[Generation stopped');
|
|
471
|
+
if (isStopped) {
|
|
472
|
+
const currentChanges = changeLogger.getCurrentChangeSet()?.changes || [];
|
|
473
|
+
const modifiedFiles = currentChanges
|
|
474
|
+
.filter((c) => c.action === 'modify' || c.action === 'create' || c.action === 'delete')
|
|
475
|
+
.map((c) => `${c.action}: ${path.relative(workspaceRoot, c.filePath)}`);
|
|
476
|
+
let summaryMsg = '';
|
|
477
|
+
if (modifiedFiles.length > 0) {
|
|
478
|
+
summaryMsg = `Generation was stopped by the user before completion. Partial code changes made before stopping:\n- ${modifiedFiles.join('\n- ')}`;
|
|
479
|
+
p.log.warn(pc.yellow('\n⚠️ Generation stopped by user before completion.'));
|
|
480
|
+
p.log.info(pc.cyan('Partial code changes created/modified before stopping:'));
|
|
481
|
+
modifiedFiles.forEach((file) => p.log.message(` ${pc.dim('•')} ${file}`));
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
summaryMsg = 'Generation was stopped by the user before completion. No code files were modified before stopping.';
|
|
485
|
+
p.log.warn(pc.yellow('\n⚠️ Generation stopped by user before completion (no files were modified).'));
|
|
486
|
+
}
|
|
487
|
+
chat.addTurn({
|
|
488
|
+
role: 'user',
|
|
489
|
+
parts: [{ text: `[USER INTERRUPT] User issued a "stop" command during execution: "${userInput}"` }],
|
|
490
|
+
}, {
|
|
491
|
+
role: 'model',
|
|
492
|
+
parts: [
|
|
493
|
+
{
|
|
494
|
+
text: `[GENERATION INTERRUPTED BY USER] I acknowledge that generation was manually stopped by the user before task completion.\n${summaryMsg}`,
|
|
495
|
+
},
|
|
496
|
+
],
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
// Inject the task context into the main proxy chat session so the agent remembers what happened
|
|
501
|
+
// and so the full history state gets saved properly on disk.
|
|
502
|
+
chat.addTurn({
|
|
503
|
+
role: 'user',
|
|
504
|
+
parts: [{ text: userInput }],
|
|
505
|
+
}, {
|
|
506
|
+
role: 'model',
|
|
507
|
+
parts: [{ text: handledByOrchestrator }],
|
|
508
|
+
});
|
|
509
|
+
// Print the summary text just like single-agent mode
|
|
482
510
|
console.log(`\n${pc.blue('◆')} ${pc.bold('Minovative Mind')} ${pc.dim(`(Orchestrator)`)}\n`);
|
|
483
511
|
const cleanText = handledByOrchestrator.replace(/\n([ \t]*\n){2,}/g, '\n\n');
|
|
484
512
|
console.log(marked.parse(cleanText));
|
|
@@ -519,7 +547,7 @@ export async function executeSingleTurn(workspaceRoot, userInput, chat, inputHan
|
|
|
519
547
|
changeLogger.markComplete();
|
|
520
548
|
changeLogger.commitChangeSet();
|
|
521
549
|
// Auto-save chat history for the orchestrator turn
|
|
522
|
-
const history = chat.
|
|
550
|
+
const history = chat.getFullHistory();
|
|
523
551
|
if (history.length > 0) {
|
|
524
552
|
if (!chatSessionState.title) {
|
|
525
553
|
chatSessionState.title = 'Generating title...';
|
|
@@ -628,8 +656,38 @@ export async function executeSingleTurn(workspaceRoot, userInput, chat, inputHan
|
|
|
628
656
|
const turnEndTime = Date.now();
|
|
629
657
|
const turnDuration = ((turnEndTime - turnStartTime) / 1000).toFixed(1);
|
|
630
658
|
p.log.info(`${pc.dim('Generated in')} ${pc.cyan(turnDuration + 's')}`);
|
|
631
|
-
//
|
|
632
|
-
|
|
659
|
+
// Check if generation was stopped by user
|
|
660
|
+
const isStopped = finalText.includes('Generation stopped') ||
|
|
661
|
+
finalText.includes('[Generation stopped');
|
|
662
|
+
if (isStopped) {
|
|
663
|
+
const currentChanges = changeLogger.getCurrentChangeSet()?.changes || [];
|
|
664
|
+
const modifiedFiles = currentChanges
|
|
665
|
+
.filter((c) => c.action === 'modify' || c.action === 'create' || c.action === 'delete')
|
|
666
|
+
.map((c) => `${c.action}: ${path.relative(workspaceRoot, c.filePath)}`);
|
|
667
|
+
let summaryMsg = '';
|
|
668
|
+
if (modifiedFiles.length > 0) {
|
|
669
|
+
summaryMsg = `Generation was stopped by the user before completion. Partial code changes made before stopping:\n- ${modifiedFiles.join('\n- ')}`;
|
|
670
|
+
p.log.warn(pc.yellow('\n⚠️ Generation stopped by user before completion.'));
|
|
671
|
+
p.log.info(pc.cyan('Partial code changes created/modified before stopping:'));
|
|
672
|
+
modifiedFiles.forEach((file) => p.log.message(` ${pc.dim('•')} ${file}`));
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
summaryMsg = 'Generation was stopped by the user before completion. No code files were modified before stopping.';
|
|
676
|
+
p.log.warn(pc.yellow('\n⚠️ Generation stopped by user before completion (no files were modified).'));
|
|
677
|
+
}
|
|
678
|
+
chat.addTurn({
|
|
679
|
+
role: 'user',
|
|
680
|
+
parts: [{ text: `[USER INTERRUPT] User issued a "stop" command during execution: "${userInput}"` }],
|
|
681
|
+
}, {
|
|
682
|
+
role: 'model',
|
|
683
|
+
parts: [
|
|
684
|
+
{
|
|
685
|
+
text: `[GENERATION INTERRUPTED BY USER] I acknowledge that generation was manually stopped by the user before task completion.\n${summaryMsg}`,
|
|
686
|
+
},
|
|
687
|
+
],
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
633
691
|
changeLogger.markComplete();
|
|
634
692
|
}
|
|
635
693
|
if (usage) {
|
|
@@ -647,7 +705,7 @@ export async function executeSingleTurn(workspaceRoot, userInput, chat, inputHan
|
|
|
647
705
|
chatSessionState.totalCreditsUsed += usage.creditsUsed || 0;
|
|
648
706
|
}
|
|
649
707
|
// Auto-save chat history
|
|
650
|
-
const history = chat.
|
|
708
|
+
const history = chat.getFullHistory();
|
|
651
709
|
if (history.length > 0) {
|
|
652
710
|
if (!chatSessionState.title) {
|
|
653
711
|
chatSessionState.title = 'Generating title...';
|
|
@@ -884,24 +942,14 @@ async function compressContextFiles(workspaceRoot, contextResult) {
|
|
|
884
942
|
}
|
|
885
943
|
/**
|
|
886
944
|
* Raw chat history entry count threshold to trigger chat history summarization.
|
|
887
|
-
*
|
|
888
|
-
*/
|
|
889
|
-
export const HISTORY_SUMMARIZATION_THRESHOLD = 12;
|
|
890
|
-
/**
|
|
891
|
-
* Checks and summarizes older chat history entries if the active session's history
|
|
892
|
-
* exceeds HISTORY_SUMMARIZATION_THRESHOLD entries.
|
|
893
|
-
*
|
|
894
|
-
* Preserves the most recent 4 entries (2 turns) intact for conversational continuity,
|
|
895
|
-
* replacing all preceding turns with a single summarized pair in the chat history.
|
|
896
|
-
*
|
|
897
|
-
* @param chat - The active ProxyChatSession instance.
|
|
898
|
-
* @returns True if history was summarized and updated; false otherwise.
|
|
945
|
+
* 50 entries = 25 conversational user/model turns.
|
|
899
946
|
*/
|
|
947
|
+
export const HISTORY_SUMMARIZATION_THRESHOLD = 50;
|
|
900
948
|
/**
|
|
901
949
|
* Summarizes older chat history entries if the active session's history
|
|
902
950
|
* exceeds {@link HISTORY_SUMMARIZATION_THRESHOLD} entries.
|
|
903
951
|
*
|
|
904
|
-
* Preserves the most recent
|
|
952
|
+
* Preserves the most recent 20 entries (10 turns) intact for conversational continuity,
|
|
905
953
|
* replacing all preceding turns with a single summarized pair in the chat history.
|
|
906
954
|
*
|
|
907
955
|
* @param chat - The active ProxyChatSession instance.
|
|
@@ -913,8 +961,8 @@ export async function summarizeHistoryIfNeeded(chat) {
|
|
|
913
961
|
return false;
|
|
914
962
|
}
|
|
915
963
|
try {
|
|
916
|
-
// Keep the most recent
|
|
917
|
-
const recentCount =
|
|
964
|
+
// Keep the most recent 20 entries (10 full user/model turns) intact
|
|
965
|
+
const recentCount = 20;
|
|
918
966
|
const olderHistory = history.slice(0, history.length - recentCount);
|
|
919
967
|
const recentHistory = history.slice(history.length - recentCount);
|
|
920
968
|
debugLog(`Summarizing ${olderHistory.length} older chat history entries...`);
|
|
@@ -930,7 +978,7 @@ export async function summarizeHistoryIfNeeded(chat) {
|
|
|
930
978
|
parts: [{ text: 'Understood. I have reviewed and absorbed the summary of our preceding conversation.' }],
|
|
931
979
|
},
|
|
932
980
|
];
|
|
933
|
-
chat.
|
|
981
|
+
chat.loadCompressedHistory([...summaryContent, ...recentHistory]);
|
|
934
982
|
debugLog(`Chat history compressed successfully from ${history.length} to ${summaryContent.length + recentHistory.length} entries.`);
|
|
935
983
|
return true;
|
|
936
984
|
}
|
package/dist/services/ai.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export declare function setGlobalActiveModel(model: string): void;
|
|
|
5
5
|
export declare function getGlobalActiveModel(): string;
|
|
6
6
|
export declare class ProxyChatSession {
|
|
7
7
|
private history;
|
|
8
|
+
private fullHistory;
|
|
8
9
|
private modelName;
|
|
9
10
|
private systemInstruction;
|
|
10
11
|
private tools;
|
|
@@ -17,7 +18,10 @@ export declare class ProxyChatSession {
|
|
|
17
18
|
getModel(): string;
|
|
18
19
|
clearHistory(): void;
|
|
19
20
|
getRawHistory(): Content[];
|
|
21
|
+
getFullHistory(): Content[];
|
|
20
22
|
loadRawHistory(history: Content[]): void;
|
|
23
|
+
loadCompressedHistory(history: Content[]): void;
|
|
24
|
+
addTurn(userContent: Content, modelContent: Content): void;
|
|
21
25
|
/**
|
|
22
26
|
* Retrieves the most recent conversation history as a formatted string.
|
|
23
27
|
* Useful for passing conversation context to stateless background agents.
|
package/dist/services/ai.js
CHANGED
|
@@ -69,6 +69,7 @@ function truncatePartText(text) {
|
|
|
69
69
|
}
|
|
70
70
|
export class ProxyChatSession {
|
|
71
71
|
history = [];
|
|
72
|
+
fullHistory = [];
|
|
72
73
|
modelName;
|
|
73
74
|
systemInstruction;
|
|
74
75
|
tools;
|
|
@@ -95,25 +96,37 @@ export class ProxyChatSession {
|
|
|
95
96
|
}
|
|
96
97
|
clearHistory() {
|
|
97
98
|
this.history = [];
|
|
99
|
+
this.fullHistory = [];
|
|
98
100
|
}
|
|
99
101
|
getRawHistory() {
|
|
100
102
|
return this.history;
|
|
101
103
|
}
|
|
104
|
+
getFullHistory() {
|
|
105
|
+
return this.fullHistory;
|
|
106
|
+
}
|
|
102
107
|
loadRawHistory(history) {
|
|
108
|
+
this.history = [...history];
|
|
109
|
+
this.fullHistory = JSON.parse(JSON.stringify(history));
|
|
110
|
+
}
|
|
111
|
+
loadCompressedHistory(history) {
|
|
103
112
|
this.history = history;
|
|
104
113
|
}
|
|
114
|
+
addTurn(userContent, modelContent) {
|
|
115
|
+
this.history.push(userContent);
|
|
116
|
+
this.history.push(modelContent);
|
|
117
|
+
this.fullHistory.push(JSON.parse(JSON.stringify(userContent)));
|
|
118
|
+
this.fullHistory.push(JSON.parse(JSON.stringify(modelContent)));
|
|
119
|
+
}
|
|
105
120
|
/**
|
|
106
121
|
* Retrieves the most recent conversation history as a formatted string.
|
|
107
122
|
* Useful for passing conversation context to stateless background agents.
|
|
108
123
|
* @param turns Number of back-and-forth turns (user + model pair = 1 turn) to retrieve.
|
|
109
124
|
*/
|
|
110
125
|
getRecentHistory(turns = 2) {
|
|
111
|
-
if (this.history.length === 0)
|
|
126
|
+
if (this.fullHistory.length === 0 && this.history.length === 0)
|
|
112
127
|
return '';
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// We'll just grab the last N * 2 items.
|
|
116
|
-
const recentItems = this.history.slice(-(turns * 2));
|
|
128
|
+
const sourceHistory = this.fullHistory.length > 0 ? this.fullHistory : this.history;
|
|
129
|
+
const recentItems = sourceHistory.slice(-(turns * 2));
|
|
117
130
|
let formattedHistory = '';
|
|
118
131
|
for (const item of recentItems) {
|
|
119
132
|
const role = item.role === 'user' ? 'User' : 'Assistant';
|
|
@@ -134,6 +147,10 @@ export class ProxyChatSession {
|
|
|
134
147
|
this.history.pop();
|
|
135
148
|
this.history.pop();
|
|
136
149
|
}
|
|
150
|
+
if (this.fullHistory.length >= 2) {
|
|
151
|
+
this.fullHistory.pop();
|
|
152
|
+
this.fullHistory.pop();
|
|
153
|
+
}
|
|
137
154
|
}
|
|
138
155
|
sessionId;
|
|
139
156
|
workspaceRoot;
|
|
@@ -148,12 +165,15 @@ export class ProxyChatSession {
|
|
|
148
165
|
*/
|
|
149
166
|
async pruneHistory() {
|
|
150
167
|
if (this.history.length > MAX_HISTORY_ENTRIES) {
|
|
151
|
-
// Always keep pairs aligned (user/model), so trim from the front
|
|
152
168
|
const excess = this.history.length - MAX_HISTORY_ENTRIES;
|
|
153
|
-
// Round up to the nearest even number to keep user/model pairs intact
|
|
154
169
|
const trimCount = excess % 2 === 0 ? excess : excess + 1;
|
|
155
|
-
const pruned = this.history.slice(0, trimCount);
|
|
156
170
|
this.history = this.history.slice(trimCount);
|
|
171
|
+
}
|
|
172
|
+
if (this.fullHistory.length > MAX_HISTORY_ENTRIES) {
|
|
173
|
+
const excess = this.fullHistory.length - MAX_HISTORY_ENTRIES;
|
|
174
|
+
const trimCount = excess % 2 === 0 ? excess : excess + 1;
|
|
175
|
+
const pruned = this.fullHistory.slice(0, trimCount);
|
|
176
|
+
this.fullHistory = this.fullHistory.slice(trimCount);
|
|
157
177
|
if (this.sessionId && this.workspaceRoot) {
|
|
158
178
|
const archiveFile = `archives/archived_history_${this.sessionId}.json`;
|
|
159
179
|
const existingArchive = readCache(this.workspaceRoot, archiveFile) || [];
|
|
@@ -199,10 +219,12 @@ export class ProxyChatSession {
|
|
|
199
219
|
if (additionalText) {
|
|
200
220
|
newParts.push({ text: truncatePartText(additionalText) });
|
|
201
221
|
}
|
|
202
|
-
|
|
222
|
+
const userEntry = {
|
|
203
223
|
role: 'user',
|
|
204
224
|
parts: newParts,
|
|
205
|
-
}
|
|
225
|
+
};
|
|
226
|
+
this.history.push(userEntry);
|
|
227
|
+
this.fullHistory.push(JSON.parse(JSON.stringify(userEntry)));
|
|
206
228
|
// Prune old history before sending to keep payload bounded
|
|
207
229
|
await this.pruneHistory();
|
|
208
230
|
const effectiveGenerationConfig = { ...this.generationConfig };
|
|
@@ -286,10 +308,12 @@ export class ProxyChatSession {
|
|
|
286
308
|
});
|
|
287
309
|
}
|
|
288
310
|
if (modelParts.length > 0) {
|
|
289
|
-
|
|
311
|
+
const modelEntry = {
|
|
290
312
|
role: 'model',
|
|
291
313
|
parts: modelParts,
|
|
292
|
-
}
|
|
314
|
+
};
|
|
315
|
+
this.history.push(modelEntry);
|
|
316
|
+
this.fullHistory.push(JSON.parse(JSON.stringify(modelEntry)));
|
|
293
317
|
// We must prune again if we just pushed new history
|
|
294
318
|
await this.pruneHistory();
|
|
295
319
|
}
|
|
@@ -35,6 +35,7 @@ export class MessageBus {
|
|
|
35
35
|
signals = [];
|
|
36
36
|
activityCursors = new Map();
|
|
37
37
|
signalCursors = new Map();
|
|
38
|
+
persistQueue = Promise.resolve();
|
|
38
39
|
persistPath;
|
|
39
40
|
workspaceRoot;
|
|
40
41
|
/** Maximum semantic signals any single agent can post */
|
|
@@ -172,8 +173,10 @@ export class MessageBus {
|
|
|
172
173
|
activityCursors: Object.fromEntries(this.activityCursors),
|
|
173
174
|
signalCursors: Object.fromEntries(this.signalCursors),
|
|
174
175
|
};
|
|
175
|
-
// Fire-and-forget — don't block the calling agent's tool loop
|
|
176
|
-
|
|
176
|
+
// Fire-and-forget — don't block the calling agent's tool loop, but serialize writes sequentially
|
|
177
|
+
this.persistQueue = this.persistQueue
|
|
178
|
+
.then(() => atomicWriteFile(this.persistPath, JSON.stringify(snapshot)))
|
|
179
|
+
.catch((err) => {
|
|
177
180
|
debugLog(`MessageBus: Failed to persist to disk: ${err}`);
|
|
178
181
|
});
|
|
179
182
|
}
|
|
@@ -5,11 +5,53 @@ export interface VerificationResult {
|
|
|
5
5
|
errors: string[];
|
|
6
6
|
aborted?: boolean;
|
|
7
7
|
}
|
|
8
|
+
export interface Mutant {
|
|
9
|
+
id: string;
|
|
10
|
+
filePath: string;
|
|
11
|
+
line: number;
|
|
12
|
+
originalCode: string;
|
|
13
|
+
mutatedCode: string;
|
|
14
|
+
operator: string;
|
|
15
|
+
}
|
|
16
|
+
export interface MutationTestResult {
|
|
17
|
+
totalMutants: number;
|
|
18
|
+
killedMutants: number;
|
|
19
|
+
survivedMutants: number;
|
|
20
|
+
mutationScore: number;
|
|
21
|
+
mutants: Array<Mutant & {
|
|
22
|
+
killed: boolean;
|
|
23
|
+
output?: string;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
8
26
|
export declare function detectVerificationCommand(workspaceRoot: string): Promise<string | null>;
|
|
9
27
|
export declare function runVerification(workspaceRoot: string, abortSignal?: AbortSignal): Promise<VerificationResult | null>;
|
|
10
28
|
export declare function formatVerificationForModel(result: VerificationResult): string;
|
|
11
29
|
export interface FullVerificationResult {
|
|
12
30
|
errors: string | null;
|
|
13
31
|
warnings: string | null;
|
|
32
|
+
mutationResult?: MutationTestResult | null;
|
|
14
33
|
}
|
|
15
34
|
export declare function verifyChangedFiles(workspaceRoot: string, filePaths: string[], abortSignal?: AbortSignal): Promise<FullVerificationResult | '[Verification Aborted]'>;
|
|
35
|
+
/**
|
|
36
|
+
* Generates mutants scoped specifically to modified lines of a source file.
|
|
37
|
+
*/
|
|
38
|
+
export declare function generateDiffScopedMutants(filePath: string, content: string, modifiedLines?: number[]): Mutant[];
|
|
39
|
+
/**
|
|
40
|
+
* Runs diff-scoped mutation testing on modified files within the workspace.
|
|
41
|
+
* Mutates target lines in-memory/temp copy, executes workspace tests, and ensures mutants are killed.
|
|
42
|
+
*/
|
|
43
|
+
export declare function runDiffScopedMutationTesting(workspaceRoot: string, filePaths: string[], modifiedLineMap?: Record<string, number[]>, abortSignal?: AbortSignal, options?: {
|
|
44
|
+
maxMutantsPerFile?: number;
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
}): Promise<MutationTestResult>;
|
|
47
|
+
/**
|
|
48
|
+
* Detects whether property-based testing framework configuration or dependencies exist in workspace.
|
|
49
|
+
*/
|
|
50
|
+
export declare function detectPropertyBasedTests(workspaceRoot: string): Promise<{
|
|
51
|
+
hasPbt: boolean;
|
|
52
|
+
framework?: string;
|
|
53
|
+
}>;
|
|
54
|
+
/**
|
|
55
|
+
* Executes workspace property-based tests if detected.
|
|
56
|
+
*/
|
|
57
|
+
export declare function runPropertyBasedVerification(workspaceRoot: string, abortSignal?: AbortSignal): Promise<VerificationResult | null>;
|
|
@@ -299,3 +299,238 @@ export async function verifyChangedFiles(workspaceRoot, filePaths, abortSignal)
|
|
|
299
299
|
warnings: terminalWarnings || null,
|
|
300
300
|
};
|
|
301
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* Common mutation operators for AST/line-level diff mutation testing.
|
|
304
|
+
*/
|
|
305
|
+
const MUTATION_OPERATORS = [
|
|
306
|
+
{
|
|
307
|
+
name: 'EqualityReplacement',
|
|
308
|
+
pattern: /(===|!==|==|!=)/g,
|
|
309
|
+
replace: (m) => {
|
|
310
|
+
if (m === '===')
|
|
311
|
+
return '!==';
|
|
312
|
+
if (m === '!==')
|
|
313
|
+
return '===';
|
|
314
|
+
if (m === '==')
|
|
315
|
+
return '!=';
|
|
316
|
+
if (m === '!=')
|
|
317
|
+
return '==';
|
|
318
|
+
return m;
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
name: 'RelationalReplacement',
|
|
323
|
+
pattern: /(>=|<=|>|<)/g,
|
|
324
|
+
replace: (m) => {
|
|
325
|
+
if (m === '>=')
|
|
326
|
+
return '<';
|
|
327
|
+
if (m === '<=')
|
|
328
|
+
return '>';
|
|
329
|
+
if (m === '>')
|
|
330
|
+
return '<=';
|
|
331
|
+
if (m === '<')
|
|
332
|
+
return '>=';
|
|
333
|
+
return m;
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
name: 'ArithmeticReplacement',
|
|
338
|
+
pattern: /(\+|\-|\*|\/)/g,
|
|
339
|
+
replace: (m) => {
|
|
340
|
+
if (m === '+')
|
|
341
|
+
return '-';
|
|
342
|
+
if (m === '-')
|
|
343
|
+
return '+';
|
|
344
|
+
if (m === '*')
|
|
345
|
+
return '/';
|
|
346
|
+
if (m === '/')
|
|
347
|
+
return '*';
|
|
348
|
+
return m;
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
name: 'LogicalReplacement',
|
|
353
|
+
pattern: /(\&\&|\|\|)/g,
|
|
354
|
+
replace: (m) => (m === '&&' ? '||' : '&&'),
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
name: 'BooleanLiteralInversion',
|
|
358
|
+
pattern: /\b(true|false)\b/g,
|
|
359
|
+
replace: (m) => (m === 'true' ? 'false' : 'true'),
|
|
360
|
+
},
|
|
361
|
+
];
|
|
362
|
+
/**
|
|
363
|
+
* Generates mutants scoped specifically to modified lines of a source file.
|
|
364
|
+
*/
|
|
365
|
+
export function generateDiffScopedMutants(filePath, content, modifiedLines) {
|
|
366
|
+
const mutants = [];
|
|
367
|
+
const lines = content.split('\n');
|
|
368
|
+
const lineNumbers = modifiedLines && modifiedLines.length > 0
|
|
369
|
+
? modifiedLines
|
|
370
|
+
: lines.map((_, i) => i + 1);
|
|
371
|
+
let idCounter = 1;
|
|
372
|
+
for (const lineNum of lineNumbers) {
|
|
373
|
+
if (lineNum < 1 || lineNum > lines.length)
|
|
374
|
+
continue;
|
|
375
|
+
const origLine = lines[lineNum - 1];
|
|
376
|
+
// Skip empty lines, comments, or imports/exports
|
|
377
|
+
const trimmed = origLine.trim();
|
|
378
|
+
if (!trimmed ||
|
|
379
|
+
trimmed.startsWith('//') ||
|
|
380
|
+
trimmed.startsWith('/*') ||
|
|
381
|
+
trimmed.startsWith('*') ||
|
|
382
|
+
trimmed.startsWith('import ') ||
|
|
383
|
+
trimmed.startsWith('export ') ||
|
|
384
|
+
trimmed.startsWith('#')) {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
for (const op of MUTATION_OPERATORS) {
|
|
388
|
+
op.pattern.lastIndex = 0;
|
|
389
|
+
let match;
|
|
390
|
+
while ((match = op.pattern.exec(origLine)) !== null) {
|
|
391
|
+
const start = match.index;
|
|
392
|
+
const matchedText = match[0];
|
|
393
|
+
const replacement = op.replace(matchedText);
|
|
394
|
+
if (replacement === matchedText)
|
|
395
|
+
continue;
|
|
396
|
+
const mutatedLine = origLine.substring(0, start) + replacement + origLine.substring(start + matchedText.length);
|
|
397
|
+
mutants.push({
|
|
398
|
+
id: `mutant-${idCounter++}`,
|
|
399
|
+
filePath,
|
|
400
|
+
line: lineNum,
|
|
401
|
+
originalCode: origLine,
|
|
402
|
+
mutatedCode: mutatedLine,
|
|
403
|
+
operator: op.name,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return mutants;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Runs diff-scoped mutation testing on modified files within the workspace.
|
|
412
|
+
* Mutates target lines in-memory/temp copy, executes workspace tests, and ensures mutants are killed.
|
|
413
|
+
*/
|
|
414
|
+
export async function runDiffScopedMutationTesting(workspaceRoot, filePaths, modifiedLineMap, abortSignal, options) {
|
|
415
|
+
const maxPerFile = options?.maxMutantsPerFile ?? 10;
|
|
416
|
+
const allMutants = [];
|
|
417
|
+
let totalKilled = 0;
|
|
418
|
+
for (const relPath of filePaths) {
|
|
419
|
+
if (abortSignal?.aborted)
|
|
420
|
+
break;
|
|
421
|
+
const fullPath = path.resolve(workspaceRoot, relPath);
|
|
422
|
+
let originalContent;
|
|
423
|
+
try {
|
|
424
|
+
originalContent = await fs.readFile(fullPath, 'utf-8');
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const modifiedLines = modifiedLineMap ? modifiedLineMap[relPath] : undefined;
|
|
430
|
+
const mutants = generateDiffScopedMutants(relPath, originalContent, modifiedLines).slice(0, maxPerFile);
|
|
431
|
+
const lines = originalContent.split('\n');
|
|
432
|
+
for (const mutant of mutants) {
|
|
433
|
+
if (abortSignal?.aborted)
|
|
434
|
+
break;
|
|
435
|
+
// Create mutated file content
|
|
436
|
+
const mutatedLines = [...lines];
|
|
437
|
+
mutatedLines[mutant.line - 1] = mutant.mutatedCode;
|
|
438
|
+
const mutatedContent = mutatedLines.join('\n');
|
|
439
|
+
let killed = false;
|
|
440
|
+
let output = '';
|
|
441
|
+
try {
|
|
442
|
+
// Safely write mutated content with try...finally cleanup
|
|
443
|
+
await fs.writeFile(fullPath, mutatedContent, 'utf-8');
|
|
444
|
+
// Execute verification/test command
|
|
445
|
+
const testResult = await runVerification(workspaceRoot, abortSignal);
|
|
446
|
+
if (testResult && !testResult.success) {
|
|
447
|
+
killed = true;
|
|
448
|
+
output = testResult.errors.join('\n').substring(0, 500);
|
|
449
|
+
}
|
|
450
|
+
else if (!testResult) {
|
|
451
|
+
// No test runner detected; default to non-kill unless error occurs
|
|
452
|
+
killed = false;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
killed = true;
|
|
457
|
+
output = err.message || String(err);
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
// Restore original file unconditionally
|
|
461
|
+
try {
|
|
462
|
+
await fs.writeFile(fullPath, originalContent, 'utf-8');
|
|
463
|
+
}
|
|
464
|
+
catch (restoreErr) {
|
|
465
|
+
debugLog(`Failed to restore ${fullPath} after mutation testing: ${restoreErr}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (killed)
|
|
469
|
+
totalKilled++;
|
|
470
|
+
allMutants.push({ ...mutant, killed, output });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const total = allMutants.length;
|
|
474
|
+
const score = total > 0 ? Math.round((totalKilled / total) * 100) : 100;
|
|
475
|
+
return {
|
|
476
|
+
totalMutants: total,
|
|
477
|
+
killedMutants: totalKilled,
|
|
478
|
+
survivedMutants: total - totalKilled,
|
|
479
|
+
mutationScore: score,
|
|
480
|
+
mutants: allMutants,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Detects whether property-based testing framework configuration or dependencies exist in workspace.
|
|
485
|
+
*/
|
|
486
|
+
export async function detectPropertyBasedTests(workspaceRoot) {
|
|
487
|
+
// Check package.json for fast-check or jsverify
|
|
488
|
+
try {
|
|
489
|
+
const pkgStr = await fs.readFile(path.join(workspaceRoot, 'package.json'), 'utf-8');
|
|
490
|
+
if (pkgStr.includes('fast-check'))
|
|
491
|
+
return { hasPbt: true, framework: 'fast-check' };
|
|
492
|
+
if (pkgStr.includes('jsverify'))
|
|
493
|
+
return { hasPbt: true, framework: 'jsverify' };
|
|
494
|
+
}
|
|
495
|
+
catch { }
|
|
496
|
+
// Check Python environment for hypothesis
|
|
497
|
+
try {
|
|
498
|
+
const reqStr = await fs.readFile(path.join(workspaceRoot, 'requirements.txt'), 'utf-8');
|
|
499
|
+
if (reqStr.includes('hypothesis'))
|
|
500
|
+
return { hasPbt: true, framework: 'hypothesis' };
|
|
501
|
+
}
|
|
502
|
+
catch { }
|
|
503
|
+
try {
|
|
504
|
+
const pyprojStr = await fs.readFile(path.join(workspaceRoot, 'pyproject.toml'), 'utf-8');
|
|
505
|
+
if (pyprojStr.includes('hypothesis'))
|
|
506
|
+
return { hasPbt: true, framework: 'hypothesis' };
|
|
507
|
+
}
|
|
508
|
+
catch { }
|
|
509
|
+
// Check Cargo.toml for proptest / quickcheck
|
|
510
|
+
try {
|
|
511
|
+
const cargoStr = await fs.readFile(path.join(workspaceRoot, 'Cargo.toml'), 'utf-8');
|
|
512
|
+
if (cargoStr.includes('proptest'))
|
|
513
|
+
return { hasPbt: true, framework: 'proptest' };
|
|
514
|
+
if (cargoStr.includes('quickcheck'))
|
|
515
|
+
return { hasPbt: true, framework: 'quickcheck' };
|
|
516
|
+
}
|
|
517
|
+
catch { }
|
|
518
|
+
// Check go.mod for rapid
|
|
519
|
+
try {
|
|
520
|
+
const goModStr = await fs.readFile(path.join(workspaceRoot, 'go.mod'), 'utf-8');
|
|
521
|
+
if (goModStr.includes('pgregory.net/rapid'))
|
|
522
|
+
return { hasPbt: true, framework: 'rapid' };
|
|
523
|
+
}
|
|
524
|
+
catch { }
|
|
525
|
+
return { hasPbt: false };
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Executes workspace property-based tests if detected.
|
|
529
|
+
*/
|
|
530
|
+
export async function runPropertyBasedVerification(workspaceRoot, abortSignal) {
|
|
531
|
+
const pbtInfo = await detectPropertyBasedTests(workspaceRoot);
|
|
532
|
+
if (!pbtInfo.hasPbt)
|
|
533
|
+
return null;
|
|
534
|
+
debugLog(`Detected property-based testing framework: ${pbtInfo.framework}`);
|
|
535
|
+
return runVerification(workspaceRoot, abortSignal);
|
|
536
|
+
}
|
|
@@ -11,6 +11,18 @@ export interface EphemeralScriptOptions {
|
|
|
11
11
|
/** AbortSignal to cancel execution. */
|
|
12
12
|
abortSignal?: AbortSignal;
|
|
13
13
|
}
|
|
14
|
+
export interface PropertyTestConfig {
|
|
15
|
+
numRuns?: number;
|
|
16
|
+
seed?: number;
|
|
17
|
+
shrinkTimeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface PropertyTestResult extends EphemeralScriptResult {
|
|
20
|
+
passed: boolean;
|
|
21
|
+
counterexample?: string;
|
|
22
|
+
shrunkInput?: string;
|
|
23
|
+
seed?: number;
|
|
24
|
+
numRunsCompleted?: number;
|
|
25
|
+
}
|
|
14
26
|
/**
|
|
15
27
|
* Normalizes user/AI provided language string to a standard runtime identifier.
|
|
16
28
|
*/
|
|
@@ -50,3 +62,11 @@ export declare function runEphemeralScript(workspaceRoot: string, language: stri
|
|
|
50
62
|
* @param options - Execution options (timeoutMs, maxOutputChars, abortSignal).
|
|
51
63
|
*/
|
|
52
64
|
export declare function runDebugScript(workspaceRoot: string, code: string, language?: string | EphemeralScriptOptions, options?: EphemeralScriptOptions): Promise<EphemeralScriptResult>;
|
|
65
|
+
/**
|
|
66
|
+
* Generates an ephemeral Property-Based Testing script template tailored to the target language and properties.
|
|
67
|
+
*/
|
|
68
|
+
export declare function generatePBTScriptTemplate(language: string, targetFunctionOrFeature: string, properties: string[]): string;
|
|
69
|
+
/**
|
|
70
|
+
* Runs an ephemeral Property-Based Test script and extracts failure counterexamples and shrink results.
|
|
71
|
+
*/
|
|
72
|
+
export declare function runPropertyBasedTest(workspaceRoot: string, code: string, language?: string | EphemeralScriptOptions, config?: PropertyTestConfig & EphemeralScriptOptions): Promise<PropertyTestResult>;
|
|
@@ -207,3 +207,120 @@ export async function runDebugScript(workspaceRoot, code, language = 'node', opt
|
|
|
207
207
|
}
|
|
208
208
|
return runEphemeralScript(workspaceRoot, lang, code, opts);
|
|
209
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Generates an ephemeral Property-Based Testing script template tailored to the target language and properties.
|
|
212
|
+
*/
|
|
213
|
+
export function generatePBTScriptTemplate(language, targetFunctionOrFeature, properties) {
|
|
214
|
+
const normLang = normalizeLanguage(language);
|
|
215
|
+
if (normLang === 'python') {
|
|
216
|
+
return `import sys, random
|
|
217
|
+
|
|
218
|
+
# Target: ${targetFunctionOrFeature}
|
|
219
|
+
# Properties to verify:
|
|
220
|
+
${properties.map((p) => `# - ${p}`).join('\n')}
|
|
221
|
+
|
|
222
|
+
def property_test():
|
|
223
|
+
runs = 100
|
|
224
|
+
for i in range(runs):
|
|
225
|
+
# Generate random inputs
|
|
226
|
+
val_int = random.randint(-10000, 10000)
|
|
227
|
+
val_str = "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=random.randint(0, 50)))
|
|
228
|
+
|
|
229
|
+
# Test property invariants
|
|
230
|
+
try:
|
|
231
|
+
# Assertion placeholder
|
|
232
|
+
assert isinstance(val_int, int)
|
|
233
|
+
except AssertionError as e:
|
|
234
|
+
print(f"[PBT FAILED] Seed/Iteration: {i}")
|
|
235
|
+
print(f"Counterexample: val_int={val_int}, val_str={val_str!r}")
|
|
236
|
+
sys.exit(1)
|
|
237
|
+
|
|
238
|
+
print(f"[PBT PASSED] Completed {runs} runs successfully.")
|
|
239
|
+
|
|
240
|
+
if __name__ == "__main__":
|
|
241
|
+
property_test()
|
|
242
|
+
`;
|
|
243
|
+
}
|
|
244
|
+
// Default Node / TypeScript / JS template
|
|
245
|
+
return `// Target: ${targetFunctionOrFeature}
|
|
246
|
+
// Properties to verify:
|
|
247
|
+
${properties.map((p) => `// - ${p}`).join('\n')}
|
|
248
|
+
|
|
249
|
+
async function runPBT() {
|
|
250
|
+
const numRuns = 100;
|
|
251
|
+
let passed = 0;
|
|
252
|
+
|
|
253
|
+
for (let i = 0; i < numRuns; i++) {
|
|
254
|
+
// Generate pseudo-random inputs
|
|
255
|
+
const randInt = Math.floor(Math.random() * 20000) - 10000;
|
|
256
|
+
const randStr = Math.random().toString(36).substring(2);
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
// Test property invariant
|
|
260
|
+
if (typeof randInt !== 'number' || isNaN(randInt)) {
|
|
261
|
+
throw new Error('Type invariant failed');
|
|
262
|
+
}
|
|
263
|
+
passed++;
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.log(\`[PBT FAILED] Iteration: \${i}\`);
|
|
266
|
+
console.log(\`Counterexample: randInt=\${randInt}, randStr="\${randStr}"\`);
|
|
267
|
+
console.log(\`Error: \${err.message}\`);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
console.log(\`[PBT PASSED] Completed \${passed} runs successfully.\`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
runPBT();
|
|
276
|
+
`;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Runs an ephemeral Property-Based Test script and extracts failure counterexamples and shrink results.
|
|
280
|
+
*/
|
|
281
|
+
export async function runPropertyBasedTest(workspaceRoot, code, language = 'node', config) {
|
|
282
|
+
let lang = 'node';
|
|
283
|
+
let opts;
|
|
284
|
+
if (typeof language === 'string') {
|
|
285
|
+
lang = language;
|
|
286
|
+
opts = config;
|
|
287
|
+
}
|
|
288
|
+
else if (typeof language === 'object' && language !== null) {
|
|
289
|
+
opts = language;
|
|
290
|
+
}
|
|
291
|
+
const scriptResult = await runEphemeralScript(workspaceRoot, lang, code, opts);
|
|
292
|
+
const combinedOutput = `${scriptResult.stdout}\n${scriptResult.stderr}`;
|
|
293
|
+
const isPassed = combinedOutput.includes('[PBT PASSED]') || (scriptResult.exitCode === 0 && !combinedOutput.includes('[PBT FAILED]'));
|
|
294
|
+
let counterexample;
|
|
295
|
+
let shrunkInput;
|
|
296
|
+
let seed;
|
|
297
|
+
let numRunsCompleted;
|
|
298
|
+
// Extract counterexample
|
|
299
|
+
const counterMatch = combinedOutput.match(/Counterexample:\s*([^\n]+)/i);
|
|
300
|
+
if (counterMatch) {
|
|
301
|
+
counterexample = counterMatch[1].trim();
|
|
302
|
+
}
|
|
303
|
+
// Extract shrunk input
|
|
304
|
+
const shrinkMatch = combinedOutput.match(/(?:Shrunk Input|Shrunk|Minimal):\s*([^\n]+)/i);
|
|
305
|
+
if (shrinkMatch) {
|
|
306
|
+
shrunkInput = shrinkMatch[1].trim();
|
|
307
|
+
}
|
|
308
|
+
// Extract seed
|
|
309
|
+
const seedMatch = combinedOutput.match(/Seed(?:|\/Iteration):\s*(\d+)/i);
|
|
310
|
+
if (seedMatch) {
|
|
311
|
+
seed = parseInt(seedMatch[1], 10);
|
|
312
|
+
}
|
|
313
|
+
// Extract completed runs
|
|
314
|
+
const runsMatch = combinedOutput.match(/Completed\s+(\d+)\s+runs/i);
|
|
315
|
+
if (runsMatch) {
|
|
316
|
+
numRunsCompleted = parseInt(runsMatch[1], 10);
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
...scriptResult,
|
|
320
|
+
passed: isPassed,
|
|
321
|
+
counterexample,
|
|
322
|
+
shrunkInput,
|
|
323
|
+
seed,
|
|
324
|
+
numRunsCompleted,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
@@ -7,7 +7,8 @@ import path from 'node:path';
|
|
|
7
7
|
*/
|
|
8
8
|
export async function atomicWriteFile(targetPath, data, encoding = 'utf-8') {
|
|
9
9
|
const dir = path.dirname(targetPath);
|
|
10
|
-
const
|
|
10
|
+
const randomSuffix = Math.random().toString(36).slice(2, 8);
|
|
11
|
+
const tempPath = path.join(dir, `.${path.basename(targetPath)}.${Date.now()}.${randomSuffix}.tmp`);
|
|
11
12
|
try {
|
|
12
13
|
// Ensure the directory exists
|
|
13
14
|
await fs.mkdir(dir, { recursive: true });
|
package/oclif.manifest.json
CHANGED
package/package.json
CHANGED