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 CHANGED
@@ -1,146 +1,123 @@
1
- # `ai-blackbox`
1
+ Markdown
2
+ # triage-trip
2
3
 
3
- > Autonomous AI Regression Triage & Provenance CLI. Pinpoint breaking contract changes introduced by AI coding tools in seconds.
4
+ > Autonomous CLI to pinpoint and patch regressions introduced by AI code assistants (Cursor, Copilot).
4
5
 
5
- <p align="center">
6
- <img src="./assets/demo.gif" alt="AI-Blackbox Demo" width="700">
7
- </p>
8
- ---
6
+ ![triage-trip Demo](assets/demo.gif)
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
- AI coding assistants (Cursor, GitHub Copilot, Windsurf) dramatically accelerate drafting and refactoring code. However, they frequently cause **silent contract drift**:
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. **Inspects Git History:** Automatically reads uncommitted working changes and recent commits using `simple-git`.
39
- 2. **Extracts AI Session Provenance:** Scans local IDE storage (e.g., Cursor's local SQLite database `state.vscdb`) to retrieve recent prompts and user instructions.
40
- 3. **Triages Semantic Drift:** Correlates the runtime stack trace with recent AST modifications and prompts via the OpenAI API to deliver an instant diagnostic report.
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
- ## Installation
45
-
46
- ### Prerequisites
25
+ ## Quick Start
47
26
 
48
- * **Node.js** (v18 or higher, ESM native)
49
- * An active **OpenAI API Key**
50
-
51
- ### Local Setup
27
+ Run instantly without global installation:
52
28
 
53
29
  ```bash
54
- # Clone the repository
55
- git clone https://github.com/your-username/ai-blackbox.git
56
- cd ai-blackbox
57
-
58
- # Install dependencies
59
- npm install
60
-
61
- # Link executable globally (optional for direct CLI access)
62
- npm link
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
- ## Quickstart & Usage
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
- # Via npm script
81
- node bin/cli.js triage "TypeError: Cannot read properties of undefined (reading 'toUpperCase')"
63
+ # Stage the updated doc and new GIF
64
+ git add README.md assets/demo.gif package.json bin/cli.js
82
65
 
83
- # Or globally (if linked)
84
- ai-blackbox triage "TypeError: Cannot read properties of undefined (reading 'toUpperCase')"
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
- ### Options
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
- ## Example Output
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
- ```markdown
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
- ✔ Root cause isolated.
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
- 1. **Root Cause**: The error occurs in `app.js` at line 6, where `profile.userId` is accessed.
103
- The breaking change was introduced in commit `b83c577` ("refactor: update user schema"),
104
- which modified the `getUserProfile` function in `user.js`.
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
- 2. **Attributed Prompt**: "Rename userId to user_id across the returned profile object."
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
- 3. **The Shift**: The `getUserProfile` function now returns `user_id` instead of `userId`,
109
- causing `profile.userId` to evaluate to `undefined`.
93
+ ## CLI Options
110
94
 
111
- 4. **Patch**:
112
- --- a/app.js
113
- +++ b/app.js
114
- @@ -6,2 +6,2 @@
115
- - console.log(`Loading dashboard for user ID: ${profile.userId.toUpperCase()}`);
116
- + console.log(`Loading dashboard for user ID: ${profile.user_id.toUpperCase()}`);
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
- ## Project Structure
123
-
124
- ```text
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
- ## Roadmap
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
- * [x] Git diff & commit stream extraction
142
- * [x] Local SQLite prompt extraction for Cursor Composer
143
- * [x] OpenAI regression diagnostic engine
144
- * [ ] Auto-patch application flag (`--fix` via `git apply`)
145
- * [ ] Multi-IDE adapters (GitHub Copilot Chat, Windsurf Cascade, Claude Code)
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■ AI-Blackbox Triage Engine\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 via git apply...').start();
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.0",
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": "echo \"Error: no test specified\" && exit 1"
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
- export async function getRecentChanges(maxCommits = 3) {
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, // Tells the LLM if newly created files exist
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
- // src/patch.js
1
+ import { spawn } from 'child_process';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
- import { spawn } from 'child_process';
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
- const diffMatch =
8
- llmOutput.match(/```diff\s*([\s\S]*?)```/) ||
9
- llmOutput.match(/(--- [^\n]+\n\+\+\+ [^\n]+[\s\S]*)/);
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
- if (!diffMatch) return null;
12
- let rawDiff = diffMatch[1] || diffMatch[0];
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
- rawDiff = rawDiff.replace(/\u00A0/g, ' ').replace(/\r\n/g, '\n');
15
- rawDiff = rawDiff.replace(/^index [0-9a-f]+\.\.[0-9a-f]+.*$\n?/m, '');
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
- if (!rawDiff.endsWith('\n')) {
18
- rawDiff += '\n';
66
+ function finalizeHunk() {
67
+ if (currentHunkIdx >= 0) {
68
+ result[currentHunkIdx] = `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@${hunkHeaderExtra}`;
69
+ }
19
70
  }
20
71
 
21
- return rawDiff.trim() + '\n';
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
- export async function applyDiff(diffString) {
25
- const flags = [
26
- '--recount',
27
- '--ignore-space-change',
28
- '--ignore-whitespace',
29
- '--whitespace=nowarn',
30
- '-C1'
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
- // 1. Try git apply
34
- const attempt = await runGitApply(diffString, flags);
35
- if (attempt.success) return true;
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
- // 2. Try git apply with 3way
38
- const attempt3Way = await runGitApply(diffString, [...flags, '--3way']);
39
- if (attempt3Way.success) return true;
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
- // 3. Fallback: Direct line search & replace on disk
42
- const applied = fallbackManualPatch(diffString);
43
- if (applied) return true;
425
+ const ast = parse(code, { sourceType: 'module', plugins: ['jsx', 'typescript'] });
426
+ let replaced = false;
427
+ let newCode = code;
44
428
 
45
- throw new Error(`Patch cannot apply cleanly:\n${attempt.stderr}`);
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', (chunk) => {
56
- stderr += chunk.toString();
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
- function fallbackManualPatch(diffString) {
72
- try {
73
- const fileMatch = diffString.match(/\+\+\+ b\/(.+)/);
74
- if (!fileMatch) return false;
75
-
76
- const relativePath = fileMatch[1].trim();
77
- const filePath = path.resolve(process.cwd(), relativePath);
78
- if (!fs.existsSync(filePath)) return false;
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
- const fileContent = fs.readFileSync(filePath, 'utf8');
613
+ const normalized = normalizeDiff(diffString);
81
614
 
82
- // Extract removed (-) and added (+) lines from the hunk
83
- const lines = diffString.split('\n');
84
- let oldLine = null;
85
- let newLine = null;
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
- for (const line of lines) {
88
- if (line.startsWith('-') && !line.startsWith('---')) {
89
- oldLine = line.slice(1).trim();
90
- } else if (line.startsWith('+') && !line.startsWith('+++')) {
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
- if (!oldLine || !newLine) return false;
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
- // Scan file lines and replace the matching content while preserving original indentation
98
- const fileLines = fileContent.split('\n');
99
- let replaced = false;
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
- for (let i = 0; i < fileLines.length; i++) {
102
- if (fileLines[i].includes(oldLine)) {
103
- const indentMatch = fileLines[i].match(/^\s*/);
104
- const indent = indentMatch ? indentMatch[0] : '';
105
- fileLines[i] = indent + newLine;
106
- replaced = true;
107
- break;
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
- if (replaced) {
112
- fs.writeFileSync(filePath, fileLines.join('\n'), 'utf8');
113
- return true;
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
- - Do not invent comments in context lines.
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(gitContext, null, 2)}
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
+ }