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.
- package/README.md +3 -3
- package/dist/doctor.js +1 -1
- package/dist/logging.d.ts +52 -0
- package/dist/logging.js +123 -0
- package/dist/runner/changeCapture.d.ts +41 -0
- package/dist/runner/changeCapture.js +171 -1
- package/dist/runner/runTask.js +204 -24
- package/dist/smoke-test.js +8 -8
- package/dist/test/unit/android-doctor.test.d.ts +1 -0
- package/dist/test/unit/android-doctor.test.js +118 -0
- package/dist/test/unit/chinese-path.test.d.ts +1 -0
- package/dist/test/unit/chinese-path.test.js +91 -0
- package/dist/test/unit/command-guard.test.d.ts +1 -0
- package/dist/test/unit/command-guard.test.js +160 -0
- package/dist/test/unit/direct-guards.test.d.ts +1 -0
- package/dist/test/unit/direct-guards.test.js +213 -0
- package/dist/test/unit/logging.test.d.ts +1 -0
- package/dist/test/unit/logging.test.js +275 -0
- package/dist/test/unit/path-guard.test.d.ts +1 -0
- package/dist/test/unit/path-guard.test.js +109 -0
- package/dist/test/unit/safe-status.test.d.ts +1 -0
- package/dist/test/unit/safe-status.test.js +165 -0
- package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
- package/dist/test/unit/sensitive-guard.test.js +104 -0
- package/dist/test/unit/sync-file.test.d.ts +1 -0
- package/dist/test/unit/sync-file.test.js +154 -0
- package/dist/test/unit/watcher-status.test.d.ts +1 -0
- package/dist/test/unit/watcher-status.test.js +169 -0
- package/dist/tools/androidDoctor.d.ts +38 -0
- package/dist/tools/androidDoctor.js +391 -0
- package/dist/tools/auditTask.js +11 -5
- package/dist/tools/getTaskSummary.d.ts +3 -0
- package/dist/tools/getTaskSummary.js +15 -1
- package/dist/tools/healthCheck.d.ts +5 -0
- package/dist/tools/healthCheck.js +21 -0
- package/dist/tools/registry.js +53 -0
- package/dist/tools/safeStatus.d.ts +19 -0
- package/dist/tools/safeStatus.js +72 -0
- package/dist/tools/syncFile.d.ts +18 -0
- package/dist/tools/syncFile.js +65 -0
- package/dist/tools/taskOutputs.d.ts +2 -2
- package/dist/tools/toolCatalog.d.ts +2 -2
- package/dist/tools/toolCatalog.js +2 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/watcherStatus.d.ts +1 -0
- package/dist/watcherStatus.js +96 -4
- package/docs/performance-notes.md +55 -0
- package/docs/release-v0.6.1.md +75 -0
- package/package.json +3 -2
- package/scripts/http-mcp-smoke.js +10 -2
- package/scripts/lifecycle-smoke.js +336 -2
- package/scripts/mcp-manifest-check.js +30 -7
- package/scripts/mcp-smoke.js +11 -8
- package/scripts/pack-clean.js +157 -1
- package/scripts/unit-tests.js +36 -0
- package/src/doctor.ts +1 -1
- package/src/logging.ts +152 -0
- package/src/runner/changeCapture.ts +212 -1
- package/src/runner/runTask.ts +220 -22
- package/src/smoke-test.ts +5 -5
- package/src/test/unit/android-doctor.test.ts +158 -0
- package/src/test/unit/chinese-path.test.ts +106 -0
- package/src/test/unit/command-guard.test.ts +221 -0
- package/src/test/unit/direct-guards.test.ts +297 -0
- package/src/test/unit/logging.test.ts +325 -0
- package/src/test/unit/path-guard.test.ts +150 -0
- package/src/test/unit/safe-status.test.ts +187 -0
- package/src/test/unit/sensitive-guard.test.ts +124 -0
- package/src/test/unit/sync-file.test.ts +231 -0
- package/src/test/unit/watcher-status.test.ts +190 -0
- package/src/tools/androidDoctor.ts +424 -0
- package/src/tools/auditTask.ts +11 -5
- package/src/tools/getTaskSummary.ts +22 -1
- package/src/tools/healthCheck.ts +22 -0
- package/src/tools/registry.ts +63 -0
- package/src/tools/safeStatus.ts +96 -0
- package/src/tools/syncFile.ts +122 -0
- package/src/tools/toolCatalog.ts +2 -0
- package/src/version.ts +2 -2
- 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
|
|
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"],
|