patchwarden 0.6.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 (81) hide show
  1. package/README.md +3 -3
  2. package/dist/doctor.js +1 -1
  3. package/dist/logging.d.ts +52 -0
  4. package/dist/logging.js +123 -0
  5. package/dist/runner/changeCapture.d.ts +41 -0
  6. package/dist/runner/changeCapture.js +171 -1
  7. package/dist/runner/runTask.js +204 -24
  8. package/dist/smoke-test.js +8 -8
  9. package/dist/test/unit/android-doctor.test.d.ts +1 -0
  10. package/dist/test/unit/android-doctor.test.js +118 -0
  11. package/dist/test/unit/chinese-path.test.d.ts +1 -0
  12. package/dist/test/unit/chinese-path.test.js +91 -0
  13. package/dist/test/unit/command-guard.test.d.ts +1 -0
  14. package/dist/test/unit/command-guard.test.js +160 -0
  15. package/dist/test/unit/direct-guards.test.d.ts +1 -0
  16. package/dist/test/unit/direct-guards.test.js +213 -0
  17. package/dist/test/unit/logging.test.d.ts +1 -0
  18. package/dist/test/unit/logging.test.js +275 -0
  19. package/dist/test/unit/path-guard.test.d.ts +1 -0
  20. package/dist/test/unit/path-guard.test.js +109 -0
  21. package/dist/test/unit/safe-status.test.d.ts +1 -0
  22. package/dist/test/unit/safe-status.test.js +165 -0
  23. package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
  24. package/dist/test/unit/sensitive-guard.test.js +104 -0
  25. package/dist/test/unit/sync-file.test.d.ts +1 -0
  26. package/dist/test/unit/sync-file.test.js +154 -0
  27. package/dist/test/unit/watcher-status.test.d.ts +1 -0
  28. package/dist/test/unit/watcher-status.test.js +169 -0
  29. package/dist/tools/androidDoctor.d.ts +38 -0
  30. package/dist/tools/androidDoctor.js +391 -0
  31. package/dist/tools/auditTask.js +11 -5
  32. package/dist/tools/getTaskSummary.d.ts +3 -0
  33. package/dist/tools/getTaskSummary.js +15 -1
  34. package/dist/tools/healthCheck.d.ts +5 -0
  35. package/dist/tools/healthCheck.js +21 -0
  36. package/dist/tools/registry.js +53 -0
  37. package/dist/tools/safeStatus.d.ts +19 -0
  38. package/dist/tools/safeStatus.js +72 -0
  39. package/dist/tools/syncFile.d.ts +18 -0
  40. package/dist/tools/syncFile.js +65 -0
  41. package/dist/tools/taskOutputs.d.ts +2 -2
  42. package/dist/tools/toolCatalog.d.ts +2 -2
  43. package/dist/tools/toolCatalog.js +2 -0
  44. package/dist/version.d.ts +2 -2
  45. package/dist/version.js +2 -2
  46. package/dist/watcherStatus.d.ts +1 -0
  47. package/dist/watcherStatus.js +96 -4
  48. package/docs/performance-notes.md +55 -0
  49. package/docs/release-v0.6.1.md +75 -0
  50. package/package.json +3 -2
  51. package/scripts/http-mcp-smoke.js +10 -2
  52. package/scripts/lifecycle-smoke.js +336 -2
  53. package/scripts/mcp-manifest-check.js +30 -7
  54. package/scripts/mcp-smoke.js +11 -8
  55. package/scripts/pack-clean.js +157 -1
  56. package/scripts/unit-tests.js +36 -0
  57. package/src/doctor.ts +1 -1
  58. package/src/logging.ts +152 -0
  59. package/src/runner/changeCapture.ts +212 -1
  60. package/src/runner/runTask.ts +220 -22
  61. package/src/smoke-test.ts +5 -5
  62. package/src/test/unit/android-doctor.test.ts +158 -0
  63. package/src/test/unit/chinese-path.test.ts +106 -0
  64. package/src/test/unit/command-guard.test.ts +221 -0
  65. package/src/test/unit/direct-guards.test.ts +297 -0
  66. package/src/test/unit/logging.test.ts +325 -0
  67. package/src/test/unit/path-guard.test.ts +150 -0
  68. package/src/test/unit/safe-status.test.ts +187 -0
  69. package/src/test/unit/sensitive-guard.test.ts +124 -0
  70. package/src/test/unit/sync-file.test.ts +231 -0
  71. package/src/test/unit/watcher-status.test.ts +190 -0
  72. package/src/tools/androidDoctor.ts +424 -0
  73. package/src/tools/auditTask.ts +11 -5
  74. package/src/tools/getTaskSummary.ts +22 -1
  75. package/src/tools/healthCheck.ts +22 -0
  76. package/src/tools/registry.ts +63 -0
  77. package/src/tools/safeStatus.ts +96 -0
  78. package/src/tools/syncFile.ts +122 -0
  79. package/src/tools/toolCatalog.ts +2 -0
  80. package/src/version.ts +2 -2
  81. package/src/watcherStatus.ts +101 -4
