@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,252 @@
|
|
|
1
|
+
# ChangeBucket :: diffsrc
|
|
2
|
+
#
|
|
3
|
+
# The git interface. Everything here is READ-ONLY: it only ever runs
|
|
4
|
+
# rev-parse / diff / ls-files. Nothing stages, commits, resets, cleans,
|
|
5
|
+
# checks out, stashes, or applies. It tolerates the absence of git or of a
|
|
6
|
+
# repository so callers always get a structured answer.
|
|
7
|
+
#
|
|
8
|
+
# Two analysis shapes are supported:
|
|
9
|
+
# worktree mode — compare the working tree (incl. untracked files) to a base
|
|
10
|
+
# ref (default HEAD, or the empty tree for a repo with no
|
|
11
|
+
# commits yet).
|
|
12
|
+
# range mode — compare two commits, base..head (no working-tree/untracked).
|
|
13
|
+
|
|
14
|
+
from src.util import basename
|
|
15
|
+
|
|
16
|
+
# The well-known empty-tree object. Diffing against it yields "everything is an
|
|
17
|
+
# addition", which is exactly what we want for a repo that has no commits yet.
|
|
18
|
+
func empty_tree() {
|
|
19
|
+
return "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# Quote a path for safe inclusion in a shell command (POSIX single-quote rules).
|
|
23
|
+
func shq(path) {
|
|
24
|
+
escaped := replace_str(path, "'", "'\\''")
|
|
25
|
+
return "'" + escaped + "'"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Run a read-only git command against `repo_path`. `-C` keeps us in the target
|
|
29
|
+
# repo without changing cwd; quotePath=false stops git from octal-escaping
|
|
30
|
+
# non-ASCII paths so our tab parsing stays simple.
|
|
31
|
+
func git(repo_path, subcmd) {
|
|
32
|
+
cmd := "git -C " + shq(repo_path) + " -c core.quotePath=false " + subcmd
|
|
33
|
+
return execute_status(cmd)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Reject ref inputs that are unsafe or ambiguous to pass through the shell/git.
|
|
37
|
+
# This keeps user-supplied --base/--head values from being interpreted as shell
|
|
38
|
+
# syntax or git options.
|
|
39
|
+
func has_unsafe_ref_char(ref) {
|
|
40
|
+
bad := [" ", "\t", "\n", "\r", "'", "\"", ";", "&", "|", "`", "$", "(", ")", "<", ">", "\\"]
|
|
41
|
+
for ch in bad {
|
|
42
|
+
if contains(ref, ch) == 1 {
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func safe_ref_value(ref) {
|
|
50
|
+
if type(ref) == "null" || ref == "" {
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
if starts_with(ref, "-") {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
if has_unsafe_ref_char(ref) {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# True if git exists and `repo_path` is inside a working tree.
|
|
63
|
+
export func is_repo(repo_path) {
|
|
64
|
+
r := git(repo_path, "rev-parse --is-inside-work-tree")
|
|
65
|
+
if r.exitcode == 0 && trim(r.stdout) == "true" {
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Current commit hash, or null when there is none (no git, no repo, no commits).
|
|
72
|
+
export func head_commit(repo_path) {
|
|
73
|
+
r := git(repo_path, "rev-parse HEAD")
|
|
74
|
+
if r.exitcode == 0 {
|
|
75
|
+
return trim(r.stdout)
|
|
76
|
+
}
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Canonical repository root. Git diff paths are root-relative even when `-C`
|
|
81
|
+
# points at a subdirectory, so all file access and untracked paths must use the
|
|
82
|
+
# same root or the model mixes incompatible path bases.
|
|
83
|
+
export func repo_root(repo_path) {
|
|
84
|
+
r := git(repo_path, "rev-parse --show-toplevel")
|
|
85
|
+
if r.exitcode == 0 {
|
|
86
|
+
mut root := r.stdout
|
|
87
|
+
if ends_with(root, "\n") {
|
|
88
|
+
root = substring(root, 0, len(root) - 1)
|
|
89
|
+
}
|
|
90
|
+
if ends_with(root, "\r") {
|
|
91
|
+
root = substring(root, 0, len(root) - 1)
|
|
92
|
+
}
|
|
93
|
+
return root
|
|
94
|
+
}
|
|
95
|
+
return repo_path
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# Decide what we are diffing and produce a stable description:
|
|
99
|
+
# {mode, spec, base_label, head_label, include_untracked}
|
|
100
|
+
# `spec` is the argument handed to `git diff` (a single ref in worktree mode,
|
|
101
|
+
# or "base..head" in range mode).
|
|
102
|
+
export func resolve_refs(repo_path, base_flag, head_flag) {
|
|
103
|
+
if type(head_flag) != "null" && head_flag != "" {
|
|
104
|
+
mut base := "HEAD"
|
|
105
|
+
if type(base_flag) != "null" && base_flag != "" {
|
|
106
|
+
base = base_flag
|
|
107
|
+
}
|
|
108
|
+
if safe_ref_value(base) == false {
|
|
109
|
+
return {"error": "invalid --base ref: " + base}
|
|
110
|
+
}
|
|
111
|
+
if safe_ref_value(head_flag) == false {
|
|
112
|
+
return {"error": "invalid --head ref: " + head_flag}
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
"mode": "range",
|
|
116
|
+
"spec": base + ".." + head_flag,
|
|
117
|
+
"base_label": base,
|
|
118
|
+
"head_label": head_flag,
|
|
119
|
+
"include_untracked": false
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
# worktree mode
|
|
123
|
+
mut base := ""
|
|
124
|
+
mut base_label := ""
|
|
125
|
+
if type(base_flag) != "null" && base_flag != "" {
|
|
126
|
+
if safe_ref_value(base_flag) == false {
|
|
127
|
+
return {"error": "invalid --base ref: " + base_flag}
|
|
128
|
+
}
|
|
129
|
+
base = base_flag
|
|
130
|
+
base_label = base_flag
|
|
131
|
+
} else {
|
|
132
|
+
hc := head_commit(repo_path)
|
|
133
|
+
if type(hc) == "null" {
|
|
134
|
+
base = empty_tree()
|
|
135
|
+
base_label = "(empty tree)"
|
|
136
|
+
} else {
|
|
137
|
+
base = "HEAD"
|
|
138
|
+
base_label = "HEAD"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
"mode": "worktree",
|
|
143
|
+
"spec": base,
|
|
144
|
+
"base_label": base_label,
|
|
145
|
+
"head_label": "working tree",
|
|
146
|
+
"include_untracked": true
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func diff_spec_arg(refs) {
|
|
151
|
+
return shq(refs["spec"])
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Validate the resolved diff spec once up front so a bad ref does not look like
|
|
155
|
+
# a legitimate empty change.
|
|
156
|
+
export func validate_diff(repo_path, refs) {
|
|
157
|
+
r := git(repo_path, "diff --name-status --no-renames " + diff_spec_arg(refs))
|
|
158
|
+
if r.exitcode == 0 {
|
|
159
|
+
return ""
|
|
160
|
+
}
|
|
161
|
+
mut msg := trim(r.stderr)
|
|
162
|
+
if msg == "" {
|
|
163
|
+
msg = trim(r.stdout)
|
|
164
|
+
}
|
|
165
|
+
if msg == "" {
|
|
166
|
+
msg = "git diff failed"
|
|
167
|
+
}
|
|
168
|
+
return "invalid git revision or range '" + refs["spec"] + "': " + msg
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# NUL-delimited `git diff --numstat` output for the resolved spec
|
|
172
|
+
# (added\tdeleted\tpath\0; "-" for binary). Renames are disabled.
|
|
173
|
+
export func numstat_text(repo_path, refs) {
|
|
174
|
+
r := git(repo_path, "diff --numstat -z --no-renames " + diff_spec_arg(refs))
|
|
175
|
+
if r.exitcode == 0 {
|
|
176
|
+
return r.stdout
|
|
177
|
+
}
|
|
178
|
+
return ""
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# NUL-delimited `git diff --name-status` output (status\0path\0).
|
|
182
|
+
export func namestatus_text(repo_path, refs) {
|
|
183
|
+
r := git(repo_path, "diff --name-status -z --no-renames " + diff_spec_arg(refs))
|
|
184
|
+
if r.exitcode == 0 {
|
|
185
|
+
return r.stdout
|
|
186
|
+
}
|
|
187
|
+
return ""
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# Untracked files not yet known to git (respecting .gitignore).
|
|
191
|
+
export func untracked_list(repo_path) {
|
|
192
|
+
mut out := []
|
|
193
|
+
r := git(repo_path, "ls-files --full-name --others --exclude-standard -z")
|
|
194
|
+
if r.exitcode != 0 {
|
|
195
|
+
return out
|
|
196
|
+
}
|
|
197
|
+
nul := parse_json("\"\\u0000\"")
|
|
198
|
+
paths := split(r.stdout, nul)
|
|
199
|
+
for name in paths {
|
|
200
|
+
if name != "" {
|
|
201
|
+
out = push(out, name)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return out
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# Treat a path as binary purely by extension. Used for untracked files, where
|
|
208
|
+
# numstat cannot tell us. Keeps us from trying to line-count an image or
|
|
209
|
+
# archive.
|
|
210
|
+
export func is_binary_path(path) {
|
|
211
|
+
pl := to_lower(path)
|
|
212
|
+
exts := [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".pdf", ".zip", ".gz", ".tar", ".tgz", ".bz2", ".7z", ".rar", ".woff", ".woff2", ".ttf", ".eot", ".otf", ".mp4", ".mp3", ".wav", ".mov", ".avi", ".class", ".jar", ".so", ".dylib", ".dll", ".exe", ".bin", ".wasm", ".o", ".a", ".lockb", ".db", ".sqlite", ".pyc", ".woff", ".pkl"]
|
|
213
|
+
for e in exts {
|
|
214
|
+
if ends_with(pl, e) {
|
|
215
|
+
return true
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return false
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# Count the lines in an untracked text file (its "additions"). Tolerates a
|
|
222
|
+
# missing/unreadable file by returning 0.
|
|
223
|
+
export func count_added_lines(repo_path, path) {
|
|
224
|
+
full := join_path(repo_path, path)
|
|
225
|
+
if file_exists(full) == false {
|
|
226
|
+
return 0
|
|
227
|
+
}
|
|
228
|
+
# Git records a symbolic link as one line containing its target. Reading
|
|
229
|
+
# the path would instead follow it, producing wrong churn and potentially
|
|
230
|
+
# inspecting data outside the repository.
|
|
231
|
+
if path_is_symlink(full) {
|
|
232
|
+
return 1
|
|
233
|
+
}
|
|
234
|
+
if path_is_file(full) == false {
|
|
235
|
+
return 0
|
|
236
|
+
}
|
|
237
|
+
mut content := ""
|
|
238
|
+
try {
|
|
239
|
+
content = read_file(full)
|
|
240
|
+
} except read_error {
|
|
241
|
+
return 0
|
|
242
|
+
}
|
|
243
|
+
if content == "" {
|
|
244
|
+
return 0
|
|
245
|
+
}
|
|
246
|
+
parts := split(content, "\n")
|
|
247
|
+
n := len(parts)
|
|
248
|
+
if parts[n - 1] == "" {
|
|
249
|
+
return n - 1
|
|
250
|
+
}
|
|
251
|
+
return n
|
|
252
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# ChangeBucket :: render
|
|
2
|
+
#
|
|
3
|
+
# Pure presentation: turn an analysis model into a compact text report or a
|
|
4
|
+
# markdown report. Nothing here touches disk or git. (JSON output is just
|
|
5
|
+
# `to_json_pretty(model)` and lives in the CLI.)
|
|
6
|
+
#
|
|
7
|
+
# Loops are index/`while` based so the module compiles under the checker.
|
|
8
|
+
|
|
9
|
+
from src.util import commas, display_path
|
|
10
|
+
from src.classify import category_order
|
|
11
|
+
|
|
12
|
+
# Friendly label for a category key.
|
|
13
|
+
func category_labels() {
|
|
14
|
+
return {
|
|
15
|
+
"source": "Source",
|
|
16
|
+
"tests": "Tests",
|
|
17
|
+
"docs": "Docs",
|
|
18
|
+
"config": "Config",
|
|
19
|
+
"dependency_manifests": "Dependency manifests",
|
|
20
|
+
"lockfiles": "Lockfiles",
|
|
21
|
+
"generated": "Generated",
|
|
22
|
+
"ci": "CI",
|
|
23
|
+
"scripts": "Scripts",
|
|
24
|
+
"other": "Other"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func cat_label(key) {
|
|
29
|
+
labels := category_labels()
|
|
30
|
+
if has_key(labels, key) == 1 {
|
|
31
|
+
return labels[key]
|
|
32
|
+
}
|
|
33
|
+
return "Other"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func mark(ok) {
|
|
37
|
+
if ok {
|
|
38
|
+
return "PASS"
|
|
39
|
+
}
|
|
40
|
+
return "FAIL"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func append_lines(out, lines) {
|
|
44
|
+
mut next := out
|
|
45
|
+
mut i := 0
|
|
46
|
+
while i < len(lines) {
|
|
47
|
+
next = push(next, lines[i])
|
|
48
|
+
i = i + 1
|
|
49
|
+
}
|
|
50
|
+
return next
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func append_text_rows(out, rows) {
|
|
54
|
+
mut next := out
|
|
55
|
+
mut i := 0
|
|
56
|
+
while i < len(rows) {
|
|
57
|
+
row := rows[i]
|
|
58
|
+
next = push(next, "- " + row[0] + ": " + row[1])
|
|
59
|
+
i = i + 1
|
|
60
|
+
}
|
|
61
|
+
return next
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Files sorted by churn descending, truncated to `n`. A copy is sorted in place
|
|
65
|
+
# via selection sort (no sort builtin is relied on).
|
|
66
|
+
export func top_by_churn(files, n) {
|
|
67
|
+
mut arr := []
|
|
68
|
+
mut i := 0
|
|
69
|
+
while i < len(files) {
|
|
70
|
+
arr = push(arr, files[i])
|
|
71
|
+
i = i + 1
|
|
72
|
+
}
|
|
73
|
+
mut a := 0
|
|
74
|
+
while a < len(arr) {
|
|
75
|
+
mut best := a
|
|
76
|
+
mut b := a + 1
|
|
77
|
+
while b < len(arr) {
|
|
78
|
+
if arr[b]["churn"] > arr[best]["churn"] {
|
|
79
|
+
best = b
|
|
80
|
+
}
|
|
81
|
+
b = b + 1
|
|
82
|
+
}
|
|
83
|
+
if best != a {
|
|
84
|
+
tmp := arr[a]
|
|
85
|
+
arr[a] := arr[best]
|
|
86
|
+
arr[best] := tmp
|
|
87
|
+
}
|
|
88
|
+
a = a + 1
|
|
89
|
+
}
|
|
90
|
+
mut out := []
|
|
91
|
+
mut k := 0
|
|
92
|
+
while k < len(arr) && k < n {
|
|
93
|
+
out = push(out, arr[k])
|
|
94
|
+
k = k + 1
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func metric_value(summary, key) {
|
|
100
|
+
if key == "risk_level" {
|
|
101
|
+
return summary[key]
|
|
102
|
+
}
|
|
103
|
+
return commas(summary[key])
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func summary_rows(summary, specs) {
|
|
107
|
+
mut rows := []
|
|
108
|
+
mut i := 0
|
|
109
|
+
while i < len(specs) {
|
|
110
|
+
spec := specs[i]
|
|
111
|
+
rows = push(rows, [spec[0], metric_value(summary, spec[1])])
|
|
112
|
+
i = i + 1
|
|
113
|
+
}
|
|
114
|
+
return rows
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func text_summary_specs() {
|
|
118
|
+
return [
|
|
119
|
+
["Files changed", "files_changed"],
|
|
120
|
+
["Lines added", "lines_added"],
|
|
121
|
+
["Lines deleted", "lines_deleted"],
|
|
122
|
+
["Total line churn", "total_churn"],
|
|
123
|
+
["Files added", "files_added"],
|
|
124
|
+
["Files modified", "files_modified"],
|
|
125
|
+
["Files deleted", "files_deleted"],
|
|
126
|
+
["Binary files", "binary_files"],
|
|
127
|
+
["Risk level", "risk_level"]
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func markdown_summary_specs() {
|
|
132
|
+
return [
|
|
133
|
+
["Files changed", "files_changed"],
|
|
134
|
+
["Files added", "files_added"],
|
|
135
|
+
["Files modified", "files_modified"],
|
|
136
|
+
["Files deleted", "files_deleted"],
|
|
137
|
+
["Binary files", "binary_files"],
|
|
138
|
+
["Lines added", "lines_added"],
|
|
139
|
+
["Lines deleted", "lines_deleted"],
|
|
140
|
+
["Total churn", "total_churn"],
|
|
141
|
+
["Risk level", "risk_level"]
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
func budget_limit_line(label, limit, actual, ok) {
|
|
146
|
+
return "- " + label + ": " + commas(limit) + " [" + mark(ok) + "] (actual " + commas(actual) + ")"
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func budget_ban_line(label, ok) {
|
|
150
|
+
return "- " + label + " allowed: no [" + mark(ok) + "]"
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# Budget lines for the text report, derived from the config so each configured
|
|
154
|
+
# constraint shows its limit and a PASS/FAIL marker.
|
|
155
|
+
func budget_text_lines(model, cfg) {
|
|
156
|
+
s := model["summary"]
|
|
157
|
+
cats := model["categories"]
|
|
158
|
+
mut lines := []
|
|
159
|
+
if type(cfg["max_files"]) != "null" {
|
|
160
|
+
lines = push(lines, budget_limit_line("Max files", cfg["max_files"], s["files_changed"], s["files_changed"] <= cfg["max_files"]))
|
|
161
|
+
}
|
|
162
|
+
if type(cfg["max_churn"]) != "null" {
|
|
163
|
+
lines = push(lines, budget_limit_line("Max churn", cfg["max_churn"], s["total_churn"], s["total_churn"] <= cfg["max_churn"]))
|
|
164
|
+
}
|
|
165
|
+
if type(cfg["max_additions"]) != "null" {
|
|
166
|
+
lines = push(lines, budget_limit_line("Max additions", cfg["max_additions"], s["lines_added"], s["lines_added"] <= cfg["max_additions"]))
|
|
167
|
+
}
|
|
168
|
+
if type(cfg["max_deletions"]) != "null" {
|
|
169
|
+
lines = push(lines, budget_limit_line("Max deletions", cfg["max_deletions"], s["lines_deleted"], s["lines_deleted"] <= cfg["max_deletions"]))
|
|
170
|
+
}
|
|
171
|
+
if cfg["no_deletes"] == true {
|
|
172
|
+
lines = push(lines, budget_ban_line("Deletes", s["files_deleted"] == 0))
|
|
173
|
+
}
|
|
174
|
+
if cfg["no_dependency_changes"] == true {
|
|
175
|
+
lines = push(lines, budget_ban_line("Dependency changes", len(cats["dependency_manifests"]) == 0))
|
|
176
|
+
}
|
|
177
|
+
if cfg["no_lockfile_changes"] == true {
|
|
178
|
+
lines = push(lines, budget_ban_line("Lockfile changes", len(cats["lockfiles"]) == 0))
|
|
179
|
+
}
|
|
180
|
+
if cfg["no_config_changes"] == true {
|
|
181
|
+
lines = push(lines, budget_ban_line("Config changes", len(cats["config"]) == 0))
|
|
182
|
+
}
|
|
183
|
+
if cfg["no_generated_changes"] == true {
|
|
184
|
+
lines = push(lines, budget_ban_line("Generated changes", len(cats["generated"]) == 0))
|
|
185
|
+
}
|
|
186
|
+
return lines
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# Compact, human-readable terminal report.
|
|
190
|
+
export func render_text(model, cfg, show_budget) {
|
|
191
|
+
s := model["summary"]
|
|
192
|
+
cats := model["categories"]
|
|
193
|
+
mut out := []
|
|
194
|
+
out = append_lines(out, [
|
|
195
|
+
"ChangeBucket",
|
|
196
|
+
"",
|
|
197
|
+
"Base: " + model["base"],
|
|
198
|
+
"Head: " + model["head"],
|
|
199
|
+
"",
|
|
200
|
+
"Summary:"
|
|
201
|
+
])
|
|
202
|
+
summary := summary_rows(s, text_summary_specs())
|
|
203
|
+
out = append_text_rows(out, summary)
|
|
204
|
+
out = append_lines(out, ["", "Categories:"])
|
|
205
|
+
order := category_order()
|
|
206
|
+
mut ci := 0
|
|
207
|
+
while ci < len(order) {
|
|
208
|
+
key := order[ci]
|
|
209
|
+
count := len(cats[key])
|
|
210
|
+
if count > 0 {
|
|
211
|
+
out = push(out, "- " + pad_right(cat_label(key) + ":", 22, " ") + commas(count))
|
|
212
|
+
}
|
|
213
|
+
ci = ci + 1
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
largest := top_by_churn(model["files"], 5)
|
|
217
|
+
if len(largest) > 0 {
|
|
218
|
+
out = append_lines(out, ["", "Largest changes:"])
|
|
219
|
+
mut li := 0
|
|
220
|
+
while li < len(largest) {
|
|
221
|
+
f := largest[li]
|
|
222
|
+
out = push(out, "- " + display_path(f["path"]) + " (" + f["status"] + ", +" + commas(f["additions"]) + " -" + commas(f["deletions"]) + ", churn " + commas(f["churn"]) + ")")
|
|
223
|
+
li = li + 1
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if show_budget && model["budget"]["checked"] == true {
|
|
228
|
+
out = append_lines(out, ["", "Budget:"])
|
|
229
|
+
blines := budget_text_lines(model, cfg)
|
|
230
|
+
if len(blines) == 0 {
|
|
231
|
+
out = push(out, "- (no constraints set)")
|
|
232
|
+
} else {
|
|
233
|
+
out = append_lines(out, blines)
|
|
234
|
+
}
|
|
235
|
+
if model["budget"]["passed"] == true {
|
|
236
|
+
out = append_lines(out, ["", "Result: within budget"])
|
|
237
|
+
} else {
|
|
238
|
+
out = append_lines(out, ["", "Result: budget exceeded"])
|
|
239
|
+
failures := model["budget"]["failures"]
|
|
240
|
+
mut fi := 0
|
|
241
|
+
while fi < len(failures) {
|
|
242
|
+
out = push(out, " - " + failures[fi])
|
|
243
|
+
fi = fi + 1
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return join(out, "\n")
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
func md_row(cells) {
|
|
252
|
+
return "| " + join(cells, " | ") + " |"
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
func append_md_rows(out, rows) {
|
|
256
|
+
mut next := out
|
|
257
|
+
mut i := 0
|
|
258
|
+
while i < len(rows) {
|
|
259
|
+
next = push(next, md_row(rows[i]))
|
|
260
|
+
i = i + 1
|
|
261
|
+
}
|
|
262
|
+
return next
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
func append_md_table(out, header, separator, rows) {
|
|
266
|
+
mut next := out
|
|
267
|
+
next = push(next, md_row(header))
|
|
268
|
+
next = push(next, md_row(separator))
|
|
269
|
+
next = append_md_rows(next, rows)
|
|
270
|
+
next = push(next, "")
|
|
271
|
+
return next
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
func markdown_code(value) {
|
|
275
|
+
safe := replace_str(display_path(value), "|", "\\|")
|
|
276
|
+
mut fence := "`"
|
|
277
|
+
while contains(safe, fence) == 1 {
|
|
278
|
+
fence = fence + "`"
|
|
279
|
+
}
|
|
280
|
+
return fence + safe + fence
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
# Clean markdown report.
|
|
284
|
+
export func render_markdown(model, cfg, show_budget) {
|
|
285
|
+
s := model["summary"]
|
|
286
|
+
cats := model["categories"]
|
|
287
|
+
mut out := []
|
|
288
|
+
out = append_lines(out, [
|
|
289
|
+
"# ChangeBucket Report",
|
|
290
|
+
"",
|
|
291
|
+
"Generated: " + model["generated_at"],
|
|
292
|
+
"",
|
|
293
|
+
"Base: " + markdown_code(model["base"]) + " Head: " + markdown_code(model["head"]),
|
|
294
|
+
"",
|
|
295
|
+
"## Summary",
|
|
296
|
+
""
|
|
297
|
+
])
|
|
298
|
+
summary := summary_rows(s, markdown_summary_specs())
|
|
299
|
+
out = append_md_table(out, ["Metric", "Value"], ["---", "---:"], summary)
|
|
300
|
+
|
|
301
|
+
if show_budget && model["budget"]["checked"] == true {
|
|
302
|
+
out = append_lines(out, ["## Budget Result", ""])
|
|
303
|
+
if model["budget"]["passed"] == true {
|
|
304
|
+
out = push(out, "Status: **within budget**")
|
|
305
|
+
} else {
|
|
306
|
+
out = push(out, "Status: **budget exceeded**")
|
|
307
|
+
}
|
|
308
|
+
out = push(out, "")
|
|
309
|
+
failures := model["budget"]["failures"]
|
|
310
|
+
if len(failures) > 0 {
|
|
311
|
+
mut fi := 0
|
|
312
|
+
while fi < len(failures) {
|
|
313
|
+
out = push(out, "- " + failures[fi])
|
|
314
|
+
fi = fi + 1
|
|
315
|
+
}
|
|
316
|
+
out = push(out, "")
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
out = append_lines(out, ["## File Categories", ""])
|
|
321
|
+
order := category_order()
|
|
322
|
+
mut category_rows := []
|
|
323
|
+
mut ci := 0
|
|
324
|
+
while ci < len(order) {
|
|
325
|
+
key := order[ci]
|
|
326
|
+
count := len(cats[key])
|
|
327
|
+
if count > 0 {
|
|
328
|
+
category_rows = push(category_rows, [cat_label(key), commas(count)])
|
|
329
|
+
}
|
|
330
|
+
ci = ci + 1
|
|
331
|
+
}
|
|
332
|
+
out = append_md_table(out, ["Category", "Count"], ["---", "---:"], category_rows)
|
|
333
|
+
|
|
334
|
+
largest := top_by_churn(model["files"], 10)
|
|
335
|
+
out = append_lines(out, ["## Largest Changes", ""])
|
|
336
|
+
mut largest_rows := []
|
|
337
|
+
mut li := 0
|
|
338
|
+
while li < len(largest) {
|
|
339
|
+
f := largest[li]
|
|
340
|
+
largest_rows = push(largest_rows, [markdown_code(f["path"]), f["status"], commas(f["additions"]), commas(f["deletions"]), commas(f["churn"])])
|
|
341
|
+
li = li + 1
|
|
342
|
+
}
|
|
343
|
+
out = append_md_table(out, ["File", "Status", "+", "-", "Churn"], ["---", "---", "---:", "---:", "---:"], largest_rows)
|
|
344
|
+
|
|
345
|
+
return join(out, "\n")
|
|
346
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# ChangeBucket :: util
|
|
2
|
+
#
|
|
3
|
+
# Small, dependency-free helpers shared across the tool: timestamps, number
|
|
4
|
+
# formatting, padding for tables, and tiny predicate helpers. Kept deliberately
|
|
5
|
+
# boring so the rest of the codebase can rely on it without surprises.
|
|
6
|
+
|
|
7
|
+
# Thousands-separated decimal string for an integer, e.g. 1280 -> "1,280".
|
|
8
|
+
# Negative numbers are handled; the sign is preserved.
|
|
9
|
+
export func commas(n) {
|
|
10
|
+
neg := n < 0
|
|
11
|
+
mut s := to_string(n)
|
|
12
|
+
if neg {
|
|
13
|
+
s = substring(s, 1, len(s))
|
|
14
|
+
}
|
|
15
|
+
mut out := ""
|
|
16
|
+
mut count := 0
|
|
17
|
+
mut i := len(s) - 1
|
|
18
|
+
while i >= 0 {
|
|
19
|
+
out = substring(s, i, i + 1) + out
|
|
20
|
+
count = count + 1
|
|
21
|
+
if count == 3 && i > 0 {
|
|
22
|
+
out = "," + out
|
|
23
|
+
count = 0
|
|
24
|
+
}
|
|
25
|
+
i = i - 1
|
|
26
|
+
}
|
|
27
|
+
if neg {
|
|
28
|
+
out = "-" + out
|
|
29
|
+
}
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Truthiness for the 1/0 predicate builtins (contains/has_key/...).
|
|
34
|
+
export func truthy(flag) {
|
|
35
|
+
return flag == 1 || flag == true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# True if any element of `arr` equals `value`.
|
|
39
|
+
export func includes(arr, value) {
|
|
40
|
+
for item in arr {
|
|
41
|
+
if item == value {
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# The basename (last path segment) of a slash-separated path.
|
|
49
|
+
export func basename(path) {
|
|
50
|
+
parts := split(path, "/")
|
|
51
|
+
return parts[len(parts) - 1]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Keep terminal/markdown reports one-record-per-line even when git paths contain
|
|
55
|
+
# legal control characters. JSON output retains the exact original path.
|
|
56
|
+
export func display_path(path) {
|
|
57
|
+
mut out := replace_str(path, "\\", "\\\\")
|
|
58
|
+
out = replace_str(out, "\r", "\\r")
|
|
59
|
+
out = replace_str(out, "\n", "\\n")
|
|
60
|
+
out = replace_str(out, "\t", "\\t")
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kujolang <contact@kujolang.ai>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|