z-clean 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/scanner.js CHANGED
@@ -1,90 +1,91 @@
1
1
  'use strict';
2
2
 
3
- const { execSync } = require('child_process');
4
- const os = require('os');
5
- const { matchPattern } = require('./detector/patterns');
6
- const { checkOrphan, isInTerminalMultiplexer } = require('./detector/orphan');
3
+ const { ProcessTree } = require('./process-tree');
4
+ const { matchPattern, AI_DIR_REGEX, buildAiDirRegex } = require('./detector/patterns');
7
5
  const { isWhitelisted } = require('./detector/whitelist');
8
6
  const { parseDuration, parseMemory } = require('./config');
9
7
 
10
- const platform = os.platform();
11
-
12
8
  /**
13
9
  * Scan for zombie/orphan processes left by AI coding tools.
14
10
  *
15
- * Returns an array of process objects:
16
- * { pid, name, cmd, ppid, mem, age, startTime, reason, pattern }
11
+ * Uses ProcessTree for all process data — single ps/wmic call,
12
+ * zero execSync during scan loop.
17
13
  *
18
14
  * @param {object} config — loaded zclean config
19
- * @param {object} opts — { sessionPid?: number } for session-aware filtering
15
+ * @param {object} opts — { sessionPid?: number }
16
+ * @returns {Array<{pid, name, cmd, ppid, mem, age, startTime, reason, pattern}>}
20
17
  */
