@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,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
|
+
}
|