@house365/comate 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.
@@ -0,0 +1,430 @@
1
+ #!/bin/bash
2
+ # comate-lib.sh — shared library for comate (sourced, never executed directly)
3
+ #
4
+ # Single source of truth for: YAML flat read/write, field-order template + enums,
5
+ # hashing, logging, exit-code contract. Other scripts `source` this file.
6
+ #
7
+ # - all YAML logic lives here (Comet duplicated it across 4 scripts).
8
+ # - .comate.yaml is a declared flat `key: value` subset; validate_flat_yaml enforces it.
9
+ # - yaml_set does full-rewrite + atomic mv + write-back verification.
10
+ # - stable exit codes — 0 OK | 1 BLOCK | 2 USAGE | 3 ENV | 4 CORRUPT.
11
+ #
12
+ # This file MUST NOT set `set -e` / `set -u`: it is sourced into hosts that
13
+ # manage their own shell options.
14
+
15
+ # --- Exit-code contract ---
16
+ readonly COMATE_OK=0
17
+ readonly COMATE_BLOCK=1
18
+ readonly COMATE_USAGE=2
19
+ readonly COMATE_ENV=3
20
+ readonly COMATE_CORRUPT=4
21
+
22
+ # --- Color logging on stderr ---
23
+ _comate_color() {
24
+ # $1 = ansi code, $2 = message
25
+ if [ -t 2 ]; then
26
+ printf '\033[%sm%s\033[0m\n' "$1" "$2" >&2
27
+ else
28
+ printf '%s\n' "$2" >&2
29
+ fi
30
+ }
31
+ log_pass() { _comate_color "32" "$1"; }
32
+ log_fail() { _comate_color "31" "$1"; }
33
+ log_warn() { _comate_color "33" "$1"; }
34
+ log_info() { _comate_color "36" "$1"; }
35
+
36
+ # die <code> <msg> — print red error to stderr and exit with the given code.
37
+ die() {
38
+ local code="$1"
39
+ shift
40
+ _comate_color "31" "$*"
41
+ exit "$code"
42
+ }
43
+
44
+ # --- Canonical field order ---
45
+ # Every yaml_set rewrites the whole file in this order, so diffs stay stable
46
+ # and duplicate keys are impossible.
47
+ # Three-place sync rule: a new field must be added here, in ENUMS (if enum),
48
+ # and in the bats yaml fixtures.
49
+ FIELD_ORDER=(
50
+ schema_version
51
+ workflow
52
+ phase
53
+ build_mode
54
+ tdd_mode
55
+ isolation
56
+ verify_mode
57
+ verify_result
58
+ design_doc
59
+ plan
60
+ verification_report
61
+ branch_status
62
+ handoff_context
63
+ handoff_hash
64
+ impl_context
65
+ impl_hash
66
+ direct_override
67
+ base_ref
68
+ base_branch
69
+ source_kind
70
+ source_manifest
71
+ build_command
72
+ verify_command
73
+ created_at
74
+ verified_at
75
+ archived
76
+ )
77
+
78
+ # Current schema version written by `state init`.
79
+ readonly COMATE_SCHEMA_VERSION=1
80
+
81
+ # --- Enum table: key -> space-separated legal values ---
82
+ declare -A ENUMS=(
83
+ [schema_version]="1"
84
+ [workflow]="full hotfix tweak"
85
+ [phase]="open design build verify archive"
86
+ [build_mode]="subagent-driven-development executing-plans direct"
87
+ [tdd_mode]="tdd direct"
88
+ [isolation]="branch worktree"
89
+ [verify_mode]="light full"
90
+ [verify_result]="pending pass fail"
91
+ [branch_status]="pending handled"
92
+ [direct_override]="true false"
93
+ [archived]="true false"
94
+ [source_kind]="conversation file"
95
+ )
96
+
97
+ # Keys whose values are free-form (paths, dates, commands, hashes, refs).
98
+ # Listed so validate_change/validate can flag genuinely unknown keys.
99
+ declare -A FREEFORM_KEYS=(
100
+ [design_doc]=1
101
+ [plan]=1
102
+ [verification_report]=1
103
+ [handoff_context]=1
104
+ [handoff_hash]=1
105
+ [impl_context]=1
106
+ [impl_hash]=1
107
+ [base_ref]=1
108
+ [base_branch]=1
109
+ [source_manifest]=1
110
+ [build_command]=1
111
+ [verify_command]=1
112
+ [created_at]=1
113
+ [verified_at]=1
114
+ )
115
+
116
+ # --- Path location ---
117
+
118
+ # comate_change_dir <name> — echo the change directory, preferring the active
119
+ # location but falling back to archive/ if that is where it lives.
120
+ comate_change_dir() {
121
+ local name="$1"
122
+ if [ -d "openspec/changes/$name" ]; then
123
+ echo "openspec/changes/$name"
124
+ elif [ -d "openspec/changes/archive/$name" ]; then
125
+ echo "openspec/changes/archive/$name"
126
+ else
127
+ echo "openspec/changes/$name"
128
+ fi
129
+ }
130
+
131
+ # comate_yaml_for <name> — echo path to that change's .comate.yaml.
132
+ comate_yaml_for() {
133
+ echo "$(comate_change_dir "$1")/.comate.yaml"
134
+ }
135
+
136
+ # --- Input validation ---
137
+
138
+ # validate_change_name <name> — only [A-Za-z0-9_-]; reject empty and `..`.
139
+ validate_change_name() {
140
+ local name="$1"
141
+ if [ -z "$name" ]; then
142
+ die "$COMATE_USAGE" "ERROR: change name cannot be empty"
143
+ fi
144
+ if [[ "$name" == *..* ]]; then
145
+ die "$COMATE_USAGE" "ERROR: change name cannot contain '..' (path traversal not allowed): '$name'"
146
+ fi
147
+ if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
148
+ die "$COMATE_USAGE" "ERROR: invalid change name '$name' (allowed: a-z A-Z 0-9 - _)"
149
+ fi
150
+ }
151
+
152
+ # validate_enum <key> <value> — die if value not legal for an enum key.
153
+ # Free-form keys and unlisted keys pass through. null/empty always allowed.
154
+ validate_enum() {
155
+ local key="$1" value="$2"
156
+ local legal="${ENUMS[$key]:-}"
157
+ [ -z "$legal" ] && return 0
158
+ [ -z "$value" ] && return 0
159
+ [ "$value" = "null" ] && return 0
160
+ local v
161
+ for v in $legal; do
162
+ [ "$value" = "$v" ] && return 0
163
+ done
164
+ die "$COMATE_USAGE" "ERROR: invalid value '$value' for '$key' (expected: $legal)"
165
+ }
166
+
167
+ # --- YAML read (single implementation) ---
168
+
169
+ # _strip_inline_comment — drop a ` #...` comment that is not inside quotes.
170
+ _strip_inline_comment() {
171
+ printf '%s\n' "$1" | awk -v squote="'" '
172
+ {
173
+ out = ""; quote = ""
174
+ for (i = 1; i <= length($0); i++) {
175
+ c = substr($0, i, 1)
176
+ if (quote == "") {
177
+ if (c == "\"" || c == squote) { quote = c }
178
+ else if (c == "#" && (i == 1 || substr($0, i - 1, 1) ~ /[[:space:]]/)) {
179
+ sub(/[[:space:]]+$/, "", out); print out; next
180
+ }
181
+ } else if (c == quote) { quote = "" }
182
+ out = out c
183
+ }
184
+ print out
185
+ }'
186
+ }
187
+
188
+ # _strip_wrapping_quotes — remove a single pair of wrapping quotes.
189
+ _strip_wrapping_quotes() {
190
+ local value="$1"
191
+ case "$value" in
192
+ \"*\") printf '%s\n' "${value:1:${#value}-2}" ;;
193
+ \'*\') printf '%s\n' "${value:1:${#value}-2}" ;;
194
+ *) printf '%s\n' "$value" ;;
195
+ esac
196
+ }
197
+
198
+ # yaml_get <file> <key> — echo the value (comment/quote-stripped), empty if absent.
199
+ yaml_get() {
200
+ local file="$1" key="$2"
201
+ [ -f "$file" ] || return 0
202
+ local value
203
+ value=$(grep "^${key}:" "$file" 2>/dev/null | head -1 | sed "s/^${key}:[[:space:]]*//" || true)
204
+ value=$(_strip_inline_comment "$value")
205
+ _strip_wrapping_quotes "$value"
206
+ }
207
+
208
+ # yaml_keys <file> — list all top-level keys, one per line.
209
+ yaml_keys() {
210
+ local file="$1"
211
+ [ -f "$file" ] || return 0
212
+ grep -E '^[A-Za-z0-9_-]+:' "$file" 2>/dev/null | sed 's/:.*//' || true
213
+ }
214
+
215
+ # _yaml_read_all <file> <assoc-array-name> — load every known field into the
216
+ # named associative array, defaulting missing fields to "null".
217
+ _yaml_read_all() {
218
+ local file="$1" arrname="$2" k
219
+ for k in "${FIELD_ORDER[@]}"; do
220
+ local v
221
+ v=$(yaml_get "$file" "$k")
222
+ [ -z "$v" ] && v="null"
223
+ printf -v "${arrname}[$k]" '%s' "$v"
224
+ done
225
+ }
226
+
227
+ # validate_flat_yaml <file> — enforce that .comate.yaml is the
228
+ # declared subset: top-level `key: value` scalars plus `#` comments only.
229
+ # Reject indented lines, list items, block/flow collections. die(CORRUPT) on violation.
230
+ validate_flat_yaml() {
231
+ local file="$1"
232
+ [ -f "$file" ] || die "$COMATE_CORRUPT" "ERROR: state file not found: $file"
233
+ local lineno=0 line
234
+ while IFS= read -r line || [ -n "$line" ]; do
235
+ lineno=$((lineno + 1))
236
+ # Allow blank lines and full-line comments.
237
+ case "$line" in
238
+ "" ) continue ;;
239
+ esac
240
+ if [[ "$line" =~ ^[[:space:]]*# ]]; then
241
+ continue
242
+ fi
243
+ # Reject leading whitespace (nesting / continuation / list under a key).
244
+ if [[ "$line" =~ ^[[:space:]]+ ]]; then
245
+ die "$COMATE_CORRUPT" "ERROR: $file:$lineno indented line not allowed (flat key:value only): $line"
246
+ fi
247
+ # Reject top-level list items.
248
+ if [[ "$line" =~ ^- ]]; then
249
+ die "$COMATE_CORRUPT" "ERROR: $file:$lineno list item not allowed (flat key:value only): $line"
250
+ fi
251
+ # Require a `key: value` shape.
252
+ if [[ ! "$line" =~ ^[A-Za-z0-9_-]+: ]]; then
253
+ die "$COMATE_CORRUPT" "ERROR: $file:$lineno not a 'key: value' line: $line"
254
+ fi
255
+ # Reject values that open a block scalar (| or >).
256
+ local val
257
+ val=$(printf '%s\n' "$line" | sed 's/^[A-Za-z0-9_-]*:[[:space:]]*//')
258
+ val=$(_strip_inline_comment "$val")
259
+ case "$val" in
260
+ "|"|">"|"|-"|">-"|"|+"|">+")
261
+ die "$COMATE_CORRUPT" "ERROR: $file:$lineno block scalar not allowed (flat key:value only): $line" ;;
262
+ esac
263
+ done < "$file"
264
+ return 0
265
+ }
266
+
267
+ # --- YAML write (full rewrite + atomic mv + write-back check) ---
268
+
269
+ # yaml_set <file> <key> <value> — validate enum, read whole file, set key in
270
+ # memory, rewrite every field in FIELD_ORDER to a temp file, atomically mv into
271
+ # place, then read back and confirm the value matches.
272
+ yaml_set() {
273
+ local file="$1" key="$2" value="$3"
274
+ [ -f "$file" ] || die "$COMATE_CORRUPT" "ERROR: state file not found: $file"
275
+
276
+ # Reject keys we do not know about (prevents silent typo fields).
277
+ if [ -z "${ENUMS[$key]:-}" ] && [ -z "${FREEFORM_KEYS[$key]:-}" ] && [ "$key" != "schema_version" ]; then
278
+ die "$COMATE_USAGE" "ERROR: unknown field '$key'"
279
+ fi
280
+ validate_enum "$key" "$value"
281
+
282
+ declare -A _cur
283
+ _yaml_read_all "$file" _cur
284
+ _cur[$key]="$value"
285
+
286
+ local tmp
287
+ tmp=$(mktemp "${TMPDIR:-/tmp}/comate.XXXXXX") || die "$COMATE_ENV" "ERROR: mktemp failed"
288
+ {
289
+ printf '# comate state — flat key:value only, no nesting\n'
290
+ local k
291
+ for k in "${FIELD_ORDER[@]}"; do
292
+ printf '%s: %s\n' "$k" "${_cur[$k]:-null}"
293
+ done
294
+ } > "$tmp"
295
+
296
+ mv "$tmp" "$file" || { rm -f "$tmp"; die "$COMATE_ENV" "ERROR: failed to replace $file"; }
297
+
298
+ # Write-back verification.
299
+ local readback
300
+ readback=$(yaml_get "$file" "$key")
301
+ if [ "$readback" != "$value" ]; then
302
+ die "$COMATE_CORRUPT" "ERROR: write-back mismatch for '$key': wrote '$value', read '$readback'"
303
+ fi
304
+ }
305
+
306
+ # --- Hashing (cross-platform) ---
307
+
308
+ # _hash_stream — sha256 of stdin, hex only.
309
+ _hash_stream() {
310
+ if command -v sha256sum >/dev/null 2>&1; then
311
+ sha256sum | awk '{print $1}'
312
+ elif command -v shasum >/dev/null 2>&1; then
313
+ shasum -a 256 | awk '{print $1}'
314
+ else
315
+ die "$COMATE_ENV" "ERROR: sha256sum or shasum is required"
316
+ fi
317
+ }
318
+
319
+ # hash_file <file> — sha256 hex of one file.
320
+ hash_file() {
321
+ local file="$1"
322
+ if command -v sha256sum >/dev/null 2>&1; then
323
+ sha256sum "$file" | awk '{print $1}'
324
+ elif command -v shasum >/dev/null 2>&1; then
325
+ shasum -a 256 "$file" | awk '{print $1}'
326
+ else
327
+ die "$COMATE_ENV" "ERROR: sha256sum or shasum is required"
328
+ fi
329
+ }
330
+
331
+ # hash_files_manifest <file...> — for each existing file emit `path:`/`sha256:`
332
+ # lines, then hash the whole manifest. Order-stable, missing files skipped.
333
+ hash_files_manifest() {
334
+ local f
335
+ for f in "$@"; do
336
+ if [ -f "$f" ]; then
337
+ printf 'path:%s\n' "$f"
338
+ printf 'sha256:%s\n' "$(hash_file "$f")"
339
+ fi
340
+ done | _hash_stream
341
+ }
342
+
343
+ # file_nonempty <path>
344
+ file_nonempty() { [ -f "$1" ] && [ -s "$1" ]; }
345
+
346
+ # --- Source tracking (knowledge-base provenance) ---
347
+
348
+ # comate_kb_root — repo-relative root under which source files may be tracked.
349
+ comate_kb_root() { echo "docs/knowledge-base"; }
350
+
351
+ # _abs_path <path> — absolute, symlink-resolved path; empty + non-zero if it
352
+ # cannot be resolved. realpath/readlink -f preferred; dir-level fallback otherwise.
353
+ _abs_path() {
354
+ local p="$1"
355
+ if command -v realpath >/dev/null 2>&1; then
356
+ realpath "$p" 2>/dev/null
357
+ elif command -v readlink >/dev/null 2>&1 && readlink -f "$p" >/dev/null 2>&1; then
358
+ readlink -f "$p" 2>/dev/null
359
+ else
360
+ local dir base
361
+ dir=$(dirname "$p"); base=$(basename "$p")
362
+ ( cd "$dir" 2>/dev/null && printf '%s/%s\n' "$(pwd -P)" "$base" )
363
+ fi
364
+ }
365
+
366
+ # validate_kb_path <path> — echo the repo-relative path if it resolves to an
367
+ # existing file under the knowledge-base root, else echo nothing and return 1.
368
+ # Rejects '..' traversal, absolute/repo-external paths, and symlink escapes.
369
+ validate_kb_path() {
370
+ local raw="$1" root_abs target_abs cwd
371
+ [ -n "$raw" ] || return 1
372
+ case "$raw" in *..*) return 1 ;; esac
373
+ [ -f "$raw" ] || return 1
374
+ root_abs=$(_abs_path "$(comate_kb_root)") || return 1
375
+ [ -n "$root_abs" ] || return 1
376
+ target_abs=$(_abs_path "$raw") || return 1
377
+ [ -n "$target_abs" ] || return 1
378
+ case "$target_abs" in
379
+ "$root_abs"/*) ;;
380
+ *) return 1 ;;
381
+ esac
382
+ cwd=$(_abs_path .) || return 1
383
+ printf '%s\n' "${target_abs#"$cwd"/}"
384
+ }
385
+
386
+ # sources_manifest_for <name> — echo path to a change's source manifest.
387
+ sources_manifest_for() {
388
+ echo "$(comate_change_dir "$1")/.comate/sources.manifest"
389
+ }
390
+
391
+ # sources_manifest_paths <manifest> — list recorded `path:` values, one per line.
392
+ sources_manifest_paths() {
393
+ local m="$1"
394
+ [ -f "$m" ] || return 0
395
+ grep '^path:' "$m" 2>/dev/null | sed 's/^path://' || true
396
+ }
397
+
398
+ # sources_drift <manifest> — recompute sha256 for each recorded path and compare
399
+ # against the manifest. Echo human-readable detail; return COMATE_OK if all
400
+ # match, COMATE_BLOCK on drift/missing file, COMATE_CORRUPT on malformed entries.
401
+ sources_drift() {
402
+ local m="$1" path="" recorded="" rc=0 line actual
403
+ [ -f "$m" ] || { echo "manifest not found: $m"; return "$COMATE_CORRUPT"; }
404
+ while IFS= read -r line || [ -n "$line" ]; do
405
+ case "$line" in
406
+ path:*) path="${line#path:}" ;;
407
+ sha256:*)
408
+ recorded="${line#sha256:}"
409
+ if [ -z "$path" ]; then echo "manifest corrupt: sha256 with no preceding path"; return "$COMATE_CORRUPT"; fi
410
+ if [ ! -f "$path" ]; then
411
+ echo "source file missing: $path"; rc=1
412
+ else
413
+ actual=$(hash_file "$path")
414
+ if [ "$actual" != "$recorded" ]; then
415
+ echo "source drifted: $path"
416
+ echo " recorded: $recorded"
417
+ echo " actual: $actual"
418
+ rc=1
419
+ fi
420
+ fi
421
+ ;;
422
+ ---|"") path=""; recorded="" ;;
423
+ esac
424
+ done < "$m"
425
+ [ "$rc" -eq 0 ] && return "$COMATE_OK"
426
+ return "$COMATE_BLOCK"
427
+ }
428
+
429
+ # json_escape <string> — escape backslash and double-quote for JSON values.
430
+ json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }