@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +116 -0
  4. package/SECURITY.md +35 -0
  5. package/VERSION +1 -0
  6. package/bundled/components/changebucket/LICENSE +9 -0
  7. package/bundled/components/changebucket/changebucket.kujo +12 -0
  8. package/bundled/components/changebucket/src/analyze.kujo +240 -0
  9. package/bundled/components/changebucket/src/budget.kujo +92 -0
  10. package/bundled/components/changebucket/src/classify.kujo +221 -0
  11. package/bundled/components/changebucket/src/cli.kujo +324 -0
  12. package/bundled/components/changebucket/src/diffsrc.kujo +252 -0
  13. package/bundled/components/changebucket/src/render.kujo +346 -0
  14. package/bundled/components/changebucket/src/util.kujo +62 -0
  15. package/bundled/components/context/LICENSE +9 -0
  16. package/bundled/components/context/scent.kujo +3250 -0
  17. package/bundled/components/failure-evidence/LICENSE +9 -0
  18. package/bundled/components/failure-evidence/casefile.kujo +2061 -0
  19. package/bundled/components/patchbrief/LICENSE +9 -0
  20. package/bundled/components/patchbrief/patchbrief.kujo +153 -0
  21. package/bundled/components/patchbrief/schemas/patchbrief-handoff.schema.json +38 -0
  22. package/bundled/components/patchbrief/schemas/patchbrief-summary.schema.json +46 -0
  23. package/bundled/components/patchbrief/src/common.kujo +212 -0
  24. package/bundled/components/patchbrief/src/git.kujo +250 -0
  25. package/bundled/components/patchbrief/src/handoff.kujo +145 -0
  26. package/bundled/components/patchbrief/src/suggest_tests.kujo +137 -0
  27. package/bundled/components/patchbrief/src/summarize.kujo +309 -0
  28. package/bundled/kujo-components.lock.json +134 -0
  29. package/dist/manifest.js +14696 -0
  30. package/dist/ui/index.js +179 -0
  31. package/dist/worker.js +30536 -0
  32. package/docs/ARCHITECTURE.md +28 -0
  33. package/docs/CATALOG_SUBMISSION.md +25 -0
  34. package/docs/COMPATIBILITY.md +21 -0
  35. package/docs/CONFIGURATION.md +33 -0
  36. package/docs/INSTALLATION.md +53 -0
  37. package/docs/OPERATIONS.md +60 -0
  38. package/docs/README.md +18 -0
  39. package/docs/RELEASE_READINESS.md +46 -0
  40. package/docs/THREAT_MODEL.md +26 -0
  41. package/docs/TROUBLESHOOTING.md +12 -0
  42. package/docs/USAGE.md +95 -0
  43. package/examples/agent-workflow.md +20 -0
  44. package/package.json +71 -0
  45. package/schemas/changebucket-analysis.schema.json +25 -0
  46. package/schemas/context-pack.schema.json +23 -0
  47. package/schemas/failure-evidence.schema.json +18 -0
  48. package/schemas/review-pack.schema.json +19 -0
  49. package/skills/scoped-repository-context/SKILL.md +9 -0
