decision-guardian 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +792 -0
  3. package/dist/adapters/github/actions-logger.js +88 -0
  4. package/dist/adapters/github/comment.js +601 -0
  5. package/dist/adapters/github/github-provider.js +260 -0
  6. package/dist/adapters/github/health.js +56 -0
  7. package/dist/adapters/local/console-logger.js +46 -0
  8. package/dist/adapters/local/local-git-provider.js +247 -0
  9. package/dist/cli/commands/check.js +134 -0
  10. package/dist/cli/commands/init.js +58 -0
  11. package/dist/cli/commands/template.js +70 -0
  12. package/dist/cli/formatter.js +68 -0
  13. package/dist/cli/index.js +12458 -0
  14. package/dist/cli/licenses.txt +143 -0
  15. package/dist/cli/paths.js +40 -0
  16. package/dist/core/content-matchers.js +333 -0
  17. package/dist/core/health.js +52 -0
  18. package/dist/core/interfaces/index.js +2 -0
  19. package/dist/core/interfaces/logger.js +2 -0
  20. package/dist/core/interfaces/scm-provider.js +5 -0
  21. package/dist/core/logger.js +20 -0
  22. package/dist/core/matcher.js +184 -0
  23. package/dist/core/metrics.js +87 -0
  24. package/dist/core/parser.js +338 -0
  25. package/dist/core/rule-evaluator.js +186 -0
  26. package/dist/core/rule-parser.js +211 -0
  27. package/dist/core/rule-types.js +22 -0
  28. package/dist/core/trie.js +83 -0
  29. package/dist/core/types.js +2 -0
  30. package/dist/index.js +61142 -0
  31. package/dist/licenses.txt +758 -0
  32. package/dist/main.js +290 -0
  33. package/dist/telemetry/payload.js +25 -0
  34. package/dist/telemetry/privacy.js +37 -0
  35. package/dist/telemetry/sender.js +40 -0
  36. package/dist/version.js +7 -0
  37. package/package.json +60 -0
  38. package/templates/advanced-rules.md +94 -0
  39. package/templates/api.md +70 -0
  40. package/templates/basic.md +38 -0
  41. package/templates/database.md +81 -0
  42. package/templates/security.md +89 -0
