patchwarden 0.4.0 → 0.6.1

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 (189) hide show
  1. package/PatchWarden.cmd +51 -0
  2. package/README.en.md +1022 -0
  3. package/README.md +897 -358
  4. package/dist/assessments/agentAssessor.d.ts +15 -0
  5. package/dist/assessments/agentAssessor.js +293 -0
  6. package/dist/assessments/assessmentStore.d.ts +133 -0
  7. package/dist/assessments/assessmentStore.js +238 -0
  8. package/dist/assessments/confirmCli.d.ts +9 -0
  9. package/dist/assessments/confirmCli.js +26 -0
  10. package/dist/config.d.ts +22 -1
  11. package/dist/config.js +156 -3
  12. package/dist/direct/directAudit.d.ts +23 -0
  13. package/dist/direct/directAudit.js +309 -0
  14. package/dist/direct/directGuards.d.ts +20 -0
  15. package/dist/direct/directGuards.js +137 -0
  16. package/dist/direct/directPatch.d.ts +17 -0
  17. package/dist/direct/directPatch.js +113 -0
  18. package/dist/direct/directSessionStore.d.ts +63 -0
  19. package/dist/direct/directSessionStore.js +192 -0
  20. package/dist/direct/directVerification.d.ts +12 -0
  21. package/dist/direct/directVerification.js +96 -0
  22. package/dist/doctor.js +73 -5
  23. package/dist/logging.d.ts +52 -0
  24. package/dist/logging.js +123 -0
  25. package/dist/runner/agentInvocation.d.ts +23 -0
  26. package/dist/runner/agentInvocation.js +106 -0
  27. package/dist/runner/changeCapture.d.ts +70 -0
  28. package/dist/runner/changeCapture.js +278 -6
  29. package/dist/runner/runTask.js +271 -65
  30. package/dist/runner/simpleProcess.d.ts +21 -0
  31. package/dist/runner/simpleProcess.js +206 -0
  32. package/dist/runner/watch.js +17 -2
  33. package/dist/security/commandGuard.d.ts +2 -1
  34. package/dist/security/commandGuard.js +25 -3
  35. package/dist/security/riskEngine.d.ts +27 -0
  36. package/dist/security/riskEngine.js +118 -0
  37. package/dist/security/runtimeGuard.d.ts +6 -0
  38. package/dist/security/runtimeGuard.js +28 -0
  39. package/dist/smoke-test.js +1353 -7
  40. package/dist/test/unit/android-doctor.test.d.ts +1 -0
  41. package/dist/test/unit/android-doctor.test.js +118 -0
  42. package/dist/test/unit/chinese-path.test.d.ts +1 -0
  43. package/dist/test/unit/chinese-path.test.js +91 -0
  44. package/dist/test/unit/command-guard.test.d.ts +1 -0
  45. package/dist/test/unit/command-guard.test.js +160 -0
  46. package/dist/test/unit/direct-guards.test.d.ts +1 -0
  47. package/dist/test/unit/direct-guards.test.js +213 -0
  48. package/dist/test/unit/logging.test.d.ts +1 -0
  49. package/dist/test/unit/logging.test.js +275 -0
  50. package/dist/test/unit/path-guard.test.d.ts +1 -0
  51. package/dist/test/unit/path-guard.test.js +109 -0
  52. package/dist/test/unit/safe-status.test.d.ts +1 -0
  53. package/dist/test/unit/safe-status.test.js +165 -0
  54. package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
  55. package/dist/test/unit/sensitive-guard.test.js +104 -0
  56. package/dist/test/unit/sync-file.test.d.ts +1 -0
  57. package/dist/test/unit/sync-file.test.js +154 -0
  58. package/dist/test/unit/watcher-status.test.d.ts +1 -0
  59. package/dist/test/unit/watcher-status.test.js +169 -0
  60. package/dist/tools/androidDoctor.d.ts +38 -0
  61. package/dist/tools/androidDoctor.js +391 -0
  62. package/dist/tools/applyPatch.d.ts +16 -0
  63. package/dist/tools/applyPatch.js +41 -0
  64. package/dist/tools/auditSession.d.ts +5 -0
  65. package/dist/tools/auditSession.js +12 -0
  66. package/dist/tools/auditTask.d.ts +7 -0
  67. package/dist/tools/auditTask.js +105 -7
  68. package/dist/tools/createDirectSession.d.ts +14 -0
  69. package/dist/tools/createDirectSession.js +54 -0
  70. package/dist/tools/createTask.d.ts +48 -1
  71. package/dist/tools/createTask.js +298 -47
  72. package/dist/tools/finalizeDirectSession.d.ts +19 -0
  73. package/dist/tools/finalizeDirectSession.js +84 -0
  74. package/dist/tools/getTaskSummary.d.ts +50 -0
  75. package/dist/tools/getTaskSummary.js +79 -2
  76. package/dist/tools/healthCheck.d.ts +27 -12
  77. package/dist/tools/healthCheck.js +44 -6
  78. package/dist/tools/readWorkspaceFile.d.ts +7 -1
  79. package/dist/tools/readWorkspaceFile.js +48 -2
  80. package/dist/tools/registry.js +270 -19
  81. package/dist/tools/runVerification.d.ts +16 -0
  82. package/dist/tools/runVerification.js +32 -0
  83. package/dist/tools/safeStatus.d.ts +19 -0
  84. package/dist/tools/safeStatus.js +72 -0
  85. package/dist/tools/savePlan.d.ts +1 -0
  86. package/dist/tools/savePlan.js +38 -7
  87. package/dist/tools/searchWorkspace.d.ts +19 -0
  88. package/dist/tools/searchWorkspace.js +205 -0
  89. package/dist/tools/syncFile.d.ts +18 -0
  90. package/dist/tools/syncFile.js +65 -0
  91. package/dist/tools/taskOutputs.d.ts +2 -2
  92. package/dist/tools/taskTemplates.js +2 -1
  93. package/dist/tools/toolCatalog.d.ts +4 -3
  94. package/dist/tools/toolCatalog.js +34 -5
  95. package/dist/tools/waitForTask.d.ts +2 -2
  96. package/dist/tools/waitForTask.js +1 -1
  97. package/dist/version.d.ts +2 -2
  98. package/dist/version.js +2 -2
  99. package/dist/watcherStatus.d.ts +1 -0
  100. package/dist/watcherStatus.js +96 -4
  101. package/docs/chatgpt-usage.md +101 -0
  102. package/docs/performance-notes.md +55 -0
  103. package/docs/release-checklist.md +14 -0
  104. package/docs/release-v0.6.0.md +71 -0
  105. package/docs/release-v0.6.1.md +75 -0
  106. package/examples/config.example.json +5 -0
  107. package/examples/openai-tunnel/README.md +11 -4
  108. package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
  109. package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
  110. package/package.json +13 -8
  111. package/scripts/brand-check.js +58 -12
  112. package/scripts/control-smoke.js +206 -0
  113. package/scripts/http-mcp-smoke.js +10 -2
  114. package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
  115. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
  116. package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
  117. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
  118. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
  119. package/scripts/lifecycle-smoke.js +402 -8
  120. package/scripts/manage-patchwarden.ps1 +639 -0
  121. package/scripts/mcp-manifest-check.js +123 -58
  122. package/scripts/mcp-smoke.js +160 -2
  123. package/scripts/pack-clean.js +160 -5
  124. package/scripts/package-manifest-check.js +78 -0
  125. package/scripts/patchwarden-mcp-direct.cmd +7 -0
  126. package/scripts/patchwarden-mcp-stdio.cmd +1 -1
  127. package/scripts/restart-patchwarden.ps1 +5 -4
  128. package/scripts/start-patchwarden-tunnel.ps1 +261 -30
  129. package/scripts/tunnel-supervisor-smoke.js +36 -4
  130. package/scripts/unit-tests.js +36 -0
  131. package/scripts/watcher-supervisor-smoke.js +10 -6
  132. package/src/assessments/agentAssessor.ts +324 -0
  133. package/src/assessments/assessmentStore.ts +426 -0
  134. package/src/assessments/confirmCli.ts +29 -0
  135. package/src/config.ts +178 -4
  136. package/src/direct/directAudit.ts +400 -0
  137. package/src/direct/directGuards.ts +279 -0
  138. package/src/direct/directPatch.ts +258 -0
  139. package/src/direct/directSessionStore.ts +345 -0
  140. package/src/direct/directVerification.ts +138 -0
  141. package/src/doctor.ts +103 -7
  142. package/src/logging.ts +152 -0
  143. package/src/runner/agentInvocation.ts +125 -0
  144. package/src/runner/changeCapture.ts +352 -6
  145. package/src/runner/runTask.ts +279 -63
  146. package/src/runner/simpleProcess.ts +223 -0
  147. package/src/runner/watch.ts +18 -2
  148. package/src/security/commandGuard.ts +46 -4
  149. package/src/security/riskEngine.ts +160 -0
  150. package/src/security/runtimeGuard.ts +41 -0
  151. package/src/smoke-test.ts +1291 -4
  152. package/src/test/unit/android-doctor.test.ts +158 -0
  153. package/src/test/unit/chinese-path.test.ts +106 -0
  154. package/src/test/unit/command-guard.test.ts +221 -0
  155. package/src/test/unit/direct-guards.test.ts +297 -0
  156. package/src/test/unit/logging.test.ts +325 -0
  157. package/src/test/unit/path-guard.test.ts +150 -0
  158. package/src/test/unit/safe-status.test.ts +187 -0
  159. package/src/test/unit/sensitive-guard.test.ts +124 -0
  160. package/src/test/unit/sync-file.test.ts +231 -0
  161. package/src/test/unit/watcher-status.test.ts +190 -0
  162. package/src/tools/androidDoctor.ts +424 -0
  163. package/src/tools/applyPatch.ts +86 -0
  164. package/src/tools/auditSession.ts +28 -0
  165. package/src/tools/auditTask.ts +111 -7
  166. package/src/tools/createDirectSession.ts +113 -0
  167. package/src/tools/createTask.ts +405 -55
  168. package/src/tools/finalizeDirectSession.ts +144 -0
  169. package/src/tools/getTaskSummary.ts +133 -2
  170. package/src/tools/healthCheck.ts +45 -6
  171. package/src/tools/readWorkspaceFile.ts +85 -2
  172. package/src/tools/registry.ts +305 -19
  173. package/src/tools/runVerification.ts +58 -0
  174. package/src/tools/safeStatus.ts +96 -0
  175. package/src/tools/savePlan.ts +57 -7
  176. package/src/tools/searchWorkspace.ts +275 -0
  177. package/src/tools/syncFile.ts +122 -0
  178. package/src/tools/taskTemplates.ts +2 -1
  179. package/src/tools/toolCatalog.ts +37 -6
  180. package/src/tools/waitForTask.ts +3 -3
  181. package/src/version.ts +2 -2
  182. package/src/watcherStatus.ts +101 -4
  183. package/tsconfig.json +18 -17
  184. package/Check-PatchWarden-Health.cmd +0 -6
  185. package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
  186. package/Restart-PatchWarden.cmd +0 -19
  187. package/Start-PatchWarden-Tunnel.cmd +0 -7
  188. package/docs/release-v0.3.0.md +0 -43
  189. package/docs/release-v0.4.0.md +0 -74