@@ -0,0 +1,221 @@
1
+ # ChangeBucket :: classify
2
+ #
3
+ # Pragmatic path/extension rules that bucket a changed file into zero or more
4
+ # categories. A file may belong to several (e.g. package.json is both `config`
5
+ # and `dependency_manifests`). Rules are intentionally simple and documented in
6
+ # the README; this is not a plugin system and should not become one.
7
+ #
8
+ # Helper predicates each use a single `for` loop so the module compiles cleanly
9
+ # under the checker (which rejects >1 `for` loop per function scope).
10
+
11
+ from src.util import basename
12
+
13
+ # The canonical category order used everywhere (reports, JSON, counts).
14
+ export func category_order() {
15
+ return ["source", "tests", "docs", "config", "dependency_manifests", "lockfiles", "generated", "ci", "scripts", "other"]
16
+ }
17
+
18
+ # --- tiny predicate helpers (one loop each) -----------------------------
19
+
20
+ func ends_any(s, suffixes) {
21
+ for suf in suffixes {
22
+ if ends_with(s, suf) {
23
+ return true
24
+ }
25
+ }
26
+ return false
27
+ }
28
+
29
+ func starts_any(s, prefixes) {
30
+ for p in prefixes {
31
+ if starts_with(s, p) {
32
+ return true
33
+ }
34
+ }
35
+ return false
36
+ }
37
+
38
+ func contains_any(s, subs) {
39
+ for sub in subs {
40
+ if contains(s, sub) == 1 {
41
+ return true
42
+ }
43
+ }
44
+ return false
45
+ }
46
+
47
+ # True when one of `names` occurs as a complete slash-delimited path segment.
48
+ # Appending/prepending slashes avoids false positives such as `rebuild/` for
49
+ # the generated directory `build/` and `descripts/` for `scripts/`.
50
+ func has_dir_segment(path, names) {
51
+ wrapped := "/" + path + "/"
52
+ for name in names {
53
+ if contains(wrapped, "/" + name + "/") == 1 {
54
+ return true
55
+ }
56
+ }
57
+ return false
58
+ }
59
+
60
+ func eq_any(s, vals) {
61
+ for v in vals {
62
+ if s == v {
63
+ return true
64
+ }
65
+ }
66
+ return false
67
+ }
68
+
69
+ # --- per-category predicates --------------------------------------------
70
+
71
+ func is_test(pl, namel) {
72
+ if contains_any(pl, ["/test/", "/tests/", "/__tests__/", "/spec/"]) {
73
+ return true
74
+ }
75
+ if starts_any(pl, ["test/", "tests/", "spec/"]) {
76
+ return true
77
+ }
78
+ if contains(namel, ".test.") == 1 || contains(namel, ".spec.") == 1 {
79
+ return true
80
+ }
81
+ if starts_with(namel, "test_") {
82
+ return true
83
+ }
84
+ if ends_any(namel, ["_test.go", "_test.py", "_test.rs", "_spec.rb", ".test.ts", ".spec.ts"]) {
85
+ return true
86
+ }
87
+ return false
88
+ }
89
+
90
+ func is_docs(pl, namel) {
91
+ if ends_any(namel, [".md", ".mdx", ".rst", ".adoc"]) {
92
+ return true
93
+ }
94
+ if has_dir_segment(pl, ["docs", "doc"]) {
95
+ return true
96
+ }
97
+ if starts_any(namel, ["readme", "changelog", "license", "contributing", "authors", "code_of_conduct"]) {
98
+ return true
99
+ }
100
+ return false
101
+ }
102
+
103
+ func is_lockfile(namel) {
104
+ return eq_any(namel, ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "cargo.lock", "poetry.lock", "composer.lock", "go.sum", "gemfile.lock", "pdm.lock", "bun.lockb", "pipfile.lock"])
105
+ }
106
+
107
+ func is_dependency_manifest(namel) {
108
+ if eq_any(namel, ["package.json", "pyproject.toml", "cargo.toml", "composer.json", "go.mod", "gemfile", "setup.py", "pipfile", "build.gradle", "pom.xml", "setup.cfg"]) {
109
+ return true
110
+ }
111
+ # requirements.txt / requirements-dev.txt / requirements/foo.txt
112
+ if starts_with(namel, "requirements") && ends_with(namel, ".txt") {
113
+ return true
114
+ }
115
+ return false
116
+ }
117
+
118
+ func is_ci(pl, namel) {
119
+ if contains(pl, "/.github/workflows/") == 1 || starts_with(pl, ".github/workflows/") {
120
+ return true
121
+ }
122
+ if contains(pl, "/.gitea/workflows/") == 1 || starts_with(pl, ".gitea/workflows/") {
123
+ return true
124
+ }
125
+ if has_dir_segment(pl, [".circleci"]) {
126
+ return true
127
+ }
128
+ if eq_any(namel, ["jenkinsfile", ".gitlab-ci.yml", "circle.yml", ".travis.yml", "azure-pipelines.yml", "bitbucket-pipelines.yml", ".drone.yml"]) {
129
+ return true
130
+ }
131
+ return false
132
+ }
133
+
134
+ func is_generated(pl, namel) {
135
+ if has_dir_segment(pl, ["dist", "build", "target", "node_modules", ".next", ".nuxt", "out", "coverage", "__pycache__", "vendor", ".svelte-kit"]) {
136
+ return true
137
+ }
138
+ if ends_any(namel, [".min.js", ".min.css", ".map", ".g.dart", ".generated.ts", ".pb.go"]) {
139
+ return true
140
+ }
141
+ if ends_with(namel, "_pb2.py") {
142
+ return true
143
+ }
144
+ return false
145
+ }
146
+
147
+ func is_scripts(pl, namel) {
148
+ if has_dir_segment(pl, ["scripts", "bin"]) {
149
+ return true
150
+ }
151
+ if ends_any(namel, [".sh", ".bash", ".zsh", ".fish", ".ps1"]) {
152
+ return true
153
+ }
154
+ return false
155
+ }
156
+
157
+ func is_config(pl, namel) {
158
+ if eq_any(namel, ["package.json", "tsconfig.json", "pyproject.toml", "cargo.toml", "composer.json", "kujo.toml", "rustfmt.toml", ".editorconfig", ".eslintrc", ".eslintrc.js", ".eslintrc.json", ".eslintrc.cjs", ".prettierrc", ".prettierrc.json", "tslint.json", "dockerfile", "makefile", ".gitignore", ".dockerignore", ".npmrc", "go.work"]) {
159
+ return true
160
+ }
161
+ if contains_any(namel, [".config.", "eslint.config", "vite.config", "rollup.config", "webpack.config", "tailwind.config", "next.config", "svelte.config", "postcss.config", "jest.config", "babel.config", "vitest.config"]) {
162
+ return true
163
+ }
164
+ if ends_any(namel, [".toml", ".ini", ".cfg", ".conf", ".properties"]) {
165
+ return true
166
+ }
167
+ if starts_with(namel, ".env") {
168
+ return true
169
+ }
170
+ return false
171
+ }
172
+
173
+ func is_source(pl, namel, is_t, is_d) {
174
+ if is_t || is_d {
175
+ return false
176
+ }
177
+ return ends_any(namel, [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".py", ".rs", ".go", ".php", ".rb", ".java", ".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".cs", ".swift", ".kt", ".kts", ".scala", ".m", ".mm", ".vue", ".svelte", ".kujo", ".kujo", ".lua", ".dart", ".ex", ".exs", ".erl", ".clj", ".sql", ".html", ".css", ".scss"])
178
+ }
179
+
180
+ # Return the list of categories a path belongs to. Always non-empty: a file
181
+ # matching nothing falls back to ["other"].
182
+ export func classify(path) {
183
+ pl := to_lower(path)
184
+ namel := to_lower(basename(path))
185
+
186
+ matched_test := is_test(pl, namel)
187
+ matched_docs := is_docs(pl, namel)
188
+
189
+ mut cats := []
190
+ if is_source(pl, namel, matched_test, matched_docs) {
191
+ cats = push(cats, "source")
192
+ }
193
+ if matched_test {
194
+ cats = push(cats, "tests")
195
+ }
196
+ if matched_docs {
197
+ cats = push(cats, "docs")
198
+ }
199
+ if is_config(pl, namel) {
200
+ cats = push(cats, "config")
201
+ }
202
+ if is_dependency_manifest(namel) {
203
+ cats = push(cats, "dependency_manifests")
204
+ }
205
+ if is_lockfile(namel) {
206
+ cats = push(cats, "lockfiles")
207
+ }
208
+ if is_generated(pl, namel) {
209
+ cats = push(cats, "generated")
210
+ }
211
+ if is_ci(pl, namel) {
212
+ cats = push(cats, "ci")
213
+ }
214
+ if is_scripts(pl, namel) {
215
+ cats = push(cats, "scripts")
216
+ }
217
+ if len(cats) == 0 {
218
+ cats = push(cats, "other")
219
+ }
220
+ return cats
221
+ }
@@ -0,0 +1,324 @@
1
+ # ChangeBucket :: cli
2
+ #
3
+ # Argument parsing and command dispatch. This is the only module that prints
4
+ # user-facing output and chooses the process exit code. It wires together
5
+ # analyze / budget / render.
6
+ #
7
+ # Exit code convention:
8
+ # 0 success, or analysis printed (default command always exits 0)
9
+ # 1 operational failure (not a git repo), OR a `check` whose budget failed
10
+ # 2 usage error (unknown command)
11
+
12
+ from src.analyze import analyze
13
+ from src.budget import empty_config, has_constraints, evaluate
14
+ from src.render import render_text, render_markdown
15
+ from src.util import includes
16
+
17
+ VERSION := "1.0.0"
18
+
19
+ # --- argument parsing ---------------------------------------------------
20
+
21
+ func split_flag_token(token) {
22
+ raw := substring(token, 2, len(token))
23
+ eq := index_of(raw, "=")
24
+ if eq >= 0 {
25
+ return {"name": substring(raw, 0, eq), "value": substring(raw, eq + 1, len(raw)), "has_value": true}
26
+ }
27
+ return {"name": raw, "value": "", "has_value": false}
28
+ }
29
+
30
+ # Split tokens into positionals and flags. Names in `bool_flags` are valueless
31
+ # switches; names in `value_flags` consume the next token as their value.
32
+ func parse_args(tokens, bool_flags) {
33
+ mut flags := {}
34
+ mut positionals := []
35
+ mut i := 0
36
+ while i < len(tokens) {
37
+ token := tokens[i]
38
+ if starts_with(token, "--") {
39
+ split_flag := split_flag_token(token)
40
+ name := split_flag["name"]
41
+ if known_flag(name, bool_flags) == false {
42
+ return {"positionals": [], "flags": {}, "error": "unknown option --" + name}
43
+ }
44
+ if includes(bool_flags, name) {
45
+ if split_flag["has_value"] == true {
46
+ return {"positionals": [], "flags": {}, "error": "option --" + name + " does not take a value"}
47
+ }
48
+ flags[name] := "true"
49
+ } else if split_flag["has_value"] == true {
50
+ if split_flag["value"] == "" {
51
+ return {"positionals": [], "flags": {}, "error": "missing value for --" + name}
52
+ }
53
+ flags[name] := split_flag["value"]
54
+ } else {
55
+ if i + 1 >= len(tokens) || starts_with(tokens[i + 1], "-") {
56
+ return {"positionals": [], "flags": {}, "error": "missing value for --" + name}
57
+ }
58
+ flags[name] := tokens[i + 1]
59
+ i = i + 1
60
+ }
61
+ } else {
62
+ positionals = append(positionals, token)
63
+ }
64
+ i = i + 1
65
+ }
66
+ return {"positionals": positionals, "flags": flags}
67
+ }
68
+
69
+ func flag(parsed, name) {
70
+ if has_key(parsed["flags"], name) == 1 {
71
+ return parsed["flags"][name]
72
+ }
73
+ return null
74
+ }
75
+
76
+ func flag_or(parsed, name, fallback) {
77
+ v := flag(parsed, name)
78
+ if type(v) == "null" {
79
+ return fallback
80
+ }
81
+ return v
82
+ }
83
+
84
+ func has_flag(parsed, name) {
85
+ return has_key(parsed["flags"], name) == 1
86
+ }
87
+
88
+ # The set of valueless boolean flags ChangeBucket understands.
89
+ func bool_flags() {
90
+ return ["json", "markdown", "no-deletes", "no-dependency-changes", "no-lockfile-changes", "no-config-changes", "no-generated-changes"]
91
+ }
92
+
93
+ func value_flags() {
94
+ return ["base", "head", "repo", "output", "max-files", "max-churn", "max-additions", "max-deletions"]
95
+ }
96
+
97
+ func numeric_flags() {
98
+ return ["max-files", "max-churn", "max-additions", "max-deletions"]
99
+ }
100
+
101
+ func known_flag(name, bools) {
102
+ return includes(bools, name) || includes(value_flags(), name)
103
+ }
104
+
105
+ func is_digits(text) {
106
+ if text == "" {
107
+ return false
108
+ }
109
+ mut i := 0
110
+ while i < len(text) {
111
+ ch := substring(text, i, i + 1)
112
+ if contains("0123456789", ch) == 0 {
113
+ return false
114
+ }
115
+ i = i + 1
116
+ }
117
+ return true
118
+ }
119
+
120
+ func trim_leading_zeroes(text) {
121
+ mut i := 0
122
+ while i + 1 < len(text) && substring(text, i, i + 1) == "0" {
123
+ i = i + 1
124
+ }
125
+ return substring(text, i, len(text))
126
+ }
127
+
128
+ func fits_int(text) {
129
+ normalized := trim_leading_zeroes(text)
130
+ max_int := "9223372036854775807"
131
+ if len(normalized) < len(max_int) {
132
+ return true
133
+ }
134
+ if len(normalized) > len(max_int) {
135
+ return false
136
+ }
137
+ return normalized <= max_int
138
+ }
139
+
140
+ func validate_numeric_flags(parsed) {
141
+ for name in numeric_flags() {
142
+ v := flag(parsed, name)
143
+ if type(v) != "null" {
144
+ if is_digits(v) == false {
145
+ return "invalid numeric value for --" + name + ": " + v
146
+ }
147
+ if fits_int(v) == false {
148
+ return "numeric value out of range for --" + name + ": " + v
149
+ }
150
+ }
151
+ }
152
+ return ""
153
+ }
154
+
155
+ # Read an integer flag (null when absent or empty).
156
+ func int_flag(parsed, name) {
157
+ v := flag(parsed, name)
158
+ if type(v) == "null" || v == "" {
159
+ return null
160
+ }
161
+ return parse_int(v)
162
+ }
163
+
164
+ # Build a budget config from parsed flags.
165
+ func build_config(parsed) {
166
+ cfg := empty_config()
167
+ cfg["max_files"] := int_flag(parsed, "max-files")
168
+ cfg["max_churn"] := int_flag(parsed, "max-churn")
169
+ cfg["max_additions"] := int_flag(parsed, "max-additions")
170
+ cfg["max_deletions"] := int_flag(parsed, "max-deletions")
171
+ cfg["no_deletes"] := has_flag(parsed, "no-deletes")
172
+ cfg["no_dependency_changes"] := has_flag(parsed, "no-dependency-changes")
173
+ cfg["no_lockfile_changes"] := has_flag(parsed, "no-lockfile-changes")
174
+ cfg["no_config_changes"] := has_flag(parsed, "no-config-changes")
175
+ cfg["no_generated_changes"] := has_flag(parsed, "no-generated-changes")
176
+ return cfg
177
+ }
178
+
179
+ # --- the one real command ----------------------------------------------
180
+
181
+ # Run analysis and emit output. `is_check` decides whether a failed budget
182
+ # turns into a non-zero exit code.
183
+ func run(parsed, is_check) {
184
+ if has_key(parsed, "error") == 1 {
185
+ print("error: " + parsed["error"])
186
+ return 2
187
+ }
188
+ if len(parsed["positionals"]) > 0 {
189
+ print("error: unexpected argument '" + parsed["positionals"][0] + "'")
190
+ return 2
191
+ }
192
+ numeric_error := validate_numeric_flags(parsed)
193
+ if numeric_error != "" {
194
+ print("error: " + numeric_error)
195
+ return 2
196
+ }
197
+
198
+ repo := flag_or(parsed, "repo", ".")
199
+ base := flag(parsed, "base")
200
+ head := flag(parsed, "head")
201
+
202
+ model := analyze(repo, base, head)
203
+ if type(model) == "dict" && has_key(model, "error") == 1 {
204
+ print("error: " + model["error"])
205
+ return 1
206
+ }
207
+
208
+ cfg := build_config(parsed)
209
+ show_budget := is_check || has_constraints(cfg)
210
+ mut m := model
211
+ if show_budget {
212
+ m = evaluate(m, cfg)
213
+ }
214
+
215
+ mut code := 0
216
+ if is_check && has_key(m, "budget") == 1 && m["budget"]["checked"] == true && m["budget"]["passed"] == false {
217
+ code = 1
218
+ }
219
+
220
+ if has_flag(parsed, "json") {
221
+ print(to_json_pretty(m))
222
+ return code
223
+ }
224
+
225
+ output := flag(parsed, "output")
226
+ wants_md := has_flag(parsed, "markdown") || (type(output) != "null" && output != "")
227
+ if wants_md {
228
+ md := render_markdown(m, cfg, show_budget)
229
+ if type(output) != "null" && output != "" {
230
+ try {
231
+ if path_is_dir(output) {
232
+ print("error: output path is a directory: " + output)
233
+ return 1
234
+ }
235
+ write_file(output, md, true)
236
+ } except write_error {
237
+ print("error: could not write report to " + output + ": " + to_string(write_error))
238
+ return 1
239
+ }
240
+ print("Wrote report to " + output)
241
+ return code
242
+ }
243
+ print(md)
244
+ return code
245
+ }
246
+
247
+ print(render_text(m, cfg, show_budget))
248
+ return code
249
+ }
250
+
251
+ # --- help / dispatch ----------------------------------------------------
252
+
253
+ func print_lines(lines) {
254
+ mut i := 0
255
+ while i < len(lines) {
256
+ print(lines[i])
257
+ i = i + 1
258
+ }
259
+ }
260
+
261
+ func print_help() {
262
+ print_lines([
263
+ "changebucket " + VERSION + " — measure the footprint of a code change",
264
+ "",
265
+ "Usage:",
266
+ " changebucket [options] Analyze the working tree against HEAD",
267
+ " changebucket check [budget opts] Analyze and enforce a budget (non-zero exit on fail)",
268
+ "",
269
+ "Range options:",
270
+ " --base <ref> Compare against this ref (default HEAD)",
271
+ " --head <ref> Compare a commit range base..head (no working tree)",
272
+ " --repo <path> Repository to analyze (default current directory)",
273
+ "",
274
+ "Output options:",
275
+ " --json Emit machine-readable JSON only",
276
+ " --markdown Emit a markdown report",
277
+ " --output <file> Write a markdown report to a file",
278
+ "",
279
+ "Budget options (informational on the default command; enforced under `check`):",
280
+ " --max-files <n>",
281
+ " --max-churn <n>",
282
+ " --max-additions <n>",
283
+ " --max-deletions <n>",
284
+ " --no-deletes",
285
+ " --no-dependency-changes",
286
+ " --no-lockfile-changes",
287
+ " --no-config-changes",
288
+ " --no-generated-changes",
289
+ "",
290
+ " help, --help Show this help",
291
+ " version Show version",
292
+ "",
293
+ "ChangeBucket uses read-only git commands and never modifies your repo."
294
+ ])
295
+ }
296
+
297
+ # Entry point. `argv` is the user argument list (args()).
298
+ export func main(argv) {
299
+ if len(argv) == 0 {
300
+ return run(parse_args([], bool_flags()), false)
301
+ }
302
+
303
+ cmd := argv[0]
304
+
305
+ if cmd == "help" || cmd == "--help" || cmd == "-h" {
306
+ print_help()
307
+ return 0
308
+ }
309
+ if cmd == "version" || cmd == "--version" || cmd == "-V" {
310
+ print("changebucket " + VERSION)
311
+ return 0
312
+ }
313
+ if cmd == "check" {
314
+ rest := slice(argv, 1, len(argv))
315
+ return run(parse_args(rest, bool_flags()), true)
316
+ }
317
+ if starts_with(cmd, "-") {
318
+ return run(parse_args(argv, bool_flags()), false)
319
+ }
320
+
321
+ print("error: unknown command '" + cmd + "'")
322
+ print("Run 'changebucket help' for usage.")
323
+ return 2
324
+ }