@kujolang/paperclip 0.1.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/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/SECURITY.md +35 -0
- package/VERSION +1 -0
- package/bundled/components/changebucket/LICENSE +9 -0
- package/bundled/components/changebucket/changebucket.kujo +12 -0
- package/bundled/components/changebucket/src/analyze.kujo +240 -0
- package/bundled/components/changebucket/src/budget.kujo +92 -0
- package/bundled/components/changebucket/src/classify.kujo +221 -0
- package/bundled/components/changebucket/src/cli.kujo +324 -0
- package/bundled/components/changebucket/src/diffsrc.kujo +252 -0
- package/bundled/components/changebucket/src/render.kujo +346 -0
- package/bundled/components/changebucket/src/util.kujo +62 -0
- package/bundled/components/context/LICENSE +9 -0
- package/bundled/components/context/scent.kujo +3250 -0
- package/bundled/components/failure-evidence/LICENSE +9 -0
- package/bundled/components/failure-evidence/casefile.kujo +2061 -0
- package/bundled/components/patchbrief/LICENSE +9 -0
- package/bundled/components/patchbrief/patchbrief.kujo +153 -0
- package/bundled/components/patchbrief/schemas/patchbrief-handoff.schema.json +38 -0
- package/bundled/components/patchbrief/schemas/patchbrief-summary.schema.json +46 -0
- package/bundled/components/patchbrief/src/common.kujo +212 -0
- package/bundled/components/patchbrief/src/git.kujo +250 -0
- package/bundled/components/patchbrief/src/handoff.kujo +145 -0
- package/bundled/components/patchbrief/src/suggest_tests.kujo +137 -0
- package/bundled/components/patchbrief/src/summarize.kujo +309 -0
- package/bundled/kujo-components.lock.json +134 -0
- package/dist/manifest.js +14696 -0
- package/dist/ui/index.js +179 -0
- package/dist/worker.js +30536 -0
- package/docs/ARCHITECTURE.md +28 -0
- package/docs/CATALOG_SUBMISSION.md +25 -0
- package/docs/COMPATIBILITY.md +21 -0
- package/docs/CONFIGURATION.md +33 -0
- package/docs/INSTALLATION.md +53 -0
- package/docs/OPERATIONS.md +60 -0
- package/docs/README.md +18 -0
- package/docs/RELEASE_READINESS.md +46 -0
- package/docs/THREAT_MODEL.md +26 -0
- package/docs/TROUBLESHOOTING.md +12 -0
- package/docs/USAGE.md +95 -0
- package/examples/agent-workflow.md +20 -0
- package/package.json +71 -0
- package/schemas/changebucket-analysis.schema.json +25 -0
- package/schemas/context-pack.schema.json +23 -0
- package/schemas/failure-evidence.schema.json +18 -0
- package/schemas/review-pack.schema.json +19 -0
- package/skills/scoped-repository-context/SKILL.md +9 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Git command wrappers for PatchBrief.
|
|
2
|
+
# Provides safe wrappers around git commands for diff inspection.
|
|
3
|
+
# Uses Kujo's execute built-in for process execution.
|
|
4
|
+
|
|
5
|
+
from src.common import str_starts_with, str_contains, str_trim, shell_quote
|
|
6
|
+
|
|
7
|
+
DIFF_ANALYSIS_MAX_BYTES := 1048576
|
|
8
|
+
|
|
9
|
+
func push_unique(items, value) {
|
|
10
|
+
text := to_string(value)
|
|
11
|
+
idx := 0
|
|
12
|
+
while idx < len(items) {
|
|
13
|
+
if to_string(items[idx]) == text {
|
|
14
|
+
return items
|
|
15
|
+
}
|
|
16
|
+
idx := idx + 1
|
|
17
|
+
}
|
|
18
|
+
return push(items, text)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Check if the current directory is inside a git repository.
|
|
22
|
+
export func is_git_repo() {
|
|
23
|
+
try {
|
|
24
|
+
_ := execute("git rev-parse --git-dir 2>/dev/null", {"timeout_ms": 5000})
|
|
25
|
+
return true
|
|
26
|
+
} except err {
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Get the current branch name.
|
|
32
|
+
export func current_branch() {
|
|
33
|
+
try {
|
|
34
|
+
result := execute("git symbolic-ref --short HEAD", {"timeout_ms": 5000})
|
|
35
|
+
branch := trim(to_string(result))
|
|
36
|
+
if branch != "" {
|
|
37
|
+
return branch
|
|
38
|
+
}
|
|
39
|
+
} except err {
|
|
40
|
+
try {
|
|
41
|
+
short := execute("git rev-parse --short HEAD", {"timeout_ms": 5000})
|
|
42
|
+
return "HEAD@" + trim(to_string(short))
|
|
43
|
+
} except err2 {
|
|
44
|
+
return "unknown"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return "unknown"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Get the repository name from the remote origin URL or directory name.
|
|
51
|
+
export func repo_name() {
|
|
52
|
+
try {
|
|
53
|
+
url := execute("git remote get-url origin 2>/dev/null", {"timeout_ms": 5000})
|
|
54
|
+
url_str := str_trim(to_string(url))
|
|
55
|
+
if url_str != "" && str_starts_with(url_str, "fatal") == false {
|
|
56
|
+
parts := split(url_str, "/")
|
|
57
|
+
last := str_trim(parts[len(parts) - 1])
|
|
58
|
+
if str_contains(last, ":") {
|
|
59
|
+
colon_parts := split(last, ":")
|
|
60
|
+
last = str_trim(colon_parts[len(colon_parts) - 1])
|
|
61
|
+
}
|
|
62
|
+
if ends_with(last, ".git") {
|
|
63
|
+
last = substring(last, 0, len(last) - 4)
|
|
64
|
+
}
|
|
65
|
+
if last != "" {
|
|
66
|
+
return last
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} except err {}
|
|
70
|
+
try {
|
|
71
|
+
root := execute("git rev-parse --show-toplevel", {"timeout_ms": 5000})
|
|
72
|
+
return basename(str_trim(to_string(root)))
|
|
73
|
+
} except err2 {
|
|
74
|
+
return "unknown"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Get the diff stat summary (files changed, insertions, deletions).
|
|
79
|
+
export func diff_stat() {
|
|
80
|
+
try {
|
|
81
|
+
output := execute("git diff --no-ext-diff --stat HEAD 2>/dev/null", {"timeout_ms": 10000})
|
|
82
|
+
text := to_string(output)
|
|
83
|
+
if str_trim(text) == "" {
|
|
84
|
+
return {"ok": true, "output": "", "files": 0, "added": 0, "removed": 0}
|
|
85
|
+
}
|
|
86
|
+
lines := split(text, "\n")
|
|
87
|
+
summary_line := ""
|
|
88
|
+
idx := len(lines) - 1
|
|
89
|
+
while idx >= 0 {
|
|
90
|
+
line := str_trim(to_string(lines[idx]))
|
|
91
|
+
if line != "" {
|
|
92
|
+
summary_line := line
|
|
93
|
+
idx := -1
|
|
94
|
+
}
|
|
95
|
+
idx := idx - 1
|
|
96
|
+
}
|
|
97
|
+
files_count := 0
|
|
98
|
+
added_count := 0
|
|
99
|
+
removed_count := 0
|
|
100
|
+
if summary_line != "" && str_contains(summary_line, "changed") {
|
|
101
|
+
comma_parts := split(summary_line, ",")
|
|
102
|
+
ci := 0
|
|
103
|
+
while ci < len(comma_parts) {
|
|
104
|
+
part := str_trim(to_string(comma_parts[ci]))
|
|
105
|
+
if str_contains(part, "file") {
|
|
106
|
+
num_str := replace(replace(part, " files changed", ""), " file changed", "")
|
|
107
|
+
num_str := str_trim(num_str)
|
|
108
|
+
files_count := to_int(num_str)
|
|
109
|
+
}
|
|
110
|
+
if str_contains(part, "insertion") {
|
|
111
|
+
num_str := replace(replace(part, " insertions(+)", ""), " insertion(+)", "")
|
|
112
|
+
num_str := str_trim(num_str)
|
|
113
|
+
added_count := to_int(num_str)
|
|
114
|
+
}
|
|
115
|
+
if str_contains(part, "deletion") {
|
|
116
|
+
num_str := replace(replace(part, " deletions(-)", ""), " deletion(-)", "")
|
|
117
|
+
num_str := str_trim(num_str)
|
|
118
|
+
removed_count := to_int(num_str)
|
|
119
|
+
}
|
|
120
|
+
ci := ci + 1
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
"ok": true,
|
|
125
|
+
"output": text,
|
|
126
|
+
"files": files_count,
|
|
127
|
+
"added": added_count,
|
|
128
|
+
"removed": removed_count
|
|
129
|
+
}
|
|
130
|
+
} except err {
|
|
131
|
+
return {"ok": false, "output": "", "files": 0, "added": 0, "removed": 0}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# Get the list of changed files.
|
|
136
|
+
export func changed_files() {
|
|
137
|
+
return changed_files_result()["files"]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export func changed_files_result() {
|
|
141
|
+
try {
|
|
142
|
+
output := execute("git status --porcelain=v1 -z --untracked-files=all 2>/dev/null", {"timeout_ms": 10000})
|
|
143
|
+
text := to_string(output)
|
|
144
|
+
if text == "" {
|
|
145
|
+
return {"ok": true, "files": []}
|
|
146
|
+
}
|
|
147
|
+
files := []
|
|
148
|
+
nul := parse_json("\"\\u0000\"")
|
|
149
|
+
entries := split(text, nul)
|
|
150
|
+
idx := 0
|
|
151
|
+
while idx < len(entries) {
|
|
152
|
+
entry := to_string(entries[idx])
|
|
153
|
+
if len(entry) >= 3 {
|
|
154
|
+
status := substring(entry, 0, 2)
|
|
155
|
+
path_text := substring(entry, 3, len(entry))
|
|
156
|
+
if path_text != "" {
|
|
157
|
+
files := push_unique(files, path_text)
|
|
158
|
+
}
|
|
159
|
+
if str_contains(status, "R") || str_contains(status, "C") {
|
|
160
|
+
idx := idx + 1
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
idx := idx + 1
|
|
164
|
+
}
|
|
165
|
+
return {"ok": true, "files": files}
|
|
166
|
+
} except err {
|
|
167
|
+
return {"ok": false, "files": []}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# Get the full diff output.
|
|
172
|
+
export func full_diff() {
|
|
173
|
+
return full_diff_analysis()["text"]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
# Read a bounded diff and report whether heuristic analysis may be incomplete.
|
|
177
|
+
export func full_diff_analysis() {
|
|
178
|
+
return full_diff_analysis_with_limit(DIFF_ANALYSIS_MAX_BYTES)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export func full_diff_analysis_with_limit(max_bytes) {
|
|
182
|
+
limit := to_int(max_bytes)
|
|
183
|
+
if limit < 1024 {
|
|
184
|
+
limit = 1024
|
|
185
|
+
}
|
|
186
|
+
if limit > 16777216 {
|
|
187
|
+
limit = 16777216
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
result := spawn_process(["git", "diff", "--no-ext-diff", "HEAD"], {"timeout_ms": 30000, "max_output_bytes": limit})
|
|
191
|
+
if result.success == false && result.timed_out == false {
|
|
192
|
+
# An unborn repository has no HEAD; compare its index with the empty tree.
|
|
193
|
+
result = spawn_process(["git", "diff", "--no-ext-diff", "--cached"], {"timeout_ms": 30000, "max_output_bytes": limit})
|
|
194
|
+
}
|
|
195
|
+
text := to_string(result.stdout)
|
|
196
|
+
failed := result.success == false || result.timed_out == true
|
|
197
|
+
return {
|
|
198
|
+
"text": text,
|
|
199
|
+
"bytes_analyzed": len(text),
|
|
200
|
+
"max_bytes": limit,
|
|
201
|
+
"truncated": result.stdout_truncated,
|
|
202
|
+
"error": failed
|
|
203
|
+
}
|
|
204
|
+
} except err {
|
|
205
|
+
return {"text": "", "bytes_analyzed": 0, "max_bytes": limit, "truncated": false, "error": true}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# Get the diff for a specific file.
|
|
210
|
+
export func file_diff(file_path) {
|
|
211
|
+
try {
|
|
212
|
+
cmd := "git diff --no-ext-diff HEAD -- " + shell_quote(file_path)
|
|
213
|
+
output := execute(cmd, {"timeout_ms": 10000})
|
|
214
|
+
return to_string(output)
|
|
215
|
+
} except err {
|
|
216
|
+
return ""
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# Get the shortlog between current state and last commit.
|
|
221
|
+
export func recent_commits(count) {
|
|
222
|
+
try {
|
|
223
|
+
safe_count := to_int(count)
|
|
224
|
+
if safe_count < 1 {
|
|
225
|
+
safe_count := 1
|
|
226
|
+
}
|
|
227
|
+
if safe_count > 100 {
|
|
228
|
+
safe_count := 100
|
|
229
|
+
}
|
|
230
|
+
cmd := "git log --oneline -" + to_string(safe_count) + " 2>/dev/null"
|
|
231
|
+
output := execute(cmd, {"timeout_ms": 10000})
|
|
232
|
+
text := str_trim(to_string(output))
|
|
233
|
+
if text == "" {
|
|
234
|
+
return []
|
|
235
|
+
}
|
|
236
|
+
commits := []
|
|
237
|
+
lines := split(text, "\n")
|
|
238
|
+
idx := 0
|
|
239
|
+
while idx < len(lines) {
|
|
240
|
+
line := str_trim(to_string(lines[idx]))
|
|
241
|
+
if line != "" {
|
|
242
|
+
commits := push(commits, line)
|
|
243
|
+
}
|
|
244
|
+
idx := idx + 1
|
|
245
|
+
}
|
|
246
|
+
return commits
|
|
247
|
+
} except err {
|
|
248
|
+
return []
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Handoff module — generates structured handoff notes for reviewers or downstream agents.
|
|
2
|
+
|
|
3
|
+
from src.git import is_git_repo, current_branch, repo_name, diff_stat, changed_files_result, full_diff_analysis, recent_commits
|
|
4
|
+
from src.common import timestamp_now, str_join, markdown_text, markdown_code, print_lines, print_prefixed_lines, print_prefixed_or_fallback, print_markdown_section, print_markdown_paragraph, print_fenced, print_not_git_repo_error
|
|
5
|
+
from src.summarize import infer_purpose, identify_risks
|
|
6
|
+
|
|
7
|
+
REVIEWER_CHECKLIST := [
|
|
8
|
+
"Changes look correct",
|
|
9
|
+
"Tests pass",
|
|
10
|
+
"No security concerns",
|
|
11
|
+
"Documentation updated if needed"
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
NEXT_AGENT_COMMANDS := [
|
|
15
|
+
"kujo run patchbrief.kujo -- summarize",
|
|
16
|
+
"kujo run patchbrief.kujo -- suggest-tests"
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
# Generate a Markdown handoff note.
|
|
20
|
+
export func handoff_markdown() {
|
|
21
|
+
if is_git_repo() == false {
|
|
22
|
+
print_not_git_repo_error()
|
|
23
|
+
exit(1)
|
|
24
|
+
}
|
|
25
|
+
branch := current_branch()
|
|
26
|
+
repo := repo_name()
|
|
27
|
+
stat := diff_stat()
|
|
28
|
+
files_result := changed_files_result()
|
|
29
|
+
if files_result["ok"] == false {
|
|
30
|
+
print_lines(["Error: Failed to inspect git working tree."])
|
|
31
|
+
exit(1)
|
|
32
|
+
}
|
|
33
|
+
files := files_result["files"]
|
|
34
|
+
diff_analysis := full_diff_analysis()
|
|
35
|
+
diff_text := diff_analysis["text"]
|
|
36
|
+
purpose := infer_purpose(files, diff_text)
|
|
37
|
+
risks := identify_risks(files, diff_text)
|
|
38
|
+
if diff_analysis["error"] == true {
|
|
39
|
+
risks := push(risks, "Diff content analysis failed — purpose and content-based risk hints may be incomplete")
|
|
40
|
+
} else if diff_analysis["truncated"] == true {
|
|
41
|
+
risks := push(risks, "Diff analysis was truncated at " + to_string(diff_analysis["max_bytes"]) + " bytes — purpose and content-based risk hints may be incomplete")
|
|
42
|
+
}
|
|
43
|
+
commits := recent_commits(5)
|
|
44
|
+
ts := timestamp_now()
|
|
45
|
+
print_lines([
|
|
46
|
+
"# Handoff Note: " + markdown_text(repo) + " — " + markdown_text(branch),
|
|
47
|
+
"",
|
|
48
|
+
"**Date:** " + ts,
|
|
49
|
+
"**Branch:** " + markdown_text(branch),
|
|
50
|
+
"**Changed Files:** " + to_string(len(files)) + " (+" + to_string(stat["added"]) + "/-" + to_string(stat["removed"]) + ")"
|
|
51
|
+
])
|
|
52
|
+
print_markdown_section("Summary")
|
|
53
|
+
print_markdown_paragraph(purpose)
|
|
54
|
+
print_markdown_section("What Changed")
|
|
55
|
+
file_lines := []
|
|
56
|
+
fi := 0
|
|
57
|
+
while fi < len(files) {
|
|
58
|
+
file_lines := push(file_lines, "- " + markdown_code(files[fi]))
|
|
59
|
+
fi := fi + 1
|
|
60
|
+
}
|
|
61
|
+
print_lines(file_lines)
|
|
62
|
+
print_markdown_section("Risk Assessment")
|
|
63
|
+
print_prefixed_lines(risks, "- ")
|
|
64
|
+
print_markdown_section("Recent Commits")
|
|
65
|
+
safe_commits := []
|
|
66
|
+
ci := 0
|
|
67
|
+
while ci < len(commits) {
|
|
68
|
+
safe_commits := push(safe_commits, markdown_text(commits[ci]))
|
|
69
|
+
ci := ci + 1
|
|
70
|
+
}
|
|
71
|
+
print_prefixed_or_fallback(safe_commits, "- ", "No recent commits found.")
|
|
72
|
+
print_markdown_section("Reviewer Notes")
|
|
73
|
+
print_prefixed_lines(REVIEWER_CHECKLIST, "- [ ] ")
|
|
74
|
+
print_markdown_section("For the Next Agent/Developer")
|
|
75
|
+
print_fenced(str_join(NEXT_AGENT_COMMANDS, "\n"))
|
|
76
|
+
print_lines([
|
|
77
|
+
"",
|
|
78
|
+
"---",
|
|
79
|
+
"*Handoff generated by PatchBrief v1.0.1 at " + ts + "*"
|
|
80
|
+
])
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Generate a JSON handoff note.
|
|
84
|
+
export func handoff_json(pretty_json) {
|
|
85
|
+
if is_git_repo() == false {
|
|
86
|
+
error_obj := {
|
|
87
|
+
"error": true,
|
|
88
|
+
"message": "Not a git repository. Run patchbrief inside a git repo."
|
|
89
|
+
}
|
|
90
|
+
if pretty_json == true { print(to_json_pretty(error_obj)) } else { print(to_json(error_obj)) }
|
|
91
|
+
exit(1)
|
|
92
|
+
}
|
|
93
|
+
branch := current_branch()
|
|
94
|
+
repo := repo_name()
|
|
95
|
+
stat := diff_stat()
|
|
96
|
+
files_result := changed_files_result()
|
|
97
|
+
if files_result["ok"] == false {
|
|
98
|
+
error_obj := {"error": true, "message": "Failed to inspect git working tree."}
|
|
99
|
+
if pretty_json == true { print(to_json_pretty(error_obj)) } else { print(to_json(error_obj)) }
|
|
100
|
+
exit(1)
|
|
101
|
+
}
|
|
102
|
+
files := files_result["files"]
|
|
103
|
+
diff_analysis := full_diff_analysis()
|
|
104
|
+
diff_text := diff_analysis["text"]
|
|
105
|
+
purpose := infer_purpose(files, diff_text)
|
|
106
|
+
risks := identify_risks(files, diff_text)
|
|
107
|
+
if diff_analysis["error"] == true {
|
|
108
|
+
risks := push(risks, "Diff content analysis failed — purpose and content-based risk hints may be incomplete")
|
|
109
|
+
} else if diff_analysis["truncated"] == true {
|
|
110
|
+
risks := push(risks, "Diff analysis was truncated at " + to_string(diff_analysis["max_bytes"]) + " bytes — purpose and content-based risk hints may be incomplete")
|
|
111
|
+
}
|
|
112
|
+
commits := recent_commits(5)
|
|
113
|
+
ts := timestamp_now()
|
|
114
|
+
# Build file array
|
|
115
|
+
file_list := []
|
|
116
|
+
fi := 0
|
|
117
|
+
while fi < len(files) {
|
|
118
|
+
file_list := push(file_list, to_string(files[fi]))
|
|
119
|
+
fi := fi + 1
|
|
120
|
+
}
|
|
121
|
+
output := {
|
|
122
|
+
"format": "patchbrief-handoff",
|
|
123
|
+
"version": "1.0.1",
|
|
124
|
+
"generated_at": ts,
|
|
125
|
+
"repo": repo,
|
|
126
|
+
"branch": branch,
|
|
127
|
+
"summary": {
|
|
128
|
+
"files_changed": len(files),
|
|
129
|
+
"lines_added": stat["added"],
|
|
130
|
+
"lines_removed": stat["removed"],
|
|
131
|
+
"likely_purpose": purpose
|
|
132
|
+
},
|
|
133
|
+
"analysis": {
|
|
134
|
+
"diff_bytes_analyzed": diff_analysis["bytes_analyzed"],
|
|
135
|
+
"diff_error": diff_analysis["error"],
|
|
136
|
+
"diff_max_bytes": diff_analysis["max_bytes"],
|
|
137
|
+
"diff_truncated": diff_analysis["truncated"]
|
|
138
|
+
},
|
|
139
|
+
"files": file_list,
|
|
140
|
+
"risk_areas": risks,
|
|
141
|
+
"recent_commits": commits,
|
|
142
|
+
"reviewer_checklist": REVIEWER_CHECKLIST
|
|
143
|
+
}
|
|
144
|
+
if pretty_json == true { print(to_json_pretty(output)) } else { print(to_json(output)) }
|
|
145
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Suggest tests module — analyzes changed files and suggests test commands.
|
|
2
|
+
|
|
3
|
+
from src.git import is_git_repo, changed_files_result
|
|
4
|
+
from src.common import str_contains, str_lower, str_ends_with, dict_has_key, markdown_code, print_lines, print_prefixed_lines, print_wrapped_lines, print_markdown_section, print_not_git_repo_error
|
|
5
|
+
|
|
6
|
+
# File pattern to test suggestion mapping.
|
|
7
|
+
TEST_SUGGESTIONS := {
|
|
8
|
+
".kujo": ["kujo test"],
|
|
9
|
+
".rs": ["cargo test", "cargo clippy --all-targets --all-features -- -D warnings"],
|
|
10
|
+
".py": ["python -m pytest", "python -m unittest discover"],
|
|
11
|
+
".js": ["npm test", "npx jest"],
|
|
12
|
+
".ts": ["npm test", "npx jest", "npx tsc --noEmit"],
|
|
13
|
+
".jsx": ["npm test", "npx jest"],
|
|
14
|
+
".tsx": ["npm test", "npx jest", "npx tsc --noEmit"],
|
|
15
|
+
".php": ["php vendor/bin/phpunit", "php artisan test"],
|
|
16
|
+
".rb": ["bundle exec rspec", "bundle exec rake test"],
|
|
17
|
+
".go": ["go test ./...", "go vet ./..."],
|
|
18
|
+
".java": ["./gradlew test", "mvn test"],
|
|
19
|
+
".kt": ["./gradlew test"],
|
|
20
|
+
".swift": ["swift test", "xcodebuild test"],
|
|
21
|
+
".sh": ["shellcheck *.sh", "bash -n script.sh"],
|
|
22
|
+
".md": ["markdownlint *.md"],
|
|
23
|
+
".css": ["npx stylelint '**/*.css'"],
|
|
24
|
+
".json": ["npx jsonlint --quiet *.json"],
|
|
25
|
+
".yaml": ["yamllint ."],
|
|
26
|
+
".yml": ["yamllint ."],
|
|
27
|
+
".sql": ["# Review migration safety manually"],
|
|
28
|
+
".dockerfile": ["docker build --check .", "hadolint Dockerfile"],
|
|
29
|
+
".tf": ["terraform validate", "terraform fmt -check"]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
GENERAL_VALIDATION := [
|
|
33
|
+
"Review all changed files for correctness",
|
|
34
|
+
"Check for leftover debug logging or commented-out code",
|
|
35
|
+
"Verify any new configuration values are documented"
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# Get unique test suggestions for a set of files.
|
|
39
|
+
export func suggestions_for_files(files_list) {
|
|
40
|
+
suggestions_set := {}
|
|
41
|
+
result := []
|
|
42
|
+
fi := 0
|
|
43
|
+
while fi < len(files_list) {
|
|
44
|
+
f := str_lower(to_string(files_list[fi]))
|
|
45
|
+
is_dockerfile := f == "dockerfile" || str_ends_with(f, "/dockerfile") || str_ends_with(f, ".dockerfile")
|
|
46
|
+
extensions := [".kujo", ".rs", ".py", ".js", ".ts", ".jsx", ".tsx", ".php", ".rb", ".go", ".java", ".kt", ".swift", ".sh", ".md", ".css", ".json", ".yaml", ".yml", ".sql", ".dockerfile", ".tf"]
|
|
47
|
+
ei := 0
|
|
48
|
+
while ei < len(extensions) {
|
|
49
|
+
ext := to_string(extensions[ei])
|
|
50
|
+
is_match := false
|
|
51
|
+
if ext == ".dockerfile" {
|
|
52
|
+
is_match := is_dockerfile
|
|
53
|
+
} else {
|
|
54
|
+
is_match := str_ends_with(f, ext)
|
|
55
|
+
}
|
|
56
|
+
if is_match {
|
|
57
|
+
cmds := TEST_SUGGESTIONS[ext]
|
|
58
|
+
ci := 0
|
|
59
|
+
while ci < len(cmds) {
|
|
60
|
+
cmd := to_string(cmds[ci])
|
|
61
|
+
# Check if already added
|
|
62
|
+
if dict_has_key(suggestions_set, cmd) == false {
|
|
63
|
+
suggestions_set[cmd] := true
|
|
64
|
+
result := push(result, cmd)
|
|
65
|
+
}
|
|
66
|
+
ci := ci + 1
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
ei := ei + 1
|
|
70
|
+
}
|
|
71
|
+
fi := fi + 1
|
|
72
|
+
}
|
|
73
|
+
if len(result) == 0 {
|
|
74
|
+
result := push(result, "# No specific test commands found — review changes manually")
|
|
75
|
+
}
|
|
76
|
+
return result
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export func manual_checks_for_file(file_path) {
|
|
80
|
+
file_name := to_string(file_path)
|
|
81
|
+
lower_file := str_lower(file_name)
|
|
82
|
+
checks := []
|
|
83
|
+
if str_contains(lower_file, "readme") || str_ends_with(lower_file, ".md") {
|
|
84
|
+
checks := push(checks, "Preview Markdown rendering for " + markdown_code(file_name))
|
|
85
|
+
}
|
|
86
|
+
if str_contains(lower_file, "config") || str_contains(lower_file, ".env") {
|
|
87
|
+
checks := push(checks, "Verify environment-specific values in " + markdown_code(file_name))
|
|
88
|
+
}
|
|
89
|
+
if str_contains(lower_file, "migration") || str_contains(lower_file, "schema") {
|
|
90
|
+
checks := push(checks, "Test migration rollback for " + markdown_code(file_name))
|
|
91
|
+
}
|
|
92
|
+
return checks
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Generate test suggestions.
|
|
96
|
+
export func suggest_tests() {
|
|
97
|
+
if is_git_repo() == false {
|
|
98
|
+
print_not_git_repo_error()
|
|
99
|
+
exit(1)
|
|
100
|
+
}
|
|
101
|
+
files_result := changed_files_result()
|
|
102
|
+
if files_result["ok"] == false {
|
|
103
|
+
print_lines(["Error: Failed to inspect git working tree."])
|
|
104
|
+
exit(1)
|
|
105
|
+
}
|
|
106
|
+
files := files_result["files"]
|
|
107
|
+
if len(files) == 0 {
|
|
108
|
+
print_lines([
|
|
109
|
+
"# PatchBrief Test Suggestions",
|
|
110
|
+
"",
|
|
111
|
+
"No uncommitted changes detected. Nothing to test."
|
|
112
|
+
])
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
print_lines([
|
|
116
|
+
"# PatchBrief Test Suggestions",
|
|
117
|
+
"",
|
|
118
|
+
"Based on the changed files, consider running:",
|
|
119
|
+
""
|
|
120
|
+
])
|
|
121
|
+
suggestions := suggestions_for_files(files)
|
|
122
|
+
print_wrapped_lines(suggestions, "- `", "`")
|
|
123
|
+
print_markdown_section("General Validation")
|
|
124
|
+
print_wrapped_lines(GENERAL_VALIDATION, "- ", "")
|
|
125
|
+
print_markdown_section("Manual Checks")
|
|
126
|
+
# Per-file suggestions
|
|
127
|
+
fi_idx := 0
|
|
128
|
+
while fi_idx < len(files) {
|
|
129
|
+
print_prefixed_lines(manual_checks_for_file(files[fi_idx]), "- ")
|
|
130
|
+
fi_idx := fi_idx + 1
|
|
131
|
+
}
|
|
132
|
+
print_lines([
|
|
133
|
+
"",
|
|
134
|
+
"---",
|
|
135
|
+
"*Generated by PatchBrief v1.0.1*"
|
|
136
|
+
])
|
|
137
|
+
}
|