package/README.md CHANGED
@@ -8,10 +8,10 @@
8
8
  [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-339933.svg)](https://nodejs.org/)
9
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
10
10
 
11
- 当前稳定版本:**v0.6.0**。查看
12
- [发布说明](docs/release-v0.6.0.md)、
11
+ 当前稳定版本:**v0.6.1**。查看
12
+ [发布说明](docs/release-v0.6.1.md)、
13
13
  [迁移指南](docs/migration-from-safe-bifrost.md)和
14
- [GitHub Release](https://github.com/jiezeng2004-design/PatchWarden/releases/tag/v0.6.0)。
14
+ [GitHub Release](https://github.com/jiezeng2004-design/PatchWarden/releases/tag/v0.6.1)。
15
15
 
16
16
  PatchWarden 是一个面向本地编程 Agent 的安全 MCP 桥接器。上游的
17
17
  ChatGPT、Codex、OpenCode 或其他 MCP 客户端负责规划与验收,
package/dist/doctor.js CHANGED
@@ -218,7 +218,7 @@ async function main() {
218
218
  const coreTools = selectToolsForProfile(fullTools, "chatgpt_core", config?.enableDirectProfile);
219
219
  const createSchema = coreTools.find((tool) => tool.name === "create_task")?.inputSchema;
220
220
  const waitSchema = coreTools.find((tool) => tool.name === "wait_for_task")?.inputSchema;
221
- check("Full tool profile exposes 28 tools", fullTools.length === 28, `${fullTools.length} tools`);
221
+ check("Full tool profile exposes 30 tools", fullTools.length === 30, `${fullTools.length} tools`);
222
222
  check(`chatgpt_core profile exposes the exact ${CHATGPT_CORE_TOOL_NAMES.length}-tool manifest`, JSON.stringify(coreTools.map((tool) => tool.name)) === JSON.stringify(CHATGPT_CORE_TOOL_NAMES), coreTools.map((tool) => tool.name).join(", "));
223
223
  check("Core task schemas expose inline_plan, verify_commands, and wait aliases", Boolean(createSchema?.properties?.inline_plan &&
224
224
  createSchema?.properties?.verify_commands &&
@@ -0,0 +1,52 @@
1
+ export type LogLevel = "info" | "warn" | "error" | "audit";
2
+ export interface LogEntry {
3
+ timestamp: string;
4
+ level: LogLevel;
5
+ message: string;
6
+ [key: string]: unknown;
7
+ }
8
+ /**
9
+ * Returns true when PATCHWARDEN_VERBOSE_LOG is set to "true".
10
+ * Verbose mode enables logging of sanitized tool-call arguments.
11
+ */
12
+ export declare function isVerboseLogging(): boolean;
13
+ /**
14
+ * Structured logger that emits JSON lines to stderr.
15
+ *
16
+ * Each entry contains `timestamp`, `level`, `message`, plus any optional
17
+ * context fields supplied by the caller.
18
+ */
19
+ export declare class Logger {
20
+ info(message: string, context?: Record<string, unknown>): void;
21
+ warn(message: string, context?: Record<string, unknown>): void;
22
+ error(message: string, context?: Record<string, unknown>): void;
23
+ /**
24
+ * Emit a tool-call audit log entry.
25
+ *
26
+ * Required fields: `tool`, `ok`, `duration_ms`.
27
+ * Optional fields: `error_reason`, `task_id`.
28
+ *
29
+ * By default raw arguments are NOT logged. When verbose mode is enabled
30
+ * (PATCHWARDEN_VERBOSE_LOG=true) and `args` is provided, the arguments
31
+ * are serialized and sanitized via `redactSensitiveContent` before being
32
+ * included in the `args` field.
33
+ */
34
+ audit(tool: string, ok: boolean, durationMs: number, errorReason?: string, taskId?: string, args?: unknown): void;
35
+ }
36
+ /** Default singleton logger instance. */
37
+ export declare const logger: Logger;
38
+ /**
39
+ * Produce a structured error log entry for an unhandled rejection or
40
+ * uncaught exception. Writes JSON to stderr.
41
+ */
42
+ export declare function logUnhandledError(error: unknown): void;
43
+ /**
44
+ * Register process-level handlers for `unhandledRejection` and
45
+ * `uncaughtException`.
46
+ *
47
+ * Both handlers log structured error output to stderr. The
48
+ * `uncaughtException` handler does NOT swallow the fatal error — after
49
+ * logging it exits with a non-zero status code to preserve the default
50
+ * crash behaviour required for truly fatal failures.
51
+ */
52
+ export declare function installGlobalHandlers(): void;
@@ -0,0 +1,123 @@
1
+ import { redactSensitiveContent } from "./security/contentRedaction.js";
2
+ // ── Helpers ───────────────────────────────────────────────────────
3
+ /**
4
+ * Returns true when PATCHWARDEN_VERBOSE_LOG is set to "true".
5
+ * Verbose mode enables logging of sanitized tool-call arguments.
6
+ */
7
+ export function isVerboseLogging() {
8
+ return process.env.PATCHWARDEN_VERBOSE_LOG === "true";
9
+ }
10
+ /**
11
+ * Serialize a value to a JSON string, falling back to String() on failure
12
+ * (e.g. circular references, functions, symbols).
13
+ */
14
+ function safeStringify(value) {
15
+ try {
16
+ const result = JSON.stringify(value);
17
+ return result === undefined ? String(value) : result;
18
+ }
19
+ catch {
20
+ return String(value);
21
+ }
22
+ }
23
+ /**
24
+ * Write a single JSON log line to stderr.
25
+ *
26
+ * All log output goes to stderr — NEVER stdout — so that MCP JSON-RPC
27
+ * traffic on stdout is never polluted by log messages.
28
+ */
29
+ function emit(entry) {
30
+ process.stderr.write(JSON.stringify(entry) + "\n");
31
+ }
32
+ // ── Logger ────────────────────────────────────────────────────────
33
+ /**
34
+ * Structured logger that emits JSON lines to stderr.
35
+ *
36
+ * Each entry contains `timestamp`, `level`, `message`, plus any optional
37
+ * context fields supplied by the caller.
38
+ */
39
+ export class Logger {
40
+ info(message, context) {
41
+ emit({ timestamp: new Date().toISOString(), level: "info", message, ...context });
42
+ }
43
+ warn(message, context) {
44
+ emit({ timestamp: new Date().toISOString(), level: "warn", message, ...context });
45
+ }
46
+ error(message, context) {
47
+ emit({ timestamp: new Date().toISOString(), level: "error", message, ...context });
48
+ }
49
+ /**
50
+ * Emit a tool-call audit log entry.
51
+ *
52
+ * Required fields: `tool`, `ok`, `duration_ms`.
53
+ * Optional fields: `error_reason`, `task_id`.
54
+ *
55
+ * By default raw arguments are NOT logged. When verbose mode is enabled
56
+ * (PATCHWARDEN_VERBOSE_LOG=true) and `args` is provided, the arguments
57
+ * are serialized and sanitized via `redactSensitiveContent` before being
58
+ * included in the `args` field.
59
+ */
60
+ audit(tool, ok, durationMs, errorReason, taskId, args) {
61
+ const entry = {
62
+ timestamp: new Date().toISOString(),
63
+ level: "audit",
64
+ message: "tool_call_audit",
65
+ tool,
66
+ ok,
67
+ duration_ms: durationMs,
68
+ };
69
+ if (errorReason !== undefined)
70
+ entry.error_reason = errorReason;
71
+ if (taskId !== undefined)
72
+ entry.task_id = taskId;
73
+ if (args !== undefined && isVerboseLogging()) {
74
+ const redacted = redactSensitiveContent(safeStringify(args));
75
+ entry.args = redacted.content;
76
+ }
77
+ emit(entry);
78
+ }
79
+ }
80
+ /** Default singleton logger instance. */
81
+ export const logger = new Logger();
82
+ // ── Unhandled error helpers ───────────────────────────────────────
83
+ /**
84
+ * Produce a structured error log entry for an unhandled rejection or
85
+ * uncaught exception. Writes JSON to stderr.
86
+ */
87
+ export function logUnhandledError(error) {
88
+ const entry = {
89
+ timestamp: new Date().toISOString(),
90
+ level: "error",
91
+ message: "unhandled_error",
92
+ error: error instanceof Error
93
+ ? error.message
94
+ : typeof error === "string"
95
+ ? error
96
+ : safeStringify(error),
97
+ error_name: error instanceof Error ? error.name : typeof error,
98
+ };
99
+ if (error instanceof Error && error.stack) {
100
+ entry.stack = error.stack;
101
+ }
102
+ emit(entry);
103
+ }
104
+ /**
105
+ * Register process-level handlers for `unhandledRejection` and
106
+ * `uncaughtException`.
107
+ *
108
+ * Both handlers log structured error output to stderr. The
109
+ * `uncaughtException` handler does NOT swallow the fatal error — after
110
+ * logging it exits with a non-zero status code to preserve the default
111
+ * crash behaviour required for truly fatal failures.
112
+ */
113
+ export function installGlobalHandlers() {
114
+ process.on("unhandledRejection", (reason) => {
115
+ logUnhandledError(reason);
116
+ });
117
+ process.on("uncaughtException", (error) => {
118
+ logUnhandledError(error);
119
+ // Do not swallow: exit with failure so the process does not continue
120
+ // in an undefined state.
121
+ process.exit(1);
122
+ });
123
+ }
@@ -11,6 +11,7 @@ export interface RepoSnapshot {
11
11
  status: string;
12
12
  workspace_dirty: boolean;
13
13
  files: Record<string, FileFingerprint>;
14
+ dirty_paths: string[];
14
15
  warnings: string[];
15
16
  }
16
17
  export interface ChangedFile {
@@ -70,3 +71,43 @@ export declare function writeSnapshot(taskDir: string, filename: string, snapsho
70
71
  export declare function buildChangeArtifacts(repoPath: string, before: RepoSnapshot, after: RepoSnapshot): ChangeArtifacts;
71
72
  export declare function compareSnapshots(before: RepoSnapshot, after: RepoSnapshot): ChangedFile[];
72
73
  export declare function emptyArtifactHygiene(): ArtifactHygiene;
74
+ export interface ExternalDirtyFile {
75
+ path: string;
76
+ change: ChangedFile["change"];
77
+ before_sha256: string | null;
78
+ after_sha256: string | null;
79
+ }
80
+ /**
81
+ * Extract files that are dirty in the workspace but outside the target repo.
82
+ * Used to establish a baseline before task execution.
83
+ */
84
+ export declare function extractExternalDirtyFiles(workspaceSnapshot: RepoSnapshot, repoPath: string, workspaceRoot: string): ExternalDirtyFile[];
85
+ /**
86
+ * Compare external dirty files between baseline and post-task snapshots.
87
+ * Returns files that are NEW (not present in baseline) or CHANGED
88
+ * (same path but different sha256, meaning the task modified them).
89
+ */
90
+ export declare function findNewExternalDirtyFiles(baseline: ExternalDirtyFile[], current: ExternalDirtyFile[]): ExternalDirtyFile[];
91
+ export interface ArtifactManifestEntry {
92
+ path: string;
93
+ type: string;
94
+ size: number;
95
+ sha256: string;
96
+ generated_by: string;
97
+ created_at: string;
98
+ }
99
+ export interface ArtifactManifest {
100
+ task_id: string | null;
101
+ generated_at: string;
102
+ artifacts: ArtifactManifestEntry[];
103
+ }
104
+ export declare function buildArtifactManifest(changedFiles: ChangedFile[], repoPath: string, taskId?: string): ArtifactManifest;
105
+ export interface ChangedFileGroups {
106
+ source_changes: ChangedFile[];
107
+ docs_changes: ChangedFile[];
108
+ config_changes: ChangedFile[];
109
+ test_changes: ChangedFile[];
110
+ release_artifacts: ChangedFile[];
111
+ runtime_generated_files: ChangedFile[];
112
+ }
113
+ export declare function groupChangedFiles(changedFiles: ChangedFile[]): ChangedFileGroups;
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { lstatSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
3
- import { join, relative, resolve } from "node:path";
3
+ import { join, relative, resolve, isAbsolute } from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { isSensitivePath } from "../security/sensitiveGuard.js";
6
6
  const MAX_HASH_BYTES = 5 * 1024 * 1024;
@@ -15,11 +15,36 @@ export function captureRepoSnapshot(repoPath) {
15
15
  let paths = [];
16
16
  const trackedPaths = new Set();
17
17
  const ignoredPaths = new Set();
18
+ const dirtyPaths = new Set();
18
19
  if (isGit) {
19
20
  const headResult = runGit(repoPath, ["rev-parse", "HEAD"]);
20
21
  if (headResult.status === 0)
21
22
  head = headResult.stdout.trim() || null;
22
23
  status = runGit(repoPath, ["status", "--porcelain=v1", "-uall"]).stdout.trimEnd();
24
+ // Parse git status --porcelain to collect all dirty paths
25
+ for (const line of status.split("\n")) {
26
+ if (line.length < 4)
27
+ continue;
28
+ const st = line.slice(0, 2); // XY status codes
29
+ const rawPath = line.slice(3);
30
+ // M=modified, A=added, D=deleted, ?=untracked, R=renamed, !=ignored
31
+ if (/[MAD\?R]/.test(st)) {
32
+ if (st.includes("R")) {
33
+ // Rename: rawPath is "oldname -> newname"
34
+ const parts = rawPath.split(" -> ");
35
+ if (parts.length === 2) {
36
+ dirtyPaths.add(normalizePath(parts[0]));
37
+ dirtyPaths.add(normalizePath(parts[1]));
38
+ }
39
+ else {
40
+ dirtyPaths.add(normalizePath(rawPath));
41
+ }
42
+ }
43
+ else {
44
+ dirtyPaths.add(normalizePath(rawPath));
45
+ }
46
+ }
47
+ }
23
48
  const tracked = runGit(repoPath, ["ls-files", "-z"]);
24
49
  if (tracked.status === 0) {
25
50
  for (const path of tracked.stdout.split("\0").filter(Boolean))
@@ -84,6 +109,7 @@ export function captureRepoSnapshot(repoPath) {
84
109
  status,
85
110
  workspace_dirty: status.trim().length > 0,
86
111
  files,
112
+ dirty_paths: [...dirtyPaths],
87
113
  warnings,
88
114
  };
89
115
  }
@@ -244,6 +270,150 @@ export function emptyArtifactHygiene() {
244
270
  suspicious_changes: [],
245
271
  };
246
272
  }
273
+ /**
274
+ * Extract files that are dirty in the workspace but outside the target repo.
275
+ * Used to establish a baseline before task execution.
276
+ */
277
+ export function extractExternalDirtyFiles(workspaceSnapshot, repoPath, workspaceRoot) {
278
+ const dirtyFiles = [];
279
+ const dirtyPathSet = new Set(workspaceSnapshot.dirty_paths);
280
+ for (const [path, fingerprint] of Object.entries(workspaceSnapshot.files)) {
281
+ const absolutePath = resolve(workspaceRoot, path);
282
+ const rel = relative(repoPath, absolutePath);
283
+ // If the path is outside repoPath (starts with .. or is absolute)
284
+ if (rel.startsWith("..") || isAbsolute(rel)) {
285
+ // A file is "external dirty" if:
286
+ // 1. Git reports it as dirty (modified/added/deleted/untracked) via dirty_paths, OR
287
+ // 2. It's not tracked by git (untracked file), OR
288
+ // 3. It's explicitly ignored
289
+ const isDirty = dirtyPathSet.has(path);
290
+ const isUntracked = !fingerprint.tracked;
291
+ const isIgnored = fingerprint.ignored;
292
+ if (isDirty || isUntracked || isIgnored) {
293
+ dirtyFiles.push({
294
+ path,
295
+ change: isDirty ? "modified" : "added",
296
+ before_sha256: fingerprint.sha256,
297
+ after_sha256: null,
298
+ });
299
+ }
300
+ }
301
+ }
302
+ return dirtyFiles;
303
+ }
304
+ /**
305
+ * Compare external dirty files between baseline and post-task snapshots.
306
+ * Returns files that are NEW (not present in baseline) or CHANGED
307
+ * (same path but different sha256, meaning the task modified them).
308
+ */
309
+ export function findNewExternalDirtyFiles(baseline, current) {
310
+ const baselineMap = new Map(baseline.map((f) => [f.path, f]));
311
+ return current.filter((f) => {
312
+ const baselineFile = baselineMap.get(f.path);
313
+ if (!baselineFile)
314
+ return true; // New path — definitely new
315
+ // Same path but content changed during task execution
316
+ if (baselineFile.before_sha256 !== f.before_sha256)
317
+ return true;
318
+ return false;
319
+ });
320
+ }
321
+ export function buildArtifactManifest(changedFiles, repoPath, taskId) {
322
+ const entries = [];
323
+ for (const file of changedFiles) {
324
+ if (file.kind !== "build_artifact")
325
+ continue;
326
+ const absolutePath = resolve(repoPath, file.path);
327
+ let size = 0;
328
+ let sha256 = file.after_sha256 || "unknown";
329
+ try {
330
+ const stat = lstatSync(absolutePath);
331
+ if (stat.isFile()) {
332
+ size = stat.size;
333
+ if (size <= MAX_HASH_BYTES) {
334
+ sha256 = createHash("sha256").update(readFileSync(absolutePath)).digest("hex");
335
+ }
336
+ }
337
+ }
338
+ catch {
339
+ // File may have been deleted
340
+ }
341
+ entries.push({
342
+ path: file.path,
343
+ type: classifyArtifactType(file.path),
344
+ size,
345
+ sha256,
346
+ generated_by: "task_execution",
347
+ created_at: new Date().toISOString(),
348
+ });
349
+ }
350
+ return {
351
+ task_id: taskId || null,
352
+ generated_at: new Date().toISOString(),
353
+ artifacts: entries,
354
+ };
355
+ }
356
+ function classifyArtifactType(path) {
357
+ const normalized = normalizePath(path).toLowerCase();
358
+ const basename = normalized.split("/").pop() || "";
359
+ if (basename.endsWith(".exe"))
360
+ return "windows_exe";
361
+ if (basename.endsWith(".apk"))
362
+ return "android_apk";
363
+ if (basename.endsWith(".zip"))
364
+ return "zip";
365
+ if (basename.endsWith(".asar"))
366
+ return "asar";
367
+ if (basename.endsWith(".dll"))
368
+ return "dll";
369
+ if (basename.endsWith(".pak"))
370
+ return "pak";
371
+ return "release_directory_file";
372
+ }
373
+ export function groupChangedFiles(changedFiles) {
374
+ const groups = {
375
+ source_changes: [],
376
+ docs_changes: [],
377
+ config_changes: [],
378
+ test_changes: [],
379
+ release_artifacts: [],
380
+ runtime_generated_files: [],
381
+ };
382
+ for (const file of changedFiles) {
383
+ const normalized = normalizePath(file.path).toLowerCase();
384
+ const parts = normalized.split("/");
385
+ const basename = parts[parts.length - 1] || "";
386
+ // Check for docs
387
+ if (parts.some((p) => p === "docs") || /\.(md|rst|txt)$/.test(basename)) {
388
+ groups.docs_changes.push(file);
389
+ continue;
390
+ }
391
+ // Check for config
392
+ if (basename === "package.json" || basename === "tsconfig.json" || basename === ".gitignore" ||
393
+ basename.startsWith(".config") || basename.endsWith(".config.js") || basename.endsWith(".config.ts")) {
394
+ groups.config_changes.push(file);
395
+ continue;
396
+ }
397
+ // Check for test files
398
+ if (basename.includes(".test.") || basename.includes(".spec.") || parts.some((p) => p === "test" || p === "tests" || p === "__tests__")) {
399
+ groups.test_changes.push(file);
400
+ continue;
401
+ }
402
+ // Check for build artifacts / release
403
+ if (file.kind === "build_artifact") {
404
+ groups.release_artifacts.push(file);
405
+ continue;
406
+ }
407
+ // Check for runtime generated
408
+ if (file.kind === "runtime_generated") {
409
+ groups.runtime_generated_files.push(file);
410
+ continue;
411
+ }
412
+ // Default: source changes
413
+ groups.source_changes.push(file);
414
+ }
415
+ return groups;
416
+ }
247
417
  function classifyChangedFile(path, change, before, after) {
248
418
  return {
249
419
  path,