|
| 1 | +#!/usr/bin/env julia |
| 2 | +# SPDX-License-Identifier: AGPL-3.0-or-later |
| 3 | +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 4 | +# |
| 5 | +# state-scm-to-v2.jl — convert a v1 STATE.scm (s-expr machine state) to the |
| 6 | +# canonical STATE.a2ml v2 "thin session journal" directive format. |
| 7 | +# |
| 8 | +# Spec: standards/a2ml-templates/STATE.a2ml.v2.spec.adoc |
| 9 | +# v2 deliberately discards everything derivable; keeps only: |
| 10 | +# phase, next_action, last_action, updated, and @blockers entries. |
| 11 | +# |
| 12 | +# Usage: |
| 13 | +# state-scm-to-v2.jl <STATE.scm> # print v2 to stdout (dry-run) |
| 14 | +# state-scm-to-v2.jl <STATE.scm> --write # write .machine_readable/STATE.a2ml |
| 15 | +# |
| 16 | +# Exit codes: 0 ok, 2 parse/usage error, 3 not a (state ...) form. |
| 17 | +# |
| 18 | +# Ported from the original Python implementation (Python is banned estate-wide); |
| 19 | +# behaviour and output are identical. A node is either a Vector (a list) or a |
| 20 | +# leaf tuple (:str, value) / (:atom, value). |
| 21 | + |
| 22 | +using Dates |
| 23 | + |
| 24 | +# --- s-expression reader ---------------------------------------------------- |
| 25 | + |
| 26 | +function tokenize(s::AbstractString) |
| 27 | + # Strip ;; line comments, then tokenize parens and "strings" and atoms. |
| 28 | + s = replace(s, r";;[^\n]*" => "") |
| 29 | + toks = Any[] |
| 30 | + cs = collect(s) |
| 31 | + i, n = 1, length(cs) |
| 32 | + while i <= n |
| 33 | + c = cs[i] |
| 34 | + if c == '(' || c == ')' |
| 35 | + push!(toks, c == '(' ? :lparen : :rparen); i += 1 |
| 36 | + elseif c == '"' |
| 37 | + j = i + 1; buf = IOBuffer() |
| 38 | + while j <= n && cs[j] != '"' |
| 39 | + if cs[j] == '\\' && j + 1 <= n |
| 40 | + print(buf, cs[j+1]); j += 2 |
| 41 | + else |
| 42 | + print(buf, cs[j]); j += 1 |
| 43 | + end |
| 44 | + end |
| 45 | + push!(toks, (:str, String(take!(buf)))); i = j + 1 |
| 46 | + elseif isspace(c) |
| 47 | + i += 1 |
| 48 | + else |
| 49 | + j = i |
| 50 | + while j <= n && !isspace(cs[j]) && cs[j] != '(' && cs[j] != ')' && cs[j] != '"' |
| 51 | + j += 1 |
| 52 | + end |
| 53 | + push!(toks, (:atom, String(cs[i:j-1]))); i = j |
| 54 | + end |
| 55 | + end |
| 56 | + return toks |
| 57 | +end |
| 58 | + |
| 59 | +function parse_forms(toks) |
| 60 | + # Returns nested Vectors; strings -> (:str, v), atoms -> (:atom, v). |
| 61 | + pos = Ref(1) |
| 62 | + function rd() |
| 63 | + t = toks[pos[]]; pos[] += 1 |
| 64 | + if t === :lparen |
| 65 | + lst = Any[] |
| 66 | + while toks[pos[]] !== :rparen |
| 67 | + push!(lst, rd()) |
| 68 | + end |
| 69 | + pos[] += 1 |
| 70 | + return lst |
| 71 | + elseif t === :rparen |
| 72 | + error("unexpected )") |
| 73 | + end |
| 74 | + return t # (:str, ..) or (:atom, ..) |
| 75 | + end |
| 76 | + forms = Any[] |
| 77 | + while pos[] <= length(toks) |
| 78 | + push!(forms, rd()) |
| 79 | + end |
| 80 | + return forms |
| 81 | +end |
| 82 | + |
| 83 | +# --- query helpers (mirror the Python truthiness: "" counts as absent) ------ |
| 84 | + |
| 85 | +isblank(x) = x === nothing || x == "" |
| 86 | + |
| 87 | +head(node) = |
| 88 | + (node isa Vector && !isempty(node) && node[1] isa Tuple) ? node[1][2] : nothing |
| 89 | + |
| 90 | +function findform(node, name) |
| 91 | + # First child list whose head atom == name. |
| 92 | + node isa Vector || return nothing |
| 93 | + for ch in node |
| 94 | + if ch isa Vector && !isempty(ch) && ch[1] isa Tuple && ch[1][2] == name |
| 95 | + return ch |
| 96 | + end |
| 97 | + end |
| 98 | + return nothing |
| 99 | +end |
| 100 | + |
| 101 | +first_string(::Nothing) = nothing |
| 102 | +function first_string(node) |
| 103 | + # First (non-empty) string literal anywhere under node, depth-first. |
| 104 | + if node isa Tuple |
| 105 | + return node[1] === :str ? node[2] : nothing |
| 106 | + elseif node isa Vector |
| 107 | + for ch in node |
| 108 | + r = first_string(ch) |
| 109 | + if !isblank(r) |
| 110 | + return r |
| 111 | + end |
| 112 | + end |
| 113 | + end |
| 114 | + return nothing |
| 115 | +end |
| 116 | + |
| 117 | +function all_strings(node) |
| 118 | + out = String[] |
| 119 | + if node isa Tuple |
| 120 | + node[1] === :str && push!(out, node[2]) |
| 121 | + elseif node isa Vector |
| 122 | + for ch in node |
| 123 | + append!(out, all_strings(ch)) |
| 124 | + end |
| 125 | + end |
| 126 | + return out |
| 127 | +end |
| 128 | + |
| 129 | +# Escape backslash then double-quote, in that order (mirrors the original). |
| 130 | +q(s) = "\"" * replace(replace(string(s), "\\" => "\\\\"), "\"" => "\\\"") * "\"" |
| 131 | + |
| 132 | +function slug(s) |
| 133 | + t = replace(lowercase(string(s)), r"[^a-z0-9]+" => "-") |
| 134 | + t = strip(t, '-') |
| 135 | + t = first(t, 40) |
| 136 | + return isempty(t) ? "blocker" : String(t) |
| 137 | +end |
| 138 | + |
| 139 | +# pick the first truthy (non-blank) candidate, falling back to `dflt` |
| 140 | +firsttruthy(dflt, cands...) = (for c in cands; isblank(c) || return c; end; dflt) |
| 141 | + |
| 142 | +# --- conversion ------------------------------------------------------------- |
| 143 | + |
| 144 | +function convert(path) |
| 145 | + src = read(path, String) |
| 146 | + forms = parse_forms(tokenize(src)) |
| 147 | + state = nothing |
| 148 | + for f in forms |
| 149 | + if head(f) == "state" |
| 150 | + state = f; break |
| 151 | + end |
| 152 | + end |
| 153 | + if state === nothing |
| 154 | + print(stderr, "ERR: no (state ...) form in $path\n") |
| 155 | + exit(3) |
| 156 | + end |
| 157 | + |
| 158 | + # phase <- (current-position (phase "..")) | (current-phase "..") |
| 159 | + phase = nothing |
| 160 | + cp = findform(state, "current-position") |
| 161 | + if cp !== nothing |
| 162 | + ph = findform(cp, "phase") |
| 163 | + ph !== nothing && (phase = first_string(ph)) |
| 164 | + end |
| 165 | + if isblank(phase) |
| 166 | + cph = findform(state, "current-phase") |
| 167 | + cph !== nothing && (phase = first_string(cph)) |
| 168 | + end |
| 169 | + isblank(phase) && (phase = "unknown") |
| 170 | + |
| 171 | + # next_action <- critical-next-actions, first string (immediate first) |
| 172 | + next_action = "TODO — review and set" |
| 173 | + cna = findform(state, "critical-next-actions") |
| 174 | + if cna !== nothing |
| 175 | + imm = findform(cna, "immediate") |
| 176 | + next_action = firsttruthy(next_action, first_string(imm), first_string(cna)) |
| 177 | + end |
| 178 | + |
| 179 | + # last_action <- last session-history entry's first accomplishment string |
| 180 | + last_action = "migrated from v1 STATE.scm" |
| 181 | + sh = findform(state, "session-history") |
| 182 | + if sh !== nothing |
| 183 | + sessions = [c for c in sh if c isa Vector && head(c) == "session"] |
| 184 | + if !isempty(sessions) |
| 185 | + acc = findform(sessions[end], "accomplishments") |
| 186 | + last_action = firsttruthy(last_action, first_string(acc), first_string(sessions[end])) |
| 187 | + end |
| 188 | + end |
| 189 | + |
| 190 | + # updated <- (metadata (updated|last-updated "..")) else today |
| 191 | + updated = nothing |
| 192 | + md = findform(state, "metadata") |
| 193 | + if md !== nothing |
| 194 | + for key in ("updated", "last-updated") |
| 195 | + u = findform(md, key) |
| 196 | + if u !== nothing |
| 197 | + updated = first_string(u); break |
| 198 | + end |
| 199 | + end |
| 200 | + end |
| 201 | + isblank(updated) && (updated = string(Dates.today())) |
| 202 | + |
| 203 | + # blockers <- (blockers-and-issues (critical ..)(high ..)(medium ..)(low ..)) |
| 204 | + blockers = Tuple{String,String}[] |
| 205 | + bi = findform(state, "blockers-and-issues") |
| 206 | + if bi !== nothing |
| 207 | + for sev in ("critical", "high", "medium", "low") |
| 208 | + grp = findform(bi, sev) |
| 209 | + if grp !== nothing |
| 210 | + for desc in all_strings(grp) |
| 211 | + push!(blockers, (sev, desc)) |
| 212 | + end |
| 213 | + end |
| 214 | + end |
| 215 | + end |
| 216 | + |
| 217 | + lines = String[] |
| 218 | + push!(lines, "# SPDX-License-Identifier: AGPL-3.0-or-later") |
| 219 | + push!(lines, "# Migrated from $(basename(path)) by state-scm-to-v2.jl on " * |
| 220 | + "$(Dates.today())") |
| 221 | + push!(lines, "") |
| 222 | + push!(lines, "@state(version=\"2.0\"):") |
| 223 | + push!(lines, "phase: $(q(phase))") |
| 224 | + push!(lines, "next_action: $(q(next_action))") |
| 225 | + push!(lines, "last_action: $(q(last_action))") |
| 226 | + push!(lines, "updated: $(updated)") |
| 227 | + if !isempty(blockers) |
| 228 | + push!(lines, "") |
| 229 | + push!(lines, "@blockers:") |
| 230 | + for (sev, desc) in blockers |
| 231 | + push!(lines, "- id: $(slug(desc))") |
| 232 | + push!(lines, " description: $(q(desc))") |
| 233 | + push!(lines, " waiting_on: $(q(sev * "-priority — internal"))") |
| 234 | + push!(lines, " since: $(updated)") |
| 235 | + end |
| 236 | + push!(lines, "@end") |
| 237 | + end |
| 238 | + push!(lines, "") |
| 239 | + push!(lines, "@end") |
| 240 | + return join(lines, "\n") * "\n" |
| 241 | +end |
| 242 | + |
| 243 | +function main() |
| 244 | + write_mode = "--write" in ARGS |
| 245 | + args = [a for a in ARGS if a != "--write"] |
| 246 | + if length(args) != 1 |
| 247 | + print(stderr, "usage: state-scm-to-v2.jl <STATE.scm> [--write]\n") |
| 248 | + exit(2) |
| 249 | + end |
| 250 | + scm = args[1] |
| 251 | + out = convert(scm) |
| 252 | + if write_mode |
| 253 | + dest = joinpath(dirname(scm), "STATE.a2ml") |
| 254 | + open(io -> Base.write(io, out), dest, "w") |
| 255 | + print(stderr, "wrote $dest\n") |
| 256 | + else |
| 257 | + Base.write(stdout, out) |
| 258 | + end |
| 259 | +end |
| 260 | + |
| 261 | +main() |
0 commit comments