21
18
  function scan(config, opts = {}) {
22
- const processes = listProcesses();
19
+ const tree = ProcessTree.build();
20
+ const aiDirRegex = buildAiDirRegex(config.customAiDirs);
23
21
  const zombies = [];
24
-
25
- for (const proc of processes) {
22
+ const warnings = Array.isArray(tree.warnings) ? [...tree.warnings] : [];
23
+ const errors = Array.isArray(tree.errors) ? [...tree.errors] : [];
24
+ const sessionPid = normalizePid(opts.sessionPid);
25
+ const session = sessionPid
26
+ ? { pid: sessionPid, filtered: true, matched: 0, excluded: 0, unattributed: 0 }
27
+ : null;
28
+
29
+ // Multiplexer detection function for tree queries
30
+ const isMultiplexer = (proc) => {
31
+ const comm = proc.cmd.split(/\s+/)[0].split('/').pop();
32
+ return comm === 'tmux' || comm === 'screen';
33
+ };
34
+
35
+ // Iterate all processes in the tree
36
+ for (const [, proc] of tree.byPid) {
26
37
  // Match against known AI tool patterns
27
- const pattern = matchPattern(proc.cmd);
38
+ const pattern = matchPattern(proc.cmd, config);
28
39
  if (!pattern) continue;
29
40
 
30
- // Check orphan status
31
- const orphanResult = checkOrphan(proc.pid);
32
- proc.ppid = orphanResult.ppid;
41
+ // AI path filter: skip generic patterns if command isn't in an AI tool directory
42
+ if (pattern.aiPathRequired && !aiDirRegex.test(proc.cmd)) continue;
33
43
 
34
- // If pattern requires orphan status and process isn't orphaned, skip
35
- if (pattern.orphanOnly && !orphanResult.isOrphan) continue;
44
+ // Check orphan status via tree (no execSync)
45
+ const orphanResult = tree.isOrphan(proc.pid);
46
+ const sessionRelated = sessionPid
47
+ ? tree.hasAncestorMatching(proc.pid, (p) => p.pid === sessionPid)
48
+ : false;
36
49
 
37
- // Skip processes in tmux/screen sessions (they're likely intentional)
38
- if (isInTerminalMultiplexer(proc.pid)) continue;
39
-
40
- // Check whitelist
41
- const whitelistResult = isWhitelisted(proc, config);
42
- if (whitelistResult.protected) continue;
43
-
44
- // Check maxOrphanAge if defined on the pattern
45
- if (pattern.maxOrphanAge) {
46
- const maxAge = parseDuration(pattern.maxOrphanAge);
47
- if (maxAge && proc.age < maxAge) continue;
48
- }
50
+ // If pattern requires orphan status and process isn't orphaned, skip
51
+ if (pattern.orphanOnly && !orphanResult.isOrphan && !sessionRelated) continue;
49
52
 
50
- // Check memory threshold for node-ai-path pattern
51
- if (pattern.memThreshold) {
52
- const threshold = parseMemory(pattern.memThreshold);
53
- const configThreshold = parseMemory(config.memoryThreshold);
54
- // Use whichever is set — pattern threshold is the gate, but either can trigger
55
- if (threshold && proc.mem < threshold) {
56
- // Also check if age exceeds maxOrphanAge
57
- if (pattern.maxOrphanAge) {
58
- const maxAge = parseDuration(pattern.maxOrphanAge);
59
- if (maxAge && proc.age < maxAge) continue;
60
- } else {
61
- continue;
62
- }
53
+ if (sessionPid && !sessionRelated) {
54
+ if (orphanResult.isOrphan) {
55
+ session.unattributed++;
56
+ } else {
57
+ session.excluded++;
63
58
  }
59
+ continue;
64
60
  }
61
+ if (sessionRelated) session.matched++;
65
62
 
66
- // Session affinity: if --session-pid was provided, prefer processes
67
- // that were children of that session
68
- if (opts.sessionPid) {
69
- // Still include non-session orphans, but note affinity
70
- proc.sessionRelated = isSessionRelated(proc.pid, opts.sessionPid);
71
- }
63
+ // tmux/screen protection but NOT for orphans (PPID=1).
64
+ // If a process is orphaned, it's already detached from any tmux session,
65
+ // so the multiplexer check is irrelevant. This fixes the tmux orphan bug.
66
+ if (!orphanResult.isOrphan && tree.hasAncestorMatching(proc.pid, isMultiplexer)) continue;
67
+
68
+ // Check whitelist (now tree-aware — no execSync)
69
+ const whitelistResult = isWhitelisted(proc, config, tree);
70
+ if (whitelistResult.protected) continue;
71
+
72
+ const threshold = evaluateThresholds(pattern, config, proc);
73
+ if (!threshold.passed) continue;
72
74
 
73
75
  // Build reason string
74
76
  const reasons = [];
75
77
  reasons.push(`pattern:${pattern.name}`);
78
+ if (sessionRelated) reasons.push(`session-pid:${sessionPid}`);
76
79
  if (orphanResult.isOrphan) reasons.push(`orphan:${orphanResult.reason}`);
77
- if (proc.age > parseDuration(config.maxAge || '24h')) reasons.push('age-exceeded');
78
- if (parseMemory(config.memoryThreshold) && proc.mem > parseMemory(config.memoryThreshold)) {
79
- reasons.push('memory-exceeded');
80
- }
80
+ if (threshold.ageExceeded) reasons.push('age-exceeded');
81
+ if (threshold.memoryExceeded) reasons.push('memory-exceeded');
81
82
 
82
83
  zombies.push({
83
84
  pid: proc.pid,
84
85
  name: pattern.name,
85
86
  cmd: proc.cmd,
86
- ppid: proc.ppid,
87
- mem: proc.mem,
87
+ ppid: orphanResult.ppid,
88
+ mem: proc.mem || 0,
88
89
  age: proc.age,
89
90
  startTime: proc.startTime,
90
91
  reason: reasons.join(', '),
@@ -92,205 +93,60 @@ function scan(config, opts = {}) {
92
93
  });
93
94
  }
94
95
 
95
- return zombies;
96
- }
97
-
98
- /**
99
- * List all processes with their details.
100
- * Cross-platform: macOS/Linux use `ps`, Windows uses `wmic`.
101
- *
102
- * Returns array of { pid, cmd, mem, age, startTime }
103
- */
104
- function listProcesses() {
105
- if (platform === 'win32') {
106
- return listProcessesWindows();
107
- }
108
- return listProcessesUnix();
109
- }
110
-
111
- /**
112
- * Unix process listing via `ps aux`.
113
- */
114
- function listProcessesUnix() {
115
- let output;
116
- try {
117
- // ps aux with etime for age calculation
118
- // Columns: PID, RSS (KB), ELAPSED, STARTED, COMMAND
119
- output = execSync('ps -eo pid=,rss=,etime=,lstart=,command=', {
120
- encoding: 'utf-8',
121
- timeout: 10000,
122
- maxBuffer: 10 * 1024 * 1024,
96
+ if (session && session.unattributed > 0) {
97
+ warnings.push({
98
+ code: 'session-attribution-gap',
99
+ sessionPid,
100
+ count: session.unattributed,
101
+ message: `${session.unattributed} candidate process${session.unattributed === 1 ? '' : 'es'} could not be proven related to session PID ${sessionPid}`,
123
102
  });
124
- } catch {
125
- return [];
126
103
  }
127
104
 
128
- const processes = [];
129
- const lines = output.trim().split('\n');
130
- const myPid = process.pid;
131
-
132
- for (const line of lines) {
133
- const trimmed = line.trim();
134
- if (!trimmed) continue;
135
-
136
- // Parse the fixed-width fields
137
- // Format: " PID RSS ELAPSED LSTART COMMAND"
138
- const match = trimmed.match(
139
- /^\s*(\d+)\s+(\d+)\s+([\d:.-]+)\s+\w+\s+(\w+\s+\d+\s+[\d:]+\s+\d+)\s+(.+)$/
140
- );
141
- if (!match) continue;
142
-
143
- const pid = parseInt(match[1], 10);
144
- const rssKB = parseInt(match[2], 10);
145
- const elapsed = match[3];
146
- const lstart = match[4];
147
- const cmd = match[5];
148
-
149
- // Skip our own process
150
- if (pid === myPid) continue;
151
-
152
- // Parse elapsed time (format: [[DD-]HH:]MM:SS)
153
- const ageMs = parseElapsed(elapsed);
154
-
155
- // Parse start time
156
- let startTime = null;
157
- try {
158
- startTime = new Date(lstart).toISOString();
159
- } catch {
160
- // Ignore parse errors
161
- }
162
-
163
- processes.push({
164
- pid,
165
- cmd,
166
- mem: rssKB * 1024, // Convert KB to bytes
167
- age: ageMs,
168
- startTime,
169
- });
170
- }
171
-
172
- return processes;
105
+ return attachDiagnostics(zombies, { warnings, errors, session });
173
106
  }
174
107
 
175
- /**
176
- * Windows process listing via wmic.
177
- */
178
- function listProcessesWindows() {
179
- let output;
180
- try {
181
- output = execSync(
182
- 'wmic process get ProcessId,CommandLine,WorkingSetSize,CreationDate /format:csv',
183
- { encoding: 'utf-8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 }
184
- );
185
- } catch {
186
- return [];
187
- }
188
-
189
- const processes = [];
190
- const lines = output.trim().split('\n');
191
- const myPid = process.pid;
192
-
193
- for (const line of lines) {
194
- const parts = line.trim().split(',');
195
- if (parts.length < 5) continue;
196
-
197
- // CSV format: Node, CommandLine, CreationDate, ProcessId, WorkingSetSize
198
- const cmd = parts[1];
199
- const creationDate = parts[2];
200
- const pid = parseInt(parts[3], 10);
201
- const workingSet = parseInt(parts[4], 10);
202
-
203
- if (isNaN(pid) || pid === myPid || !cmd) continue;
204
-
205
- // Parse WMI datetime: YYYYMMDDHHMMSS.MMMMMM+UUU
206
- let ageMs = 0;
207
- let startTime = null;
208
- if (creationDate) {
209
- try {
210
- const year = creationDate.substring(0, 4);
211
- const month = creationDate.substring(4, 6);
212
- const day = creationDate.substring(6, 8);
213
- const hours = creationDate.substring(8, 10);
214
- const minutes = creationDate.substring(10, 12);
215
- const seconds = creationDate.substring(12, 14);
216
- const dt = new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}`);
217
- startTime = dt.toISOString();
218
- ageMs = Date.now() - dt.getTime();
219
- } catch {
220
- // Ignore
221
- }
222
- }
223
-
224
- processes.push({
225
- pid,
226
- cmd,
227
- mem: workingSet || 0,
228
- age: ageMs,
229
- startTime,
230
- });
231
- }
232
-
233
- return processes;
108
+ function normalizePid(value) {
109
+ const pid = Number(value);
110
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
234
111
  }
235
112
 
236
- /**
237
- * Parse ps elapsed time format: [[DD-]HH:]MM:SS or DD-HH:MM:SS
238
- * Returns milliseconds.
239
- */
240
- function parseElapsed(elapsed) {
241
- if (!elapsed) return 0;
242
-
243
- let days = 0;
244
- let rest = elapsed.trim();
245
-
246
- // Check for "DD-" prefix
247
- const dayMatch = rest.match(/^(\d+)-(.+)$/);
248
- if (dayMatch) {
249
- days = parseInt(dayMatch[1], 10);
250
- rest = dayMatch[2];
251
- }
252
-
253
- const parts = rest.split(':').map((p) => parseInt(p, 10));
113
+ function evaluateThresholds(pattern, config, proc) {
114
+ const maxAgeValue = getPatternMaxAge(pattern, config);
115
+ const memoryValue = getPatternMemoryThreshold(pattern, config);
116
+ const maxAge = maxAgeValue ? parseDuration(maxAgeValue) : null;
117
+ const memoryThreshold = memoryValue ? parseMemory(memoryValue) : null;
118
+ const ageExceeded = Boolean(maxAge && proc.age >= maxAge);
119
+ const memoryExceeded = Boolean(memoryThreshold && proc.mem >= memoryThreshold);
254
120
 
255
- let hours = 0, minutes = 0, seconds = 0;
256
- if (parts.length === 3) {
257
- [hours, minutes, seconds] = parts;
258
- } else if (parts.length === 2) {
259
- [minutes, seconds] = parts;
260
- } else if (parts.length === 1) {
261
- [seconds] = parts;
121
+ if (maxAge && memoryThreshold) {
122
+ return { passed: ageExceeded || memoryExceeded, ageExceeded, memoryExceeded };
262
123
  }
263
-
264
- return ((days * 24 + hours) * 3600 + minutes * 60 + seconds) * 1000;
124
+ if (maxAge) return { passed: ageExceeded, ageExceeded, memoryExceeded: false };
125
+ if (memoryThreshold) return { passed: memoryExceeded, ageExceeded: false, memoryExceeded };
126
+ return { passed: true, ageExceeded: false, memoryExceeded: false };
265
127
  }
266
128
 
267
- /**
268
- * Check if a process was a descendant of a given session PID.
269
- * Used for session-affinity cleanup.
270
- */
271
- function isSessionRelated(pid, sessionPid) {
272
- if (platform === 'win32') return false;
273
-
274
- const visited = new Set();
275
- let currentPid = pid;
129
+ function getPatternMaxAge(pattern, config) {
130
+ if (!pattern.maxOrphanAge) return null;
131
+ if (pattern.aiPathRequired && config.maxAge) return config.maxAge;
132
+ return pattern.maxOrphanAge;
133
+ }
276
134
 
277
- while (currentPid > 1 && !visited.has(currentPid)) {
278
- visited.add(currentPid);
279
- if (currentPid === sessionPid) return true;
135
+ function getPatternMemoryThreshold(pattern, config) {
136
+ if (!pattern.memThreshold) return null;
137
+ return config.memoryThreshold || pattern.memThreshold;
138
+ }
280
139
 
281
- try {
282
- const ppidStr = execSync(`ps -o ppid= -p ${currentPid}`, {
283
- encoding: 'utf-8',
284
- timeout: 3000,
285
- }).trim();
286
- currentPid = parseInt(ppidStr, 10);
287
- if (isNaN(currentPid)) break;
288
- } catch {
289
- break;
290
- }
291
- }
140
+ function attachDiagnostics(zombies, diagnostics) {
141
+ zombies.warnings = diagnostics.warnings || [];
142
+ zombies.errors = diagnostics.errors || [];
143
+ zombies.enumerationFailed = hasScanErrors(zombies);
144
+ if (diagnostics.session) zombies.session = diagnostics.session;
145
+ return zombies;
146
+ }
292
147
 
293
- return false;
148
+ function hasScanErrors(result) {
149
+ return Array.isArray(result?.errors) && result.errors.length > 0;
294
150
  }
295
151
 
296
- module.exports = { scan, listProcesses, parseElapsed };
152
+ module.exports = { scan, hasScanErrors };
@@ -0,0 +1,257 @@
1
+ 'use strict';
2
+
3
+ function normalizePid(pid) {
4
+ const parsed = Number(pid);
5
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
6
+ }
7
+
8
+ function readWMICProcesses(runtime) {
9
+ let output;
10
+ try {
11
+ output = runtime.execSync(
12
+ 'wmic process get ProcessId,ParentProcessId,CommandLine,WorkingSetSize,CreationDate /format:csv',
13
+ { encoding: 'utf-8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 }
14
+ );
15
+ } catch (err) {
16
+ return {
17
+ processes: [],
18
+ warnings: [providerDiagnostic('wmic', 'process-enumeration-provider-failed', err)],
19
+ };
20
+ }
21
+
22
+ const processes = parseWMICOutput(String(output || ''), runtime);
23
+ return {
24
+ processes,
25
+ warnings: processes.length > 0 ? [] : [providerDiagnostic(
26
+ 'wmic',
27
+ 'process-enumeration-provider-empty',
28
+ 'WMIC returned no process rows.'
29
+ )],
30
+ };
31
+ }
32
+
33
+ function readCIMProcesses(runtime) {
34
+ let output;
35
+ try {
36
+ output = runtime.execSync(
37
+ 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine,WorkingSetSize,CreationDate | ConvertTo-Json -Compress"',
38
+ { encoding: 'utf-8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 }
39
+ );
40
+ } catch (err) {
41
+ return {
42
+ processes: [],
43
+ warnings: [providerDiagnostic('cim', 'process-enumeration-provider-failed', err)],
44
+ };
45
+ }
46
+
47
+ const processes = parseCIMOutput(String(output || ''), runtime);
48
+ return {
49
+ processes,
50
+ warnings: processes.length > 0 ? [] : [providerDiagnostic(
51
+ 'cim',
52
+ 'process-enumeration-provider-empty',
53
+ 'CIM returned no process rows.'
54
+ )],
55
+ };
56
+ }
57
+
58
+ function readWindowsProcess(pid, runtime) {
59
+ const safePid = normalizePid(pid);
60
+ if (!safePid) return null;
61
+
62
+ let output;
63
+ try {
64
+ output = runtime.execSync(
65
+ `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_Process -Filter 'ProcessId = ${safePid}' | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress"`,
66
+ { encoding: 'utf-8', timeout: 5000, maxBuffer: 1024 * 1024 }
67
+ );
68
+ } catch {
69
+ return null;
70
+ }
71
+
72
+ const rows = parseJsonRows(String(output || ''));
73
+ const row = rows.find((item) => Number(readField(item, 'ProcessId')) === safePid);
74
+ if (!row) return null;
75
+
76
+ return {
77
+ pid: safePid,
78
+ cmd: String(readField(row, 'CommandLine') || ''),
79
+ startTime: parseWindowsDate(readField(row, 'CreationDate')),
80
+ };
81
+ }
82
+
83
+ function windowsProcessExists(pid, runtime) {
84
+ const safePid = normalizePid(pid);
85
+ if (!safePid) return false;
86
+
87
+ try {
88
+ const output = runtime.execSync(
89
+ `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_Process -Filter 'ProcessId = ${safePid}' | Select-Object ProcessId | ConvertTo-Json -Compress"`,
90
+ { encoding: 'utf-8', timeout: 3000, maxBuffer: 1024 * 1024 }
91
+ );
92
+ return parseJsonRows(String(output || '')).some((row) => Number(readField(row, 'ProcessId')) === safePid);
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ function parseWMICOutput(output, runtime) {
99
+ const processes = [];
100
+ let headers = null;
101
+
102
+ for (const line of output.trim().split(/\r?\n/)) {
103
+ const trimmed = line.trim();
104
+ if (!trimmed) continue;
105
+
106
+ const parts = parseCsvLine(trimmed);
107
+ if (!headers) {
108
+ headers = parts.map((h) => h.trim().toLowerCase());
109
+ continue;
110
+ }
111
+
112
+ if (parts.length < headers.length) continue;
113
+
114
+ const idx = (name) => headers.indexOf(name);
115
+ const col = (name) => (idx(name) >= 0 ? parts[idx(name)] : '');
116
+ const pid = Number(col('processid'));
117
+ const cmd = String(col('commandline') || '').trim();
118
+ if (!Number.isFinite(pid) || pid === runtime.currentPid || !cmd) continue;
119
+
120
+ processes.push(processFromWindowsFields({
121
+ pid,
122
+ ppid: Number(col('parentprocessid')),
123
+ cmd,
124
+ workingSet: Number(col('workingsetsize')),
125
+ creationDate: col('creationdate'),
126
+ runtime,
127
+ }));
128
+ }
129
+
130
+ return processes;
131
+ }
132
+
133
+ function parseCIMOutput(output, runtime) {
134
+ const rows = parseJsonRows(output);
135
+ const processes = [];
136
+
137
+ for (const row of rows) {
138
+ const pid = Number(readField(row, 'ProcessId'));
139
+ const cmd = String(readField(row, 'CommandLine') || '').trim();
140
+ if (!Number.isFinite(pid) || pid === runtime.currentPid || !cmd) continue;
141
+
142
+ processes.push(processFromWindowsFields({
143
+ pid,
144
+ ppid: Number(readField(row, 'ParentProcessId')),
145
+ cmd,
146
+ workingSet: Number(readField(row, 'WorkingSetSize')),
147
+ creationDate: readField(row, 'CreationDate'),
148
+ runtime,
149
+ }));
150
+ }
151
+
152
+ return processes;
153
+ }
154
+
155
+ function processFromWindowsFields(fields) {
156
+ const startTime = parseWindowsDate(fields.creationDate);
157
+ const startMs = startTime ? new Date(startTime).getTime() : 0;
158
+ const now = typeof fields.runtime.now === 'function' ? fields.runtime.now() : fields.runtime.now;
159
+
160
+ return {
161
+ pid: fields.pid,
162
+ ppid: Number.isFinite(fields.ppid) ? fields.ppid : 0,
163
+ cmd: fields.cmd,
164
+ mem: Number.isFinite(fields.workingSet) ? fields.workingSet : 0,
165
+ age: startMs && Number.isFinite(now) ? now - startMs : 0,
166
+ startTime,
167
+ };
168
+ }
169
+
170
+ function parseJsonRows(output) {
171
+ if (!output.trim()) return [];
172
+ try {
173
+ const parsed = JSON.parse(output);
174
+ if (Array.isArray(parsed)) return parsed;
175
+ if (parsed && typeof parsed === 'object') return [parsed];
176
+ } catch {
177
+ return [];
178
+ }
179
+ return [];
180
+ }
181
+
182
+ function parseWindowsDate(value) {
183
+ if (!value) return null;
184
+ const raw = String(value).trim();
185
+ if (!raw) return null;
186
+
187
+ if (/^\d{14}/.test(raw)) {
188
+ const year = raw.substring(0, 4);
189
+ const month = raw.substring(4, 6);
190
+ const day = raw.substring(6, 8);
191
+ const hours = raw.substring(8, 10);
192
+ const minutes = raw.substring(10, 12);
193
+ const seconds = raw.substring(12, 14);
194
+ const date = new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}`);
195
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
196
+ }
197
+
198
+ const date = new Date(raw);
199
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
200
+ }
201
+
202
+ function readField(row, name) {
203
+ if (!row || typeof row !== 'object') return undefined;
204
+ if (Object.prototype.hasOwnProperty.call(row, name)) return row[name];
205
+
206
+ const lower = name.toLowerCase();
207
+ const key = Object.keys(row).find((candidate) => candidate.toLowerCase() === lower);
208
+ return key ? row[key] : undefined;
209
+ }
210
+
211
+ function parseCsvLine(line) {
212
+ const parts = [];
213
+ let current = '';
214
+ let quoted = false;
215
+
216
+ for (let i = 0; i < line.length; i += 1) {
217
+ const char = line[i];
218
+ if (char === '"') {
219
+ if (quoted && line[i + 1] === '"') {
220
+ current += '"';
221
+ i += 1;
222
+ } else {
223
+ quoted = !quoted;
224
+ }
225
+ continue;
226
+ }
227
+
228
+ if (char === ',' && !quoted) {
229
+ parts.push(current);
230
+ current = '';
231
+ continue;
232
+ }
233
+
234
+ current += char;
235
+ }
236
+
237
+ parts.push(current);
238
+ return parts;
239
+ }
240
+
241
+ function providerDiagnostic(provider, code, err) {
242
+ return {
243
+ code,
244
+ provider,
245
+ message: err instanceof Error ? err.message : String(err || ''),
246
+ };
247
+ }
248
+
249
+ module.exports = {
250
+ normalizePid,
251
+ readCIMProcesses,
252
+ readWMICProcesses,
253
+ readWindowsProcess,
254
+ windowsProcessExists,
255
+ parseCIMOutput,
256
+ parseWindowsDate,
257
+ };