@@ -0,0 +1,279 @@
1
+ import { resolve, relative, isAbsolute, sep } from "node:path";
2
+ import { existsSync, statSync, openSync, readSync, closeSync } from "node:fs";
3
+ import { PatchWardenError } from "../errors.js";
4
+ import { isSensitivePath } from "../security/sensitiveGuard.js";
5
+ import { guardWorkspacePath } from "../security/pathGuard.js";
6
+ import { getConfig } from "../config.js";
7
+ import type { DirectSessionRecord } from "./directSessionStore.js";
8
+
9
+ // ── Session state guards ───────────────────────────────────────────
10
+
11
+ export function guardDirectSessionActive(session: DirectSessionRecord): void {
12
+ if (new Date(session.expires_at) < new Date()) {
13
+ throw new PatchWardenError(
14
+ "session_expired",
15
+ `Direct session "${session.session_id}" expired at ${session.expires_at}.`,
16
+ "Create a new direct session with create_direct_session.",
17
+ true,
18
+ { session_id: session.session_id, expires_at: session.expires_at }
19
+ );
20
+ }
21
+
22
+ if (session.finalized) {
23
+ throw new PatchWardenError(
24
+ "session_finalized",
25
+ `Direct session "${session.session_id}" has been finalized. No further modifications are allowed.`,
26
+ "Create a new direct session with create_direct_session to continue making changes.",
27
+ true,
28
+ { session_id: session.session_id, finalized_at: session.finalized_at }
29
+ );
30
+ }
31
+ }
32
+
33
+ export function guardDirectSessionFinalized(
34
+ session: DirectSessionRecord
35
+ ): void {
36
+ if (!session.finalized) {
37
+ throw new PatchWardenError(
38
+ "session_not_finalized",
39
+ `Direct session "${session.session_id}" has not been finalized. Call finalize_direct_session first.`,
40
+ "Call finalize_direct_session before audit_session.",
41
+ true,
42
+ { session_id: session.session_id }
43
+ );
44
+ }
45
+ }
46
+
47
+ // ── Path guards ────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Resolve a relative path against the session's repo_path and verify it stays
51
+ * inside both the repo and the workspace root.
52
+ */
53
+ export function guardDirectPath(
54
+ filePath: string,
55
+ resolvedRepoPath: string,
56
+ workspaceRoot: string
57
+ ): string {
58
+ const config = getConfig();
59
+ const resolved = resolve(resolvedRepoPath, filePath);
60
+ const normalizedRepo = resolve(resolvedRepoPath);
61
+ const normalizedWs = resolve(workspaceRoot);
62
+
63
+ // Check path is inside workspace root
64
+ const relToWs = relative(normalizedWs, resolved);
65
+ if (isAbsolute(relToWs) || relToWs.startsWith("..")) {
66
+ throw new PatchWardenError(
67
+ "path_outside_repo",
68
+ `Path "${filePath}" resolves outside the session repo.`,
69
+ "Use a relative path inside the session's repo_path.",
70
+ true,
71
+ { path: filePath, operation: "direct_path_access" }
72
+ );
73
+ }
74
+
75
+ // Check path is inside repo_path
76
+ const relToRepo = relative(normalizedRepo, resolved);
77
+ if (isAbsolute(relToRepo) || relToRepo.startsWith("..")) {
78
+ throw new PatchWardenError(
79
+ "path_outside_repo",
80
+ `Path "${filePath}" is outside the session repo_path "${resolvedRepoPath}".`,
81
+ "Use a relative path inside the session's repo_path.",
82
+ true,
83
+ { path: filePath, operation: "direct_path_access" }
84
+ );
85
+ }
86
+
87
+ return resolved;
88
+ }
89
+
90
+ /**
91
+ * Guard a read path: must be inside repo, not sensitive, not binary.
92
+ */
93
+ export function guardDirectReadPath(
94
+ filePath: string,
95
+ resolvedRepoPath: string,
96
+ workspaceRoot: string
97
+ ): string {
98
+ const resolved = guardDirectPath(filePath, resolvedRepoPath, workspaceRoot);
99
+ const normalized = filePath.replace(/\\/g, "/");
100
+
101
+ // Block internal PatchWarden paths (sessions, tasks, plans, assessments)
102
+ if (
103
+ normalized.startsWith(".patchwarden/") ||
104
+ normalized.includes("/.patchwarden/")
105
+ ) {
106
+ throw new PatchWardenError(
107
+ "internal_patchwarden_path_blocked",
108
+ `Access denied: "${filePath}" is inside the internal .patchwarden directory.`,
109
+ "Internal PatchWarden files cannot be accessed through Direct mode.",
110
+ true,
111
+ { path: filePath, operation: "direct_read" }
112
+ );
113
+ }
114
+
115
+ if (isSensitivePath(filePath)) {
116
+ throw new PatchWardenError(
117
+ "sensitive_path_blocked",
118
+ `Access denied: "${filePath}" matches a sensitive file pattern.`,
119
+ "Read only non-sensitive files within the session repo.",
120
+ true,
121
+ { path: filePath, operation: "direct_read" }
122
+ );
123
+ }
124
+
125
+ if (existsSync(resolved) && statSync(resolved).isFile() && isBinaryFile(resolved)) {
126
+ throw new PatchWardenError(
127
+ "binary_file_blocked",
128
+ `File "${filePath}" appears to be a binary file.`,
129
+ "Binary files cannot be read in Direct mode.",
130
+ true,
131
+ { path: filePath, operation: "direct_read" }
132
+ );
133
+ }
134
+
135
+ return resolved;
136
+ }
137
+
138
+ /**
139
+ * Guard a write path: must be inside repo, not sensitive, not binary,
140
+ * not in node_modules, release, or dist directories.
141
+ */
142
+ export function guardDirectWritePath(
143
+ filePath: string,
144
+ resolvedRepoPath: string,
145
+ workspaceRoot: string
146
+ ): string {
147
+ const resolved = guardDirectPath(filePath, resolvedRepoPath, workspaceRoot);
148
+ const normalized = filePath.replace(/\\/g, "/");
149
+
150
+ // Block internal PatchWarden paths
151
+ if (
152
+ normalized.startsWith(".patchwarden/") ||
153
+ normalized.includes("/.patchwarden/")
154
+ ) {
155
+ throw new PatchWardenError(
156
+ "internal_patchwarden_path_blocked",
157
+ `Modification denied: "${filePath}" is inside the internal .patchwarden directory.`,
158
+ "Internal PatchWarden files cannot be modified through Direct mode.",
159
+ true,
160
+ { path: filePath, operation: "direct_write" }
161
+ );
162
+ }
163
+
164
+ if (isSensitivePath(normalized)) {
165
+ throw new PatchWardenError(
166
+ "sensitive_path_blocked",
167
+ `Modification denied: "${filePath}" matches a sensitive file pattern.`,
168
+ "Do not modify .env, credentials, keys, or other sensitive files.",
169
+ true,
170
+ { path: filePath, operation: "direct_write" }
171
+ );
172
+ }
173
+
174
+ // Block node_modules
175
+ if (normalized.startsWith("node_modules/") || normalized.includes("/node_modules/")) {
176
+ throw new PatchWardenError(
177
+ "blocked_artifact_path",
178
+ `Modification denied: "${filePath}" is inside node_modules.`,
179
+ "Do not modify dependencies directly. Use package management tools instead.",
180
+ true,
181
+ { path: filePath, operation: "direct_write" }
182
+ );
183
+ }
184
+
185
+ // Block release/
186
+ if (normalized.startsWith("release/") || normalized.includes("/release/")) {
187
+ throw new PatchWardenError(
188
+ "blocked_artifact_path",
189
+ `Modification denied: "${filePath}" is inside the release directory.`,
190
+ "Do not manually modify release artifacts.",
191
+ true,
192
+ { path: filePath, operation: "direct_write" }
193
+ );
194
+ }
195
+
196
+ // Block dist/
197
+ if (normalized.startsWith("dist/") || normalized.includes("/dist/")) {
198
+ throw new PatchWardenError(
199
+ "blocked_artifact_path",
200
+ `Modification denied: "${filePath}" is inside the dist directory.`,
201
+ "Do not manually modify build output. Modify source files instead.",
202
+ true,
203
+ { path: filePath, operation: "direct_write" }
204
+ );
205
+ }
206
+
207
+ if (existsSync(resolved) && statSync(resolved).isFile() && isBinaryFile(resolved)) {
208
+ throw new PatchWardenError(
209
+ "binary_file_blocked",
210
+ `Modification denied: "${filePath}" appears to be a binary file.`,
211
+ "Binary files cannot be modified in Direct mode.",
212
+ true,
213
+ { path: filePath, operation: "direct_write" }
214
+ );
215
+ }
216
+
217
+ return resolved;
218
+ }
219
+
220
+ // ── Size guards ────────────────────────────────────────────────────
221
+
222
+ export function guardDirectPatchSize(patchBytes: number): void {
223
+ const config = getConfig();
224
+ if (patchBytes > config.directMaxPatchBytes) {
225
+ throw new PatchWardenError(
226
+ "patch_too_large",
227
+ `Patch size ${patchBytes} bytes exceeds maximum ${config.directMaxPatchBytes} bytes.`,
228
+ "Split the patch into smaller operations or reduce the patch size.",
229
+ true,
230
+ { patch_bytes: patchBytes, max_bytes: config.directMaxPatchBytes }
231
+ );
232
+ }
233
+ }
234
+
235
+ export function guardDirectFileSize(fileBytes: number): void {
236
+ const config = getConfig();
237
+ if (fileBytes > config.directMaxFileBytes) {
238
+ throw new PatchWardenError(
239
+ "file_too_large",
240
+ `File size ${fileBytes} bytes exceeds maximum ${config.directMaxFileBytes} bytes.`,
241
+ "Use search_workspace to find specific content, or split the read into smaller portions.",
242
+ true,
243
+ { file_bytes: fileBytes, max_bytes: config.directMaxFileBytes }
244
+ );
245
+ }
246
+ }
247
+
248
+ // ── Binary detection ───────────────────────────────────────────────
249
+
250
+ const BINARY_EXTENSIONS = new Set([
251
+ ".exe", ".dll", ".so", ".dylib", ".a", ".lib", ".o", ".obj",
252
+ ".zip", ".gz", ".tar", ".tgz", ".bz2", ".7z", ".rar",
253
+ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".tiff",
254
+ ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
255
+ ".mp3", ".mp4", ".avi", ".mov", ".wav", ".flv",
256
+ ".bin", ".dat", ".pak", ".pfx", ".p12", ".class", ".jar", ".war",
257
+ ".wasm", ".node",
258
+ ]);
259
+
260
+ export function isBinaryFile(filePath: string): boolean {
261
+ const normalized = filePath.replace(/\\/g, "/").toLowerCase();
262
+ for (const ext of BINARY_EXTENSIONS) {
263
+ if (normalized.endsWith(ext)) return true;
264
+ }
265
+
266
+ // Check file content for null bytes (simple heuristic)
267
+ try {
268
+ const fd = openSync(filePath, "r");
269
+ const buffer = Buffer.alloc(8192);
270
+ const bytesRead = readSync(fd, buffer, 0, 8192, 0);
271
+ closeSync(fd);
272
+ for (let i = 0; i < bytesRead; i++) {
273
+ if (buffer[i] === 0) return true;
274
+ }
275
+ return false;
276
+ } catch {
277
+ return false;
278
+ }
279
+ }
@@ -0,0 +1,258 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { dirname, resolve } from "node:path";
4
+ import { PatchWardenError } from "../errors.js";
5
+
6
+ // ── Types ──────────────────────────────────────────────────────────
7
+
8
+ export type PatchOperationType =
9
+ | "replace_exact"
10
+ | "insert_before"
11
+ | "insert_after"
12
+ | "replace_whole_file";
13
+
14
+ export interface PatchOperation {
15
+ type: PatchOperationType;
16
+ old_text?: string;
17
+ new_text: string;
18
+ occurrence?: "first" | "all" | "exactly_once";
19
+ }
20
+
21
+ export interface ApplyPatchResult {
22
+ before_sha256: string;
23
+ after_sha256: string;
24
+ operations_applied: number;
25
+ bytes_changed: number;
26
+ }
27
+
28
+ // ── Hash helpers ───────────────────────────────────────────────────
29
+
30
+ export function computeFileSha256(filePath: string): string {
31
+ const content = readFileSync(filePath);
32
+ return createHash("sha256").update(content).digest("hex");
33
+ }
34
+
35
+ export function computeContentSha256(content: string): string {
36
+ return createHash("sha256").update(content, "utf-8").digest("hex");
37
+ }
38
+
39
+ export function validateExpectedSha256(
40
+ filePath: string,
41
+ expectedSha256: string
42
+ ): string {
43
+ if (!existsSync(filePath)) {
44
+ throw new PatchWardenError(
45
+ "file_hash_mismatch",
46
+ `File does not exist: "${filePath}".`,
47
+ "Ensure the file path is correct and the file exists before applying a patch.",
48
+ true,
49
+ { expected_sha256: expectedSha256 }
50
+ );
51
+ }
52
+
53
+ const currentHash = computeFileSha256(filePath);
54
+ if (currentHash !== expectedSha256) {
55
+ throw new PatchWardenError(
56
+ "file_hash_mismatch",
57
+ `File hash mismatch. Expected "${expectedSha256}" but got "${currentHash}".`,
58
+ "Re-read the file to get the current sha256, then retry the patch with the updated expected_sha256.",
59
+ true,
60
+ { expected_sha256: expectedSha256, actual_sha256: currentHash }
61
+ );
62
+ }
63
+
64
+ return currentHash;
65
+ }
66
+
67
+ // ── Patch application ──────────────────────────────────────────────
68
+
69
+ export function applyPatchOperations(
70
+ filePath: string,
71
+ operations: PatchOperation[]
72
+ ): ApplyPatchResult {
73
+ if (!existsSync(filePath)) {
74
+ throw new PatchWardenError(
75
+ "patch_anchor_not_found",
76
+ `File does not exist: "${filePath}".`,
77
+ "Ensure the file exists before applying a patch. Direct mode does not support creating new files.",
78
+ true,
79
+ { path: filePath }
80
+ );
81
+ }
82
+
83
+ const beforeContent = readFileSync(filePath, "utf-8");
84
+ const beforeSha256 = computeContentSha256(beforeContent);
85
+ let content = beforeContent;
86
+ let operationsApplied = 0;
87
+
88
+ for (const op of operations) {
89
+ content = applySingleOperation(content, op);
90
+ operationsApplied++;
91
+ }
92
+
93
+ const afterSha256 = computeContentSha256(content);
94
+ const bytesChanged = Math.abs(
95
+ Buffer.byteLength(content, "utf-8") - Buffer.byteLength(beforeContent, "utf-8")
96
+ );
97
+
98
+ // Ensure parent directory exists (should already exist for existing files)
99
+ mkdirSync(dirname(filePath), { recursive: true });
100
+ writeFileSync(filePath, content, "utf-8");
101
+
102
+ return {
103
+ before_sha256: beforeSha256,
104
+ after_sha256: afterSha256,
105
+ operations_applied: operationsApplied,
106
+ bytes_changed: bytesChanged,
107
+ };
108
+ }
109
+
110
+ function applySingleOperation(
111
+ content: string,
112
+ op: PatchOperation
113
+ ): string {
114
+ switch (op.type) {
115
+ case "replace_exact":
116
+ return applyReplaceExact(content, op);
117
+ case "insert_before":
118
+ return applyInsertBefore(content, op);
119
+ case "insert_after":
120
+ return applyInsertAfter(content, op);
121
+ case "replace_whole_file":
122
+ return op.new_text;
123
+ default:
124
+ throw new PatchWardenError(
125
+ "patch_anchor_not_found",
126
+ `Unknown operation type: "${op.type}".`,
127
+ "Use one of: replace_exact, insert_before, insert_after, replace_whole_file.",
128
+ true,
129
+ { operation_type: op.type }
130
+ );
131
+ }
132
+ }
133
+
134
+ function applyReplaceExact(
135
+ content: string,
136
+ op: PatchOperation
137
+ ): string {
138
+ if (op.old_text === undefined || op.old_text === "") {
139
+ throw new PatchWardenError(
140
+ "patch_anchor_not_found",
141
+ "replace_exact requires a non-empty old_text.",
142
+ "Provide the exact text to replace.",
143
+ true,
144
+ { operation: "replace_exact" }
145
+ );
146
+ }
147
+
148
+ const occurrence = op.occurrence || "first";
149
+ const oldText = op.old_text;
150
+ const newText = op.new_text;
151
+
152
+ if (occurrence === "all") {
153
+ if (!content.includes(oldText)) {
154
+ throw new PatchWardenError(
155
+ "patch_anchor_not_found",
156
+ `old_text not found in file content.`,
157
+ "Ensure old_text exactly matches content in the file.",
158
+ true,
159
+ { operation: "replace_exact", occurrence: "all" }
160
+ );
161
+ }
162
+ return content.split(oldText).join(newText);
163
+ }
164
+
165
+ if (occurrence === "exactly_once") {
166
+ const firstIndex = content.indexOf(oldText);
167
+ if (firstIndex === -1) {
168
+ throw new PatchWardenError(
169
+ "patch_anchor_not_found",
170
+ `old_text not found in file content.`,
171
+ "Ensure old_text exactly matches content in the file.",
172
+ true,
173
+ { operation: "replace_exact", occurrence: "exactly_once" }
174
+ );
175
+ }
176
+ const secondIndex = content.indexOf(oldText, firstIndex + 1);
177
+ if (secondIndex !== -1) {
178
+ throw new PatchWardenError(
179
+ "patch_ambiguous",
180
+ `old_text appears multiple times in file content, but occurrence is "exactly_once".`,
181
+ "Use occurrence: \"first\" to replace the first match, or occurrence: \"all\" to replace all matches.",
182
+ true,
183
+ { operation: "replace_exact", occurrence: "exactly_once" }
184
+ );
185
+ }
186
+ return content.slice(0, firstIndex) + newText + content.slice(firstIndex + oldText.length);
187
+ }
188
+
189
+ // occurrence === "first" (default)
190
+ if (!content.includes(oldText)) {
191
+ throw new PatchWardenError(
192
+ "patch_anchor_not_found",
193
+ `old_text not found in file content.`,
194
+ "Ensure old_text exactly matches content in the file.",
195
+ true,
196
+ { operation: "replace_exact", occurrence: "first" }
197
+ );
198
+ }
199
+ const index = content.indexOf(oldText);
200
+ return content.slice(0, index) + newText + content.slice(index + oldText.length);
201
+ }
202
+
203
+ function applyInsertBefore(
204
+ content: string,
205
+ op: PatchOperation
206
+ ): string {
207
+ if (op.old_text === undefined || op.old_text === "") {
208
+ throw new PatchWardenError(
209
+ "patch_anchor_not_found",
210
+ "insert_before requires a non-empty old_text as anchor.",
211
+ "Provide the anchor text before which new_text should be inserted.",
212
+ true,
213
+ { operation: "insert_before" }
214
+ );
215
+ }
216
+
217
+ const index = content.indexOf(op.old_text);
218
+ if (index === -1) {
219
+ throw new PatchWardenError(
220
+ "patch_anchor_not_found",
221
+ `Anchor text not found in file content for insert_before.`,
222
+ "Ensure old_text exactly matches content in the file.",
223
+ true,
224
+ { operation: "insert_before" }
225
+ );
226
+ }
227
+
228
+ return content.slice(0, index) + op.new_text + content.slice(index);
229
+ }
230
+
231
+ function applyInsertAfter(
232
+ content: string,
233
+ op: PatchOperation
234
+ ): string {
235
+ if (op.old_text === undefined || op.old_text === "") {
236
+ throw new PatchWardenError(
237
+ "patch_anchor_not_found",
238
+ "insert_after requires a non-empty old_text as anchor.",
239
+ "Provide the anchor text after which new_text should be inserted.",
240
+ true,
241
+ { operation: "insert_after" }
242
+ );
243
+ }
244
+
245
+ const index = content.indexOf(op.old_text);
246
+ if (index === -1) {
247
+ throw new PatchWardenError(
248
+ "patch_anchor_not_found",
249
+ `Anchor text not found in file content for insert_after.`,
250
+ "Ensure old_text exactly matches content in the file.",
251
+ true,
252
+ { operation: "insert_after" }
253
+ );
254
+ }
255
+
256
+ const insertPos = index + op.old_text.length;
257
+ return content.slice(0, insertPos) + op.new_text + content.slice(insertPos);
258
+ }