triage-trip 0.1.0 → 0.1.3
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 +87 -110
- package/bin/cli.js +12 -7
- package/package.json +2 -2
- package/src/git.js +130 -3
- package/src/patch.js +662 -73
- package/src/triage.js +31 -3
package/README.md
CHANGED
|
@@ -1,146 +1,123 @@
|
|
|
1
|
-
|
|
1
|
+
Markdown
|
|
2
|
+
# triage-trip
|
|
2
3
|
|
|
3
|
-
> Autonomous
|
|
4
|
+
> Autonomous CLI to pinpoint and patch regressions introduced by AI code assistants (Cursor, Copilot).
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
<img src="./assets/demo.gif" alt="AI-Blackbox Demo" width="700">
|
|
7
|
-
</p>
|
|
8
|
-
---
|
|
6
|
+

|
|
9
7
|
|
|
10
8
|
## The Problem
|
|
9
|
+
When you use AI coding assistants to refactor or draft features, errors often slip through:
|
|
10
|
+
- Subtle schema property renames (`userId` ➔ `user_id`).
|
|
11
|
+
- Dropped function arguments or breaking contract shifts.
|
|
12
|
+
- Regressions that aren't caught until 3 commits later.
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
* An AI renames an object key or subtle schema attribute (e.g., `userId` ➔ `user_id`).
|
|
15
|
-
* Standard linters and compilers pass because the syntax is completely valid.
|
|
16
|
-
* Downstream modules crash 2–3 commits later with cryptic runtime errors (`TypeError: Cannot read properties of undefined`).
|
|
17
|
-
* Developers spend 20–30 minutes manually inspecting multi-file diffs, running `git bisect`, and searching for what changed.
|
|
18
|
-
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
## What `ai-blackbox` Does
|
|
22
|
-
|
|
23
|
-
`ai-blackbox` connects the dots across your development environment to trace the exact lineage of a regression:
|
|
24
|
-
|
|
25
|
-
```text
|
|
26
|
-
[ User Prompt in IDE ] ➔ [ AI Generated Diff ] ➔ [ Git Commit ] ➔ [ Runtime Error ]
|
|
27
|
-
│
|
|
28
|
-
ai-blackbox triage
|
|
29
|
-
│
|
|
30
|
-
▼
|
|
31
|
-
• Exact Root Cause & Commit
|
|
32
|
-
• Attributed AI Prompt
|
|
33
|
-
• Semantic Contract Shift
|
|
34
|
-
• Clean Unified Diff Patch
|
|
14
|
+
Developers lose 30+ minutes digging through hundreds of lines of AI diffs trying to figure out which prompt broke the build.
|
|
35
15
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
1. **
|
|
39
|
-
2. **
|
|
40
|
-
3. **
|
|
16
|
+
## How It Works
|
|
17
|
+
`triage-trip` connects runtime failures back to their origin:
|
|
18
|
+
1. **Reads Local Git Stream:** Analyzes unstaged changes and recent commit diffs.
|
|
19
|
+
2. **Inspects Cursor Provenance:** Reads local IDE session SQLite databases (`state.vscdb`) to identify the exact human prompt that generated the regression.
|
|
20
|
+
3. **Isolates Semantic Shifts:** Determines contract and schema breaks.
|
|
21
|
+
4. **Interactive In-Memory Patching:** Applies clean unified diffs directly to your working tree via `git apply` with a single confirmation.
|
|
41
22
|
|
|
42
23
|
---
|
|
43
24
|
|
|
44
|
-
##
|
|
45
|
-
|
|
46
|
-
### Prerequisites
|
|
25
|
+
## Quick Start
|
|
47
26
|
|
|
48
|
-
|
|
49
|
-
* An active **OpenAI API Key**
|
|
50
|
-
|
|
51
|
-
### Local Setup
|
|
27
|
+
Run instantly without global installation:
|
|
52
28
|
|
|
53
29
|
```bash
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
30
|
+
npx triage-trip triage "TypeError: Cannot read properties of undefined (reading 'toUpperCase')"
|
|
31
|
+
Or install globally:
|
|
32
|
+
|
|
33
|
+
Bash
|
|
34
|
+
npm install -g triage-trip
|
|
35
|
+
Environment Setup
|
|
36
|
+
Set your preferred LLM API key:
|
|
37
|
+
|
|
38
|
+
Bash
|
|
39
|
+
export ANTHROPIC_API_KEY="your-anthropic-key"
|
|
40
|
+
# or
|
|
41
|
+
export OPENAI_API_KEY="your-openai-key"
|
|
42
|
+
Usage
|
|
43
|
+
Diagnose an Error Trace
|
|
44
|
+
Bash
|
|
45
|
+
triage-trip triage "Cannot read properties of undefined (reading 'toUpperCase')"
|
|
46
|
+
Auto-Apply Patches Instantly
|
|
47
|
+
Skip the confirmation prompt to patch immediately:
|
|
48
|
+
|
|
49
|
+
Bash
|
|
50
|
+
triage-trip triage "ReferenceError: getUserProfile is not defined" --yes
|
|
51
|
+
Inspect More Commits
|
|
52
|
+
Bash
|
|
53
|
+
triage-trip triage "SyntaxError: Unexpected token" --commits 10
|
|
54
|
+
License
|
|
55
|
+
MIT
|
|
63
56
|
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
Set your OpenAI API key in your terminal environment or add it to a `.env` file:
|
|
67
|
-
|
|
68
|
-
```bash
|
|
69
|
-
export OPENAI_API_KEY="sk-..."
|
|
70
|
-
|
|
71
|
-
```
|
|
72
57
|
|
|
73
58
|
---
|
|
74
59
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
Run the triage engine directly against any runtime error message or stack trace:
|
|
60
|
+
### Step 3: Commit and Publish
|
|
78
61
|
|
|
79
62
|
```bash
|
|
80
|
-
#
|
|
81
|
-
|
|
63
|
+
# Stage the updated doc and new GIF
|
|
64
|
+
git add README.md assets/demo.gif package.json bin/cli.js
|
|
82
65
|
|
|
83
|
-
#
|
|
84
|
-
|
|
66
|
+
# Commit
|
|
67
|
+
git commit -m "docs: rebrand to triage-trip with new demo gif"
|
|
85
68
|
|
|
86
|
-
|
|
69
|
+
# Push to your remote
|
|
70
|
+
git push origin main
|
|
87
71
|
|
|
88
|
-
|
|
72
|
+
# Publish live on npm
|
|
73
|
+
npm publish --access public
|
|
89
74
|
|
|
90
|
-
* `<query_or_error>` *(required)*: The error message or failing test description.
|
|
91
|
-
* `-c, --commits <number>`: Number of recent commits to analyze (default: `5`).
|
|
92
75
|
|
|
93
|
-
|
|
76
|
+
## Inspiration & Ecosystem
|
|
94
77
|
|
|
95
|
-
|
|
78
|
+
Large engineering organizations (like Uber with their internal autonomous debugging agents) have long relied on automated bisect and triage systems to catch regressions before they reach staging.
|
|
96
79
|
|
|
97
|
-
|
|
98
|
-
■ AI-Blackbox Triage Engine
|
|
80
|
+
However, existing enterprise tooling wasn't built for the AI code assistant era. When an LLM refactors multi-file repositories in Cursor or Copilot, conventional stack traces don't tell you which human prompt caused the contract shift.
|
|
99
81
|
|
|
100
|
-
|
|
82
|
+
`triage-trip` bridges that gap for every developer: a zero-overhead local CLI that brings automated root-cause isolation, Cursor prompt provenance, and in-memory patching right to your terminal.
|
|
101
83
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
84
|
+
## Security & Privacy
|
|
85
|
+
- **Read-Only SQLite Access:** `triage-trip` reads local IDE databases (`state.vscdb`) in strict read-only mode (`readonly: true`). It never modifies editor settings or session files.
|
|
86
|
+
- **Direct LLM Communication:** Your repository context and diffs are sent directly to Anthropic or OpenAI using your own local environment API keys. No intermediate servers, telemetry trackers, or external logging daemons.
|
|
105
87
|
|
|
106
|
-
|
|
88
|
+
## Compatibility
|
|
89
|
+
- **IDEs:** Cursor (Full prompt provenance support via local Composer storage), VS Code / Windsurf (Git diff & contract triage)
|
|
90
|
+
- **Platforms:** macOS, Linux, Windows (WSL / PowerShell)
|
|
91
|
+
- **Runtime:** Node.js `>= 18.0.0`
|
|
107
92
|
|
|
108
|
-
|
|
109
|
-
causing `profile.userId` to evaluate to `undefined`.
|
|
93
|
+
## CLI Options
|
|
110
94
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
95
|
+
| Flag | Default | Description |
|
|
96
|
+
| :--- | :--- | :--- |
|
|
97
|
+
| `-c, --commits <num>` | `5` | Number of recent commits to inspect alongside unstaged working diffs |
|
|
98
|
+
| `-y, --yes` | `false` | Automatically apply the proposed patch without an interactive prompt |
|
|
99
|
+
| `-V, --version` | | Display current version |
|
|
100
|
+
| `-h, --help` | | Display help and usage options |
|
|
117
101
|
|
|
118
|
-
|
|
102
|
+
## Development
|
|
119
103
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
ai-blackbox/
|
|
126
|
-
├── bin/
|
|
127
|
-
│ └── cli.js # CLI commands & terminal output formatting (Commander, Ora, Picocolors)
|
|
128
|
-
├── src/
|
|
129
|
-
│ ├── git.js # Working tree and commit diff extraction (simple-git)
|
|
130
|
-
│ ├── cursor.js # Cursor SQLite database parser (better-sqlite3)
|
|
131
|
-
│ └── triage.js # OpenAI triage diagnostic engine (GPT-4o)
|
|
132
|
-
├── package.json
|
|
133
|
-
└── README.md
|
|
104
|
+
```bash
|
|
105
|
+
git clone [https://github.com/arules3/triage-trip.git](https://github.com/arules3/triage-trip.git)
|
|
106
|
+
cd triage-trip
|
|
107
|
+
npm install
|
|
108
|
+
npm link
|
|
134
109
|
|
|
135
|
-
```
|
|
136
110
|
|
|
137
|
-
|
|
111
|
+
## Current Status & Roadmap
|
|
138
112
|
|
|
139
|
-
|
|
113
|
+
### What's Working Today (`v0.1.0`)
|
|
114
|
+
- **Git Context Analysis:** Reads unstaged working tree diffs and recent commit logs.
|
|
115
|
+
- **Cursor Prompt Provenance:** Direct read-only extraction from local `state.vscdb` to link regressions to specific Composer prompts.
|
|
116
|
+
- **Root Cause Isolation:** LLM-assisted semantic contract shift detection.
|
|
117
|
+
- **Interactive Patching:** Clean, in-memory `git apply` with user confirmation prompts (`--yes` fallback).
|
|
140
118
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
* [ ] GitHub Action runner for automated PR failure triage
|
|
119
|
+
### On the Horizon
|
|
120
|
+
- [ ] **Automated Test Verification:** Automatically run project test suites (`npm test`, `pytest`) to verify fixes before finalizing patches.
|
|
121
|
+
- [ ] **Expanded IDE Provenance:** Support for Windsurf (`cascade`), VS Code Copilot Chat, and Claude Code logs.
|
|
122
|
+
- [ ] **GitHub Action / CI Mode:** Comment directly on failed PR runs with the attributed prompt and proposed fix.
|
|
123
|
+
- [ ] **Deterministic AST Diffing:** Local offline schema diffing before LLM invocation to cut latency and token usage.
|
package/bin/cli.js
CHANGED
|
@@ -24,15 +24,15 @@ program
|
|
|
24
24
|
.option('-c, --commits <number>', 'Number of recent commits to analyze', '5')
|
|
25
25
|
.option('-y, --yes', 'Automatically apply the patch without asking', false)
|
|
26
26
|
.action(async (errorInput, options) => {
|
|
27
|
-
console.log(pc.bold(pc.cyan('\n■
|
|
27
|
+
console.log(pc.bold(pc.cyan('\n■ Triage-Trip Engine\n')));
|
|
28
28
|
|
|
29
29
|
const spinner = ora('Reading recent git changes & Cursor prompts...').start();
|
|
30
30
|
let diagnosis = '';
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
try {
|
|
33
33
|
const commitCount = parseInt(options.commits, 10);
|
|
34
34
|
const [changes, prompts] = await Promise.all([
|
|
35
|
-
getRecentChanges(commitCount),
|
|
35
|
+
getRecentChanges(commitCount, errorInput),
|
|
36
36
|
Promise.resolve().then(() => getRecentCursorPrompts(10))
|
|
37
37
|
]);
|
|
38
38
|
|
|
@@ -60,16 +60,21 @@ program
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
if (shouldApply) {
|
|
63
|
-
const patchSpinner = ora('Applying patch
|
|
63
|
+
const patchSpinner = ora('Applying patch to working tree...').start();
|
|
64
64
|
try {
|
|
65
|
-
await applyDiff(patch);
|
|
65
|
+
const res = await applyDiff(patch);
|
|
66
66
|
patchSpinner.succeed(pc.green('Patch applied successfully to working tree.'));
|
|
67
|
+
if (res?.modifiedFiles && res.modifiedFiles.length > 0) {
|
|
68
|
+
console.log(pc.dim(`Modified: ${res.modifiedFiles.join(', ')}`));
|
|
69
|
+
}
|
|
67
70
|
console.log(pc.dim('Run `git diff` to review or `git commit` to save the fix.\n'));
|
|
68
71
|
} catch (err) {
|
|
69
|
-
patchSpinner.fail(pc.red(err.message));
|
|
72
|
+
patchSpinner.fail(pc.red('Patch application failed: ' + err.message));
|
|
70
73
|
}
|
|
71
74
|
}
|
|
75
|
+
} else {
|
|
76
|
+
console.log(pc.dim('ℹ No executable patch block was detected in the diagnostic report.\n'));
|
|
72
77
|
}
|
|
73
78
|
});
|
|
74
79
|
|
|
75
|
-
program.parse(process.argv);
|
|
80
|
+
program.parse(process.argv);
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "triage-trip",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Autonomous CLI to pinpoint and patch regressions introduced by AI code assistants",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "
|
|
7
|
+
"test": "node test/patch.test.js"
|
|
8
8
|
},
|
|
9
9
|
"keywords": [
|
|
10
10
|
"cursor",
|
package/src/git.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { simpleGit } from 'simple-git';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
2
4
|
|
|
3
5
|
const git = simpleGit();
|
|
4
6
|
|
|
@@ -13,7 +15,126 @@ function sanitizeDiff(diffStr, maxChars = 4000) {
|
|
|
13
15
|
return diffStr;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
const BINARY_EXTENSIONS = new Set([
|
|
19
|
+
'.gif', '.png', '.jpg', '.jpeg', '.cast', '.ico', '.pdf', '.zip',
|
|
20
|
+
'.tar', '.gz', '.db', '.sqlite', '.sqlite3', '.bin', '.wasm'
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Collects active working copy text for relevant files with line numbers.
|
|
25
|
+
* Gives the LLM exact source of truth for current file state, eliminating hallucinated diffs.
|
|
26
|
+
*/
|
|
27
|
+
function collectActiveFiles(status, commitDiffs, errorInput = '', maxFiles = 5) {
|
|
28
|
+
const candidates = new Set();
|
|
29
|
+
|
|
30
|
+
// 1. Extract file paths from error input / stack trace
|
|
31
|
+
if (errorInput) {
|
|
32
|
+
const fileMatches = errorInput.match(/(?:[a-zA-Z0-9_\-\.\/]+\/)?([a-zA-Z0-9_\-\.]+\.[a-zA-Z0-9]+)/g) || [];
|
|
33
|
+
for (const match of fileMatches) {
|
|
34
|
+
const cleanPath = match.replace(/^(?:file:\/\/\/|\/)/, '');
|
|
35
|
+
candidates.add(cleanPath);
|
|
36
|
+
candidates.add(path.basename(cleanPath));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 2. Working tree files from git status
|
|
41
|
+
const statusFiles = [
|
|
42
|
+
...(status.modified || []),
|
|
43
|
+
...(status.staged || []),
|
|
44
|
+
...(status.created || []),
|
|
45
|
+
...(status.not_added || []),
|
|
46
|
+
];
|
|
47
|
+
for (const f of statusFiles) candidates.add(f);
|
|
48
|
+
|
|
49
|
+
// 3. Files from latest commit diff
|
|
50
|
+
if (commitDiffs.length > 0 && commitDiffs[0].diff) {
|
|
51
|
+
const diffFiles = commitDiffs[0].diff.match(/(?:---|\+\+\+)\s+[ab]\/([^\s\n]+)/g) || [];
|
|
52
|
+
for (const df of diffFiles) {
|
|
53
|
+
const p = df.replace(/^(?:---|\+\+\+)\s+[ab]\//, '');
|
|
54
|
+
candidates.add(p);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const activeFiles = {};
|
|
59
|
+
let count = 0;
|
|
60
|
+
|
|
61
|
+
for (const candidate of candidates) {
|
|
62
|
+
if (count >= maxFiles) break;
|
|
63
|
+
|
|
64
|
+
// Resolve file
|
|
65
|
+
let fullPath = path.resolve(process.cwd(), candidate);
|
|
66
|
+
if (!fs.existsSync(fullPath)) {
|
|
67
|
+
// Try resolving by basename in workspace
|
|
68
|
+
const baseName = path.basename(candidate);
|
|
69
|
+
const found = findCandidateFile(process.cwd(), baseName);
|
|
70
|
+
if (found) fullPath = found;
|
|
71
|
+
else continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const ext = path.extname(fullPath).toLowerCase();
|
|
75
|
+
if (BINARY_EXTENSIONS.has(ext)) continue;
|
|
76
|
+
if (fullPath.includes('node_modules') || fullPath.includes('.git')) continue;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const stat = fs.statSync(fullPath);
|
|
80
|
+
if (!stat.isFile() || stat.size > 50000) continue;
|
|
81
|
+
|
|
82
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
83
|
+
const relPath = path.relative(process.cwd(), fullPath);
|
|
84
|
+
|
|
85
|
+
const lines = content.split('\n');
|
|
86
|
+
const formatted = lines
|
|
87
|
+
.slice(0, 150)
|
|
88
|
+
.map((line, idx) => `${idx + 1}| ${line}`)
|
|
89
|
+
.join('\n');
|
|
90
|
+
|
|
91
|
+
activeFiles[relPath] = formatted;
|
|
92
|
+
count++;
|
|
93
|
+
|
|
94
|
+
// Also inspect relative local imports for JS/TS
|
|
95
|
+
if (['.js', '.ts', '.jsx', '.tsx', '.mjs'].includes(ext)) {
|
|
96
|
+
const importMatches = content.match(/(?:import|require)\s*\(?['"](\.[^'"]+)['"]/g) || [];
|
|
97
|
+
for (const imp of importMatches) {
|
|
98
|
+
const importRel = imp.match(/['"](\.[^'"]+)['"]/)?.[1];
|
|
99
|
+
if (importRel) {
|
|
100
|
+
const dir = path.dirname(fullPath);
|
|
101
|
+
let importedPath = path.resolve(dir, importRel);
|
|
102
|
+
if (!fs.existsSync(importedPath) && fs.existsSync(importedPath + '.js')) {
|
|
103
|
+
importedPath = importedPath + '.js';
|
|
104
|
+
}
|
|
105
|
+
if (fs.existsSync(importedPath) && !activeFiles[path.relative(process.cwd(), importedPath)]) {
|
|
106
|
+
candidates.add(path.relative(process.cwd(), importedPath));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch {}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return activeFiles;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function findCandidateFile(dir, baseName, depth = 0) {
|
|
118
|
+
if (depth > 4) return null;
|
|
119
|
+
const ignored = new Set(['node_modules', '.git', 'dist', 'build', '.cache', 'coverage']);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
123
|
+
for (const ent of entries) {
|
|
124
|
+
if (ent.isDirectory()) {
|
|
125
|
+
if (!ignored.has(ent.name)) {
|
|
126
|
+
const found = findCandidateFile(path.join(dir, ent.name), baseName, depth + 1);
|
|
127
|
+
if (found) return found;
|
|
128
|
+
}
|
|
129
|
+
} else if (ent.isFile() && ent.name === baseName) {
|
|
130
|
+
return path.join(dir, ent.name);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch {}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function getRecentChanges(maxCommits = 3, errorInput = '') {
|
|
17
138
|
const isRepo = await git.checkIsRepo();
|
|
18
139
|
if (!isRepo) {
|
|
19
140
|
throw new Error('Not inside a valid Git repository.');
|
|
@@ -27,6 +148,9 @@ export async function getRecentChanges(maxCommits = 3) {
|
|
|
27
148
|
':!yarn.lock',
|
|
28
149
|
':!pnpm-lock.yaml',
|
|
29
150
|
':!bun.lockb',
|
|
151
|
+
':!assets/*',
|
|
152
|
+
':!*.gif',
|
|
153
|
+
':!*.cast',
|
|
30
154
|
];
|
|
31
155
|
|
|
32
156
|
const unstagedDiff = await git.diff(['--', '.', ...lockfileIgnores]);
|
|
@@ -58,10 +182,13 @@ export async function getRecentChanges(maxCommits = 3) {
|
|
|
58
182
|
})
|
|
59
183
|
);
|
|
60
184
|
|
|
185
|
+
const activeFileContents = collectActiveFiles(status, commitDiffs, errorInput);
|
|
186
|
+
|
|
61
187
|
return {
|
|
62
188
|
branch: status.current,
|
|
63
|
-
untrackedFiles: status.not_added,
|
|
189
|
+
untrackedFiles: status.not_added,
|
|
64
190
|
workingDiff: sanitizeDiff(combinedWorkingDiff) || null,
|
|
65
191
|
recentCommits: commitDiffs,
|
|
192
|
+
activeFileContents,
|
|
66
193
|
};
|
|
67
|
-
}
|
|
194
|
+
}
|
package/src/patch.js
CHANGED
|
@@ -1,65 +1,597 @@
|
|
|
1
|
-
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import {
|
|
4
|
+
import { parse } from '@babel/parser';
|
|
5
|
+
import traversePkg from '@babel/traverse';
|
|
6
|
+
|
|
7
|
+
const traverse = traversePkg.default?.default || traversePkg.default || traversePkg;
|
|
5
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Extracts unified diff text from LLM response.
|
|
11
|
+
* Handles markdown fences (```diff, ```patch, generic ```), raw diffs, and cleans common noise.
|
|
12
|
+
*/
|
|
6
13
|
export function extractDiff(llmOutput) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
14
|
+
if (!llmOutput || typeof llmOutput !== 'string') return null;
|
|
15
|
+
|
|
16
|
+
// 1. Try markdown code block markers
|
|
17
|
+
const fenceMatch =
|
|
18
|
+
llmOutput.match(/```(?:diff|patch)\s*([\s\S]*?)```/i) ||
|
|
19
|
+
llmOutput.match(/```\s*\n(--- [^\n]+\n\+\+\+ [^\n]+[\s\S]*?)```/) ||
|
|
20
|
+
llmOutput.match(/```\s*\n(diff --git [^\n]+[\s\S]*?)```/);
|
|
21
|
+
|
|
22
|
+
let raw = '';
|
|
23
|
+
if (fenceMatch) {
|
|
24
|
+
raw = fenceMatch[1];
|
|
25
|
+
} else {
|
|
26
|
+
// 2. Try raw unified diff pattern
|
|
27
|
+
const rawMatch =
|
|
28
|
+
llmOutput.match(/(diff --git a\/[^\n]+ b\/[^\n]+[\s\S]*)/) ||
|
|
29
|
+
llmOutput.match(/(--- [^\n]+\n\+\+\+ [^\n]+[\s\S]*)/);
|
|
30
|
+
if (rawMatch) {
|
|
31
|
+
raw = rawMatch[1];
|
|
32
|
+
} else {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Clean non-breaking spaces and Windows newlines
|
|
38
|
+
raw = raw.replace(/\u00A0/g, ' ').replace(/\r\n/g, '\n');
|
|
39
|
+
|
|
40
|
+
// Strip git index metadata lines if malformed
|
|
41
|
+
raw = raw.replace(/^index [0-9a-f]+\.\.[0-9a-f]+.*$\n?/gm, '');
|
|
42
|
+
|
|
43
|
+
if (!raw.endsWith('\n')) raw += '\n';
|
|
44
|
+
|
|
45
|
+
return raw.trim() + '\n';
|
|
46
|
+
}
|
|
10
47
|
|
|
11
|
-
|
|
12
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Normalizes diff headers and recounts hunk lines (@@ -oldStart,oldCount +newStart,newCount @@).
|
|
50
|
+
* LLMs frequently miscount lines in the hunk header, which causes git apply to fail.
|
|
51
|
+
*/
|
|
52
|
+
export function normalizeDiff(diffString) {
|
|
53
|
+
if (!diffString) return '';
|
|
54
|
+
const cleaned = diffString.replace(/\u00A0/g, ' ').replace(/\r\n/g, '\n');
|
|
55
|
+
const lines = cleaned.split('\n');
|
|
56
|
+
const result = [];
|
|
13
57
|
|
|
14
|
-
|
|
15
|
-
|
|
58
|
+
let currentHunkIdx = -1;
|
|
59
|
+
let oldCount = 0;
|
|
60
|
+
let newCount = 0;
|
|
61
|
+
let inHunk = false;
|
|
62
|
+
let oldStart = 1;
|
|
63
|
+
let newStart = 1;
|
|
64
|
+
let hunkHeaderExtra = '';
|
|
16
65
|
|
|
17
|
-
|
|
18
|
-
|
|
66
|
+
function finalizeHunk() {
|
|
67
|
+
if (currentHunkIdx >= 0) {
|
|
68
|
+
result[currentHunkIdx] = `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@${hunkHeaderExtra}`;
|
|
69
|
+
}
|
|
19
70
|
}
|
|
20
71
|
|
|
21
|
-
|
|
72
|
+
for (let i = 0; i < lines.length; i++) {
|
|
73
|
+
const line = lines[i];
|
|
74
|
+
|
|
75
|
+
// Normalize header paths: ensure standard a/ and b/ prefixes
|
|
76
|
+
if (line.startsWith('--- ') && !line.startsWith('--- a/') && !line.startsWith('--- /dev/null')) {
|
|
77
|
+
const p = line.slice(4).trim().replace(/^([ab]\/|\.\/)/, '');
|
|
78
|
+
result.push(`--- a/${p}`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (line.startsWith('+++ ') && !line.startsWith('+++ b/') && !line.startsWith('+++ /dev/null')) {
|
|
82
|
+
const p = line.slice(4).trim().replace(/^([ab]\/|\.\/)/, '');
|
|
83
|
+
result.push(`+++ b/${p}`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
|
|
88
|
+
if (hunkMatch) {
|
|
89
|
+
finalizeHunk();
|
|
90
|
+
oldStart = hunkMatch[1];
|
|
91
|
+
newStart = hunkMatch[2];
|
|
92
|
+
hunkHeaderExtra = hunkMatch[3] || '';
|
|
93
|
+
oldCount = 0;
|
|
94
|
+
newCount = 0;
|
|
95
|
+
inHunk = true;
|
|
96
|
+
currentHunkIdx = result.length;
|
|
97
|
+
result.push(line);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (inHunk) {
|
|
102
|
+
if (line.startsWith('--- ') || line.startsWith('diff --git')) {
|
|
103
|
+
finalizeHunk();
|
|
104
|
+
inHunk = false;
|
|
105
|
+
currentHunkIdx = -1;
|
|
106
|
+
result.push(line);
|
|
107
|
+
} else if (line.startsWith('-')) {
|
|
108
|
+
oldCount++;
|
|
109
|
+
result.push(line);
|
|
110
|
+
} else if (line.startsWith('+')) {
|
|
111
|
+
newCount++;
|
|
112
|
+
result.push(line);
|
|
113
|
+
} else if (line.startsWith(' ') || line === '') {
|
|
114
|
+
oldCount++;
|
|
115
|
+
newCount++;
|
|
116
|
+
result.push(line === '' ? ' ' : line);
|
|
117
|
+
} else if (line.startsWith('\\')) {
|
|
118
|
+
// e.g.
|
|
119
|
+
result.push(line);
|
|
120
|
+
} else {
|
|
121
|
+
finalizeHunk();
|
|
122
|
+
inHunk = false;
|
|
123
|
+
currentHunkIdx = -1;
|
|
124
|
+
result.push(line);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
result.push(line);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
finalizeHunk();
|
|
132
|
+
return result.join('\n').trim() + '\n';
|
|
22
133
|
}
|
|
23
134
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Validates syntax of patched content to prevent breaking the workspace with unparseable code.
|
|
137
|
+
*/
|
|
138
|
+
export function validateSyntax(filePath, content) {
|
|
139
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
140
|
+
|
|
141
|
+
if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext)) {
|
|
142
|
+
try {
|
|
143
|
+
parse(content, {
|
|
144
|
+
sourceType: 'module',
|
|
145
|
+
plugins: ['jsx', 'typescript'],
|
|
146
|
+
});
|
|
147
|
+
return { valid: true };
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return { valid: false, error: err.message };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (ext === '.json') {
|
|
154
|
+
try {
|
|
155
|
+
JSON.parse(content);
|
|
156
|
+
return { valid: true };
|
|
157
|
+
} catch (err) {
|
|
158
|
+
return { valid: false, error: err.message };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { valid: true };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Parses a unified diff string into structured file patches and hunks.
|
|
167
|
+
*/
|
|
168
|
+
export function parseUnifiedDiff(diffString) {
|
|
169
|
+
const files = [];
|
|
170
|
+
const lines = diffString.split('\n');
|
|
171
|
+
let currentFile = null;
|
|
172
|
+
let currentHunk = null;
|
|
173
|
+
|
|
174
|
+
for (let i = 0; i < lines.length; i++) {
|
|
175
|
+
const line = lines[i];
|
|
176
|
+
|
|
177
|
+
if (line.startsWith('--- ')) {
|
|
178
|
+
const rawPath = line.slice(4).trim().split('\t')[0];
|
|
179
|
+
const oldPath = rawPath.replace(/^(?:a\/|\.\/)/, '');
|
|
180
|
+
currentFile = {
|
|
181
|
+
oldPath,
|
|
182
|
+
newPath: '',
|
|
183
|
+
isNew: rawPath === '/dev/null' || rawPath === 'dev/null',
|
|
184
|
+
isDeleted: false,
|
|
185
|
+
hunks: [],
|
|
186
|
+
};
|
|
187
|
+
files.push(currentFile);
|
|
188
|
+
currentHunk = null;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (line.startsWith('+++ ') && currentFile) {
|
|
193
|
+
const rawPath = line.slice(4).trim().split('\t')[0];
|
|
194
|
+
currentFile.newPath = rawPath.replace(/^(?:b\/|\.\/)/, '');
|
|
195
|
+
currentFile.isDeleted = rawPath === '/dev/null' || rawPath === 'dev/null';
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
200
|
+
if (hunkMatch && currentFile) {
|
|
201
|
+
currentHunk = {
|
|
202
|
+
oldStart: parseInt(hunkMatch[1], 10),
|
|
203
|
+
oldCount: parseInt(hunkMatch[2] || '1', 10),
|
|
204
|
+
newStart: parseInt(hunkMatch[3], 10),
|
|
205
|
+
newCount: parseInt(hunkMatch[4] || '1', 10),
|
|
206
|
+
rawLines: [],
|
|
207
|
+
oldLines: [],
|
|
208
|
+
newLines: [],
|
|
209
|
+
removed: [],
|
|
210
|
+
added: [],
|
|
211
|
+
};
|
|
212
|
+
currentFile.hunks.push(currentHunk);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (currentHunk) {
|
|
217
|
+
if (line.startsWith('-')) {
|
|
218
|
+
currentHunk.rawLines.push(line);
|
|
219
|
+
currentHunk.oldLines.push(line.slice(1));
|
|
220
|
+
currentHunk.removed.push(line.slice(1));
|
|
221
|
+
} else if (line.startsWith('+')) {
|
|
222
|
+
currentHunk.rawLines.push(line);
|
|
223
|
+
currentHunk.newLines.push(line.slice(1));
|
|
224
|
+
currentHunk.added.push(line.slice(1));
|
|
225
|
+
} else if (line.startsWith(' ') || line === '') {
|
|
226
|
+
const text = line.startsWith(' ') ? line.slice(1) : line;
|
|
227
|
+
currentHunk.rawLines.push(line);
|
|
228
|
+
currentHunk.oldLines.push(text);
|
|
229
|
+
currentHunk.newLines.push(text);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return files;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Resolves a file path from a diff to an actual file on disk.
|
|
239
|
+
*/
|
|
240
|
+
function resolveFilePath(targetRelPath) {
|
|
241
|
+
if (!targetRelPath || targetRelPath === '/dev/null') return null;
|
|
242
|
+
|
|
243
|
+
// 1. Direct path from cwd
|
|
244
|
+
const directPath = path.resolve(process.cwd(), targetRelPath);
|
|
245
|
+
if (fs.existsSync(directPath)) return directPath;
|
|
246
|
+
|
|
247
|
+
// 2. Search workspace for matching basename (avoiding noisy directories)
|
|
248
|
+
const baseName = path.basename(targetRelPath);
|
|
249
|
+
const found = findFileByBasename(process.cwd(), baseName);
|
|
250
|
+
if (found) return found;
|
|
251
|
+
|
|
252
|
+
return directPath; // return unresolved direct path for creation or not-found handling
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function findFileByBasename(dir, targetName, depth = 0) {
|
|
256
|
+
if (depth > 5) return null;
|
|
257
|
+
const ignored = new Set(['node_modules', '.git', 'dist', 'build', '.cache', 'coverage']);
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
261
|
+
for (const ent of entries) {
|
|
262
|
+
if (ent.isDirectory()) {
|
|
263
|
+
if (!ignored.has(ent.name)) {
|
|
264
|
+
const res = findFileByBasename(path.join(dir, ent.name), targetName, depth + 1);
|
|
265
|
+
if (res) return res;
|
|
266
|
+
}
|
|
267
|
+
} else if (ent.isFile() && ent.name === targetName) {
|
|
268
|
+
return path.join(dir, ent.name);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} catch {}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Computes fast token-based similarity between two lines.
|
|
277
|
+
*/
|
|
278
|
+
function lineTokenSimilarity(a, b) {
|
|
279
|
+
const s1 = a.trim().toLowerCase();
|
|
280
|
+
const s2 = b.trim().toLowerCase();
|
|
281
|
+
if (s1 === s2) return 1.0;
|
|
282
|
+
if (!s1 || !s2) return 0.0;
|
|
283
|
+
|
|
284
|
+
const t1 = s1.split(/[^a-z0-9_$]+/i).filter(Boolean);
|
|
285
|
+
const t2 = s2.split(/[^a-z0-9_$]+/i).filter(Boolean);
|
|
286
|
+
if (t1.length === 0 || t2.length === 0) return 0.0;
|
|
287
|
+
|
|
288
|
+
let matches = 0;
|
|
289
|
+
const set2 = new Set(t2);
|
|
290
|
+
for (const t of t1) {
|
|
291
|
+
if (set2.has(t)) matches++;
|
|
292
|
+
}
|
|
293
|
+
return (2 * matches) / (t1.length + t2.length);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Applies a hunk to an array of file lines using progressive matching tiers.
|
|
298
|
+
* Returns { success: boolean, updatedLines: string[] }.
|
|
299
|
+
*/
|
|
300
|
+
function applyHunkToLines(fileLines, hunk, targetFilePath) {
|
|
301
|
+
const oldLines = hunk.oldLines;
|
|
302
|
+
const newLines = hunk.newLines;
|
|
303
|
+
const removed = hunk.removed;
|
|
304
|
+
const added = hunk.added;
|
|
305
|
+
|
|
306
|
+
// --- Tier 1: Exact oldLines (context + removed) match ---
|
|
307
|
+
if (oldLines.length > 0) {
|
|
308
|
+
for (let i = 0; i <= fileLines.length - oldLines.length; i++) {
|
|
309
|
+
let match = true;
|
|
310
|
+
for (let j = 0; j < oldLines.length; j++) {
|
|
311
|
+
if (fileLines[i + j] !== oldLines[j]) {
|
|
312
|
+
match = false;
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (match) {
|
|
317
|
+
const updated = [...fileLines.slice(0, i), ...newLines, ...fileLines.slice(i + oldLines.length)];
|
|
318
|
+
return { success: true, updatedLines: updated };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// --- Tier 2: Exact removed-only lines match (ignoring context) ---
|
|
324
|
+
if (removed.length > 0) {
|
|
325
|
+
for (let i = 0; i <= fileLines.length - removed.length; i++) {
|
|
326
|
+
let match = true;
|
|
327
|
+
for (let j = 0; j < removed.length; j++) {
|
|
328
|
+
if (fileLines[i + j] !== removed[j]) {
|
|
329
|
+
match = false;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (match) {
|
|
334
|
+
const updated = [...fileLines.slice(0, i), ...added, ...fileLines.slice(i + removed.length)];
|
|
335
|
+
return { success: true, updatedLines: updated };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// --- Tier 3: Whitespace & indentation normalized match ---
|
|
341
|
+
if (oldLines.length > 0) {
|
|
342
|
+
const normOld = oldLines.map((l) => l.trim());
|
|
343
|
+
for (let i = 0; i <= fileLines.length - normOld.length; i++) {
|
|
344
|
+
let match = true;
|
|
345
|
+
for (let j = 0; j < normOld.length; j++) {
|
|
346
|
+
if (fileLines[i + j].trim() !== normOld[j]) {
|
|
347
|
+
match = false;
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (match) {
|
|
352
|
+
// Adapt indentation from original lines
|
|
353
|
+
const indentMatch = fileLines[i].match(/^\s*/);
|
|
354
|
+
const baseIndent = indentMatch ? indentMatch[0] : '';
|
|
355
|
+
const adaptedNew = newLines.map((l) => {
|
|
356
|
+
if (l.trim().length === 0) return '';
|
|
357
|
+
const diffIndent = l.match(/^\s*/)?.[0] || '';
|
|
358
|
+
return diffIndent ? l : baseIndent + l;
|
|
359
|
+
});
|
|
360
|
+
const updated = [...fileLines.slice(0, i), ...adaptedNew, ...fileLines.slice(i + normOld.length)];
|
|
361
|
+
return { success: true, updatedLines: updated };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// --- Tier 4: Case-insensitive & token similarity match ---
|
|
367
|
+
// Covers cases like `profile.userID` vs `profile.userid` vs `profile.userId`
|
|
368
|
+
if (removed.length > 0) {
|
|
369
|
+
let bestIdx = -1;
|
|
370
|
+
let bestScore = 0;
|
|
371
|
+
|
|
372
|
+
for (let i = 0; i <= fileLines.length - removed.length; i++) {
|
|
373
|
+
let totalSim = 0;
|
|
374
|
+
for (let j = 0; j < removed.length; j++) {
|
|
375
|
+
const sim = lineTokenSimilarity(fileLines[i + j], removed[j]);
|
|
376
|
+
totalSim += sim;
|
|
377
|
+
}
|
|
378
|
+
const avgSim = totalSim / removed.length;
|
|
379
|
+
if (avgSim > bestScore) {
|
|
380
|
+
bestScore = avgSim;
|
|
381
|
+
bestIdx = i;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (bestScore >= 0.75 && bestIdx >= 0) {
|
|
386
|
+
const indentMatch = fileLines[bestIdx].match(/^\s*/);
|
|
387
|
+
const baseIndent = indentMatch ? indentMatch[0] : '';
|
|
388
|
+
|
|
389
|
+
const adaptedAdded = added.map((l) => {
|
|
390
|
+
if (l.trim().length === 0) return '';
|
|
391
|
+
const curIndent = l.match(/^\s*/)?.[0] || '';
|
|
392
|
+
return curIndent ? l : baseIndent + l;
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const updated = [...fileLines.slice(0, bestIdx), ...adaptedAdded, ...fileLines.slice(bestIdx + removed.length)];
|
|
396
|
+
return { success: true, updatedLines: updated };
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// --- Tier 5: AST-guided MemberExpression / Identifier rename for JS/TS ---
|
|
401
|
+
const ext = path.extname(targetFilePath).toLowerCase();
|
|
402
|
+
if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext) && removed.length === 1 && added.length === 1) {
|
|
403
|
+
const astResult = tryAstMemberRename(fileLines.join('\n'), removed[0], added[0]);
|
|
404
|
+
if (astResult.success) {
|
|
405
|
+
return { success: true, updatedLines: astResult.code.split('\n') };
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return { success: false };
|
|
410
|
+
}
|
|
32
411
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
412
|
+
/**
|
|
413
|
+
* Uses Babel AST traversal to pinpoint and rename member expressions (e.g. profile.userid -> profile.user_id).
|
|
414
|
+
*/
|
|
415
|
+
function tryAstMemberRename(code, removedLine, addedLine) {
|
|
416
|
+
try {
|
|
417
|
+
const oldPropMatch = removedLine.match(/([a-zA-Z0-9_$]+)\.([a-zA-Z0-9_$]+)/);
|
|
418
|
+
const newPropMatch = addedLine.match(/([a-zA-Z0-9_$]+)\.([a-zA-Z0-9_$]+)/);
|
|
36
419
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
420
|
+
if (!oldPropMatch || !newPropMatch) return { success: false };
|
|
421
|
+
const objName = oldPropMatch[1];
|
|
422
|
+
const oldProp = oldPropMatch[2];
|
|
423
|
+
const newProp = newPropMatch[2];
|
|
40
424
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
425
|
+
const ast = parse(code, { sourceType: 'module', plugins: ['jsx', 'typescript'] });
|
|
426
|
+
let replaced = false;
|
|
427
|
+
let newCode = code;
|
|
44
428
|
|
|
45
|
-
|
|
429
|
+
traverse(ast, {
|
|
430
|
+
MemberExpression(p) {
|
|
431
|
+
if (
|
|
432
|
+
!replaced &&
|
|
433
|
+
p.node.object?.type === 'Identifier' &&
|
|
434
|
+
p.node.object.name === objName &&
|
|
435
|
+
p.node.property?.type === 'Identifier' &&
|
|
436
|
+
p.node.property.name.toLowerCase() === oldProp.toLowerCase()
|
|
437
|
+
) {
|
|
438
|
+
const start = p.node.property.start;
|
|
439
|
+
const end = p.node.property.end;
|
|
440
|
+
newCode = newCode.slice(0, start) + newProp + newCode.slice(end);
|
|
441
|
+
replaced = true;
|
|
442
|
+
p.stop();
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
return { success: replaced, code: newCode };
|
|
448
|
+
} catch {
|
|
449
|
+
return { success: false };
|
|
450
|
+
}
|
|
46
451
|
}
|
|
47
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Semantic Fallback Patcher: Applies parsed diff directly to files on disk with atomic rollback.
|
|
455
|
+
* Guarantees zero false positives: if no files are changed or any file fails, rolls back completely.
|
|
456
|
+
*/
|
|
457
|
+
export async function applySemanticFallback(diffString) {
|
|
458
|
+
const filePatches = parseUnifiedDiff(diffString);
|
|
459
|
+
if (filePatches.length === 0) {
|
|
460
|
+
throw new Error('No valid file hunks could be parsed from the diff.');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const backups = new Map(); // filePath -> originalContent
|
|
464
|
+
const createdFiles = new Set();
|
|
465
|
+
const modifiedFiles = [];
|
|
466
|
+
|
|
467
|
+
try {
|
|
468
|
+
for (const patchObj of filePatches) {
|
|
469
|
+
const targetRelPath = patchObj.newPath || patchObj.oldPath;
|
|
470
|
+
const targetPath = resolveFilePath(targetRelPath);
|
|
471
|
+
|
|
472
|
+
// 1. Handle new file creation
|
|
473
|
+
if (patchObj.isNew) {
|
|
474
|
+
const fullNewPath = targetPath || path.resolve(process.cwd(), targetRelPath);
|
|
475
|
+
const dir = path.dirname(fullNewPath);
|
|
476
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
477
|
+
|
|
478
|
+
const newContent = patchObj.hunks.flatMap((h) => h.added).join('\n') + '\n';
|
|
479
|
+
const syntaxCheck = validateSyntax(fullNewPath, newContent);
|
|
480
|
+
if (!syntaxCheck.valid) {
|
|
481
|
+
throw new Error(`Proposed new file '${targetRelPath}' has a syntax error: ${syntaxCheck.error}`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
fs.writeFileSync(fullNewPath, newContent, 'utf8');
|
|
485
|
+
createdFiles.add(fullNewPath);
|
|
486
|
+
modifiedFiles.push(targetRelPath);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// 2. Handle file deletion
|
|
491
|
+
if (patchObj.isDeleted) {
|
|
492
|
+
if (fs.existsSync(targetPath)) {
|
|
493
|
+
backups.set(targetPath, fs.readFileSync(targetPath, 'utf8'));
|
|
494
|
+
fs.unlinkSync(targetPath);
|
|
495
|
+
modifiedFiles.push(targetRelPath);
|
|
496
|
+
}
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// 3. Handle file modification
|
|
501
|
+
if (!targetPath || !fs.existsSync(targetPath)) {
|
|
502
|
+
throw new Error(`Target file not found in repository: ${targetRelPath}`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const originalContent = fs.readFileSync(targetPath, 'utf8');
|
|
506
|
+
backups.set(targetPath, originalContent);
|
|
507
|
+
|
|
508
|
+
const hasCrlf = originalContent.includes('\r\n');
|
|
509
|
+
const normalizedOriginal = originalContent.replace(/\r\n/g, '\n');
|
|
510
|
+
let currentLines = normalizedOriginal.split('\n');
|
|
511
|
+
|
|
512
|
+
let anyHunkApplied = false;
|
|
513
|
+
|
|
514
|
+
for (const hunk of patchObj.hunks) {
|
|
515
|
+
const hunkRes = applyHunkToLines(currentLines, hunk, targetPath);
|
|
516
|
+
if (hunkRes.success) {
|
|
517
|
+
currentLines = hunkRes.updatedLines;
|
|
518
|
+
anyHunkApplied = true;
|
|
519
|
+
} else {
|
|
520
|
+
// Check if already applied
|
|
521
|
+
const alreadyContainsAdded = hunk.added.length > 0 && hunk.added.every((l) => normalizedOriginal.includes(l.trim()));
|
|
522
|
+
if (alreadyContainsAdded) {
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
throw new Error(
|
|
526
|
+
`Patch hunk at line ${hunk.oldStart} could not be matched to any code in '${targetRelPath}'.`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
let updatedContent = currentLines.join(hasCrlf ? '\r\n' : '\n');
|
|
532
|
+
|
|
533
|
+
// Verify actual content modification
|
|
534
|
+
if (updatedContent === originalContent) {
|
|
535
|
+
// If content did not change, check if diff was already applied
|
|
536
|
+
const alreadyApplied = patchObj.hunks.some((h) =>
|
|
537
|
+
h.added.length > 0 && h.added.every((l) => originalContent.includes(l.trim()))
|
|
538
|
+
);
|
|
539
|
+
if (alreadyApplied) {
|
|
540
|
+
continue; // File already has the fix
|
|
541
|
+
}
|
|
542
|
+
throw new Error(`Patch resulted in no changes to '${targetRelPath}'.`);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Validate syntax before committing to disk
|
|
546
|
+
const syntaxCheck = validateSyntax(targetPath, updatedContent);
|
|
547
|
+
if (!syntaxCheck.valid) {
|
|
548
|
+
throw new Error(`Proposed patch introduces a syntax error in '${targetRelPath}': ${syntaxCheck.error}`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
fs.writeFileSync(targetPath, updatedContent, 'utf8');
|
|
552
|
+
modifiedFiles.push(targetRelPath);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (modifiedFiles.length === 0) {
|
|
556
|
+
throw new Error('Patch was already applied or resulted in no changes to working tree.');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return { success: true, method: 'semantic-fallback', modifiedFiles };
|
|
560
|
+
} catch (err) {
|
|
561
|
+
// Atomic Rollback: Restore all modified files from backups
|
|
562
|
+
for (const [filePath, content] of backups.entries()) {
|
|
563
|
+
try {
|
|
564
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
565
|
+
} catch {}
|
|
566
|
+
}
|
|
567
|
+
// Remove any created files
|
|
568
|
+
for (const createdPath of createdFiles) {
|
|
569
|
+
try {
|
|
570
|
+
if (fs.existsSync(createdPath)) fs.unlinkSync(createdPath);
|
|
571
|
+
} catch {}
|
|
572
|
+
}
|
|
573
|
+
throw err;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Spawns git apply with given command-line flags.
|
|
579
|
+
*/
|
|
48
580
|
function runGitApply(diffContent, flags = []) {
|
|
49
581
|
return new Promise((resolve) => {
|
|
50
582
|
const git = spawn('git', ['apply', ...flags, '-'], {
|
|
51
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
583
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
52
584
|
});
|
|
53
585
|
|
|
54
586
|
let stderr = '';
|
|
55
|
-
git.stderr.on('data', (
|
|
56
|
-
stderr +=
|
|
587
|
+
git.stderr.on('data', (d) => {
|
|
588
|
+
stderr += d.toString();
|
|
57
589
|
});
|
|
58
590
|
|
|
59
591
|
git.on('close', (code) => {
|
|
60
592
|
resolve({
|
|
61
593
|
success: code === 0,
|
|
62
|
-
stderr: stderr.trim()
|
|
594
|
+
stderr: stderr.trim(),
|
|
63
595
|
});
|
|
64
596
|
});
|
|
65
597
|
|
|
@@ -68,53 +600,110 @@ function runGitApply(diffContent, flags = []) {
|
|
|
68
600
|
});
|
|
69
601
|
}
|
|
70
602
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
603
|
+
/**
|
|
604
|
+
* Main patch application entry point.
|
|
605
|
+
* Tries progressive Git Apply strategies, then falls back to Semantic Fallback Patcher.
|
|
606
|
+
* Guarantees syntax validation and atomic rollback on errors.
|
|
607
|
+
*/
|
|
608
|
+
export async function applyDiff(diffString) {
|
|
609
|
+
if (!diffString || typeof diffString !== 'string' || !diffString.trim()) {
|
|
610
|
+
throw new Error('No valid diff provided to apply.');
|
|
611
|
+
}
|
|
79
612
|
|
|
80
|
-
|
|
613
|
+
const normalized = normalizeDiff(diffString);
|
|
81
614
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
615
|
+
// Snapshot target file mtimes to verify Git apply actually touched files
|
|
616
|
+
const filePatches = parseUnifiedDiff(normalized);
|
|
617
|
+
const targetPaths = filePatches
|
|
618
|
+
.map((p) => resolveFilePath(p.newPath || p.oldPath))
|
|
619
|
+
.filter(Boolean);
|
|
86
620
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
newLine = line.slice(1).trim();
|
|
92
|
-
}
|
|
621
|
+
const initialContents = new Map();
|
|
622
|
+
for (const p of targetPaths) {
|
|
623
|
+
if (fs.existsSync(p)) {
|
|
624
|
+
initialContents.set(p, fs.readFileSync(p, 'utf8'));
|
|
93
625
|
}
|
|
626
|
+
}
|
|
94
627
|
|
|
95
|
-
|
|
628
|
+
// Strategy 1: Git apply with whitespace and recount adjustments
|
|
629
|
+
const strat1 = await runGitApply(normalized, ['-p1', '--recount', '--whitespace=fix', '-C1']);
|
|
630
|
+
if (strat1.success && verifyAndValidateGitApply(targetPaths, initialContents)) {
|
|
631
|
+
return { success: true, method: 'git-apply-recount' };
|
|
632
|
+
}
|
|
96
633
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
634
|
+
// Strategy 2: Git apply with relaxed whitespace
|
|
635
|
+
const strat2 = await runGitApply(normalized, [
|
|
636
|
+
'-p1',
|
|
637
|
+
'--ignore-space-change',
|
|
638
|
+
'--ignore-whitespace',
|
|
639
|
+
'--recount',
|
|
640
|
+
'-C1',
|
|
641
|
+
]);
|
|
642
|
+
if (strat2.success && verifyAndValidateGitApply(targetPaths, initialContents)) {
|
|
643
|
+
return { success: true, method: 'git-apply-ignore-whitespace' };
|
|
644
|
+
}
|
|
100
645
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
646
|
+
// Strategy 3: Git apply with zero context
|
|
647
|
+
const strat3 = await runGitApply(normalized, [
|
|
648
|
+
'-p1',
|
|
649
|
+
'--unidiff-zero',
|
|
650
|
+
'-C0',
|
|
651
|
+
'--ignore-space-change',
|
|
652
|
+
'--ignore-whitespace',
|
|
653
|
+
]);
|
|
654
|
+
if (strat3.success && verifyAndValidateGitApply(targetPaths, initialContents)) {
|
|
655
|
+
return { success: true, method: 'git-apply-unidiff-zero' };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Strategy 4: Git apply with -p0
|
|
659
|
+
const strat4 = await runGitApply(normalized, ['-p0', '--recount', '--whitespace=fix']);
|
|
660
|
+
if (strat4.success && verifyAndValidateGitApply(targetPaths, initialContents)) {
|
|
661
|
+
return { success: true, method: 'git-apply-p0' };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Strategy 5: Git 3-way merge
|
|
665
|
+
const strat5 = await runGitApply(normalized, ['-3', '--recount', '--whitespace=fix']);
|
|
666
|
+
if (strat5.success && verifyAndValidateGitApply(targetPaths, initialContents)) {
|
|
667
|
+
return { success: true, method: 'git-apply-3way' };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Rollback any partial/broken git apply attempt before semantic fallback
|
|
671
|
+
for (const [p, content] of initialContents.entries()) {
|
|
672
|
+
try {
|
|
673
|
+
if (fs.existsSync(p)) fs.writeFileSync(p, content, 'utf8');
|
|
674
|
+
} catch {}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Strategy 6: Intelligent Semantic Fallback Patcher
|
|
678
|
+
return await applySemanticFallback(normalized);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Verifies that git apply actually modified the target files and did not produce syntax errors.
|
|
683
|
+
*/
|
|
684
|
+
function verifyAndValidateGitApply(targetPaths, initialContents) {
|
|
685
|
+
let anyModified = false;
|
|
686
|
+
|
|
687
|
+
for (const p of targetPaths) {
|
|
688
|
+
if (!fs.existsSync(p)) continue;
|
|
689
|
+
let curContent = fs.readFileSync(p, 'utf8');
|
|
690
|
+
const initContent = initialContents.get(p);
|
|
691
|
+
|
|
692
|
+
if (initContent !== undefined && curContent !== initContent) {
|
|
693
|
+
// If original file had CRLF line endings, ensure CRLF is preserved consistently
|
|
694
|
+
const origHadCrlf = initContent.includes('\r\n');
|
|
695
|
+
if (origHadCrlf) {
|
|
696
|
+
curContent = curContent.replace(/\r?\n/g, '\r\n');
|
|
697
|
+
fs.writeFileSync(p, curContent, 'utf8');
|
|
108
698
|
}
|
|
109
|
-
}
|
|
110
699
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
700
|
+
anyModified = true;
|
|
701
|
+
const syntax = validateSyntax(p, curContent);
|
|
702
|
+
if (!syntax.valid) {
|
|
703
|
+
return false; // Reject git apply if syntax error introduced
|
|
704
|
+
}
|
|
114
705
|
}
|
|
115
|
-
|
|
116
|
-
return false;
|
|
117
|
-
} catch {
|
|
118
|
-
return false;
|
|
119
706
|
}
|
|
120
|
-
|
|
707
|
+
|
|
708
|
+
return anyModified;
|
|
709
|
+
}
|
package/src/triage.js
CHANGED
|
@@ -23,24 +23,52 @@ When generating the "Patch" section:
|
|
|
23
23
|
- Ensure @@ -line,count +line,count @@ markers accurately match the context.
|
|
24
24
|
- Include 1-2 lines of unchanged surrounding context above and below the change.
|
|
25
25
|
- Only touch lines that actually exist in the file.
|
|
26
|
-
-
|
|
26
|
+
- Match the exact current text and casing from the active working file on disk as the '-' (removed) lines.
|
|
27
27
|
`.trim();
|
|
28
28
|
|
|
29
|
+
const activeFilesSection = gitContext.activeFileContents && Object.keys(gitContext.activeFileContents).length > 0
|
|
30
|
+
? `Active Working Files on Disk (Current File Contents & Line Numbers):\n${JSON.stringify(gitContext.activeFileContents, null, 2)}`
|
|
31
|
+
: '';
|
|
32
|
+
|
|
29
33
|
const userPrompt = `
|
|
30
34
|
User problem / error trace:
|
|
31
35
|
"${errorDescription}"
|
|
32
36
|
|
|
37
|
+
${activeFilesSection}
|
|
38
|
+
|
|
33
39
|
Recent Cursor Composer Prompts:
|
|
34
40
|
${JSON.stringify(cursorPrompts, null, 2)}
|
|
35
41
|
|
|
36
42
|
Recent Repository Git Stream (Working diff + Commits):
|
|
37
|
-
${JSON.stringify(
|
|
43
|
+
${JSON.stringify({
|
|
44
|
+
branch: gitContext.branch,
|
|
45
|
+
untrackedFiles: gitContext.untrackedFiles,
|
|
46
|
+
workingDiff: gitContext.workingDiff,
|
|
47
|
+
recentCommits: gitContext.recentCommits,
|
|
48
|
+
}, null, 2)}
|
|
38
49
|
|
|
39
50
|
Provide a strict, concise triage output formatted with terminal-friendly Markdown:
|
|
40
51
|
1. **Root Cause**: Pinpoint the file, line, and commit/working diff where the breaking change occurred.
|
|
41
52
|
2. **Attributed Prompt**: Name the specific Cursor prompt that likely caused this diff (if found in the list; otherwise state none).
|
|
42
53
|
3. **The Shift**: Explain what broke (e.g., schema key rename, dropped parameter, async/await omission).
|
|
43
54
|
4. **Patch**: Provide the exact unified diff adhering strictly to the system diff formatting instructions.
|
|
55
|
+
|
|
56
|
+
CRITICAL RULES FOR THE PATCH:
|
|
57
|
+
- The '-' (removed) lines MUST match the EXACT text and casing currently in the file shown in "Active Working Files on Disk".
|
|
58
|
+
- Target the file accurately:
|
|
59
|
+
--- a/<path>
|
|
60
|
+
+++ b/<path>
|
|
61
|
+
- Never delete the entire file. The patch must FIX the bug in the existing file.
|
|
62
|
+
- Format strictly as a valid unified diff block in \`\`\`diff.
|
|
63
|
+
- Only change the breaking line(s). Keep 1-2 lines of unchanged surrounding context lines above and below without '+' or '-'.
|
|
64
|
+
- Example format:
|
|
65
|
+
\`\`\`diff
|
|
66
|
+
--- a/app.js
|
|
67
|
+
+++ b/app.js
|
|
68
|
+
@@ -6,3 +6,3 @@
|
|
69
|
+
- console.log(\`Loading dashboard for user ID: \${profile.userid.toUpperCase()}\`);
|
|
70
|
+
+ console.log(\`Loading dashboard for user ID: \${profile.user_id.toUpperCase()}\`);
|
|
71
|
+
\`\`\`
|
|
44
72
|
`;
|
|
45
73
|
|
|
46
74
|
// 1. Prefer Anthropic if key exists
|
|
@@ -66,4 +94,4 @@ Provide a strict, concise triage output formatted with terminal-friendly Markdow
|
|
|
66
94
|
]
|
|
67
95
|
});
|
|
68
96
|
return response.choices[0].message.content;
|
|
69
|
-
}
|
|
97
|
+
}
|