@@ -0,0 +1,260 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.GitHubProvider = void 0;
37
+ /**
38
+ * GitHubProvider — ISCMProvider for GitHub Actions context.
39
+ *
40
+ * Wraps the existing github-utils.ts logic behind the ISCMProvider interface.
41
+ * All @actions/github usage is contained here.
42
+ */
43
+ const github = __importStar(require("@actions/github"));
44
+ const metrics_1 = require("../../core/metrics");
45
+ const comment_1 = require("./comment");
46
+ const MAX_RETRIES = 3;
47
+ const MAX_WAIT_TIME_MS = 6 * 60 * 1000; // 6 minutes
48
+ class GitHubProvider {
49
+ octokit;
50
+ logger;
51
+ token;
52
+ owner;
53
+ repo;
54
+ pullNumber;
55
+ commentManager;
56
+ constructor(token, logger) {
57
+ this.token = token;
58
+ this.logger = logger;
59
+ this.octokit = github.getOctokit(token);
60
+ this.commentManager = new comment_1.CommentManager(token, logger);
61
+ const context = github.context;
62
+ if (!context.payload.pull_request) {
63
+ throw new Error('This action only works on pull_request events');
64
+ }
65
+ this.owner = context.repo.owner;
66
+ this.repo = context.repo.repo;
67
+ this.pullNumber = context.payload.pull_request.number;
68
+ }
69
+ /**
70
+ * Get list of changed file paths in the PR.
71
+ */
72
+ async getChangedFiles() {
73
+ const files = [];
74
+ let page = 1;
75
+ const MAX_PAGES = 30;
76
+ while (page <= MAX_PAGES) {
77
+ const { data } = await this.executeWithRateLimit(() => this.octokit.rest.pulls.listFiles({
78
+ owner: this.owner,
79
+ repo: this.repo,
80
+ pull_number: this.pullNumber,
81
+ per_page: 100,
82
+ page,
83
+ }), `fetch files page ${page}`);
84
+ files.push(...data.map((f) => f.filename.replace(/\\/g, '/')));
85
+ this.logger.debug(`Fetched page ${page}: ${data.length} files`);
86
+ if (data.length < 100)
87
+ break;
88
+ page++;
89
+ }
90
+ if (page > MAX_PAGES) {
91
+ throw new Error('PR too large for automatic verification');
92
+ }
93
+ return files;
94
+ }
95
+ /**
96
+ * Get file diffs with patch content for advanced rule matching.
97
+ */
98
+ async getFileDiffs() {
99
+ const firstPage = await this.executeWithRateLimit(() => this.octokit.rest.pulls.listFiles({
100
+ owner: this.owner,
101
+ repo: this.repo,
102
+ pull_number: this.pullNumber,
103
+ per_page: 100,
104
+ page: 1,
105
+ }), 'fetch files page 1');
106
+ if (firstPage.data.length < 100) {
107
+ this.logger.debug(`Fetched all ${firstPage.data.length} file diffs in single request`);
108
+ return firstPage.data.map((f) => ({
109
+ filename: f.filename.replace(/\\/g, '/'),
110
+ status: f.status,
111
+ additions: f.additions,
112
+ deletions: f.deletions,
113
+ changes: f.changes,
114
+ patch: f.patch || '',
115
+ previous_filename: f.previous_filename,
116
+ }));
117
+ }
118
+ const fileMap = new Map();
119
+ let page = 1;
120
+ const MAX_PAGES = 30;
121
+ while (page <= MAX_PAGES) {
122
+ const { data } = page === 1
123
+ ? firstPage
124
+ : await this.executeWithRateLimit(() => this.octokit.rest.pulls.listFiles({
125
+ owner: this.owner,
126
+ repo: this.repo,
127
+ pull_number: this.pullNumber,
128
+ per_page: 100,
129
+ page,
130
+ }), `fetch diffs page ${page}`);
131
+ if (data.length === 0)
132
+ break;
133
+ for (const file of data) {
134
+ const normalized = file.filename.replace(/\\/g, '/');
135
+ fileMap.set(normalized, {
136
+ filename: normalized,
137
+ status: file.status,
138
+ additions: file.additions,
139
+ deletions: file.deletions,
140
+ changes: file.changes,
141
+ patch: file.patch || '',
142
+ previous_filename: file.previous_filename,
143
+ });
144
+ }
145
+ this.logger.debug(`Fetched page ${page}: ${data.length} file diffs`);
146
+ if (data.length < 100)
147
+ break;
148
+ page++;
149
+ }
150
+ if (page > MAX_PAGES) {
151
+ throw new Error('PR too large for automatic verification');
152
+ }
153
+ return Array.from(fileMap.values());
154
+ }
155
+ /**
156
+ * Stream file diffs for very large PRs.
157
+ */
158
+ async *streamFileDiffs() {
159
+ let page = 1;
160
+ const MAX_PAGES = 30;
161
+ while (page <= MAX_PAGES) {
162
+ const { data } = await this.executeWithRateLimit(() => this.octokit.rest.pulls.listFiles({
163
+ owner: this.owner,
164
+ repo: this.repo,
165
+ pull_number: this.pullNumber,
166
+ per_page: 100,
167
+ page,
168
+ }), `stream diffs page ${page}`);
169
+ if (data.length === 0)
170
+ break;
171
+ yield data.map((f) => ({
172
+ filename: f.filename.replace(/\\/g, '/'),
173
+ status: f.status,
174
+ additions: f.additions,
175
+ deletions: f.deletions,
176
+ changes: f.changes,
177
+ patch: f.patch || '',
178
+ previous_filename: f.previous_filename,
179
+ }));
180
+ if (data.length < 100)
181
+ break;
182
+ page++;
183
+ }
184
+ if (page > MAX_PAGES) {
185
+ throw new Error('PR too large for automatic verification');
186
+ }
187
+ }
188
+ /**
189
+ * Post decision alerts as PR comment.
190
+ */
191
+ async postComment(matches) {
192
+ await this.commentManager.postAlert(matches, {
193
+ owner: this.owner,
194
+ repo: this.repo,
195
+ number: this.pullNumber,
196
+ });
197
+ }
198
+ /**
199
+ * Post "All Clear" status if previous alerts exist.
200
+ */
201
+ async postAllClear() {
202
+ await this.commentManager.postAllClear({
203
+ owner: this.owner,
204
+ repo: this.repo,
205
+ number: this.pullNumber,
206
+ });
207
+ }
208
+ /**
209
+ * Execute with rate limit handling (Circuit Breaker pattern).
210
+ */
211
+ async executeWithRateLimit(operation, description) {
212
+ let lastError;
213
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
214
+ try {
215
+ metrics_1.metrics.incrementApiCall();
216
+ return await operation();
217
+ }
218
+ catch (error) {
219
+ const err = error;
220
+ lastError = error instanceof Error ? error : new Error(String(error));
221
+ const isRateLimit = (err.status === 403 && err.response?.headers['x-ratelimit-remaining'] === '0') ||
222
+ err.status === 429;
223
+ if (!isRateLimit) {
224
+ metrics_1.metrics.incrementApiError();
225
+ throw error;
226
+ }
227
+ if (attempt >= MAX_RETRIES) {
228
+ metrics_1.metrics.incrementApiError();
229
+ throw error;
230
+ }
231
+ let waitMs = 60000;
232
+ let calculated = false;
233
+ if (err.response?.headers['x-ratelimit-reset']) {
234
+ const resetEpoch = parseInt(err.response.headers['x-ratelimit-reset'], 10);
235
+ if (!isNaN(resetEpoch)) {
236
+ waitMs = Math.max(resetEpoch * 1000 - Date.now() + 1000, 1000);
237
+ calculated = true;
238
+ }
239
+ }
240
+ if (!calculated && err.response?.headers['retry-after']) {
241
+ const retrySeconds = parseInt(err.response.headers['retry-after'], 10);
242
+ if (!isNaN(retrySeconds)) {
243
+ waitMs = retrySeconds * 1000;
244
+ }
245
+ }
246
+ if (waitMs > MAX_WAIT_TIME_MS) {
247
+ this.logger.error(`Rate limit hit for ${description}. ` +
248
+ `Wait time (${Math.round(waitMs / 60000)}m) exceeds limit.`);
249
+ throw error;
250
+ }
251
+ metrics_1.metrics.incrementRateLimitHit();
252
+ this.logger.warning(`Rate limit hit for ${description}. ` +
253
+ `Waiting ${Math.round(waitMs / 1000)}s before retry ${attempt}/${MAX_RETRIES}`);
254
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
255
+ }
256
+ }
257
+ throw lastError || new Error('Operation failed');
258
+ }
259
+ }
260
+ exports.GitHubProvider = GitHubProvider;
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.validateToken = validateToken;
37
+ /**
38
+ * GitHub-specific health check — validates GitHub token and API access.
39
+ */
40
+ const github = __importStar(require("@actions/github"));
41
+ /**
42
+ * Validate that the GitHub token has proper permissions.
43
+ */
44
+ async function validateToken(token, logger) {
45
+ try {
46
+ const octokit = github.getOctokit(token);
47
+ const { owner, repo } = github.context.repo;
48
+ await octokit.rest.repos.get({ owner, repo });
49
+ return true;
50
+ }
51
+ catch (error) {
52
+ const message = error instanceof Error ? error.message : String(error);
53
+ logger.error(`GitHub token validation failed: ${message}`);
54
+ return false;
55
+ }
56
+ }
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ConsoleLogger = void 0;
4
+ // ANSI color codes
5
+ const RESET = '\x1b[0m';
6
+ const BLUE = '\x1b[34m';
7
+ const YELLOW = '\x1b[33m';
8
+ const RED = '\x1b[31m';
9
+ const GRAY = '\x1b[90m';
10
+ const BOLD = '\x1b[1m';
11
+ const CYAN = '\x1b[36m';
12
+ class ConsoleLogger {
13
+ groupDepth = 0;
14
+ info(message) {
15
+ const indent = this.getIndent();
16
+ console.log(`${indent}${BLUE}ℹ${RESET} ${message}`);
17
+ }
18
+ warning(message) {
19
+ const indent = this.getIndent();
20
+ console.warn(`${indent}${YELLOW}⚠${RESET} ${YELLOW}${message}${RESET}`);
21
+ }
22
+ error(message) {
23
+ const indent = this.getIndent();
24
+ console.error(`${indent}${RED}✖${RESET} ${RED}${message}${RESET}`);
25
+ }
26
+ debug(message) {
27
+ if (process.env.DEBUG || process.env.VERBOSE) {
28
+ const indent = this.getIndent();
29
+ console.log(`${indent}${GRAY}[debug]${RESET} ${GRAY}${message}${RESET}`);
30
+ }
31
+ }
32
+ startGroup(name) {
33
+ const indent = this.getIndent();
34
+ console.log(`${indent}${BOLD}${CYAN}▸ ${name}${RESET}`);
35
+ this.groupDepth++;
36
+ }
37
+ endGroup() {
38
+ if (this.groupDepth > 0) {
39
+ this.groupDepth--;
40
+ }
41
+ }
42
+ getIndent() {
43
+ return ' '.repeat(this.groupDepth);
44
+ }
45
+ }
46
+ exports.ConsoleLogger = ConsoleLogger;
@@ -0,0 +1,247 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LocalGitProvider = void 0;
4
+ /**
5
+ * LocalGitProvider — ISCMProvider for local git repositories.
6
+ *
7
+ * Uses `git diff` to get changed files and diffs.
8
+ * Configuration passed via constructor (diff mode, base branch, working directory).
9
+ */
10
+ const child_process_1 = require("child_process");
11
+ class LocalGitProvider {
12
+ config;
13
+ constructor(config) {
14
+ // Validate baseBranch to prevent shell injection
15
+ if (config.baseBranch && !this.isValidBranchName(config.baseBranch)) {
16
+ throw new Error(`Invalid baseBranch: "${config.baseBranch}". ` +
17
+ 'Branch names must only contain alphanumeric characters, hyphens, underscores, slashes, and dots.');
18
+ }
19
+ this.config = {
20
+ mode: config.mode,
21
+ baseBranch: config.baseBranch || 'main',
22
+ cwd: config.cwd || process.cwd(),
23
+ };
24
+ }
25
+ /**
26
+ * Validate branch name to prevent shell injection.
27
+ * Allows: letters, numbers, -, _, /, .
28
+ * Git allows more, but we restrict to safe subset.
29
+ */
30
+ isValidBranchName(name) {
31
+ // Only allow safe characters: alphanumeric, -, _, /, .
32
+ // Reject shell metacharacters: ; & | $ ` \ " ' < > ( ) etc.
33
+ // eslint-disable-next-line no-useless-escape
34
+ return /^[a-zA-Z0-9\-_.\/]+$/.test(name) && name.length > 0 && name.length < 256; // Reasonable length limit
35
+ }
36
+ /**
37
+ * Get list of changed file paths from git diff.
38
+ * Uses NUL-terminated output to safely handle spaces/newlines/quotes.
39
+ */
40
+ async getChangedFiles() {
41
+ const diffArgs = this.buildDiffArgs();
42
+ const args = ['diff', ...diffArgs, '--name-only', '-z'];
43
+ const output = this.execGit(args);
44
+ return output
45
+ .split('\0')
46
+ .map((f) => f.trim())
47
+ .filter(Boolean)
48
+ .map((f) => this.normalizePath(f));
49
+ }
50
+ /**
51
+ * Get file diffs with patch content for advanced rule matching.
52
+ */
53
+ async getFileDiffs() {
54
+ const diffArgs = this.buildDiffArgs();
55
+ const numstatArgs = ['diff', ...diffArgs, '--numstat', '-z', '-M'];
56
+ const patchArgs = ['diff', ...diffArgs, '-U3', '-M'];
57
+ const numstatOutput = this.execGit(numstatArgs);
58
+ const patchOutput = this.execGit(patchArgs);
59
+ const files = this.parseNumstat(numstatOutput);
60
+ const patches = this.parsePatchesByFile(patchOutput);
61
+ return files.map((f) => ({
62
+ ...f,
63
+ patch: patches.get(f.filename) || '',
64
+ }));
65
+ }
66
+ /**
67
+ * Build git diff arguments based on configured mode.
68
+ * Returns an array of args (safe for execFileSync).
69
+ */
70
+ buildDiffArgs() {
71
+ switch (this.config.mode) {
72
+ case 'staged':
73
+ return ['--cached'];
74
+ case 'branch':
75
+ return [`${this.config.baseBranch}...HEAD`];
76
+ case 'all':
77
+ return ['HEAD'];
78
+ default:
79
+ return ['--cached'];
80
+ }
81
+ }
82
+ /**
83
+ * Execute a git command using execFileSync (no shell).
84
+ * Args is an array of git arguments (e.g. ['diff', '--name-only', '-z'])
85
+ */
86
+ execGit(args) {
87
+ try {
88
+ const out = (0, child_process_1.execFileSync)('git', args, {
89
+ cwd: this.config.cwd,
90
+ encoding: 'utf-8',
91
+ stdio: ['pipe', 'pipe', 'pipe'],
92
+ maxBuffer: 10 * 1024 * 1024, // 10MB for large diffs
93
+ });
94
+ // execFileSync with encoding returns string already
95
+ return String(out).trim();
96
+ }
97
+ catch (error) {
98
+ const message = error instanceof Error ? error.message : String(error);
99
+ throw new Error(`Git command failed: git ${args.join(' ')}\n${message}`);
100
+ }
101
+ }
102
+ /**
103
+ * Normalize path: unescape backslash escapes and use forward slashes.
104
+ */
105
+ normalizePath(raw) {
106
+ // Git may escape characters with backslashes in some outputs; unescape common escapes
107
+ // e.g. "a\\/b" or "file\\ name" -> remove the escaping backslash
108
+ const unescaped = raw.replace(/\\(.)/g, '$1');
109
+ return unescaped.replace(/\\/g, '/').trim();
110
+ }
111
+ /**
112
+ * Parse git diff --numstat -z output into FileDiff objects (without patches).
113
+ *
114
+ * Behavior handled:
115
+ * - Normal: "<adds>\t<dels>\t<path>\0"
116
+ * - Rename-like: "<adds>\t<dels>\0<old_path>\0<new_path>\0"
117
+ *
118
+ * We iterate NUL-separated tokens and robustly handle both shapes.
119
+ */
120
+ parseNumstat(output) {
121
+ if (!output)
122
+ return [];
123
+ const parts = output.split('\0');
124
+ const results = [];
125
+ let i = 0;
126
+ while (i < parts.length) {
127
+ const token = parts[i++];
128
+ if (!token)
129
+ continue;
130
+ // token usually looks like "<adds>\t<dels>\t<path>" but for some rename cases it might be "<adds>\t<dels>"
131
+ const tabParts = token.split('\t');
132
+ const additionsRaw = tabParts[0] ?? '-';
133
+ const deletionsRaw = tabParts[1] ?? '-';
134
+ const additions = additionsRaw === '-' ? 0 : parseInt(additionsRaw, 10) || 0;
135
+ const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
136
+ // prefer filename inline if present
137
+ if (tabParts.length >= 3) {
138
+ const filenameRaw = tabParts.slice(2).join('\t');
139
+ const filename = this.normalizePath(filenameRaw);
140
+ const status = additions > 0 && deletions === 0
141
+ ? 'added'
142
+ : additions === 0 && deletions > 0
143
+ ? 'removed'
144
+ : 'modified';
145
+ results.push({
146
+ filename,
147
+ status,
148
+ additions,
149
+ deletions,
150
+ changes: additions + deletions,
151
+ });
152
+ continue;
153
+ }
154
+ // If we get here, the token did not include a filename -- filenames come in subsequent NUL-separated tokens.
155
+ // Consume next NUL parts for old/new.
156
+ const next1 = i < parts.length ? parts[i++] : '';
157
+ // Peek to see if there's another token (rename case)
158
+ const peek = i < parts.length ? parts[i] : undefined;
159
+ let filename = '';
160
+ let status = 'modified';
161
+ if (peek !== undefined && peek !== '') {
162
+ // Treat as rename: next1 = old, peek = new
163
+ const next2 = parts[i++];
164
+ filename = this.normalizePath(next2);
165
+ status = 'renamed';
166
+ }
167
+ else {
168
+ // Single following filename
169
+ filename = this.normalizePath(next1 || '');
170
+ status =
171
+ additions > 0 && deletions === 0
172
+ ? 'added'
173
+ : additions === 0 && deletions > 0
174
+ ? 'removed'
175
+ : 'modified';
176
+ }
177
+ if (filename) {
178
+ results.push({
179
+ filename,
180
+ status,
181
+ additions,
182
+ deletions,
183
+ changes: additions + deletions,
184
+ });
185
+ }
186
+ }
187
+ return results;
188
+ }
189
+ /**
190
+ * Parse unified diff output and split by file.
191
+ * Tries to robustly extract the b/<path> filename from the diff header whether
192
+ * paths are quoted or not, and unescapes where necessary.
193
+ */
194
+ parsePatchesByFile(output) {
195
+ const patches = new Map();
196
+ if (!output)
197
+ return patches;
198
+ // Split by diff header (keep header with section)
199
+ const sections = output.split(/(?=^diff --git )/m);
200
+ for (const section of sections) {
201
+ if (!section.trim())
202
+ continue;
203
+ // Several header forms:
204
+ // diff --git a/path b/path
205
+ // diff --git "a/path with space" "b/path with space"
206
+ // We capture both sides and prefer the b/ path
207
+ let filename;
208
+ // Try unquoted first
209
+ const standardMatch = section.match(/^diff --git a\/(.+?) b\/(.+?)(?:\s|$)/m);
210
+ if (standardMatch) {
211
+ filename = standardMatch[2];
212
+ }
213
+ else {
214
+ // Try quoted "a/..." "b/..."
215
+ const quotedMatch = section.match(/^diff --git "(?:a\/(.+?))" "(?:b\/(.+?))"(?:\s|$)/m);
216
+ if (quotedMatch) {
217
+ filename = quotedMatch[2];
218
+ }
219
+ else {
220
+ // As a last resort, try to parse rename/from/to lines
221
+ const rnFrom = section.match(/^rename from (.+)$/m);
222
+ const rnTo = section.match(/^rename to (.+)$/m);
223
+ if (rnTo)
224
+ filename = rnTo[1].trim();
225
+ else if (rnFrom)
226
+ filename = rnFrom[1].trim();
227
+ }
228
+ }
229
+ if (!filename)
230
+ continue;
231
+ filename = this.normalizePath(filename);
232
+ // Find first hunk start for patch contents
233
+ const hunkStart = section.search(/^@@/m);
234
+ if (hunkStart !== -1) {
235
+ // include the hunk(s) only for patch (from first @@ onward)
236
+ const patch = section.substring(hunkStart);
237
+ patches.set(filename, patch);
238
+ }
239
+ else {
240
+ // No hunks (maybe binary or mode-only changes); store whole section
241
+ patches.set(filename, section);
242
+ }
243
+ }
244
+ return patches;
245
+ }
246
+ }
247
+ exports.LocalGitProvider = LocalGitProvider;