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
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { spawnSync } from "node:child_process";
4
+ import { resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const scriptDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
8
+ const root = resolve(scriptDir, "..");
9
+ const unitDir = resolve(root, "dist", "test", "unit");
10
+
11
+ if (!existsSync(unitDir)) {
12
+ console.error(`[unit-tests] Missing compiled test directory: ${unitDir}`);
13
+ console.error("[unit-tests] Run npm run build before npm run test:unit.");
14
+ process.exit(1);
15
+ }
16
+
17
+ const testFiles = readdirSync(unitDir)
18
+ .filter((name) => name.endsWith(".test.js"))
19
+ .sort()
20
+ .map((name) => resolve(unitDir, name));
21
+
22
+ if (testFiles.length === 0) {
23
+ console.error(`[unit-tests] No compiled unit tests found in ${unitDir}`);
24
+ process.exit(1);
25
+ }
26
+
27
+ const result = spawnSync(process.execPath, ["--test", ...testFiles], {
28
+ stdio: "inherit",
29
+ });
30
+
31
+ if (result.error) {
32
+ console.error(`[unit-tests] Failed to run unit tests: ${result.error.message}`);
33
+ process.exit(1);
34
+ }
35
+
36
+ process.exit(typeof result.status === "number" ? result.status : 1);
package/src/doctor.ts CHANGED
@@ -245,7 +245,7 @@ try {
245
245
  const coreTools = selectToolsForProfile(fullTools, "chatgpt_core", config?.enableDirectProfile);
246
246
  const createSchema = coreTools.find((tool) => tool.name === "create_task")?.inputSchema as any;
247
247
  const waitSchema = coreTools.find((tool) => tool.name === "wait_for_task")?.inputSchema as any;
248
- check("Full tool profile exposes 28 tools", fullTools.length === 28, `${fullTools.length} tools`);
248
+ check("Full tool profile exposes 30 tools", fullTools.length === 30, `${fullTools.length} tools`);
249
249
  check(
250
250
  `chatgpt_core profile exposes the exact ${CHATGPT_CORE_TOOL_NAMES.length}-tool manifest`,
251
251
  JSON.stringify(coreTools.map((tool) => tool.name)) === JSON.stringify(CHATGPT_CORE_TOOL_NAMES),
package/src/logging.ts ADDED
@@ -0,0 +1,152 @@
1
+ import { redactSensitiveContent } from "./security/contentRedaction.js";
2
+
3
+ // ── Types ─────────────────────────────────────────────────────────
4
+
5
+ export type LogLevel = "info" | "warn" | "error" | "audit";
6
+
7
+ export interface LogEntry {
8
+ timestamp: string;
9
+ level: LogLevel;
10
+ message: string;
11
+ [key: string]: unknown;
12
+ }
13
+
14
+ // ── Helpers ───────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Returns true when PATCHWARDEN_VERBOSE_LOG is set to "true".
18
+ * Verbose mode enables logging of sanitized tool-call arguments.
19
+ */
20
+ export function isVerboseLogging(): boolean {
21
+ return process.env.PATCHWARDEN_VERBOSE_LOG === "true";
22
+ }
23
+
24
+ /**
25
+ * Serialize a value to a JSON string, falling back to String() on failure
26
+ * (e.g. circular references, functions, symbols).
27
+ */
28
+ function safeStringify(value: unknown): string {
29
+ try {
30
+ const result = JSON.stringify(value);
31
+ return result === undefined ? String(value) : result;
32
+ } catch {
33
+ return String(value);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Write a single JSON log line to stderr.
39
+ *
40
+ * All log output goes to stderr — NEVER stdout — so that MCP JSON-RPC
41
+ * traffic on stdout is never polluted by log messages.
42
+ */
43
+ function emit(entry: LogEntry): void {
44
+ process.stderr.write(JSON.stringify(entry) + "\n");
45
+ }
46
+
47
+ // ── Logger ────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Structured logger that emits JSON lines to stderr.
51
+ *
52
+ * Each entry contains `timestamp`, `level`, `message`, plus any optional
53
+ * context fields supplied by the caller.
54
+ */
55
+ export class Logger {
56
+ info(message: string, context?: Record<string, unknown>): void {
57
+ emit({ timestamp: new Date().toISOString(), level: "info", message, ...context });
58
+ }
59
+
60
+ warn(message: string, context?: Record<string, unknown>): void {
61
+ emit({ timestamp: new Date().toISOString(), level: "warn", message, ...context });
62
+ }
63
+
64
+ error(message: string, context?: Record<string, unknown>): void {
65
+ emit({ timestamp: new Date().toISOString(), level: "error", message, ...context });
66
+ }
67
+
68
+ /**
69
+ * Emit a tool-call audit log entry.
70
+ *
71
+ * Required fields: `tool`, `ok`, `duration_ms`.
72
+ * Optional fields: `error_reason`, `task_id`.
73
+ *
74
+ * By default raw arguments are NOT logged. When verbose mode is enabled
75
+ * (PATCHWARDEN_VERBOSE_LOG=true) and `args` is provided, the arguments
76
+ * are serialized and sanitized via `redactSensitiveContent` before being
77
+ * included in the `args` field.
78
+ */
79
+ audit(
80
+ tool: string,
81
+ ok: boolean,
82
+ durationMs: number,
83
+ errorReason?: string,
84
+ taskId?: string,
85
+ args?: unknown,
86
+ ): void {
87
+ const entry: LogEntry = {
88
+ timestamp: new Date().toISOString(),
89
+ level: "audit",
90
+ message: "tool_call_audit",
91
+ tool,
92
+ ok,
93
+ duration_ms: durationMs,
94
+ };
95
+ if (errorReason !== undefined) entry.error_reason = errorReason;
96
+ if (taskId !== undefined) entry.task_id = taskId;
97
+ if (args !== undefined && isVerboseLogging()) {
98
+ const redacted = redactSensitiveContent(safeStringify(args));
99
+ entry.args = redacted.content;
100
+ }
101
+ emit(entry);
102
+ }
103
+ }
104
+
105
+ /** Default singleton logger instance. */
106
+ export const logger = new Logger();
107
+
108
+ // ── Unhandled error helpers ───────────────────────────────────────
109
+
110
+ /**
111
+ * Produce a structured error log entry for an unhandled rejection or
112
+ * uncaught exception. Writes JSON to stderr.
113
+ */
114
+ export function logUnhandledError(error: unknown): void {
115
+ const entry: LogEntry = {
116
+ timestamp: new Date().toISOString(),
117
+ level: "error",
118
+ message: "unhandled_error",
119
+ error: error instanceof Error
120
+ ? error.message
121
+ : typeof error === "string"
122
+ ? error
123
+ : safeStringify(error),
124
+ error_name: error instanceof Error ? error.name : typeof error,
125
+ };
126
+ if (error instanceof Error && error.stack) {
127
+ entry.stack = error.stack;
128
+ }
129
+ emit(entry);
130
+ }
131
+
132
+ /**
133
+ * Register process-level handlers for `unhandledRejection` and
134
+ * `uncaughtException`.
135
+ *
136
+ * Both handlers log structured error output to stderr. The
137
+ * `uncaughtException` handler does NOT swallow the fatal error — after
138
+ * logging it exits with a non-zero status code to preserve the default
139
+ * crash behaviour required for truly fatal failures.
140
+ */
141
+ export function installGlobalHandlers(): void {
142
+ process.on("unhandledRejection", (reason: unknown) => {
143
+ logUnhandledError(reason);
144
+ });
145
+
146
+ process.on("uncaughtException", (error: Error) => {
147
+ logUnhandledError(error);
148
+ // Do not swallow: exit with failure so the process does not continue
149
+ // in an undefined state.
150
+ process.exit(1);
151
+ });
152
+ }
@@ -7,7 +7,7 @@ import {
7
7
  statSync,
8
8
  writeFileSync,
9
9
  } from "node:fs";
10
- import { join, relative, resolve } from "node:path";
10
+ import { join, relative, resolve, isAbsolute } from "node:path";
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { isSensitivePath } from "../security/sensitiveGuard.js";
13
13
 
@@ -30,6 +30,7 @@ export interface RepoSnapshot {
30
30
  status: string;
31
31
  workspace_dirty: boolean;
32
32
  files: Record<string, FileFingerprint>;
33
+ dirty_paths: string[]; // paths that git status --porcelain reports as modified/added/deleted/untracked/renamed
33
34
  warnings: string[];
34
35
  }
35
36
 
@@ -98,10 +99,32 @@ export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
98
99
  const trackedPaths = new Set<string>();
99
100
  const ignoredPaths = new Set<string>();
100
101
 
102
+ const dirtyPaths = new Set<string>();
101
103
  if (isGit) {
102
104
  const headResult = runGit(repoPath, ["rev-parse", "HEAD"]);
103
105
  if (headResult.status === 0) head = headResult.stdout.trim() || null;
104
106
  status = runGit(repoPath, ["status", "--porcelain=v1", "-uall"]).stdout.trimEnd();
107
+ // Parse git status --porcelain to collect all dirty paths
108
+ for (const line of status.split("\n")) {
109
+ if (line.length < 4) continue;
110
+ const st = line.slice(0, 2); // XY status codes
111
+ const rawPath = line.slice(3);
112
+ // M=modified, A=added, D=deleted, ?=untracked, R=renamed, !=ignored
113
+ if (/[MAD\?R]/.test(st)) {
114
+ if (st.includes("R")) {
115
+ // Rename: rawPath is "oldname -> newname"
116
+ const parts = rawPath.split(" -> ");
117
+ if (parts.length === 2) {
118
+ dirtyPaths.add(normalizePath(parts[0]));
119
+ dirtyPaths.add(normalizePath(parts[1]));
120
+ } else {
121
+ dirtyPaths.add(normalizePath(rawPath));
122
+ }
123
+ } else {
124
+ dirtyPaths.add(normalizePath(rawPath));
125
+ }
126
+ }
127
+ }
105
128
  const tracked = runGit(repoPath, ["ls-files", "-z"]);
106
129
  if (tracked.status === 0) {
107
130
  for (const path of tracked.stdout.split("\0").filter(Boolean)) trackedPaths.add(normalizePath(path));
@@ -161,6 +184,7 @@ export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
161
184
  status,
162
185
  workspace_dirty: status.trim().length > 0,
163
186
  files,
187
+ dirty_paths: [...dirtyPaths],
164
188
  warnings,
165
189
  };
166
190
  }
@@ -336,6 +360,193 @@ export function emptyArtifactHygiene(): ArtifactHygiene {
336
360
  };
337
361
  }
338
362
 
363
+ // ── Phase 4: External dirty file baseline ─────────────────────────
364
+
365
+ export interface ExternalDirtyFile {
366
+ path: string;
367
+ change: ChangedFile["change"];
368
+ before_sha256: string | null;
369
+ after_sha256: string | null;
370
+ }
371
+
372
+ /**
373
+ * Extract files that are dirty in the workspace but outside the target repo.
374
+ * Used to establish a baseline before task execution.
375
+ */
376
+ export function extractExternalDirtyFiles(
377
+ workspaceSnapshot: RepoSnapshot,
378
+ repoPath: string,
379
+ workspaceRoot: string
380
+ ): ExternalDirtyFile[] {
381
+ const dirtyFiles: ExternalDirtyFile[] = [];
382
+ const dirtyPathSet = new Set(workspaceSnapshot.dirty_paths);
383
+ for (const [path, fingerprint] of Object.entries(workspaceSnapshot.files)) {
384
+ const absolutePath = resolve(workspaceRoot, path);
385
+ const rel = relative(repoPath, absolutePath);
386
+ // If the path is outside repoPath (starts with .. or is absolute)
387
+ if (rel.startsWith("..") || isAbsolute(rel)) {
388
+ // A file is "external dirty" if:
389
+ // 1. Git reports it as dirty (modified/added/deleted/untracked) via dirty_paths, OR
390
+ // 2. It's not tracked by git (untracked file), OR
391
+ // 3. It's explicitly ignored
392
+ const isDirty = dirtyPathSet.has(path);
393
+ const isUntracked = !fingerprint.tracked;
394
+ const isIgnored = fingerprint.ignored;
395
+ if (isDirty || isUntracked || isIgnored) {
396
+ dirtyFiles.push({
397
+ path,
398
+ change: isDirty ? "modified" : "added",
399
+ before_sha256: fingerprint.sha256,
400
+ after_sha256: null,
401
+ });
402
+ }
403
+ }
404
+ }
405
+ return dirtyFiles;
406
+ }
407
+
408
+ /**
409
+ * Compare external dirty files between baseline and post-task snapshots.
410
+ * Returns files that are NEW (not present in baseline) or CHANGED
411
+ * (same path but different sha256, meaning the task modified them).
412
+ */
413
+ export function findNewExternalDirtyFiles(
414
+ baseline: ExternalDirtyFile[],
415
+ current: ExternalDirtyFile[]
416
+ ): ExternalDirtyFile[] {
417
+ const baselineMap = new Map(baseline.map((f) => [f.path, f]));
418
+ return current.filter((f) => {
419
+ const baselineFile = baselineMap.get(f.path);
420
+ if (!baselineFile) return true; // New path — definitely new
421
+ // Same path but content changed during task execution
422
+ if (baselineFile.before_sha256 !== f.before_sha256) return true;
423
+ return false;
424
+ });
425
+ }
426
+
427
+ // ── Phase 6: Artifact manifest ────────────────────────────────────
428
+
429
+ export interface ArtifactManifestEntry {
430
+ path: string;
431
+ type: string;
432
+ size: number;
433
+ sha256: string;
434
+ generated_by: string;
435
+ created_at: string;
436
+ }
437
+
438
+ export interface ArtifactManifest {
439
+ task_id: string | null;
440
+ generated_at: string;
441
+ artifacts: ArtifactManifestEntry[];
442
+ }
443
+
444
+ export function buildArtifactManifest(
445
+ changedFiles: ChangedFile[],
446
+ repoPath: string,
447
+ taskId?: string
448
+ ): ArtifactManifest {
449
+ const entries: ArtifactManifestEntry[] = [];
450
+ for (const file of changedFiles) {
451
+ if (file.kind !== "build_artifact") continue;
452
+ const absolutePath = resolve(repoPath, file.path);
453
+ let size = 0;
454
+ let sha256 = file.after_sha256 || "unknown";
455
+ try {
456
+ const stat = lstatSync(absolutePath);
457
+ if (stat.isFile()) {
458
+ size = stat.size;
459
+ if (size <= MAX_HASH_BYTES) {
460
+ sha256 = createHash("sha256").update(readFileSync(absolutePath)).digest("hex");
461
+ }
462
+ }
463
+ } catch {
464
+ // File may have been deleted
465
+ }
466
+ entries.push({
467
+ path: file.path,
468
+ type: classifyArtifactType(file.path),
469
+ size,
470
+ sha256,
471
+ generated_by: "task_execution",
472
+ created_at: new Date().toISOString(),
473
+ });
474
+ }
475
+ return {
476
+ task_id: taskId || null,
477
+ generated_at: new Date().toISOString(),
478
+ artifacts: entries,
479
+ };
480
+ }
481
+
482
+ function classifyArtifactType(path: string): string {
483
+ const normalized = normalizePath(path).toLowerCase();
484
+ const basename = normalized.split("/").pop() || "";
485
+ if (basename.endsWith(".exe")) return "windows_exe";
486
+ if (basename.endsWith(".apk")) return "android_apk";
487
+ if (basename.endsWith(".zip")) return "zip";
488
+ if (basename.endsWith(".asar")) return "asar";
489
+ if (basename.endsWith(".dll")) return "dll";
490
+ if (basename.endsWith(".pak")) return "pak";
491
+ return "release_directory_file";
492
+ }
493
+
494
+ // ── Phase 6: Changed file grouping ────────────────────────────────
495
+
496
+ export interface ChangedFileGroups {
497
+ source_changes: ChangedFile[];
498
+ docs_changes: ChangedFile[];
499
+ config_changes: ChangedFile[];
500
+ test_changes: ChangedFile[];
501
+ release_artifacts: ChangedFile[];
502
+ runtime_generated_files: ChangedFile[];
503
+ }
504
+
505
+ export function groupChangedFiles(changedFiles: ChangedFile[]): ChangedFileGroups {
506
+ const groups: ChangedFileGroups = {
507
+ source_changes: [],
508
+ docs_changes: [],
509
+ config_changes: [],
510
+ test_changes: [],
511
+ release_artifacts: [],
512
+ runtime_generated_files: [],
513
+ };
514
+ for (const file of changedFiles) {
515
+ const normalized = normalizePath(file.path).toLowerCase();
516
+ const parts = normalized.split("/");
517
+ const basename = parts[parts.length - 1] || "";
518
+ // Check for docs
519
+ if (parts.some((p) => p === "docs") || /\.(md|rst|txt)$/.test(basename)) {
520
+ groups.docs_changes.push(file);
521
+ continue;
522
+ }
523
+ // Check for config
524
+ if (basename === "package.json" || basename === "tsconfig.json" || basename === ".gitignore" ||
525
+ basename.startsWith(".config") || basename.endsWith(".config.js") || basename.endsWith(".config.ts")) {
526
+ groups.config_changes.push(file);
527
+ continue;
528
+ }
529
+ // Check for test files
530
+ if (basename.includes(".test.") || basename.includes(".spec.") || parts.some((p) => p === "test" || p === "tests" || p === "__tests__")) {
531
+ groups.test_changes.push(file);
532
+ continue;
533
+ }
534
+ // Check for build artifacts / release
535
+ if (file.kind === "build_artifact") {
536
+ groups.release_artifacts.push(file);
537
+ continue;
538
+ }
539
+ // Check for runtime generated
540
+ if (file.kind === "runtime_generated") {
541
+ groups.runtime_generated_files.push(file);
542
+ continue;
543
+ }
544
+ // Default: source changes
545
+ groups.source_changes.push(file);
546
+ }
547
+ return groups;
548
+ }
549
+
339
550
  function classifyChangedFile(
340
551
  path: string,
341
552
  change: ChangedFile["change"],