@tanagram/cli 0.4.3 → 0.4.5
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/checker/matcher.go +64 -0
- package/commands/run.go +23 -19
- package/commands/snapshot.go +15 -3
- package/commands/snapshot_test.go +88 -0
- package/main.go +26 -0
- package/package.json +1 -1
- package/snapshot/snapshot.go +2 -2
- package/utils/process.go +15 -4
package/checker/matcher.go
CHANGED
|
@@ -2,7 +2,9 @@ package checker
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
+
"encoding/json"
|
|
5
6
|
"fmt"
|
|
7
|
+
"os"
|
|
6
8
|
"sort"
|
|
7
9
|
"strings"
|
|
8
10
|
|
|
@@ -138,3 +140,65 @@ func FormatClaudeInstructions(result *CheckResult) string {
|
|
|
138
140
|
|
|
139
141
|
return output.String()
|
|
140
142
|
}
|
|
143
|
+
|
|
144
|
+
// FormatCursorInstructions formats violations as a JSON response for Cursor
|
|
145
|
+
// structured as {"followup_message": "<text>"}
|
|
146
|
+
func FormatCursorInstructions(result *CheckResult) string {
|
|
147
|
+
if len(result.Violations) == 0 {
|
|
148
|
+
return ""
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Group violations by file
|
|
152
|
+
violationsByFile := make(map[string][]Violation)
|
|
153
|
+
for _, v := range result.Violations {
|
|
154
|
+
violationsByFile[v.File] = append(violationsByFile[v.File], v)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Sort files for consistent output
|
|
158
|
+
files := make([]string, 0, len(violationsByFile))
|
|
159
|
+
for file := range violationsByFile {
|
|
160
|
+
files = append(files, file)
|
|
161
|
+
}
|
|
162
|
+
sort.Strings(files)
|
|
163
|
+
|
|
164
|
+
var message strings.Builder
|
|
165
|
+
message.WriteString("POLICY VIOLATIONS DETECTED - PLEASE FIX\n\n")
|
|
166
|
+
message.WriteString(fmt.Sprintf("Found %d policy violation(s) that need to be fixed:\n\n", len(result.Violations)))
|
|
167
|
+
|
|
168
|
+
for _, file := range files {
|
|
169
|
+
violations := violationsByFile[file]
|
|
170
|
+
|
|
171
|
+
// Sort violations by line number
|
|
172
|
+
sort.Slice(violations, func(i, j int) bool {
|
|
173
|
+
return violations[i].LineNumber < violations[j].LineNumber
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
message.WriteString(fmt.Sprintf("File: %s\n", file))
|
|
177
|
+
|
|
178
|
+
for _, v := range violations {
|
|
179
|
+
message.WriteString(fmt.Sprintf("\n Line %d:\n", v.LineNumber))
|
|
180
|
+
message.WriteString(fmt.Sprintf(" Code: %s\n", v.Code))
|
|
181
|
+
message.WriteString(fmt.Sprintf(" Policy: %s\n", v.PolicyName))
|
|
182
|
+
message.WriteString(fmt.Sprintf(" Issue: %s\n", v.Message))
|
|
183
|
+
message.WriteString(fmt.Sprintf(" Action: Review and fix this code to comply with the policy.\n"))
|
|
184
|
+
}
|
|
185
|
+
message.WriteString("\n")
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
message.WriteString("Please fix all violations listed above and ensure the code complies with all policies.\n")
|
|
189
|
+
|
|
190
|
+
// Wrap in JSON structure
|
|
191
|
+
response := struct {
|
|
192
|
+
FollowupMessage string `json:"followup_message"`
|
|
193
|
+
}{
|
|
194
|
+
FollowupMessage: message.String(),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
bytes, err := json.MarshalIndent(response, "", " ")
|
|
198
|
+
if err != nil {
|
|
199
|
+
fmt.Fprintf(os.Stderr, "Error formatting violations: %v\n", err)
|
|
200
|
+
return "{}"
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return string(bytes)
|
|
204
|
+
}
|
package/commands/run.go
CHANGED
|
@@ -26,10 +26,10 @@ func spinner(stop chan bool, message string) {
|
|
|
26
26
|
for {
|
|
27
27
|
select {
|
|
28
28
|
case <-stop:
|
|
29
|
-
fmt.
|
|
29
|
+
fmt.Fprint(os.Stderr, "\r")
|
|
30
30
|
return
|
|
31
31
|
default:
|
|
32
|
-
fmt.
|
|
32
|
+
fmt.Fprintf(os.Stderr, "\r%s %s", chars[i%len(chars)], message)
|
|
33
33
|
i++
|
|
34
34
|
time.Sleep(100 * time.Millisecond)
|
|
35
35
|
}
|
|
@@ -80,7 +80,7 @@ func Run() error {
|
|
|
80
80
|
return err
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
fmt.
|
|
83
|
+
fmt.Fprintf(os.Stderr, "\nSyncing policies with LLM (processing %d changed file(s) in parallel)...\n", len(filesToSync))
|
|
84
84
|
|
|
85
85
|
syncStart := time.Now()
|
|
86
86
|
ctx := context.Background()
|
|
@@ -107,13 +107,13 @@ func Run() error {
|
|
|
107
107
|
for {
|
|
108
108
|
select {
|
|
109
109
|
case <-stop:
|
|
110
|
-
fmt.
|
|
110
|
+
fmt.Fprint(os.Stderr, "\r")
|
|
111
111
|
return
|
|
112
112
|
default:
|
|
113
113
|
mu.Lock()
|
|
114
114
|
c := completed
|
|
115
115
|
mu.Unlock()
|
|
116
|
-
fmt.
|
|
116
|
+
fmt.Fprintf(os.Stderr, "\r%s Processing files... (%d/%d completed)", chars[i%len(chars)], c, len(filesToSync))
|
|
117
117
|
i++
|
|
118
118
|
time.Sleep(100 * time.Millisecond)
|
|
119
119
|
}
|
|
@@ -145,7 +145,7 @@ func Run() error {
|
|
|
145
145
|
close(stop)
|
|
146
146
|
time.Sleep(50 * time.Millisecond)
|
|
147
147
|
mu.Lock()
|
|
148
|
-
fmt.
|
|
148
|
+
fmt.Fprintf(os.Stderr, "\r\033[K✗ Failed to process %s\n", result.relPath)
|
|
149
149
|
mu.Unlock()
|
|
150
150
|
return fmt.Errorf("failed to extract policies from %s: %w", result.file, result.err)
|
|
151
151
|
}
|
|
@@ -162,7 +162,7 @@ func Run() error {
|
|
|
162
162
|
// Atomic update of counter and output (prevents race with spinner)
|
|
163
163
|
mu.Lock()
|
|
164
164
|
completed++
|
|
165
|
-
fmt.
|
|
165
|
+
fmt.Fprintf(os.Stderr, "\r\033[K✓ %s - %d policies\n", result.relPath, len(result.policies))
|
|
166
166
|
mu.Unlock()
|
|
167
167
|
}
|
|
168
168
|
|
|
@@ -176,7 +176,7 @@ func Run() error {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
syncDuration := time.Since(syncStart)
|
|
179
|
-
fmt.
|
|
179
|
+
fmt.Fprintf(os.Stderr, "\n✓ Synced %d policies from %d changed file(s)\n", totalPolicies, len(filesToSync))
|
|
180
180
|
|
|
181
181
|
// Track sync metrics
|
|
182
182
|
metrics.Track("cli.sync.complete", map[string]interface{}{
|
|
@@ -202,10 +202,10 @@ func Run() error {
|
|
|
202
202
|
cloudPolicies, err = cloudStorage.LoadCloudPoliciesAsParserFormat(repoInfo.Owner, repoInfo.Name)
|
|
203
203
|
if err != nil {
|
|
204
204
|
// Cloud policies exist but failed to load - warn but continue
|
|
205
|
-
fmt.
|
|
205
|
+
fmt.Fprintf(os.Stderr, "Warning: Failed to load cloud policies: %v\n", err)
|
|
206
206
|
cloudPolicies = []parser.Policy{}
|
|
207
207
|
} else if len(cloudPolicies) > 0 {
|
|
208
|
-
fmt.
|
|
208
|
+
fmt.Fprintf(os.Stderr, "Loaded %d cloud policies for %s/%s\n", len(cloudPolicies), repoInfo.Owner, repoInfo.Name)
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
211
|
// If repo detection failed, silently continue with local-only policies
|
|
@@ -214,7 +214,7 @@ func Run() error {
|
|
|
214
214
|
policies := storage.MergePolicies(localPolicies, cloudPolicies)
|
|
215
215
|
|
|
216
216
|
if len(policies) == 0 {
|
|
217
|
-
fmt.
|
|
217
|
+
fmt.Fprintln(os.Stderr, "No enforceable policies found")
|
|
218
218
|
return nil
|
|
219
219
|
}
|
|
220
220
|
|
|
@@ -223,9 +223,9 @@ func Run() error {
|
|
|
223
223
|
totalMerged := len(policies)
|
|
224
224
|
|
|
225
225
|
if totalCloud > 0 {
|
|
226
|
-
fmt.
|
|
226
|
+
fmt.Fprintf(os.Stderr, "Total policies: %d (%d local, %d cloud, %d after merge)\n", totalMerged, totalLocal, totalCloud, totalMerged)
|
|
227
227
|
} else {
|
|
228
|
-
fmt.
|
|
228
|
+
fmt.Fprintf(os.Stderr, "Loaded %d local policies\n", totalLocal)
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
// Check if a snapshot exists (from PreToolUse hook)
|
|
@@ -233,7 +233,7 @@ func Run() error {
|
|
|
233
233
|
useSnapshot := false
|
|
234
234
|
|
|
235
235
|
if snapshot.Exists(gitRoot) {
|
|
236
|
-
fmt.
|
|
236
|
+
fmt.Fprintln(os.Stderr, "Snapshot detected - checking only Claude's changes...")
|
|
237
237
|
|
|
238
238
|
// Load snapshot
|
|
239
239
|
snap, err := snapshot.Load(gitRoot)
|
|
@@ -271,7 +271,7 @@ func Run() error {
|
|
|
271
271
|
useSnapshot = true
|
|
272
272
|
} else {
|
|
273
273
|
// No snapshot - fall back to checking all git changes
|
|
274
|
-
fmt.
|
|
274
|
+
fmt.Fprintln(os.Stderr, "Checking all changes (unstaged + staged)...")
|
|
275
275
|
diffResult, err := gitpkg.GetAllChanges()
|
|
276
276
|
if err != nil {
|
|
277
277
|
return fmt.Errorf("error getting git diff: %w", err)
|
|
@@ -282,14 +282,14 @@ func Run() error {
|
|
|
282
282
|
|
|
283
283
|
if len(changesToCheck) == 0 {
|
|
284
284
|
if useSnapshot {
|
|
285
|
-
fmt.
|
|
285
|
+
fmt.Fprintln(os.Stderr, "No changes detected since snapshot")
|
|
286
286
|
} else {
|
|
287
|
-
fmt.
|
|
287
|
+
fmt.Fprintln(os.Stderr, "No changes to check")
|
|
288
288
|
}
|
|
289
289
|
return nil
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
-
fmt.
|
|
292
|
+
fmt.Fprintf(os.Stderr, "Scanning %d changed lines...\n\n", len(changesToCheck))
|
|
293
293
|
|
|
294
294
|
// Get API key once upfront before checking
|
|
295
295
|
apiKey, err := config.GetAPIKey()
|
|
@@ -325,6 +325,10 @@ func Run() error {
|
|
|
325
325
|
|
|
326
326
|
// Exit with code 2 to trigger Claude Code hook behavior
|
|
327
327
|
os.Exit(2)
|
|
328
|
+
} else if parentProcess == "cursor" {
|
|
329
|
+
// Format and output Cursor-friendly instructions to stdout
|
|
330
|
+
cursorInstructions := checker.FormatCursorInstructions(result)
|
|
331
|
+
fmt.Fprint(os.Stdout, cursorInstructions)
|
|
328
332
|
} else {
|
|
329
333
|
// Format and output violations to stderr
|
|
330
334
|
violationOutput := checker.FormatViolations(result)
|
|
@@ -333,6 +337,6 @@ func Run() error {
|
|
|
333
337
|
}
|
|
334
338
|
|
|
335
339
|
// No violations found - output success message
|
|
336
|
-
fmt.
|
|
340
|
+
fmt.Fprintln(os.Stderr, "✓ No policy violations found")
|
|
337
341
|
return nil
|
|
338
342
|
}
|
package/commands/snapshot.go
CHANGED
|
@@ -5,21 +5,33 @@ import (
|
|
|
5
5
|
|
|
6
6
|
"github.com/tanagram/cli/snapshot"
|
|
7
7
|
"github.com/tanagram/cli/storage"
|
|
8
|
+
"github.com/tanagram/cli/utils"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// Extracted variables so that tests can override these
|
|
12
|
+
var (
|
|
13
|
+
findGitRoot = storage.FindGitRoot
|
|
14
|
+
createSnapshot = snapshot.Create
|
|
15
|
+
getParentProcess = utils.GetParentProcess
|
|
8
16
|
)
|
|
9
17
|
|
|
10
18
|
// Snapshot creates a snapshot of the current working directory state
|
|
11
19
|
func Snapshot() error {
|
|
12
20
|
// Find git root
|
|
13
|
-
gitRoot, err :=
|
|
21
|
+
gitRoot, err := findGitRoot()
|
|
14
22
|
if err != nil {
|
|
15
23
|
return err
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
// Create snapshot
|
|
19
|
-
if err :=
|
|
27
|
+
if err := createSnapshot(gitRoot); err != nil {
|
|
20
28
|
return fmt.Errorf("failed to create snapshot: %w", err)
|
|
21
29
|
}
|
|
22
30
|
|
|
23
|
-
|
|
31
|
+
parentProcess := getParentProcess()
|
|
32
|
+
if parentProcess == "Cursor" || parentProcess == "cursor" {
|
|
33
|
+
// https://cursor.com/docs/agent/hooks#beforesubmitprompt
|
|
34
|
+
fmt.Println(`{ "continue": true }`)
|
|
35
|
+
}
|
|
24
36
|
return nil
|
|
25
37
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
package commands
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"io"
|
|
6
|
+
"os"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestSnapshot_Cursor(t *testing.T) {
|
|
11
|
+
// Save original functions
|
|
12
|
+
origFindGitRoot := findGitRoot
|
|
13
|
+
origCreateSnapshot := createSnapshot
|
|
14
|
+
origGetParentProcess := getParentProcess
|
|
15
|
+
defer func() {
|
|
16
|
+
findGitRoot = origFindGitRoot
|
|
17
|
+
createSnapshot = origCreateSnapshot
|
|
18
|
+
getParentProcess = origGetParentProcess
|
|
19
|
+
}()
|
|
20
|
+
|
|
21
|
+
// Mock functions
|
|
22
|
+
findGitRoot = func() (string, error) {
|
|
23
|
+
return "/tmp/mock-git-root", nil
|
|
24
|
+
}
|
|
25
|
+
createSnapshot = func(root string) error {
|
|
26
|
+
return nil
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
tests := []struct {
|
|
30
|
+
name string
|
|
31
|
+
parentProcess string
|
|
32
|
+
expectedOutput string
|
|
33
|
+
}{
|
|
34
|
+
{
|
|
35
|
+
name: "Cursor process",
|
|
36
|
+
parentProcess: "Cursor",
|
|
37
|
+
expectedOutput: "{ \"continue\": true }\n",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "cursor process lowercase",
|
|
41
|
+
parentProcess: "cursor",
|
|
42
|
+
expectedOutput: "{ \"continue\": true }\n",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "Claude",
|
|
46
|
+
parentProcess: "claude",
|
|
47
|
+
expectedOutput: "",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "Terminal process",
|
|
51
|
+
parentProcess: "zsh",
|
|
52
|
+
expectedOutput: "",
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for _, tt := range tests {
|
|
57
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
58
|
+
// Set mock
|
|
59
|
+
getParentProcess = func() string {
|
|
60
|
+
return tt.parentProcess
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Capture stdout
|
|
64
|
+
oldStdout := os.Stdout
|
|
65
|
+
r, w, _ := os.Pipe()
|
|
66
|
+
os.Stdout = w
|
|
67
|
+
|
|
68
|
+
err := Snapshot()
|
|
69
|
+
|
|
70
|
+
// Restore stdout
|
|
71
|
+
w.Close()
|
|
72
|
+
os.Stdout = oldStdout
|
|
73
|
+
|
|
74
|
+
// Read captured output
|
|
75
|
+
var buf bytes.Buffer
|
|
76
|
+
io.Copy(&buf, r)
|
|
77
|
+
output := buf.String()
|
|
78
|
+
|
|
79
|
+
if err != nil {
|
|
80
|
+
t.Errorf("Snapshot() returned unexpected error: %v", err)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if output != tt.expectedOutput {
|
|
84
|
+
t.Errorf("Expected output %q, got %q", tt.expectedOutput, output)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
package/main.go
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
package main
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"encoding/json"
|
|
4
5
|
"fmt"
|
|
6
|
+
"io"
|
|
5
7
|
"os"
|
|
6
8
|
|
|
7
9
|
"github.com/tanagram/cli/commands"
|
|
8
10
|
"github.com/tanagram/cli/metrics"
|
|
9
11
|
"github.com/tanagram/cli/tui"
|
|
12
|
+
"github.com/tanagram/cli/utils"
|
|
10
13
|
)
|
|
11
14
|
|
|
12
15
|
// Version is set in install.js via `-X` ldflags
|
|
@@ -37,6 +40,29 @@ func main() {
|
|
|
37
40
|
"subcommand": subcommand,
|
|
38
41
|
})
|
|
39
42
|
|
|
43
|
+
// THIS IS A HUGE HACK
|
|
44
|
+
// Cursor runs its hooks in the ~/.cursor directory as cwd
|
|
45
|
+
// Claude runs its hooks directly as a subprocess, with your terminal directory as cwd
|
|
46
|
+
// We have existing code that depends on cwd being the workspace root
|
|
47
|
+
// So we detect if we're running through Cursor (`GetParentProcess` does some magic here)
|
|
48
|
+
// and set cwd based on the workspace_root that Cursor gives us.
|
|
49
|
+
// TODO: handle 0 or multiple workspace_roots
|
|
50
|
+
if utils.GetParentProcess() == "cursor" {
|
|
51
|
+
input, err := io.ReadAll(os.Stdin)
|
|
52
|
+
if err == nil {
|
|
53
|
+
var payload struct {
|
|
54
|
+
WorkspaceRoots []string `json:"workspace_roots"`
|
|
55
|
+
}
|
|
56
|
+
if err := json.Unmarshal(input, &payload); err != nil {
|
|
57
|
+
fmt.Fprintf(os.Stderr, "Error parsing input: %v\n", err)
|
|
58
|
+
} else if len(payload.WorkspaceRoots) > 0 {
|
|
59
|
+
os.Chdir(payload.WorkspaceRoots[0])
|
|
60
|
+
} else {
|
|
61
|
+
fmt.Fprintf(os.Stderr, "No workspace roots found\n")
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
40
66
|
var err error
|
|
41
67
|
switch subcommand {
|
|
42
68
|
case "run":
|
package/package.json
CHANGED
package/snapshot/snapshot.go
CHANGED
|
@@ -47,7 +47,7 @@ func Create(gitRoot string) error {
|
|
|
47
47
|
|
|
48
48
|
// Skip .git directory and .tanagram directory
|
|
49
49
|
if info.IsDir() {
|
|
50
|
-
if info.Name() == ".git" || info.Name() == ".tanagram" || info.Name() == "node_modules" {
|
|
50
|
+
if info.Name() == ".git" || info.Name() == ".tanagram" || info.Name() == "node_modules" || info.Name() == ".venv" {
|
|
51
51
|
return filepath.SkipDir
|
|
52
52
|
}
|
|
53
53
|
return nil
|
|
@@ -161,7 +161,7 @@ func Compare(gitRoot string, snapshot *Snapshot) (*CompareResult, error) {
|
|
|
161
161
|
|
|
162
162
|
// Skip directories
|
|
163
163
|
if info.IsDir() {
|
|
164
|
-
if info.Name() == ".git" || info.Name() == ".tanagram" || info.Name() == "node_modules" {
|
|
164
|
+
if info.Name() == ".git" || info.Name() == ".tanagram" || info.Name() == "node_modules" || info.Name() == ".venv" {
|
|
165
165
|
return filepath.SkipDir
|
|
166
166
|
}
|
|
167
167
|
return nil
|
package/utils/process.go
CHANGED
|
@@ -2,6 +2,7 @@ package utils
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"os"
|
|
5
|
+
"strings"
|
|
5
6
|
"sync"
|
|
6
7
|
|
|
7
8
|
"github.com/shirou/gopsutil/v3/process"
|
|
@@ -17,20 +18,30 @@ var (
|
|
|
17
18
|
func GetParentProcess() string {
|
|
18
19
|
parentProcessOnce.Do(func() {
|
|
19
20
|
parentProcessName = "unknown"
|
|
20
|
-
|
|
21
|
+
|
|
21
22
|
parentPID := int32(os.Getppid())
|
|
22
23
|
parent, err := process.NewProcess(parentPID)
|
|
23
24
|
if err != nil {
|
|
24
25
|
return
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
+
|
|
27
28
|
name, err := parent.Name()
|
|
28
29
|
if err != nil {
|
|
29
30
|
return
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
+
|
|
33
|
+
// Cursor runs hooks via `node` or `zsh` (I've seen both) in ~/.cursor
|
|
34
|
+
// Handle this case and make life easier for users of this function.
|
|
35
|
+
if name == "node" || name == "zsh" {
|
|
36
|
+
cwd, err := parent.Cwd()
|
|
37
|
+
if err == nil && strings.Contains(cwd, ".cursor") {
|
|
38
|
+
parentProcessName = "cursor"
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
parentProcessName = name
|
|
33
44
|
})
|
|
34
|
-
|
|
45
|
+
|
|
35
46
|
return parentProcessName
|
|
36
47
|
}
|