@kafka0102/onespec 0.1.2

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.
@@ -0,0 +1,444 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ die() {
5
+ echo "ERROR: $*" >&2
6
+ exit 1
7
+ }
8
+
9
+ valid_change() {
10
+ local change="$1"
11
+ [[ -n "$change" ]] || die "change name is required"
12
+ [[ "$change" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "invalid change name: $change"
13
+ [[ "$change" != *".."* ]] || die "change name must not contain '..'"
14
+ }
15
+
16
+ change_dir() {
17
+ local change="$1"
18
+ if [ -d "openspec/changes/$change" ]; then
19
+ printf 'openspec/changes/%s\n' "$change"
20
+ elif [ -d "openspec/changes/archive/$change" ]; then
21
+ printf 'openspec/changes/archive/%s\n' "$change"
22
+ else
23
+ printf 'openspec/changes/%s\n' "$change"
24
+ fi
25
+ }
26
+
27
+ state_file() {
28
+ local change="$1"
29
+ printf '%s/.onespec.yaml\n' "$(change_dir "$change")"
30
+ }
31
+
32
+ normalize_path() {
33
+ local input="$1"
34
+ local normalized="${input#./}"
35
+ [[ -n "$normalized" ]] || die "path must not be empty"
36
+ [[ "$normalized" != .*"/../"* ]] || die "path must not contain parent traversal: $input"
37
+ [[ "$normalized" != ../* ]] || die "path must not contain parent traversal: $input"
38
+ [[ "$normalized" != *"/.." ]] || die "path must not contain parent traversal: $input"
39
+ printf '%s\n' "$normalized"
40
+ }
41
+
42
+ sort_unique_lines() {
43
+ awk 'NF && !seen[$0]++ { print $0 }'
44
+ }
45
+
46
+ field_value() {
47
+ local file="$1"
48
+ local key="$2"
49
+ awk -F ': *' -v key="$key" '$1 == key { sub(/^[^:]+: */, ""); print; found=1; exit } END { if (!found) exit 0 }' "$file" 2>/dev/null
50
+ }
51
+
52
+ set_field() {
53
+ local file="$1"
54
+ local key="$2"
55
+ local value="$3"
56
+ local tmp
57
+ tmp="$(mktemp)"
58
+ if grep -q "^${key}:" "$file"; then
59
+ awk -v key="$key" -v value="$value" '
60
+ $0 ~ "^" key ":" { print key ": " value; next }
61
+ { print }
62
+ ' "$file" > "$tmp"
63
+ else
64
+ cat "$file" > "$tmp"
65
+ printf '%s: %s\n' "$key" "$value" >> "$tmp"
66
+ fi
67
+ mv "$tmp" "$file"
68
+ }
69
+
70
+ encode_base64() {
71
+ base64 | tr -d '\n'
72
+ }
73
+
74
+ decode_base64() {
75
+ if base64 --help >/dev/null 2>&1; then
76
+ base64 --decode 2>/dev/null || base64 -d 2>/dev/null || base64 -D
77
+ else
78
+ base64 -d 2>/dev/null || base64 -D
79
+ fi
80
+ }
81
+
82
+ load_tracked_lines() {
83
+ local change="$1"
84
+ local file encoded
85
+ file="$(state_file "$change")"
86
+ [ -f "$file" ] || return 0
87
+ encoded="$(field_value "$file" touched_files_b64)"
88
+ if [ -z "$encoded" ] || [ "$encoded" = "null" ]; then
89
+ return 0
90
+ fi
91
+ printf '%s' "$encoded" | decode_base64
92
+ }
93
+
94
+ save_tracked_lines() {
95
+ local change="$1"
96
+ local file="$2"
97
+ local state encoded
98
+ state="$(state_file "$change")"
99
+ [ -f "$state" ] || die "state not found: $state"
100
+ if [ ! -s "$file" ]; then
101
+ set_field "$state" touched_files_b64 "null"
102
+ return 0
103
+ fi
104
+ encoded="$(encode_base64 < "$file")"
105
+ set_field "$state" touched_files_b64 "$encoded"
106
+ }
107
+
108
+ ensure_git_repo() {
109
+ git rev-parse --show-toplevel >/dev/null 2>&1 || die "current directory is not inside a git repository"
110
+ }
111
+
112
+ git_dirty_paths() {
113
+ git status --porcelain=v1 --untracked-files=all | awk '
114
+ {
115
+ path = substr($0, 4)
116
+ sub(/^.* -> /, "", path)
117
+ if (length(path) > 0) {
118
+ print path
119
+ }
120
+ }
121
+ '
122
+ }
123
+
124
+ dirty_change_artifact_paths() {
125
+ local change="$1"
126
+ local dir
127
+ dir="$(change_dir "$change")"
128
+ git_dirty_paths | awk -v prefix="$dir/" 'index($0, prefix) == 1 { print }'
129
+ }
130
+
131
+ repo_layout() {
132
+ if [ -f "pnpm-workspace.yaml" ] || [ -f "nx.json" ] || [ -f "turbo.json" ] || [ -f "lerna.json" ] || [ -f "go.work" ] || [ -f "settings.gradle" ] || [ -f "settings.gradle.kts" ]; then
133
+ echo "multi"
134
+ return 0
135
+ fi
136
+ if [ -f "package.json" ] && grep -Eq '"workspaces"[[:space:]]*:' package.json; then
137
+ echo "multi"
138
+ return 0
139
+ fi
140
+ if [ -f "Cargo.toml" ] && grep -Eq '^\[workspace\]' Cargo.toml; then
141
+ echo "multi"
142
+ return 0
143
+ fi
144
+ if [ -f "pom.xml" ] && grep -Eq '<modules>' pom.xml; then
145
+ echo "multi"
146
+ return 0
147
+ fi
148
+ echo "single"
149
+ }
150
+
151
+ find_policy_doc() {
152
+ local pattern='提交|commit message|commit messages|提交信息|提交规范|提交格式|conventional commit|conventional commits|git workflow|commitlint|commitizen'
153
+ local file
154
+
155
+ for file in AGENTS.md CONTRIBUTING.md CONTRIBUTING.zh-CN.md README.md README-zh.md README.en.md; do
156
+ if [ -f "$file" ] && grep -Eiq "$pattern" "$file"; then
157
+ printf '%s\n' "$file"
158
+ return 0
159
+ fi
160
+ done
161
+
162
+ if [ -d docs ]; then
163
+ while IFS= read -r file; do
164
+ if grep -Eiq "$pattern" "$file"; then
165
+ printf '%s\n' "$file"
166
+ return 0
167
+ fi
168
+ done < <(find docs -type f \( -name '*.md' -o -name '*.txt' \) | sort)
169
+ fi
170
+
171
+ return 1
172
+ }
173
+
174
+ find_commit_config() {
175
+ local file
176
+
177
+ for file in \
178
+ commitlint.config.js \
179
+ commitlint.config.cjs \
180
+ commitlint.config.mjs \
181
+ commitlint.config.ts \
182
+ .commitlintrc \
183
+ .commitlintrc.json \
184
+ .commitlintrc.yml \
185
+ .commitlintrc.yaml \
186
+ .commitlintrc.js \
187
+ .commitlintrc.cjs \
188
+ .czrc
189
+ do
190
+ if [ -f "$file" ]; then
191
+ printf '%s\n' "$file"
192
+ return 0
193
+ fi
194
+ done
195
+
196
+ if [ -f "package.json" ] && grep -Eiq '"(commitlint|commitizen)"[[:space:]]*:' package.json; then
197
+ echo "package.json"
198
+ return 0
199
+ fi
200
+
201
+ return 1
202
+ }
203
+
204
+ detect_language_from_doc() {
205
+ local file="$1"
206
+
207
+ if grep -Eiq '简体中文|中文 conventional|中文提交|提交标题.*中文|描述.*中文' "$file"; then
208
+ echo "zh"
209
+ return 0
210
+ fi
211
+ if grep -Eiq 'english|commit message.*english|description.*english' "$file"; then
212
+ echo "en"
213
+ return 0
214
+ fi
215
+ if grep -q '[一-龥]' "$file"; then
216
+ echo "zh"
217
+ return 0
218
+ fi
219
+ echo "unknown"
220
+ }
221
+
222
+ detect_format_from_file() {
223
+ local file="$1"
224
+
225
+ if grep -Eiq '<type>\(<scope>\):|conventional commit|conventional commits|commitlint|type\(scope\)' "$file"; then
226
+ echo "conventional"
227
+ return 0
228
+ fi
229
+ echo "unknown"
230
+ }
231
+
232
+ infer_scope() {
233
+ local change="${1:-}"
234
+ local tracked
235
+
236
+ if [ -z "$change" ]; then
237
+ echo "repo"
238
+ return 0
239
+ fi
240
+
241
+ tracked="$(mktemp)"
242
+ load_tracked_lines "$change" > "$tracked"
243
+ if [ ! -s "$tracked" ]; then
244
+ rm -f "$tracked"
245
+ echo "repo"
246
+ return 0
247
+ fi
248
+
249
+ awk -F/ '
250
+ function scope_for(path, first, second) {
251
+ first = $1
252
+ second = $2
253
+ if (first == "packages" || first == "apps" || first == "services" || first == "libs") {
254
+ return second != "" ? second : "repo"
255
+ }
256
+ if (first == "docs" || first == "openspec") {
257
+ return "docs"
258
+ }
259
+ if (second == "") {
260
+ return "repo"
261
+ }
262
+ return first
263
+ }
264
+ {
265
+ candidate = scope_for($0)
266
+ seen[candidate] = 1
267
+ }
268
+ END {
269
+ count = 0
270
+ for (key in seen) {
271
+ choice = key
272
+ count++
273
+ }
274
+ if (count == 1) {
275
+ print choice
276
+ } else {
277
+ print "repo"
278
+ }
279
+ }
280
+ ' "$tracked"
281
+ rm -f "$tracked"
282
+ }
283
+
284
+ cmd_track() {
285
+ local change="$1"
286
+ shift
287
+ valid_change "$change"
288
+ [ "$#" -gt 0 ] || die "track requires at least one path"
289
+
290
+ local tracked tmp path
291
+ tracked="$(mktemp)"
292
+ tmp="$(mktemp)"
293
+
294
+ load_tracked_lines "$change" > "$tmp"
295
+
296
+ for path in "$@"; do
297
+ normalize_path "$path" >> "$tmp"
298
+ done
299
+
300
+ sort_unique_lines < "$tmp" > "$tracked"
301
+ save_tracked_lines "$change" "$tracked"
302
+ cat "$tracked"
303
+ rm -f "$tmp" "$tracked"
304
+ }
305
+
306
+ cmd_tracked() {
307
+ local change="$1"
308
+ valid_change "$change"
309
+ load_tracked_lines "$change"
310
+ }
311
+
312
+ cmd_related_dirty() {
313
+ local change="$1"
314
+ valid_change "$change"
315
+ ensure_git_repo
316
+
317
+ local tracked state dirty artifacts
318
+ tracked="$(mktemp)"
319
+ dirty="$(mktemp)"
320
+ artifacts="$(mktemp)"
321
+ load_tracked_lines "$change" > "$tracked"
322
+ state="$(state_file "$change")"
323
+ git_dirty_paths | sort_unique_lines > "$dirty"
324
+ dirty_change_artifact_paths "$change" | sort_unique_lines > "$artifacts"
325
+
326
+ if grep -Fxq "$state" "$dirty"; then
327
+ printf '%s\n' "$state" >> "$tracked"
328
+ fi
329
+
330
+ if [ -s "$artifacts" ]; then
331
+ cat "$artifacts" >> "$tracked"
332
+ fi
333
+
334
+ sort_unique_lines < "$tracked" > "${tracked}.sorted"
335
+ mv "${tracked}.sorted" "$tracked"
336
+
337
+ if [ ! -s "$tracked" ]; then
338
+ rm -f "$tracked" "$dirty" "$artifacts"
339
+ return 0
340
+ fi
341
+
342
+ awk 'NR==FNR { dirty[$0] = 1; next } dirty[$0] { print $0 }' "$dirty" "$tracked" | sort_unique_lines
343
+ rm -f "$tracked" "$dirty" "$artifacts"
344
+ }
345
+
346
+ cmd_stage_related() {
347
+ local change="$1"
348
+ valid_change "$change"
349
+ ensure_git_repo
350
+
351
+ local -a files=()
352
+ local file
353
+ while IFS= read -r file; do
354
+ files+=("$file")
355
+ done < <(cmd_related_dirty "$change")
356
+ if [ "${#files[@]}" -eq 0 ]; then
357
+ return 0
358
+ fi
359
+
360
+ git add -A -- "${files[@]}"
361
+ printf '%s\n' "${files[@]}"
362
+ }
363
+
364
+ cmd_detect_policy() {
365
+ local change="${1:-}"
366
+ local layout source origin format language confidence scope
367
+
368
+ layout="$(repo_layout)"
369
+ scope="$(infer_scope "$change")"
370
+ confidence="default"
371
+ origin="default"
372
+ source="default"
373
+ format="conventional"
374
+ language="en"
375
+
376
+ if source="$(find_policy_doc)"; then
377
+ origin="project-doc"
378
+ confidence="explicit"
379
+ format="$(detect_format_from_file "$source")"
380
+ language="$(detect_language_from_doc "$source")"
381
+ [ "$format" != "unknown" ] || format="conventional"
382
+ [ "$language" != "unknown" ] || language="en"
383
+ else
384
+ local config_source
385
+ if config_source="$(find_commit_config)"; then
386
+ source="$config_source"
387
+ origin="project-config"
388
+ confidence="partial"
389
+ format="conventional"
390
+ language="en"
391
+ fi
392
+ fi
393
+
394
+ cat <<EOF
395
+ policy_source: $source
396
+ policy_origin: $origin
397
+ policy_confidence: $confidence
398
+ commit_format: $format
399
+ message_language: $language
400
+ repo_layout: $layout
401
+ scope_hint: $scope
402
+ template: <type>(<scope>): <summary>
403
+ EOF
404
+ }
405
+
406
+ usage() {
407
+ cat <<'EOF'
408
+ 用法:
409
+ onespec-commit.sh track <change> <path>...
410
+ onespec-commit.sh tracked <change>
411
+ onespec-commit.sh related-dirty <change>
412
+ onespec-commit.sh stage-related <change>
413
+ onespec-commit.sh detect-policy [change]
414
+ EOF
415
+ }
416
+
417
+ cmd="${1:-}"
418
+ case "$cmd" in
419
+ track)
420
+ [ "$#" -ge 3 ] || { usage; exit 2; }
421
+ shift
422
+ cmd_track "$@"
423
+ ;;
424
+ tracked)
425
+ [ "$#" -eq 2 ] || { usage; exit 2; }
426
+ cmd_tracked "$2"
427
+ ;;
428
+ related-dirty)
429
+ [ "$#" -eq 2 ] || { usage; exit 2; }
430
+ cmd_related_dirty "$2"
431
+ ;;
432
+ stage-related)
433
+ [ "$#" -eq 2 ] || { usage; exit 2; }
434
+ cmd_stage_related "$2"
435
+ ;;
436
+ detect-policy)
437
+ [ "$#" -le 2 ] || { usage; exit 2; }
438
+ cmd_detect_policy "${2:-}"
439
+ ;;
440
+ *)
441
+ usage
442
+ exit 2
443
+ ;;
444
+ esac
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+
3
+ _onespec_env_source="${BASH_SOURCE[0]:-$0}"
4
+ _onespec_script_dir="$(cd "$(dirname "$_onespec_env_source")" && pwd -P)"
5
+
6
+ export ONESPEC_STATE="${ONESPEC_STATE:-${_onespec_script_dir}/onespec-state.sh}"
7
+ export ONESPEC_HANDOFF="${ONESPEC_HANDOFF:-${_onespec_script_dir}/onespec-handoff.sh}"
8
+ export ONESPEC_COMMIT="${ONESPEC_COMMIT:-${_onespec_script_dir}/onespec-commit.sh}"
9
+ export ONESPEC_CLOSEOUT="${ONESPEC_CLOSEOUT:-${_onespec_script_dir}/onespec-closeout.sh}"
10
+ export ONESPEC_BASH="${ONESPEC_BASH:-${BASH:-bash}}"
11
+
12
+ if [ ! -f "$ONESPEC_STATE" ] || [ ! -f "$ONESPEC_HANDOFF" ] || [ ! -f "$ONESPEC_COMMIT" ] || [ ! -f "$ONESPEC_CLOSEOUT" ]; then
13
+ echo "ERROR: OneSpec scripts are incomplete. Re-run onespec init --overwrite." >&2
14
+ return 1 2>/dev/null || exit 1
15
+ fi
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ die() {
5
+ echo "ERROR: $*" >&2
6
+ exit 1
7
+ }
8
+
9
+ hash_file() {
10
+ if command -v shasum >/dev/null 2>&1; then
11
+ shasum -a 256 "$1" | awk '{print $1}'
12
+ elif command -v sha256sum >/dev/null 2>&1; then
13
+ sha256sum "$1" | awk '{print $1}'
14
+ else
15
+ die "shasum or sha256sum is required"
16
+ fi
17
+ }
18
+
19
+ hash_text() {
20
+ if command -v shasum >/dev/null 2>&1; then
21
+ shasum -a 256 | awk '{print $1}'
22
+ elif command -v sha256sum >/dev/null 2>&1; then
23
+ sha256sum | awk '{print $1}'
24
+ else
25
+ die "shasum or sha256sum is required"
26
+ fi
27
+ }
28
+
29
+ json_escape() {
30
+ sed 's/\\/\\\\/g; s/"/\\"/g' <<<"$1"
31
+ }
32
+
33
+ valid_change() {
34
+ local change="$1"
35
+ [[ -n "$change" ]] || die "change name is required"
36
+ [[ "$change" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "invalid change name: $change"
37
+ [[ "$change" != *".."* ]] || die "change name must not contain '..'"
38
+ }
39
+
40
+ source_files() {
41
+ for file in "$change_dir/proposal.md" "$change_dir/design.md" "$change_dir/tasks.md"; do
42
+ [ -f "$file" ] && printf '%s\n' "$file"
43
+ done
44
+ if [ -d "$change_dir/specs" ]; then
45
+ find "$change_dir/specs" -path '*/spec.md' -type f | sort
46
+ fi
47
+ }
48
+
49
+ context_hash() {
50
+ source_files | while IFS= read -r file; do
51
+ printf '%s %s\n' "$(hash_file "$file")" "$file"
52
+ done | hash_text
53
+ }
54
+
55
+ summary_text() {
56
+ local files count first
57
+ files="$(source_files)"
58
+ count="$(printf '%s\n' "$files" | sed '/^$/d' | wc -l | tr -d ' ')"
59
+ first="$(printf '%s\n' "$files" | sed -n '1p')"
60
+ printf '%s handoff from %s file(s); primary artifact: %s' "$purpose" "$count" "$first"
61
+ }
62
+
63
+ write_excerpt() {
64
+ local file="$1"
65
+ local max_lines=100
66
+ local lines
67
+ lines="$(wc -l < "$file" | tr -d ' ')"
68
+ echo "## $file"
69
+ echo
70
+ echo "- sha256: $(hash_file "$file")"
71
+ echo "- lines: $lines"
72
+ echo
73
+ if [ "$mode" = "full" ] || [ "$lines" -le "$max_lines" ]; then
74
+ echo '```md'
75
+ cat "$file"
76
+ echo '```'
77
+ else
78
+ echo "[TRUNCATED: first ${max_lines} lines only]"
79
+ echo
80
+ echo '```md'
81
+ sed -n "1,${max_lines}p" "$file"
82
+ echo '```'
83
+ fi
84
+ echo
85
+ }
86
+
87
+ change="${1:-}"
88
+ purpose="${2:-}"
89
+ action="${3:-}"
90
+ full_flag="${4:-}"
91
+
92
+ [ "$action" = "--write" ] || die "usage: onespec-handoff.sh <change> <purpose> --write [--full]"
93
+ valid_change "$change"
94
+ case "$purpose" in proposal|plan|review|archive) ;; *) die "purpose must be proposal, plan, review, or archive" ;; esac
95
+ case "$full_flag" in "" ) mode="compact" ;; "--full" ) mode="full" ;; * ) die "unknown option: $full_flag" ;; esac
96
+
97
+ change_dir="openspec/changes/$change"
98
+ state="$change_dir/.onespec.yaml"
99
+ [ -d "$change_dir" ] || die "change directory not found: $change_dir"
100
+ [ -f "$state" ] || die "state file not found: $state"
101
+
102
+ if [ "$(source_files | wc -l | tr -d ' ')" -eq 0 ]; then
103
+ die "no OpenSpec artifacts found under $change_dir"
104
+ fi
105
+
106
+ hash="$(context_hash)"
107
+ summary="$(summary_text)"
108
+
109
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd -P)"
110
+ "${BASH:-bash}" "$script_dir/onespec-state.sh" set "$change" handoff_context "$state"
111
+ "${BASH:-bash}" "$script_dir/onespec-state.sh" set "$change" handoff_purpose "$purpose"
112
+ "${BASH:-bash}" "$script_dir/onespec-state.sh" set "$change" handoff_summary "$summary"
113
+ "${BASH:-bash}" "$script_dir/onespec-state.sh" set "$change" handoff_hash "$hash"
114
+
115
+ echo "$state"