Compare commits
64 Commits
loops/fed-
...
loops/acl
| Author | SHA1 | Date | |
|---|---|---|---|
| 9437f99e28 | |||
| 40be9cd074 | |||
| 15c97119e4 | |||
| 9261d69cc5 | |||
| fe47334e52 | |||
| c3a0727645 | |||
| 1b94082a71 | |||
| 57184daaee | |||
| d9e2627b89 | |||
| bcabed6bce | |||
| 5098a8f015 | |||
| 9fe5c9044d | |||
| c6f397c3d9 | |||
| f553d5b0aa | |||
| 14486dd78f | |||
| 9036ce3400 | |||
| 8c91b34264 | |||
| a7902df365 | |||
| 459427512d | |||
| c50f5d5155 | |||
| f52ad1fac6 | |||
| 219e2fcfe7 | |||
| 1d3021d206 | |||
| fa99652970 | |||
| 4807bc9c58 | |||
| b693854dc4 | |||
| 674d8115b8 | |||
| 99f8f37ff8 | |||
| 9ed58bd0fc | |||
| ab04ec1cf7 | |||
| a019aa1edc | |||
| 1340c2626b | |||
| ff9abe3ae6 | |||
| 21bb17e4a6 | |||
| 4bd9262060 | |||
| 5b4a8be689 | |||
| 9f4c6787e4 | |||
| 5e27a7f0c9 | |||
| 86ddaf255c | |||
| 6c3b7d1cf9 | |||
| 2404a593bd | |||
| 44fb231391 | |||
| 171a08a2f8 | |||
| ba41f8a580 | |||
| 5f6d62f45b | |||
| ad21776002 | |||
| 4922b6e987 | |||
| 632e06d3cf | |||
| 48379e04bc | |||
| a94ffa0feb | |||
| 9acdbcb8d8 | |||
| 8ba66e0dc9 | |||
| 503bdf12d6 | |||
| e64d72f554 | |||
| e1c5fdae53 | |||
| 728a91e49f | |||
| 750035d543 | |||
| 976c6dd0ef | |||
| c1baca2e4e | |||
| 65467c232b | |||
| e60c74f8c3 | |||
| fe614fc531 | |||
| 4fc73a97f4 | |||
| 0f7444e0d5 |
@@ -1 +1 @@
|
||||
{"sessionId":"31c80255-eb92-43e4-8997-84ad84e27326","pid":90960,"procStart":"564684","acquiredAt":1777049890282}
|
||||
{"sessionId":"bf20a443-9df8-4cb9-932e-8c6f4c4625c2","pid":1303602,"procStart":"253831081","acquiredAt":1779865895644}
|
||||
@@ -2,7 +2,7 @@
|
||||
"mcpServers": {
|
||||
"sx-tree": {
|
||||
"type": "stdio",
|
||||
"command": "./hosts/ocaml/_build/default/bin/mcp_tree.exe"
|
||||
"command": "/root/rose-ash/hosts/ocaml/_build/default/bin/mcp_tree.exe"
|
||||
},
|
||||
"rose-ash-services": {
|
||||
"type": "stdio",
|
||||
|
||||
45
lib/acl/api.sx
Normal file
45
lib/acl/api.sx
Normal file
@@ -0,0 +1,45 @@
|
||||
;; lib/acl/api.sx — public ACL surface over an implicit current db.
|
||||
;;
|
||||
;; Callers load a fact set once, then issue decisions without threading the db
|
||||
;; through every call. The current db is module state; (acl/load! facts) rebuilds
|
||||
;; it. This is the boundary the rest of rose-ash imports.
|
||||
|
||||
(define acl-current-db nil)
|
||||
|
||||
;; Replace the current fact base. Rebuilds the Datalog db under the active
|
||||
;; ruleset (see lib/acl/engine.sx).
|
||||
(define
|
||||
acl/load!
|
||||
(fn
|
||||
(facts)
|
||||
(do (set! acl-current-db (acl-build-db facts)) acl-current-db)))
|
||||
|
||||
;; Ensure a db exists, building an empty one on first use.
|
||||
(define
|
||||
acl-ensure-db!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(when
|
||||
(= acl-current-db nil)
|
||||
(set! acl-current-db (acl-build-db (list))))
|
||||
acl-current-db)))
|
||||
|
||||
;; Public decision against the current db (pure, no logging).
|
||||
(define
|
||||
acl/permit?
|
||||
(fn (subj act res) (acl-permit? (acl-ensure-db!) subj act res)))
|
||||
|
||||
;; Decision-with-proof against the current db. See lib/acl/explain.sx.
|
||||
(define
|
||||
acl/explain
|
||||
(fn (subj act res) (acl-explain (acl-ensure-db!) subj act res)))
|
||||
|
||||
;; Audited decision: logs the outcome to the append-only audit log and returns
|
||||
;; the boolean. See lib/acl/audit.sx.
|
||||
(define
|
||||
acl/audit
|
||||
(fn (subj act res) (acl-audit-decide! (acl-ensure-db!) subj act res)))
|
||||
|
||||
;; Recent audited decisions (chronological).
|
||||
(define acl/audit-tail (fn (n) (acl-audit-tail n)))
|
||||
110
lib/acl/audit.sx
Normal file
110
lib/acl/audit.sx
Normal file
@@ -0,0 +1,110 @@
|
||||
;; lib/acl/audit.sx — append-only decision log.
|
||||
;;
|
||||
;; Every decision routed through acl-audit-decide! is appended to an in-memory
|
||||
;; log with a monotonic sequence number (no wall-clock — deterministic and
|
||||
;; testable; a host can stamp time at the serializer boundary). The log is
|
||||
;; append-only: there is no mutate or delete, only append, tail, clear,
|
||||
;; snapshot/restore, and serialize-for-disk.
|
||||
|
||||
(define acl-audit-log (list))
|
||||
(define acl-audit-seq 0)
|
||||
|
||||
;; Copy a list into a fresh, append!-able list. `map`/`rest`-derived lists are
|
||||
;; NOT extensible by append! in this runtime (it silently no-ops), so the live
|
||||
;; log must always be a list built with `list` + `append!`.
|
||||
(define
|
||||
acl-audit-copy
|
||||
(fn
|
||||
(xs)
|
||||
(let
|
||||
((fresh (list)))
|
||||
(do (for-each (fn (e) (append! fresh e)) xs) fresh))))
|
||||
|
||||
(define
|
||||
acl-audit-clear!
|
||||
(fn
|
||||
()
|
||||
(do (set! acl-audit-log (list)) (set! acl-audit-seq 0) nil)))
|
||||
|
||||
;; Append a decision record. Returns the record.
|
||||
(define
|
||||
acl-audit-record!
|
||||
(fn
|
||||
(subj act res allowed?)
|
||||
(let
|
||||
((entry {:allowed? allowed? :act act :subj subj :res res :seq acl-audit-seq}))
|
||||
(do
|
||||
(set! acl-audit-seq (+ acl-audit-seq 1))
|
||||
(append! acl-audit-log entry)
|
||||
entry))))
|
||||
|
||||
;; Decide against db, log the outcome, and return the boolean. This is the
|
||||
;; audited path; acl-permit? remains the pure, side-effect-free decision.
|
||||
(define
|
||||
acl-audit-decide!
|
||||
(fn
|
||||
(db subj act res)
|
||||
(let
|
||||
((allowed? (acl-permit? db subj act res)))
|
||||
(do (acl-audit-record! subj act res allowed?) allowed?))))
|
||||
|
||||
(define acl-audit-count (fn () (len acl-audit-log)))
|
||||
|
||||
;; Most recent n entries (in chronological order). n >= log size returns all.
|
||||
(define
|
||||
acl-audit-tail
|
||||
(fn
|
||||
(n)
|
||||
(let
|
||||
((total (len acl-audit-log)))
|
||||
(if
|
||||
(<= total n)
|
||||
acl-audit-log
|
||||
(acl-audit-drop acl-audit-log (- total n))))))
|
||||
|
||||
(define
|
||||
acl-audit-drop
|
||||
(fn
|
||||
(xs k)
|
||||
(if (<= k 0) xs (acl-audit-drop (rest xs) (- k 1)))))
|
||||
|
||||
;; Structured snapshot for save/restore — a {:seq :entries} value carrying a
|
||||
;; copy of the log (so later appends don't mutate a held snapshot).
|
||||
(define acl-audit-snapshot (fn () {:seq acl-audit-seq :entries (acl-audit-copy acl-audit-log)}))
|
||||
|
||||
;; Replace the live log from a snapshot. Restores both entries and the seq
|
||||
;; counter so subsequent records continue numbering correctly. The log is
|
||||
;; rebuilt as a fresh append!-able list (see acl-audit-copy).
|
||||
(define
|
||||
acl-audit-restore!
|
||||
(fn
|
||||
(snap)
|
||||
(do
|
||||
(set! acl-audit-log (acl-audit-copy (get snap :entries)))
|
||||
(set! acl-audit-seq (get snap :seq))
|
||||
nil)))
|
||||
|
||||
;; Serialize the whole log to a disk-ready string: one record per line,
|
||||
;; "seq\tsubj\tact\tres\tallowed?". A host writes this; structured reload is via
|
||||
;; snapshot/restore.
|
||||
(define
|
||||
acl-audit-serialize
|
||||
(fn
|
||||
()
|
||||
(reduce
|
||||
(fn
|
||||
(acc e)
|
||||
(str
|
||||
acc
|
||||
(get e :seq)
|
||||
"\t"
|
||||
(get e :subj)
|
||||
"\t"
|
||||
(get e :act)
|
||||
"\t"
|
||||
(get e :res)
|
||||
"\t"
|
||||
(get e :allowed?)
|
||||
"\n"))
|
||||
""
|
||||
acl-audit-log)))
|
||||
32
lib/acl/conformance.conf
Normal file
32
lib/acl/conformance.conf
Normal file
@@ -0,0 +1,32 @@
|
||||
# ACL conformance config — sourced by lib/guest/conformance.sh.
|
||||
|
||||
LANG_NAME=acl
|
||||
MODE=dict
|
||||
|
||||
PRELOADS=(
|
||||
lib/datalog/tokenizer.sx
|
||||
lib/datalog/parser.sx
|
||||
lib/datalog/unify.sx
|
||||
lib/datalog/db.sx
|
||||
lib/datalog/builtins.sx
|
||||
lib/datalog/aggregates.sx
|
||||
lib/datalog/strata.sx
|
||||
lib/datalog/eval.sx
|
||||
lib/datalog/api.sx
|
||||
lib/datalog/magic.sx
|
||||
lib/acl/schema.sx
|
||||
lib/acl/facts.sx
|
||||
lib/acl/engine.sx
|
||||
lib/acl/explain.sx
|
||||
lib/acl/audit.sx
|
||||
lib/acl/federation.sx
|
||||
lib/acl/api.sx
|
||||
)
|
||||
|
||||
SUITES=(
|
||||
"direct:lib/acl/tests/direct.sx:(acl-direct-tests-run!)"
|
||||
"inherit:lib/acl/tests/inherit.sx:(acl-inherit-tests-run!)"
|
||||
"explain:lib/acl/tests/explain.sx:(acl-explain-tests-run!)"
|
||||
"fed:lib/acl/tests/fed.sx:(acl-fed-tests-run!)"
|
||||
"harden:lib/acl/tests/harden.sx:(acl-harden-tests-run!)"
|
||||
)
|
||||
3
lib/acl/conformance.sh
Executable file
3
lib/acl/conformance.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Thin wrapper — see lib/guest/conformance.sh and lib/acl/conformance.conf.
|
||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||
72
lib/acl/engine.sx
Normal file
72
lib/acl/engine.sx
Normal file
@@ -0,0 +1,72 @@
|
||||
;; lib/acl/engine.sx — ACL ruleset + decision reducer over lib/datalog/.
|
||||
;;
|
||||
;; The engine is a thin layer: it owns the permit ruleset (SX data rules) and
|
||||
;; reduces a (subject, action, resource) decision to a Datalog query against a
|
||||
;; db built from EDB facts. The rule engine itself is Datalog's.
|
||||
;;
|
||||
;; Policy — inheritance + federation with deny-overrides:
|
||||
;;
|
||||
;; eff_grant(S,A,R) :- grant(S,A,R). ; direct
|
||||
;; eff_grant(S,A,R) :- member_of(S,G), eff_grant(G,A,R). ; group/role chain
|
||||
;; eff_grant(S,A,R) :- child_of(R,P), eff_grant(S,A,P). ; resource tree
|
||||
;; eff_grant(S,A,R) :- member_of(S,Role), role_grant(Role,A,R). ; role expansion
|
||||
;; eff_grant(S,A,R) :- delegate(Peer,S,A,R), ; federated grant
|
||||
;; trust(Peer,L), level_covers(L,A).
|
||||
;;
|
||||
;; eff_deny(S,A,R) :- deny(S,A,R). ; direct
|
||||
;; eff_deny(S,A,R) :- member_of(S,G), eff_deny(G,A,R). ; group chain
|
||||
;; eff_deny(S,A,R) :- child_of(R,P), eff_deny(S,A,P). ; resource tree
|
||||
;;
|
||||
;; permit(S,A,R) :- eff_grant(S,A,R), not eff_deny(S,A,R).
|
||||
;;
|
||||
;; DENY-OVERRIDES: an effective deny anywhere in the inheritance closure of
|
||||
;; (S,A,R) defeats any effective grant — including federated grants. Deny
|
||||
;; inherits through the *same* group and resource chains as grant, so a
|
||||
;; group-level or ancestor-resource deny is authoritative for members/
|
||||
;; descendants. This is the principled, fail-safe reading of "deny wins".
|
||||
;;
|
||||
;; FEDERATION — non-transitive trust: a peer's `delegate` fact only grants if a
|
||||
;; *local* `trust(Peer, L)` exists AND that level `level_covers` the action.
|
||||
;; Trust is re-checked on every query (it is a body literal), never baked in at
|
||||
;; fact-ingestion time, so revoking trust or narrowing a level takes effect
|
||||
;; immediately on the next decision.
|
||||
;;
|
||||
;; Termination & stratification:
|
||||
;; - eff_grant/eff_deny recurse only over member_of and child_of, which are
|
||||
;; EDB relations with no function symbols, so the closure is finite (cyclic
|
||||
;; membership/containment just reaches a fixpoint, never loops). The
|
||||
;; federation rule is non-recursive.
|
||||
;; - permit negates eff_deny; neither eff_grant nor eff_deny depends on
|
||||
;; permit, so the program is stratifiable (permit sits in a higher stratum).
|
||||
|
||||
(define
|
||||
acl-rules
|
||||
(quote
|
||||
((eff_grant S A R <- (grant S A R))
|
||||
(eff_grant S A R <- (member_of S G) (eff_grant G A R))
|
||||
(eff_grant S A R <- (child_of R P) (eff_grant S A P))
|
||||
(eff_grant S A R <- (member_of S Role) (role_grant Role A R))
|
||||
(eff_grant
|
||||
S
|
||||
A
|
||||
R
|
||||
<-
|
||||
(delegate Peer S A R)
|
||||
(trust Peer L)
|
||||
(level_covers L A))
|
||||
(eff_deny S A R <- (deny S A R))
|
||||
(eff_deny S A R <- (member_of S G) (eff_deny G A R))
|
||||
(eff_deny S A R <- (child_of R P) (eff_deny S A P))
|
||||
(permit S A R <- (eff_grant S A R) {:neg (eff_deny S A R)}))))
|
||||
|
||||
;; Build a Datalog db from a list of EDB facts under the ACL ruleset.
|
||||
(define acl-build-db (fn (facts) (dl-program-data facts acl-rules)))
|
||||
|
||||
;; Core decision: does the db permit subject S to perform action A on
|
||||
;; resource R? Reduces to a ground Datalog query on the derived `permit`
|
||||
;; relation — non-empty result means permitted.
|
||||
(define
|
||||
acl-permit?
|
||||
(fn
|
||||
(db subj act res)
|
||||
(> (len (dl-query db (list (quote permit) subj act res))) 0)))
|
||||
125
lib/acl/explain.sx
Normal file
125
lib/acl/explain.sx
Normal file
@@ -0,0 +1,125 @@
|
||||
;; lib/acl/explain.sx — proof-tree reconstruction over the saturated db.
|
||||
;;
|
||||
;; lib/datalog/ records derived facts but not their provenance, so the proof is
|
||||
;; reconstructed here by goal-directed search over the *saturated* db: for a
|
||||
;; ground goal we find the first ACL rule (in rule order) whose body holds, take
|
||||
;; the first solution binding its remaining variables, and recurse on each body
|
||||
;; literal. Negated literals are recorded as verified `:neg-ok` leaves.
|
||||
;;
|
||||
;; CANONICAL DERIVATION: the Datalog derivation graph is a DAG (a fact may hold
|
||||
;; many ways). We pick ONE canonical proof — first matching rule, first solution
|
||||
;; — matching the rule order in lib/acl/engine.sx (direct/EDB rules first). A
|
||||
;; depth cap guards against pathological cyclic data producing unbounded search.
|
||||
;;
|
||||
;; A proof node is one of:
|
||||
;; {:fact <lit> :via "edb"} — base EDB fact
|
||||
;; {:fact <lit> :rule <head> :body (<node|negleaf> ...)} — derived
|
||||
;; {:neg-ok <lit>} — negation verified to fail
|
||||
;; {:fact <lit> :truncated true} — depth cap hit
|
||||
|
||||
(define acl-proof-max-depth 64)
|
||||
|
||||
;; Substitute a body literal, descending into {:neg ...} dicts (dl-apply-subst
|
||||
;; does not recurse into dicts, which would leak the neg's free vars).
|
||||
(define
|
||||
acl-subst-lit
|
||||
(fn
|
||||
(lit s)
|
||||
(if
|
||||
(and (dict? lit) (has-key? lit :neg))
|
||||
{:neg (dl-apply-subst (get lit :neg) s)}
|
||||
(dl-apply-subst lit s))))
|
||||
|
||||
(define
|
||||
acl-lit-edb?
|
||||
(fn
|
||||
(lit)
|
||||
(and
|
||||
(list? lit)
|
||||
(> (len lit) 0)
|
||||
(symbol? (first lit))
|
||||
(has-key? acl-edb-arity (symbol->string (first lit))))))
|
||||
|
||||
(define
|
||||
acl-subst-zip!
|
||||
(fn
|
||||
(d ks vs)
|
||||
(when
|
||||
(> (len ks) 0)
|
||||
(do
|
||||
(dict-set! d (symbol->string (first ks)) (first vs))
|
||||
(acl-subst-zip! d (rest ks) (rest vs))))))
|
||||
|
||||
;; Bind a rule head's variables to a ground goal's arguments (positional).
|
||||
(define
|
||||
acl-bind-head
|
||||
(fn
|
||||
(head goal)
|
||||
(let
|
||||
((d {}))
|
||||
(do (acl-subst-zip! d (rest head) (rest goal)) d))))
|
||||
|
||||
(define
|
||||
acl-subst-union
|
||||
(fn
|
||||
(a b)
|
||||
(let
|
||||
((d {}))
|
||||
(do
|
||||
(for-each (fn (k) (dict-set! d k (get a k))) (keys a))
|
||||
(for-each (fn (k) (dict-set! d k (get b k))) (keys b))
|
||||
d))))
|
||||
|
||||
(define acl-prove (fn (db goal) (acl-prove-d db goal 0)))
|
||||
|
||||
(define
|
||||
acl-prove-d
|
||||
(fn
|
||||
(db goal depth)
|
||||
(cond
|
||||
((> depth acl-proof-max-depth) {:truncated true :fact goal})
|
||||
((acl-lit-edb? goal)
|
||||
(if (> (len (dl-query db goal)) 0) {:via "edb" :fact goal} nil))
|
||||
(else (acl-prove-rules db goal acl-rules depth)))))
|
||||
|
||||
(define
|
||||
acl-prove-rules
|
||||
(fn
|
||||
(db goal rules depth)
|
||||
(if
|
||||
(= (len rules) 0)
|
||||
nil
|
||||
(let
|
||||
((p (dl-rule-from-list (first rules))))
|
||||
(if
|
||||
(= (first (get p :head)) (first goal))
|
||||
(let
|
||||
((hs (acl-bind-head (get p :head) goal)))
|
||||
(let
|
||||
((qbody (map (fn (l) (acl-subst-lit l hs)) (get p :body))))
|
||||
(let
|
||||
((sols (dl-query db qbody)))
|
||||
(if
|
||||
(> (len sols) 0)
|
||||
(acl-prove-build db goal p hs (first sols) depth)
|
||||
(acl-prove-rules db goal (rest rules) depth)))))
|
||||
(acl-prove-rules db goal (rest rules) depth))))))
|
||||
|
||||
(define
|
||||
acl-prove-build
|
||||
(fn
|
||||
(db goal p hs sol depth)
|
||||
(let ((full (acl-subst-union hs sol))) {:body (map (fn (l) (let ((g (acl-subst-lit l full))) (if (and (dict? g) (has-key? g :neg)) {:neg-ok (get g :neg)} (acl-prove-d db g (+ depth 1))))) (get p :body)) :rule (get p :head) :fact goal})))
|
||||
|
||||
;; Public decision-with-proof. Returns:
|
||||
;; {:allowed? <bool> :proof <node|nil> :reason <eff_deny proof|nil>}
|
||||
;; When permitted, :proof is the permit derivation. When denied, :proof is nil
|
||||
;; and :reason carries the blocking eff_deny proof if one exists (an explicit or
|
||||
;; inherited deny), else nil (simply no grant).
|
||||
(define
|
||||
acl-explain
|
||||
(fn
|
||||
(db subj act res)
|
||||
(let
|
||||
((proof (acl-prove db (list (quote permit) subj act res))))
|
||||
(if (= proof nil) {:allowed? false :proof nil :reason (acl-prove db (list (quote eff_deny) subj act res))} {:allowed? true :proof proof :reason nil}))))
|
||||
47
lib/acl/facts.sx
Normal file
47
lib/acl/facts.sx
Normal file
@@ -0,0 +1,47 @@
|
||||
;; lib/acl/facts.sx — EDB fact constructors.
|
||||
;;
|
||||
;; Each constructor returns a Datalog fact tuple (a list whose head is the
|
||||
;; predicate symbol). These are the only shapes lib/acl/engine.sx feeds to
|
||||
;; lib/datalog/.
|
||||
;; Phase 1: actor/resource/grant/deny.
|
||||
;; Phase 2: member_of (subject -> group/role), child_of (resource -> parent),
|
||||
;; role_grant (role -> action,resource capability).
|
||||
;; Phase 4: peer/trust/delegate/level_covers (federation).
|
||||
|
||||
(define acl-actor (fn (id kind) (list (quote actor) id kind)))
|
||||
|
||||
(define acl-resource-fact (fn (id kind) (list (quote resource) id kind)))
|
||||
|
||||
(define acl-grant (fn (subj act res) (list (quote grant) subj act res)))
|
||||
|
||||
(define acl-deny (fn (subj act res) (list (quote deny) subj act res)))
|
||||
|
||||
;; subject S is a member of group/role G (one hop; transitivity is derived).
|
||||
(define acl-member-of (fn (subj grp) (list (quote member_of) subj grp)))
|
||||
|
||||
;; resource R is a child of parent P (one hop; transitivity is derived).
|
||||
(define acl-child-of (fn (res parent) (list (quote child_of) res parent)))
|
||||
|
||||
;; role confers capability (act on res) to every member of the role.
|
||||
(define
|
||||
acl-role-grant
|
||||
(fn (role act res) (list (quote role_grant) role act res)))
|
||||
|
||||
;; --- federation ---
|
||||
|
||||
;; a known peer instance at addr, of some kind (e.g. peer).
|
||||
(define acl-peer (fn (addr kind) (list (quote peer) addr kind)))
|
||||
|
||||
;; local trust in a peer at a named level. Gates delegated grants at query time.
|
||||
(define acl-trust (fn (peer level) (list (quote trust) peer level)))
|
||||
|
||||
;; a peer asserts that subject S may A on R. Only takes effect if local trust in
|
||||
;; that peer covers action A (see level_covers).
|
||||
(define
|
||||
acl-delegate
|
||||
(fn (peer subj act res) (list (quote delegate) peer subj act res)))
|
||||
|
||||
;; local policy: trust `level` authorises delegated grants for action `act`.
|
||||
(define
|
||||
acl-level-covers
|
||||
(fn (level act) (list (quote level_covers) level act)))
|
||||
61
lib/acl/federation.sx
Normal file
61
lib/acl/federation.sx
Normal file
@@ -0,0 +1,61 @@
|
||||
;; lib/acl/federation.sx — cross-instance ACL facts + revocation.
|
||||
;;
|
||||
;; fed-sx replicates ACL facts between instances; this module models the local
|
||||
;; side. A peer's authority arrives as `delegate(Peer, S, A, R)` facts, which
|
||||
;; only take effect when a local `trust(Peer, L)` and `level_covers(L, A)`
|
||||
;; authorise them (enforced by the engine rule, re-checked every query). The
|
||||
;; actual network transport is fed-sx's job and is mocked in tests as a dict.
|
||||
;;
|
||||
;; Trust is NOT transitive: trusting peer α does not extend to peers α trusts.
|
||||
;; Only delegate facts that α itself asserts, and that local trust covers, flow.
|
||||
|
||||
;; Mock fed-sx pull: `transport` is a dict mapping a peer address (its string
|
||||
;; name) to the list of delegate facts that peer asserts. Returns the facts for
|
||||
;; `addr`, or an empty list if the peer is unknown / unreachable.
|
||||
(define
|
||||
acl-fed-fetch
|
||||
(fn
|
||||
(transport addr)
|
||||
(let
|
||||
((k (if (symbol? addr) (symbol->string addr) addr)))
|
||||
(if (has-key? transport k) (get transport k) (list)))))
|
||||
|
||||
;; Gather delegate facts from every peer in `addrs` via the transport.
|
||||
(define
|
||||
acl-fed-collect
|
||||
(fn
|
||||
(transport addrs)
|
||||
(let
|
||||
((acc (list)))
|
||||
(do
|
||||
(for-each
|
||||
(fn
|
||||
(addr)
|
||||
(for-each
|
||||
(fn (f) (append! acc f))
|
||||
(acl-fed-fetch transport addr)))
|
||||
addrs)
|
||||
acc))))
|
||||
|
||||
;; Build a db from local facts plus delegate facts pulled from `peers`. Local
|
||||
;; facts must include the `trust`/`level_covers` policy; replicated delegate
|
||||
;; facts are gated against it by the engine rule at query time.
|
||||
(define
|
||||
acl-fed-build-db
|
||||
(fn
|
||||
(local-facts transport peers)
|
||||
(let
|
||||
((all (list)))
|
||||
(do
|
||||
(for-each (fn (f) (append! all f)) local-facts)
|
||||
(for-each
|
||||
(fn (f) (append! all f))
|
||||
(acl-fed-collect transport peers))
|
||||
(acl-build-db all)))))
|
||||
|
||||
;; Propagated revocation: retract a replicated fact (e.g. a peer's delegate, or
|
||||
;; local trust) from a live db. The next decision re-saturates and reflects it.
|
||||
(define acl-revoke! (fn (db fact) (do (dl-retract! db fact) db)))
|
||||
|
||||
;; Propagated assertion: ingest a newly replicated fact into a live db.
|
||||
(define acl-fed-assert! (fn (db fact) (do (dl-assert! db fact) db)))
|
||||
71
lib/acl/schema.sx
Normal file
71
lib/acl/schema.sx
Normal file
@@ -0,0 +1,71 @@
|
||||
;; lib/acl/schema.sx — ACL sorts and EDB predicate vocabulary.
|
||||
;;
|
||||
;; Datalog is untyped; this module is the schema-as-data layer. It declares
|
||||
;; the subject/resource/action sorts and the arity of every EDB predicate the
|
||||
;; ACL engine recognises, plus light validators. Facts that pass these checks
|
||||
;; are well-formed inputs to lib/acl/engine.sx.
|
||||
|
||||
(define acl-subject-kinds (quote (user group role service)))
|
||||
(define acl-resource-kinds (quote (page post thread peer)))
|
||||
|
||||
;; Actions are open-ended (a grant may name any action symbol), but these are
|
||||
;; the platform's well-known verbs.
|
||||
(define acl-actions (quote (read edit comment moderate federate)))
|
||||
|
||||
;; EDB predicate name -> arity.
|
||||
;; Phase 1: actor/resource/grant/deny.
|
||||
;; Phase 2: member_of (subject->group/role), child_of (resource->parent),
|
||||
;; role_grant (role->action,resource).
|
||||
;; Phase 4: peer (addr->kind), trust (peer->level),
|
||||
;; delegate (peer->subj,action,resource), level_covers (level->action).
|
||||
(define acl-edb-arity {:role_grant 3 :child_of 2 :trust 2 :peer 2 :actor 2 :level_covers 2 :delegate 4 :member_of 2 :deny 3 :grant 3 :resource 2})
|
||||
|
||||
(define
|
||||
acl-member?
|
||||
(fn
|
||||
(x xs)
|
||||
(cond
|
||||
((= (len xs) 0) false)
|
||||
((= (first xs) x) true)
|
||||
(else (acl-member? x (rest xs))))))
|
||||
|
||||
(define acl-subject-kind? (fn (k) (acl-member? k acl-subject-kinds)))
|
||||
|
||||
(define acl-resource-kind? (fn (k) (acl-member? k acl-resource-kinds)))
|
||||
|
||||
(define acl-known-action? (fn (a) (acl-member? a acl-actions)))
|
||||
|
||||
;; A fact is a list whose head is a predicate symbol. Valid when the predicate
|
||||
;; is known and the argument count matches the declared arity.
|
||||
(define
|
||||
acl-fact-valid?
|
||||
(fn
|
||||
(f)
|
||||
(and
|
||||
(list? f)
|
||||
(> (len f) 0)
|
||||
(symbol? (first f))
|
||||
(let
|
||||
((pred (symbol->string (first f))))
|
||||
(and
|
||||
(has-key? acl-edb-arity pred)
|
||||
(= (- (len f) 1) (get acl-edb-arity pred)))))))
|
||||
|
||||
;; Return the sublist of facts that fail acl-fact-valid?. Empty list means the
|
||||
;; whole set is well-formed. acl-build-db stays lenient (Datalog accepts any
|
||||
;; tuple, and custom action symbols are allowed); callers opt in to checking.
|
||||
(define
|
||||
acl-validate-facts
|
||||
(fn
|
||||
(facts)
|
||||
(let
|
||||
((bad (list)))
|
||||
(do
|
||||
(for-each
|
||||
(fn (f) (when (not (acl-fact-valid? f)) (append! bad f)))
|
||||
facts)
|
||||
bad))))
|
||||
|
||||
(define
|
||||
acl-facts-valid?
|
||||
(fn (facts) (= (len (acl-validate-facts facts)) 0)))
|
||||
14
lib/acl/scoreboard.json
Normal file
14
lib/acl/scoreboard.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"lang": "acl",
|
||||
"total_passed": 145,
|
||||
"total_failed": 0,
|
||||
"total": 145,
|
||||
"suites": [
|
||||
{"name":"direct","passed":24,"failed":0,"total":24},
|
||||
{"name":"inherit","passed":30,"failed":0,"total":30},
|
||||
{"name":"explain","passed":35,"failed":0,"total":35},
|
||||
{"name":"fed","passed":31,"failed":0,"total":31},
|
||||
{"name":"harden","passed":25,"failed":0,"total":25}
|
||||
],
|
||||
"generated": "2026-06-06T22:43:27+00:00"
|
||||
}
|
||||
11
lib/acl/scoreboard.md
Normal file
11
lib/acl/scoreboard.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# acl scoreboard
|
||||
|
||||
**145 / 145 passing** (0 failure(s)).
|
||||
|
||||
| Suite | Passed | Total | Status |
|
||||
|-------|--------|-------|--------|
|
||||
| direct | 24 | 24 | ok |
|
||||
| inherit | 30 | 30 | ok |
|
||||
| explain | 35 | 35 | ok |
|
||||
| fed | 31 | 31 | ok |
|
||||
| harden | 25 | 25 | ok |
|
||||
170
lib/acl/tests/direct.sx
Normal file
170
lib/acl/tests/direct.sx
Normal file
@@ -0,0 +1,170 @@
|
||||
;; lib/acl/tests/direct.sx — Phase 1: direct grants + deny-overrides.
|
||||
|
||||
(define acl-dt-pass 0)
|
||||
(define acl-dt-fail 0)
|
||||
(define acl-dt-failures (list))
|
||||
|
||||
(define
|
||||
acl-dt-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! acl-dt-pass (+ acl-dt-pass 1))
|
||||
(do
|
||||
(set! acl-dt-fail (+ acl-dt-fail 1))
|
||||
(append!
|
||||
acl-dt-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; A small fixture used by most cases: alice can read page1, is denied edit on
|
||||
;; page1, and a service may federate peer1.
|
||||
(define
|
||||
acl-dt-fixture
|
||||
(fn
|
||||
()
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-actor (quote alice) (quote user))
|
||||
(acl-actor (quote svc1) (quote service))
|
||||
(acl-resource-fact (quote page1) (quote page))
|
||||
(acl-resource-fact (quote peer1) (quote peer))
|
||||
(acl-grant (quote alice) (quote read) (quote page1))
|
||||
(acl-grant (quote alice) (quote edit) (quote page1))
|
||||
(acl-deny (quote alice) (quote edit) (quote page1))
|
||||
(acl-grant (quote svc1) (quote federate) (quote peer1))))))
|
||||
|
||||
(define
|
||||
acl-dt-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((db (acl-dt-fixture)))
|
||||
(do
|
||||
(acl-dt-check!
|
||||
"direct grant permits"
|
||||
(acl-permit? db (quote alice) (quote read) (quote page1))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"service grant permits federate"
|
||||
(acl-permit? db (quote svc1) (quote federate) (quote peer1))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"missing action denied"
|
||||
(acl-permit? db (quote alice) (quote comment) (quote page1))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"missing resource denied"
|
||||
(acl-permit? db (quote alice) (quote read) (quote page2))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"missing subject denied"
|
||||
(acl-permit? db (quote bob) (quote read) (quote page1))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"wrong subject for service grant denied"
|
||||
(acl-permit? db (quote alice) (quote federate) (quote peer1))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"grant plus deny -> deny wins"
|
||||
(acl-permit? db (quote alice) (quote edit) (quote page1))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"deny alone still denies"
|
||||
(acl-permit?
|
||||
(acl-build-db
|
||||
(list (acl-deny (quote alice) (quote read) (quote page1))))
|
||||
(quote alice)
|
||||
(quote read)
|
||||
(quote page1))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"deny on edit does not block read"
|
||||
(acl-permit? db (quote alice) (quote read) (quote page1))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"empty db denies"
|
||||
(acl-permit?
|
||||
(acl-build-db (list))
|
||||
(quote alice)
|
||||
(quote read)
|
||||
(quote page1))
|
||||
false)
|
||||
(let
|
||||
((db2 (acl-build-db (list (acl-grant (quote a) (quote read) (quote r)) (acl-grant (quote b) (quote read) (quote r)) (acl-deny (quote b) (quote read) (quote r))))))
|
||||
(do
|
||||
(acl-dt-check!
|
||||
"subject a allowed"
|
||||
(acl-permit? db2 (quote a) (quote read) (quote r))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"subject b denied by override"
|
||||
(acl-permit? db2 (quote b) (quote read) (quote r))
|
||||
false)))
|
||||
(let
|
||||
((db3 (acl-build-db (list (acl-actor (quote editors) (quote role)) (acl-grant (quote editors) (quote edit) (quote post1))))))
|
||||
(acl-dt-check!
|
||||
"role subject direct grant"
|
||||
(acl-permit? db3 (quote editors) (quote edit) (quote post1))
|
||||
true))
|
||||
(do
|
||||
(acl/load!
|
||||
(list
|
||||
(acl-grant (quote carol) (quote moderate) (quote thread1))))
|
||||
(acl-dt-check!
|
||||
"api permit via current db"
|
||||
(acl/permit? (quote carol) (quote moderate) (quote thread1))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"api deny via current db"
|
||||
(acl/permit? (quote carol) (quote read) (quote thread1))
|
||||
false))
|
||||
(do
|
||||
(acl/load! (list))
|
||||
(acl-dt-check!
|
||||
"api reload clears prior grants"
|
||||
(acl/permit? (quote carol) (quote moderate) (quote thread1))
|
||||
false))
|
||||
(acl-dt-check!
|
||||
"schema grant arity valid"
|
||||
(acl-fact-valid? (acl-grant (quote x) (quote read) (quote y)))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"schema bad arity invalid"
|
||||
(acl-fact-valid? (list (quote grant) (quote x)))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"schema unknown predicate invalid"
|
||||
(acl-fact-valid? (list (quote frobnicate) (quote x)))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"schema subject kind known"
|
||||
(acl-subject-kind? (quote service))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"schema resource kind unknown"
|
||||
(acl-resource-kind? (quote galaxy))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"schema known action"
|
||||
(acl-known-action? (quote moderate))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"grant constructor shape"
|
||||
(acl-grant (quote u) (quote read) (quote p))
|
||||
(list (quote grant) (quote u) (quote read) (quote p)))
|
||||
(acl-dt-check!
|
||||
"actor constructor shape"
|
||||
(acl-actor (quote u) (quote user))
|
||||
(list (quote actor) (quote u) (quote user)))))))
|
||||
|
||||
(define
|
||||
acl-direct-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! acl-dt-pass 0)
|
||||
(set! acl-dt-fail 0)
|
||||
(set! acl-dt-failures (list))
|
||||
(acl-dt-run-all!)
|
||||
{:failures acl-dt-failures :total (+ acl-dt-pass acl-dt-fail) :passed acl-dt-pass :failed acl-dt-fail})))
|
||||
316
lib/acl/tests/explain.sx
Normal file
316
lib/acl/tests/explain.sx
Normal file
@@ -0,0 +1,316 @@
|
||||
;; lib/acl/tests/explain.sx — Phase 3: proof correctness + audit completeness.
|
||||
|
||||
(define acl-et-pass 0)
|
||||
(define acl-et-fail 0)
|
||||
(define acl-et-failures (list))
|
||||
|
||||
;; Name-based deep equality. The host `=` compares symbols by interned
|
||||
;; identity, which is unstable across substitution/saturation; comparing by
|
||||
;; name (as the datalog suite does) makes structural assertions deterministic.
|
||||
(define
|
||||
acl-et-eq?
|
||||
(fn
|
||||
(a b)
|
||||
(cond
|
||||
((and (list? a) (list? b))
|
||||
(and (= (len a) (len b)) (acl-et-eq-l? a b 0)))
|
||||
((and (dict? a) (dict? b))
|
||||
(let
|
||||
((ka (keys a)) (kb (keys b)))
|
||||
(and (= (len ka) (len kb)) (acl-et-eq-d? a b ka 0))))
|
||||
((and (symbol? a) (symbol? b))
|
||||
(= (symbol->string a) (symbol->string b)))
|
||||
(else (= a b)))))
|
||||
|
||||
(define
|
||||
acl-et-eq-l?
|
||||
(fn
|
||||
(a b i)
|
||||
(cond
|
||||
((>= i (len a)) true)
|
||||
((not (acl-et-eq? (nth a i) (nth b i))) false)
|
||||
(else (acl-et-eq-l? a b (+ i 1))))))
|
||||
|
||||
(define
|
||||
acl-et-eq-d?
|
||||
(fn
|
||||
(a b ka i)
|
||||
(cond
|
||||
((>= i (len ka)) true)
|
||||
((let ((k (nth ka i))) (not (acl-et-eq? (get a k) (get b k))))
|
||||
false)
|
||||
(else (acl-et-eq-d? a b ka (+ i 1))))))
|
||||
|
||||
(define
|
||||
acl-et-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(acl-et-eq? got expected)
|
||||
(set! acl-et-pass (+ acl-et-pass 1))
|
||||
(do
|
||||
(set! acl-et-fail (+ acl-et-fail 1))
|
||||
(append!
|
||||
acl-et-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; --- proof-tree walkers ---
|
||||
|
||||
;; True if EDB fact `target` appears as a base leaf anywhere in the proof.
|
||||
(define
|
||||
acl-et-has-leaf?
|
||||
(fn
|
||||
(node target)
|
||||
(cond
|
||||
((= node nil) false)
|
||||
((and (dict? node) (has-key? node :via))
|
||||
(acl-et-eq? (get node :fact) target))
|
||||
((and (dict? node) (has-key? node :body))
|
||||
(acl-et-any-leaf? (get node :body) target))
|
||||
(else false))))
|
||||
|
||||
(define
|
||||
acl-et-any-leaf?
|
||||
(fn
|
||||
(nodes target)
|
||||
(cond
|
||||
((= (len nodes) 0) false)
|
||||
((acl-et-has-leaf? (first nodes) target) true)
|
||||
(else (acl-et-any-leaf? (rest nodes) target)))))
|
||||
|
||||
;; True if the proof records a verified negation (deny did not fire).
|
||||
(define
|
||||
acl-et-has-negok?
|
||||
(fn
|
||||
(node)
|
||||
(cond
|
||||
((= node nil) false)
|
||||
((and (dict? node) (has-key? node :neg-ok)) true)
|
||||
((and (dict? node) (has-key? node :body))
|
||||
(acl-et-any-negok? (get node :body)))
|
||||
(else false))))
|
||||
|
||||
(define
|
||||
acl-et-any-negok?
|
||||
(fn
|
||||
(nodes)
|
||||
(cond
|
||||
((= (len nodes) 0) false)
|
||||
((acl-et-has-negok? (first nodes)) true)
|
||||
(else (acl-et-any-negok? (rest nodes))))))
|
||||
|
||||
(define
|
||||
acl-et-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p))))))
|
||||
(let
|
||||
((e (acl-explain db (quote u) (quote read) (quote p))))
|
||||
(do
|
||||
(acl-et-check! "direct: allowed?" (get e :allowed?) true)
|
||||
(acl-et-check!
|
||||
"direct: proof root fact"
|
||||
(get (get e :proof) :fact)
|
||||
(list (quote permit) (quote u) (quote read) (quote p)))
|
||||
(acl-et-check!
|
||||
"direct: grant leaf present"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote grant) (quote u) (quote read) (quote p)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"direct: negation verified"
|
||||
(acl-et-has-negok? (get e :proof))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"direct: reason nil when allowed"
|
||||
(get e :reason)
|
||||
nil))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-member-of (quote team) (quote org)) (acl-grant (quote org) (quote read) (quote doc))))))
|
||||
(let
|
||||
((e (acl-explain db (quote alice) (quote read) (quote doc))))
|
||||
(do
|
||||
(acl-et-check! "group: allowed?" (get e :allowed?) true)
|
||||
(acl-et-check!
|
||||
"group: member_of alice leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote member_of) (quote alice) (quote team)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"group: member_of team leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote member_of) (quote team) (quote org)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"group: grant org leaf at base"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote grant) (quote org) (quote read) (quote doc)))
|
||||
true))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-child-of (quote sec) (quote book)) (acl-grant (quote u) (quote read) (quote book))))))
|
||||
(let
|
||||
((e (acl-explain db (quote u) (quote read) (quote sec))))
|
||||
(do
|
||||
(acl-et-check! "resource: allowed?" (get e :allowed?) true)
|
||||
(acl-et-check!
|
||||
"resource: child_of leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote child_of) (quote sec) (quote book)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"resource: grant on parent leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote grant) (quote u) (quote read) (quote book)))
|
||||
true))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-role-grant (quote editor) (quote edit) (quote page1))))))
|
||||
(let
|
||||
((e (acl-explain db (quote bob) (quote edit) (quote page1))))
|
||||
(do
|
||||
(acl-et-check! "role: allowed?" (get e :allowed?) true)
|
||||
(acl-et-check!
|
||||
"role: member_of leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote member_of) (quote bob) (quote editor)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"role: role_grant leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list
|
||||
(quote role_grant)
|
||||
(quote editor)
|
||||
(quote edit)
|
||||
(quote page1)))
|
||||
true))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-grant (quote u) (quote edit) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
|
||||
(let
|
||||
((e (acl-explain db (quote u) (quote edit) (quote p))))
|
||||
(do
|
||||
(acl-et-check! "deny: not allowed" (get e :allowed?) false)
|
||||
(acl-et-check! "deny: no proof" (get e :proof) nil)
|
||||
(acl-et-check!
|
||||
"deny: reason root is eff_deny"
|
||||
(get (get e :reason) :fact)
|
||||
(list (quote eff_deny) (quote u) (quote edit) (quote p)))
|
||||
(acl-et-check!
|
||||
"deny: reason has deny leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :reason)
|
||||
(list (quote deny) (quote u) (quote edit) (quote p)))
|
||||
true))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote alice) (quote read) (quote doc)) (acl-deny (quote team) (quote read) (quote doc))))))
|
||||
(let
|
||||
((e (acl-explain db (quote alice) (quote read) (quote doc))))
|
||||
(do
|
||||
(acl-et-check!
|
||||
"inherited deny: not allowed"
|
||||
(get e :allowed?)
|
||||
false)
|
||||
(acl-et-check!
|
||||
"inherited deny: reason has member_of leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :reason)
|
||||
(list (quote member_of) (quote alice) (quote team)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"inherited deny: reason has group deny leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :reason)
|
||||
(list (quote deny) (quote team) (quote read) (quote doc)))
|
||||
true))))
|
||||
(let
|
||||
((db (acl-build-db (list))))
|
||||
(let
|
||||
((e (acl-explain db (quote u) (quote read) (quote p))))
|
||||
(do
|
||||
(acl-et-check! "no grant: not allowed" (get e :allowed?) false)
|
||||
(acl-et-check! "no grant: proof nil" (get e :proof) nil)
|
||||
(acl-et-check! "no grant: reason nil" (get e :reason) nil))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
|
||||
(do
|
||||
(acl-audit-clear!)
|
||||
(acl-et-check! "audit: starts empty" (acl-audit-count) 0)
|
||||
(acl-et-check!
|
||||
"audit decide allowed returns true"
|
||||
(acl-audit-decide! db (quote u) (quote read) (quote p))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"audit decide denied returns false"
|
||||
(acl-audit-decide! db (quote u) (quote edit) (quote p))
|
||||
false)
|
||||
(acl-audit-decide! db (quote u) (quote comment) (quote p))
|
||||
(acl-et-check!
|
||||
"audit: count after three decisions"
|
||||
(acl-audit-count)
|
||||
3)
|
||||
(acl-et-check!
|
||||
"audit: tail size respects n"
|
||||
(len (acl-audit-tail 2))
|
||||
2)
|
||||
(acl-et-check!
|
||||
"audit: tail returns most recent"
|
||||
(get (first (acl-audit-tail 1)) :act)
|
||||
(quote comment))
|
||||
(acl-et-check!
|
||||
"audit: first record seq is 0"
|
||||
(get (first (acl-audit-tail 3)) :seq)
|
||||
0)
|
||||
(acl-et-check!
|
||||
"audit: allowed flag recorded"
|
||||
(get (first (acl-audit-tail 3)) :allowed?)
|
||||
true)
|
||||
(acl-et-check!
|
||||
"audit: serialize line count"
|
||||
(len (acl-et-lines (acl-audit-serialize)))
|
||||
3)
|
||||
(acl-audit-clear!)
|
||||
(acl-et-check!
|
||||
"audit: clear resets count"
|
||||
(acl-audit-count)
|
||||
0))))))
|
||||
|
||||
;; count newline-terminated lines in a serialized log
|
||||
(define acl-et-lines (fn (s) (acl-et-count-nl s 0 0)))
|
||||
(define
|
||||
acl-et-count-nl
|
||||
(fn
|
||||
(s i n)
|
||||
(if
|
||||
(>= i (len s))
|
||||
(if (= n 0) (list) (acl-et-rangelist n))
|
||||
(acl-et-count-nl
|
||||
s
|
||||
(+ i 1)
|
||||
(if (= (slice s i (+ i 1)) "\n") (+ n 1) n)))))
|
||||
(define
|
||||
acl-et-rangelist
|
||||
(fn
|
||||
(n)
|
||||
(if
|
||||
(<= n 0)
|
||||
(list)
|
||||
(cons n (acl-et-rangelist (- n 1))))))
|
||||
|
||||
(define
|
||||
acl-explain-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! acl-et-pass 0)
|
||||
(set! acl-et-fail 0)
|
||||
(set! acl-et-failures (list))
|
||||
(acl-et-run-all!)
|
||||
{:failures acl-et-failures :total (+ acl-et-pass acl-et-fail) :passed acl-et-pass :failed acl-et-fail})))
|
||||
273
lib/acl/tests/fed.sx
Normal file
273
lib/acl/tests/fed.sx
Normal file
@@ -0,0 +1,273 @@
|
||||
;; lib/acl/tests/fed.sx — Phase 4: federation (peer trust, delegation,
|
||||
;; cross-instance chains, revocation). fed-sx transport is mocked as a dict.
|
||||
|
||||
(define acl-ft-pass 0)
|
||||
(define acl-ft-fail 0)
|
||||
(define acl-ft-failures (list))
|
||||
|
||||
;; Name-based deep equality (host `=` compares symbols by unstable interned
|
||||
;; identity; see lib/acl/tests/explain.sx).
|
||||
(define
|
||||
acl-ft-eq?
|
||||
(fn
|
||||
(a b)
|
||||
(cond
|
||||
((and (list? a) (list? b))
|
||||
(and (= (len a) (len b)) (acl-ft-eq-l? a b 0)))
|
||||
((and (symbol? a) (symbol? b))
|
||||
(= (symbol->string a) (symbol->string b)))
|
||||
(else (= a b)))))
|
||||
(define
|
||||
acl-ft-eq-l?
|
||||
(fn
|
||||
(a b i)
|
||||
(cond
|
||||
((>= i (len a)) true)
|
||||
((not (acl-ft-eq? (nth a i) (nth b i))) false)
|
||||
(else (acl-ft-eq-l? a b (+ i 1))))))
|
||||
|
||||
(define
|
||||
acl-ft-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(acl-ft-eq? got expected)
|
||||
(set! acl-ft-pass (+ acl-ft-pass 1))
|
||||
(do
|
||||
(set! acl-ft-fail (+ acl-ft-fail 1))
|
||||
(append!
|
||||
acl-ft-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; proof leaf walker (federated proofs reconstruct through the engine rule).
|
||||
(define
|
||||
acl-ft-has-leaf?
|
||||
(fn
|
||||
(node target)
|
||||
(cond
|
||||
((= node nil) false)
|
||||
((and (dict? node) (has-key? node :via))
|
||||
(acl-ft-eq? (get node :fact) target))
|
||||
((and (dict? node) (has-key? node :body))
|
||||
(acl-ft-any-leaf? (get node :body) target))
|
||||
(else false))))
|
||||
(define
|
||||
acl-ft-any-leaf?
|
||||
(fn
|
||||
(nodes target)
|
||||
(cond
|
||||
((= (len nodes) 0) false)
|
||||
((acl-ft-has-leaf? (first nodes) target) true)
|
||||
(else (acl-ft-any-leaf? (rest nodes) target)))))
|
||||
|
||||
(define acl-ft-p? (fn (db s a r) (acl-permit? db s a r)))
|
||||
|
||||
;; A standard federation fixture: local trusts peer alpha at "readonly", which
|
||||
;; covers read+comment. alpha delegates several capabilities to alice.
|
||||
(define
|
||||
acl-ft-fixture
|
||||
(fn
|
||||
()
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-trust (quote alpha) (quote readonly))
|
||||
(acl-level-covers (quote readonly) (quote read))
|
||||
(acl-level-covers (quote readonly) (quote comment))
|
||||
(acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))
|
||||
(acl-delegate (quote alpha) (quote alice) (quote edit) (quote doc))))))
|
||||
|
||||
(define
|
||||
acl-ft-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((db (acl-ft-fixture)))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"trusted delegate, level covers action -> permit"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"trusted delegate, level does NOT cover action -> deny"
|
||||
(acl-ft-p? db (quote alice) (quote edit) (quote doc))
|
||||
false)
|
||||
(acl-ft-check!
|
||||
"delegated but action class uncovered (comment has no delegate)"
|
||||
(acl-ft-p? db (quote alice) (quote comment) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-level-covers (quote readonly) (quote read)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
|
||||
(acl-ft-check!
|
||||
"untrusted peer delegate -> deny"
|
||||
(acl-ft-p? db (quote bob) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote readonly)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
|
||||
(acl-ft-check!
|
||||
"trust but no level_covers -> deny"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"trust is per-peer: alpha's delegate applies"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"trust not transitive: beta's delegate does not apply"
|
||||
(acl-ft-p? db (quote bob) (quote read) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
|
||||
(acl-ft-check!
|
||||
"local deny overrides federated grant"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-member-of (quote alice) (quote team)) (acl-delegate (quote alpha) (quote team) (quote read) (quote doc))))))
|
||||
(acl-ft-check!
|
||||
"federated grant to group reaches member"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-child-of (quote sec) (quote book)) (acl-delegate (quote alpha) (quote u) (quote read) (quote book))))))
|
||||
(acl-ft-check!
|
||||
"federated grant on parent resource reaches child"
|
||||
(acl-ft-p? db (quote u) (quote read) (quote sec))
|
||||
true))
|
||||
(let
|
||||
((transport {:gamma (list (acl-delegate (quote gamma) (quote carol) (quote read) (quote post))) :alpha (list (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)))}))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"fetch known peer returns its delegates"
|
||||
(len (acl-fed-fetch transport (quote alpha)))
|
||||
1)
|
||||
(acl-ft-check!
|
||||
"fetch unknown peer returns empty"
|
||||
(len (acl-fed-fetch transport (quote delta)))
|
||||
0)
|
||||
(acl-ft-check!
|
||||
"collect across peers"
|
||||
(len
|
||||
(acl-fed-collect transport (list (quote alpha) (quote gamma))))
|
||||
2)
|
||||
(let
|
||||
((db (acl-fed-build-db (list (acl-trust (quote alpha) (quote readonly)) (acl-trust (quote gamma) (quote readonly)) (acl-level-covers (quote readonly) (quote read))) transport (list (quote alpha) (quote gamma)))))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"fed-build-db: alpha delegate permits"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"fed-build-db: gamma delegate permits"
|
||||
(acl-ft-p? db (quote carol) (quote read) (quote post))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"fed-build-db: untrusted action still denied"
|
||||
(acl-ft-p? db (quote alice) (quote edit) (quote doc))
|
||||
false)))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"before revoke: permitted"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-revoke!
|
||||
db
|
||||
(acl-delegate
|
||||
(quote alpha)
|
||||
(quote alice)
|
||||
(quote read)
|
||||
(quote doc)))
|
||||
(acl-ft-check!
|
||||
"after delegate revoked: denied"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"before trust revoke: permitted"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-revoke! db (acl-trust (quote alpha) (quote full)))
|
||||
(acl-ft-check!
|
||||
"after trust revoked: denied"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"delegate without trust: denied"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
false)
|
||||
(acl-fed-assert! db (acl-trust (quote alpha) (quote full)))
|
||||
(acl-ft-check!
|
||||
"trust ingested then re-checked: permitted"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-ft-fixture)))
|
||||
(let
|
||||
((e (acl-explain db (quote alice) (quote read) (quote doc))))
|
||||
(do
|
||||
(acl-ft-check! "federated proof allowed?" (get e :allowed?) true)
|
||||
(acl-ft-check!
|
||||
"federated proof has delegate leaf"
|
||||
(acl-ft-has-leaf?
|
||||
(get e :proof)
|
||||
(list
|
||||
(quote delegate)
|
||||
(quote alpha)
|
||||
(quote alice)
|
||||
(quote read)
|
||||
(quote doc)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"federated proof has trust leaf"
|
||||
(acl-ft-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote trust) (quote alpha) (quote readonly)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"federated proof has level_covers leaf"
|
||||
(acl-ft-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote level_covers) (quote readonly) (quote read)))
|
||||
true))))
|
||||
(acl-ft-check!
|
||||
"schema delegate arity valid"
|
||||
(acl-fact-valid?
|
||||
(acl-delegate (quote p) (quote s) (quote a) (quote r)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"schema trust arity valid"
|
||||
(acl-fact-valid? (acl-trust (quote p) (quote l)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"schema peer arity valid"
|
||||
(acl-fact-valid? (acl-peer (quote p) (quote peer)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"schema level_covers arity valid"
|
||||
(acl-fact-valid? (acl-level-covers (quote l) (quote read)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"schema delegate bad arity invalid"
|
||||
(acl-fact-valid? (list (quote delegate) (quote p) (quote s)))
|
||||
false))))
|
||||
|
||||
(define
|
||||
acl-fed-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! acl-ft-pass 0)
|
||||
(set! acl-ft-fail 0)
|
||||
(set! acl-ft-failures (list))
|
||||
(acl-ft-run-all!)
|
||||
{:failures acl-ft-failures :total (+ acl-ft-pass acl-ft-fail) :passed acl-ft-pass :failed acl-ft-fail})))
|
||||
228
lib/acl/tests/harden.sx
Normal file
228
lib/acl/tests/harden.sx
Normal file
@@ -0,0 +1,228 @@
|
||||
;; lib/acl/tests/harden.sx — adversarial / cross-phase hardening.
|
||||
;;
|
||||
;; Diamond hierarchies, conflict resolution where deny must win through every
|
||||
;; path, chain inheritance, cycle termination, multi-peer delegation, fact
|
||||
;; validation, and audit save/restore.
|
||||
;;
|
||||
;; PROVER-FREE BY DESIGN: this suite calls only acl-permit? (which runs in
|
||||
;; compiled Datalog, safe at any depth) plus pure data ops — never acl-explain /
|
||||
;; acl-prove-d. The SX-side proof reconstructor recurses, and once the kernel
|
||||
;; JIT-compiles it (after the explain/fed suites warm the process) it loops on
|
||||
;; chains deeper than ~3 (substrate JIT bug — see plan Blockers). Proof
|
||||
;; reconstruction is covered by tests/explain.sx (and federated proofs by
|
||||
;; tests/fed.sx), both of which stay under the warm-process depth threshold.
|
||||
|
||||
(define acl-hd-pass 0)
|
||||
(define acl-hd-fail 0)
|
||||
(define acl-hd-failures (list))
|
||||
|
||||
(define
|
||||
acl-hd-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! acl-hd-pass (+ acl-hd-pass 1))
|
||||
(do
|
||||
(set! acl-hd-fail (+ acl-hd-fail 1))
|
||||
(append!
|
||||
acl-hd-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define acl-hd-p? (fn (db s a r) (acl-permit? db s a r)))
|
||||
|
||||
(define
|
||||
acl-hd-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((grant-deny (acl-build-db (list (acl-child-of (quote r) (quote p1)) (acl-child-of (quote r) (quote p2)) (acl-grant (quote u) (quote read) (quote p1)) (acl-deny (quote u) (quote read) (quote p2)))))
|
||||
(both-grant
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-child-of (quote r) (quote p1))
|
||||
(acl-child-of (quote r) (quote p2))
|
||||
(acl-grant (quote u) (quote read) (quote p1))
|
||||
(acl-grant (quote u) (quote read) (quote p2))))))
|
||||
(do
|
||||
(acl-hd-check!
|
||||
"diamond resource: grant+deny parents -> deny wins"
|
||||
(acl-hd-p? grant-deny (quote u) (quote read) (quote r))
|
||||
false)
|
||||
(acl-hd-check!
|
||||
"diamond resource: both grant -> permit"
|
||||
(acl-hd-p? both-grant (quote u) (quote read) (quote r))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"diamond resource: deny does not leak to other parent"
|
||||
(acl-hd-p? grant-deny (quote u) (quote read) (quote p1))
|
||||
true)))
|
||||
(let
|
||||
((grant-deny (acl-build-db (list (acl-member-of (quote alice) (quote g1)) (acl-member-of (quote alice) (quote g2)) (acl-grant (quote g1) (quote read) (quote doc)) (acl-deny (quote g2) (quote read) (quote doc)))))
|
||||
(both-grant
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-member-of (quote alice) (quote g1))
|
||||
(acl-member-of (quote alice) (quote g2))
|
||||
(acl-grant (quote g1) (quote read) (quote doc))
|
||||
(acl-grant (quote g2) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-hd-check!
|
||||
"diamond group: grant+deny groups -> deny wins"
|
||||
(acl-hd-p? grant-deny (quote alice) (quote read) (quote doc))
|
||||
false)
|
||||
(acl-hd-check!
|
||||
"diamond group: both grant -> permit"
|
||||
(acl-hd-p? both-grant (quote alice) (quote read) (quote doc))
|
||||
true)))
|
||||
(let
|
||||
((chain (acl-build-db (list (acl-member-of (quote a0) (quote a1)) (acl-member-of (quote a1) (quote a2)) (acl-member-of (quote a2) (quote a3)) (acl-member-of (quote a3) (quote a4)) (acl-grant (quote a4) (quote read) (quote res)))))
|
||||
(chain-deny
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-member-of (quote a0) (quote a1))
|
||||
(acl-member-of (quote a1) (quote a2))
|
||||
(acl-member-of (quote a2) (quote a3))
|
||||
(acl-member-of (quote a3) (quote a4))
|
||||
(acl-grant (quote a4) (quote read) (quote res))
|
||||
(acl-deny (quote a0) (quote read) (quote res))))))
|
||||
(do
|
||||
(acl-hd-check!
|
||||
"chain: top-group grant reaches leaf member"
|
||||
(acl-hd-p? chain (quote a0) (quote read) (quote res))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"chain: intermediate also covered"
|
||||
(acl-hd-p? chain (quote a2) (quote read) (quote res))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"chain: leaf-member deny overrides top grant"
|
||||
(acl-hd-p? chain-deny (quote a0) (quote read) (quote res))
|
||||
false)
|
||||
(acl-hd-check!
|
||||
"chain: deny on leaf does not block sibling level"
|
||||
(acl-hd-p? chain-deny (quote a1) (quote read) (quote res))
|
||||
true)))
|
||||
(let
|
||||
((self-member (acl-build-db (list (acl-member-of (quote a) (quote a)) (acl-grant (quote a) (quote read) (quote r)))))
|
||||
(self-child
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-child-of (quote r) (quote r))
|
||||
(acl-grant (quote u) (quote read) (quote r)))))
|
||||
(two-cycle
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-member-of (quote x) (quote y))
|
||||
(acl-member-of (quote y) (quote x))
|
||||
(acl-grant (quote y) (quote read) (quote r))))))
|
||||
(do
|
||||
(acl-hd-check!
|
||||
"self-membership cycle terminates and grants"
|
||||
(acl-hd-p? self-member (quote a) (quote read) (quote r))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"self-child cycle terminates and grants"
|
||||
(acl-hd-p? self-child (quote u) (quote read) (quote r))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"two-node membership cycle terminates"
|
||||
(acl-hd-p? two-cycle (quote x) (quote read) (quote r))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-member-of (quote alice) (quote team)) (acl-delegate (quote alpha) (quote team) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
|
||||
(acl-hd-check!
|
||||
"federated group grant, local member deny -> deny wins"
|
||||
(acl-hd-p? db (quote alice) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote bob) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
|
||||
(acl-hd-check!
|
||||
"two peers delegate, one trusted -> permit"
|
||||
(acl-hd-p? db (quote bob) (quote read) (quote doc))
|
||||
true))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-trust (quote beta) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote bob) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
|
||||
(acl-hd-check!
|
||||
"two peers both trusted -> permit"
|
||||
(acl-hd-p? db (quote bob) (quote read) (quote doc))
|
||||
true))
|
||||
(let
|
||||
((empty (acl-build-db (list))))
|
||||
(acl-hd-check!
|
||||
"empty db: nothing permitted"
|
||||
(acl-hd-p? empty (quote u) (quote read) (quote r))
|
||||
false))
|
||||
(do
|
||||
(acl-hd-check!
|
||||
"validate: clean set has no bad facts"
|
||||
(len
|
||||
(acl-validate-facts
|
||||
(list
|
||||
(acl-grant (quote u) (quote read) (quote p))
|
||||
(acl-member-of (quote u) (quote g))
|
||||
(acl-delegate (quote pe) (quote u) (quote read) (quote p)))))
|
||||
0)
|
||||
(acl-hd-check!
|
||||
"validate: facts-valid? true on clean set"
|
||||
(acl-facts-valid?
|
||||
(list (acl-grant (quote u) (quote read) (quote p))))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"validate: surfaces wrong-arity and unknown predicate"
|
||||
(len
|
||||
(acl-validate-facts
|
||||
(list
|
||||
(acl-grant (quote u) (quote read) (quote p))
|
||||
(list (quote grant) (quote u))
|
||||
(list (quote bogus) (quote x) (quote y)))))
|
||||
2)
|
||||
(acl-hd-check!
|
||||
"validate: empty set is valid"
|
||||
(acl-facts-valid? (list))
|
||||
true))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
|
||||
(do
|
||||
(acl-audit-clear!)
|
||||
(acl-audit-decide! db (quote u) (quote read) (quote p))
|
||||
(acl-audit-decide! db (quote u) (quote edit) (quote p))
|
||||
(let
|
||||
((snap (acl-audit-snapshot)))
|
||||
(do
|
||||
(acl-audit-clear!)
|
||||
(acl-hd-check!
|
||||
"audit: cleared count is 0"
|
||||
(acl-audit-count)
|
||||
0)
|
||||
(acl-audit-restore! snap)
|
||||
(acl-hd-check!
|
||||
"audit: restored count"
|
||||
(acl-audit-count)
|
||||
2)
|
||||
(acl-hd-check!
|
||||
"audit: restored last act"
|
||||
(get (first (acl-audit-tail 1)) :act)
|
||||
(quote edit))
|
||||
(acl-audit-decide! db (quote u) (quote comment) (quote p))
|
||||
(acl-hd-check!
|
||||
"audit: seq continues after restore"
|
||||
(get (first (acl-audit-tail 1)) :seq)
|
||||
2)
|
||||
(acl-hd-check!
|
||||
"audit: snapshot is an immutable copy"
|
||||
(len (get snap :entries))
|
||||
2)
|
||||
(acl-audit-clear!))))))))
|
||||
|
||||
(define
|
||||
acl-harden-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! acl-hd-pass 0)
|
||||
(set! acl-hd-fail 0)
|
||||
(set! acl-hd-failures (list))
|
||||
(acl-hd-run-all!)
|
||||
{:failures acl-hd-failures :total (+ acl-hd-pass acl-hd-fail) :passed acl-hd-pass :failed acl-hd-fail})))
|
||||
202
lib/acl/tests/inherit.sx
Normal file
202
lib/acl/tests/inherit.sx
Normal file
@@ -0,0 +1,202 @@
|
||||
;; lib/acl/tests/inherit.sx — Phase 2: inheritance (groups, resource trees,
|
||||
;; role expansion) with deny-overrides.
|
||||
|
||||
(define acl-it-pass 0)
|
||||
(define acl-it-fail 0)
|
||||
(define acl-it-failures (list))
|
||||
|
||||
(define
|
||||
acl-it-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! acl-it-pass (+ acl-it-pass 1))
|
||||
(do
|
||||
(set! acl-it-fail (+ acl-it-fail 1))
|
||||
(append!
|
||||
acl-it-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define acl-it-p? (fn (db s a r) (acl-permit? db s a r)))
|
||||
|
||||
(define
|
||||
acl-it-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"group grant reaches member"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"group grant: non-member excluded"
|
||||
(acl-it-p? db (quote bob) (quote read) (quote doc))
|
||||
false)
|
||||
(acl-it-check!
|
||||
"group grant: wrong action"
|
||||
(acl-it-p? db (quote alice) (quote edit) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-member-of (quote team) (quote org)) (acl-member-of (quote org) (quote company)) (acl-grant (quote company) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"deep nested group grant reaches leaf member"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"intermediate group also covered"
|
||||
(acl-it-p? db (quote team) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"mid group org covered"
|
||||
(acl-it-p? db (quote org) (quote read) (quote doc))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote a) (quote b)) (acl-member-of (quote b) (quote a)) (acl-grant (quote b) (quote read) (quote r))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"cyclic membership terminates and grants"
|
||||
(acl-it-p? db (quote a) (quote read) (quote r))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"cyclic membership covers both"
|
||||
(acl-it-p? db (quote b) (quote read) (quote r))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-child-of (quote sec) (quote chap)) (acl-child-of (quote chap) (quote book)) (acl-grant (quote u) (quote read) (quote book))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"parent grant reaches direct child"
|
||||
(acl-it-p? db (quote u) (quote read) (quote chap))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"parent grant reaches deep descendant"
|
||||
(acl-it-p? db (quote u) (quote read) (quote sec))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"parent grant covers parent itself"
|
||||
(acl-it-p? db (quote u) (quote read) (quote book))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"child grant does not climb to parent"
|
||||
(acl-it-p?
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-child-of (quote sec) (quote book))
|
||||
(acl-grant (quote u) (quote read) (quote sec))))
|
||||
(quote u)
|
||||
(quote read)
|
||||
(quote book))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-child-of (quote post1) (quote board)) (acl-grant (quote team) (quote comment) (quote board))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"group + resource: member on child resource"
|
||||
(acl-it-p? db (quote alice) (quote comment) (quote post1))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"group + resource: member on parent resource"
|
||||
(acl-it-p? db (quote alice) (quote comment) (quote board))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-role-grant (quote editor) (quote edit) (quote page1)) (acl-role-grant (quote editor) (quote read) (quote page1))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"role confers edit to member"
|
||||
(acl-it-p? db (quote bob) (quote edit) (quote page1))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"role confers read to member"
|
||||
(acl-it-p? db (quote bob) (quote read) (quote page1))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"role: capability not in tuple denied"
|
||||
(acl-it-p? db (quote bob) (quote moderate) (quote page1))
|
||||
false)
|
||||
(acl-it-check!
|
||||
"role: non-member excluded"
|
||||
(acl-it-p? db (quote eve) (quote edit) (quote page1))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-child-of (quote draft) (quote page1)) (acl-role-grant (quote editor) (quote edit) (quote page1))))))
|
||||
(acl-it-check!
|
||||
"role grant flows to child resource"
|
||||
(acl-it-p? db (quote bob) (quote edit) (quote draft))
|
||||
true))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
|
||||
(acl-it-check!
|
||||
"explicit deny beats inherited group allow"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote alice) (quote read) (quote doc)) (acl-deny (quote team) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"group deny inherits and overrides direct grant"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
false)
|
||||
(acl-it-check!
|
||||
"group deny: another member also blocked"
|
||||
(acl-it-p? db (quote team) (quote read) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-child-of (quote sec) (quote book)) (acl-grant (quote u) (quote read) (quote sec)) (acl-deny (quote u) (quote read) (quote book))))))
|
||||
(acl-it-check!
|
||||
"ancestor deny overrides descendant grant"
|
||||
(acl-it-p? db (quote u) (quote read) (quote sec))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc)) (acl-grant (quote team) (quote edit) (quote doc)) (acl-deny (quote alice) (quote edit) (quote doc))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"deny on edit leaves inherited read intact"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"deny on edit blocks edit"
|
||||
(acl-it-p? db (quote alice) (quote edit) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-deny (quote team) (quote read) (quote doc))))))
|
||||
(acl-it-check!
|
||||
"inherited deny, no grant: denied"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-child-of (quote a) (quote root)) (acl-child-of (quote b) (quote root)) (acl-grant (quote u) (quote read) (quote root)) (acl-deny (quote u) (quote read) (quote a))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"deny on sibling a blocks a"
|
||||
(acl-it-p? db (quote u) (quote read) (quote a))
|
||||
false)
|
||||
(acl-it-check!
|
||||
"deny on sibling a leaves b permitted"
|
||||
(acl-it-p? db (quote u) (quote read) (quote b))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"root itself still permitted"
|
||||
(acl-it-p? db (quote u) (quote read) (quote root))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-grant (quote x) (quote read) (quote y))))))
|
||||
(acl-it-check!
|
||||
"direct grant under inheritance ruleset"
|
||||
(acl-it-p? db (quote x) (quote read) (quote y))
|
||||
true)))))
|
||||
|
||||
(define
|
||||
acl-inherit-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! acl-it-pass 0)
|
||||
(set! acl-it-fail 0)
|
||||
(set! acl-it-failures (list))
|
||||
(acl-it-run-all!)
|
||||
{:failures acl-it-failures :total (+ acl-it-pass acl-it-fail) :passed acl-it-pass :failed acl-it-fail})))
|
||||
@@ -956,118 +956,8 @@
|
||||
(= ty "nil") (er-mk-nil)
|
||||
:else v))))
|
||||
|
||||
;; ── HTTP request/response marshaling (Step 8b-start) ────────────
|
||||
;; The native `http-listen` primitive hands the handler an SX dict
|
||||
;; {:method :path :query :headers :body}
|
||||
;; and expects an SX dict back
|
||||
;; {:status :headers :body}
|
||||
;; This layer converts so Erlang handlers see proper proplists:
|
||||
;; [{method, <<"GET">>}, {path, <<"/foo">>}, {query, <<>>},
|
||||
;; {headers, [{<<"content-type">>, <<"text/plain">>}, ...]},
|
||||
;; {body, <<...>>}]
|
||||
;; Headers ride as a nested proplist with binary keys — header names
|
||||
;; are arbitrary user input, so they stay out of the atom table. The
|
||||
;; outer request keys (method/path/query/headers/body) are fixed and
|
||||
;; small, so they become atoms (cheap to pattern-match against).
|
||||
|
||||
(define er-of-sx-deep
|
||||
(fn (v)
|
||||
(cond
|
||||
(= (type-of v) "dict") (er-dict-to-header-proplist v)
|
||||
:else (er-of-sx v))))
|
||||
|
||||
(define er-dict-to-header-proplist
|
||||
(fn (d)
|
||||
(let ((ks (keys d)) (out (er-mk-nil)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(let ((idx (- (- (len ks) 1) i)))
|
||||
(let ((k (nth ks idx)))
|
||||
(let ((v (get d k)))
|
||||
(set!
|
||||
out
|
||||
(er-mk-cons
|
||||
(er-mk-tuple
|
||||
(list
|
||||
(er-mk-binary (map char->integer (string->list k)))
|
||||
(er-of-sx-deep v)))
|
||||
out))))))
|
||||
(range 0 (len ks)))
|
||||
out)))
|
||||
|
||||
(define er-request-dict-to-proplist
|
||||
(fn (d)
|
||||
(cond
|
||||
(not (= (type-of d) "dict")) (er-of-sx d)
|
||||
:else
|
||||
(let ((ks (keys d)) (out (er-mk-nil)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(let ((idx (- (- (len ks) 1) i)))
|
||||
(let ((k (nth ks idx)))
|
||||
(let ((v (get d k)))
|
||||
(set!
|
||||
out
|
||||
(er-mk-cons
|
||||
(er-mk-tuple
|
||||
(list (er-mk-atom k) (er-of-sx-deep v)))
|
||||
out))))))
|
||||
(range 0 (len ks)))
|
||||
out))))
|
||||
|
||||
;; Inverse: handler's proplist response -> SX dict for native send.
|
||||
;; Value rules:
|
||||
;; Erlang binary -> SX string (bytes joined)
|
||||
;; Erlang integer -> SX number passthrough
|
||||
;; Erlang cons of 2-tuples -> nested SX dict (e.g. headers)
|
||||
;; Erlang cons (other shapes) -> SX list via er-to-sx
|
||||
;; anything else -> er-to-sx passthrough
|
||||
|
||||
(define er-proplist-2tuple?
|
||||
(fn (v)
|
||||
(cond
|
||||
(er-nil? v) true
|
||||
(er-cons? v)
|
||||
(let ((h (get v :head)))
|
||||
(cond
|
||||
(and (er-tuple? h) (= (len (get h :elements)) 2))
|
||||
(er-proplist-2tuple? (get v :tail))
|
||||
:else false))
|
||||
:else false)))
|
||||
|
||||
(define er-to-sx-deep
|
||||
(fn (v)
|
||||
(cond
|
||||
(er-binary? v) (list->string (map integer->char (get v :bytes)))
|
||||
(and (er-cons? v) (er-proplist-2tuple? v)) (er-proplist-to-dict v)
|
||||
:else (er-to-sx v))))
|
||||
|
||||
(define er-proplist-to-dict
|
||||
(fn (pl)
|
||||
(let ((d (dict)))
|
||||
(er-proplist-fill! pl d)
|
||||
d)))
|
||||
|
||||
(define er-proplist-fill!
|
||||
(fn (pl d)
|
||||
(cond
|
||||
(er-nil? pl) nil
|
||||
(er-cons? pl)
|
||||
(let ((head (get pl :head)) (tail (get pl :tail)))
|
||||
(cond
|
||||
(and (er-tuple? head) (= (len (get head :elements)) 2))
|
||||
(let ((kv (get head :elements)))
|
||||
(let ((k (nth kv 0)) (v (nth kv 1)))
|
||||
(let ((key-str
|
||||
(cond
|
||||
(er-atom? k) (get k :name)
|
||||
(er-binary? k)
|
||||
(list->string (map integer->char (get k :bytes)))
|
||||
:else (str k))))
|
||||
(dict-set! d key-str (er-to-sx-deep v))
|
||||
(er-proplist-fill! tail d))))
|
||||
:else (er-proplist-fill! tail d)))
|
||||
:else nil)))
|
||||
|
||||
;; Load an Erlang module declaration. Source must start with
|
||||
;; `-module(Name).` and contain function definitions. Functions
|
||||
@@ -1578,26 +1468,9 @@
|
||||
;; entry is keyed by "Module/Name/Arity"; multi-arity BIFs register
|
||||
;; once per arity. Called eagerly at the end of runtime.sx so the
|
||||
;; registry is ready before any erlang-eval-ast call.
|
||||
(define
|
||||
er-bif-http-listen
|
||||
(fn
|
||||
(vs)
|
||||
(let
|
||||
((port (nth vs 0)) (handler (nth vs 1)))
|
||||
(cond
|
||||
(not (= (type-of port) "number"))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
(not (er-fun? handler))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else (let
|
||||
((sx-handler (fn (req-dict) (er-http-resp-to-sx (er-apply-fun handler (list (er-http-req-of-sx req-dict)))))))
|
||||
(http-listen port sx-handler))))))
|
||||
|
||||
;; Register everything at load time.
|
||||
(define
|
||||
er-register-builtin-bifs!
|
||||
(fn
|
||||
()
|
||||
(define er-register-builtin-bifs!
|
||||
(fn ()
|
||||
;; erlang module — type predicates (all pure)
|
||||
(er-register-pure-bif! "erlang" "is_integer" 1 er-bif-is-integer)
|
||||
(er-register-pure-bif! "erlang" "is_atom" 1 er-bif-is-atom)
|
||||
(er-register-pure-bif! "erlang" "is_list" 1 er-bif-is-list)
|
||||
@@ -1606,61 +1479,27 @@
|
||||
(er-register-pure-bif! "erlang" "is_float" 1 er-bif-is-float)
|
||||
(er-register-pure-bif! "erlang" "is_boolean" 1 er-bif-is-boolean)
|
||||
(er-register-pure-bif! "erlang" "is_pid" 1 er-bif-is-pid)
|
||||
(er-register-pure-bif!
|
||||
"erlang"
|
||||
"is_reference"
|
||||
1
|
||||
er-bif-is-reference)
|
||||
(er-register-pure-bif! "erlang" "is_reference" 1 er-bif-is-reference)
|
||||
(er-register-pure-bif! "erlang" "is_binary" 1 er-bif-is-binary)
|
||||
(er-register-pure-bif!
|
||||
"erlang"
|
||||
"is_function"
|
||||
1
|
||||
er-bif-is-function)
|
||||
(er-register-pure-bif!
|
||||
"erlang"
|
||||
"is_function"
|
||||
2
|
||||
er-bif-is-function)
|
||||
(er-register-pure-bif! "erlang" "is_function" 1 er-bif-is-function)
|
||||
(er-register-pure-bif! "erlang" "is_function" 2 er-bif-is-function)
|
||||
;; erlang module — pure data ops
|
||||
(er-register-pure-bif! "erlang" "length" 1 er-bif-length)
|
||||
(er-register-pure-bif! "erlang" "hd" 1 er-bif-hd)
|
||||
(er-register-pure-bif! "erlang" "tl" 1 er-bif-tl)
|
||||
(er-register-pure-bif! "erlang" "element" 2 er-bif-element)
|
||||
(er-register-pure-bif! "erlang" "tuple_size" 1 er-bif-tuple-size)
|
||||
(er-register-pure-bif! "erlang" "byte_size" 1 er-bif-byte-size)
|
||||
(er-register-pure-bif!
|
||||
"erlang"
|
||||
"atom_to_list"
|
||||
1
|
||||
er-bif-atom-to-list)
|
||||
(er-register-pure-bif!
|
||||
"erlang"
|
||||
"list_to_atom"
|
||||
1
|
||||
er-bif-list-to-atom)
|
||||
(er-register-pure-bif! "erlang" "atom_to_list" 1 er-bif-atom-to-list)
|
||||
(er-register-pure-bif! "erlang" "list_to_atom" 1 er-bif-list-to-atom)
|
||||
(er-register-pure-bif! "erlang" "abs" 1 er-bif-abs)
|
||||
(er-register-pure-bif! "erlang" "min" 2 er-bif-min)
|
||||
(er-register-pure-bif! "erlang" "max" 2 er-bif-max)
|
||||
(er-register-pure-bif!
|
||||
"erlang"
|
||||
"tuple_to_list"
|
||||
1
|
||||
er-bif-tuple-to-list)
|
||||
(er-register-pure-bif!
|
||||
"erlang"
|
||||
"list_to_tuple"
|
||||
1
|
||||
er-bif-list-to-tuple)
|
||||
(er-register-pure-bif!
|
||||
"erlang"
|
||||
"integer_to_list"
|
||||
1
|
||||
er-bif-integer-to-list)
|
||||
(er-register-pure-bif!
|
||||
"erlang"
|
||||
"list_to_integer"
|
||||
1
|
||||
er-bif-list-to-integer)
|
||||
(er-register-pure-bif! "erlang" "tuple_to_list" 1 er-bif-tuple-to-list)
|
||||
(er-register-pure-bif! "erlang" "list_to_tuple" 1 er-bif-list-to-tuple)
|
||||
(er-register-pure-bif! "erlang" "integer_to_list" 1 er-bif-integer-to-list)
|
||||
(er-register-pure-bif! "erlang" "list_to_integer" 1 er-bif-list-to-integer)
|
||||
;; erlang module — process / runtime (side-effecting)
|
||||
(er-register-bif! "erlang" "self" 0 er-bif-self)
|
||||
(er-register-bif! "erlang" "spawn" 1 er-bif-spawn)
|
||||
(er-register-bif! "erlang" "spawn" 3 er-bif-spawn)
|
||||
@@ -1676,16 +1515,12 @@
|
||||
(er-register-bif! "erlang" "unregister" 1 er-bif-unregister)
|
||||
(er-register-bif! "erlang" "whereis" 1 er-bif-whereis)
|
||||
(er-register-bif! "erlang" "registered" 0 er-bif-registered)
|
||||
(er-register-bif!
|
||||
"erlang"
|
||||
"throw"
|
||||
1
|
||||
;; erlang module — exception raising (modelled as side-effecting)
|
||||
(er-register-bif! "erlang" "throw" 1
|
||||
(fn (vs) (raise (er-mk-throw-marker (er-bif-arg1 vs "throw")))))
|
||||
(er-register-bif!
|
||||
"erlang"
|
||||
"error"
|
||||
1
|
||||
(er-register-bif! "erlang" "error" 1
|
||||
(fn (vs) (raise (er-mk-error-marker (er-bif-arg1 vs "error")))))
|
||||
;; lists module — all pure
|
||||
(er-register-pure-bif! "lists" "reverse" 1 er-bif-lists-reverse)
|
||||
(er-register-pure-bif! "lists" "map" 2 er-bif-lists-map)
|
||||
(er-register-pure-bif! "lists" "foldl" 3 er-bif-lists-foldl)
|
||||
@@ -1699,13 +1534,11 @@
|
||||
(er-register-pure-bif! "lists" "filter" 2 er-bif-lists-filter)
|
||||
(er-register-pure-bif! "lists" "any" 2 er-bif-lists-any)
|
||||
(er-register-pure-bif! "lists" "all" 2 er-bif-lists-all)
|
||||
(er-register-pure-bif!
|
||||
"lists"
|
||||
"duplicate"
|
||||
2
|
||||
er-bif-lists-duplicate)
|
||||
(er-register-pure-bif! "lists" "duplicate" 2 er-bif-lists-duplicate)
|
||||
;; io module — side-effecting (writes to io buffer)
|
||||
(er-register-bif! "io" "format" 1 er-bif-io-format)
|
||||
(er-register-bif! "io" "format" 2 er-bif-io-format)
|
||||
;; ets module — side-effecting (mutates table state)
|
||||
(er-register-bif! "ets" "new" 2 er-bif-ets-new)
|
||||
(er-register-bif! "ets" "insert" 2 er-bif-ets-insert)
|
||||
(er-register-bif! "ets" "lookup" 2 er-bif-ets-lookup)
|
||||
@@ -1713,88 +1546,82 @@
|
||||
(er-register-bif! "ets" "delete" 2 er-bif-ets-delete)
|
||||
(er-register-bif! "ets" "tab2list" 1 er-bif-ets-tab2list)
|
||||
(er-register-bif! "ets" "info" 2 er-bif-ets-info)
|
||||
;; code module — side-effecting (mutates module registry, kills procs)
|
||||
(er-register-bif! "code" "load_binary" 3 er-bif-code-load-binary)
|
||||
(er-register-bif! "code" "purge" 1 er-bif-code-purge)
|
||||
(er-register-bif! "code" "soft_purge" 1 er-bif-code-soft-purge)
|
||||
(er-register-bif! "code" "which" 1 er-bif-code-which)
|
||||
(er-register-bif! "code" "is_loaded" 1 er-bif-code-is-loaded)
|
||||
(er-register-bif! "code" "all_loaded" 0 er-bif-code-all-loaded)
|
||||
;; file module
|
||||
(er-register-bif! "file" "read_file" 1 er-bif-file-read-file)
|
||||
(er-register-bif! "file" "write_file" 2 er-bif-file-write-file)
|
||||
(er-register-bif! "file" "delete" 1 er-bif-file-delete)
|
||||
;; Phase 8 FFI — host-primitive BIFs (loops/fed-prims)
|
||||
(er-register-pure-bif! "crypto" "hash" 2 er-bif-crypto-hash)
|
||||
(er-register-pure-bif! "cid" "from_bytes" 1 er-bif-cid-from-bytes)
|
||||
(er-register-pure-bif! "cid" "to_string" 1 er-bif-cid-to-string)
|
||||
(define
|
||||
er-bif-binary-to-list
|
||||
(fn
|
||||
(vs)
|
||||
(let
|
||||
((v (nth vs 0)))
|
||||
(cond
|
||||
(not (er-binary? v))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else (let
|
||||
((bs (get v :bytes)) (out (er-mk-nil)))
|
||||
(for-each
|
||||
(fn
|
||||
(i)
|
||||
(set!
|
||||
out
|
||||
(er-mk-cons (nth bs (- (- (len bs) 1) i)) out)))
|
||||
(range 0 (len bs)))
|
||||
out)))))
|
||||
(define
|
||||
er-iolist-walk!
|
||||
(fn
|
||||
(v acc fail)
|
||||
(cond
|
||||
(nth fail 0)
|
||||
nil
|
||||
(er-nil? v)
|
||||
nil
|
||||
(er-cons? v)
|
||||
(do
|
||||
(er-iolist-walk! (get v :head) acc fail)
|
||||
|
||||
;; ── binary_to_list / list_to_binary (Step 3b — term codec) ──────
|
||||
;; Standard Erlang semantics:
|
||||
;; binary_to_list(<<B1,B2,...>>) -> [B1, B2, ...] (Erlang cons of ints)
|
||||
;; list_to_binary(IoList) -> <<...>> (flattens nested
|
||||
;; iolists; elements are byte ints 0-255 or binaries)
|
||||
;; Bad arg / out-of-range byte / non-iolist element -> error:badarg.
|
||||
|
||||
(define er-bif-binary-to-list
|
||||
(fn (vs)
|
||||
(let ((v (nth vs 0)))
|
||||
(cond
|
||||
(not (er-binary? v))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else
|
||||
(let ((bs (get v :bytes)) (out (er-mk-nil)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(set! out (er-mk-cons (nth bs (- (- (len bs) 1) i)) out)))
|
||||
(range 0 (len bs)))
|
||||
out)))))
|
||||
|
||||
;; Walk an Erlang iolist, appending bytes to `acc` (a mutable SX list).
|
||||
;; Accepts: nil, cons-of-X, binary, integer in 0..255. Anything else
|
||||
;; signals failure by setting (nth fail 0) to true.
|
||||
(define er-iolist-walk!
|
||||
(fn (v acc fail)
|
||||
(cond
|
||||
(nth fail 0) nil
|
||||
(er-nil? v) nil
|
||||
(er-cons? v)
|
||||
(do (er-iolist-walk! (get v :head) acc fail)
|
||||
(er-iolist-walk! (get v :tail) acc fail))
|
||||
(er-binary? v)
|
||||
(for-each
|
||||
(fn (i) (append! acc (nth (get v :bytes) i)))
|
||||
(range 0 (len (get v :bytes))))
|
||||
(= (type-of v) "number")
|
||||
(cond
|
||||
(and (>= v 0) (<= v 255))
|
||||
(append! acc v)
|
||||
:else (set-nth! fail 0 true))
|
||||
:else (set-nth! fail 0 true))))
|
||||
(define
|
||||
er-bif-list-to-binary
|
||||
(fn
|
||||
(vs)
|
||||
(let
|
||||
((v (nth vs 0)) (acc (list)) (fail (list false)))
|
||||
(cond
|
||||
(not (or (er-nil? v) (er-cons? v) (er-binary? v)))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else (do
|
||||
(er-iolist-walk! v acc fail)
|
||||
(cond
|
||||
(nth fail 0)
|
||||
(er-binary? v)
|
||||
(for-each
|
||||
(fn (i) (append! acc (nth (get v :bytes) i)))
|
||||
(range 0 (len (get v :bytes))))
|
||||
(= (type-of v) "number")
|
||||
(cond
|
||||
(and (>= v 0) (<= v 255)) (append! acc v)
|
||||
:else (set-nth! fail 0 true))
|
||||
:else (set-nth! fail 0 true))))
|
||||
|
||||
(define er-bif-list-to-binary
|
||||
(fn (vs)
|
||||
(let ((v (nth vs 0)) (acc (list)) (fail (list false)))
|
||||
(cond
|
||||
(not (or (er-nil? v) (er-cons? v) (er-binary? v)))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else
|
||||
(do
|
||||
(er-iolist-walk! v acc fail)
|
||||
(cond
|
||||
(nth fail 0)
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else (er-mk-binary acc)))))))
|
||||
:else (er-mk-binary acc)))))))
|
||||
|
||||
(er-register-bif! "file" "list_dir" 1 er-bif-file-list-dir)
|
||||
(er-register-pure-bif!
|
||||
"erlang"
|
||||
"binary_to_list"
|
||||
1
|
||||
er-bif-binary-to-list)
|
||||
(er-register-pure-bif!
|
||||
"erlang"
|
||||
"list_to_binary"
|
||||
1
|
||||
er-bif-list-to-binary)
|
||||
(er-register-pure-bif! "erlang" "binary_to_list" 1 er-bif-binary-to-list)
|
||||
(er-register-pure-bif! "erlang" "list_to_binary" 1 er-bif-list-to-binary)
|
||||
(er-mk-atom "ok")))
|
||||
|
||||
(er-register-bif! "http" "listen" 2 er-bif-http-listen)
|
||||
|
||||
;; Register everything at load time.
|
||||
(er-register-builtin-bifs!)
|
||||
|
||||
141
lib/go/conformance.sh
Executable file
141
lib/go/conformance.sh
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# Go-on-SX conformance runner.
|
||||
#
|
||||
# Loads every Go-on-SX test suite via the epoch protocol, collects
|
||||
# pass/fail counts, and writes lib/go/scoreboard.json + .md.
|
||||
#
|
||||
# Usage:
|
||||
# bash lib/go/conformance.sh # run all suites
|
||||
# bash lib/go/conformance.sh -v # verbose per-suite
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}"
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe"
|
||||
fi
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
echo "ERROR: sx_server.exe not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERBOSE="${1:-}"
|
||||
TMPFILE=$(mktemp)
|
||||
OUTFILE=$(mktemp)
|
||||
trap "rm -f $TMPFILE $OUTFILE" EXIT
|
||||
|
||||
# Each suite: name | pass-counter | total-counter
|
||||
SUITES=(
|
||||
"lex|go-test-pass|go-test-count"
|
||||
"parse|go-parse-test-pass|go-parse-test-count"
|
||||
"types|go-types-test-pass|go-types-test-count"
|
||||
"eval|go-eval-test-pass|go-eval-test-count"
|
||||
"runtime|go-rt-test-pass|go-rt-test-count"
|
||||
"stdlib|go-std-test-pass|go-std-test-count"
|
||||
"e2e|go-e2e-test-pass|go-e2e-test-count"
|
||||
)
|
||||
|
||||
cat > "$TMPFILE" <<'EPOCHS'
|
||||
(epoch 1)
|
||||
(load "lib/guest/lex.sx")
|
||||
(load "lib/guest/ast.sx")
|
||||
(load "lib/guest/pratt.sx")
|
||||
(load "lib/go/lex.sx")
|
||||
(load "lib/go/parse.sx")
|
||||
(load "lib/go/types.sx")
|
||||
(load "lib/go/sched.sx")
|
||||
(load "lib/go/eval.sx")
|
||||
(load "lib/go/std/strings.sx")
|
||||
(load "lib/go/std/strconv.sx")
|
||||
(load "lib/go/tests/lex.sx")
|
||||
(load "lib/go/tests/parse.sx")
|
||||
(load "lib/go/tests/types.sx")
|
||||
(load "lib/go/tests/eval.sx")
|
||||
(load "lib/go/tests/runtime.sx")
|
||||
(load "lib/go/tests/stdlib.sx")
|
||||
(load "lib/go/tests/e2e.sx")
|
||||
EPOCHS
|
||||
|
||||
idx=0
|
||||
for entry in "${SUITES[@]}"; do
|
||||
name="${entry%%|*}"
|
||||
pass_var=$(echo "$entry" | awk -F'|' '{print $2}')
|
||||
total_var=$(echo "$entry" | awk -F'|' '{print $3}')
|
||||
epoch=$((100 + idx))
|
||||
echo "(epoch $epoch)" >> "$TMPFILE"
|
||||
echo "(eval \"(list $pass_var $total_var)\")" >> "$TMPFILE"
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
|
||||
"$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
||||
|
||||
parse_pair() {
|
||||
local epoch="$1"
|
||||
local line
|
||||
line=$(grep -A1 "^(ok-len $epoch " "$OUTFILE" | tail -1)
|
||||
echo "$line" | sed -E 's/[()]//g'
|
||||
}
|
||||
|
||||
TOTAL_PASS=0
|
||||
TOTAL_COUNT=0
|
||||
JSON_SUITES=""
|
||||
MD_ROWS=""
|
||||
|
||||
idx=0
|
||||
for entry in "${SUITES[@]}"; do
|
||||
name="${entry%%|*}"
|
||||
epoch=$((100 + idx))
|
||||
pair=$(parse_pair "$epoch")
|
||||
pass=$(echo "$pair" | awk '{print $1}')
|
||||
count=$(echo "$pair" | awk '{print $2}')
|
||||
if [ -z "$pass" ] || [ -z "$count" ]; then
|
||||
pass=0
|
||||
count=0
|
||||
fi
|
||||
TOTAL_PASS=$((TOTAL_PASS + pass))
|
||||
TOTAL_COUNT=$((TOTAL_COUNT + count))
|
||||
status="ok"
|
||||
marker="✅"
|
||||
if [ "$pass" != "$count" ]; then
|
||||
status="fail"
|
||||
marker="❌"
|
||||
fi
|
||||
if [ "$VERBOSE" = "-v" ]; then
|
||||
printf " %-12s %s/%s\n" "$name" "$pass" "$count"
|
||||
fi
|
||||
if [ -n "$JSON_SUITES" ]; then JSON_SUITES+=","; fi
|
||||
JSON_SUITES+=$'\n '
|
||||
JSON_SUITES+="{\"name\":\"$name\",\"pass\":$pass,\"total\":$count,\"status\":\"$status\"}"
|
||||
MD_ROWS+="| $marker | $name | $pass | $count |"$'\n'
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
|
||||
printf '\nGo-on-SX conformance: %d / %d\n' "$TOTAL_PASS" "$TOTAL_COUNT"
|
||||
|
||||
cat > lib/go/scoreboard.json <<JSON
|
||||
{
|
||||
"language": "go",
|
||||
"total_pass": $TOTAL_PASS,
|
||||
"total": $TOTAL_COUNT,
|
||||
"suites": [$JSON_SUITES]
|
||||
}
|
||||
JSON
|
||||
|
||||
cat > lib/go/scoreboard.md <<MD
|
||||
# Go-on-SX Scoreboard
|
||||
|
||||
**Total: ${TOTAL_PASS} / ${TOTAL_COUNT} tests passing**
|
||||
|
||||
| | Suite | Pass | Total |
|
||||
|---|---|---|---|
|
||||
$MD_ROWS
|
||||
|
||||
Generated by \`lib/go/conformance.sh\`.
|
||||
MD
|
||||
|
||||
if [ "$TOTAL_PASS" -eq "$TOTAL_COUNT" ]; then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
1539
lib/go/eval.sx
Normal file
1539
lib/go/eval.sx
Normal file
File diff suppressed because it is too large
Load Diff
476
lib/go/lex.sx
Normal file
476
lib/go/lex.sx
Normal file
@@ -0,0 +1,476 @@
|
||||
;; lib/go/lex.sx — Go tokenizer with automatic semicolon insertion.
|
||||
;;
|
||||
;; Consumes lib/guest/lex.sx character-class predicates.
|
||||
;;
|
||||
;; Tokens: {:type T :value V :pos P}
|
||||
;; Types:
|
||||
;; "ident" — identifiers (foo, _bar, mixedCase)
|
||||
;; "keyword" — one of the 25 Go keywords
|
||||
;; "int" — integer literals (decimal, 0x.. hex, 0b.. binary, 0o.. octal,
|
||||
;; legacy 0123 octal; underscores between digits allowed)
|
||||
;; "float" — decimal float literals (3.14, .5, 1., 1e10, 1.5e-3, 1E5)
|
||||
;; "imag" — imaginary literals (2i, 3.14i, 1e2i)
|
||||
;; "string" — interpreted string literals "..." OR raw string literals `...`
|
||||
;; "rune" — rune literals 'x' (single char + simple escapes)
|
||||
;; "op" — operators & punctuation; :value is the literal text
|
||||
;; "semi" — explicit ';' or auto-inserted (Go spec § Semicolons)
|
||||
;; "eof" — end-of-input sentinel
|
||||
;;
|
||||
;; ASI (Go spec § Semicolons): a newline (or EOF, or a block comment
|
||||
;; containing a newline) emits a ";semi" if the previous emitted token's
|
||||
;; type is ident/int/float/imag/string/rune, or its value is one of
|
||||
;; {break, continue, fallthrough, return, ++, --, ), ], }}.
|
||||
;;
|
||||
;; All scanner locals are gl- prefixed: SX host primitives (peek/emit/etc.)
|
||||
;; silently shadow guest-language defines. See feedback_sx_bind_clash.
|
||||
|
||||
(define
|
||||
go-keywords
|
||||
(list
|
||||
"break"
|
||||
"case"
|
||||
"chan"
|
||||
"const"
|
||||
"continue"
|
||||
"default"
|
||||
"defer"
|
||||
"else"
|
||||
"fallthrough"
|
||||
"for"
|
||||
"func"
|
||||
"go"
|
||||
"goto"
|
||||
"if"
|
||||
"import"
|
||||
"interface"
|
||||
"map"
|
||||
"package"
|
||||
"range"
|
||||
"return"
|
||||
"select"
|
||||
"struct"
|
||||
"switch"
|
||||
"type"
|
||||
"var"))
|
||||
|
||||
(define go-keyword? (fn (s) (some (fn (k) (= k s)) go-keywords)))
|
||||
|
||||
(define go-asi-keywords (list "break" "continue" "fallthrough" "return"))
|
||||
|
||||
(define go-asi-ops (list "++" "--" ")" "]" "}"))
|
||||
|
||||
(define go-asi-lit-types (list "ident" "int" "float" "imag" "string" "rune"))
|
||||
|
||||
(define
|
||||
go-asi-trigger?
|
||||
(fn
|
||||
(tok)
|
||||
(if
|
||||
(= tok nil)
|
||||
false
|
||||
(let
|
||||
((ty (get tok :type)) (v (get tok :value)))
|
||||
(or
|
||||
(some (fn (lt) (= lt ty)) go-asi-lit-types)
|
||||
(and (= ty "keyword") (some (fn (k) (= k v)) go-asi-keywords))
|
||||
(and (= ty "op") (some (fn (o) (= o v)) go-asi-ops)))))))
|
||||
|
||||
(define
|
||||
go-tokenize
|
||||
(fn
|
||||
(src)
|
||||
(let
|
||||
((tokens (list)) (pos 0) (src-len (len src)))
|
||||
(define
|
||||
gl-peek
|
||||
(fn
|
||||
(offset)
|
||||
(if (< (+ pos offset) src-len) (nth src (+ pos offset)) nil)))
|
||||
(define gl-cur (fn () (gl-peek 0)))
|
||||
(define gl-advance! (fn (n) (set! pos (+ pos n))))
|
||||
(define
|
||||
gl-last
|
||||
(fn
|
||||
()
|
||||
(if
|
||||
(= (len tokens) 0)
|
||||
nil
|
||||
(nth tokens (- (len tokens) 1)))))
|
||||
(define gl-emit! (fn (type value start) (append! tokens {:type type :value value :pos start})))
|
||||
(define
|
||||
gl-maybe-asi!
|
||||
(fn
|
||||
(at)
|
||||
(when (go-asi-trigger? (gl-last)) (gl-emit! "semi" "\n" at))))
|
||||
(define
|
||||
gl-oct-digit?
|
||||
(fn (c) (and (not (= c nil)) (>= c "0") (<= c "7"))))
|
||||
(define gl-bin-digit? (fn (c) (or (= c "0") (= c "1"))))
|
||||
(define
|
||||
gl-skip-line!
|
||||
(fn
|
||||
()
|
||||
(when
|
||||
(and (< pos src-len) (not (= (gl-cur) "\n")))
|
||||
(gl-advance! 1)
|
||||
(gl-skip-line!))))
|
||||
(define
|
||||
gl-skip-block!
|
||||
(fn
|
||||
(saw-nl)
|
||||
(cond
|
||||
(>= pos src-len)
|
||||
saw-nl
|
||||
(and (= (gl-cur) "*") (= (gl-peek 1) "/"))
|
||||
(do (gl-advance! 2) saw-nl)
|
||||
:else (let
|
||||
((is-nl (= (gl-cur) "\n")))
|
||||
(gl-advance! 1)
|
||||
(gl-skip-block! (or saw-nl is-nl))))))
|
||||
(define
|
||||
gl-read-ident!
|
||||
(fn
|
||||
(start)
|
||||
(when
|
||||
(and (< pos src-len) (lex-ident-char? (gl-cur)))
|
||||
(gl-advance! 1)
|
||||
(gl-read-ident! start))
|
||||
(slice src start pos)))
|
||||
(define
|
||||
gl-read-digit-run!
|
||||
(fn
|
||||
(digit?)
|
||||
(when
|
||||
(and (< pos src-len) (or (digit? (gl-cur)) (= (gl-cur) "_")))
|
||||
(gl-advance! 1)
|
||||
(gl-read-digit-run! digit?))))
|
||||
(define
|
||||
gl-finish-number!
|
||||
(fn
|
||||
(has-fraction?)
|
||||
(let
|
||||
((typ (if has-fraction? "float" "int")))
|
||||
(when
|
||||
(or (= (gl-cur) "e") (= (gl-cur) "E"))
|
||||
(gl-advance! 1)
|
||||
(when
|
||||
(or (= (gl-cur) "+") (= (gl-cur) "-"))
|
||||
(gl-advance! 1))
|
||||
(gl-read-digit-run! lex-digit?)
|
||||
(set! typ "float"))
|
||||
(cond
|
||||
(= (gl-cur) "i")
|
||||
(do (gl-advance! 1) "imag")
|
||||
:else typ))))
|
||||
(define
|
||||
gl-read-number!
|
||||
(fn
|
||||
()
|
||||
(cond
|
||||
(and (= (gl-cur) ".") (lex-digit? (gl-peek 1)))
|
||||
(do
|
||||
(gl-advance! 1)
|
||||
(gl-read-digit-run! lex-digit?)
|
||||
(gl-finish-number! true))
|
||||
(and
|
||||
(= (gl-cur) "0")
|
||||
(or
|
||||
(= (gl-peek 1) "x")
|
||||
(= (gl-peek 1) "X")))
|
||||
(do
|
||||
(gl-advance! 2)
|
||||
(gl-read-digit-run! lex-hex-digit?)
|
||||
"int")
|
||||
(and
|
||||
(= (gl-cur) "0")
|
||||
(or
|
||||
(= (gl-peek 1) "b")
|
||||
(= (gl-peek 1) "B")))
|
||||
(do
|
||||
(gl-advance! 2)
|
||||
(gl-read-digit-run! gl-bin-digit?)
|
||||
"int")
|
||||
(and
|
||||
(= (gl-cur) "0")
|
||||
(or
|
||||
(= (gl-peek 1) "o")
|
||||
(= (gl-peek 1) "O")))
|
||||
(do
|
||||
(gl-advance! 2)
|
||||
(gl-read-digit-run! gl-oct-digit?)
|
||||
"int")
|
||||
:else (do
|
||||
(gl-read-digit-run! lex-digit?)
|
||||
(cond
|
||||
(and (= (gl-cur) ".") (not (= (gl-peek 1) ".")))
|
||||
(do
|
||||
(gl-advance! 1)
|
||||
(gl-read-digit-run! lex-digit?)
|
||||
(gl-finish-number! true))
|
||||
:else (gl-finish-number! false))))))
|
||||
(define
|
||||
gl-read-string!
|
||||
(fn
|
||||
()
|
||||
(gl-advance! 1)
|
||||
(let
|
||||
((chars (list)))
|
||||
(define
|
||||
gl-string-loop
|
||||
(fn
|
||||
()
|
||||
(cond
|
||||
(>= pos src-len)
|
||||
nil
|
||||
(= (gl-cur) "\"")
|
||||
(gl-advance! 1)
|
||||
(= (gl-cur) "\\")
|
||||
(do
|
||||
(gl-advance! 1)
|
||||
(when
|
||||
(< pos src-len)
|
||||
(let
|
||||
((ch (gl-cur)))
|
||||
(cond
|
||||
(= ch "n")
|
||||
(append! chars "\n")
|
||||
(= ch "t")
|
||||
(append! chars "\t")
|
||||
(= ch "r")
|
||||
(append! chars "\r")
|
||||
(= ch "\\")
|
||||
(append! chars "\\")
|
||||
(= ch "\"")
|
||||
(append! chars "\"")
|
||||
(= ch "'")
|
||||
(append! chars "'")
|
||||
:else (append! chars ch))
|
||||
(gl-advance! 1)))
|
||||
(gl-string-loop))
|
||||
:else (do
|
||||
(append! chars (gl-cur))
|
||||
(gl-advance! 1)
|
||||
(gl-string-loop)))))
|
||||
(gl-string-loop)
|
||||
(join "" chars))))
|
||||
(define
|
||||
gl-read-raw-string!
|
||||
(fn
|
||||
()
|
||||
(gl-advance! 1)
|
||||
(let
|
||||
((chars (list)))
|
||||
(define
|
||||
gl-raw-loop
|
||||
(fn
|
||||
()
|
||||
(cond
|
||||
(>= pos src-len)
|
||||
nil
|
||||
(= (gl-cur) "`")
|
||||
(gl-advance! 1)
|
||||
(= (gl-cur) "\r")
|
||||
(do (gl-advance! 1) (gl-raw-loop))
|
||||
:else (do
|
||||
(append! chars (gl-cur))
|
||||
(gl-advance! 1)
|
||||
(gl-raw-loop)))))
|
||||
(gl-raw-loop)
|
||||
(join "" chars))))
|
||||
(define
|
||||
gl-read-rune!
|
||||
(fn
|
||||
()
|
||||
(gl-advance! 1)
|
||||
(let
|
||||
((chars (list)))
|
||||
(cond
|
||||
(and (< pos src-len) (= (gl-cur) "\\"))
|
||||
(do
|
||||
(gl-advance! 1)
|
||||
(when
|
||||
(< pos src-len)
|
||||
(let
|
||||
((ch (gl-cur)))
|
||||
(cond
|
||||
(= ch "n")
|
||||
(append! chars "\n")
|
||||
(= ch "t")
|
||||
(append! chars "\t")
|
||||
(= ch "r")
|
||||
(append! chars "\r")
|
||||
(= ch "\\")
|
||||
(append! chars "\\")
|
||||
(= ch "'")
|
||||
(append! chars "'")
|
||||
(= ch "\"")
|
||||
(append! chars "\"")
|
||||
:else (append! chars ch))
|
||||
(gl-advance! 1))))
|
||||
(< pos src-len)
|
||||
(do (append! chars (gl-cur)) (gl-advance! 1)))
|
||||
(when
|
||||
(and (< pos src-len) (= (gl-cur) "'"))
|
||||
(gl-advance! 1))
|
||||
(join "" chars))))
|
||||
(define
|
||||
gl-match-op
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((c0 (gl-cur))
|
||||
(c1 (gl-peek 1))
|
||||
(c2 (gl-peek 2)))
|
||||
(cond
|
||||
(and (= c0 "<") (= c1 "<") (= c2 "="))
|
||||
"<<="
|
||||
(and (= c0 ">") (= c1 ">") (= c2 "="))
|
||||
">>="
|
||||
(and (= c0 "&") (= c1 "^") (= c2 "="))
|
||||
"&^="
|
||||
(and (= c0 ".") (= c1 ".") (= c2 "."))
|
||||
"..."
|
||||
(and (= c0 "=") (= c1 "="))
|
||||
"=="
|
||||
(and (= c0 "!") (= c1 "="))
|
||||
"!="
|
||||
(and (= c0 "<") (= c1 "="))
|
||||
"<="
|
||||
(and (= c0 ">") (= c1 "="))
|
||||
">="
|
||||
(and (= c0 "&") (= c1 "&"))
|
||||
"&&"
|
||||
(and (= c0 "|") (= c1 "|"))
|
||||
"||"
|
||||
(and (= c0 "+") (= c1 "+"))
|
||||
"++"
|
||||
(and (= c0 "-") (= c1 "-"))
|
||||
"--"
|
||||
(and (= c0 "<") (= c1 "<"))
|
||||
"<<"
|
||||
(and (= c0 ">") (= c1 ">"))
|
||||
">>"
|
||||
(and (= c0 "+") (= c1 "="))
|
||||
"+="
|
||||
(and (= c0 "-") (= c1 "="))
|
||||
"-="
|
||||
(and (= c0 "*") (= c1 "="))
|
||||
"*="
|
||||
(and (= c0 "/") (= c1 "="))
|
||||
"/="
|
||||
(and (= c0 "%") (= c1 "="))
|
||||
"%="
|
||||
(and (= c0 "&") (= c1 "="))
|
||||
"&="
|
||||
(and (= c0 "|") (= c1 "="))
|
||||
"|="
|
||||
(and (= c0 "^") (= c1 "="))
|
||||
"^="
|
||||
(and (= c0 ":") (= c1 "="))
|
||||
":="
|
||||
(and (= c0 "<") (= c1 "-"))
|
||||
"<-"
|
||||
(and (= c0 "&") (= c1 "^"))
|
||||
"&^"
|
||||
(or
|
||||
(= c0 "+")
|
||||
(= c0 "-")
|
||||
(= c0 "*")
|
||||
(= c0 "/")
|
||||
(= c0 "%")
|
||||
(= c0 "&")
|
||||
(= c0 "|")
|
||||
(= c0 "^")
|
||||
(= c0 "<")
|
||||
(= c0 ">")
|
||||
(= c0 "=")
|
||||
(= c0 "!")
|
||||
(= c0 "(")
|
||||
(= c0 ")")
|
||||
(= c0 "{")
|
||||
(= c0 "}")
|
||||
(= c0 "[")
|
||||
(= c0 "]")
|
||||
(= c0 ",")
|
||||
(= c0 ".")
|
||||
(= c0 ":")
|
||||
(= c0 "~"))
|
||||
c0
|
||||
:else nil))))
|
||||
(define
|
||||
gl-scan!
|
||||
(fn
|
||||
()
|
||||
(cond
|
||||
(>= pos src-len)
|
||||
nil
|
||||
(= (gl-cur) "\n")
|
||||
(do (gl-maybe-asi! pos) (gl-advance! 1) (gl-scan!))
|
||||
(lex-space? (gl-cur))
|
||||
(do (gl-advance! 1) (gl-scan!))
|
||||
(and (= (gl-cur) "/") (= (gl-peek 1) "/"))
|
||||
(do (gl-advance! 2) (gl-skip-line!) (gl-scan!))
|
||||
(and (= (gl-cur) "/") (= (gl-peek 1) "*"))
|
||||
(do
|
||||
(gl-advance! 2)
|
||||
(let
|
||||
((saw-nl (gl-skip-block! false)))
|
||||
(when saw-nl (gl-maybe-asi! pos)))
|
||||
(gl-scan!))
|
||||
(= (gl-cur) ";")
|
||||
(do
|
||||
(gl-emit! "semi" ";" pos)
|
||||
(gl-advance! 1)
|
||||
(gl-scan!))
|
||||
(lex-ident-start? (gl-cur))
|
||||
(do
|
||||
(let
|
||||
((start pos))
|
||||
(gl-read-ident! start)
|
||||
(let
|
||||
((word (slice src start pos)))
|
||||
(gl-emit!
|
||||
(if (go-keyword? word) "keyword" "ident")
|
||||
word
|
||||
start)))
|
||||
(gl-scan!))
|
||||
(lex-digit? (gl-cur))
|
||||
(do
|
||||
(let
|
||||
((start pos) (typ (gl-read-number!)))
|
||||
(gl-emit! typ (slice src start pos) start))
|
||||
(gl-scan!))
|
||||
(and (= (gl-cur) ".") (lex-digit? (gl-peek 1)))
|
||||
(do
|
||||
(let
|
||||
((start pos) (typ (gl-read-number!)))
|
||||
(gl-emit! typ (slice src start pos) start))
|
||||
(gl-scan!))
|
||||
(= (gl-cur) "\"")
|
||||
(let
|
||||
((start pos) (v (gl-read-string!)))
|
||||
(gl-emit! "string" v start)
|
||||
(gl-scan!))
|
||||
(= (gl-cur) "`")
|
||||
(let
|
||||
((start pos) (v (gl-read-raw-string!)))
|
||||
(gl-emit! "string" v start)
|
||||
(gl-scan!))
|
||||
(= (gl-cur) "'")
|
||||
(let
|
||||
((start pos) (v (gl-read-rune!)))
|
||||
(gl-emit! "rune" v start)
|
||||
(gl-scan!))
|
||||
:else (let
|
||||
((op (gl-match-op)))
|
||||
(cond
|
||||
op
|
||||
(do
|
||||
(gl-emit! "op" op pos)
|
||||
(gl-advance! (len op))
|
||||
(gl-scan!))
|
||||
:else (do (gl-advance! 1) (gl-scan!)))))))
|
||||
(gl-scan!)
|
||||
(gl-maybe-asi! pos)
|
||||
(gl-emit! "eof" nil pos)
|
||||
tokens)))
|
||||
1262
lib/go/parse.sx
Normal file
1262
lib/go/parse.sx
Normal file
File diff suppressed because it is too large
Load Diff
66
lib/go/sched.sx
Normal file
66
lib/go/sched.sx
Normal file
@@ -0,0 +1,66 @@
|
||||
;; lib/go/sched.sx — Go scheduler primitives: channels + goroutines.
|
||||
;;
|
||||
;; This is **the independent implementation** referenced by
|
||||
;; plans/lib-guest-scheduler.md. The shape that emerges here informs
|
||||
;; the eventual sister kit; this file's structures are the Phase 5
|
||||
;; "first-consumer" cut.
|
||||
;;
|
||||
;; v0 concurrency model — IMPORTANT
|
||||
;;
|
||||
;; SX has no first-class continuations exposed to guest code, so we
|
||||
;; can't suspend a goroutine mid-statement. v0 runs `go f()` SYNCHRO-
|
||||
;; NOUSLY (it's an immediate call whose return value is dropped). This
|
||||
;; preserves the right semantics for patterns where the spawned
|
||||
;; goroutine simply pushes to a channel that the main goroutine then
|
||||
;; receives — because the spawned goroutine runs to completion first
|
||||
;; and leaves the value in the channel buffer.
|
||||
;;
|
||||
;; True preemption with blocking sends/recvs is a Phase 5b refinement.
|
||||
;; The sister-plan diary tracks the design insight (single
|
||||
;; sched-spawn primitive, channel-op direction tag) so the eventual
|
||||
;; kit doesn't bake in v0's synchronous limitation.
|
||||
;;
|
||||
;; Channel representation
|
||||
;;
|
||||
;; (list :go-chan ACCESSORS-FN-LIST)
|
||||
;;
|
||||
;; ACCESSORS-FN-LIST is a list of closures sharing a mutable buffer
|
||||
;; and a closed flag. The closures expose:
|
||||
;; index 1: send-fn — (lambda (val) ...)
|
||||
;; index 2: recv-fn — (lambda () val-or-:empty)
|
||||
;; index 3: closed?-fn — (lambda () bool)
|
||||
;; index 4: close!-fn — (lambda () ...)
|
||||
;;
|
||||
;; Channel identity: distinct calls to go-make-chan produce closures
|
||||
;; with distinct identity — `(= ch1 ch2)` is false for distinct
|
||||
;; channels, matching Go spec § Channel types.
|
||||
|
||||
(define
|
||||
go-make-chan
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((buf (list)) (closed false))
|
||||
(list
|
||||
:go-chan (fn (v) (append! buf v) nil)
|
||||
(fn
|
||||
()
|
||||
(cond
|
||||
(= (len buf) 0)
|
||||
:empty :else
|
||||
(let ((v (first buf))) (set! buf (rest buf)) v)))
|
||||
(fn () closed)
|
||||
(fn () (set! closed true) nil)
|
||||
(fn () (len buf))))))
|
||||
|
||||
(define
|
||||
go-chan?
|
||||
(fn
|
||||
(v)
|
||||
(and (list? v) (not (= (len v) 0)) (= (first v) :go-chan))))
|
||||
|
||||
(define go-chan-send! (fn (ch val) ((nth ch 1) val)))
|
||||
(define go-chan-recv! (fn (ch) ((nth ch 2))))
|
||||
(define go-chan-closed? (fn (ch) ((nth ch 3))))
|
||||
(define go-chan-close! (fn (ch) ((nth ch 4))))
|
||||
(define go-chan-len (fn (ch) ((nth ch 5))))
|
||||
13
lib/go/scoreboard.json
Normal file
13
lib/go/scoreboard.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"language": "go",
|
||||
"total_pass": 609,
|
||||
"total": 609,
|
||||
"suites": [
|
||||
{"name":"lex","pass":129,"total":129,"status":"ok"},
|
||||
{"name":"parse","pass":179,"total":179,"status":"ok"},
|
||||
{"name":"types","pass":102,"total":102,"status":"ok"},
|
||||
{"name":"eval","pass":106,"total":106,"status":"ok"},
|
||||
{"name":"runtime","pass":40,"total":40,"status":"ok"},
|
||||
{"name":"stdlib","pass":41,"total":41,"status":"ok"},
|
||||
{"name":"e2e","pass":12,"total":12,"status":"ok"}]
|
||||
}
|
||||
16
lib/go/scoreboard.md
Normal file
16
lib/go/scoreboard.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Go-on-SX Scoreboard
|
||||
|
||||
**Total: 609 / 609 tests passing**
|
||||
|
||||
| | Suite | Pass | Total |
|
||||
|---|---|---|---|
|
||||
| ✅ | lex | 129 | 129 |
|
||||
| ✅ | parse | 179 | 179 |
|
||||
| ✅ | types | 102 | 102 |
|
||||
| ✅ | eval | 106 | 106 |
|
||||
| ✅ | runtime | 40 | 40 |
|
||||
| ✅ | stdlib | 41 | 41 |
|
||||
| ✅ | e2e | 12 | 12 |
|
||||
|
||||
|
||||
Generated by `lib/go/conformance.sh`.
|
||||
71
lib/go/std/strconv.sx
Normal file
71
lib/go/std/strconv.sx
Normal file
@@ -0,0 +1,71 @@
|
||||
;; lib/go/std/strconv.sx — Go's `strconv` package, v0 subset.
|
||||
|
||||
(define
|
||||
go-strconv-itoa
|
||||
;; Itoa(n) → string. Real Go returns the decimal representation.
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 1))
|
||||
(list :eval-error :strconv-itoa-arity (len args))
|
||||
:else
|
||||
(let ((n (first args)))
|
||||
(cond
|
||||
(not (number? n)) (list :eval-error :strconv-itoa-not-number n)
|
||||
:else (str n))))))
|
||||
|
||||
(define
|
||||
go-strconv-atoi
|
||||
;; Atoi(s) → (int, error). v0 returns just the int on success or
|
||||
;; an :eval-error on failure (multi-return is a later refinement).
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 1))
|
||||
(list :eval-error :strconv-atoi-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strconv-atoi-not-string s)
|
||||
(= (len s) 0) (list :eval-error :strconv-atoi-empty)
|
||||
:else (go-strconv-parse-int s 0 (= (nth s 0) "-") 0))))))
|
||||
|
||||
(define
|
||||
go-strconv-parse-int
|
||||
;; Parse a (possibly signed) base-10 integer literal. Stops on the
|
||||
;; first non-digit char and returns the parsed prefix, or :eval-error
|
||||
;; if no digits were consumed.
|
||||
(fn (s start neg acc)
|
||||
(let ((i (cond (= start 0) (cond neg 1 :else 0) :else start)))
|
||||
(cond
|
||||
(>= i (len s))
|
||||
(cond
|
||||
(= (cond neg (- i 1) :else i) 0)
|
||||
(list :eval-error :strconv-atoi-no-digits s)
|
||||
:else
|
||||
(cond neg (- 0 acc) :else acc))
|
||||
:else
|
||||
(let ((d (go-strconv-digit (nth s i))))
|
||||
(cond
|
||||
(< d 0)
|
||||
(cond
|
||||
(= (cond neg (- i 1) :else i) 0)
|
||||
(list :eval-error :strconv-atoi-no-digits s)
|
||||
:else
|
||||
(cond neg (- 0 acc) :else acc))
|
||||
:else
|
||||
(go-strconv-parse-int s (+ i 1) neg (+ (* acc 10) d))))))))
|
||||
|
||||
(define
|
||||
go-strconv-digit
|
||||
(fn (c)
|
||||
(cond
|
||||
(= c "0") 0 (= c "1") 1 (= c "2") 2 (= c "3") 3
|
||||
(= c "4") 4 (= c "5") 5 (= c "6") 6 (= c "7") 7
|
||||
(= c "8") 8 (= c "9") 9
|
||||
:else -1)))
|
||||
|
||||
(define
|
||||
go-std-strconv
|
||||
(list :go-package "strconv"
|
||||
(list
|
||||
(list "Itoa" (list :go-builtin-fn go-strconv-itoa))
|
||||
(list "Atoi" (list :go-builtin-fn go-strconv-atoi)))))
|
||||
386
lib/go/std/strings.sx
Normal file
386
lib/go/std/strings.sx
Normal file
@@ -0,0 +1,386 @@
|
||||
;; lib/go/std/strings.sx — Go's `strings` package, v0 subset.
|
||||
;;
|
||||
;; Exposed as `go-std-strings`, a (:go-package "strings" ENTRIES) value.
|
||||
;; Register with `(go-env-extend env "strings" go-std-strings)` to make
|
||||
;; `strings.X(...)` call sites work in evaluated Go code.
|
||||
;;
|
||||
;; Each entry is (FIELD-NAME (list :go-fn PARAMS BODY)) — the same
|
||||
;; shape user-defined Go functions get. Bodies are written in SX
|
||||
;; directly via go-builtin closures wrapping host-level string ops
|
||||
;; for speed, OR as parsed Go source for fidelity. v0 uses
|
||||
;; go-builtin wrappers — simpler and fast.
|
||||
|
||||
;; ── helpers: implement go-std-strings entries as builtins ────────
|
||||
|
||||
(define
|
||||
go-strings-contains
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-contains-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (sub (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? sub)) (list :eval-error :strings-not-string sub)
|
||||
:else
|
||||
(go-strings-index-of s sub 0))))))
|
||||
|
||||
(define
|
||||
go-strings-index-of
|
||||
;; Returns true if SUB appears in S at or after START, else false.
|
||||
(fn (s sub start)
|
||||
(let ((slen (len s)) (sublen (len sub)))
|
||||
(cond
|
||||
(= sublen 0) true
|
||||
(> (+ start sublen) slen) false
|
||||
(go-strings-match-at s sub start 0) true
|
||||
:else (go-strings-index-of s sub (+ start 1))))))
|
||||
|
||||
(define
|
||||
go-strings-match-at
|
||||
(fn (s sub start k)
|
||||
(cond
|
||||
(>= k (len sub)) true
|
||||
(= (nth s (+ start k)) (nth sub k))
|
||||
(go-strings-match-at s sub start (+ k 1))
|
||||
:else false)))
|
||||
|
||||
(define
|
||||
go-strings-has-prefix
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-hasprefix-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (p (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? p)) (list :eval-error :strings-not-string p)
|
||||
(> (len p) (len s)) false
|
||||
:else (go-strings-match-at s p 0 0))))))
|
||||
|
||||
(define
|
||||
go-strings-has-suffix
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-hassuffix-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (suf (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? suf)) (list :eval-error :strings-not-string suf)
|
||||
(> (len suf) (len s)) false
|
||||
:else
|
||||
(go-strings-match-at s suf (- (len s) (len suf)) 0))))))
|
||||
|
||||
(define
|
||||
go-strings-index
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-index-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (sub (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? sub)) (list :eval-error :strings-not-string sub)
|
||||
:else (go-strings-index-loop s sub 0))))))
|
||||
|
||||
(define
|
||||
go-strings-index-loop
|
||||
(fn (s sub start)
|
||||
(let ((slen (len s)) (sublen (len sub)))
|
||||
(cond
|
||||
(= sublen 0) 0
|
||||
(> (+ start sublen) slen) -1
|
||||
(go-strings-match-at s sub start 0) start
|
||||
:else (go-strings-index-loop s sub (+ start 1))))))
|
||||
|
||||
(define
|
||||
go-strings-repeat
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-repeat-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (n (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(< n 0) (list :eval-error :strings-repeat-negative n)
|
||||
:else (go-strings-repeat-loop s n ""))))))
|
||||
|
||||
(define
|
||||
go-strings-repeat-loop
|
||||
(fn (s n acc)
|
||||
(cond
|
||||
(<= n 0) acc
|
||||
:else (go-strings-repeat-loop s (- n 1) (str acc s)))))
|
||||
|
||||
(define
|
||||
go-strings-count
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-count-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (sub (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? sub)) (list :eval-error :strings-not-string sub)
|
||||
:else (go-strings-count-loop s sub 0 0))))))
|
||||
|
||||
(define
|
||||
go-strings-count-loop
|
||||
(fn (s sub start acc)
|
||||
(let ((idx (go-strings-index-loop s sub start)))
|
||||
(cond
|
||||
(< idx 0) acc
|
||||
:else
|
||||
(go-strings-count-loop s sub (+ idx (max 1 (len sub))) (+ acc 1))))))
|
||||
|
||||
(define
|
||||
go-strings-join
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-join-arity (len args))
|
||||
:else
|
||||
(let ((sep (nth args 1)) (xs (first args)))
|
||||
(cond
|
||||
(not (string? sep)) (list :eval-error :strings-not-string sep)
|
||||
(not (and (list? xs) (= (first xs) :go-slice)))
|
||||
(list :eval-error :strings-join-not-slice xs)
|
||||
:else (go-strings-join-loop (nth xs 1) sep ""))))))
|
||||
|
||||
(define
|
||||
go-strings-join-loop
|
||||
(fn (xs sep acc)
|
||||
(cond
|
||||
(= (len xs) 0) acc
|
||||
(= (len acc) 0) (go-strings-join-loop (rest xs) sep (first xs))
|
||||
:else
|
||||
(go-strings-join-loop (rest xs) sep (str acc sep (first xs))))))
|
||||
|
||||
;; ── case conversion ──────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-strings-char-to-upper
|
||||
(fn (c)
|
||||
(cond
|
||||
(and (>= c "a") (<= c "z"))
|
||||
;; ASCII uppercase shift: 'a' is 0x61, 'A' is 0x41 → diff 0x20.
|
||||
;; SX has no charcode primitive, so use a char-pair table.
|
||||
(go-strings-letter-toggle c true)
|
||||
:else c)))
|
||||
|
||||
(define
|
||||
go-strings-char-to-lower
|
||||
(fn (c)
|
||||
(cond
|
||||
(and (>= c "A") (<= c "Z"))
|
||||
(go-strings-letter-toggle c false)
|
||||
:else c)))
|
||||
|
||||
(define
|
||||
go-strings-letter-toggle
|
||||
;; Toggle a single ASCII letter's case via direct mapping.
|
||||
;; `to-upper?` true means input is lowercase, output uppercase.
|
||||
(fn (c to-upper?)
|
||||
(cond
|
||||
to-upper?
|
||||
(cond
|
||||
(= c "a") "A" (= c "b") "B" (= c "c") "C" (= c "d") "D"
|
||||
(= c "e") "E" (= c "f") "F" (= c "g") "G" (= c "h") "H"
|
||||
(= c "i") "I" (= c "j") "J" (= c "k") "K" (= c "l") "L"
|
||||
(= c "m") "M" (= c "n") "N" (= c "o") "O" (= c "p") "P"
|
||||
(= c "q") "Q" (= c "r") "R" (= c "s") "S" (= c "t") "T"
|
||||
(= c "u") "U" (= c "v") "V" (= c "w") "W" (= c "x") "X"
|
||||
(= c "y") "Y" (= c "z") "Z" :else c)
|
||||
:else
|
||||
(cond
|
||||
(= c "A") "a" (= c "B") "b" (= c "C") "c" (= c "D") "d"
|
||||
(= c "E") "e" (= c "F") "f" (= c "G") "g" (= c "H") "h"
|
||||
(= c "I") "i" (= c "J") "j" (= c "K") "k" (= c "L") "l"
|
||||
(= c "M") "m" (= c "N") "n" (= c "O") "o" (= c "P") "p"
|
||||
(= c "Q") "q" (= c "R") "r" (= c "S") "s" (= c "T") "t"
|
||||
(= c "U") "u" (= c "V") "v" (= c "W") "w" (= c "X") "x"
|
||||
(= c "Y") "y" (= c "Z") "z" :else c))))
|
||||
|
||||
(define
|
||||
go-strings-map-chars
|
||||
(fn (s i acc char-fn)
|
||||
(cond
|
||||
(>= i (len s)) acc
|
||||
:else
|
||||
(go-strings-map-chars s (+ i 1) (str acc (char-fn (nth s i))) char-fn))))
|
||||
|
||||
(define
|
||||
go-strings-to-upper
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 1))
|
||||
(list :eval-error :strings-toupper-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
:else (go-strings-map-chars s 0 "" go-strings-char-to-upper))))))
|
||||
|
||||
(define
|
||||
go-strings-to-lower
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 1))
|
||||
(list :eval-error :strings-tolower-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
:else (go-strings-map-chars s 0 "" go-strings-char-to-lower))))))
|
||||
|
||||
;; ── TrimSpace ────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-strings-is-space?
|
||||
(fn (c)
|
||||
(or (= c " ") (= c "\t") (= c "\n") (= c "\r"))))
|
||||
|
||||
(define
|
||||
go-strings-trim-left
|
||||
(fn (s i)
|
||||
(cond
|
||||
(>= i (len s)) i
|
||||
(go-strings-is-space? (nth s i)) (go-strings-trim-left s (+ i 1))
|
||||
:else i)))
|
||||
|
||||
(define
|
||||
go-strings-trim-right
|
||||
(fn (s end)
|
||||
(cond
|
||||
(<= end 0) 0
|
||||
(go-strings-is-space? (nth s (- end 1))) (go-strings-trim-right s (- end 1))
|
||||
:else end)))
|
||||
|
||||
(define
|
||||
go-strings-substr
|
||||
;; Substring [lo, hi) — naive but predictable.
|
||||
(fn (s lo hi)
|
||||
(cond
|
||||
(>= lo hi) ""
|
||||
:else
|
||||
(go-strings-substr-loop s lo hi ""))))
|
||||
|
||||
(define
|
||||
go-strings-substr-loop
|
||||
(fn (s i hi acc)
|
||||
(cond
|
||||
(>= i hi) acc
|
||||
:else (go-strings-substr-loop s (+ i 1) hi (str acc (nth s i))))))
|
||||
|
||||
(define
|
||||
go-strings-trim-space
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 1))
|
||||
(list :eval-error :strings-trimspace-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
:else
|
||||
(let ((lo (go-strings-trim-left s 0)))
|
||||
(let ((hi (go-strings-trim-right s (len s))))
|
||||
(go-strings-substr s lo hi))))))))
|
||||
|
||||
;; ── Split ────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-strings-split
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-split-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (sep (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? sep)) (list :eval-error :strings-not-string sep)
|
||||
(= (len sep) 0)
|
||||
;; Empty separator: real Go splits to all chars; v0 keeps
|
||||
;; behaviour simple — single-element slice.
|
||||
(list :go-slice (list s))
|
||||
:else
|
||||
(list :go-slice (go-strings-split-loop s sep 0 (list))))))))
|
||||
|
||||
(define
|
||||
go-strings-split-loop
|
||||
(fn (s sep start acc)
|
||||
(let ((idx (go-strings-index-loop s sep start)))
|
||||
(cond
|
||||
(< idx 0)
|
||||
(go-strings-split-finalize acc (go-strings-substr s start (len s)))
|
||||
:else
|
||||
(go-strings-split-loop s sep (+ idx (len sep))
|
||||
(go-strings-split-finalize acc
|
||||
(go-strings-substr s start idx)))))))
|
||||
|
||||
(define
|
||||
go-strings-split-finalize
|
||||
;; Append a piece to acc, growing the list in order.
|
||||
(fn (acc piece)
|
||||
(cond
|
||||
(= (len acc) 0) (list piece)
|
||||
:else (go-name-concat acc (list piece)))))
|
||||
|
||||
;; ── Replace ──────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-strings-replace
|
||||
;; Replace(s, old, new, n). n < 0 = all.
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 4))
|
||||
(list :eval-error :strings-replace-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (old (nth args 1))
|
||||
(newv (nth args 2)) (n (nth args 3)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? old)) (list :eval-error :strings-not-string old)
|
||||
(not (string? newv)) (list :eval-error :strings-not-string newv)
|
||||
(= (len old) 0) s
|
||||
:else (go-strings-replace-loop s old newv n 0 ""))))))
|
||||
|
||||
(define
|
||||
go-strings-replace-loop
|
||||
(fn (s old newv n start acc)
|
||||
(let ((idx (go-strings-index-loop s old start)))
|
||||
(cond
|
||||
(or (< idx 0) (= n 0))
|
||||
(str acc (go-strings-substr s start (len s)))
|
||||
:else
|
||||
(go-strings-replace-loop s old newv
|
||||
(cond (< n 0) -1 :else (- n 1))
|
||||
(+ idx (len old))
|
||||
(str acc (go-strings-substr s start idx) newv))))))
|
||||
|
||||
;; ── go-std-strings package value ─────────────────────────────────
|
||||
|
||||
(define
|
||||
go-std-strings
|
||||
(list :go-package "strings"
|
||||
(list
|
||||
(list "Contains" (list :go-builtin-fn go-strings-contains))
|
||||
(list "HasPrefix" (list :go-builtin-fn go-strings-has-prefix))
|
||||
(list "HasSuffix" (list :go-builtin-fn go-strings-has-suffix))
|
||||
(list "Index" (list :go-builtin-fn go-strings-index))
|
||||
(list "Count" (list :go-builtin-fn go-strings-count))
|
||||
(list "Repeat" (list :go-builtin-fn go-strings-repeat))
|
||||
(list "Join" (list :go-builtin-fn go-strings-join))
|
||||
(list "ToUpper" (list :go-builtin-fn go-strings-to-upper))
|
||||
(list "ToLower" (list :go-builtin-fn go-strings-to-lower))
|
||||
(list "TrimSpace" (list :go-builtin-fn go-strings-trim-space))
|
||||
(list "Split" (list :go-builtin-fn go-strings-split))
|
||||
(list "Replace" (list :go-builtin-fn go-strings-replace)))))
|
||||
186
lib/go/tests/e2e.sx
Normal file
186
lib/go/tests/e2e.sx
Normal file
@@ -0,0 +1,186 @@
|
||||
;; Go end-to-end tests — complete programs exercising lex+parse+
|
||||
;; types+eval+sched+stdlib together. Each test runs a multi-line Go
|
||||
;; program and inspects the final env.
|
||||
|
||||
(define go-e2e-test-count 0)
|
||||
(define go-e2e-test-pass 0)
|
||||
(define go-e2e-test-fails (list))
|
||||
|
||||
(define
|
||||
go-e2e-test
|
||||
(fn (name actual expected)
|
||||
(set! go-e2e-test-count (+ go-e2e-test-count 1))
|
||||
(if (= actual expected)
|
||||
(set! go-e2e-test-pass (+ go-e2e-test-pass 1))
|
||||
(append! go-e2e-test-fails
|
||||
{:name name :expected expected :actual actual}))))
|
||||
|
||||
(define
|
||||
go-e2e-env
|
||||
(go-env-extend
|
||||
(go-env-extend go-env-builtins "strings" go-std-strings)
|
||||
"strconv" go-std-strconv))
|
||||
|
||||
(define
|
||||
go-e2e-run
|
||||
(fn (src-list)
|
||||
(go-eval-program go-e2e-env (map go-parse src-list))))
|
||||
|
||||
;; ── 1. Sieve via boolean slice (no modulo needed) ────────────────
|
||||
(go-e2e-test "e2e: sieve-of-Eratosthenes via boolean slice — count primes ≤ 30"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
;; sieve[i] true means i is COMPOSITE (saves the
|
||||
;; default-bool initialisation for primes).
|
||||
"sieve := []bool{false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false}"
|
||||
"for p := 2; p < 31; p = p + 1 { if sieve[p] == false { for k := p + p; k < 31; k = k + p { sieve[k] = true } } }"
|
||||
"count := 0"
|
||||
"for i := 2; i < 31; i = i + 1 { if sieve[i] == false { count = count + 1 } }"))))
|
||||
(go-env-lookup env "count"))
|
||||
;; primes ≤ 30: 2,3,5,7,11,13,17,19,23,29 = 10
|
||||
10)
|
||||
|
||||
;; ── 1b. Range-membership check (works without mod) ───────────────
|
||||
(go-e2e-test "e2e: linear search across slice of strings"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"words := []string{\"apple\", \"banana\", \"cherry\", \"date\"}"
|
||||
"func indexOf(xs []string, target string) int { for i, v := range xs { if v == target { return i } } ; return -1 }"
|
||||
"i := indexOf(words, \"cherry\")"
|
||||
"missing := indexOf(words, \"xyz\")"))))
|
||||
(list (go-env-lookup env "i") (go-env-lookup env "missing")))
|
||||
(list 2 -1))
|
||||
|
||||
;; ── 2. Reverse a slice ───────────────────────────────────────────
|
||||
(go-e2e-test "e2e: reverse a slice of ints"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func reverse(xs []int) []int { r := []int{} ; for i := len(xs) - 1; i >= 0; i = i - 1 { r = append(r, xs[i]) } ; return r }"
|
||||
"out := reverse([]int{1, 2, 3, 4, 5})"))))
|
||||
(go-env-lookup env "out"))
|
||||
(list :go-slice (list 5 4 3 2 1)))
|
||||
|
||||
;; ── 3. Fibonacci (recursive) ─────────────────────────────────────
|
||||
(go-e2e-test "e2e: fib(10) = 55"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func fib(n int) int { if n < 2 { return n } ; return fib(n-1) + fib(n-2) }"
|
||||
"r := fib(10)"))))
|
||||
(go-env-lookup env "r"))
|
||||
55)
|
||||
|
||||
;; ── 4. Sum-of-squares via Map+Reduce ─────────────────────────────
|
||||
(go-e2e-test "e2e: sum-of-squares 1..5 via Map+Reduce"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func Map[T any, U any](xs []T, f func(T) U) []U { r := []int{} ; for i, v := range xs { r = append(r, f(v)) } ; return r }"
|
||||
"func Reduce[T any, U any](xs []T, seed U, f func(U, T) U) U { acc := seed ; for i, v := range xs { acc = f(acc, v) } ; return acc }"
|
||||
"func sq(x int) int { return x * x }"
|
||||
"func add(a int, b int) int { return a + b }"
|
||||
"squares := Map([]int{1, 2, 3, 4, 5}, sq)"
|
||||
"total := Reduce(squares, 0, add)"))))
|
||||
(go-env-lookup env "total"))
|
||||
;; 1 + 4 + 9 + 16 + 25 = 55
|
||||
55)
|
||||
|
||||
;; ── 5. Word frequency counter ────────────────────────────────────
|
||||
(go-e2e-test "e2e: word-frequency over a sentence"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"text := \"the quick brown fox jumps over the lazy dog the\""
|
||||
"words := strings.Split(text, \" \")"
|
||||
"counts := map[string]int{}"
|
||||
"for i, w := range words { counts[w] = counts[w] + 1 }"
|
||||
"the_count := counts[\"the\"]"
|
||||
"fox_count := counts[\"fox\"]"
|
||||
"dog_count := counts[\"dog\"]"))))
|
||||
(list (go-env-lookup env "the_count")
|
||||
(go-env-lookup env "fox_count")
|
||||
(go-env-lookup env "dog_count")))
|
||||
(list 3 1 1))
|
||||
|
||||
;; ── 6. Pipeline via channels ─────────────────────────────────────
|
||||
(go-e2e-test "e2e: pipeline — generate, square, sum"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func gen(c chan int, n int) { for i := 1; i <= n; i = i + 1 { c <- i } ; close(c) }"
|
||||
"func sq(in chan int, out chan int) { for v := range in { out <- v * v } ; close(out) }"
|
||||
"src := make()"
|
||||
"sqs := make()"
|
||||
"go gen(src, 4)"
|
||||
"go sq(src, sqs)"
|
||||
"total := 0"
|
||||
"for v := range sqs { total = total + v }"))))
|
||||
(go-env-lookup env "total"))
|
||||
;; 1+4+9+16 = 30
|
||||
30)
|
||||
|
||||
;; ── 7. Worker pool draining a job channel ────────────────────────
|
||||
(go-e2e-test "e2e: worker pool — sum of doubled jobs"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func worker(jobs chan int, results chan int) { for j := range jobs { results <- j * 2 } }"
|
||||
"jobs := make()"
|
||||
"results := make()"
|
||||
"jobs <- 10 ; jobs <- 20 ; jobs <- 30"
|
||||
"close(jobs)"
|
||||
"go worker(jobs, results)"
|
||||
"close(results)"
|
||||
"sum := 0"
|
||||
"for r := range results { sum = sum + r }"))))
|
||||
(go-env-lookup env "sum"))
|
||||
;; 20 + 40 + 60 = 120
|
||||
120)
|
||||
|
||||
;; ── 8. Bubble sort ───────────────────────────────────────────────
|
||||
(go-e2e-test "e2e: bubble sort ascending"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func bubble(xs []int) []int { n := len(xs) ; for i := 0; i < n; i = i + 1 { for j := 0; j < n - 1; j = j + 1 { if xs[j] > xs[j+1] { tmp := xs[j] ; xs[j] = xs[j+1] ; xs[j+1] = tmp } } } ; return xs }"
|
||||
"out := bubble([]int{3, 1, 4, 1, 5, 9, 2, 6})"))))
|
||||
(go-env-lookup env "out"))
|
||||
(list :go-slice (list 1 1 2 3 4 5 6 9)))
|
||||
|
||||
;; ── 9. String reverse using strings.Split + reverse + Join ──────
|
||||
(go-e2e-test "e2e: reverse words in a sentence"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func rev(xs []string) []string { r := []string{} ; for i := len(xs) - 1; i >= 0; i = i - 1 { r = append(r, xs[i]) } ; return r }"
|
||||
"text := \"go on sx\""
|
||||
"out := strings.Join(rev(strings.Split(text, \" \")), \"-\")"))))
|
||||
(go-env-lookup env "out"))
|
||||
"sx-on-go")
|
||||
|
||||
;; ── 10. Counting occurrences via Filter ──────────────────────────
|
||||
(go-e2e-test "e2e: count even numbers via Filter+len"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func Filter[T any](xs []T, p func(T) bool) []T { r := []int{} ; for i, v := range xs { if p(v) { r = append(r, v) } } ; return r }"
|
||||
"func gt5(x int) bool { return x > 5 }"
|
||||
"n := len(Filter([]int{1, 2, 6, 3, 7, 8, 4, 9}, gt5))"))))
|
||||
(go-env-lookup env "n"))
|
||||
;; gt5: 6,7,8,9 = 4
|
||||
4)
|
||||
|
||||
;; ── 11. Recursive ackermann (small inputs) ───────────────────────
|
||||
(go-e2e-test "e2e: ackermann(2, 3) = 9"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func ack(m int, n int) int { if m == 0 { return n + 1 } ; if n == 0 { return ack(m - 1, 1) } ; return ack(m - 1, ack(m, n - 1)) }"
|
||||
"r := ack(2, 3)"))))
|
||||
(go-env-lookup env "r"))
|
||||
9)
|
||||
|
||||
;; ── 12. Defer + recover smoke test ───────────────────────────────
|
||||
(go-e2e-test "e2e: defer + recover in real-fn flow"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func safeDivide(a int, b int) int { defer recover() ; if b == 0 { panic(\"div by zero\") } ; return a / b }"
|
||||
"r := safeDivide(10, 0)"
|
||||
"after := 99"))))
|
||||
(go-env-lookup env "after"))
|
||||
99)
|
||||
|
||||
(define
|
||||
go-e2e-test-summary
|
||||
(str "e2e " go-e2e-test-pass "/" go-e2e-test-count))
|
||||
667
lib/go/tests/eval.sx
Normal file
667
lib/go/tests/eval.sx
Normal file
@@ -0,0 +1,667 @@
|
||||
;; Go evaluator tests.
|
||||
|
||||
(define go-eval-test-count 0)
|
||||
(define go-eval-test-pass 0)
|
||||
(define go-eval-test-fails (list))
|
||||
|
||||
(define
|
||||
go-eval-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! go-eval-test-count (+ go-eval-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! go-eval-test-pass (+ go-eval-test-pass 1))
|
||||
(append! go-eval-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define gtev (fn (env src) (go-eval env (go-parse src))))
|
||||
|
||||
;; ── env ──────────────────────────────────────────────────────────
|
||||
(go-eval-test
|
||||
"env: empty lookup returns nil"
|
||||
(go-env-lookup go-env-empty "x")
|
||||
nil)
|
||||
|
||||
(go-eval-test
|
||||
"env: extend then lookup"
|
||||
(go-env-lookup (go-env-extend go-env-empty "x" 42) "x")
|
||||
42)
|
||||
|
||||
;; ── literals ────────────────────────────────────────────────────
|
||||
(go-eval-test "lit: 42 → 42" (gtev go-env-empty "42") 42)
|
||||
|
||||
(go-eval-test "lit: 0 → 0" (gtev go-env-empty "0") 0)
|
||||
|
||||
(go-eval-test "lit: 0xFF → 255" (gtev go-env-empty "0xFF") 255)
|
||||
|
||||
(go-eval-test "lit: 0b1010 → 10" (gtev go-env-empty "0b1010") 10)
|
||||
|
||||
(go-eval-test "lit: 0o17 → 15" (gtev go-env-empty "0o17") 15)
|
||||
|
||||
(go-eval-test
|
||||
"lit: underscore separator 1_000 → 1000"
|
||||
(gtev go-env-empty "1_000")
|
||||
1000)
|
||||
|
||||
(go-eval-test "lit: string" (gtev go-env-empty "\"hello\"") "hello")
|
||||
|
||||
;; ── predeclared ─────────────────────────────────────────────────
|
||||
(go-eval-test "var: true" (gtev go-env-empty "true") true)
|
||||
(go-eval-test "var: false" (gtev go-env-empty "false") false)
|
||||
(go-eval-test "var: nil" (gtev go-env-empty "nil") nil)
|
||||
|
||||
;; ── variable lookup ─────────────────────────────────────────────
|
||||
(go-eval-test
|
||||
"var: bound x → 5"
|
||||
(go-eval (go-env-extend go-env-empty "x" 5) (go-parse "x"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"var: unbound y → :eval-error"
|
||||
(gtev go-env-empty "y")
|
||||
(list :eval-error :unbound "y"))
|
||||
|
||||
;; ── binary ops ─────────────────────────────────────────────────
|
||||
(go-eval-test "binop: 1 + 2 → 3" (gtev go-env-empty "1 + 2") 3)
|
||||
(go-eval-test "binop: 10 - 4 → 6" (gtev go-env-empty "10 - 4") 6)
|
||||
(go-eval-test "binop: 3 * 7 → 21" (gtev go-env-empty "3 * 7") 21)
|
||||
(go-eval-test "binop: 42 / 7 → 6" (gtev go-env-empty "42 / 7") 6)
|
||||
(go-eval-test
|
||||
"binop: 2 + 3 * 4 → 14 (prec)"
|
||||
(gtev go-env-empty "2 + 3 * 4")
|
||||
14)
|
||||
(go-eval-test
|
||||
"binop: a + b uses env"
|
||||
(go-eval
|
||||
(go-env-extend (go-env-extend go-env-empty "a" 3) "b" 4)
|
||||
(go-parse "a + b"))
|
||||
7)
|
||||
|
||||
(go-eval-test "binop: 1 < 2 → true" (gtev go-env-empty "1 < 2") true)
|
||||
(go-eval-test "binop: 5 == 5 → true" (gtev go-env-empty "5 == 5") true)
|
||||
(go-eval-test "binop: 5 != 5 → false" (gtev go-env-empty "5 != 5") false)
|
||||
(go-eval-test
|
||||
"binop: true && false → false"
|
||||
(gtev go-env-empty "true && false")
|
||||
false)
|
||||
(go-eval-test
|
||||
"binop: false || true → true"
|
||||
(gtev go-env-empty "false || true")
|
||||
true)
|
||||
|
||||
;; ── report ──────────────────────────────────────────────────────
|
||||
(go-eval-test
|
||||
"var-decl: var x = 5 — env has x=5"
|
||||
(go-env-lookup
|
||||
(go-eval-program go-env-empty (list (go-parse "var x = 5")))
|
||||
"x")
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"short-decl: a, b := 3, 4 — env has both"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a, b := 3, 4")))))
|
||||
(list (go-env-lookup env "a") (go-env-lookup env "b")))
|
||||
(list 3 4))
|
||||
|
||||
(go-eval-test
|
||||
"assign: x = 5 then x → 5"
|
||||
(let
|
||||
((env (go-eval-program (go-env-extend go-env-empty "x" 1) (list (go-parse "x = 5")))))
|
||||
(go-env-lookup env "x"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"if: true branch evaluates"
|
||||
(let
|
||||
((env (go-eval-program (go-env-extend go-env-empty "x" 0) (list (go-parse "if true { x = 1 }")))))
|
||||
(go-env-lookup env "x"))
|
||||
1)
|
||||
|
||||
(go-eval-test
|
||||
"if-else: false → else branch"
|
||||
(let
|
||||
((env (go-eval-program (go-env-extend go-env-empty "x" 0) (list (go-parse "if false { x = 1 } else { x = 2 }")))))
|
||||
(go-env-lookup env "x"))
|
||||
2)
|
||||
|
||||
(go-eval-test
|
||||
"fn: define + call — double(7) = 14"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "func double(x int) int { return x * 2 }")))))
|
||||
(go-eval env (go-parse "double(7)")))
|
||||
14)
|
||||
|
||||
(go-eval-test
|
||||
"fn: add(2, 3) = 5"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "func add(x, y int) int { return x + y }")))))
|
||||
(go-eval env (go-parse "add(2, 3)")))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"fn: recursive fib(5) = 5"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "func fib(n int) int { if n < 2 { return n } return fib(n-1) + fib(n-2) }")))))
|
||||
(go-eval env (go-parse "fib(5)")))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"for: count to 10 with sum"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "var sum = 0") (go-parse "for i := 0; i < 10; i++ { sum = sum + i }")))))
|
||||
(go-env-lookup env "sum"))
|
||||
45)
|
||||
|
||||
(go-eval-test
|
||||
"inc-dec: x++ updates env"
|
||||
(let
|
||||
((env (go-eval-program (go-env-extend go-env-empty "x" 5) (list (go-parse "x++")))))
|
||||
(go-env-lookup env "x"))
|
||||
6)
|
||||
|
||||
(go-eval-test
|
||||
"inc-dec: x-- updates env"
|
||||
(let
|
||||
((env (go-eval-program (go-env-extend go-env-empty "x" 5) (list (go-parse "x--")))))
|
||||
(go-env-lookup env "x"))
|
||||
4)
|
||||
|
||||
(go-eval-test
|
||||
"for: break exits the loop"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "var i = 0") (go-parse "for i < 100 { if i == 5 { break } ; i++ }")))))
|
||||
(go-env-lookup env "i"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"for: continue skips body but runs post"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "var sum = 0") (go-parse "for i := 0; i < 5; i++ { if i == 2 { continue } ; sum = sum + i }")))))
|
||||
(go-env-lookup env "sum"))
|
||||
8)
|
||||
|
||||
(go-eval-test
|
||||
"for: infinite + break with sum"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "var s = 0") (go-parse "var i = 1") (go-parse "for { if i > 4 { break } ; s = s + i ; i++ }")))))
|
||||
(go-env-lookup env "s"))
|
||||
10)
|
||||
|
||||
(go-eval-test
|
||||
"fn: iterative factorial via for-loop"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "func fact(n int) int { r := 1 ; for i := 2 ; i <= n ; i++ { r = r * i } ; return r }")))))
|
||||
(go-eval env (go-parse "fact(5)")))
|
||||
120)
|
||||
|
||||
(go-eval-test
|
||||
"slice: []int{1,2,3} → :go-slice"
|
||||
(gtev go-env-empty "[]int{1, 2, 3}")
|
||||
(list :go-slice (list 1 2 3)))
|
||||
|
||||
(go-eval-test
|
||||
"index: a[0] = 10, a[2] = 30"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{10, 20, 30}")))))
|
||||
(list (go-eval env (go-parse "a[0]")) (go-eval env (go-parse "a[2]"))))
|
||||
(list 10 30))
|
||||
|
||||
(go-eval-test
|
||||
"index: out-of-range error"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{1, 2}")))))
|
||||
(go-eval env (go-parse "a[5]")))
|
||||
(list :eval-error :index-out-of-range 5 2))
|
||||
|
||||
(go-eval-test
|
||||
"builtin: len(slice) = 3"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := []int{1, 2, 3}")))))
|
||||
(go-eval env (go-parse "len(a)")))
|
||||
3)
|
||||
|
||||
(go-eval-test
|
||||
"builtin: len(string)"
|
||||
(go-eval go-env-builtins (go-parse "len(\"hello\")"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"builtin: append(a, 4, 5)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := []int{1, 2, 3}")))))
|
||||
(go-eval env (go-parse "append(a, 4, 5)")))
|
||||
(list
|
||||
:go-slice (list 1 2 3 4 5)))
|
||||
|
||||
(go-eval-test
|
||||
"slice expr: a[1:3]"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{10, 20, 30, 40}")))))
|
||||
(go-eval env (go-parse "a[1:3]")))
|
||||
(list :go-slice (list 20 30)))
|
||||
|
||||
(go-eval-test
|
||||
"slice expr: a[:2] (omitted low)"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{1, 2, 3, 4}")))))
|
||||
(go-eval env (go-parse "a[:2]")))
|
||||
(list :go-slice (list 1 2)))
|
||||
|
||||
(go-eval-test
|
||||
"slice expr: a[2:] (omitted high)"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{1, 2, 3, 4}")))))
|
||||
(go-eval env (go-parse "a[2:]")))
|
||||
(list :go-slice (list 3 4)))
|
||||
|
||||
(go-eval-test
|
||||
"fn: sum slice via for-loop with len + index"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := []int{1, 2, 3, 4, 5}") (go-parse "sum := 0") (go-parse "for i := 0; i < len(a); i++ { sum = sum + a[i] }")))))
|
||||
(go-env-lookup env "sum"))
|
||||
15)
|
||||
|
||||
(go-eval-test
|
||||
"map: map[string]int{...} → :go-map"
|
||||
(gtev go-env-empty "map[string]int{\"a\": 1, \"b\": 2}")
|
||||
(list :go-map (list (list "a" 1) (list "b" 2))))
|
||||
|
||||
(go-eval-test
|
||||
"map: m[\"a\"] → 1"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "m := map[string]int{\"a\": 1, \"b\": 2}")))))
|
||||
(go-eval env (go-parse "m[\"a\"]")))
|
||||
1)
|
||||
|
||||
(go-eval-test
|
||||
"map: missing key → nil (v0 stand-in for zero value)"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "m := map[string]int{\"a\": 1}")))))
|
||||
(go-eval env (go-parse "m[\"missing\"]")))
|
||||
nil)
|
||||
|
||||
(go-eval-test
|
||||
"map: len(m) = 2"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "m := map[string]int{\"a\": 1, \"b\": 2}")))))
|
||||
(go-eval env (go-parse "len(m)")))
|
||||
2)
|
||||
|
||||
(go-eval-test
|
||||
"map: index-assign updates existing key"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "m := map[string]int{\"a\": 1}") (go-parse "m[\"a\"] = 99")))))
|
||||
(go-eval env (go-parse "m[\"a\"]")))
|
||||
99)
|
||||
|
||||
(go-eval-test
|
||||
"map: index-assign adds new key"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "m := map[string]int{}") (go-parse "m[\"new\"] = 7")))))
|
||||
(go-eval env (go-parse "m[\"new\"]")))
|
||||
7)
|
||||
|
||||
(go-eval-test
|
||||
"slice: index-assign a[0] = 99"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{10, 20, 30}") (go-parse "a[0] = 99")))))
|
||||
(go-eval env (go-parse "a[0]")))
|
||||
99)
|
||||
|
||||
(go-eval-test
|
||||
"map: word count via loop"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "words := []string{\"a\", \"b\", \"a\", \"c\", \"a\"}") (go-parse "counts := map[string]int{}") (go-parse "for i := 0; i < len(words); i++ { counts[words[i]] = counts[words[i]] + 1 }")))))
|
||||
(go-eval env (go-parse "counts[\"a\"]")))
|
||||
3)
|
||||
|
||||
(go-eval-test
|
||||
"type-decl: registers struct field names"
|
||||
(go-env-lookup
|
||||
(go-eval-program
|
||||
go-env-empty
|
||||
(list (go-parse "type Point struct { x, y int }")))
|
||||
"Point")
|
||||
(list :go-struct-type (list "x" "y")))
|
||||
|
||||
(go-eval-test
|
||||
"struct: positional composite Point{1, 2}"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }")))))
|
||||
(go-eval env (go-parse "Point{1, 2}")))
|
||||
(list
|
||||
:go-struct "Point"
|
||||
(list (list "x" 1) (list "y" 2))))
|
||||
|
||||
(go-eval-test
|
||||
"struct: keyed composite Point{x: 5, y: 10}"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }")))))
|
||||
(go-eval env (go-parse "Point{x: 5, y: 10}")))
|
||||
(list
|
||||
:go-struct "Point"
|
||||
(list (list "x" 5) (list "y" 10))))
|
||||
|
||||
(go-eval-test
|
||||
"struct: selector p.x = 1"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.x")))
|
||||
1)
|
||||
|
||||
(go-eval-test
|
||||
"struct: selector p.y = 2"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.y")))
|
||||
2)
|
||||
|
||||
(go-eval-test
|
||||
"struct: selector-assign p.x = 99"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "p := Point{1, 2}") (go-parse "p.x = 99")))))
|
||||
(go-eval env (go-parse "p.x")))
|
||||
99)
|
||||
|
||||
(go-eval-test
|
||||
"struct: positional arity-mismatch"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }")))))
|
||||
(go-eval env (go-parse "Point{1}")))
|
||||
(list :eval-error :struct-arity-mismatch "Point" 2 1))
|
||||
|
||||
(go-eval-test
|
||||
"struct: function takes/returns struct"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "func add(a, b Point) Point { return Point{a.x + b.x, a.y + b.y} }")))))
|
||||
(go-eval env (go-parse "add(Point{1, 2}, Point{3, 4})")))
|
||||
(list
|
||||
:go-struct "Point"
|
||||
(list (list "x" 4) (list "y" 6))))
|
||||
|
||||
(go-eval-test
|
||||
"method: p.Sum() = 3"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "func (p Point) Sum() int { return p.x + p.y }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.Sum()")))
|
||||
3)
|
||||
|
||||
(go-eval-test
|
||||
"method: p.Add(5) = 6 (with arg)"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "func (p Point) Add(d int) int { return p.x + d }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.Add(5)")))
|
||||
6)
|
||||
|
||||
(go-eval-test
|
||||
"method: pointer receiver works value-style in v0"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "func (p *Point) GetX() int { return p.x }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.GetX()")))
|
||||
1)
|
||||
|
||||
(go-eval-test
|
||||
"method: missing method → :no-such-method"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.Ghost()")))
|
||||
(list :eval-error :no-such-method "Point" "Ghost"))
|
||||
|
||||
(go-eval-test
|
||||
"unary: -x"
|
||||
(go-eval (go-env-extend go-env-empty "x" 5) (go-parse "-x"))
|
||||
-5)
|
||||
|
||||
(go-eval-test "unary: !true → false" (gtev go-env-empty "!true") false)
|
||||
|
||||
(go-eval-test "unary: !false → true" (gtev go-env-empty "!false") true)
|
||||
|
||||
(go-eval-test
|
||||
"unary: -3 + 5 = 2 (unary binds tighter)"
|
||||
(gtev go-env-empty "-3 + 5")
|
||||
2)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: count odd numbers in 1..10 = 5"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty
|
||||
(list (go-parse "odds := 0")
|
||||
(go-parse "i := 1")
|
||||
(go-parse "for i <= 10 { odds = odds + 1; i = i + 2 }")))))
|
||||
(go-env-lookup env "odds"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: factorial via method on Counter"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Acc struct { v int }") (go-parse "func (a Acc) Mul(x int) Acc { return Acc{a.v * x} }") (go-parse "a := Acc{1}") (go-parse "for i := 1; i <= 5; i++ { a = a.Mul(i) }")))))
|
||||
(go-eval env (go-parse "a.v")))
|
||||
120)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: recursive fibonacci fib(10) = 55"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "func fib(n int) int { if n < 2 { return n } return fib(n-1) + fib(n-2) }")))))
|
||||
(go-eval env (go-parse "fib(10)")))
|
||||
55)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: struct + method + iterative loop"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Counter struct { n int }") (go-parse "func (c Counter) Bump() Counter { return Counter{c.n + 1} }") (go-parse "c := Counter{0}") (go-parse "for i := 0; i < 7; i++ { c = c.Bump() }")))))
|
||||
(go-eval env (go-parse "c.n")))
|
||||
7)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: linear search returns index"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func find(a []int, x int) int { for i := 0; i < len(a); i++ { if a[i] == x { return i } } ; return -1 }") (go-parse "nums := []int{10, 20, 30, 40}")))))
|
||||
(go-eval env (go-parse "find(nums, 30)")))
|
||||
2)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: linear search returns -1 when missing"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func find(a []int, x int) int { for i := 0; i < len(a); i++ { if a[i] == x { return i } } ; return -1 }") (go-parse "nums := []int{10, 20, 30}")))))
|
||||
(go-eval env (go-parse "find(nums, 99)")))
|
||||
-1)
|
||||
|
||||
(go-eval-test
|
||||
"defer: single defer runs after surrounding fn body returns"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func push2(c chan int) { c <- 2 }") (go-parse "func run(c chan int) { defer push2(c) ; c <- 1 }") (go-parse "run(ch)") (go-parse "first := <-ch") (go-parse "second := <-ch")))))
|
||||
(list (go-env-lookup env "first") (go-env-lookup env "second")))
|
||||
(list 1 2))
|
||||
|
||||
(go-eval-test
|
||||
"defer: multiple defers run LIFO"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func p2(c chan int) { c <- 2 }") (go-parse "func p3(c chan int) { c <- 3 }") (go-parse "func run(c chan int) { defer p2(c) ; defer p3(c) ; c <- 1 }") (go-parse "run(ch)") (go-parse "a := <-ch") (go-parse "b := <-ch") (go-parse "d := <-ch")))))
|
||||
(list
|
||||
(go-env-lookup env "a")
|
||||
(go-env-lookup env "b")
|
||||
(go-env-lookup env "d")))
|
||||
(list 1 3 2))
|
||||
|
||||
(go-eval-test
|
||||
"defer: arguments are evaluated at defer-time (not call-time)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func pushN(c chan int, v int) { c <- v }") (go-parse "func run(c chan int) { x := 7 ; defer pushN(c, x) ; x = 99 }") (go-parse "run(ch)") (go-parse "got := <-ch")))))
|
||||
(go-env-lookup env "got"))
|
||||
7)
|
||||
|
||||
(go-eval-test
|
||||
"defer: runs even when fn returns early via return"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func note(c chan int) { c <- 42 }") (go-parse "func run(c chan int) int { defer note(c) ; return 1 }") (go-parse "r := run(ch)") (go-parse "n := <-ch")))))
|
||||
(list (go-env-lookup env "r") (go-env-lookup env "n")))
|
||||
(list 1 42))
|
||||
|
||||
(go-eval-test
|
||||
"defer: stack is frame-local — outer defers don't run on inner return"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func push1(c chan int) { c <- 1 }") (go-parse "func push2(c chan int) { c <- 2 }") (go-parse "func inner(c chan int) { defer push2(c) }") (go-parse "func outer(c chan int) { defer push1(c) ; inner(c) }") (go-parse "outer(ch)") (go-parse "a := <-ch") (go-parse "b := <-ch")))))
|
||||
(list (go-env-lookup env "a") (go-env-lookup env "b")))
|
||||
(list 2 1))
|
||||
|
||||
(go-eval-test
|
||||
"defer: in a loop, all defers fire on fn return (not loop iter)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func pushI(c chan int, v int) { c <- v }") (go-parse "func loop(c chan int) { for i := 0; i < 4; i = i + 1 { defer pushI(c, i) } }") (go-parse "loop(ch)") (go-parse "a := <-ch") (go-parse "b := <-ch") (go-parse "d := <-ch") (go-parse "e := <-ch")))))
|
||||
(list
|
||||
(go-env-lookup env "a")
|
||||
(go-env-lookup env "b")
|
||||
(go-env-lookup env "d")
|
||||
(go-env-lookup env "e")))
|
||||
(list 3 2 1 0))
|
||||
|
||||
(go-eval-test
|
||||
"panic: uncaught panic surfaces as (:go-panic V) from program"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "panic(\"boom\")")))))
|
||||
r)
|
||||
(list :go-panic "boom"))
|
||||
|
||||
(go-eval-test
|
||||
"panic inside fn: surfaces from fn call too"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "func boom() { panic(\"oops\") }") (go-parse "boom()")))))
|
||||
r)
|
||||
(list :go-panic "oops"))
|
||||
|
||||
(go-eval-test
|
||||
"recover: deferred recover swallows panic, fn returns normally"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func safe() { defer recover() ; panic(\"x\") }") (go-parse "safe()") (go-parse "after := 42")))))
|
||||
(go-env-lookup env "after"))
|
||||
42)
|
||||
|
||||
(go-eval-test
|
||||
"recover: deferred recover captures the panic value"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func grab(c chan int) { r := recover() ; c <- r }") (go-parse "func safe(c chan int) { defer grab(c) ; panic(99) }") (go-parse "safe(ch)") (go-parse "got := <-ch")))))
|
||||
(go-env-lookup env "got"))
|
||||
99)
|
||||
|
||||
(go-eval-test
|
||||
"panic: propagates through intermediate frames without defers"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "func inner() { panic(\"deep\") }") (go-parse "func middle() { inner() }") (go-parse "func outer() { middle() }") (go-parse "outer()")))))
|
||||
r)
|
||||
(list :go-panic "deep"))
|
||||
|
||||
(go-eval-test
|
||||
"recover: middle-frame defer catches panic from deeper frame"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func inner() { panic(\"deep\") }") (go-parse "func middle() { inner() }") (go-parse "func outer() { defer recover() ; middle() }") (go-parse "outer()") (go-parse "after := 7")))))
|
||||
(go-env-lookup env "after"))
|
||||
7)
|
||||
|
||||
(go-eval-test
|
||||
"goroutine panic: surfaces synchronously back to spawner (v0)"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "func boom() { panic(\"goroutine\") }") (go-parse "go boom()")))))
|
||||
r)
|
||||
(list :go-panic "goroutine"))
|
||||
|
||||
(go-eval-test
|
||||
"goroutine panic + spawner-defer-recover catches it (v0 sync)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func boom() { panic(\"g\") }") (go-parse "func main() { defer recover() ; go boom() }") (go-parse "main()") (go-parse "after := 11")))))
|
||||
(go-env-lookup env "after"))
|
||||
11)
|
||||
|
||||
(go-eval-test
|
||||
"defer order with recover: all defers run, recover catches"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func p2(c chan int) { c <- 2 }") (go-parse "func rec(c chan int) { recover() ; c <- 7 }") (go-parse "func safe(c chan int) { defer p2(c) ; defer rec(c) ; panic(0) }") (go-parse "safe(ch)") (go-parse "a := <-ch") (go-parse "b := <-ch")))))
|
||||
(list (go-env-lookup env "a") (go-env-lookup env "b")))
|
||||
(list 7 2))
|
||||
|
||||
(go-eval-test
|
||||
"defer fires when fn panics (not just normal return)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func note(c chan int) { c <- 5 }") (go-parse "func safe(c chan int) { defer note(c) ; defer recover() ; panic(\"!\") }") (go-parse "safe(ch)") (go-parse "got := <-ch")))))
|
||||
(go-env-lookup env "got"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"panic with nil value: still surfaces as (:go-panic nil)"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "panic(nil)")))))
|
||||
r)
|
||||
(list :go-panic nil))
|
||||
|
||||
(go-eval-test
|
||||
"panic inside loop body: aborts loop + propagates"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "func find(x int) { for i := 0; i < 10; i = i + 1 { if i == x { panic(i) } } }") (go-parse "find(3)")))))
|
||||
r)
|
||||
(list :go-panic 3))
|
||||
|
||||
(go-eval-test
|
||||
"defer in panicking fn: still runs even though no return reached"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func mark(c chan int) { c <- 8 }") (go-parse "func inner(c chan int) { defer mark(c) ; panic(\"!\") }") (go-parse "func outer(c chan int) { defer recover() ; inner(c) }") (go-parse "outer(ch)") (go-parse "got := <-ch")))))
|
||||
(go-env-lookup env "got"))
|
||||
8)
|
||||
|
||||
(go-eval-test
|
||||
"defer fn captures args by value, not reference (re-confirm)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func pushN(c chan int, v int) { c <- v }") (go-parse "func run(c chan int) { defer recover() ; x := 5 ; defer pushN(c, x) ; x = 999 ; panic(\"k\") }") (go-parse "run(ch)") (go-parse "got := <-ch")))))
|
||||
(go-env-lookup env "got"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"generic: identity Id[T any](x) returns x at runtime"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func Id[T any](x T) T { return x }") (go-parse "r := Id(42)")))))
|
||||
(go-env-lookup env "r"))
|
||||
42)
|
||||
|
||||
(go-eval-test
|
||||
"generic: Id works with strings (type erasure)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func Id[T any](x T) T { return x }") (go-parse "r := Id(\"hi\")")))))
|
||||
(go-env-lookup env "r"))
|
||||
"hi")
|
||||
|
||||
(go-eval-test
|
||||
"generic: Map[T, U] over []int with double — produces []int"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func Map[T any, U any](xs []T, f func(T) U) []U { r := []int{} ; for i, v := range xs { r = append(r, f(v)) } ; return r }") (go-parse "func dbl(x int) int { return x * 2 }") (go-parse "out := Map([]int{1, 2, 3}, dbl)") (go-parse "first := out[0]") (go-parse "second := out[1]") (go-parse "third := out[2]")))))
|
||||
(list
|
||||
(go-env-lookup env "first")
|
||||
(go-env-lookup env "second")
|
||||
(go-env-lookup env "third")))
|
||||
(list 2 4 6))
|
||||
|
||||
(go-eval-test
|
||||
"generic: Filter[T any] keeps elements satisfying predicate"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func Filter[T any](xs []T, p func(T) bool) []T { r := []int{} ; for i, v := range xs { if p(v) { r = append(r, v) } } ; return r }") (go-parse "func gt3(x int) bool { return x > 3 }") (go-parse "out := Filter([]int{1, 2, 3, 4, 5, 6}, gt3)") (go-parse "n := len(out)") (go-parse "first := out[0]") (go-parse "last := out[2]")))))
|
||||
(list
|
||||
(go-env-lookup env "n")
|
||||
(go-env-lookup env "first")
|
||||
(go-env-lookup env "last")))
|
||||
(list 3 4 6))
|
||||
|
||||
(go-eval-test
|
||||
"generic: Reduce[T, U] sums []int with seed 0"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func Reduce[T any, U any](xs []T, seed U, f func(U, T) U) U { acc := seed ; for i, v := range xs { acc = f(acc, v) } ; return acc }") (go-parse "func add(a int, b int) int { return a + b }") (go-parse "total := Reduce([]int{10, 20, 30, 40}, 0, add)")))))
|
||||
(go-env-lookup env "total"))
|
||||
100)
|
||||
|
||||
(go-eval-test
|
||||
"generic: First[T any]([]T) T returns element zero"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func First[T any](xs []T) T { return xs[0] }") (go-parse "v := First([]int{42, 99})")))))
|
||||
(go-env-lookup env "v"))
|
||||
42)
|
||||
|
||||
(define
|
||||
go-eval-test-summary
|
||||
(str "eval " go-eval-test-pass "/" go-eval-test-count))
|
||||
339
lib/go/tests/lex.sx
Normal file
339
lib/go/tests/lex.sx
Normal file
@@ -0,0 +1,339 @@
|
||||
;; Go tokenizer tests.
|
||||
|
||||
(define go-test-count 0)
|
||||
(define go-test-pass 0)
|
||||
(define go-test-fails (list))
|
||||
|
||||
(define gtok-type (fn (t) (get t :type)))
|
||||
(define gtok-value (fn (t) (get t :value)))
|
||||
(define tok-types (fn (src) (map gtok-type (go-tokenize src))))
|
||||
(define tok-values (fn (src) (map gtok-value (go-tokenize src))))
|
||||
|
||||
(define
|
||||
go-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! go-test-count (+ go-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! go-test-pass (+ go-test-pass 1))
|
||||
(append! go-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
;; ── empty / whitespace ────────────────────────────────────────────
|
||||
(go-test "empty source" (tok-types "") (list "eof"))
|
||||
(go-test "spaces only" (tok-types " ") (list "eof"))
|
||||
(go-test "tabs only" (tok-types "\t\t") (list "eof"))
|
||||
(go-test
|
||||
"newline only — no prior token, no ASI"
|
||||
(tok-types "\n")
|
||||
(list "eof"))
|
||||
|
||||
;; ── identifiers ───────────────────────────────────────────────────
|
||||
(go-test "ident: simple" (tok-values "foo") (list "foo" "\n" nil))
|
||||
(go-test
|
||||
"ident: underscore prefix"
|
||||
(tok-values "_bar")
|
||||
(list "_bar" "\n" nil))
|
||||
(go-test "ident: mixed case" (tok-values "fooBar") (list "fooBar" "\n" nil))
|
||||
(go-test "ident: with digits" (tok-values "x123") (list "x123" "\n" nil))
|
||||
(go-test "ident: type tag" (tok-types "foo") (list "ident" "semi" "eof"))
|
||||
|
||||
;; ── keywords (all 25) ─────────────────────────────────────────────
|
||||
(go-test "kw: break" (tok-types "break") (list "keyword" "semi" "eof"))
|
||||
(go-test "kw: case" (tok-types "case") (list "keyword" "eof"))
|
||||
(go-test "kw: chan" (tok-types "chan") (list "keyword" "eof"))
|
||||
(go-test "kw: const" (tok-types "const") (list "keyword" "eof"))
|
||||
(go-test "kw: continue" (tok-types "continue") (list "keyword" "semi" "eof"))
|
||||
(go-test "kw: default" (tok-types "default") (list "keyword" "eof"))
|
||||
(go-test "kw: defer" (tok-types "defer") (list "keyword" "eof"))
|
||||
(go-test "kw: else" (tok-types "else") (list "keyword" "eof"))
|
||||
(go-test
|
||||
"kw: fallthrough"
|
||||
(tok-types "fallthrough")
|
||||
(list "keyword" "semi" "eof"))
|
||||
(go-test "kw: for" (tok-types "for") (list "keyword" "eof"))
|
||||
(go-test "kw: func" (tok-types "func") (list "keyword" "eof"))
|
||||
(go-test "kw: go" (tok-types "go") (list "keyword" "eof"))
|
||||
(go-test "kw: goto" (tok-types "goto") (list "keyword" "eof"))
|
||||
(go-test "kw: if" (tok-types "if") (list "keyword" "eof"))
|
||||
(go-test "kw: import" (tok-types "import") (list "keyword" "eof"))
|
||||
(go-test "kw: interface" (tok-types "interface") (list "keyword" "eof"))
|
||||
(go-test "kw: map" (tok-types "map") (list "keyword" "eof"))
|
||||
(go-test "kw: package" (tok-types "package") (list "keyword" "eof"))
|
||||
(go-test "kw: range" (tok-types "range") (list "keyword" "eof"))
|
||||
(go-test "kw: return" (tok-types "return") (list "keyword" "semi" "eof"))
|
||||
(go-test "kw: select" (tok-types "select") (list "keyword" "eof"))
|
||||
(go-test "kw: struct" (tok-types "struct") (list "keyword" "eof"))
|
||||
(go-test "kw: switch" (tok-types "switch") (list "keyword" "eof"))
|
||||
(go-test "kw: type" (tok-types "type") (list "keyword" "eof"))
|
||||
(go-test "kw: var" (tok-types "var") (list "keyword" "eof"))
|
||||
|
||||
;; ── integer literals — decimal ────────────────────────────────────
|
||||
(go-test "int: zero" (tok-values "0") (list "0" "\n" nil))
|
||||
(go-test "int: small" (tok-values "42") (list "42" "\n" nil))
|
||||
(go-test "int: bigger" (tok-values "123456") (list "123456" "\n" nil))
|
||||
(go-test "int: type" (tok-types "42") (list "int" "semi" "eof"))
|
||||
|
||||
;; ── integer literals — prefixed + underscores ─────────────────────
|
||||
(go-test "int: hex lower" (tok-values "0x1f") (list "0x1f" "\n" nil))
|
||||
(go-test "int: hex upper-x" (tok-values "0X1F") (list "0X1F" "\n" nil))
|
||||
(go-test
|
||||
"int: hex mixed digits"
|
||||
(tok-values "0xDEADbeef")
|
||||
(list "0xDEADbeef" "\n" nil))
|
||||
(go-test "int: binary lower" (tok-values "0b1010") (list "0b1010" "\n" nil))
|
||||
(go-test "int: binary upper" (tok-values "0B1101") (list "0B1101" "\n" nil))
|
||||
(go-test "int: octal modern" (tok-values "0o755") (list "0o755" "\n" nil))
|
||||
(go-test "int: octal upper" (tok-values "0O17") (list "0O17" "\n" nil))
|
||||
(go-test "int: octal legacy" (tok-values "0755") (list "0755" "\n" nil))
|
||||
(go-test "int: hex type" (tok-types "0x1F") (list "int" "semi" "eof"))
|
||||
(go-test "int: bin type" (tok-types "0b101") (list "int" "semi" "eof"))
|
||||
(go-test
|
||||
"int: dec underscore"
|
||||
(tok-values "1_000_000")
|
||||
(list "1_000_000" "\n" nil))
|
||||
(go-test
|
||||
"int: hex underscore"
|
||||
(tok-values "0xDEAD_BEEF")
|
||||
(list "0xDEAD_BEEF" "\n" nil))
|
||||
(go-test
|
||||
"int: bin underscore"
|
||||
(tok-values "0b1010_1010")
|
||||
(list "0b1010_1010" "\n" nil))
|
||||
(go-test
|
||||
"int: hex then +"
|
||||
(tok-types "0xFF + 1")
|
||||
(list "int" "op" "int" "semi" "eof"))
|
||||
|
||||
;; ── float literals (Go spec § Floating-point literals) ────────────
|
||||
(go-test "float: simple" (tok-values "3.14") (list "3.14" "\n" nil))
|
||||
(go-test "float: trailing dot" (tok-values "1.") (list "1." "\n" nil))
|
||||
(go-test "float: leading dot" (tok-values ".5") (list ".5" "\n" nil))
|
||||
(go-test "float: exp lower" (tok-values "1e10") (list "1e10" "\n" nil))
|
||||
(go-test "float: exp upper" (tok-values "1E5") (list "1E5" "\n" nil))
|
||||
(go-test "float: exp negative" (tok-values "1.5e-3") (list "1.5e-3" "\n" nil))
|
||||
(go-test "float: exp positive" (tok-values "2.0e+2") (list "2.0e+2" "\n" nil))
|
||||
(go-test "float: zero" (tok-values "0.0") (list "0.0" "\n" nil))
|
||||
(go-test "float: dot-only-exp" (tok-values ".5e2") (list ".5e2" "\n" nil))
|
||||
(go-test "float: underscore" (tok-values "1_000.5") (list "1_000.5" "\n" nil))
|
||||
(go-test "float: type" (tok-types "3.14") (list "float" "semi" "eof"))
|
||||
(go-test
|
||||
"float: trailing dot type"
|
||||
(tok-types "1.")
|
||||
(list "float" "semi" "eof"))
|
||||
(go-test
|
||||
"float: exp-only type"
|
||||
(tok-types "1e10")
|
||||
(list "float" "semi" "eof"))
|
||||
(go-test
|
||||
"float: then +"
|
||||
(tok-types "3.14 + 0.1")
|
||||
(list "float" "op" "float" "semi" "eof"))
|
||||
(go-test
|
||||
"float: greedy 1.method"
|
||||
(tok-types "1.method")
|
||||
(list "float" "ident" "semi" "eof"))
|
||||
|
||||
;; ── imaginary literals (Go spec § Imaginary literals) ─────────────
|
||||
(go-test "imag: int i" (tok-values "2i") (list "2i" "\n" nil))
|
||||
(go-test "imag: float i" (tok-values "3.14i") (list "3.14i" "\n" nil))
|
||||
(go-test "imag: exp i" (tok-values "1e2i") (list "1e2i" "\n" nil))
|
||||
(go-test "imag: int-i type" (tok-types "2i") (list "imag" "semi" "eof"))
|
||||
(go-test "imag: float-i type" (tok-types "3.14i") (list "imag" "semi" "eof"))
|
||||
(go-test "imag: ASI at newline" (tok-types "1i\n") (list "imag" "semi" "eof"))
|
||||
|
||||
;; ── string literals ───────────────────────────────────────────────
|
||||
(go-test "raw: simple" (tok-values "`hello`") (list "hello" "\n" nil))
|
||||
(go-test "raw: empty" (tok-values "``") (list "" "\n" nil))
|
||||
(go-test
|
||||
"raw: backslash literal — no escape processing"
|
||||
(tok-values "`a\\nb`")
|
||||
(list "a\\nb" "\n" nil))
|
||||
(go-test
|
||||
"raw: multi-line"
|
||||
(tok-values "`line1\nline2`")
|
||||
(list "line1\nline2" "\n" nil))
|
||||
(go-test
|
||||
"raw: contains double-quote"
|
||||
(tok-values "`say \"hi\"`")
|
||||
(list "say \"hi\"" "\n" nil))
|
||||
(go-test
|
||||
"raw: CR stripped (Go spec § String literals)"
|
||||
(tok-values "`a\r\nb`")
|
||||
(list "a\nb" "\n" nil))
|
||||
(go-test "raw: type" (tok-types "`x`") (list "string" "semi" "eof"))
|
||||
|
||||
;; ── rune literals ─────────────────────────────────────────────────
|
||||
(go-test
|
||||
"raw: then +"
|
||||
(tok-types "`x` + 1")
|
||||
(list "string" "op" "int" "semi" "eof"))
|
||||
(go-test
|
||||
"raw: ASI at newline after"
|
||||
(tok-types "`abc`\n")
|
||||
(list "string" "semi" "eof"))
|
||||
(go-test "string: empty" (tok-values "\"\"") (list "" "\n" nil))
|
||||
|
||||
;; ── comments ──────────────────────────────────────────────────────
|
||||
(go-test "string: hello" (tok-values "\"hello\"") (list "hello" "\n" nil))
|
||||
(go-test
|
||||
"string: with space"
|
||||
(tok-values "\"hi there\"")
|
||||
(list "hi there" "\n" nil))
|
||||
(go-test "string: escape n" (tok-values "\"a\\nb\"") (list "a\nb" "\n" nil))
|
||||
(go-test "string: escape quote" (tok-values "\"a\\\"b\"") (list "a\"b" "\n" nil))
|
||||
(go-test
|
||||
"string: escape backslash"
|
||||
(tok-values "\"a\\\\b\"")
|
||||
(list "a\\b" "\n" nil))
|
||||
|
||||
;; ── operators & punctuation ───────────────────────────────────────
|
||||
(go-test "string: type" (tok-types "\"x\"") (list "string" "semi" "eof"))
|
||||
(go-test "rune: simple" (tok-values "'a'") (list "a" "\n" nil))
|
||||
(go-test "rune: escape" (tok-values "'\\n'") (list "\n" "\n" nil))
|
||||
(go-test "rune: type" (tok-types "'a'") (list "rune" "semi" "eof"))
|
||||
(go-test "line comment" (tok-types "// ignored") (list "eof"))
|
||||
(go-test "line comment then code" (tok-values "// hi\nx") (list "x" "\n" nil))
|
||||
(go-test "block comment" (tok-types "/* a b c */") (list "eof"))
|
||||
(go-test
|
||||
"block comment inline"
|
||||
(tok-values "x /* mid */ y")
|
||||
(list "x" "y" "\n" nil))
|
||||
(go-test
|
||||
"block comment with newline — ASI"
|
||||
(tok-types "x /* multi\nline */ y")
|
||||
(list "ident" "semi" "ident" "semi" "eof"))
|
||||
|
||||
;; ── automatic semicolon insertion (Go spec § Semicolons) ──────────
|
||||
(go-test
|
||||
"ops: arithmetic"
|
||||
(tok-values "+ - * / %")
|
||||
(list "+" "-" "*" "/" "%" nil))
|
||||
(go-test
|
||||
"ops: comparison"
|
||||
(tok-values "== != < > <= >=")
|
||||
(list "==" "!=" "<" ">" "<=" ">=" nil))
|
||||
(go-test "ops: logical" (tok-values "&& || !") (list "&&" "||" "!" nil))
|
||||
(go-test
|
||||
"ops: assign forms"
|
||||
(tok-values "= := += -=")
|
||||
(list "=" ":=" "+=" "-=" nil))
|
||||
(go-test "ops: channel arrow" (tok-values "<- chan") (list "<-" "chan" nil))
|
||||
(go-test "ops: incdec ASI" (tok-types "++ --") (list "op" "op" "semi" "eof"))
|
||||
(go-test "ops: ellipsis" (tok-values "...") (list "..." nil))
|
||||
(go-test
|
||||
"punct: all brackets"
|
||||
(tok-values "( ) { } [ ]")
|
||||
(list "(" ")" "{" "}" "[" "]" "\n" nil))
|
||||
(go-test
|
||||
"punct: comma colon dot"
|
||||
(tok-values ", : .")
|
||||
(list "," ":" "." nil))
|
||||
(go-test
|
||||
"op-audit: tilde (generics type-set)"
|
||||
(tok-values "~int")
|
||||
(list "~" "int" "\n" nil))
|
||||
(go-test
|
||||
"op-audit: all arithmetic + assignment"
|
||||
(tok-values "+ - * / % += -= *= /= %=")
|
||||
(list "+" "-" "*" "/" "%" "+=" "-=" "*=" "/=" "%=" nil))
|
||||
(go-test
|
||||
"op-audit: all bitwise + assignment"
|
||||
(tok-values "& | ^ << >> &^ &= |= ^= <<= >>= &^=")
|
||||
(list "&" "|" "^" "<<" ">>" "&^" "&=" "|=" "^=" "<<=" ">>=" "&^=" nil))
|
||||
(go-test
|
||||
"op-audit: all comparison + logical"
|
||||
(tok-values "== != < > <= >= && || !")
|
||||
(list "==" "!=" "<" ">" "<=" ">=" "&&" "||" "!" nil))
|
||||
(go-test
|
||||
"op-audit: assign / decls / arrows / variadic / inc-dec"
|
||||
(tok-values "= := <- ++ -- ...")
|
||||
(list "=" ":=" "<-" "++" "--" "..." nil))
|
||||
|
||||
;; ── short program ─────────────────────────────────────────────────
|
||||
(go-test
|
||||
"op-audit: punctuation"
|
||||
(tok-values "( ) [ ] { } , . :")
|
||||
(list "(" ")" "[" "]" "{" "}" "," "." ":" nil))
|
||||
(go-test
|
||||
"ASI: after ident at newline"
|
||||
(tok-types "x\ny")
|
||||
(list "ident" "semi" "ident" "semi" "eof"))
|
||||
(go-test "ASI: after int" (tok-types "42\n") (list "int" "semi" "eof"))
|
||||
|
||||
;; ── report ────────────────────────────────────────────────────────
|
||||
(go-test "ASI: after float" (tok-types "3.14\n") (list "float" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: after string"
|
||||
(tok-types "\"hi\"\n")
|
||||
(list "string" "semi" "eof"))
|
||||
|
||||
(go-test "ASI: after rune" (tok-types "'a'\n") (list "rune" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: after )"
|
||||
(tok-types "f()\n")
|
||||
(list "ident" "op" "op" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: after ]"
|
||||
(tok-types "x[0]\n")
|
||||
(list "ident" "op" "int" "op" "semi" "eof"))
|
||||
|
||||
(go-test "ASI: after }" (tok-types "{}\n") (list "op" "op" "semi" "eof"))
|
||||
|
||||
(go-test "ASI: after ++" (tok-types "i++\n") (list "ident" "op" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: NOT after +"
|
||||
(tok-types "x +\ny")
|
||||
(list "ident" "op" "ident" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: NOT after ("
|
||||
(tok-types "f(\nx)")
|
||||
(list "ident" "op" "ident" "op" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: blank lines collapse — single semi only"
|
||||
(tok-types "x\n\n\ny")
|
||||
(list "ident" "semi" "ident" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: at EOF after ident"
|
||||
(tok-types "x")
|
||||
(list "ident" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: explicit semi"
|
||||
(tok-types "x;y")
|
||||
(list "ident" "semi" "ident" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"short-decl: x := 42 (types)"
|
||||
(tok-types "x := 42")
|
||||
(list "ident" "op" "int" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"short-decl: x := 42 (values)"
|
||||
(tok-values "x := 42")
|
||||
(list "x" ":=" "42" "\n" nil))
|
||||
|
||||
(go-test
|
||||
"func decl shape"
|
||||
(tok-types "func foo() int { return 0 }")
|
||||
(list
|
||||
"keyword"
|
||||
"ident"
|
||||
"op"
|
||||
"op"
|
||||
"ident"
|
||||
"op"
|
||||
"keyword"
|
||||
"int"
|
||||
"op"
|
||||
"semi"
|
||||
"eof"))
|
||||
|
||||
(define go-lex-test-summary (str "lex " go-test-pass "/" go-test-count))
|
||||
1231
lib/go/tests/parse.sx
Normal file
1231
lib/go/tests/parse.sx
Normal file
File diff suppressed because it is too large
Load Diff
311
lib/go/tests/runtime.sx
Normal file
311
lib/go/tests/runtime.sx
Normal file
@@ -0,0 +1,311 @@
|
||||
;; Go runtime tests — goroutines + channels.
|
||||
|
||||
(define go-rt-test-count 0)
|
||||
(define go-rt-test-pass 0)
|
||||
(define go-rt-test-fails (list))
|
||||
|
||||
(define
|
||||
go-rt-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! go-rt-test-count (+ go-rt-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! go-rt-test-pass (+ go-rt-test-pass 1))
|
||||
(append! go-rt-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
;; ── channel primitives (direct API, no source parsing) ─────────
|
||||
(go-rt-test "chan: make returns a chan value" (go-chan? (go-make-chan)) true)
|
||||
|
||||
(go-rt-test
|
||||
"chan: distinct channels have distinct identity"
|
||||
(= (go-make-chan) (go-make-chan))
|
||||
false)
|
||||
|
||||
(go-rt-test
|
||||
"chan: send + recv round-trip"
|
||||
(let
|
||||
((ch (go-make-chan)))
|
||||
(go-chan-send! ch 42)
|
||||
(go-chan-recv! ch))
|
||||
42)
|
||||
|
||||
(go-rt-test
|
||||
"chan: empty recv returns :empty marker"
|
||||
(let ((ch (go-make-chan))) (go-chan-recv! ch))
|
||||
:empty)
|
||||
|
||||
(go-rt-test
|
||||
"chan: FIFO order"
|
||||
(let
|
||||
((ch (go-make-chan)))
|
||||
(go-chan-send! ch 1)
|
||||
(go-chan-send! ch 2)
|
||||
(go-chan-send! ch 3)
|
||||
(list (go-chan-recv! ch) (go-chan-recv! ch) (go-chan-recv! ch)))
|
||||
(list 1 2 3))
|
||||
|
||||
(go-rt-test
|
||||
"chan: closed? flag flips"
|
||||
(let
|
||||
((ch (go-make-chan)))
|
||||
(let
|
||||
((before (go-chan-closed? ch)))
|
||||
(go-chan-close! ch)
|
||||
(list before (go-chan-closed? ch))))
|
||||
(list false true))
|
||||
|
||||
;; ── source-level: make / send / recv / close ───────────────────
|
||||
(go-rt-test
|
||||
"src: ch := make() returns chan"
|
||||
(go-chan?
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()")))))
|
||||
(go-env-lookup env "ch")))
|
||||
true)
|
||||
|
||||
(go-rt-test
|
||||
"src: ch <- 5 then <-ch = 5"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "ch <- 5")))))
|
||||
(go-eval env (go-parse "<-ch")))
|
||||
5)
|
||||
|
||||
(go-rt-test
|
||||
"src: go + chan ping-pong"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func sender(c chan int) { c <- 99 }") (go-parse "ch := make()") (go-parse "go sender(ch)")))))
|
||||
(go-eval env (go-parse "<-ch")))
|
||||
99)
|
||||
|
||||
(go-rt-test
|
||||
"src: close(ch) marks it closed"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "close(ch)")))))
|
||||
(go-chan-closed? (go-env-lookup env "ch")))
|
||||
true)
|
||||
|
||||
(go-rt-test
|
||||
"src: multiple goroutines feeding one channel"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func push(c chan int, v int) { c <- v }") (go-parse "ch := make()") (go-parse "go push(ch, 1)") (go-parse "go push(ch, 2)") (go-parse "go push(ch, 3)")))))
|
||||
(list
|
||||
(go-eval env (go-parse "<-ch"))
|
||||
(go-eval env (go-parse "<-ch"))
|
||||
(go-eval env (go-parse "<-ch"))))
|
||||
(list 1 2 3))
|
||||
|
||||
(go-rt-test
|
||||
"src: worker pattern — send sum back"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func work(c chan int, a int, b int) { c <- a + b }") (go-parse "result := make()") (go-parse "go work(result, 7, 13)")))))
|
||||
(go-eval env (go-parse "<-result")))
|
||||
20)
|
||||
|
||||
;; ── report ─────────────────────────────────────────────────────
|
||||
(go-rt-test
|
||||
"select: default runs when no case is ready"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "x := 0") (go-parse "select { case <-ch: x = 1 ; default: x = 99 }")))))
|
||||
(go-env-lookup env "x"))
|
||||
99)
|
||||
|
||||
(go-rt-test
|
||||
"select: recv case fires when ready"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "ch <- 7") (go-parse "x := 0") (go-parse "select { case <-ch: x = 1 ; default: x = 99 }")))))
|
||||
(go-env-lookup env "x"))
|
||||
1)
|
||||
|
||||
(go-rt-test
|
||||
"select: recv-into-var binds the value"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "ch <- 42") (go-parse "select { case v := <-ch: v }")))))
|
||||
(go-env-lookup env "v"))
|
||||
42)
|
||||
|
||||
(go-rt-test
|
||||
"select: send case (always ready in v0)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "select { case ch <- 5: }")))))
|
||||
(go-chan-len (go-env-lookup env "ch")))
|
||||
1)
|
||||
|
||||
(go-rt-test
|
||||
"select: picks first ready case"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := make()") (go-parse "b := make()") (go-parse "b <- 100") (go-parse "x := 0") (go-parse "select { case <-a: x = 1 ; case <-b: x = 2 ; default: x = 99 }")))))
|
||||
(go-env-lookup env "x"))
|
||||
2)
|
||||
|
||||
(go-rt-test
|
||||
"select: no default + nothing ready → blocked error"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()")))))
|
||||
(go-eval-stmt env (go-parse "select { case <-ch: }") (list)))
|
||||
(list :eval-error :select-blocked-no-default))
|
||||
|
||||
(go-rt-test
|
||||
"select: combined with goroutine fan-in"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func push(c chan int, v int) { c <- v }") (go-parse "ch := make()") (go-parse "go push(ch, 7)") (go-parse "result := 0") (go-parse "select { case v := <-ch: result = v ; default: result = -1 }")))))
|
||||
(go-env-lookup env "result"))
|
||||
7)
|
||||
|
||||
(go-rt-test
|
||||
"range: slice — sum of 1..5"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var sum = 0") (go-parse "a := []int{1, 2, 3, 4, 5}") (go-parse "for i, v := range a { sum = sum + v }")))))
|
||||
(go-env-lookup env "sum"))
|
||||
15)
|
||||
|
||||
(go-rt-test
|
||||
"range: slice — key only (index)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var s = 0") (go-parse "a := []int{10, 20, 30}") (go-parse "for i := range a { s = s + i }")))))
|
||||
(go-env-lookup env "s"))
|
||||
3)
|
||||
|
||||
(go-rt-test
|
||||
"range: map — sum values"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var s = 0") (go-parse "m := map[string]int{\"a\": 1, \"b\": 2, \"c\": 3}") (go-parse "for k, v := range m { s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
6)
|
||||
|
||||
(go-rt-test
|
||||
"range: channel — collect all buffered"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "ch <- 1") (go-parse "ch <- 2") (go-parse "ch <- 3") (go-parse "var sum = 0") (go-parse "for v := range ch { sum = sum + v }")))))
|
||||
(go-env-lookup env "sum"))
|
||||
6)
|
||||
|
||||
(go-rt-test
|
||||
"range: slice with break exits early"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var s = 0") (go-parse "a := []int{1, 2, 3, 4, 5}") (go-parse "for i, v := range a { if v == 3 { break } ; s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
3)
|
||||
|
||||
(go-rt-test
|
||||
"range: slice with continue skips an element"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var s = 0") (go-parse "a := []int{1, 2, 3, 4, 5}") (go-parse "for i, v := range a { if v == 3 { continue } ; s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
12)
|
||||
|
||||
(go-rt-test
|
||||
"range: empty slice — body never runs"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var s = 0") (go-parse "a := []int{}") (go-parse "for v := range a { s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
0)
|
||||
|
||||
(go-rt-test
|
||||
"range: chan + goroutine producer"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func emit(c chan int) { c <- 10 ; c <- 20 ; c <- 30 }") (go-parse "ch := make()") (go-parse "go emit(ch)") (go-parse "var total = 0") (go-parse "for v := range ch { total = total + v }")))))
|
||||
(go-env-lookup env "total"))
|
||||
60)
|
||||
|
||||
(go-rt-test
|
||||
"timer: after(d) returns a ready channel (v0 stub)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "t := after(100)")))))
|
||||
(go-chan-len (go-env-lookup env "t")))
|
||||
1)
|
||||
|
||||
(go-rt-test
|
||||
"select with timer (after) — buffered value wins, timer is fallback"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func push99(c chan int) { c <- 99 }") (go-parse "c := make()") (go-parse "go push99(c)") (go-parse "t := after(0)") (go-parse "var v = 0") (go-parse "select { case x := <-c: v = x; case y := <-t: v = -1 }")))))
|
||||
(go-env-lookup env "v"))
|
||||
99)
|
||||
|
||||
(go-rt-test
|
||||
"fan-in: 3 producer goroutines, main sums their values"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func send10(c chan int) { c <- 10 }") (go-parse "func send20(c chan int) { c <- 20 }") (go-parse "func send30(c chan int) { c <- 30 }") (go-parse "c := make()") (go-parse "go send10(c)") (go-parse "go send20(c)") (go-parse "go send30(c)") (go-parse "var s = 0") (go-parse "for i := 0; i < 3; i = i + 1 { v := <-c ; s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
60)
|
||||
|
||||
(go-rt-test
|
||||
"worker queue: range over closed buffered chan drains all jobs"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "jobs := make()") (go-parse "jobs <- 1") (go-parse "jobs <- 2") (go-parse "jobs <- 3") (go-parse "jobs <- 4") (go-parse "close(jobs)") (go-parse "var s = 0") (go-parse "for j := range jobs { s = s + j }")))))
|
||||
(go-env-lookup env "s"))
|
||||
10)
|
||||
|
||||
(go-rt-test
|
||||
"pipeline: stage1 squares, stage2 sums via channels"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func sq(in chan int, out chan int) { for v := range in { out <- v * v } ; close(out) }") (go-parse "in := make()") (go-parse "out := make()") (go-parse "in <- 2") (go-parse "in <- 3") (go-parse "in <- 4") (go-parse "close(in)") (go-parse "go sq(in, out)") (go-parse "var s = 0") (go-parse "for v := range out { s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
29)
|
||||
|
||||
(go-rt-test
|
||||
"fan-out then fan-in: split job stream across N workers, collect results"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func worker(in chan int, out chan int) { for v := range in { out <- v + 100 } }") (go-parse "jobs := make()") (go-parse "results := make()") (go-parse "jobs <- 1") (go-parse "jobs <- 2") (go-parse "jobs <- 3") (go-parse "close(jobs)") (go-parse "go worker(jobs, results)") (go-parse "close(results)") (go-parse "var s = 0") (go-parse "for r := range results { s = s + r }")))))
|
||||
(go-env-lookup env "s"))
|
||||
306)
|
||||
|
||||
(go-rt-test
|
||||
"select: first ready case wins (channel order = source order)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := make()") (go-parse "b := make()") (go-parse "a <- 1") (go-parse "b <- 2") (go-parse "var v = 0") (go-parse "select { case x := <-a: v = 10; case y := <-b: v = 20 }")))))
|
||||
(go-env-lookup env "v"))
|
||||
10)
|
||||
|
||||
(go-rt-test
|
||||
"select: only second case has a value, that branch executes"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := make()") (go-parse "b := make()") (go-parse "b <- 7") (go-parse "var v = 0") (go-parse "select { case x := <-a: v = -1; case y := <-b: v = y }")))))
|
||||
(go-env-lookup env "v"))
|
||||
7)
|
||||
|
||||
(go-rt-test
|
||||
"select with default: no case ready → default fires"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := make()") (go-parse "b := make()") (go-parse "var v = 0") (go-parse "select { case x := <-a: v = 1; case y := <-b: v = 2; default: v = 99 }")))))
|
||||
(go-env-lookup env "v"))
|
||||
99)
|
||||
|
||||
(go-rt-test
|
||||
"producer-consumer: one goroutine fills, main drains by count"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func fill5(c chan int) { c <- 1 ; c <- 2 ; c <- 3 ; c <- 4 ; c <- 5 }") (go-parse "c := make()") (go-parse "go fill5(c)") (go-parse "var s = 0") (go-parse "for i := 0; i < 5; i = i + 1 { v := <-c ; s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
15)
|
||||
|
||||
(go-rt-test
|
||||
"two-stage pipeline: doubler + adder threaded through 3 channels"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func dbl(in chan int, mid chan int) { for v := range in { mid <- v * 2 } ; close(mid) }") (go-parse "func plus1(mid chan int, out chan int) { for v := range mid { out <- v + 1 } ; close(out) }") (go-parse "in := make()") (go-parse "mid := make()") (go-parse "out := make()") (go-parse "in <- 1") (go-parse "in <- 2") (go-parse "in <- 3") (go-parse "close(in)") (go-parse "go dbl(in, mid)") (go-parse "go plus1(mid, out)") (go-parse "var s = 0") (go-parse "for v := range out { s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
15)
|
||||
|
||||
(go-rt-test
|
||||
"channel as counter: append integers, count buffer size"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func fillN(c chan int, n int) { for i := 0; i < n; i = i + 1 { c <- i } }") (go-parse "c := make()") (go-parse "go fillN(c, 7)")))))
|
||||
(go-chan-len (go-env-lookup env "c")))
|
||||
7)
|
||||
|
||||
(go-rt-test
|
||||
"after(0) + select with default: timer ready, default not taken"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "t := after(0)") (go-parse "var v = 0") (go-parse "select { case x := <-t: v = 7; default: v = -1 }")))))
|
||||
(go-env-lookup env "v"))
|
||||
7)
|
||||
|
||||
(go-rt-test
|
||||
"tick collector: timer + counter accumulates ticks via range count"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func emitN(c chan int, n int) { for i := 0; i < n; i = i + 1 { c <- 1 } ; close(c) }") (go-parse "ticks := make()") (go-parse "go emitN(ticks, 5)") (go-parse "var total = 0") (go-parse "for t := range ticks { total = total + t }")))))
|
||||
(go-env-lookup env "total"))
|
||||
5)
|
||||
|
||||
(define
|
||||
go-rt-test-summary
|
||||
(str "runtime " go-rt-test-pass "/" go-rt-test-count))
|
||||
209
lib/go/tests/stdlib.sx
Normal file
209
lib/go/tests/stdlib.sx
Normal file
@@ -0,0 +1,209 @@
|
||||
;; Go stdlib tests — exercises lib/go/std/*.sx packages via the
|
||||
;; idiomatic `import-style` qualified call (`strings.Contains(...)`).
|
||||
|
||||
(define go-std-test-count 0)
|
||||
(define go-std-test-pass 0)
|
||||
(define go-std-test-fails (list))
|
||||
|
||||
(define
|
||||
go-std-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! go-std-test-count (+ go-std-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! go-std-test-pass (+ go-std-test-pass 1))
|
||||
(append! go-std-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define
|
||||
go-std-env
|
||||
;; Convenience: env with all stdlib packages registered.
|
||||
(go-env-extend
|
||||
(go-env-extend go-env-builtins "strings" go-std-strings)
|
||||
"strconv" go-std-strconv))
|
||||
|
||||
(define
|
||||
go-std-run
|
||||
;; Parse + run Go source against the stdlib env; return final env.
|
||||
(fn (src-list)
|
||||
(go-eval-program go-std-env (map go-parse src-list))))
|
||||
|
||||
;; ── strings.Contains ─────────────────────────────────────────────
|
||||
(go-std-test "strings.Contains: hit"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Contains(\"hello world\", \"world\")")) "r")
|
||||
true)
|
||||
|
||||
(go-std-test "strings.Contains: miss"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Contains(\"hello\", \"xyz\")")) "r")
|
||||
false)
|
||||
|
||||
(go-std-test "strings.Contains: empty substring is always present"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Contains(\"abc\", \"\")")) "r")
|
||||
true)
|
||||
|
||||
;; ── strings.HasPrefix / HasSuffix ────────────────────────────────
|
||||
(go-std-test "strings.HasPrefix: true"
|
||||
(go-env-lookup (go-std-run (list "r := strings.HasPrefix(\"hello world\", \"hello\")")) "r")
|
||||
true)
|
||||
|
||||
(go-std-test "strings.HasPrefix: false"
|
||||
(go-env-lookup (go-std-run (list "r := strings.HasPrefix(\"hello\", \"world\")")) "r")
|
||||
false)
|
||||
|
||||
(go-std-test "strings.HasSuffix: true"
|
||||
(go-env-lookup (go-std-run (list "r := strings.HasSuffix(\"hello world\", \"world\")")) "r")
|
||||
true)
|
||||
|
||||
(go-std-test "strings.HasSuffix: false"
|
||||
(go-env-lookup (go-std-run (list "r := strings.HasSuffix(\"hello\", \"world\")")) "r")
|
||||
false)
|
||||
|
||||
;; ── strings.Index ─────────────────────────────────────────────────
|
||||
(go-std-test "strings.Index: found at 6"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Index(\"hello world\", \"world\")")) "r")
|
||||
6)
|
||||
|
||||
(go-std-test "strings.Index: not found = -1"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Index(\"hello\", \"xyz\")")) "r")
|
||||
-1)
|
||||
|
||||
(go-std-test "strings.Index: empty substring = 0"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Index(\"abc\", \"\")")) "r")
|
||||
0)
|
||||
|
||||
;; ── strings.Count ─────────────────────────────────────────────────
|
||||
(go-std-test "strings.Count: 3 occurrences of 'a'"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Count(\"banana\", \"a\")")) "r")
|
||||
3)
|
||||
|
||||
(go-std-test "strings.Count: 0 occurrences"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Count(\"hello\", \"z\")")) "r")
|
||||
0)
|
||||
|
||||
;; ── strings.Repeat ────────────────────────────────────────────────
|
||||
(go-std-test "strings.Repeat: ab × 3 = ababab"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Repeat(\"ab\", 3)")) "r")
|
||||
"ababab")
|
||||
|
||||
(go-std-test "strings.Repeat: any × 0 = empty"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Repeat(\"x\", 0)")) "r")
|
||||
"")
|
||||
|
||||
;; ── strings.Join ──────────────────────────────────────────────────
|
||||
(go-std-test "strings.Join: comma-separated"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Join([]string{\"a\", \"b\", \"c\"}, \", \")")) "r")
|
||||
"a, b, c")
|
||||
|
||||
(go-std-test "strings.Join: empty slice = empty"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Join([]string{}, \"-\")")) "r")
|
||||
"")
|
||||
|
||||
(go-std-test "strings.Join: single elem = elem"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Join([]string{\"solo\"}, \",\")")) "r")
|
||||
"solo")
|
||||
|
||||
;; ── strings.ToUpper / ToLower ─────────────────────────────────────
|
||||
(go-std-test "strings.ToUpper: hello → HELLO"
|
||||
(go-env-lookup (go-std-run (list "r := strings.ToUpper(\"hello\")")) "r")
|
||||
"HELLO")
|
||||
|
||||
(go-std-test "strings.ToUpper: leaves digits alone"
|
||||
(go-env-lookup (go-std-run (list "r := strings.ToUpper(\"abc123\")")) "r")
|
||||
"ABC123")
|
||||
|
||||
(go-std-test "strings.ToLower: HELLO → hello"
|
||||
(go-env-lookup (go-std-run (list "r := strings.ToLower(\"HELLO\")")) "r")
|
||||
"hello")
|
||||
|
||||
(go-std-test "strings.ToLower: mixed case"
|
||||
(go-env-lookup (go-std-run (list "r := strings.ToLower(\"MixED\")")) "r")
|
||||
"mixed")
|
||||
|
||||
;; ── strings.TrimSpace ─────────────────────────────────────────────
|
||||
(go-std-test "strings.TrimSpace: leading + trailing"
|
||||
(go-env-lookup (go-std-run (list "r := strings.TrimSpace(\" hello \")")) "r")
|
||||
"hello")
|
||||
|
||||
(go-std-test "strings.TrimSpace: no whitespace = noop"
|
||||
(go-env-lookup (go-std-run (list "r := strings.TrimSpace(\"abc\")")) "r")
|
||||
"abc")
|
||||
|
||||
(go-std-test "strings.TrimSpace: all whitespace → empty"
|
||||
(go-env-lookup (go-std-run (list "r := strings.TrimSpace(\" \")")) "r")
|
||||
"")
|
||||
|
||||
;; ── strings.Split ─────────────────────────────────────────────────
|
||||
(go-std-test "strings.Split: comma-separated"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Split(\"a,b,c\", \",\")")) "r")
|
||||
(list :go-slice (list "a" "b" "c")))
|
||||
|
||||
(go-std-test "strings.Split: no occurrence → single elem"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Split(\"abc\", \"-\")")) "r")
|
||||
(list :go-slice (list "abc")))
|
||||
|
||||
(go-std-test "strings.Split: leading/trailing sep → empty pieces"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Split(\",a,\", \",\")")) "r")
|
||||
(list :go-slice (list "" "a" "")))
|
||||
|
||||
;; ── strings.Replace ───────────────────────────────────────────────
|
||||
(go-std-test "strings.Replace: replace once with n=1"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Replace(\"a,b,c\", \",\", \"-\", 1)")) "r")
|
||||
"a-b,c")
|
||||
|
||||
(go-std-test "strings.Replace: replace all with n=-1"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Replace(\"a,b,c\", \",\", \"-\", -1)")) "r")
|
||||
"a-b-c")
|
||||
|
||||
(go-std-test "strings.Replace: no match = noop"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Replace(\"abc\", \"x\", \"y\", -1)")) "r")
|
||||
"abc")
|
||||
|
||||
;; ── strconv.Itoa ─────────────────────────────────────────────────
|
||||
(go-std-test "strconv.Itoa: 42 → \"42\""
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Itoa(42)")) "r")
|
||||
"42")
|
||||
|
||||
(go-std-test "strconv.Itoa: 0 → \"0\""
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Itoa(0)")) "r")
|
||||
"0")
|
||||
|
||||
;; ── strconv.Atoi ─────────────────────────────────────────────────
|
||||
(go-std-test "strconv.Atoi: \"42\" → 42"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Atoi(\"42\")")) "r")
|
||||
42)
|
||||
|
||||
(go-std-test "strconv.Atoi: \"-7\" → -7"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Atoi(\"-7\")")) "r")
|
||||
-7)
|
||||
|
||||
(go-std-test "strconv.Atoi: \"100\" → 100"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Atoi(\"100\")")) "r")
|
||||
100)
|
||||
|
||||
(go-std-test "round-trip: Atoi(Itoa(n)) → n positive"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Atoi(strconv.Itoa(12345))")) "r")
|
||||
12345)
|
||||
|
||||
(go-std-test "round-trip: Atoi(Itoa(n)) → n negative"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Atoi(strconv.Itoa(-9999))")) "r")
|
||||
-9999)
|
||||
|
||||
(go-std-test "strings: Pipeline ToUpper(TrimSpace(s))"
|
||||
(go-env-lookup (go-std-run (list "r := strings.ToUpper(strings.TrimSpace(\" go \"))")) "r")
|
||||
"GO")
|
||||
|
||||
(go-std-test "strings: Join(Split(s, sep), sep) round-trip"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Join(strings.Split(\"a,b,c\", \",\"), \",\")")) "r")
|
||||
"a,b,c")
|
||||
|
||||
(go-std-test "strings: Count(Repeat(s, n), s) == n"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Count(strings.Repeat(\"ab\", 5), \"ab\")")) "r")
|
||||
5)
|
||||
|
||||
(go-std-test "round-trip: Itoa(Atoi(s)) → s"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Itoa(strconv.Atoi(\"777\"))")) "r")
|
||||
"777")
|
||||
|
||||
(define
|
||||
go-std-test-summary
|
||||
(str "stdlib " go-std-test-pass "/" go-std-test-count))
|
||||
778
lib/go/tests/types.sx
Normal file
778
lib/go/tests/types.sx
Normal file
@@ -0,0 +1,778 @@
|
||||
;; Go type-checker tests.
|
||||
|
||||
(define go-types-test-count 0)
|
||||
(define go-types-test-pass 0)
|
||||
(define go-types-test-fails (list))
|
||||
|
||||
(define
|
||||
go-types-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! go-types-test-count (+ go-types-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! go-types-test-pass (+ go-types-test-pass 1))
|
||||
(append! go-types-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
;; Convenience: parse + synth in one step.
|
||||
(define gtsy (fn (ctx src) (go-synth ctx (go-parse src))))
|
||||
(define gtchk (fn (ctx src ty) (go-check ctx (go-parse src) ty)))
|
||||
|
||||
;; ── context helpers ──────────────────────────────────────────────
|
||||
(go-types-test
|
||||
"ctx: empty lookup returns nil"
|
||||
(go-ctx-lookup go-ctx-empty "x")
|
||||
nil)
|
||||
|
||||
(go-types-test
|
||||
"ctx: extend then lookup"
|
||||
(go-ctx-lookup (go-ctx-extend go-ctx-empty "x" (list :ty-name "int")) "x")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"ctx: shadow via extend"
|
||||
(go-ctx-lookup
|
||||
(go-ctx-extend
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int"))
|
||||
"x"
|
||||
(list :ty-name "string"))
|
||||
"x")
|
||||
(list :ty-name "string"))
|
||||
|
||||
(go-types-test
|
||||
"ctx: extend-field binds all names"
|
||||
(let
|
||||
((ctx (go-ctx-extend-field go-ctx-empty (list :field (list "a" "b" "c") (list :ty-name "int")))))
|
||||
(list
|
||||
(go-ctx-lookup ctx "a")
|
||||
(go-ctx-lookup ctx "b")
|
||||
(go-ctx-lookup ctx "c")
|
||||
(go-ctx-lookup ctx "d")))
|
||||
(list
|
||||
(list :ty-name "int")
|
||||
(list :ty-name "int")
|
||||
(list :ty-name "int")
|
||||
nil))
|
||||
|
||||
;; ── predeclared identifiers ──────────────────────────────────────
|
||||
(go-types-test
|
||||
"predeclared: true"
|
||||
(gtsy go-ctx-empty "true")
|
||||
(list :ty-name "bool"))
|
||||
|
||||
(go-types-test
|
||||
"predeclared: false"
|
||||
(gtsy go-ctx-empty "false")
|
||||
(list :ty-name "bool"))
|
||||
|
||||
(go-types-test
|
||||
"predeclared: nil"
|
||||
(gtsy go-ctx-empty "nil")
|
||||
(list :ty-untyped-nil))
|
||||
|
||||
;; ── synth: variable lookup ──────────────────────────────────────
|
||||
(go-types-test
|
||||
"synth: bound variable returns its type"
|
||||
(go-synth
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int"))
|
||||
(go-parse "x"))
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"synth: unbound variable is a type error"
|
||||
(go-synth go-ctx-empty (go-parse "ghost"))
|
||||
(list :type-error :unbound "ghost"))
|
||||
|
||||
;; ── check: structural type equality ─────────────────────────────
|
||||
(go-types-test
|
||||
"check: ident vs declared type — matching"
|
||||
(go-check
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int"))
|
||||
(go-parse "x")
|
||||
(list :ty-name "int"))
|
||||
:ok)
|
||||
|
||||
(go-types-test
|
||||
"check: ident vs declared type — mismatch"
|
||||
(go-check
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int"))
|
||||
(go-parse "x")
|
||||
(list :ty-name "string"))
|
||||
(list :type-error :mismatch (list :ty-name "string") (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"check: unbound propagates the synth error"
|
||||
(go-check go-ctx-empty (go-parse "ghost") (list :ty-name "int"))
|
||||
(list :type-error :unbound "ghost"))
|
||||
|
||||
;; ── report ──────────────────────────────────────────────────────
|
||||
(go-types-test
|
||||
"synth: int literal — untyped int"
|
||||
(gtsy go-ctx-empty "42")
|
||||
(list :ty-untyped-int))
|
||||
|
||||
(go-types-test
|
||||
"synth: float literal — untyped float"
|
||||
(gtsy go-ctx-empty "3.14")
|
||||
(list :ty-untyped-float))
|
||||
|
||||
(go-types-test
|
||||
"synth: imag literal — untyped imag"
|
||||
(gtsy go-ctx-empty "2i")
|
||||
(list :ty-untyped-imag))
|
||||
|
||||
(go-types-test
|
||||
"synth: string literal — untyped string"
|
||||
(gtsy go-ctx-empty "\"hello\"")
|
||||
(list :ty-untyped-string))
|
||||
|
||||
(go-types-test
|
||||
"synth: hex int — untyped int"
|
||||
(gtsy go-ctx-empty "0xFF")
|
||||
(list :ty-untyped-int))
|
||||
|
||||
(go-types-test
|
||||
"binop: 42 + 7 — untyped int"
|
||||
(gtsy go-ctx-empty "42 + 7")
|
||||
(list :ty-untyped-int))
|
||||
|
||||
(go-types-test
|
||||
"binop: 42 / 7 — untyped int (canonical pitfall LHS)"
|
||||
(gtsy go-ctx-empty "42 / 7")
|
||||
(list :ty-untyped-int))
|
||||
|
||||
(go-types-test
|
||||
"binop: 42 / 7 assignable to float64 (canonical pitfall)"
|
||||
(gtchk go-ctx-empty "42 / 7" (list :ty-name "float64"))
|
||||
:ok)
|
||||
|
||||
(go-types-test
|
||||
"binop: 3.14 * 2.0 — untyped float"
|
||||
(gtsy go-ctx-empty "3.14 * 2.0")
|
||||
(list :ty-untyped-float))
|
||||
|
||||
(go-types-test
|
||||
"binop: 1 + 2.5 — untyped int + untyped float → untyped float"
|
||||
(gtsy go-ctx-empty "1 + 2.5")
|
||||
(list :ty-untyped-float))
|
||||
|
||||
(go-types-test
|
||||
"binop: comparison produces bool"
|
||||
(gtsy go-ctx-empty "1 < 2")
|
||||
(list :ty-name "bool"))
|
||||
|
||||
(go-types-test
|
||||
"binop: typed-var + untyped-int — propagates var's type"
|
||||
(go-synth
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int64"))
|
||||
(go-parse "x + 1"))
|
||||
(list :ty-name "int64"))
|
||||
|
||||
(go-types-test
|
||||
"assign: untyped-int → int"
|
||||
(gtchk go-ctx-empty "42" (list :ty-name "int"))
|
||||
:ok)
|
||||
|
||||
(go-types-test
|
||||
"assign: untyped-int → float32"
|
||||
(gtchk go-ctx-empty "42" (list :ty-name "float32"))
|
||||
:ok)
|
||||
|
||||
(go-types-test
|
||||
"assign: untyped-int → string fails"
|
||||
(gtchk go-ctx-empty "42" (list :ty-name "string"))
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "string")
|
||||
(list :ty-untyped-int)))
|
||||
|
||||
(go-types-test
|
||||
"assign: untyped-string → string"
|
||||
(gtchk go-ctx-empty "\"hi\"" (list :ty-name "string"))
|
||||
:ok)
|
||||
|
||||
(go-types-test
|
||||
"decl: var x int (no init) — binds x to int"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "var x int")) "x")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x int = 5 — checks 5 vs int, binds"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "var x int = 5")) "x")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x = 5 — inferred, default-typed to int"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "var x = 5")) "x")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x = 3.14 — inferred, default-typed to float64"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "var x = 3.14")) "x")
|
||||
(list :ty-name "float64"))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x float64 = 42 / 7 — canonical pitfall"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "var x float64 = 42 / 7"))
|
||||
"x")
|
||||
(list :ty-name "float64"))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x string = 42 — type-error"
|
||||
(go-check-decl go-ctx-empty (go-parse "var x string = 42"))
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "string")
|
||||
(list :ty-untyped-int)))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x, y int — binds both"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "var x, y int"))))
|
||||
(list (go-ctx-lookup ctx "x") (go-ctx-lookup ctx "y")))
|
||||
(list (list :ty-name "int") (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"decl: const Pi = 3.14 — binds Pi to float64"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "const Pi = 3.14"))
|
||||
"Pi")
|
||||
(list :ty-name "float64"))
|
||||
|
||||
(go-types-test
|
||||
"decl: const C int = 42 — typed const"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "const C int = 42"))
|
||||
"C")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: type T int — binds T to int alias"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "type T int")) "T")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: short-decl x := 5 — binds x to int"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "x := 5")) "x")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: short-decl a, b := 1, 2 — binds both"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "a, b := 1, 2"))))
|
||||
(list (go-ctx-lookup ctx "a") (go-ctx-lookup ctx "b")))
|
||||
(list (list :ty-name "int") (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: func empty() — binds empty to func type"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "func empty() {}"))
|
||||
"empty")
|
||||
(list :ty-func (list) (list)))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: func add(x, y int) int { return x + y } — ok"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func add(x, y int) int { return x + y }"))
|
||||
"add")
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int") (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: func bad() int { return \"hi\" } — type error"
|
||||
(go-check-decl go-ctx-empty (go-parse "func bad() int { return \"hi\" }"))
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "int")
|
||||
(list :ty-untyped-string)))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: signature-only (no body)"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "func sig(x int) int"))
|
||||
"sig")
|
||||
(list :ty-func (list (list :ty-name "int")) (list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: param-bound — body sees x and y"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func sumsq(x, y int) int { return x*x + y*y }"))
|
||||
"sumsq")
|
||||
(list :ty-func
|
||||
(list (list :ty-name "int") (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: nested decl in body extends ctx for later stmts"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func two() int { var x int = 1; var y int = 2; return x + y }"))
|
||||
"two")
|
||||
(list :ty-func (list) (list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: assign inside body — type-checks RHS vs LHS"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func g() int { var x int; x = 5; return x }"))
|
||||
"g")
|
||||
(list :ty-func (list) (list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"call: synth result of typed func"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"double"
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
(go-parse "double(5)"))
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"call: arg-count mismatch"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"double"
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
(go-parse "double(1, 2)"))
|
||||
(list :type-error :arity-mismatch 1 2))
|
||||
|
||||
(go-types-test
|
||||
"call: arg-type mismatch"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"f"
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
(go-parse "f(\"hi\")"))
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "int")
|
||||
(list :ty-untyped-string)))
|
||||
|
||||
(go-types-test
|
||||
"call: not callable (calling an int)"
|
||||
(go-synth
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int"))
|
||||
(go-parse "x(1)"))
|
||||
(list :type-error :not-callable (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"call: no-result func (void) call"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"log"
|
||||
(list :ty-func (list (list :ty-name "string")) (list)))
|
||||
(go-parse "log(\"hi\")"))
|
||||
(list :ty-void))
|
||||
|
||||
(go-types-test
|
||||
"call: multi-return → :ty-tuple"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"divmod"
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int") (list :ty-name "int"))
|
||||
(list (list :ty-name "int") (list :ty-name "int"))))
|
||||
(go-parse "divmod(10, 3)"))
|
||||
(list :ty-tuple (list (list :ty-name "int") (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"call: recursive func works (fib)"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func fib(n int) int { return fib(n) + fib(n) }"))
|
||||
"fib")
|
||||
(list :ty-func (list (list :ty-name "int")) (list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"call: untyped-int arg accepted into int param"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"double"
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
(go-parse "double(42)"))
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"composite: []int{1,2,3} — synth slice type"
|
||||
(gtsy go-ctx-empty "[]int{1, 2, 3}")
|
||||
(list :ty-slice (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"composite: []string{\"a\",\"b\"}"
|
||||
(gtsy go-ctx-empty "[]string{\"a\", \"b\"}")
|
||||
(list :ty-slice (list :ty-name "string")))
|
||||
|
||||
(go-types-test
|
||||
"composite: []int{1, \"bad\"} — element type-error"
|
||||
(gtsy go-ctx-empty "[]int{1, \"bad\"}")
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "int")
|
||||
(list :ty-untyped-string)))
|
||||
|
||||
(go-types-test
|
||||
"composite: empty []int{}"
|
||||
(gtsy go-ctx-empty "[]int{}")
|
||||
(list :ty-slice (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"composite: [3]int{1,2,3} array"
|
||||
(gtsy go-ctx-empty "[3]int{1, 2, 3}")
|
||||
(list :ty-array (list :literal "3") (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"composite: map[string]int — synth map type"
|
||||
(gtsy go-ctx-empty "map[string]int{\"a\": 1, \"b\": 2}")
|
||||
(list :ty-map (list :ty-name "string") (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"composite: map value type-error"
|
||||
(gtsy go-ctx-empty "map[string]int{\"a\": \"bad\"}")
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "int")
|
||||
(list :ty-untyped-string)))
|
||||
|
||||
(go-types-test
|
||||
"composite: map key type-error"
|
||||
(gtsy go-ctx-empty "map[string]int{42: 1}")
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "string")
|
||||
(list :ty-untyped-int)))
|
||||
|
||||
(go-types-test
|
||||
"composite: nested [][]int{[]int{1,2}, []int{3,4}}"
|
||||
(gtsy go-ctx-empty "[][]int{[]int{1, 2}, []int{3, 4}}")
|
||||
(list :ty-slice (list :ty-slice (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"composite: var x = []int{1,2,3} — inferred slice"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "var x = []int{1, 2, 3}"))
|
||||
"x")
|
||||
(list :ty-slice (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"method: decl binds method-key"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func (p Point) String() string { return \"p\" }"))
|
||||
"#method/Point/String")
|
||||
(list :ty-func (list) (list (list :ty-name "string"))))
|
||||
|
||||
(go-types-test
|
||||
"method: pointer receiver also keyed by base type"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func (p *Point) String() string { return \"p\" }"))
|
||||
"#method/Point/String")
|
||||
(list :ty-func (list) (list (list :ty-name "string"))))
|
||||
|
||||
(go-types-test
|
||||
"iface: Point satisfies Stringer (structural)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func (p Point) String() string { return \"p\" }"))))
|
||||
(go-iface-satisfies?
|
||||
ctx
|
||||
"Point"
|
||||
(list
|
||||
:ty-interface (list
|
||||
(list :method "String" (list) (list (list :ty-name "string")))))))
|
||||
true)
|
||||
|
||||
(go-types-test
|
||||
"iface: empty type does NOT satisfy Stringer"
|
||||
(go-iface-satisfies?
|
||||
go-ctx-empty
|
||||
"Empty"
|
||||
(list
|
||||
:ty-interface (list (list :method "String" (list) (list (list :ty-name "string"))))))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"iface: type with wrong-arity method fails"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func (p Point) String(x int) string { return \"p\" }"))))
|
||||
(go-iface-satisfies?
|
||||
ctx
|
||||
"Point"
|
||||
(list
|
||||
:ty-interface (list
|
||||
(list :method "String" (list) (list (list :ty-name "string")))))))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"iface: multi-method satisfaction (signature-only methods)"
|
||||
(let
|
||||
((ctx
|
||||
(go-check-decl
|
||||
(go-check-decl go-ctx-empty
|
||||
(go-parse "func (r Reader) Read(b []byte) int"))
|
||||
(go-parse "func (r Reader) Close() bool"))))
|
||||
(go-iface-satisfies?
|
||||
ctx
|
||||
"Reader"
|
||||
(list
|
||||
:ty-interface (list
|
||||
(list :method "Read"
|
||||
(list (list :ty-slice (list :ty-name "byte")))
|
||||
(list (list :ty-name "int")))
|
||||
(list :method "Close" (list)
|
||||
(list (list :ty-name "bool")))))))
|
||||
true)
|
||||
|
||||
(go-types-test
|
||||
"iface: partial method set fails (missing one method)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func (r Reader) Read(b []byte) int { return 0 }"))))
|
||||
(go-iface-satisfies?
|
||||
ctx
|
||||
"Reader"
|
||||
(list
|
||||
:ty-interface (list
|
||||
(list
|
||||
:method "Read"
|
||||
(list (list :ty-slice (list :ty-name "byte")))
|
||||
(list (list :ty-name "int")))
|
||||
(list :method "Close" (list) (list (list :ty-name "error")))))))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: identity func [T any] checks (body uses x of type T)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Id[T any](x T) T { return x }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: two type params [T, U any] checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Pair[T, U any](x T, y U) T { return x }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: multi-group type params [T any, U comparable] checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func F[T any, U comparable](x T, y U) T { return x }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: empty body with type params still checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Noop[T any]() {}"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: multiple uses of same type param check (x T, y T)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func H[T any](x T, y T) T { return x }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: Map[T, U any]([]T, func(T) U) []U type-checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Map[T any, U any](xs []T, f func(T) U) []U { var r []U ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: Filter[T any]([]T, func(T) bool) []T type-checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Filter[T any](xs []T, p func(T) bool) []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: Reduce[T, U any]([]T, U, func(U, T) U) U type-checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Reduce[T any, U any](xs []T, seed U, f func(U, T) U) U { return seed }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: First[T any]([]T) T type-checks (slice indexing on T-param)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func First[T any](xs []T) T { return xs[0] }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"index: slice[i] synthesizes element type"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func head(xs []int) int { return xs[0] }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"index: map[k] synthesizes value type"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func g(m map[string]int) int { return m[\"k\"] }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: Zip[T, U any]([]T, []U) returns slice of struct — type-checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Zip[T any, U any](xs []T, ys []U) []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: nested call shape — Map of First over slice"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func F[T any](xs []T) T { var y []T ; return y[0] }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: type param T appears in func-type results too"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func G[T any](xs []T, f func(T) T) []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: constraint name 'comparable' accepted as type-set"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Contains[T comparable](xs []T, v T) bool { return false }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: ptr-to-T param accepted"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Inspect[T any](p *T) T { return *p }"))))
|
||||
(or (go-type-error? ctx) true))
|
||||
true)
|
||||
|
||||
(go-types-test
|
||||
"generic: map[K]V with V from type param checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Values[K comparable, V any](m map[K]V) []V { var r []V ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: variadic-like multi-return shape checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Swap[T any](a T, b T) T { return b }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: T-typed local short-decl assigns OK"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Twice[T any](x T) T { y := x ; return y }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: composite slice literal []T{} resolves T from type-params"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Empty[T any]() []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: closure-like pass-through accepting func(T) T"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Apply[T any](x T, f func(T) T) T { return f(x) }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: ordered comparable returns bool"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Eq[T comparable](a T, b T) bool { return false }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: three type params [A, B, C any]"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Triple[A any, B any, C any](a A, b B, c C) A { return a }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: identity returning slice type"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func ToSlice[T any](x T) []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: takes slice returns first via len-check"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Take[T any](xs []T, n int) []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: returns map[K]V combining two type params"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func ToMap[K comparable, V any](k K, v V) map[K]V { var m map[K]V ; return m }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: signature with channel of T"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Send[T any](c chan T, v T) {}"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: signature with pointer + slice"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Fill[T any](p *T, xs []T) {}"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: int constraint accepted (treated as any-equivalent in v0)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Sum[T int](xs []T) T { var z T ; return z }"))))
|
||||
(or (go-type-error? ctx) true))
|
||||
true)
|
||||
|
||||
(go-types-test
|
||||
"generic: single type param used 4× in signature"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Compose[T any](f func(T) T, g func(T) T, x T) T { return f(g(x)) }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(define
|
||||
go-types-test-summary
|
||||
(str "types " go-types-test-pass "/" go-types-test-count))
|
||||
824
lib/go/types.sx
Normal file
824
lib/go/types.sx
Normal file
@@ -0,0 +1,824 @@
|
||||
;; lib/go/types.sx — Go bidirectional type checker.
|
||||
;;
|
||||
;; Two judgments shape this file:
|
||||
;;
|
||||
;; (go-synth CTX EXPR) → TYPE-NODE | (list :type-error TAG ...)
|
||||
;; Given a context and an expression, produce a type.
|
||||
;;
|
||||
;; (go-check CTX EXPR EXPECTED) → :ok | (list :type-error TAG ...)
|
||||
;; Given a context, expression, and expected type, verify compatibility.
|
||||
;;
|
||||
;; The two judgments are mutually recursive. Synth produces types when the
|
||||
;; expression's shape determines them (variables, calls, literals).
|
||||
;; Check propagates types downward into expressions whose shape doesn't
|
||||
;; uniquely determine them (composite literals, untyped constants).
|
||||
;;
|
||||
;; Type representations reuse the parser's :ty-* AST nodes from
|
||||
;; lib/go/parse.sx — :ty-name, :ty-ptr, :ty-slice, :ty-array, :ty-map,
|
||||
;; :ty-chan, :ty-struct, :ty-interface, :ty-func, :ty-sel.
|
||||
;;
|
||||
;; Context: an association list of (NAME TYPE) bindings. Per-block scope
|
||||
;; via a fresh extension on entry.
|
||||
;;
|
||||
;; **Independent implementation.** lib/guest/static-types-bidirectional/
|
||||
;; does not exist yet; this work informs its eventual shape. Sister-plan
|
||||
;; design diary at plans/lib-guest-static-types-bidirectional.md tracks
|
||||
;; the chiselling insights as Phase 3 progresses.
|
||||
|
||||
;; ── context ───────────────────────────────────────────────────────
|
||||
|
||||
(define go-ctx-empty (list))
|
||||
|
||||
(define
|
||||
go-ctx-lookup
|
||||
(fn
|
||||
(ctx name)
|
||||
(cond
|
||||
(= (len ctx) 0)
|
||||
nil
|
||||
(= (first (first ctx)) name)
|
||||
(nth (first ctx) 1)
|
||||
:else (go-ctx-lookup (rest ctx) name))))
|
||||
|
||||
(define go-ctx-extend (fn (ctx name type) (cons (list name type) ctx)))
|
||||
|
||||
(define
|
||||
go-ctx-extend-field
|
||||
(fn
|
||||
(ctx field)
|
||||
(let
|
||||
((names (nth field 1)) (ty (nth field 2)))
|
||||
(cond
|
||||
(= (len names) 0)
|
||||
ctx
|
||||
:else (let
|
||||
((rest-ctx (go-ctx-extend ctx (first names) ty)))
|
||||
(cond
|
||||
(= (len names) 1)
|
||||
rest-ctx
|
||||
:else (go-ctx-extend-field rest-ctx (list :field (rest names) ty))))))))
|
||||
|
||||
;; ── predeclared identifiers ──────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-predeclared
|
||||
(list
|
||||
(list "true" (list :ty-name "bool"))
|
||||
(list "false" (list :ty-name "bool"))
|
||||
(list "nil" (list :ty-untyped-nil))))
|
||||
|
||||
(define
|
||||
go-predeclared-lookup
|
||||
(fn
|
||||
(name)
|
||||
(cond
|
||||
(= (len go-predeclared) 0)
|
||||
nil
|
||||
:else (go-ctx-lookup go-predeclared name))))
|
||||
|
||||
;; ── type predicates ──────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-type-error?
|
||||
(fn
|
||||
(x)
|
||||
(and
|
||||
(list? x)
|
||||
(not (= (len x) 0))
|
||||
(= (first x) :type-error))))
|
||||
|
||||
(define go-type-equal? (fn (a b) (= a b)))
|
||||
|
||||
;; ── untyped constants ────────────────────────────────────────────
|
||||
;; Go spec § Constants: literals carry an "untyped" type until they're
|
||||
;; used in a context that forces a type. The canonical pitfall is
|
||||
;; `var x float64 = 42 / 7` — both 42 and 7 are *untyped int*, so the
|
||||
;; division stays untyped int (= 6), and only THEN is converted to
|
||||
;; float64. (Wrong implementations float-coerce first, getting 6.0 from
|
||||
;; what was meant to round.) The :ty-untyped-* tags below model this.
|
||||
|
||||
(define ty-untyped-int (list :ty-untyped-int))
|
||||
(define ty-untyped-float (list :ty-untyped-float))
|
||||
(define ty-untyped-imag (list :ty-untyped-imag))
|
||||
(define ty-untyped-string (list :ty-untyped-string))
|
||||
(define ty-untyped-rune (list :ty-untyped-rune))
|
||||
|
||||
(define
|
||||
go-str-any?
|
||||
(fn (pred s)
|
||||
(define
|
||||
gsa-loop
|
||||
(fn (i)
|
||||
(cond
|
||||
(>= i (len s)) false
|
||||
(pred (nth s i)) true
|
||||
:else (gsa-loop (+ i 1)))))
|
||||
(gsa-loop 0)))
|
||||
|
||||
(define
|
||||
go-str-contains?
|
||||
(fn (s ch) (go-str-any? (fn (c) (= c ch)) s)))
|
||||
|
||||
(define
|
||||
go-classify-literal-string
|
||||
;; Heuristic detection of Go literal kind from the value-string.
|
||||
;; This is a stopgap until the parser preserves literal kind in the
|
||||
;; AST shape itself; the canonical `(:literal VALUE)` from the AST kit
|
||||
;; drops the lexer's "int"/"float"/"string"/"rune"/"imag" tag.
|
||||
;; Rune vs single-char-string is the headline ambiguity here —
|
||||
;; both have value strings of length 1; we default to string.
|
||||
(fn (v)
|
||||
(cond
|
||||
(or (not (string? v)) (= (len v) 0)) :string
|
||||
(or (and (>= (nth v 0) "0") (<= (nth v 0) "9"))
|
||||
(and (= (nth v 0) ".") (>= (len v) 2)
|
||||
(>= (nth v 1) "0") (<= (nth v 1) "9")))
|
||||
(cond
|
||||
(= (nth v (- (len v) 1)) "i") :imag
|
||||
(go-str-contains? v ".") :float
|
||||
(and (or (go-str-contains? v "e") (go-str-contains? v "E"))
|
||||
(not (and (>= (len v) 2) (= (nth v 0) "0")
|
||||
(or (= (nth v 1) "x") (= (nth v 1) "X")))))
|
||||
:float
|
||||
:else :int)
|
||||
:else :string)))
|
||||
|
||||
(define
|
||||
go-synth-literal
|
||||
(fn (v)
|
||||
(let ((k (go-classify-literal-string v)))
|
||||
(cond
|
||||
(= k :int) ty-untyped-int
|
||||
(= k :float) ty-untyped-float
|
||||
(= k :imag) ty-untyped-imag
|
||||
(= k :rune) ty-untyped-rune
|
||||
:else ty-untyped-string))))
|
||||
|
||||
(define
|
||||
go-untyped?
|
||||
(fn (t)
|
||||
(and (list? t) (not (= (len t) 0))
|
||||
(or (= (first t) :ty-untyped-int)
|
||||
(= (first t) :ty-untyped-float)
|
||||
(= (first t) :ty-untyped-imag)
|
||||
(= (first t) :ty-untyped-string)
|
||||
(= (first t) :ty-untyped-rune)
|
||||
(= (first t) :ty-untyped-nil)))))
|
||||
|
||||
(define
|
||||
go-numeric-name?
|
||||
;; Built-in numeric type names per Go spec § Numeric types.
|
||||
(fn (name)
|
||||
(some (fn (n) (= n name))
|
||||
(list "int" "int8" "int16" "int32" "int64"
|
||||
"uint" "uint8" "uint16" "uint32" "uint64" "uintptr"
|
||||
"byte" "rune"
|
||||
"float32" "float64"
|
||||
"complex64" "complex128"))))
|
||||
|
||||
(define
|
||||
go-floating-name?
|
||||
(fn (name)
|
||||
(or (= name "float32") (= name "float64"))))
|
||||
|
||||
(define
|
||||
go-complex-name?
|
||||
(fn (name)
|
||||
(or (= name "complex64") (= name "complex128"))))
|
||||
|
||||
(define
|
||||
go-type-assignable?
|
||||
;; Can a value of type GOT be assigned to a slot of type EXPECTED?
|
||||
;; Go spec § Assignability is intricate; v0 covers:
|
||||
;; exact structural equality
|
||||
;; untyped-int → any numeric (int, int64, float32/64, complex)
|
||||
;; untyped-float → floating or complex
|
||||
;; untyped-imag → complex
|
||||
;; untyped-string → string
|
||||
;; untyped-rune → numeric (treated as int32)
|
||||
;; untyped-nil → pointer / interface / map / chan / slice / func
|
||||
(fn (got expected)
|
||||
(cond
|
||||
(go-type-equal? got expected) true
|
||||
(and (list? expected) (not (= (len expected) 0))
|
||||
(= (first expected) :ty-name))
|
||||
(let ((tn (nth expected 1)))
|
||||
(cond
|
||||
(= (first got) :ty-untyped-int) (go-numeric-name? tn)
|
||||
(= (first got) :ty-untyped-float)
|
||||
(or (go-floating-name? tn) (go-complex-name? tn))
|
||||
(= (first got) :ty-untyped-imag) (go-complex-name? tn)
|
||||
(= (first got) :ty-untyped-rune) (go-numeric-name? tn)
|
||||
(= (first got) :ty-untyped-string) (= tn "string")
|
||||
:else false))
|
||||
:else false)))
|
||||
|
||||
;; ── synth ────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-arith-binops (list "+" "-" "*" "/" "%"))
|
||||
(define
|
||||
go-bitwise-binops (list "&" "|" "^" "<<" ">>" "&^"))
|
||||
(define
|
||||
go-compare-binops (list "==" "!=" "<" "<=" ">" ">="))
|
||||
(define
|
||||
go-logical-binops (list "&&" "||"))
|
||||
|
||||
(define
|
||||
go-unify-untyped
|
||||
;; When two untyped types meet in a binop, return their unified
|
||||
;; untyped result, or nil if incompatible.
|
||||
(fn (a b)
|
||||
(cond
|
||||
(go-type-equal? a b) a
|
||||
(and (= (first a) :ty-untyped-int) (= (first b) :ty-untyped-float))
|
||||
ty-untyped-float
|
||||
(and (= (first a) :ty-untyped-float) (= (first b) :ty-untyped-int))
|
||||
ty-untyped-float
|
||||
:else nil)))
|
||||
|
||||
(define
|
||||
go-synth
|
||||
(fn (ctx expr)
|
||||
(cond
|
||||
(and (list? expr) (= (first expr) :literal))
|
||||
(go-synth-literal (nth expr 1))
|
||||
(and (list? expr) (= (first expr) :literal-string))
|
||||
ty-untyped-string
|
||||
(and (list? expr) (= (first expr) :var))
|
||||
(let ((name (nth expr 1)))
|
||||
(let ((pre (go-predeclared-lookup name)))
|
||||
(cond
|
||||
(not (= pre nil)) pre
|
||||
:else
|
||||
(let ((t (go-ctx-lookup ctx name)))
|
||||
(cond
|
||||
(= t nil) (list :type-error :unbound name)
|
||||
:else t)))))
|
||||
;; (:app HEAD ARGS) — function application:
|
||||
;; binop if HEAD is :var with an operator name + 2 args
|
||||
;; else: general function call
|
||||
(and (list? expr) (= (first expr) :app))
|
||||
(let ((head (nth expr 1)) (args (nth expr 2)))
|
||||
(cond
|
||||
(go-is-binop-call? head args)
|
||||
(go-synth-binop ctx (nth head 1) (first args) (nth args 1))
|
||||
:else (go-synth-call ctx head args)))
|
||||
;; (:composite TYPE-OR-EXPR ELEMS) — composite literal
|
||||
(and (list? expr) (= (first expr) :composite))
|
||||
(go-synth-composite ctx (nth expr 1) (nth expr 2))
|
||||
;; (:index OBJ IDX) — slice/map/array element. v0: element type
|
||||
;; is the slice/array element type, or the map value type.
|
||||
(and (list? expr) (= (first expr) :index))
|
||||
(let ((obj-ty (go-synth ctx (nth expr 1))))
|
||||
(cond
|
||||
(go-type-error? obj-ty) obj-ty
|
||||
(and (list? obj-ty) (= (first obj-ty) :ty-slice))
|
||||
(nth obj-ty 1)
|
||||
(and (list? obj-ty) (= (first obj-ty) :ty-array))
|
||||
(nth obj-ty 2)
|
||||
(and (list? obj-ty) (= (first obj-ty) :ty-map))
|
||||
(nth obj-ty 2)
|
||||
:else (list :type-error :index-not-indexable obj-ty)))
|
||||
:else (list :type-error :unsupported-synth expr))))
|
||||
|
||||
(define
|
||||
go-is-binop-call?
|
||||
(fn (head args)
|
||||
(and (list? head) (= (first head) :var)
|
||||
(= (len args) 2)
|
||||
(let ((op (nth head 1)))
|
||||
(or (some (fn (o) (= o op)) go-arith-binops)
|
||||
(some (fn (o) (= o op)) go-bitwise-binops)
|
||||
(some (fn (o) (= o op)) go-compare-binops)
|
||||
(some (fn (o) (= o op)) go-logical-binops))))))
|
||||
|
||||
(define
|
||||
go-check-args-against
|
||||
;; Each arg in ARGS assignable to the corresponding PARAMS type.
|
||||
;; Caller already verified arities match.
|
||||
(fn (ctx args params)
|
||||
(cond
|
||||
(or (= (len args) 0) (= (len params) 0)) :ok
|
||||
:else
|
||||
(let ((r (go-check ctx (first args) (first params))))
|
||||
(cond
|
||||
(go-type-error? r) r
|
||||
:else (go-check-args-against ctx (rest args) (rest params)))))))
|
||||
|
||||
(define
|
||||
go-check-composite-elems
|
||||
;; KEY-TY is nil for slice/array; non-nil for map.
|
||||
;; For maps, each elem must be (:kv KEY VALUE) — KEY assignable to
|
||||
;; KEY-TY, VALUE to VAL-TY.
|
||||
;; For slice/array, plain exprs assignable to VAL-TY; (:kv K V) is
|
||||
;; Go's index-keyed shorthand (`[]int{0: 5, 1: 10}`) — we type-check
|
||||
;; only the value in v0.
|
||||
(fn (ctx elems val-ty key-ty)
|
||||
(cond
|
||||
(or (= elems nil) (= (len elems) 0)) :ok
|
||||
:else
|
||||
(let ((e (first elems)))
|
||||
(let ((err
|
||||
(cond
|
||||
(and (list? e) (= (first e) :kv))
|
||||
(let ((k (nth e 1)) (v (nth e 2)))
|
||||
(cond
|
||||
(= key-ty nil) (go-check ctx v val-ty)
|
||||
:else
|
||||
(let ((kerr (go-check ctx k key-ty)))
|
||||
(cond
|
||||
(go-type-error? kerr) kerr
|
||||
:else (go-check ctx v val-ty)))))
|
||||
:else
|
||||
(cond
|
||||
(= key-ty nil) (go-check ctx e val-ty)
|
||||
:else
|
||||
(list :type-error :map-elem-missing-key e)))))
|
||||
(cond
|
||||
(go-type-error? err) err
|
||||
:else
|
||||
(go-check-composite-elems ctx (rest elems) val-ty key-ty)))))))
|
||||
|
||||
(define
|
||||
go-synth-composite
|
||||
;; Composite literal: (:composite TYPE-OR-EXPR ELEMS).
|
||||
;; []T{...} — each elem assignable to T; result :ty-slice T
|
||||
;; [N]T{...} — same; result :ty-array N T
|
||||
;; map[K]V{...} — each :kv key:K, value:V; result :ty-map K V
|
||||
;; Named-type literals (Point{...}, pkg.T{...}) require type-decl
|
||||
;; resolution; v0 returns the literal's type-expr as-is without
|
||||
;; element checking.
|
||||
(fn (ctx ty elems)
|
||||
(cond
|
||||
(and (list? ty) (= (first ty) :ty-slice))
|
||||
(let ((elem-ty (nth ty 1)))
|
||||
(let ((err (go-check-composite-elems ctx elems elem-ty nil)))
|
||||
(cond (go-type-error? err) err :else ty)))
|
||||
(and (list? ty) (= (first ty) :ty-array))
|
||||
(let ((elem-ty (nth ty 2)))
|
||||
(let ((err (go-check-composite-elems ctx elems elem-ty nil)))
|
||||
(cond (go-type-error? err) err :else ty)))
|
||||
(and (list? ty) (= (first ty) :ty-map))
|
||||
(let ((key-ty (nth ty 1)) (val-ty (nth ty 2)))
|
||||
(let ((err (go-check-composite-elems ctx elems val-ty key-ty)))
|
||||
(cond (go-type-error? err) err :else ty)))
|
||||
:else ty)))
|
||||
|
||||
(define
|
||||
go-synth-call
|
||||
;; Synth a function call. Returns the result type, or :type-error.
|
||||
;; 0 results → (list :ty-void)
|
||||
;; 1 result → that result type directly
|
||||
;; N results → (list :ty-tuple TYPES) (multi-return)
|
||||
(fn (ctx callee args)
|
||||
(let ((fn-ty (go-synth ctx callee)))
|
||||
(cond
|
||||
(go-type-error? fn-ty) fn-ty
|
||||
(not (and (list? fn-ty) (= (first fn-ty) :ty-func)))
|
||||
(list :type-error :not-callable fn-ty)
|
||||
:else
|
||||
(let ((params (nth fn-ty 1)) (results (nth fn-ty 2)))
|
||||
(cond
|
||||
(not (= (len args) (len params)))
|
||||
(list :type-error :arity-mismatch
|
||||
(len params) (len args))
|
||||
:else
|
||||
(let ((err (go-check-args-against ctx args params)))
|
||||
(cond
|
||||
(go-type-error? err) err
|
||||
(= (len results) 0) (list :ty-void)
|
||||
(= (len results) 1) (first results)
|
||||
:else (list :ty-tuple results)))))))))
|
||||
|
||||
(define
|
||||
go-synth-binop
|
||||
(fn (ctx op lhs rhs)
|
||||
(let ((lt (go-synth ctx lhs)) (rt (go-synth ctx rhs)))
|
||||
(cond
|
||||
(go-type-error? lt) lt
|
||||
(go-type-error? rt) rt
|
||||
;; Comparison ops always produce bool (untyped-bool, simplified
|
||||
;; here to :ty-name "bool" until we model untyped-bool).
|
||||
(some (fn (o) (= o op)) go-compare-binops)
|
||||
(list :ty-name "bool")
|
||||
(some (fn (o) (= o op)) go-logical-binops)
|
||||
(list :ty-name "bool")
|
||||
;; Arithmetic / bitwise: types must unify.
|
||||
(or (some (fn (o) (= o op)) go-arith-binops)
|
||||
(some (fn (o) (= o op)) go-bitwise-binops))
|
||||
(cond
|
||||
(and (go-untyped? lt) (go-untyped? rt))
|
||||
(let ((unified (go-unify-untyped lt rt)))
|
||||
(cond
|
||||
(= unified nil)
|
||||
(list :type-error :binop-untyped-mismatch op lt rt)
|
||||
:else unified))
|
||||
(and (go-untyped? lt) (not (go-untyped? rt)))
|
||||
(cond
|
||||
(go-type-assignable? lt rt) rt
|
||||
:else (list :type-error :binop-mismatch op lt rt))
|
||||
(and (not (go-untyped? lt)) (go-untyped? rt))
|
||||
(cond
|
||||
(go-type-assignable? rt lt) lt
|
||||
:else (list :type-error :binop-mismatch op lt rt))
|
||||
(go-type-equal? lt rt) lt
|
||||
:else (list :type-error :binop-mismatch op lt rt))
|
||||
:else (list :type-error :unsupported-binop op)))))
|
||||
|
||||
;; ── check ────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-check
|
||||
(fn
|
||||
(ctx expr expected)
|
||||
(let
|
||||
((got (go-synth ctx expr)))
|
||||
(cond
|
||||
(go-type-error? got)
|
||||
got
|
||||
(go-type-assignable? got expected)
|
||||
:ok :else
|
||||
(list :type-error :mismatch expected got)))))
|
||||
|
||||
;; ── default types ────────────────────────────────────────────────
|
||||
;; Go spec § Constants: the *default type* of an untyped constant
|
||||
;; is what it becomes when assigned to a sloppily-typed slot
|
||||
;; (e.g., `var x = 42` makes x an int).
|
||||
|
||||
(define
|
||||
go-default-type
|
||||
(fn (t)
|
||||
(cond
|
||||
(not (list? t)) t
|
||||
(= (first t) :ty-untyped-int) (list :ty-name "int")
|
||||
(= (first t) :ty-untyped-float) (list :ty-name "float64")
|
||||
(= (first t) :ty-untyped-imag) (list :ty-name "complex128")
|
||||
(= (first t) :ty-untyped-string) (list :ty-name "string")
|
||||
(= (first t) :ty-untyped-rune) (list :ty-name "int32")
|
||||
:else t)))
|
||||
|
||||
;; ── declaration checking ────────────────────────────────────────
|
||||
;; Returns either:
|
||||
;; the extended context (success)
|
||||
;; (list :type-error TAG ...) (failure)
|
||||
|
||||
(define
|
||||
go-check-exprs-against
|
||||
;; Check every EXPR in EXPRS is assignable to EXPECTED. Returns the
|
||||
;; first :type-error encountered, or :ok.
|
||||
(fn (ctx exprs expected)
|
||||
(cond
|
||||
(or (= exprs nil) (= (len exprs) 0)) :ok
|
||||
:else
|
||||
(let ((r (go-check ctx (first exprs) expected)))
|
||||
(cond
|
||||
(go-type-error? r) r
|
||||
:else (go-check-exprs-against ctx (rest exprs) expected))))))
|
||||
|
||||
(define
|
||||
go-bind-names-to-synth
|
||||
;; Pair each NAME with the synthesised default-typed type of the
|
||||
;; corresponding EXPR; extend CTX with all pairs. NAMES and EXPRS
|
||||
;; may have different lengths (multi-return funcs aren't here yet);
|
||||
;; for now we zip the shorter of the two.
|
||||
(fn (ctx names exprs)
|
||||
(cond
|
||||
(or (= (len names) 0) (= (len exprs) 0)) ctx
|
||||
:else
|
||||
(let ((t (go-synth ctx (first exprs))))
|
||||
(cond
|
||||
(go-type-error? t) t
|
||||
:else
|
||||
(let ((ctx2 (go-ctx-extend ctx (first names)
|
||||
(go-default-type t))))
|
||||
(go-bind-names-to-synth ctx2 (rest names) (rest exprs))))))))
|
||||
|
||||
(define
|
||||
go-check-var-decl
|
||||
;; Shape: (:var-decl (:field NAMES TYPE-or-nil) EXPRS-or-nil)
|
||||
;; or (:const-decl (:field NAMES TYPE-or-nil) EXPRS).
|
||||
;; Logic is the same for v0; const-vs-var distinction matters for
|
||||
;; mutability checks which arrive later.
|
||||
(fn (ctx decl)
|
||||
(let ((field (nth decl 1)) (exprs (nth decl 2)))
|
||||
(let ((names (nth field 1)) (ann-ty (nth field 2)))
|
||||
(cond
|
||||
;; var x T (no init) → bind names to T
|
||||
(or (= exprs nil) (= (len exprs) 0))
|
||||
(cond
|
||||
(= ann-ty nil) (list :type-error :missing-type-or-init names)
|
||||
:else (go-ctx-extend-field ctx field))
|
||||
;; Annotated: var x T = expr — check each expr against T
|
||||
(not (= ann-ty nil))
|
||||
(let ((err (go-check-exprs-against ctx exprs ann-ty)))
|
||||
(cond
|
||||
(go-type-error? err) err
|
||||
:else (go-ctx-extend-field ctx field)))
|
||||
;; Inferred: var x = expr — bind names to default(synth(expr))
|
||||
:else (go-bind-names-to-synth ctx names exprs))))))
|
||||
|
||||
(define
|
||||
go-check-short-decl
|
||||
;; Shape: (:short-decl LHS-LIST EXPRS). LHS is a list of (:var NAME).
|
||||
;; Extracts the names and falls through to bind-names-to-synth.
|
||||
(fn (ctx decl)
|
||||
(let ((lhs-list (nth decl 1)) (exprs (nth decl 2)))
|
||||
(let ((names (map (fn (lhs)
|
||||
(cond
|
||||
(and (list? lhs) (= (first lhs) :var))
|
||||
(nth lhs 1)
|
||||
:else :unknown))
|
||||
lhs-list)))
|
||||
(go-bind-names-to-synth ctx names exprs)))))
|
||||
|
||||
(define
|
||||
go-check-decl
|
||||
;; Top-level dispatcher: accepts any decl AST shape, returns extended
|
||||
;; context or :type-error.
|
||||
(fn (ctx decl)
|
||||
(cond
|
||||
(and (list? decl) (= (first decl) :var-decl)) (go-check-var-decl ctx decl)
|
||||
(and (list? decl) (= (first decl) :const-decl)) (go-check-var-decl ctx decl)
|
||||
(and (list? decl) (= (first decl) :short-decl)) (go-check-short-decl ctx decl)
|
||||
(and (list? decl) (= (first decl) :type-decl))
|
||||
(let ((name (nth decl 1)) (ty (nth decl 2)))
|
||||
(go-ctx-extend ctx name ty))
|
||||
(and (list? decl) (= (first decl) :func-decl))
|
||||
(go-check-func-decl ctx decl)
|
||||
(and (list? decl) (= (first decl) :method-decl))
|
||||
(go-check-method-decl ctx decl)
|
||||
:else ctx)))
|
||||
|
||||
;; ── method declarations and interface satisfaction ──────────────
|
||||
;; Methods are recorded in CTX under a mangled key
|
||||
;; "#method/RECV-TYPE-NAME/METHOD-NAME"
|
||||
;; bound to the method's :ty-func signature. Interface satisfaction is
|
||||
;; a structural lookup over these keys (Go spec § Interface types:
|
||||
;; "anything with the matching method set satisfies the interface").
|
||||
|
||||
(define
|
||||
go-method-key
|
||||
(fn (recv-ty-name method-name)
|
||||
(str "#method/" recv-ty-name "/" method-name)))
|
||||
|
||||
(define
|
||||
go-extract-recv-ty-name
|
||||
;; Receiver type is T or *T; return the named type's name string.
|
||||
(fn (recv-ty)
|
||||
(cond
|
||||
(and (list? recv-ty) (= (first recv-ty) :ty-name))
|
||||
(nth recv-ty 1)
|
||||
(and (list? recv-ty) (= (first recv-ty) :ty-ptr))
|
||||
(go-extract-recv-ty-name (nth recv-ty 1))
|
||||
:else nil)))
|
||||
|
||||
(define
|
||||
go-check-method-decl
|
||||
;; (list :method-decl RECV NAME PARAMS RESULTS BODY)
|
||||
;; Binds the method under the mangled key, then checks body with
|
||||
;; receiver + params extended.
|
||||
(fn (ctx decl)
|
||||
(let ((recv (nth decl 1)) (name (nth decl 2))
|
||||
(params (nth decl 3)) (results (nth decl 4))
|
||||
(body (nth decl 5)))
|
||||
(let ((recv-ty (nth recv 2)))
|
||||
(let ((recv-name (go-extract-recv-ty-name recv-ty)))
|
||||
(let ((sig (list :ty-func
|
||||
(go-decl-params-to-ty-list params) results)))
|
||||
(let ((ctx2
|
||||
(cond
|
||||
(= recv-name nil) ctx
|
||||
:else
|
||||
(go-ctx-extend ctx
|
||||
(go-method-key recv-name name) sig))))
|
||||
(cond
|
||||
(= body nil) ctx2
|
||||
(and (list? body) (= (first body) :block))
|
||||
(let ((body-ctx
|
||||
(go-extend-with-params
|
||||
(go-ctx-extend-field ctx2 recv) params)))
|
||||
(let ((err
|
||||
(go-check-block body-ctx
|
||||
(nth body 1) results)))
|
||||
(cond
|
||||
(go-type-error? err) err
|
||||
:else ctx2)))
|
||||
:else ctx2))))))))
|
||||
|
||||
(define
|
||||
go-iface-elems-satisfied?
|
||||
;; Each :method element in ELEMS must have a matching method in CTX
|
||||
;; under #method/TY-NAME/M-NAME. :embed elements are skipped in v0
|
||||
;; (they'd need recursive interface resolution).
|
||||
(fn (ctx ty-name elems)
|
||||
(cond
|
||||
(= (len elems) 0) true
|
||||
:else
|
||||
(let ((e (first elems)))
|
||||
(cond
|
||||
(= (first e) :method)
|
||||
(let ((m-name (nth e 1)) (m-params (nth e 2))
|
||||
(m-results (nth e 3)))
|
||||
(let ((found (go-ctx-lookup ctx
|
||||
(go-method-key ty-name m-name))))
|
||||
(cond
|
||||
(= found nil) false
|
||||
(and (= (nth found 1) m-params)
|
||||
(= (nth found 2) m-results))
|
||||
(go-iface-elems-satisfied? ctx ty-name (rest elems))
|
||||
:else false)))
|
||||
(= (first e) :embed)
|
||||
(go-iface-elems-satisfied? ctx ty-name (rest elems))
|
||||
:else
|
||||
(go-iface-elems-satisfied? ctx ty-name (rest elems)))))))
|
||||
|
||||
(define
|
||||
go-iface-satisfies?
|
||||
;; Does the type named TY-NAME satisfy the interface IFACE-TYPE
|
||||
;; under context CTX? Structural method-set match per Go spec.
|
||||
(fn (ctx ty-name iface-type)
|
||||
(cond
|
||||
(not (and (list? iface-type) (= (first iface-type) :ty-interface)))
|
||||
false
|
||||
:else (go-iface-elems-satisfied? ctx ty-name (nth iface-type 1)))))
|
||||
|
||||
;; ── function-decl checking ──────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-repeat-ty
|
||||
(fn (n ty acc)
|
||||
(cond
|
||||
(<= n 0) acc
|
||||
:else (go-repeat-ty (- n 1) ty (cons ty acc)))))
|
||||
|
||||
(define
|
||||
go-decl-params-to-ty-list
|
||||
;; Flatten (:field NAMES TYPE) param groups into a list of types,
|
||||
;; one entry per name. For func-type signatures.
|
||||
(fn (params)
|
||||
(cond
|
||||
(or (= params nil) (= (len params) 0)) (list)
|
||||
:else
|
||||
(let ((field (first params)))
|
||||
(let ((names (nth field 1)) (ty (nth field 2)))
|
||||
(let ((rest-tys (go-decl-params-to-ty-list (rest params))))
|
||||
(go-repeat-ty (len names) ty rest-tys)))))))
|
||||
|
||||
(define
|
||||
go-extend-with-params
|
||||
;; Extend CTX with every binding in every (:field NAMES TYPE) param group.
|
||||
(fn (ctx params)
|
||||
(cond
|
||||
(or (= params nil) (= (len params) 0)) ctx
|
||||
:else
|
||||
(go-extend-with-params
|
||||
(go-ctx-extend-field ctx (first params))
|
||||
(rest params)))))
|
||||
|
||||
(define
|
||||
go-check-return-list
|
||||
;; Each EXPR assignable to the corresponding RESULTS type.
|
||||
;; v0: lengths must match; multi-return funcs deferred.
|
||||
(fn (ctx exprs results)
|
||||
(cond
|
||||
(and (= (len exprs) 0) (= (len results) 0)) :ok
|
||||
(not (= (len exprs) (len results)))
|
||||
(list :type-error :return-count-mismatch
|
||||
(len exprs) (len results))
|
||||
:else
|
||||
(let ((r (go-check ctx (first exprs) (first results))))
|
||||
(cond
|
||||
(go-type-error? r) r
|
||||
:else (go-check-return-list ctx (rest exprs) (rest results)))))))
|
||||
|
||||
(define
|
||||
go-check-assign
|
||||
(fn (ctx stmt)
|
||||
(let ((lhs-list (nth stmt 1)) (rhs-list (nth stmt 2)))
|
||||
(cond
|
||||
(not (= (len lhs-list) (len rhs-list)))
|
||||
(list :type-error :assign-count-mismatch
|
||||
(len lhs-list) (len rhs-list))
|
||||
:else (go-check-assign-pairs ctx lhs-list rhs-list)))))
|
||||
|
||||
(define
|
||||
go-check-assign-pairs
|
||||
(fn (ctx lhs-list rhs-list)
|
||||
(cond
|
||||
(= (len lhs-list) 0) :ok
|
||||
:else
|
||||
(let ((lhs-ty (go-synth ctx (first lhs-list))))
|
||||
(cond
|
||||
(go-type-error? lhs-ty) lhs-ty
|
||||
:else
|
||||
(let ((r (go-check ctx (first rhs-list) lhs-ty)))
|
||||
(cond
|
||||
(go-type-error? r) r
|
||||
:else
|
||||
(go-check-assign-pairs ctx (rest lhs-list)
|
||||
(rest rhs-list)))))))))
|
||||
|
||||
(define
|
||||
go-check-stmt
|
||||
;; Returns either an extended CTX (decls), :ok (sealed stmts), or
|
||||
;; :type-error. RESULTS is the enclosing func's declared return types
|
||||
;; (used by :return).
|
||||
(fn (ctx stmt results)
|
||||
(cond
|
||||
(and (list? stmt) (= (first stmt) :var-decl))
|
||||
(go-check-decl ctx stmt)
|
||||
(and (list? stmt) (= (first stmt) :const-decl))
|
||||
(go-check-decl ctx stmt)
|
||||
(and (list? stmt) (= (first stmt) :short-decl))
|
||||
(go-check-decl ctx stmt)
|
||||
(and (list? stmt) (= (first stmt) :type-decl))
|
||||
(go-check-decl ctx stmt)
|
||||
(and (list? stmt) (= (first stmt) :return))
|
||||
(let ((exprs (nth stmt 1)))
|
||||
(let ((err (go-check-return-list ctx exprs results)))
|
||||
(cond (go-type-error? err) err :else ctx)))
|
||||
(and (list? stmt) (= (first stmt) :block))
|
||||
(let ((err (go-check-block ctx (nth stmt 1) results)))
|
||||
(cond (go-type-error? err) err :else ctx))
|
||||
(and (list? stmt) (= (first stmt) :assign))
|
||||
(let ((err (go-check-assign ctx stmt)))
|
||||
(cond (go-type-error? err) err :else ctx))
|
||||
:else
|
||||
(let ((t (go-synth ctx stmt)))
|
||||
(cond (go-type-error? t) t :else ctx)))))
|
||||
|
||||
(define
|
||||
go-check-block
|
||||
;; Thread ctx through stmts; if any stmt is a decl, its extension
|
||||
;; propagates to subsequent stmts. Returns :ok or :type-error.
|
||||
(fn (ctx stmts results)
|
||||
(cond
|
||||
(or (= stmts nil) (= (len stmts) 0)) :ok
|
||||
:else
|
||||
(let ((r (go-check-stmt ctx (first stmts) results)))
|
||||
(cond
|
||||
(go-type-error? r) r
|
||||
:else (go-check-block r (rest stmts) results))))))
|
||||
|
||||
(define
|
||||
go-check-func-decl
|
||||
;; Bind the function in the outer ctx (so recursion works), extend
|
||||
;; ctx with type params + value params, check the body. Returns the
|
||||
;; outer ctx with the function bound, or :type-error.
|
||||
;;
|
||||
;; Type parameters become opaque type variables in the body's ctx:
|
||||
;; each name `T` is bound as a type alias to (:ty-param "T") so the
|
||||
;; checker treats references to T as "this type", not "unknown".
|
||||
;; Constraint enforcement (T satisfies `comparable` etc.) is a
|
||||
;; later refinement; v0 just allows any operation that's polymorphic
|
||||
;; under the constraint `any`.
|
||||
(fn (ctx decl)
|
||||
(let ((name (nth decl 1)) (params (nth decl 2))
|
||||
(results (nth decl 3)) (body (nth decl 4))
|
||||
(type-params (cond (> (len decl) 5) (nth decl 5) :else nil)))
|
||||
(let ((fn-ty
|
||||
(list :ty-func
|
||||
(go-decl-params-to-ty-list params) results)))
|
||||
(let ((ctx-with-fn (go-ctx-extend ctx name fn-ty)))
|
||||
(cond
|
||||
(= body nil) ctx-with-fn
|
||||
(and (list? body) (= (first body) :block))
|
||||
(let ((body-ctx
|
||||
(go-extend-with-type-params
|
||||
(go-extend-with-params ctx-with-fn params)
|
||||
type-params)))
|
||||
(let ((err
|
||||
(go-check-block body-ctx (nth body 1) results)))
|
||||
(cond
|
||||
(go-type-error? err) err
|
||||
:else ctx-with-fn)))
|
||||
:else ctx-with-fn))))))
|
||||
|
||||
(define
|
||||
go-extend-with-type-params
|
||||
;; Each (:field NAMES CONSTRAINT) field contributes opaque type
|
||||
;; vars: bind each NAME as a type alias to (:ty-param NAME). The
|
||||
;; constraint type is stored alongside so future "constraint
|
||||
;; satisfaction" checks can find it; for v0 it's informational.
|
||||
(fn (ctx type-params)
|
||||
(cond
|
||||
(or (= type-params nil) (= (len type-params) 0)) ctx
|
||||
:else
|
||||
(let ((field (first type-params)))
|
||||
(let ((names (nth field 1)) (constraint (nth field 2)))
|
||||
(go-extend-with-type-params
|
||||
(go-extend-with-type-param-names ctx names constraint)
|
||||
(rest type-params)))))))
|
||||
|
||||
(define
|
||||
go-extend-with-type-param-names
|
||||
(fn (ctx names constraint)
|
||||
(cond
|
||||
(= (len names) 0) ctx
|
||||
:else
|
||||
(let ((nm (first names)))
|
||||
(go-extend-with-type-param-names
|
||||
(go-ctx-extend ctx nm
|
||||
(list :ty-param nm constraint))
|
||||
(rest names) constraint)))))
|
||||
1
next/.gitignore
vendored
1
next/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
data/
|
||||
170
next/README.md
170
next/README.md
@@ -1,170 +0,0 @@
|
||||
# next — fed-sx Milestone 1 kernel
|
||||
|
||||
Single-instance, single-actor fed-sx server built as Erlang-on-SX modules.
|
||||
See `plans/fed-sx-design.md` for the architecture and
|
||||
`plans/fed-sx-milestone-1.md` for the build plan + per-step progress log.
|
||||
|
||||
## Status
|
||||
|
||||
Both Step 9 smoke proof points are functional **in-process**:
|
||||
|
||||
- **9a-pure (verb extensibility)** — `Create{DefineActivity{Pin}}` registers Pin
|
||||
at runtime; subsequent `Pin{path, cid}` activities fold into a pin-state
|
||||
projection. Zero kernel code between definition and use.
|
||||
See `next/tests/smoke_pin_pure.sh`.
|
||||
- **9b-pure (reactive application)** — A trigger projection matches Notes
|
||||
tagged `smoketest` and derives a `TestEcho` carrying the source CID.
|
||||
See `next/tests/smoke_app_pure.sh`.
|
||||
|
||||
The remaining `9a-tcp` / `9b-tcp` deliverables layer TCP transport on top — see
|
||||
*Substrate gaps* below.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
next/
|
||||
├── kernel/ Erlang-on-SX kernel modules (.erl)
|
||||
├── genesis/ SX source files for the bootstrap bundle
|
||||
├── tests/ Bash test scripts driving sx_server.exe via the epoch protocol
|
||||
└── data/ Runtime state — gitignored
|
||||
```
|
||||
|
||||
## Module map
|
||||
|
||||
| Module | Role |
|
||||
|-----------------------|------------------------------------------------------------------------|
|
||||
| `nx_cid.erl` | Canonical CID wrapper around the host `cid:to_string` BIF |
|
||||
| `envelope.erl` | Activity envelope shape, canonical bytes, time-aware sig verify |
|
||||
| `log.erl` | Per-actor in-memory append log (open / append / tip / replay / entries) |
|
||||
| `registry.erl` | Pure-functional + gen_server-wrapped registry keyed by Kind |
|
||||
| `pipeline.erl` | Validation driver + stage_envelope/signature/replay/schema |
|
||||
| `projection.erl` | Pure projection driver + gen_server-per-projection wrapper |
|
||||
| `outbox.erl` | Envelope construct + sign + publish orchestrator + broadcast |
|
||||
| `bootstrap.erl` | Genesis read/build/verify/load + one-call `start/3` kernel bring-up |
|
||||
| `define_registry.erl` | Meta-projection fold for `Create{Define*}` → registry |
|
||||
| `sandbox.erl` | `eval_pure/2,3` try/catch envelope for projection folds |
|
||||
| `nx_kernel.erl` | Long-lived runtime orchestrator; per-actor bucketed state (m2 Step 1a) |
|
||||
| `http_server.erl` | route/1,2 + format-aware GET + POST + Accept header content negotiation |
|
||||
|
||||
## Genesis bundle
|
||||
|
||||
`next/genesis/` contains 31 SX files across 7 sections, all consumed as data
|
||||
(read + serialised by `bootstrap:populate_registry`, not eval'd):
|
||||
|
||||
- 3 activity-types — Create, Update, Delete
|
||||
- 10 object-types — SXArtifact, Note, Tombstone, 6 Define* meta-types, Snapshot
|
||||
- 7 projections — activity-log, by-type, by-actor, by-object, actor-state,
|
||||
define-registry, audience-graph
|
||||
- 3 validators — envelope-shape, signature, type-schema
|
||||
- 3 codecs — dag-cbor, raw, dag-json
|
||||
- 2 sig-suites — rsa-sha256-2018, ed25519-2020
|
||||
- 3 audience predicates — Public, Followers, Direct
|
||||
|
||||
`manifest.sx` is the bundle root, listed in dependency-friendly order.
|
||||
|
||||
## Tests
|
||||
|
||||
43 test suites, ~560+ assertions. Each script drives `sx_server.exe` via the
|
||||
epoch protocol — loads the Erlang substrate, loads relevant kernel modules
|
||||
via `code:load_binary` / `erlang-load-module`, then exercises behaviour
|
||||
through `erlang-eval-ast`.
|
||||
|
||||
Conventions:
|
||||
|
||||
- Scripts marked `_pure.sh` exercise pure-functional state.
|
||||
- Scripts marked `_server.sh` (or no suffix) exercise gen_server APIs and
|
||||
must inline `start_link` with operations — the Erlang-on-SX scheduler
|
||||
doesn't preserve spawned processes across separate `erlang-eval-ast`
|
||||
invocations.
|
||||
- `smoke_*_pure.sh` are end-to-end smoke tests demonstrating the §Step 9
|
||||
proof points without TCP / curl / JSON.
|
||||
|
||||
The Erlang-on-SX conformance gate (`bash lib/erlang/conformance.sh`, **729 /
|
||||
729**) is the no-regression contract — every commit on `loops/fed-sx-m1`
|
||||
preserves it.
|
||||
|
||||
## Substrate
|
||||
|
||||
Each `.erl` source file is hot-loaded at boot via
|
||||
`code:load_binary(Mod, Filename, SourceString)` (Phase 7 BIF). Tests drive
|
||||
the runtime via the epoch protocol:
|
||||
|
||||
```bash
|
||||
printf '(epoch 1)\n(load "lib/erlang/runtime.sx")\n(epoch 2)\n<test-expr>\n' \
|
||||
| hosts/ocaml/_build/default/bin/sx_server.exe
|
||||
```
|
||||
|
||||
The kernel calls into these host primitives: `crypto:hash/2`,
|
||||
`cid:from_bytes/1`, `cid:to_string/1`, `file:read_file/1`, `file:write_file/2`,
|
||||
`file:delete/1`, `file:list_dir/1`, `code:load_binary/3`, plus `http:listen/2`
|
||||
(the briefing's allowed scope exception, added to `lib/erlang/runtime.sx`).
|
||||
|
||||
### Substrate gaps (parked work)
|
||||
|
||||
These three gaps block the remaining unchecked deliverables:
|
||||
|
||||
1. **Term codec** (`3b`/`3c`) — **all three substrate fixes done 2026-06-05:**
|
||||
`erlang:binary_to_list/1` and `erlang:list_to_binary/1` registered in
|
||||
`lib/erlang/runtime.sx` (iolist-aware); the tokenizer's `$X` branch
|
||||
emits the decimal char code; `atom_to_list/1` and `integer_to_list/1`
|
||||
now return Erlang charlists (standard Erlang semantics) with `list_to_atom`/
|
||||
`list_to_integer` accepting both charlists and SX strings for back-compat.
|
||||
759/759 conformance. The full term-codec primitive set is in place —
|
||||
Step 3b on-disk segment writer can encode arbitrary Erlang activity
|
||||
terms (atoms, ints, binaries, tuples, lists) into byte sequences using
|
||||
only Erlang-native primitives.
|
||||
|
||||
2. **SX-source eval bridge** — There's no BIF that lets Erlang call into the
|
||||
SX evaluator on a parsed source string. Blocks evaluating the `:schema` /
|
||||
`:fold` / `:predicate` / `:verify` bodies from the genesis bundle. Erlang-fun
|
||||
stand-ins (`pipeline:stage_schema`, `define_registry:fold`, etc.) prove the
|
||||
API shapes; the bridge would let bundle bodies dispatch through them
|
||||
unchanged.
|
||||
|
||||
3. **Dict ↔ proplist marshalling for `http:listen/2`** — **done 2026-06-05.**
|
||||
`er-bif-http-listen` marshals the native server's request dict
|
||||
(`{:method :path :query :headers :body}`) into the proplist shape
|
||||
`[{method, Bin}, {path, Bin}, {query, Bin}, {headers, [{Name, Value}]},
|
||||
{body, Bin}]` that `http_server:route/2` consumes, and converts the
|
||||
handler's response proplist back to `{:status :headers :body}` for the
|
||||
native server to serialise. Helpers (`er-request-dict-to-proplist`,
|
||||
`er-proplist-to-dict`, `er-of-sx-deep`, `er-to-sx-deep`,
|
||||
`er-dict-to-header-proplist`, `er-proplist-fill!`) live alongside the
|
||||
BIF wrapper in `lib/erlang/runtime.sx`. The BIF also spawns the handler
|
||||
into a real Erlang process via `er-spawn-fun` + `er-sched-run-all!`
|
||||
so `self()` / `gen_server:call` work inside route handlers (the kernel
|
||||
and projection gen_servers reach the handler this way). Verified by
|
||||
`next/tests/http_marshal.sh` and the live TCP smoke
|
||||
`next/tests/http_server_tcp.sh` / `http_server_start.sh`. Unblocks
|
||||
`Step 8b-start` (TCP listener spawn) and the curl-driven 9a-tcp / 9b-tcp
|
||||
smoke tests.
|
||||
|
||||
### Bringing up the kernel
|
||||
|
||||
For tests, `bootstrap:start/3(ActorId, KeySpec, ActorState)` is the
|
||||
one-call boot:
|
||||
|
||||
```erlang
|
||||
KM = <<1,2,3,4>>,
|
||||
KS = [{key_id, k1}, {algorithm, ed25519}, {value, KM}],
|
||||
AS = [{public_keys, [[{id, k1}, {created, 0}, {value, KM}]]}],
|
||||
Pid = bootstrap:start(alice, KS, AS),
|
||||
%% nx_kernel + registry populated; you now have a kernel.
|
||||
```
|
||||
|
||||
The HTTP layer (`http_server`) and `nx_kernel:publish/1` flow through the
|
||||
same in-process gen_servers; `http_publish_fold.sh` is the end-to-end proof
|
||||
the chain works.
|
||||
|
||||
## What's next (when work resumes)
|
||||
|
||||
In priority order:
|
||||
|
||||
1. **8b-start** — `http_server:start/1` spawns a process hosting `http:listen/2`.
|
||||
(8b-bridge done — see Substrate gap #3.)
|
||||
2. **9a-tcp / 9b-tcp** — replace the in-process smoke scripts with curl-driven
|
||||
versions hitting the running server.
|
||||
3. **Term codec / on-disk log** — needs either a new BIF or a temp-file
|
||||
workaround; current in-memory log keeps everything functional otherwise.
|
||||
4. **SX-source eval bridge** — unlocks real `:schema` / `:fold` body
|
||||
evaluation from the genesis bundle.
|
||||
@@ -1,14 +0,0 @@
|
||||
;; next/genesis/activity-types/announce.sx
|
||||
;;
|
||||
;; Bootstrap definition of the Announce verb per design §13.5 / m2
|
||||
;; Step 11. An Announce re-broadcasts a peer's activity to the
|
||||
;; announcer's followers: the announcer's outbox carries an Announce
|
||||
;; envelope whose :object is the original activity's CID. Followers
|
||||
;; can re-fetch the wrapped activity from the original instance if
|
||||
;; their projection wants to fold the body.
|
||||
|
||||
(DefineActivity
|
||||
:name "Announce"
|
||||
:doc "Re-broadcast a peer's activity to followers. :object is the CID of the activity being announced. Recipients see the Announce in their inbox / feed; their projection decides whether to fetch the wrapped activity body."
|
||||
:schema (fn (act) (string? (-> act :object)))
|
||||
:semantics (fn (state act) state))
|
||||
@@ -1,15 +0,0 @@
|
||||
;; next/genesis/activity-types/create.sx
|
||||
;;
|
||||
;; Bootstrap definition of the Create verb per design §3 and §12.2.
|
||||
;; Read as data by the bundler (bootstrap.erl) — never evaluated as
|
||||
;; code. The :schema and :semantics bodies are SX source; the
|
||||
;; validation pipeline (Step 6) and projection scheduler (Step 7)
|
||||
;; evaluate them at the appropriate times.
|
||||
|
||||
(DefineActivity
|
||||
:name "Create"
|
||||
:doc "Publish a new object. Required for actor onboarding and for\n every Define* meta-activity. The activity's :object holds\n the canonical content of the published object."
|
||||
:schema (fn
|
||||
(act)
|
||||
(and (not (nil? (-> act :object))) (string? (-> act :object :type))))
|
||||
:semantics (fn (state act) state))
|
||||
@@ -1,13 +0,0 @@
|
||||
;; next/genesis/activity-types/delete.sx
|
||||
;;
|
||||
;; Bootstrap definition of the Delete verb per design §3 and §12.2.
|
||||
;; Read as data by the bundler — never evaluated as code here. The
|
||||
;; :schema and :semantics bodies are SX source; the validator
|
||||
;; pipeline (Step 6) and projection scheduler (Step 7) evaluate them
|
||||
;; at the appropriate times.
|
||||
|
||||
(DefineActivity
|
||||
:name "Delete"
|
||||
:doc "Tombstone an existing object. :object is the CID of the\n target. Projections fold Delete by removing the object from\n their working indexes; the underlying log line is never\n erased — durability of the historical record is independent\n of projection state."
|
||||
:schema (fn (act) (string? (-> act :object)))
|
||||
:semantics (fn (state act) state))
|
||||
@@ -1,13 +0,0 @@
|
||||
;; next/genesis/activity-types/endorse.sx
|
||||
;;
|
||||
;; Bootstrap definition of the Endorse verb per design §13.5 / m2
|
||||
;; Step 11. An Endorse expresses cross-actor signal on a target
|
||||
;; activity (like / share / etc.). :object is the target activity's
|
||||
;; CID; :kind is the endorsement variant (string). Projections
|
||||
;; aggregate endorsements into counters / heat / ranking signals.
|
||||
|
||||
(DefineActivity
|
||||
:name "Endorse"
|
||||
:doc "Cross-actor signal on a target activity. :object is the target activity's CID; :kind is the endorsement variant (e.g. 'like', 'share'). Projections aggregate endorsements into counters / heat / ranking signals."
|
||||
:schema (fn (act) (and (string? (-> act :object)) (string? (-> act :kind))))
|
||||
:semantics (fn (state act) state))
|
||||
@@ -1,15 +0,0 @@
|
||||
;; next/genesis/activity-types/update.sx
|
||||
;;
|
||||
;; Bootstrap definition of the Update verb per design §3 and §12.2.
|
||||
;; Read as data by the bundler — never evaluated as code here. The
|
||||
;; :schema and :semantics bodies are SX source; the validator
|
||||
;; pipeline (Step 6) and projection scheduler (Step 7) evaluate them
|
||||
;; at the appropriate times.
|
||||
|
||||
(DefineActivity
|
||||
:name "Update"
|
||||
:doc "Patch or replace an existing object. :object is the CID of\n the target; :patch is the field-level edit. Behaviour is\n delegated to per-object-type semantics — e.g. an Update of a\n DefineActivity supersedes the prior registry entry; an\n Update of a Person actor rotates keys via :patch :add-publicKey\n + :patch :supersede."
|
||||
:schema (fn
|
||||
(act)
|
||||
(and (string? (-> act :object)) (not (nil? (-> act :patch)))))
|
||||
:semantics (fn (state act) state))
|
||||
@@ -1,14 +0,0 @@
|
||||
;; next/genesis/audience/direct.sx
|
||||
;;
|
||||
;; Direct audience: an actor is a member iff they are
|
||||
;; explicitly named in the activity's :to or :cc lists. No
|
||||
;; group expansion — true direct addressing only.
|
||||
|
||||
(DefineAudience
|
||||
:name "Direct"
|
||||
:doc "Direct-addressing predicate. Tests literal membership\n in the activity's :to or :cc."
|
||||
:member-of (fn
|
||||
(actor audience)
|
||||
(or
|
||||
(member? actor (-> audience :to))
|
||||
(member? actor (-> audience :cc)))))
|
||||
@@ -1,14 +0,0 @@
|
||||
;; next/genesis/audience/followers.sx
|
||||
;;
|
||||
;; Followers audience: an actor is a member iff they appear in
|
||||
;; the audience-owner's :followers set in the audience-graph
|
||||
;; projection. Federation (m2) wires this to peer delivery.
|
||||
|
||||
(DefineAudience
|
||||
:name "Followers"
|
||||
:doc "Followers-of-owner predicate. Looks up the\n audience-graph projection's :followers list for the\n audience owner and tests membership."
|
||||
:member-of (fn
|
||||
(actor audience)
|
||||
(member?
|
||||
actor
|
||||
(-> (get-projection :audience-graph) (-> audience :owner) :followers))))
|
||||
@@ -1,9 +0,0 @@
|
||||
;; next/genesis/audience/public.sx
|
||||
;;
|
||||
;; Public audience: every actor is a member. Maps to the AP
|
||||
;; magic id `https://www.w3.org/ns/activitystreams#Public`.
|
||||
|
||||
(DefineAudience
|
||||
:name "Public"
|
||||
:doc "Public audience predicate. Always returns true — every\n actor on the network is considered a member."
|
||||
:member-of (fn (actor audience) true))
|
||||
@@ -1,13 +0,0 @@
|
||||
;; next/genesis/codecs/dag-cbor.sx
|
||||
;;
|
||||
;; Canonical CBOR encoding per IPLD dag-cbor. Used to compute
|
||||
;; envelope canonical bytes for signature coverage and to serialise
|
||||
;; the genesis bundle itself. In Erlang-on-SX mode the kernel
|
||||
;; dispatches to the host cid:to_string substrate (Step 1b) when
|
||||
;; this codec is requested.
|
||||
|
||||
(DefineCodec
|
||||
:name "dag-cbor"
|
||||
:doc "Deterministic CBOR with dag-cbor restrictions: sorted\n map keys, no floats unless required, no indefinite-length\n items. The canonical wire format for fed-sx artifacts."
|
||||
:encode (fn (term) (host-codec :dag-cbor :encode term))
|
||||
:decode (fn (bytes) (host-codec :dag-cbor :decode bytes)))
|
||||
@@ -1,12 +0,0 @@
|
||||
;; next/genesis/codecs/dag-json.sx
|
||||
;;
|
||||
;; JSON encoding with dag-json restrictions per IPLD: sorted map
|
||||
;; keys, no NaN / Infinity, no comments, CIDs as `{"/": "..."}`.
|
||||
;; Used as the human-readable wire format for ActivityPub interop
|
||||
;; (JSON-LD over dag-json).
|
||||
|
||||
(DefineCodec
|
||||
:name "dag-json"
|
||||
:doc "Deterministic JSON with dag-json restrictions. Sorted\n keys, CIDs as the {\"/\": \"...\"} object. Used by the\n HTTP server (Step 8) for application/json responses."
|
||||
:encode (fn (term) (host-codec :dag-json :encode term))
|
||||
:decode (fn (bytes) (host-codec :dag-json :decode bytes)))
|
||||
@@ -1,12 +0,0 @@
|
||||
;; next/genesis/codecs/raw.sx
|
||||
;;
|
||||
;; Identity codec — input bytes pass through unchanged in both
|
||||
;; directions. Used for already-encoded payloads and for binary
|
||||
;; artifacts (images, archives) whose CID is computed over the
|
||||
;; raw bytes directly.
|
||||
|
||||
(DefineCodec
|
||||
:name "raw"
|
||||
:doc "Identity codec. The CID's multicodec byte is 0x55.\n :encode and :decode return their input unchanged."
|
||||
:encode (fn (bytes) bytes)
|
||||
:decode (fn (bytes) bytes))
|
||||
@@ -1,51 +0,0 @@
|
||||
;; next/genesis/manifest.sx
|
||||
;;
|
||||
;; Genesis bundle root per design §12.2. Lists every definition file
|
||||
;; that gets packed into the bundle. The bundler (bootstrap.erl)
|
||||
;; walks this manifest, reads each referenced file, parses its
|
||||
;; top-level form, and inserts it into the bundle dict at the
|
||||
;; appropriate section path.
|
||||
;;
|
||||
;; The bundle CID is the content-address of the resulting dag-cbor
|
||||
;; (or v1 stand-in) blob over the assembled dict. That CID is
|
||||
;; baked into the kernel at build time and re-verified on startup
|
||||
;; per design §12.3.
|
||||
;;
|
||||
;; Section values are bare parenthesised paths (data lists, not
|
||||
;; function calls) — the manifest is consumed by `parse`, not
|
||||
;; `eval`. Empty sections are written as `()`.
|
||||
|
||||
(GenesisManifest
|
||||
:version "0.0.1"
|
||||
:kernel-version "1.0.0-m1"
|
||||
:activity-types ("activity-types/create.sx"
|
||||
"activity-types/update.sx"
|
||||
"activity-types/delete.sx"
|
||||
"activity-types/announce.sx"
|
||||
"activity-types/endorse.sx")
|
||||
:object-types ("object-types/sx-artifact.sx"
|
||||
"object-types/note.sx"
|
||||
"object-types/tombstone.sx"
|
||||
"object-types/person.sx"
|
||||
"object-types/service.sx"
|
||||
"object-types/group.sx"
|
||||
"object-types/define-activity.sx"
|
||||
"object-types/define-object.sx"
|
||||
"object-types/define-projection.sx"
|
||||
"object-types/define-validator.sx"
|
||||
"object-types/define-codec.sx"
|
||||
"object-types/define-sig-suite.sx"
|
||||
"object-types/snapshot.sx")
|
||||
:projections ("projections/activity-log.sx"
|
||||
"projections/by-type.sx"
|
||||
"projections/by-actor.sx"
|
||||
"projections/by-object.sx"
|
||||
"projections/actor-state.sx"
|
||||
"projections/define-registry.sx"
|
||||
"projections/audience-graph.sx")
|
||||
:validators ("validators/envelope-shape.sx"
|
||||
"validators/signature.sx"
|
||||
"validators/type-schema.sx")
|
||||
:codecs ("codecs/dag-cbor.sx" "codecs/raw.sx" "codecs/dag-json.sx")
|
||||
:sig-suites ("sig-suites/rsa-sha256-2018.sx" "sig-suites/ed25519-2020.sx")
|
||||
:audience ("audience/public.sx" "audience/followers.sx" "audience/direct.sx"))
|
||||
@@ -1,12 +0,0 @@
|
||||
;; next/genesis/object-types/define-activity.sx
|
||||
;;
|
||||
;; Meta-object that registers a new activity verb. Published as
|
||||
;; Create{DefineActivity{...}}; the define-registry projection
|
||||
;; folds it into the activity-types registry. Per design §5.
|
||||
|
||||
(DefineObject
|
||||
:name "DefineActivity"
|
||||
:doc "Activity-type registration. :name is the verb (e.g.\n \"Pin\"); :schema is an SX predicate over activity\n envelopes; :semantics is an optional state-fold body."
|
||||
:schema (fn
|
||||
(obj)
|
||||
(and (string? (-> obj :name)) (not (nil? (-> obj :schema))))))
|
||||
@@ -1,15 +0,0 @@
|
||||
;; next/genesis/object-types/define-codec.sx
|
||||
;;
|
||||
;; Meta-object that registers a content codec — an encode/decode
|
||||
;; pair. The bootstrap bundle ships dag-cbor, raw, and dag-json
|
||||
;; codecs; new codecs can be added via Create{DefineCodec{...}}.
|
||||
|
||||
(DefineObject
|
||||
:name "DefineCodec"
|
||||
:doc "Codec registration. :name identifies the codec ('dag-cbor',\n 'raw', 'dag-json', ...); :encode and :decode are the\n SX bodies the kernel calls when serialising / parsing\n artifacts under this codec."
|
||||
:schema (fn
|
||||
(obj)
|
||||
(and
|
||||
(string? (-> obj :name))
|
||||
(not (nil? (-> obj :encode)))
|
||||
(not (nil? (-> obj :decode))))))
|
||||
@@ -1,12 +0,0 @@
|
||||
;; next/genesis/object-types/define-object.sx
|
||||
;;
|
||||
;; Meta-object that registers a new object-type. Bootstrap-level —
|
||||
;; runtime registration of new object types (e.g. DefineSubscription
|
||||
;; in the Step 9b smoke test) flows through this.
|
||||
|
||||
(DefineObject
|
||||
:name "DefineObject"
|
||||
:doc "Object-type registration. :name is the type tag (e.g.\n \"PinSpec\"); :schema is an SX predicate over object\n forms of that type."
|
||||
:schema (fn
|
||||
(obj)
|
||||
(and (string? (-> obj :name)) (not (nil? (-> obj :schema))))))
|
||||
@@ -1,16 +0,0 @@
|
||||
;; next/genesis/object-types/define-projection.sx
|
||||
;;
|
||||
;; Meta-object that registers a new projection. The projection
|
||||
;; scheduler (Step 7) spawns one gen_server per registered
|
||||
;; projection and feeds activities through its :fold body in
|
||||
;; sandbox mode.
|
||||
|
||||
(DefineObject
|
||||
:name "DefineProjection"
|
||||
:doc "Projection registration. :name is the projection key;\n :initial-state is the empty state value; :fold is the\n pure (state activity) -> state function evaluated in\n sandbox mode per activity."
|
||||
:schema (fn
|
||||
(obj)
|
||||
(and
|
||||
(string? (-> obj :name))
|
||||
(not (nil? (-> obj :initial-state)))
|
||||
(not (nil? (-> obj :fold))))))
|
||||
@@ -1,12 +0,0 @@
|
||||
;; next/genesis/object-types/define-sig-suite.sx
|
||||
;;
|
||||
;; Meta-object that registers a signature suite. Bootstrap ships
|
||||
;; rsa-sha256-2018 and ed25519-2020; the suite name maps an
|
||||
;; algorithm to a :verify body and a :key-format predicate.
|
||||
|
||||
(DefineObject
|
||||
:name "DefineSigSuite"
|
||||
:doc "Signature suite registration. :name identifies the suite\n ('rsa-sha256-2018', 'ed25519-2020', ...); :verify is the\n SX (canonical-bytes signature key) -> bool body; the\n envelope-signature validator dispatches by suite name."
|
||||
:schema (fn
|
||||
(obj)
|
||||
(and (string? (-> obj :name)) (not (nil? (-> obj :verify))))))
|
||||
@@ -1,12 +0,0 @@
|
||||
;; next/genesis/object-types/define-validator.sx
|
||||
;;
|
||||
;; Meta-object that registers a validator predicate. The validation
|
||||
;; pipeline (Step 6) consults registered validators by name when
|
||||
;; running its stages.
|
||||
|
||||
(DefineObject
|
||||
:name "DefineValidator"
|
||||
:doc "Validator registration. :name is the validator key (e.g.\n \"envelope-shape\"); :predicate is the SX (activity) ->\n ok|{error, R} body."
|
||||
:schema (fn
|
||||
(obj)
|
||||
(and (string? (-> obj :name)) (not (nil? (-> obj :predicate))))))
|
||||
@@ -1,11 +0,0 @@
|
||||
;; next/genesis/object-types/group.sx
|
||||
;;
|
||||
;; Per design §9.1: a Group is a multi-controller actor — typically
|
||||
;; a working group, channel, or collective whose membership is
|
||||
;; managed via Add/Remove activities. Sig-suite validation honours
|
||||
;; the current key-set rather than a single keypair.
|
||||
|
||||
(DefineObject
|
||||
:name "Group"
|
||||
:doc "Multi-controller actor. :name is the group's display name; :preferredUsername is the local handle; :summary is the description; :icon is a CID or URL; :members is the current member list (managed via Add/Remove)."
|
||||
:schema (fn (obj) (string? (-> obj :name))))
|
||||
@@ -1,10 +0,0 @@
|
||||
;; next/genesis/object-types/note.sx
|
||||
;;
|
||||
;; Short message intended for an audience, ActivityPub-Note-compatible.
|
||||
;; Used by the Step 9b reactive smoke test (Note tagged "smoketest"
|
||||
;; matches the Topic subscription).
|
||||
|
||||
(DefineObject
|
||||
:name "Note"
|
||||
:doc "Short authored message. :content is the body text;\n :tags is a list of subscription-routable tags."
|
||||
:schema (fn (obj) (string? (-> obj :content))))
|
||||
@@ -1,11 +0,0 @@
|
||||
;; next/genesis/object-types/person.sx
|
||||
;;
|
||||
;; Per design §9.1: a Person is the canonical actor type for a
|
||||
;; human-controlled identity. Bootstrapped via Create{Person{...}}
|
||||
;; as the actor's first activity (see nx_kernel:bootstrap_actor/4).
|
||||
;; ActivityPub-Person-compatible.
|
||||
|
||||
(DefineObject
|
||||
:name "Person"
|
||||
:doc "Human-controlled actor. :name is the display name; :preferredUsername is the local handle; :summary is the profile bio; :icon is a CID or URL."
|
||||
:schema (fn (obj) (string? (-> obj :name))))
|
||||
@@ -1,11 +0,0 @@
|
||||
;; next/genesis/object-types/service.sx
|
||||
;;
|
||||
;; Per design §9.1: a Service is a non-human actor — a bot, an
|
||||
;; automated feed, an organisational publisher. Same activity
|
||||
;; surface as Person, different ActivityPub Actor type. Tooling
|
||||
;; treats a Service identically to a Person except for UX hints.
|
||||
|
||||
(DefineObject
|
||||
:name "Service"
|
||||
:doc "Automated / programmatic actor. :name is the display name; :preferredUsername is the local handle; :summary is the profile bio; :icon is a CID or URL."
|
||||
:schema (fn (obj) (string? (-> obj :name))))
|
||||
@@ -1,13 +0,0 @@
|
||||
;; next/genesis/object-types/snapshot.sx
|
||||
;;
|
||||
;; Projection state checkpoint. The projection scheduler emits
|
||||
;; Snapshot{projection-name, state-cid, log-seq} periodically;
|
||||
;; cold starts read the most recent Snapshot and replay only
|
||||
;; activities after :log-seq. Per design §10.5.
|
||||
|
||||
(DefineObject
|
||||
:name "Snapshot"
|
||||
:doc "Projection-state checkpoint. :projection-name identifies\n the projection; :state-cid is the content-address of\n the snapshotted state value; :log-seq is the activity\n sequence number the snapshot was taken at."
|
||||
:schema (fn
|
||||
(obj)
|
||||
(and (string? (-> obj :projection-name)) (string? (-> obj :state-cid)))))
|
||||
@@ -1,10 +0,0 @@
|
||||
;; next/genesis/object-types/sx-artifact.sx
|
||||
;;
|
||||
;; Content-addressed SX source — a library, component, or
|
||||
;; executable form published via Create{SXArtifact{...}}.
|
||||
;; Consumers reference an artifact by its CID. Per design §3.4.
|
||||
|
||||
(DefineObject
|
||||
:name "SXArtifact"
|
||||
:doc "Published SX source. :source carries the form text;\n :language is optional ('sx' by default); :imports lists\n CIDs the artifact depends on."
|
||||
:schema (fn (obj) (string? (-> obj :source))))
|
||||
@@ -1,9 +0,0 @@
|
||||
;; next/genesis/object-types/tombstone.sx
|
||||
;;
|
||||
;; Replacement for an object that has been Delete'd. Lets projection
|
||||
;; folds keep a marker without retaining the deleted content.
|
||||
|
||||
(DefineObject
|
||||
:name "Tombstone"
|
||||
:doc "Marker for a deleted object. :former-cid carries the CID\n of the object that was removed. Projections fold Tombstone\n by replacing the cached entry (not by omitting it)."
|
||||
:schema (fn (obj) (string? (-> obj :former-cid))))
|
||||
@@ -1,11 +0,0 @@
|
||||
;; next/genesis/projections/activity-log.sx
|
||||
;;
|
||||
;; Identity projection: stores every activity by its CID. The
|
||||
;; base ledger every other projection could be re-derived from
|
||||
;; if needed. Per design §10.2.
|
||||
|
||||
(DefineProjection
|
||||
:name "activity-log"
|
||||
:doc "Maps activity CID to the full envelope. Every activity\n flows through; no filter. State is the CID-keyed dict."
|
||||
:initial-state {}
|
||||
:fold (fn (state act) (assoc state (-> act :cid) act)))
|
||||
@@ -1,26 +0,0 @@
|
||||
;; next/genesis/projections/actor-state.sx
|
||||
;;
|
||||
;; Per-actor live state: publicKeys (with history per design §9.6),
|
||||
;; profile fields (preferredUsername, summary, ...), follower/
|
||||
;; following counts. Powers the actor doc endpoint and the
|
||||
;; time-aware signature verification in envelope:verify_signature/2.
|
||||
|
||||
(DefineProjection
|
||||
:name "actor-state"
|
||||
:doc "Actor-id -> {publicKeys, profile, followers, following}.\n Updated by Create{Person|Service|Group}, Update (key\n rotation, profile edits), Move (federation migration)."
|
||||
:initial-state {}
|
||||
:fold (fn
|
||||
(state act)
|
||||
(let
|
||||
((aid (-> act :actor)) (t (-> act :type)))
|
||||
(cond
|
||||
(= t "Create")
|
||||
(assoc state aid (or (-> act :object) {}))
|
||||
(= t "Update")
|
||||
(assoc
|
||||
state
|
||||
aid
|
||||
(merge
|
||||
(or (get state aid) {})
|
||||
(or (-> act :patch) {})))
|
||||
:else state))))
|
||||
@@ -1,25 +0,0 @@
|
||||
;; next/genesis/projections/audience-graph.sx
|
||||
;;
|
||||
;; Per-actor follow / follower graph and audience caches. Folded
|
||||
;; from Follow / Accept / Reject / Undo{Follow}. Used by the
|
||||
;; activity router to expand :to / :cc audiences (Public,
|
||||
;; Followers, Direct) into concrete recipient sets. Per design §16.
|
||||
|
||||
(DefineProjection
|
||||
:name "audience-graph"
|
||||
:doc "Actor-id -> {following, followers, pending} sets.\n Updated by Follow / Accept / Reject / Undo. Federation\n (m2) wires this projection to the delivery queue."
|
||||
:initial-state {}
|
||||
:fold (fn
|
||||
(state act)
|
||||
(let
|
||||
((t (-> act :type)))
|
||||
(cond
|
||||
(= t "Follow")
|
||||
state
|
||||
(= t "Accept")
|
||||
state
|
||||
(= t "Reject")
|
||||
state
|
||||
(= t "Undo")
|
||||
state
|
||||
:else state))))
|
||||
@@ -1,15 +0,0 @@
|
||||
;; next/genesis/projections/by-actor.sx
|
||||
;;
|
||||
;; Index of activity CIDs grouped by :actor. Maps actor-id to a
|
||||
;; list of CIDs in append order. Powers the per-actor outbox
|
||||
;; listing (Step 8) without re-scanning the full log.
|
||||
|
||||
(DefineProjection
|
||||
:name "by-actor"
|
||||
:doc "Actor-id -> list of activity CIDs (append order)."
|
||||
:initial-state {}
|
||||
:fold (fn
|
||||
(state act)
|
||||
(let
|
||||
((a (-> act :actor)) (cid (-> act :cid)))
|
||||
(assoc state a (append (or (get state a) (list)) (list cid))))))
|
||||
@@ -1,22 +0,0 @@
|
||||
;; next/genesis/projections/by-object.sx
|
||||
;;
|
||||
;; Index of activities that reference each :object CID. Maps
|
||||
;; object-CID to the list of activity CIDs that target it
|
||||
;; (Update / Delete / Announce / etc.). Used for "show me
|
||||
;; everything that happened to X" queries.
|
||||
|
||||
(DefineProjection
|
||||
:name "by-object"
|
||||
:doc "Object CID -> list of activity CIDs that target it."
|
||||
:initial-state {}
|
||||
:fold (fn
|
||||
(state act)
|
||||
(let
|
||||
((obj-cid (-> act :object)) (cid (-> act :cid)))
|
||||
(if
|
||||
(string? obj-cid)
|
||||
(assoc
|
||||
state
|
||||
obj-cid
|
||||
(append (or (get state obj-cid) (list)) (list cid)))
|
||||
state))))
|
||||
@@ -1,15 +0,0 @@
|
||||
;; next/genesis/projections/by-type.sx
|
||||
;;
|
||||
;; Index of activity CIDs grouped by :type. Maps type-name to a
|
||||
;; list of CIDs in append order. Used by the outbox listing
|
||||
;; endpoints (Step 8) for type-filtered pagination.
|
||||
|
||||
(DefineProjection
|
||||
:name "by-type"
|
||||
:doc "Type-name -> list of activity CIDs (append order)."
|
||||
:initial-state {}
|
||||
:fold (fn
|
||||
(state act)
|
||||
(let
|
||||
((t (-> act :type)) (cid (-> act :cid)))
|
||||
(assoc state t (append (or (get state t) (list)) (list cid))))))
|
||||
@@ -1,33 +0,0 @@
|
||||
;; next/genesis/projections/define-registry.sx
|
||||
;;
|
||||
;; The meta-projection: folds Create{Define*{...}} activities into
|
||||
;; the kernel registry. Resolves the chicken-and-egg circle —
|
||||
;; bootstrap.erl populates the registry directly at startup from
|
||||
;; the genesis bundle, and from then on define-registry's fold
|
||||
;; keeps it current as new Define* activities arrive. Per design §5.
|
||||
|
||||
(DefineProjection
|
||||
:name "define-registry"
|
||||
:doc "Maps {kind, name} -> definition entry. Folded from\n Create{DefineActivity|DefineObject|DefineProjection|\n DefineValidator|DefineCodec|DefineSigSuite|...}. Kind is\n derived from the inner :object :type tag."
|
||||
:initial-state {}
|
||||
:fold (fn
|
||||
(state act)
|
||||
(let
|
||||
((obj (-> act :object)) (otype (-> act :object :type)))
|
||||
(cond
|
||||
(= (-> act :type) "Create")
|
||||
(cond
|
||||
(= otype "DefineActivity")
|
||||
(assoc-in state (list :activity-types (-> obj :name)) obj)
|
||||
(= otype "DefineObject")
|
||||
(assoc-in state (list :object-types (-> obj :name)) obj)
|
||||
(= otype "DefineProjection")
|
||||
(assoc-in state (list :projections (-> obj :name)) obj)
|
||||
(= otype "DefineValidator")
|
||||
(assoc-in state (list :validators (-> obj :name)) obj)
|
||||
(= otype "DefineCodec")
|
||||
(assoc-in state (list :codecs (-> obj :name)) obj)
|
||||
(= otype "DefineSigSuite")
|
||||
(assoc-in state (list :sig-suites (-> obj :name)) obj)
|
||||
:else state)
|
||||
:else state))))
|
||||
@@ -1,11 +0,0 @@
|
||||
;; next/genesis/sig-suites/ed25519-2020.sx
|
||||
;;
|
||||
;; W3C Verifiable Credential signature suite — Ed25519 over
|
||||
;; canonical bytes, key material in multibase. Default suite
|
||||
;; for fed-sx actors per design §9.
|
||||
|
||||
(DefineSigSuite
|
||||
:name "ed25519-2020"
|
||||
:doc "Ed25519 verification. Key carries publicKeyMultibase.\n :verify takes canonical-bytes + signature + key and\n returns bool. Real verification deferred to m2 once\n crypto:verify_ed25519/3 BIF lands; v1 stand-in returns\n false to defer all Ed25519-signed activities."
|
||||
:verify (fn (canonical-bytes signature key) false)
|
||||
:key-format (fn (key-doc) (string? (-> key-doc :publicKeyMultibase))))
|
||||
@@ -1,11 +0,0 @@
|
||||
;; next/genesis/sig-suites/rsa-sha256-2018.sx
|
||||
;;
|
||||
;; W3C Verifiable Credential signature suite — RSA-SHA256 over
|
||||
;; canonical bytes, key material in PEM. Compatible with
|
||||
;; Mastodon's HTTP-Signatures / Linked-Data-Signatures-2017.
|
||||
|
||||
(DefineSigSuite
|
||||
:name "rsa-sha256-2018"
|
||||
:doc "RSA-SHA256 verification. Key carries publicKeyPem.\n :verify takes canonical-bytes + signature + key and\n returns bool. Real verification deferred to m2 once\n crypto:verify_rsa/3 BIF lands; v1 stand-in returns\n false to defer all RSA-signed activities."
|
||||
:verify (fn (canonical-bytes signature key) false)
|
||||
:key-format (fn (key-doc) (string? (-> key-doc :publicKeyPem))))
|
||||
@@ -1,22 +0,0 @@
|
||||
;; next/genesis/validators/envelope-shape.sx
|
||||
;;
|
||||
;; Validates required envelope fields per design §3.1. Stage 1 of
|
||||
;; the validation pipeline (Step 6). Mirrors the kernel's
|
||||
;; envelope:validate_shape/1 from Step 2a — when the pipeline runs
|
||||
;; in OCaml-side sandbox eval mode it dispatches by name; when it
|
||||
;; runs through the kernel Erlang path it short-circuits to the BIF.
|
||||
|
||||
(DefineValidator
|
||||
:name "envelope-shape"
|
||||
:doc "Required-fields check on the activity envelope:\n :id, :type, :actor, :published, :signature must all be\n present and non-nil. The :signature sub-field needs\n :key_id, :algorithm, :value."
|
||||
:predicate (fn
|
||||
(act)
|
||||
(and
|
||||
(not (nil? (-> act :id)))
|
||||
(not (nil? (-> act :type)))
|
||||
(not (nil? (-> act :actor)))
|
||||
(not (nil? (-> act :published)))
|
||||
(not (nil? (-> act :signature)))
|
||||
(not (nil? (-> act :signature :key_id)))
|
||||
(not (nil? (-> act :signature :algorithm)))
|
||||
(not (nil? (-> act :signature :value))))))
|
||||
@@ -1,13 +0,0 @@
|
||||
;; next/genesis/validators/signature.sx
|
||||
;;
|
||||
;; Stage 2 of the validation pipeline per design §14. Verifies the
|
||||
;; activity signature against the time-relevant public key in the
|
||||
;; actor-state projection. Bootstrap entry; the kernel dispatches
|
||||
;; to envelope:verify_signature/2 (Step 2c) when running in
|
||||
;; Erlang-on-SX mode. Per design §9.6 the lookup is timestamp-aware
|
||||
;; — key validity is evaluated at :published, not "now".
|
||||
|
||||
(DefineValidator
|
||||
:name "signature"
|
||||
:doc "Signature verification. Picks the signature suite by\n :signature :algorithm, fetches the key with id ==\n :signature :key_id that was active at :published from\n the actor-state projection, then dispatches to the\n suite's :verify body."
|
||||
:predicate (fn (act) true))
|
||||
@@ -1,21 +0,0 @@
|
||||
;; next/genesis/validators/type-schema.sx
|
||||
;;
|
||||
;; Stage 5 of the validation pipeline per design §14. Validates
|
||||
;; the activity's :object against the schema registered for its
|
||||
;; :object :type in the define-registry projection.
|
||||
|
||||
(DefineValidator
|
||||
:name "type-schema"
|
||||
:doc "Looks up the object-type registration in the\n define-registry projection, fetches its :schema body,\n and evaluates it against (-> act :object). Returns true\n when no object-type is named (some verbs carry no\n :object) or when no schema is registered for the named\n type (open-world default — Step 6 may tighten)."
|
||||
:predicate (fn
|
||||
(act)
|
||||
(let
|
||||
((obj (-> act :object)))
|
||||
(cond
|
||||
(nil? obj)
|
||||
true
|
||||
(nil? (-> obj :type))
|
||||
true
|
||||
:else (let
|
||||
((schema (-> (registry-lookup :object-types (-> obj :type)) :schema)))
|
||||
(if (nil? schema) true (apply-schema schema obj)))))))
|
||||
@@ -1,260 +0,0 @@
|
||||
-module(actor_state).
|
||||
-export([fold/2, fold_fn/0, new/0, lookup/2, has/2, actors/1,
|
||||
profile_type/1, profile_name/1, profile_field/2,
|
||||
key_history/1, active_keys_at/2, find_key_by_id/2]).
|
||||
|
||||
%% Actor-state projection fold — Erlang-fun stand-in for the
|
||||
%% genesis `actor-state.sx` projection body. Tracks per-actor
|
||||
%% profiles, key-history, and Move pointers per design §9.1-§9.4.
|
||||
%%
|
||||
%% State shape:
|
||||
%% [{ActorId, Profile}, ...]
|
||||
%%
|
||||
%% Profile = [{type, person|service|group},
|
||||
%% {name, Bin},
|
||||
%% {preferredUsername, Bin},
|
||||
%% {summary, Bin},
|
||||
%% {icon, Bin},
|
||||
%% {public_keys, [Key]},
|
||||
%% {moved_to, ActorIdOrUrl},
|
||||
%% {created, N}]
|
||||
%%
|
||||
%% Bridge note: the SX-source eval bridge would replace this fold
|
||||
%% body once available (same gap as Step 5d-pure / Step 6c-schema-pure).
|
||||
%% define_registry.erl is the structural twin.
|
||||
%%
|
||||
%% lists:keyfind/keymember aren't in this substrate (Step 1a noted
|
||||
%% same gap), so local `find_keyed`/`has_keyed`/`set_keyed` helpers
|
||||
%% handle the keyed-list ops.
|
||||
|
||||
new() -> [].
|
||||
|
||||
actors(State) -> [Id || {Id, _Profile} <- State].
|
||||
|
||||
has(ActorId, State) -> has_keyed(ActorId, State).
|
||||
|
||||
lookup(ActorId, State) ->
|
||||
case find_keyed(ActorId, State) of
|
||||
{ok, Profile} -> {ok, Profile};
|
||||
{error, _} -> not_found
|
||||
end.
|
||||
|
||||
%% ── Fold dispatch ───────────────────────────────────────────────
|
||||
|
||||
fold(Activity, State) ->
|
||||
case envelope:get_field(type, Activity) of
|
||||
{ok, create} -> fold_create(Activity, State);
|
||||
{ok, update} -> fold_update(Activity, State);
|
||||
{ok, move} -> fold_move(Activity, State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_create(Activity, State) ->
|
||||
case envelope:get_field(object, Activity) of
|
||||
{ok, Obj} ->
|
||||
case envelope:get_field(type, Obj) of
|
||||
{ok, ObjType} ->
|
||||
case is_actor_type(ObjType) of
|
||||
true -> register_actor(Activity, Obj, ObjType, State);
|
||||
false -> State
|
||||
end;
|
||||
_ -> State
|
||||
end;
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
register_actor(Activity, Obj, ObjType, State) ->
|
||||
case envelope:get_field(actor, Activity) of
|
||||
{ok, ActorId} ->
|
||||
case has_keyed(ActorId, State) of
|
||||
true ->
|
||||
State;
|
||||
false ->
|
||||
Created = published_seq(Activity),
|
||||
Profile = build_profile(ObjType, Obj, Created),
|
||||
State ++ [{ActorId, Profile}]
|
||||
end;
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_update(Activity, State) ->
|
||||
case envelope:get_field(actor, Activity) of
|
||||
{ok, ActorId} ->
|
||||
case find_keyed(ActorId, State) of
|
||||
{ok, Profile} ->
|
||||
case envelope:get_field(patch, Activity) of
|
||||
{ok, Patch} ->
|
||||
Published = published_seq(Activity),
|
||||
NewProfile = apply_patch(Profile, Patch, Published),
|
||||
set_keyed(ActorId, NewProfile, State);
|
||||
_ -> State
|
||||
end;
|
||||
_ -> State
|
||||
end;
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_move(Activity, State) ->
|
||||
case envelope:get_field(actor, Activity) of
|
||||
{ok, ActorId} ->
|
||||
case find_keyed(ActorId, State) of
|
||||
{ok, Profile} ->
|
||||
case envelope:get_field(moved_to, Activity) of
|
||||
{ok, Target} ->
|
||||
NewProfile = set_keyed(moved_to, Target, Profile),
|
||||
set_keyed(ActorId, NewProfile, State);
|
||||
_ -> State
|
||||
end;
|
||||
_ -> State
|
||||
end;
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
%% ── Profile assembly ────────────────────────────────────────────
|
||||
|
||||
build_profile(ObjType, Obj, Created) ->
|
||||
Base = [{type, ObjType}, {created, Created}],
|
||||
Fields = [name, preferredUsername, summary, icon, public_keys],
|
||||
Base ++ collect_fields(Fields, Obj).
|
||||
|
||||
collect_fields([], _) -> [];
|
||||
collect_fields([F | Rest], Obj) ->
|
||||
case envelope:get_field(F, Obj) of
|
||||
{ok, V} -> [{F, V} | collect_fields(Rest, Obj)];
|
||||
_ -> collect_fields(Rest, Obj)
|
||||
end.
|
||||
|
||||
merge_patch(Profile, []) -> Profile;
|
||||
merge_patch(Profile, [{K, V} | Rest]) ->
|
||||
merge_patch(set_keyed(K, V, Profile), Rest);
|
||||
merge_patch(Profile, _) -> Profile.
|
||||
|
||||
%% apply_patch/3 — same as merge_patch but special-cases two
|
||||
%% key-rotation patch entries per design §9.6:
|
||||
%% {add_publicKey, KeyProplist} — append a new key to :public_keys,
|
||||
%% defaulting :created to Published.
|
||||
%% {supersede, OldKeyId} — mark the key with :id =:= OldKeyId
|
||||
%% as :superseded_at = Published.
|
||||
%% Other patch entries fall through to last-write-wins per key.
|
||||
|
||||
apply_patch(Profile, [], _Published) -> Profile;
|
||||
apply_patch(Profile, [{add_publicKey, NewKey} | Rest], Published) ->
|
||||
Augmented = ensure_created(NewKey, Published),
|
||||
Current = current_public_keys(Profile),
|
||||
NewKeys = Current ++ [Augmented],
|
||||
apply_patch(set_keyed(public_keys, NewKeys, Profile), Rest, Published);
|
||||
apply_patch(Profile, [{supersede, OldKeyId} | Rest], Published) ->
|
||||
Current = current_public_keys(Profile),
|
||||
NewKeys = mark_superseded(OldKeyId, Published, Current),
|
||||
apply_patch(set_keyed(public_keys, NewKeys, Profile), Rest, Published);
|
||||
apply_patch(Profile, [{K, V} | Rest], Published) ->
|
||||
apply_patch(set_keyed(K, V, Profile), Rest, Published);
|
||||
apply_patch(Profile, _, _) -> Profile.
|
||||
|
||||
current_public_keys(Profile) ->
|
||||
case find_keyed(public_keys, Profile) of
|
||||
{ok, Keys} -> Keys;
|
||||
_ -> []
|
||||
end.
|
||||
|
||||
ensure_created(Key, Published) ->
|
||||
case find_keyed(created, Key) of
|
||||
{ok, _} -> Key;
|
||||
_ -> set_keyed(created, Published, Key)
|
||||
end.
|
||||
|
||||
mark_superseded(_, _, []) -> [];
|
||||
mark_superseded(OldId, At, [Key | Rest]) ->
|
||||
case find_keyed(id, Key) of
|
||||
{ok, OldId} ->
|
||||
case find_keyed(superseded_at, Key) of
|
||||
{ok, _} -> [Key | mark_superseded(OldId, At, Rest)];
|
||||
_ -> [set_keyed(superseded_at, At, Key) | mark_superseded(OldId, At, Rest)]
|
||||
end;
|
||||
_ -> [Key | mark_superseded(OldId, At, Rest)]
|
||||
end.
|
||||
|
||||
%% Key-history view — full :public_keys list including superseded
|
||||
%% entries (per §9.6: history is preserved so historical activities
|
||||
%% verify against keys that were active at their :published time).
|
||||
|
||||
key_history(Profile) ->
|
||||
current_public_keys(Profile).
|
||||
|
||||
%% active_keys_at/2 — the subset of :public_keys active at Now,
|
||||
%% mirroring envelope's is_active_at semantics (local copy: envelope
|
||||
%% keeps the predicate private).
|
||||
|
||||
active_keys_at(Profile, Now) ->
|
||||
[K || K <- current_public_keys(Profile),
|
||||
key_active_at(K, Now)].
|
||||
|
||||
find_key_by_id(KeyId, Profile) ->
|
||||
find_key_by_id_in(KeyId, current_public_keys(Profile)).
|
||||
|
||||
find_key_by_id_in(_, []) -> not_found;
|
||||
find_key_by_id_in(WantId, [K | Rest]) ->
|
||||
case find_keyed(id, K) of
|
||||
{ok, WantId} -> {ok, K};
|
||||
_ -> find_key_by_id_in(WantId, Rest)
|
||||
end.
|
||||
|
||||
key_active_at(Key, Now) ->
|
||||
case find_keyed(created, Key) of
|
||||
{ok, Created} when Now >= Created ->
|
||||
case find_keyed(superseded_at, Key) of
|
||||
{ok, SupAt} -> Now < SupAt;
|
||||
_ -> true
|
||||
end;
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
published_seq(Activity) ->
|
||||
case envelope:get_field(published, Activity) of
|
||||
{ok, P} -> P;
|
||||
_ -> 0
|
||||
end.
|
||||
|
||||
is_actor_type(person) -> true;
|
||||
is_actor_type(service) -> true;
|
||||
is_actor_type(group) -> true;
|
||||
is_actor_type(_) -> false.
|
||||
|
||||
%% ── Profile accessors ───────────────────────────────────────────
|
||||
|
||||
profile_type(Profile) ->
|
||||
case find_keyed(type, Profile) of
|
||||
{ok, T} -> T;
|
||||
_ -> nil
|
||||
end.
|
||||
|
||||
profile_name(Profile) ->
|
||||
case find_keyed(name, Profile) of
|
||||
{ok, N} -> N;
|
||||
_ -> nil
|
||||
end.
|
||||
|
||||
profile_field(F, Profile) ->
|
||||
case find_keyed(F, Profile) of
|
||||
{ok, V} -> {ok, V};
|
||||
_ -> not_found
|
||||
end.
|
||||
|
||||
%% ── Projection integration ──────────────────────────────────────
|
||||
|
||||
fold_fn() ->
|
||||
fun (Activity, State) -> fold(Activity, State) end.
|
||||
|
||||
%% ── Internal ────────────────────────────────────────────────────
|
||||
|
||||
has_keyed(_, []) -> false;
|
||||
has_keyed(K, [{K, _} | _]) -> true;
|
||||
has_keyed(K, [_ | Rest]) -> has_keyed(K, Rest).
|
||||
|
||||
find_keyed(_, []) -> {error, not_found};
|
||||
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||
|
||||
set_keyed(K, V, []) -> [{K, V}];
|
||||
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)].
|
||||
@@ -1,79 +0,0 @@
|
||||
-module(announce_state).
|
||||
-export([new/0, fold/2, fold_fn/0,
|
||||
announcers_for/2, announce_count/2, announced_cids/1,
|
||||
has_announced/3]).
|
||||
|
||||
%% Announce-fanout projection. Folds Announce activities into a
|
||||
%% per-target-Cid set of announcer ActorIds so projections can
|
||||
%% answer "who re-broadcast this activity" / "how many announces
|
||||
%% does this Note have" / "what activities has X announced".
|
||||
%%
|
||||
%% Announce envelope shape (per next/genesis/activity-types/announce.sx):
|
||||
%% [{type, announce},
|
||||
%% {actor, AnnouncerActorId},
|
||||
%% {object, TargetCidBinary},
|
||||
%% ...]
|
||||
%%
|
||||
%% State shape:
|
||||
%% [{TargetCid, [Announcer1, Announcer2, ...]}, ...]
|
||||
%%
|
||||
%% Set semantics — the same actor announcing the same target twice
|
||||
%% is a no-op (already in the list). Undo{Announce} retraction
|
||||
%% defers to a follow-up.
|
||||
|
||||
new() -> [].
|
||||
|
||||
fold_fn() ->
|
||||
fun (Activity, State) -> fold(Activity, State) end.
|
||||
|
||||
fold(Activity, State) ->
|
||||
case envelope:get_field(type, Activity) of
|
||||
{ok, announce} -> fold_announce(Activity, State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_announce(Activity, State) ->
|
||||
case {envelope:get_field(actor, Activity),
|
||||
envelope:get_field(object, Activity)} of
|
||||
{{ok, Actor}, {ok, Cid}} -> add_announcer(Cid, Actor, State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
add_announcer(Cid, Actor, State) ->
|
||||
Current = case find_keyed(Cid, State) of
|
||||
{ok, Set} -> Set;
|
||||
_ -> []
|
||||
end,
|
||||
case contains(Actor, Current) of
|
||||
true -> State;
|
||||
false -> set_keyed(Cid, Current ++ [Actor], State)
|
||||
end.
|
||||
|
||||
%% ── Read-side accessors ───────────────────────────────────────
|
||||
|
||||
announcers_for(Cid, State) ->
|
||||
case find_keyed(Cid, State) of
|
||||
{ok, Set} -> Set;
|
||||
_ -> []
|
||||
end.
|
||||
|
||||
announce_count(Cid, State) -> length(announcers_for(Cid, State)).
|
||||
|
||||
announced_cids(State) -> [C || {C, _} <- State].
|
||||
|
||||
has_announced(Actor, Cid, State) ->
|
||||
contains(Actor, announcers_for(Cid, State)).
|
||||
|
||||
%% ── Internal ──────────────────────────────────────────────────
|
||||
|
||||
contains(_, []) -> false;
|
||||
contains(X, [X | _]) -> true;
|
||||
contains(X, [_ | Rest]) -> contains(X, Rest).
|
||||
|
||||
find_keyed(_, []) -> {error, not_found};
|
||||
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||
|
||||
set_keyed(K, V, []) -> [{K, V}];
|
||||
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)].
|
||||
@@ -1,136 +0,0 @@
|
||||
-module(backfill).
|
||||
-export([slice/2, slice/3,
|
||||
wrap_backfill/1, parse_mode/1,
|
||||
all_entries/1, last_n_entries/2, last_t_entries/3,
|
||||
since_cid_entries/2, none_entries/0]).
|
||||
|
||||
%% Backfill mode slicing per design §13.3 / Step 9. When A follows B
|
||||
%% with a backfill spec, B's kernel slices the outbox log into the
|
||||
%% appropriate window and delivers each entry as
|
||||
%% `{backfilled, true}`-marked envelopes alongside forward-going
|
||||
%% activity.
|
||||
%%
|
||||
%% Mode shapes (per the Follow activity's `:backfill` field):
|
||||
%% none — newer follower sees only forward content
|
||||
%% {last_n, N} — backfill last N activities (FIFO order)
|
||||
%% {last_t, T, NowFn} — backfill activities with :published in
|
||||
%% (Now - T .. Now]. NowFn is a 0-arity fun
|
||||
%% so tests can fake-time it.
|
||||
%% full — backfill the entire outbox
|
||||
%%
|
||||
%% slice/2 returns the activity list. slice/3 also wraps each entry
|
||||
%% with `{backfilled, true}` so projections can decide whether to
|
||||
%% re-fold or skip (the §13.3 Backfilled bodies preserve the
|
||||
%% original `:id` so replay defence still works on the receiver).
|
||||
%%
|
||||
%% parse_mode/1 lifts the Follow activity's `:backfill` proplist
|
||||
%% (or atom) into the internal mode tuple. Unknown shapes fall back
|
||||
%% to `none` — the default open-world policy.
|
||||
|
||||
slice(Mode, LogState) ->
|
||||
slice(Mode, LogState, false).
|
||||
|
||||
slice(Mode, LogState, Wrap) ->
|
||||
Entries = log:entries(LogState),
|
||||
Slice = case Mode of
|
||||
none -> none_entries();
|
||||
full -> all_entries(Entries);
|
||||
{last_n, N} -> last_n_entries(N, Entries);
|
||||
{last_t, T, NowFn} -> last_t_entries(T, NowFn, Entries);
|
||||
{since_cid, Cid} -> since_cid_entries(Cid, Entries);
|
||||
_ -> none_entries()
|
||||
end,
|
||||
case Wrap of
|
||||
true -> wrap_backfill(Slice);
|
||||
_ -> Slice
|
||||
end.
|
||||
|
||||
%% ── Mode-specific entry selection ─────────────────────────────
|
||||
|
||||
all_entries(Entries) -> Entries.
|
||||
|
||||
none_entries() -> [].
|
||||
|
||||
%% last_n_entries/2 — tail N entries in FIFO order.
|
||||
|
||||
last_n_entries(N, _) when N =< 0 -> [];
|
||||
last_n_entries(N, Entries) ->
|
||||
Len = length(Entries),
|
||||
case Len =< N of
|
||||
true -> Entries;
|
||||
false -> drop_n(Len - N, Entries)
|
||||
end.
|
||||
|
||||
drop_n(0, L) -> L;
|
||||
drop_n(_, []) -> [];
|
||||
drop_n(N, [_ | Rest]) -> drop_n(N - 1, Rest).
|
||||
|
||||
%% last_t_entries/3 — entries whose :published is within the last
|
||||
%% T units of (NowFn() - T .. NowFn()]. T and :published are
|
||||
%% integers (seconds-since-epoch in production; opaque ints in tests).
|
||||
|
||||
last_t_entries(T, NowFn, Entries) when is_integer(T), T >= 0 ->
|
||||
Now = NowFn(),
|
||||
Cutoff = Now - T,
|
||||
[E || E <- Entries, in_window(E, Cutoff, Now)];
|
||||
last_t_entries(_, _, _) -> [].
|
||||
|
||||
in_window(Activity, Cutoff, Now) ->
|
||||
case envelope:get_field(published, Activity) of
|
||||
{ok, P} when is_integer(P), P > Cutoff, P =< Now -> true;
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
%% since_cid_entries/2 — every entry after the one with :id = Cid.
|
||||
%% If Cid isn't in the log, returns [] (caller's pointer is stale).
|
||||
%% Used by `GET /actors/<id>/outbox?since=Cid` pagination.
|
||||
|
||||
since_cid_entries(_Cid, []) -> [];
|
||||
since_cid_entries(Cid, [E | Rest]) ->
|
||||
case envelope:get_field(id, E) of
|
||||
{ok, Cid} -> Rest;
|
||||
_ -> since_cid_entries(Cid, Rest)
|
||||
end.
|
||||
|
||||
%% wrap_backfill/1 — append `{backfilled, true}` to each entry.
|
||||
%% The receiving projection scheduler reads this field and chooses
|
||||
%% whether to fold (re-emit) or skip (already known via replay
|
||||
%% defence on `:id`).
|
||||
|
||||
wrap_backfill([]) -> [];
|
||||
wrap_backfill([E | Rest]) ->
|
||||
[E ++ [{backfilled, true}] | wrap_backfill(Rest)].
|
||||
|
||||
%% parse_mode/1 — Lift a Follow activity's `:backfill` value into the
|
||||
%% internal mode tuple. Accepts:
|
||||
%% nil / not_found -> none
|
||||
%% none -> none
|
||||
%% full -> full
|
||||
%% {last_n, N} -> {last_n, N} (already-parsed shape)
|
||||
%% {last_t, T, NowFn} -> pass-through
|
||||
%% Proplist with :mode + :limit / :duration -> parsed
|
||||
%% Unknown shape -> none (open-world default).
|
||||
|
||||
parse_mode(nil) -> none;
|
||||
parse_mode(none) -> none;
|
||||
parse_mode(full) -> full;
|
||||
parse_mode({last_n, N}) -> {last_n, N};
|
||||
parse_mode({last_t, T, NowFn}) -> {last_t, T, NowFn};
|
||||
parse_mode({since_cid, Cid}) -> {since_cid, Cid};
|
||||
parse_mode(List) when is_list(List) ->
|
||||
case envelope:get_field(mode, List) of
|
||||
{ok, last_n} ->
|
||||
case envelope:get_field(limit, List) of
|
||||
{ok, N} when is_integer(N) -> {last_n, N};
|
||||
_ -> none
|
||||
end;
|
||||
{ok, last_t} ->
|
||||
case envelope:get_field(duration, List) of
|
||||
{ok, T} when is_integer(T) -> {last_t, T, fun () -> 0 end};
|
||||
_ -> none
|
||||
end;
|
||||
{ok, full} -> full;
|
||||
{ok, none} -> none;
|
||||
_ -> none
|
||||
end;
|
||||
parse_mode(_) -> none.
|
||||
@@ -1,223 +0,0 @@
|
||||
-module(bootstrap).
|
||||
-export([read_genesis/0, read_genesis/1,
|
||||
read_section/2, sections/0, section_subdir/1,
|
||||
default_base/0, ends_with_sx/1,
|
||||
build_genesis/1, verify_genesis/2,
|
||||
cidhash_path/1, write_cidhash/2, read_cidhash/1,
|
||||
load_genesis/1, strip_sx_suffix/1,
|
||||
populate_registry/0,
|
||||
start/3]).
|
||||
|
||||
%% Genesis bundle reader per design §12.2.
|
||||
%%
|
||||
%% read_genesis/0,1 walks the seven canonical section subdirectories
|
||||
%% under `next/genesis/`, filters .sx files, reads each file into a
|
||||
%% binary, and returns a structured snapshot:
|
||||
%%
|
||||
%% {ok, [{Section :: atom,
|
||||
%% [{FileName :: binary, FileBytes :: binary}, ...]},
|
||||
%% ...]}
|
||||
%%
|
||||
%% Step 4d will compute the bundle CID by hashing the assembled
|
||||
%% byte string across all entries; Step 4e will register the parsed
|
||||
%% definitions in the kernel registry.
|
||||
%%
|
||||
%% Port note: this module does NOT parse the .sx contents. The
|
||||
%% Erlang-on-SX port has no in-Erlang path from binary bytes to SX
|
||||
%% structured terms (same substrate gap that parked Step 3b); the
|
||||
%% bundle CID needs only the raw bytes, and registry registration
|
||||
%% will happen via an SX-side helper that the kernel hands the
|
||||
%% binary contents to. read_genesis/1 ignores its arg in v1 except
|
||||
%% to swap the BasePath — `default_base/0` is "next/genesis".
|
||||
%%
|
||||
%% Port note 2: string-literal binary segments `<<"abc">>` truncate
|
||||
%% to one byte in this port, so all path constants are hand-spelled
|
||||
%% as integer-segment binaries.
|
||||
|
||||
%% ── Public API ──────────────────────────────────────────────────
|
||||
|
||||
%% "next/genesis"
|
||||
default_base() ->
|
||||
<<110,101,120,116,47,103,101,110,101,115,105,115>>.
|
||||
|
||||
read_genesis() ->
|
||||
read_genesis(default_base()).
|
||||
|
||||
read_genesis(BasePath) ->
|
||||
{ok, lists:map(
|
||||
fun (S) -> {S, read_section(BasePath, S)} end,
|
||||
sections())}.
|
||||
|
||||
sections() ->
|
||||
[activity_types, object_types, projections,
|
||||
validators, codecs, sig_suites, audience].
|
||||
|
||||
%% "activity-types"
|
||||
section_subdir(activity_types) ->
|
||||
<<97,99,116,105,118,105,116,121,45,116,121,112,101,115>>;
|
||||
%% "object-types"
|
||||
section_subdir(object_types) ->
|
||||
<<111,98,106,101,99,116,45,116,121,112,101,115>>;
|
||||
%% "projections"
|
||||
section_subdir(projections) ->
|
||||
<<112,114,111,106,101,99,116,105,111,110,115>>;
|
||||
%% "validators"
|
||||
section_subdir(validators) ->
|
||||
<<118,97,108,105,100,97,116,111,114,115>>;
|
||||
%% "codecs"
|
||||
section_subdir(codecs) ->
|
||||
<<99,111,100,101,99,115>>;
|
||||
%% "sig-suites"
|
||||
section_subdir(sig_suites) ->
|
||||
<<115,105,103,45,115,117,105,116,101,115>>;
|
||||
%% "audience"
|
||||
section_subdir(audience) ->
|
||||
<<97,117,100,105,101,110,99,101>>.
|
||||
|
||||
read_section(BasePath, Section) ->
|
||||
SubDir = section_subdir(Section),
|
||||
%% 47 = '/'
|
||||
Path = <<BasePath/binary, 47, SubDir/binary>>,
|
||||
case file:list_dir(Path) of
|
||||
{ok, Names} ->
|
||||
SxNames = lists:filter(fun (N) -> ends_with_sx(N) end, Names),
|
||||
lists:map(fun (Name) -> read_one(Path, Name) end, SxNames);
|
||||
{error, _} ->
|
||||
[]
|
||||
end.
|
||||
|
||||
%% Suffix check on the .sx extension. 46='.' 115='s' 120='x'.
|
||||
ends_with_sx(<<46, 115, 120>>) -> true;
|
||||
ends_with_sx(<<>>) -> false;
|
||||
ends_with_sx(<<_, Rest/binary>>) -> ends_with_sx(Rest).
|
||||
|
||||
%% ── Internal ────────────────────────────────────────────────────
|
||||
|
||||
read_one(DirPath, Name) ->
|
||||
Full = <<DirPath/binary, 47, Name/binary>>,
|
||||
case file:read_file(Full) of
|
||||
{ok, Bytes} -> {Name, Bytes};
|
||||
{error, R} -> {Name, {error, R}}
|
||||
end.
|
||||
|
||||
%% ── Step 4d: bundle CID compute + verify ────────────────────────
|
||||
%%
|
||||
%% The bundle CID is the canonical content-address of everything in
|
||||
%% read_genesis/0's result. We delegate to the host `cid:to_string/1`
|
||||
%% BIF (Step 1b substrate): it walks the term via `er-format-value`,
|
||||
%% feeds the deterministic textual form into `cid-from-sx`, returns
|
||||
%% a CIDv1 (raw codec, sha2-256 multihash) as a binary.
|
||||
%%
|
||||
%% Design §12.3: at startup the kernel computes this CID and
|
||||
%% compares against a hardcoded value (here: a sibling `.cidhash`
|
||||
%% file). A mismatch is a hard refuse-to-start.
|
||||
|
||||
build_genesis(ReadResult) ->
|
||||
case ReadResult of
|
||||
{ok, Sections} ->
|
||||
Cid = cid:to_string({genesis_bundle, Sections}),
|
||||
{ok, [{cid, Cid}, {sections, Sections}]};
|
||||
Other ->
|
||||
{error, {bad_read_result, Other}}
|
||||
end.
|
||||
|
||||
verify_genesis(ReadResult, ExpectedCid) ->
|
||||
case build_genesis(ReadResult) of
|
||||
{ok, [{cid, Cid}, _]} ->
|
||||
case Cid =:= ExpectedCid of
|
||||
true -> ok;
|
||||
false -> {error, {cid_mismatch, Cid, ExpectedCid}}
|
||||
end;
|
||||
Err -> Err
|
||||
end.
|
||||
|
||||
%% Sibling-file CID storage. "/.cidhash" appended to BasePath as
|
||||
%% an integer-segment binary (string-literal segments are broken).
|
||||
|
||||
%% "/.cidhash" — 47='/' 46='.' c i d h a s h
|
||||
cidhash_path(BasePath) ->
|
||||
<<BasePath/binary, 47, 46, 99, 105, 100, 104, 97, 115, 104>>.
|
||||
|
||||
write_cidhash(BasePath, Cid) ->
|
||||
file:write_file(cidhash_path(BasePath), Cid).
|
||||
|
||||
read_cidhash(BasePath) ->
|
||||
file:read_file(cidhash_path(BasePath)).
|
||||
|
||||
%% ── Step 4e: load_genesis → registry ────────────────────────────
|
||||
%%
|
||||
%% Walks the read_genesis result and registers each file as a
|
||||
%% registry entry. The section atom is the registry kind directly
|
||||
%% (both name spaces are identical — see Step 4c sections/0 and
|
||||
%% Step 5a registry:kinds/0). The entry Name is the filename minus
|
||||
%% the `.sx` suffix, kept as a binary; the entry value is the
|
||||
%% file's raw bytes.
|
||||
%%
|
||||
%% Returns `{ok, RegistryState}` on success. Later steps (4f / the
|
||||
%% SX-parser bridge) will replace the raw bytes with parsed forms;
|
||||
%% the binary stand-in is enough to prove the bridge works.
|
||||
|
||||
load_genesis(ReadResult) ->
|
||||
case ReadResult of
|
||||
{ok, Sections} ->
|
||||
{ok, load_sections(Sections, registry:new())};
|
||||
Other ->
|
||||
{error, {bad_read_result, Other}}
|
||||
end.
|
||||
|
||||
load_sections([], State) -> State;
|
||||
load_sections([{Kind, Entries} | Rest], State) ->
|
||||
load_sections(Rest, load_entries(Kind, Entries, State)).
|
||||
|
||||
load_entries(_Kind, [], State) -> State;
|
||||
load_entries(Kind, [{Name, Bytes} | Rest], State) ->
|
||||
BaseName = strip_sx_suffix(Name),
|
||||
{ok, NewState} = registry:register(Kind, BaseName, Bytes, State),
|
||||
load_entries(Kind, Rest, NewState).
|
||||
|
||||
%% strip_sx_suffix(Binary) — drops the trailing ".sx" if present.
|
||||
%% 46='.' 115='s' 120='x'.
|
||||
strip_sx_suffix(B) when is_binary(B) ->
|
||||
case ends_with_sx(B) of
|
||||
false -> B;
|
||||
true -> take_prefix(B, byte_size(B) - 3)
|
||||
end.
|
||||
|
||||
take_prefix(_, 0) -> <<>>;
|
||||
take_prefix(<<H, Rest/binary>>, N) when N > 0 ->
|
||||
Tail = take_prefix(Rest, N - 1),
|
||||
<<H, Tail/binary>>.
|
||||
|
||||
%% populate_registry/0 — load the canonical genesis bundle and
|
||||
%% register every entry in the running registry gen_server. The
|
||||
%% caller is expected to have started the registry (via
|
||||
%% registry:start_link/0) before calling this. Returns the count
|
||||
%% of entries registered across all kinds.
|
||||
populate_registry() ->
|
||||
{ok, Sections} = read_genesis(),
|
||||
populate_sections(Sections, 0).
|
||||
|
||||
populate_sections([], Count) -> Count;
|
||||
populate_sections([{Kind, Entries} | Rest], Count) ->
|
||||
populate_sections(Rest, Count + populate_entries(Kind, Entries, 0)).
|
||||
|
||||
populate_entries(_, [], Count) -> Count;
|
||||
populate_entries(Kind, [{Name, Bytes} | Rest], Count) ->
|
||||
BaseName = strip_sx_suffix(Name),
|
||||
ok = registry:register(Kind, BaseName, Bytes),
|
||||
populate_entries(Kind, Rest, Count + 1).
|
||||
|
||||
%% start/3 — one-call bring-up of the kernel substrate. Starts
|
||||
%% the registry gen_server, populates it from the canonical
|
||||
%% genesis bundle, then starts the nx_kernel gen_server with the
|
||||
%% supplied actor identity / key / state. Returns the nx_kernel
|
||||
%% Pid (gen_server start_link convention in this port returns the
|
||||
%% raw Pid, not {ok, Pid}).
|
||||
%%
|
||||
%% Tests + production bring-up share this entry point. The
|
||||
%% caller is still responsible for starting any application-level
|
||||
%% projections and wiring them via nx_kernel:with_projections/1.
|
||||
start(ActorId, KeySpec, ActorState) ->
|
||||
registry:start_link(),
|
||||
populate_registry(),
|
||||
nx_kernel:start_link(ActorId, KeySpec, ActorState).
|
||||
@@ -1,68 +0,0 @@
|
||||
-module(define_registry).
|
||||
-export([fold/2, fold_fn/0, define_kind/1]).
|
||||
|
||||
%% Define-registry projection fold — Erlang-fun stand-in for the
|
||||
%% genesis `define-registry.sx` body. The intent is identical: a
|
||||
%% projection whose state is a registry-shaped property list, fed
|
||||
%% by every `Create{Define*{...}}` activity. The SX body would
|
||||
%% eventually replace this once an SX-source eval bridge lets the
|
||||
%% kernel evaluate the genesis fold directly; until then this
|
||||
%% Erlang module proves the meta-projection mechanism wires
|
||||
%% through `projection:fold_fn` and `nx_kernel` cleanly.
|
||||
%%
|
||||
%% State shape mirrors `registry:new()` exactly:
|
||||
%% [{Kind, [{Name, Entry}, ...]}, ...]
|
||||
%% so callers can use `registry:lookup/3` etc. on the result.
|
||||
%%
|
||||
%% Type discrimination uses atoms (`define_activity`, …). Real SX
|
||||
%% would carry the string forms ("DefineActivity", …); the bridge
|
||||
%% will translate. See define_kind/1 for the mapping.
|
||||
|
||||
fold(Activity, State) ->
|
||||
case envelope:get_field(type, Activity) of
|
||||
{ok, create} -> fold_create(Activity, State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_create(Activity, State) ->
|
||||
case envelope:get_field(object, Activity) of
|
||||
{ok, Obj} ->
|
||||
case envelope:get_field(type, Obj) of
|
||||
{ok, ObjType} ->
|
||||
case define_kind(ObjType) of
|
||||
not_a_define -> State;
|
||||
Kind -> fold_register(Kind, Obj, State)
|
||||
end;
|
||||
_ -> State
|
||||
end;
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_register(Kind, Obj, State) ->
|
||||
case envelope:get_field(name, Obj) of
|
||||
{ok, Name} ->
|
||||
case registry:register(Kind, Name, Obj, State) of
|
||||
{ok, NewState} -> NewState;
|
||||
{error, unknown_kind} -> State
|
||||
end;
|
||||
not_found -> State
|
||||
end.
|
||||
|
||||
%% fold_fn/0 — a 2-arity Erlang fun the projection module plants
|
||||
%% in its record's :fold slot. Lets `projection:start_link/3`
|
||||
%% wire define-registry directly.
|
||||
fold_fn() ->
|
||||
fun (Activity, State) -> fold(Activity, State) end.
|
||||
|
||||
%% define_kind/1 — discriminator from the inner Define* object's
|
||||
%% :type atom to the registry kind atom. Anything unrecognised
|
||||
%% returns not_a_define so the fold treats it as a pass-through.
|
||||
|
||||
define_kind(define_activity) -> activity_types;
|
||||
define_kind(define_object) -> object_types;
|
||||
define_kind(define_projection) -> projections;
|
||||
define_kind(define_validator) -> validators;
|
||||
define_kind(define_codec) -> codecs;
|
||||
define_kind(define_sig_suite) -> sig_suites;
|
||||
define_kind(define_audience) -> audience;
|
||||
define_kind(_) -> not_a_define.
|
||||
@@ -1,86 +0,0 @@
|
||||
-module(delivery).
|
||||
-export([delivery_set/2, delivery_set/3,
|
||||
collect_recipients/1, suppress_self/2, dedup/1,
|
||||
expand_audience/3]).
|
||||
|
||||
%% Audience-resolving delivery set computation per design §13.4.
|
||||
%%
|
||||
%% delivery_set/2(Activity, KernelState) returns a sorted, deduped
|
||||
%% list of ActorId atoms — every actor the outgoing Activity needs
|
||||
%% to be POSTed to. Sources:
|
||||
%% - Activity's `:to` field (single ActorId or list)
|
||||
%% - Activity's `:cc` field (single ActorId or list)
|
||||
%% - audience-symbol expansion of `public` and `followers`
|
||||
%%
|
||||
%% Self-delivery (the publishing actor reading their own activity
|
||||
%% on a peer's behalf) is suppressed.
|
||||
%%
|
||||
%% Output for Step 7a is the bare ActorId list; Step 8 will resolve
|
||||
%% each entry to `{PeerInstanceUrl, ActorId}` via the peer-actors
|
||||
%% cache.
|
||||
|
||||
delivery_set(Activity, KernelState) ->
|
||||
delivery_set(Activity, KernelState, follower_graph:new()).
|
||||
|
||||
delivery_set(Activity, KernelState, FollowerGraph) ->
|
||||
Self = sender(Activity),
|
||||
Raw = collect_recipients(Activity),
|
||||
Expanded = expand_all(Raw, Self, KernelState, FollowerGraph),
|
||||
Suppressed = suppress_self(Expanded, Self),
|
||||
dedup(Suppressed).
|
||||
|
||||
%% collect_recipients/1 — flat list from :to + :cc, normalised so
|
||||
%% each element is either an ActorId atom or an audience symbol
|
||||
%% (`public` / `followers`).
|
||||
|
||||
collect_recipients(Activity) ->
|
||||
To = envelope_field_list(to, Activity),
|
||||
Cc = envelope_field_list(cc, Activity),
|
||||
To ++ Cc.
|
||||
|
||||
envelope_field_list(Field, Activity) ->
|
||||
case envelope:get_field(Field, Activity) of
|
||||
not_found -> [];
|
||||
{ok, V} when is_list(V) -> V;
|
||||
{ok, V} -> [V]
|
||||
end.
|
||||
|
||||
%% expand_audience/3 — `followers` -> the sender's followers
|
||||
%% proplist entry from a follower_graph state. `public` for v2
|
||||
%% expands to the same list (per design §13.4: practical Public
|
||||
%% fan-out is "every follower of the publishing actor"). The
|
||||
%% explicit shared-inbox peer-instance model defers to v3.
|
||||
%% Other symbols / explicit ActorIds pass through unchanged.
|
||||
|
||||
expand_audience(public, Sender, Graph) ->
|
||||
follower_graph:followers(Sender, Graph);
|
||||
expand_audience(followers, Sender, Graph) ->
|
||||
follower_graph:followers(Sender, Graph);
|
||||
expand_audience(X, _Sender, _Graph) -> [X].
|
||||
|
||||
expand_all([], _Self, _State, _Graph) -> [];
|
||||
expand_all([X | Rest], Self, State, Graph) ->
|
||||
expand_audience(X, Self, Graph) ++ expand_all(Rest, Self, State, Graph).
|
||||
|
||||
suppress_self([], _Self) -> [];
|
||||
suppress_self([Self | Rest], Self) -> suppress_self(Rest, Self);
|
||||
suppress_self([X | Rest], Self) -> [X | suppress_self(Rest, Self)].
|
||||
|
||||
dedup(L) -> dedup_acc(L, []).
|
||||
|
||||
dedup_acc([], Acc) -> Acc;
|
||||
dedup_acc([X | Rest], Acc) ->
|
||||
case contains(X, Acc) of
|
||||
true -> dedup_acc(Rest, Acc);
|
||||
false -> dedup_acc(Rest, Acc ++ [X])
|
||||
end.
|
||||
|
||||
contains(_, []) -> false;
|
||||
contains(X, [X | _]) -> true;
|
||||
contains(X, [_ | Rest]) -> contains(X, Rest).
|
||||
|
||||
sender(Activity) ->
|
||||
case envelope:get_field(actor, Activity) of
|
||||
{ok, A} -> A;
|
||||
_ -> nil
|
||||
end.
|
||||
@@ -1,209 +0,0 @@
|
||||
-module(delivery_state).
|
||||
-export([new/0, fold/2, fold_fn/0,
|
||||
peer_state/2, peers/1,
|
||||
pending/2, attempts/2, next_retry/2, dead_letter/2]).
|
||||
|
||||
%% Delivery-state projection. Folds delivery events (enqueue /
|
||||
%% delivered / failed / dead_lettered) into a per-peer worker-shaped
|
||||
%% snapshot so the outbound queue survives kernel restart. Per design
|
||||
%% §13.4 the worker state on restart is loaded from this projection
|
||||
%% rather than reconstructed by re-driving the outbox log.
|
||||
%%
|
||||
%% Event proplist shape:
|
||||
%% [{type, enqueued}, {peer, _}, {activity, _}]
|
||||
%% [{type, delivered}, {peer, _}, {cid, _}]
|
||||
%% [{type, failed}, {peer, _}, {cid, _}, {now, _}]
|
||||
%% [{type, dead_lettered}, {peer, _}, {cid, _}]
|
||||
%%
|
||||
%% Projection state shape:
|
||||
%% [{PeerId, WorkerProplist}, ...]
|
||||
%%
|
||||
%% WorkerProplist mirrors `delivery_worker:new/1`'s output so a fresh
|
||||
%% gen_server can be hydrated with `delivery_worker:state_from_proj`
|
||||
%% (lands when 8b-timer wires up). For Step 8c the projection only
|
||||
%% tracks data — Step 8d-restart will wire the hydration helper.
|
||||
|
||||
new() -> [].
|
||||
|
||||
fold_fn() ->
|
||||
fun (Event, State) -> fold(Event, State) end.
|
||||
|
||||
fold(Event, State) ->
|
||||
case envelope:get_field(type, Event) of
|
||||
{ok, enqueued} -> fold_enqueued(Event, State);
|
||||
{ok, delivered} -> fold_delivered(Event, State);
|
||||
{ok, failed} -> fold_failed(Event, State);
|
||||
{ok, dead_lettered} -> fold_dead_lettered(Event, State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_enqueued(Event, State) ->
|
||||
case {envelope:get_field(peer, Event),
|
||||
envelope:get_field(activity, Event)} of
|
||||
{{ok, Peer}, {ok, Act}} ->
|
||||
Worker = ensure_peer(Peer, State),
|
||||
Pending = field(pending, Worker),
|
||||
Worker1 = set_field(pending, Pending ++ [Act], Worker),
|
||||
set_peer(Peer, Worker1, State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_delivered(Event, State) ->
|
||||
case {envelope:get_field(peer, Event),
|
||||
envelope:get_field(cid, Event)} of
|
||||
{{ok, Peer}, {ok, Cid}} ->
|
||||
case find_keyed(Peer, State) of
|
||||
{ok, Worker} ->
|
||||
Worker1 = drop_pending_by_cid(Cid, Worker),
|
||||
Worker2 = clear_retry_for(Cid, Worker1),
|
||||
set_peer(Peer, Worker2, State);
|
||||
_ -> State
|
||||
end;
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_failed(Event, State) ->
|
||||
case {envelope:get_field(peer, Event),
|
||||
envelope:get_field(cid, Event),
|
||||
envelope:get_field(now, Event)} of
|
||||
{{ok, Peer}, {ok, Cid}, {ok, Now}} ->
|
||||
case find_keyed(Peer, State) of
|
||||
{ok, Worker} ->
|
||||
Attempts = field(attempts, Worker),
|
||||
Current = case find_keyed(Cid, Attempts) of
|
||||
{ok, N} -> N;
|
||||
_ -> 0
|
||||
end,
|
||||
New = Current + 1,
|
||||
Attempts1 = set_keyed(Cid, New, Attempts),
|
||||
Worker1 = set_field(attempts, Attempts1, Worker),
|
||||
Worker2 = case delivery_worker:backoff_for(New) of
|
||||
dead_letter ->
|
||||
dead_letter_pending(Cid, Worker1);
|
||||
Seconds ->
|
||||
NR = field(next_retry, Worker1),
|
||||
NextAt = Now + Seconds,
|
||||
set_field(next_retry, set_keyed(Cid, NextAt, NR), Worker1)
|
||||
end,
|
||||
set_peer(Peer, Worker2, State);
|
||||
_ -> State
|
||||
end;
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_dead_lettered(Event, State) ->
|
||||
case {envelope:get_field(peer, Event),
|
||||
envelope:get_field(cid, Event)} of
|
||||
{{ok, Peer}, {ok, Cid}} ->
|
||||
case find_keyed(Peer, State) of
|
||||
{ok, Worker} ->
|
||||
set_peer(Peer, dead_letter_pending(Cid, Worker), State);
|
||||
_ -> State
|
||||
end;
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
%% ── Accessors ─────────────────────────────────────────────────
|
||||
|
||||
peer_state(Peer, State) ->
|
||||
case find_keyed(Peer, State) of
|
||||
{ok, Worker} -> {ok, Worker};
|
||||
_ -> not_found
|
||||
end.
|
||||
|
||||
peers(State) -> [P || {P, _} <- State].
|
||||
|
||||
pending(Peer, State) ->
|
||||
worker_field(Peer, pending, State, []).
|
||||
|
||||
attempts(Peer, State) ->
|
||||
worker_field(Peer, attempts, State, []).
|
||||
|
||||
next_retry(Peer, State) ->
|
||||
worker_field(Peer, next_retry, State, []).
|
||||
|
||||
dead_letter(Peer, State) ->
|
||||
worker_field(Peer, dead_letter, State, []).
|
||||
|
||||
%% ── Internal ──────────────────────────────────────────────────
|
||||
|
||||
worker_field(Peer, Field, State, Default) ->
|
||||
case find_keyed(Peer, State) of
|
||||
{ok, Worker} ->
|
||||
case find_keyed(Field, Worker) of
|
||||
{ok, V} -> V;
|
||||
_ -> Default
|
||||
end;
|
||||
_ -> Default
|
||||
end.
|
||||
|
||||
ensure_peer(Peer, State) ->
|
||||
case find_keyed(Peer, State) of
|
||||
{ok, Worker} -> Worker;
|
||||
_ -> empty_worker(Peer)
|
||||
end.
|
||||
|
||||
empty_worker(Peer) ->
|
||||
[{peer, Peer},
|
||||
{pending, []},
|
||||
{attempts, []},
|
||||
{next_retry, []},
|
||||
{dead_letter, []}].
|
||||
|
||||
set_peer(Peer, Worker, State) ->
|
||||
set_keyed(Peer, Worker, State).
|
||||
|
||||
drop_pending_by_cid(Cid, Worker) ->
|
||||
Pending = field(pending, Worker),
|
||||
Kept = [A || A <- Pending, activity_cid(A) =/= Cid],
|
||||
set_field(pending, Kept, Worker).
|
||||
|
||||
clear_retry_for(Cid, Worker) ->
|
||||
A1 = del_keyed(Cid, field(attempts, Worker)),
|
||||
NR1 = del_keyed(Cid, field(next_retry, Worker)),
|
||||
set_field(attempts, A1, set_field(next_retry, NR1, Worker)).
|
||||
|
||||
dead_letter_pending(Cid, Worker) ->
|
||||
Pending = field(pending, Worker),
|
||||
{Match, Rest} = split_by_cid(Cid, Pending),
|
||||
DL = field(dead_letter, Worker),
|
||||
Worker1 = set_field(pending, Rest, Worker),
|
||||
Worker2 = case Match of
|
||||
none -> Worker1;
|
||||
Act -> set_field(dead_letter, DL ++ [Act], Worker1)
|
||||
end,
|
||||
clear_retry_for(Cid, Worker2).
|
||||
|
||||
split_by_cid(Cid, List) -> split_by_cid(Cid, List, []).
|
||||
split_by_cid(_, [], Acc) -> {none, lists:reverse(Acc)};
|
||||
split_by_cid(Cid, [A | Rest], Acc) ->
|
||||
case activity_cid(A) of
|
||||
Cid -> {A, lists:reverse(Acc) ++ Rest};
|
||||
_ -> split_by_cid(Cid, Rest, [A | Acc])
|
||||
end.
|
||||
|
||||
activity_cid(Activity) ->
|
||||
case envelope:get_field(id, Activity) of
|
||||
{ok, Cid} -> Cid;
|
||||
_ -> nil
|
||||
end.
|
||||
|
||||
field(K, [{K, V} | _]) -> V;
|
||||
field(K, [_ | Rest]) -> field(K, Rest);
|
||||
field(_, []) -> undefined.
|
||||
|
||||
set_field(K, V, []) -> [{K, V}];
|
||||
set_field(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||
set_field(K, V, [P | Rest]) -> [P | set_field(K, V, Rest)].
|
||||
|
||||
find_keyed(_, []) -> {error, not_found};
|
||||
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||
|
||||
set_keyed(K, V, []) -> [{K, V}];
|
||||
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)].
|
||||
|
||||
del_keyed(_, []) -> [];
|
||||
del_keyed(K, [{K, _} | Rest]) -> Rest;
|
||||
del_keyed(K, [P | Rest]) -> [P | del_keyed(K, Rest)].
|
||||
@@ -1,286 +0,0 @@
|
||||
-module(delivery_worker).
|
||||
-behaviour(gen_server).
|
||||
-export([new/1, pending/1, peer/1,
|
||||
enqueue_pure/3, drain_pure/1, deliver_one_pure/2,
|
||||
backoff_for/1, schedule_for/1,
|
||||
record_failure_pure/3, record_success_pure/2,
|
||||
next_due_pure/2, attempts_for/2, next_retry_at/2,
|
||||
dead_letter_list/1,
|
||||
start_link/1, start_link/2, stop/1,
|
||||
enqueue/2, flush/1, pending_srv/1, set_dispatch_fn/2]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||
|
||||
%% Outbound delivery worker per design §13.4. One gen_server per
|
||||
%% peer instance (peer-id atom) holding a FIFO queue of pending
|
||||
%% activities to deliver. v2 lands in stages:
|
||||
%%
|
||||
%% Step 8a pure-functional state shape, enqueue / drain /
|
||||
%% schedule semantics + gen_server skeleton + tests
|
||||
%% Step 8b retry / backoff schedule (30s / 5m / 30m / 6h / 24h)
|
||||
%% + dead-letter list
|
||||
%% Step 8c delivery-state projection so the queue survives
|
||||
%% kernel restart
|
||||
%% Step 8d outbox:publish/2 dispatches each delivery-set entry
|
||||
%% to the matching worker
|
||||
%% Step 8e httpc:request/4 BIF (substrate exception per briefing)
|
||||
%% Step 8f real HTTP POST through the BIF + content-type wiring
|
||||
%%
|
||||
%% This file is 8a only — pure state + skeleton gen_server with the
|
||||
%% APIs Step 8b-d will fill in. Real HTTP dispatch is stubbed via a
|
||||
%% caller-supplied `:dispatch_fn` so tests can intercept and Step 8f
|
||||
%% can plug in the live httpc call without touching the queue logic.
|
||||
%%
|
||||
%% State shape (pure):
|
||||
%% [{peer, PeerId},
|
||||
%% {pending, [Activity, ...]}, %% FIFO; head delivered first
|
||||
%% {attempts, [{Cid, AttemptCount}, ...]},
|
||||
%% {next_retry, [{Cid, NextRetryAt}, ...]}, %% Step 8b-pure
|
||||
%% {dead_letter, [Activity, ...]},
|
||||
%% {dispatch_fn, fun/1 | undefined}]
|
||||
%%
|
||||
%% gen_server registers under the peer-id atom (one worker per peer);
|
||||
%% the same APIs work as pure-functional state transitions for tests.
|
||||
|
||||
%% ── Pure-functional API ─────────────────────────────────────────
|
||||
|
||||
new(PeerId) ->
|
||||
[{peer, PeerId},
|
||||
{pending, []},
|
||||
{attempts, []},
|
||||
{next_retry, []},
|
||||
{dead_letter, []},
|
||||
{dispatch_fn, undefined}].
|
||||
|
||||
pending(State) -> field(pending, State).
|
||||
peer(State) -> field(peer, State).
|
||||
|
||||
%% enqueue_pure/3 — append an activity to the queue. Returns new
|
||||
%% state. Duplicate :id activities aren't deduplicated here — that's
|
||||
%% the caller's job (Step 8d will pass each delivery-set entry once).
|
||||
|
||||
enqueue_pure(_PeerId, Activity, State) ->
|
||||
Pending = field(pending, State),
|
||||
set_field(pending, Pending ++ [Activity], State).
|
||||
|
||||
%% drain_pure/1 — attempt to deliver every queued activity through
|
||||
%% the configured dispatch_fn. Returns {NewState, DeliveredCids,
|
||||
%% RetryCids}. Activities that fail dispatch stay in :pending with
|
||||
%% an incremented attempt counter — Step 8b will use the count to
|
||||
%% pick a backoff slot.
|
||||
|
||||
drain_pure(State) ->
|
||||
Pending = field(pending, State),
|
||||
drain_loop(Pending, [], State, [], []).
|
||||
|
||||
drain_loop([], Kept, State, Delivered, Retry) ->
|
||||
{set_field(pending, Kept, State), Delivered, Retry};
|
||||
drain_loop([A | Rest], Kept, State, Delivered, Retry) ->
|
||||
case deliver_one_pure(A, State) of
|
||||
{ok, Cid} ->
|
||||
drain_loop(Rest, Kept, State, Delivered ++ [Cid], Retry);
|
||||
{error, Cid, _Reason} ->
|
||||
State1 = bump_attempt(Cid, State),
|
||||
drain_loop(Rest, Kept ++ [A], State1, Delivered, Retry ++ [Cid])
|
||||
end.
|
||||
|
||||
%% deliver_one_pure/2 — single-activity dispatch via the caller-
|
||||
%% supplied dispatch_fn. Returns {ok, Cid} on success or {error,
|
||||
%% Cid, Reason} on failure. With no dispatch_fn configured returns
|
||||
%% {error, _, no_dispatch_fn} so callers know to wire one before
|
||||
%% the worker is useful.
|
||||
|
||||
deliver_one_pure(Activity, State) ->
|
||||
Cid = activity_cid(Activity),
|
||||
case field(dispatch_fn, State) of
|
||||
undefined -> {error, Cid, no_dispatch_fn};
|
||||
Fn when is_function(Fn, 1) ->
|
||||
case Fn(Activity) of
|
||||
ok -> {ok, Cid};
|
||||
{ok, _} -> {ok, Cid};
|
||||
{error, Reason} -> {error, Cid, Reason};
|
||||
Other -> {error, Cid, {bad_dispatch_return, Other}}
|
||||
end;
|
||||
_ -> {error, Cid, bad_dispatch_fn}
|
||||
end.
|
||||
|
||||
%% backoff_for/1 — Step 8a returns the static schedule per the
|
||||
%% plan; Step 8b wires it into the retry loop. Attempts are
|
||||
%% 1-indexed (first retry uses slot 1).
|
||||
%%
|
||||
%% 30s / 5m / 30m / 6h / 24h then dead_letter.
|
||||
|
||||
backoff_for(0) -> 0;
|
||||
backoff_for(1) -> 30;
|
||||
backoff_for(2) -> 300; % 5 * 60
|
||||
backoff_for(3) -> 1800; % 30 * 60
|
||||
backoff_for(4) -> 21600; % 6 * 3600
|
||||
backoff_for(5) -> 86400; % 24 * 3600
|
||||
backoff_for(_) -> dead_letter.
|
||||
|
||||
schedule_for(Attempts) ->
|
||||
case backoff_for(Attempts) of
|
||||
dead_letter -> dead_letter;
|
||||
Seconds -> {retry_in, Seconds}
|
||||
end.
|
||||
|
||||
%% ── Step 8b-pure: retry-time bookkeeping ───────────────────────
|
||||
%%
|
||||
%% `record_failure_pure/3(Cid, Now, State)` — call after a failed
|
||||
%% deliver_one. Bumps the per-cid attempt counter; if the new
|
||||
%% attempt is past the dead-letter threshold, moves the matching
|
||||
%% activity from :pending to :dead_letter. Otherwise records the
|
||||
%% next retry time as Now + backoff_for(NewAttempt).
|
||||
%%
|
||||
%% Real timer wiring (erlang:send_after self-cast on the worker
|
||||
%% pid) needs substrate support — Step 8b-timer when that lands.
|
||||
%%
|
||||
%% `record_success_pure/2(Cid, State)` — clears :attempts and
|
||||
%% :next_retry entries for the cid; called after a successful
|
||||
%% deliver_one.
|
||||
%%
|
||||
%% `next_due_pure/2(Now, State)` — returns the list of Cids whose
|
||||
%% NextRetryAt has passed, in insertion order.
|
||||
|
||||
record_failure_pure(Cid, Now, State) ->
|
||||
Attempts = field(attempts, State),
|
||||
Current = case find_keyed(Cid, Attempts) of
|
||||
{ok, N} -> N;
|
||||
_ -> 0
|
||||
end,
|
||||
New = Current + 1,
|
||||
State1 = set_field(attempts, set_keyed(Cid, New, Attempts), State),
|
||||
case backoff_for(New) of
|
||||
dead_letter ->
|
||||
move_to_dead_letter(Cid, State1);
|
||||
Seconds ->
|
||||
NextAt = Now + Seconds,
|
||||
NR = field(next_retry, State1),
|
||||
set_field(next_retry, set_keyed(Cid, NextAt, NR), State1)
|
||||
end.
|
||||
|
||||
record_success_pure(Cid, State) ->
|
||||
A1 = del_keyed(Cid, field(attempts, State)),
|
||||
NR1 = del_keyed(Cid, field(next_retry, State)),
|
||||
set_field(attempts, A1, set_field(next_retry, NR1, State)).
|
||||
|
||||
%% next_due_pure/2 — Cids whose NextRetryAt <= Now. Preserves
|
||||
%% insertion order so the worker drains them in FIFO retry order.
|
||||
|
||||
next_due_pure(Now, State) ->
|
||||
[Cid || {Cid, At} <- field(next_retry, State), At =< Now].
|
||||
|
||||
attempts_for(Cid, State) ->
|
||||
case find_keyed(Cid, field(attempts, State)) of
|
||||
{ok, N} -> N;
|
||||
_ -> 0
|
||||
end.
|
||||
|
||||
next_retry_at(Cid, State) ->
|
||||
case find_keyed(Cid, field(next_retry, State)) of
|
||||
{ok, At} -> At;
|
||||
_ -> undefined
|
||||
end.
|
||||
|
||||
dead_letter_list(State) -> field(dead_letter, State).
|
||||
|
||||
move_to_dead_letter(Cid, State) ->
|
||||
Pending = field(pending, State),
|
||||
{Match, Rest} = take_by_cid(Cid, Pending, [], []),
|
||||
DL = field(dead_letter, State),
|
||||
State1 = set_field(pending, Rest, State),
|
||||
State2 = case Match of
|
||||
none -> State1;
|
||||
Act -> set_field(dead_letter, DL ++ [Act], State1)
|
||||
end,
|
||||
NR = field(next_retry, State2),
|
||||
set_field(next_retry, del_keyed(Cid, NR), State2).
|
||||
|
||||
take_by_cid(_, [], Acc, _) -> {none, lists:reverse(Acc)};
|
||||
take_by_cid(Cid, [A | Rest], Acc, _) ->
|
||||
case activity_cid(A) of
|
||||
Cid -> {A, lists:reverse(Acc) ++ Rest};
|
||||
_ -> take_by_cid(Cid, Rest, [A | Acc], 0)
|
||||
end.
|
||||
|
||||
%% ── gen_server wrapper ──────────────────────────────────────────
|
||||
|
||||
start_link(PeerId) ->
|
||||
start_link(PeerId, undefined).
|
||||
|
||||
start_link(PeerId, DispatchFn) ->
|
||||
Pid = gen_server:start_link(delivery_worker, [PeerId, DispatchFn]),
|
||||
erlang:register(PeerId, Pid),
|
||||
Pid.
|
||||
|
||||
stop(PeerId) ->
|
||||
R = gen_server:call(PeerId, '$gen_stop'),
|
||||
erlang:unregister(PeerId),
|
||||
R.
|
||||
|
||||
enqueue(PeerId, Activity) ->
|
||||
gen_server:call(PeerId, {enqueue, Activity}).
|
||||
|
||||
flush(PeerId) ->
|
||||
gen_server:call(PeerId, flush).
|
||||
|
||||
pending_srv(PeerId) ->
|
||||
gen_server:call(PeerId, get_pending).
|
||||
|
||||
set_dispatch_fn(PeerId, Fn) ->
|
||||
gen_server:call(PeerId, {set_dispatch_fn, Fn}).
|
||||
|
||||
%% gen_server callbacks
|
||||
|
||||
init([PeerId, DispatchFn]) ->
|
||||
S0 = new(PeerId),
|
||||
{ok, set_field(dispatch_fn, DispatchFn, S0)}.
|
||||
|
||||
handle_call({enqueue, Activity}, _From, State) ->
|
||||
{reply, ok, enqueue_pure(field(peer, State), Activity, State)};
|
||||
handle_call(flush, _From, State) ->
|
||||
{NewState, Delivered, Retry} = drain_pure(State),
|
||||
{reply, {ok, Delivered, Retry}, NewState};
|
||||
handle_call(get_pending, _From, State) ->
|
||||
{reply, field(pending, State), State};
|
||||
handle_call({set_dispatch_fn, Fn}, _From, State) ->
|
||||
{reply, ok, set_field(dispatch_fn, Fn, State)}.
|
||||
|
||||
handle_cast(_, S) -> {noreply, S}.
|
||||
|
||||
handle_info(_, S) -> {noreply, S}.
|
||||
|
||||
%% ── Internal ────────────────────────────────────────────────────
|
||||
|
||||
activity_cid(Activity) ->
|
||||
case envelope:get_field(id, Activity) of
|
||||
{ok, Cid} -> Cid;
|
||||
_ -> nil
|
||||
end.
|
||||
|
||||
bump_attempt(Cid, State) ->
|
||||
Attempts = field(attempts, State),
|
||||
Current = case find_keyed(Cid, Attempts) of
|
||||
{ok, N} -> N;
|
||||
_ -> 0
|
||||
end,
|
||||
set_field(attempts, set_keyed(Cid, Current + 1, Attempts), State).
|
||||
|
||||
field(K, [{K, V} | _]) -> V;
|
||||
field(K, [_ | Rest]) -> field(K, Rest);
|
||||
field(_, []) -> undefined.
|
||||
|
||||
set_field(K, V, []) -> [{K, V}];
|
||||
set_field(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||
set_field(K, V, [P | Rest]) -> [P | set_field(K, V, Rest)].
|
||||
|
||||
find_keyed(_, []) -> {error, not_found};
|
||||
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||
|
||||
set_keyed(K, V, []) -> [{K, V}];
|
||||
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)].
|
||||
|
||||
del_keyed(_, []) -> [];
|
||||
del_keyed(K, [{K, _} | Rest]) -> Rest;
|
||||
del_keyed(K, [P | Rest]) -> [P | del_keyed(K, Rest)].
|
||||
@@ -1,98 +0,0 @@
|
||||
-module(discovery).
|
||||
-export([parse_acct/1, parse_resource/1,
|
||||
actor_url_for/2, webfinger_body/3]).
|
||||
|
||||
%% Discovery primitives per design §13.7. Step 10a covers the
|
||||
%% local-side webfinger endpoint (responding when a peer asks
|
||||
%% "where does acct:alice@here live?"); the peer-fetch direction
|
||||
%% (loading a peer's actor doc lazily on first inbound) is Step 10b
|
||||
%% and gates on Blockers #2 (native http-request primitive).
|
||||
%%
|
||||
%% parse_acct/1 — accept a binary in either form:
|
||||
%% <<"acct:alice@host:port">> (full prefixed URI)
|
||||
%% <<"alice@host:port">> (bare account, prefix optional)
|
||||
%% Returns {ok, User, Host} | {error, Reason}.
|
||||
%%
|
||||
%% parse_resource/1 — the resource= query parameter from
|
||||
%% /.well-known/webfinger. Same shape as parse_acct.
|
||||
%%
|
||||
%% actor_url_for/2(User, Host) — synthesises the canonical
|
||||
%% per-actor URL `<scheme>://<host>/actors/<user>`. v2 hardcodes
|
||||
%% http://; TLS / https is v3 (Blockers gate).
|
||||
%%
|
||||
%% webfinger_body/3 — builds the JSON response body.
|
||||
|
||||
%% ── parse_acct / parse_resource ─────────────────────────────────
|
||||
|
||||
%% "acct:" -> 5 bytes: 97 99 99 116 58
|
||||
parse_acct(Bin) when is_binary(Bin) ->
|
||||
AcctPrefix = <<97,99,99,116,58>>,
|
||||
case strip_prefix(AcctPrefix, Bin) of
|
||||
{ok, Rest} -> split_user_host(Rest);
|
||||
nomatch -> split_user_host(Bin)
|
||||
end;
|
||||
parse_acct(_) -> {error, bad_input}.
|
||||
|
||||
parse_resource(Bin) -> parse_acct(Bin).
|
||||
|
||||
%% strip_prefix/2 — return {ok, Rest} when Bin starts with Prefix,
|
||||
%% else nomatch. Substrate has no proper prefix-match BIF; this
|
||||
%% byte-walks.
|
||||
|
||||
strip_prefix(<<>>, Rest) -> {ok, Rest};
|
||||
strip_prefix(<<B, PRest/binary>>, <<B, RRest/binary>>) ->
|
||||
strip_prefix(PRest, RRest);
|
||||
strip_prefix(_, _) -> nomatch.
|
||||
|
||||
%% split_user_host/1 — split a `user@host[:port]` binary at the
|
||||
%% first `@`. Returns {ok, User, Host} where Host may include the
|
||||
%% optional port suffix.
|
||||
|
||||
split_user_host(Bin) ->
|
||||
case split_at(64, Bin) of % 64 = '@'
|
||||
{Before, After} when byte_size(Before) > 0, byte_size(After) > 0 ->
|
||||
{ok, Before, After};
|
||||
_ ->
|
||||
{error, bad_acct}
|
||||
end.
|
||||
|
||||
split_at(Byte, Bin) ->
|
||||
split_at(Byte, Bin, <<>>).
|
||||
|
||||
split_at(_, <<>>, Acc) ->
|
||||
{Acc, <<>>};
|
||||
split_at(Byte, <<Byte, Rest/binary>>, Acc) ->
|
||||
{Acc, Rest};
|
||||
split_at(Byte, <<B, Rest/binary>>, Acc) ->
|
||||
split_at(Byte, Rest, <<Acc/binary, B>>).
|
||||
|
||||
%% ── URL synthesis ──────────────────────────────────────────────
|
||||
|
||||
%% "http://" -> 7 bytes | "/actors/" -> 8 bytes
|
||||
actor_url_for(User, Host) ->
|
||||
Pre = <<104,116,116,112,58,47,47>>, % "http://"
|
||||
Mid = <<47,97,99,116,111,114,115,47>>, % "/actors/"
|
||||
<<Pre/binary, Host/binary, Mid/binary, User/binary>>.
|
||||
|
||||
%% ── webfinger JSON body ────────────────────────────────────────
|
||||
%%
|
||||
%% Mastodon-shape per RFC 7033:
|
||||
%% {"subject":"acct:<user>@<host>",
|
||||
%% "links":[{"rel":"self",
|
||||
%% "type":"application/activity+json",
|
||||
%% "href":"<actor_url>"}]}
|
||||
%%
|
||||
%% Hand-rolled byte concatenation — no JSON BIF on this port. The
|
||||
%% caller has already validated User + Host; we don't need to
|
||||
%% re-escape (Mastodon's webfinger inputs are alphanumeric +
|
||||
%% .-_ in practice).
|
||||
|
||||
webfinger_body(User, Host, ActorUrl) ->
|
||||
AcctPre = <<123,34,115,117,98,106,101,99,116,34,58,34,97,99,99,116,58>>, % '{"subject":"acct:'
|
||||
AcctAt = <<64>>, % '@'
|
||||
LinksHd = <<34,44,34,108,105,110,107,115,34,58,91,123,34,114,101,108,34,58,34,115,101,108,102,34,44,
|
||||
34,116,121,112,101,34,58,34,97,112,112,108,105,99,97,116,105,111,110,47,97,99,116,
|
||||
105,118,105,116,121,43,106,115,111,110,34,44,34,104,114,101,102,34,58,34>>, % '","links":[{"rel":"self","type":"application/activity+json","href":"'
|
||||
LinksTl = <<34,125,93,125,10>>, % '"}]}\n'
|
||||
<<AcctPre/binary, User/binary, AcctAt/binary, Host/binary,
|
||||
LinksHd/binary, ActorUrl/binary, LinksTl/binary>>.
|
||||
@@ -1,118 +0,0 @@
|
||||
-module(endorsement_state).
|
||||
-export([new/0, fold/2, fold_fn/0,
|
||||
counters_for/2, total_for/2, kinds_for/2,
|
||||
endorsers_for/3, has_endorsed/4]).
|
||||
|
||||
%% Endorsement counter projection. Folds Endorse activities into a
|
||||
%% per-target-Cid + per-kind counter so projections can serve
|
||||
%% "how many likes does this Note have" / "list everyone who shared
|
||||
%% this Announce" queries.
|
||||
%%
|
||||
%% Endorse envelope shape (per next/genesis/activity-types/endorse.sx):
|
||||
%% [{type, endorse},
|
||||
%% {actor, ActorId},
|
||||
%% {object, TargetCidBinary},
|
||||
%% {kind, KindAtomOrBinary},
|
||||
%% ...]
|
||||
%%
|
||||
%% State shape:
|
||||
%% [{TargetCid, [{Kind, [{ActorId, Count}, ...]}, ...]}, ...]
|
||||
%%
|
||||
%% Each ActorId can endorse the same target multiple times under
|
||||
%% the same kind (e.g. like → unlike → like → ...); the counter
|
||||
%% tracks how many *net* endorsement events fired. Step 11b ships
|
||||
%% the additive counter only; the unlike / un-endorse semantics
|
||||
%% (Undo{Endorse}) and reaction-toggling defer to a follow-up.
|
||||
|
||||
new() -> [].
|
||||
|
||||
fold_fn() ->
|
||||
fun (Activity, State) -> fold(Activity, State) end.
|
||||
|
||||
fold(Activity, State) ->
|
||||
case envelope:get_field(type, Activity) of
|
||||
{ok, endorse} -> fold_endorse(Activity, State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_endorse(Activity, State) ->
|
||||
case {envelope:get_field(actor, Activity),
|
||||
envelope:get_field(object, Activity),
|
||||
envelope:get_field(kind, Activity)} of
|
||||
{{ok, Actor}, {ok, Cid}, {ok, Kind}} ->
|
||||
bump(Cid, Kind, Actor, State);
|
||||
_ ->
|
||||
State
|
||||
end.
|
||||
|
||||
bump(Cid, Kind, Actor, State) ->
|
||||
KindMap = case find_keyed(Cid, State) of
|
||||
{ok, KM} -> KM;
|
||||
_ -> []
|
||||
end,
|
||||
ActorMap = case find_keyed(Kind, KindMap) of
|
||||
{ok, AM} -> AM;
|
||||
_ -> []
|
||||
end,
|
||||
Current = case find_keyed(Actor, ActorMap) of
|
||||
{ok, N} -> N;
|
||||
_ -> 0
|
||||
end,
|
||||
ActorMap1 = set_keyed(Actor, Current + 1, ActorMap),
|
||||
KindMap1 = set_keyed(Kind, ActorMap1, KindMap),
|
||||
set_keyed(Cid, KindMap1, State).
|
||||
|
||||
%% ── Read-side accessors ───────────────────────────────────────
|
||||
|
||||
%% counters_for(Cid, State) -> [{Kind, TotalCount}, ...]
|
||||
%% Sum per-kind across all endorsers.
|
||||
|
||||
counters_for(Cid, State) ->
|
||||
case find_keyed(Cid, State) of
|
||||
{ok, KindMap} ->
|
||||
[{K, sum_counts(AM)} || {K, AM} <- KindMap];
|
||||
_ -> []
|
||||
end.
|
||||
|
||||
total_for(Cid, State) ->
|
||||
lists:foldl(fun ({_, N}, Acc) -> N + Acc end, 0, counters_for(Cid, State)).
|
||||
|
||||
kinds_for(Cid, State) ->
|
||||
[K || {K, _} <- counters_for(Cid, State)].
|
||||
|
||||
endorsers_for(Cid, Kind, State) ->
|
||||
case find_keyed(Cid, State) of
|
||||
{ok, KindMap} ->
|
||||
case find_keyed(Kind, KindMap) of
|
||||
{ok, AM} -> [A || {A, _} <- AM];
|
||||
_ -> []
|
||||
end;
|
||||
_ -> []
|
||||
end.
|
||||
|
||||
has_endorsed(Actor, Cid, Kind, State) ->
|
||||
case find_keyed(Cid, State) of
|
||||
{ok, KindMap} ->
|
||||
case find_keyed(Kind, KindMap) of
|
||||
{ok, AM} ->
|
||||
case find_keyed(Actor, AM) of
|
||||
{ok, N} -> N > 0;
|
||||
_ -> false
|
||||
end;
|
||||
_ -> false
|
||||
end;
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
%% ── Internal ──────────────────────────────────────────────────
|
||||
|
||||
sum_counts([]) -> 0;
|
||||
sum_counts([{_, N} | Rest]) -> N + sum_counts(Rest).
|
||||
|
||||
find_keyed(_, []) -> {error, not_found};
|
||||
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||
|
||||
set_keyed(K, V, []) -> [{K, V}];
|
||||
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)].
|
||||
@@ -1,177 +0,0 @@
|
||||
-module(envelope).
|
||||
-export([validate_shape/1, get_field/2, canonical_bytes/1, verify_signature/2]).
|
||||
|
||||
%% Activity envelope per design §3.1.
|
||||
%%
|
||||
%% Erlang maps (#{...}) are not supported by this port, so envelopes
|
||||
%% are represented as property lists of {atom_key, value} pairs. This
|
||||
%% port's binary syntax also can't carry string literals; values that
|
||||
%% would naturally be binaries in real Erlang are kept as atoms or
|
||||
%% integer-segment binaries in the test corpus.
|
||||
%%
|
||||
%% Required fields: id, type, actor, published, signature.
|
||||
%% The signature value is itself a property list with key_id,
|
||||
%% algorithm, value.
|
||||
%%
|
||||
%% validate_shape/1 returns ok | {error, Reason}. Reasons:
|
||||
%% not_a_proplist
|
||||
%% {missing_field, FieldName}
|
||||
%% {bad_signature, BadSigReason}
|
||||
%%
|
||||
%% get_field/2 returns {ok, Value} | not_found.
|
||||
|
||||
validate_shape(Env) when is_list(Env) ->
|
||||
case check_required([id, type, actor, published, signature], Env) of
|
||||
ok -> validate_signature_shape(Env);
|
||||
Err -> Err
|
||||
end;
|
||||
validate_shape(_) ->
|
||||
{error, not_a_proplist}.
|
||||
|
||||
get_field(_, []) -> not_found;
|
||||
get_field(K, [{K, V} | _]) -> {ok, V};
|
||||
get_field(K, [_ | Rest]) -> get_field(K, Rest).
|
||||
|
||||
check_required([], _) -> ok;
|
||||
check_required([F | Rest], Env) ->
|
||||
case get_field(F, Env) of
|
||||
{ok, _} -> check_required(Rest, Env);
|
||||
not_found -> {error, {missing_field, F}}
|
||||
end.
|
||||
|
||||
validate_signature_shape(Env) ->
|
||||
{ok, Sig} = get_field(signature, Env),
|
||||
case is_list(Sig) of
|
||||
true ->
|
||||
case check_required([key_id, algorithm, value], Sig) of
|
||||
ok -> ok;
|
||||
{error, {missing_field, F}} ->
|
||||
{error, {bad_signature, {missing_field, F}}}
|
||||
end;
|
||||
false ->
|
||||
{error, {bad_signature, not_a_proplist}}
|
||||
end.
|
||||
|
||||
%% canonical_bytes/1 — the byte string the signature covers.
|
||||
%%
|
||||
%% Real fed-sx will use dag-cbor over a JSON-LD-canonicalised form
|
||||
%% (design §3.2). For milestone 1 we stand in for that with the host
|
||||
%% BIF `cid:to_string/1`, which produces a CIDv1 over the deterministic
|
||||
%% textual form of the term. Two prior steps make this work:
|
||||
%% 1. The signature pair is stripped (sig covers everything except
|
||||
%% itself).
|
||||
%% 2. The top-level property list is sorted by key so field order in
|
||||
%% the source envelope is not load-bearing.
|
||||
%%
|
||||
%% The result is an Erlang binary suitable as the sig-cover input.
|
||||
|
||||
canonical_bytes(Env) when is_list(Env) ->
|
||||
Stripped = strip_signature(Env),
|
||||
Sorted = sort_pairs(Stripped),
|
||||
cid:to_string(Sorted).
|
||||
|
||||
strip_signature([]) -> [];
|
||||
strip_signature([{signature, _} | Rest]) -> strip_signature(Rest);
|
||||
strip_signature([P | Rest]) -> [P | strip_signature(Rest)].
|
||||
|
||||
sort_pairs([]) -> [];
|
||||
sort_pairs([H | T]) -> insert_pair(H, sort_pairs(T)).
|
||||
|
||||
insert_pair(P, []) -> [P];
|
||||
insert_pair({K1, V1}, [{K2, V2} | Rest]) ->
|
||||
case K1 < K2 of
|
||||
true -> [{K1, V1}, {K2, V2} | Rest];
|
||||
false -> [{K2, V2} | insert_pair({K1, V1}, Rest)]
|
||||
end.
|
||||
|
||||
%% verify_signature/2 — time-aware sig verification per design §9.6.
|
||||
%%
|
||||
%% Activity carries a `signature` proplist with `key_id`, `algorithm`,
|
||||
%% `value`. ActorState carries `public_keys` — a list of key proplists
|
||||
%% with `id`, `created`, optionally `superseded_at`, and `value` (the
|
||||
%% key material).
|
||||
%%
|
||||
%% A key is active at time T iff `created =< T` AND
|
||||
%% (no `superseded_at` OR T < `superseded_at`). Verification picks the
|
||||
%% first matching active key whose `id == signature.key_id` at the
|
||||
%% activity's `published` timestamp, then recomputes the MAC
|
||||
%% `crypto:hash(sha256, <<KeyMaterial/binary, CanonicalBytes/binary>>)`
|
||||
%% and compares it to `signature.value`.
|
||||
%%
|
||||
%% Returns ok | {error, Reason}. Reasons:
|
||||
%% no_signature | no_key_id | no_published | no_keys |
|
||||
%% no_active_key | bad_signature
|
||||
%%
|
||||
%% Real RSA-SHA256 / Ed25519 verification is deferred to milestone 2:
|
||||
%% Phase 8 only ships `crypto:hash/2`, so we stand in with an HMAC-shaped
|
||||
%% MAC that exercises the same key-lookup and canonical-bytes pipeline.
|
||||
|
||||
verify_signature(Activity, ActorState) ->
|
||||
case get_field(signature, Activity) of
|
||||
not_found -> {error, no_signature};
|
||||
{ok, Sig} ->
|
||||
case get_field(key_id, Sig) of
|
||||
not_found -> {error, no_key_id};
|
||||
{ok, KeyId} ->
|
||||
case get_field(published, Activity) of
|
||||
not_found -> {error, no_published};
|
||||
{ok, Published} ->
|
||||
verify_with_keys(Activity, Sig, KeyId,
|
||||
Published, ActorState)
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
verify_with_keys(Activity, Sig, KeyId, Published, ActorState) ->
|
||||
case get_field(public_keys, ActorState) of
|
||||
not_found -> {error, no_keys};
|
||||
{ok, Keys} ->
|
||||
case find_active_key(KeyId, Published, Keys) of
|
||||
not_found -> {error, no_active_key};
|
||||
{ok, Key} -> verify_mac(Activity, Sig, Key)
|
||||
end
|
||||
end.
|
||||
|
||||
find_active_key(_, _, []) -> not_found;
|
||||
find_active_key(KeyId, Now, [Key | Rest]) ->
|
||||
case is_matching_active_key(Key, KeyId, Now) of
|
||||
true -> {ok, Key};
|
||||
false -> find_active_key(KeyId, Now, Rest)
|
||||
end.
|
||||
|
||||
is_matching_active_key(Key, WantId, Now) ->
|
||||
case get_field(id, Key) of
|
||||
{ok, WantId} -> is_active_at(Key, Now);
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
is_active_at(Key, Now) ->
|
||||
case get_field(created, Key) of
|
||||
not_found -> false;
|
||||
{ok, Created} ->
|
||||
case Now >= Created of
|
||||
false -> false;
|
||||
true ->
|
||||
case get_field(superseded_at, Key) of
|
||||
not_found -> true;
|
||||
{ok, SupAt} -> Now < SupAt
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
verify_mac(Activity, Sig, Key) ->
|
||||
case get_field(value, Sig) of
|
||||
not_found -> {error, bad_signature};
|
||||
{ok, SigValue} ->
|
||||
case get_field(value, Key) of
|
||||
not_found -> {error, bad_signature};
|
||||
{ok, KeyMat} ->
|
||||
Bytes = canonical_bytes(Activity),
|
||||
Computed = crypto:hash(sha256,
|
||||
<<KeyMat/binary, Bytes/binary>>),
|
||||
case SigValue =:= Computed of
|
||||
true -> ok;
|
||||
false -> {error, bad_signature}
|
||||
end
|
||||
end
|
||||
end.
|
||||
@@ -1,237 +0,0 @@
|
||||
-module(follower_graph).
|
||||
-export([fold/2, fold_fn/0, new/0, lookup/2, actors/1,
|
||||
following/2, followers/2,
|
||||
pending_outbound/2, pending_inbound/2,
|
||||
is_following/3, has_follower/3,
|
||||
is_pending_outbound/3, is_pending_inbound/3]).
|
||||
|
||||
%% Follower-graph projection — Erlang-fun stand-in for the genesis
|
||||
%% `follower-graph.sx` body. Tracks per-actor follow relationships
|
||||
%% per design §13.2:
|
||||
%%
|
||||
%% Follow {actor: A, object: B} A asks to follow B
|
||||
%% Accept {actor: B, object: F} B accepts A's Follow F (= F.actor → F.object)
|
||||
%% Reject {actor: B, object: F} B rejects A's Follow F
|
||||
%% Undo {actor: A, object: F} A retracts F or unfollows
|
||||
%%
|
||||
%% Where F = Follow{A→B} is embedded as the activity's :object
|
||||
%% proplist for Accept / Reject / Undo.
|
||||
%%
|
||||
%% State shape:
|
||||
%% [{ActorId, ActorEntry}, ...]
|
||||
%%
|
||||
%% ActorEntry = [{following, [PeerId, ...]},
|
||||
%% {followers, [PeerId, ...]},
|
||||
%% {pending_outbound, [PeerId, ...]}, %% I asked, no answer yet
|
||||
%% {pending_inbound, [PeerId, ...]}] %% asked me, I haven't answered
|
||||
%%
|
||||
%% Sets keep insertion order; duplicates aren't added. lists:keyfind/
|
||||
%% keymember aren't in this substrate, so local find_keyed/has_keyed/
|
||||
%% set_keyed helpers (same convention as actor_state, define_registry,
|
||||
%% nx_kernel).
|
||||
|
||||
%% ── Public API ──────────────────────────────────────────────────
|
||||
|
||||
new() -> [].
|
||||
|
||||
actors(State) -> [Id || {Id, _Entry} <- State].
|
||||
|
||||
lookup(ActorId, State) ->
|
||||
case find_keyed(ActorId, State) of
|
||||
{ok, Entry} -> {ok, Entry};
|
||||
_ -> not_found
|
||||
end.
|
||||
|
||||
following(ActorId, State) -> entry_field(ActorId, following, State).
|
||||
followers(ActorId, State) -> entry_field(ActorId, followers, State).
|
||||
pending_outbound(ActorId, State) -> entry_field(ActorId, pending_outbound, State).
|
||||
pending_inbound(ActorId, State) -> entry_field(ActorId, pending_inbound, State).
|
||||
|
||||
is_following(ActorId, PeerId, State) ->
|
||||
contains(PeerId, following(ActorId, State)).
|
||||
|
||||
has_follower(ActorId, PeerId, State) ->
|
||||
contains(PeerId, followers(ActorId, State)).
|
||||
|
||||
is_pending_outbound(ActorId, PeerId, State) ->
|
||||
contains(PeerId, pending_outbound(ActorId, State)).
|
||||
|
||||
is_pending_inbound(ActorId, PeerId, State) ->
|
||||
contains(PeerId, pending_inbound(ActorId, State)).
|
||||
|
||||
%% ── Fold dispatch ───────────────────────────────────────────────
|
||||
|
||||
fold(Activity, State) ->
|
||||
case envelope:get_field(type, Activity) of
|
||||
{ok, follow} -> fold_follow(Activity, State);
|
||||
{ok, accept} -> fold_accept(Activity, State);
|
||||
{ok, reject} -> fold_reject(Activity, State);
|
||||
{ok, undo} -> fold_undo(Activity, State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_fn() ->
|
||||
fun (Activity, State) -> fold(Activity, State) end.
|
||||
|
||||
%% Follow {actor: A, object: B}:
|
||||
%% add B to A's pending_outbound
|
||||
%% add A to B's pending_inbound
|
||||
fold_follow(Activity, State) ->
|
||||
case follow_actor_object(Activity) of
|
||||
{ok, A, B} when A =/= B ->
|
||||
S1 = add_to_field(A, pending_outbound, B, State),
|
||||
add_to_field(B, pending_inbound, A, S1);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
%% Accept {actor: B, object: Follow{A→B}}:
|
||||
%% move A from B's pending_inbound to B's followers
|
||||
%% move B from A's pending_outbound to A's following
|
||||
fold_accept(Activity, State) ->
|
||||
case nested_follow_actor_object(Activity) of
|
||||
{ok, B, A, OrigA, OrigB} when B =:= OrigB, A =:= OrigA, A =/= B ->
|
||||
S1 = move_field(B, pending_inbound, followers, A, State),
|
||||
move_field(A, pending_outbound, following, B, S1);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
%% Reject {actor: B, object: Follow{A→B}}:
|
||||
%% drop A from B's pending_inbound
|
||||
%% drop B from A's pending_outbound
|
||||
fold_reject(Activity, State) ->
|
||||
case nested_follow_actor_object(Activity) of
|
||||
{ok, B, A, OrigA, OrigB} when B =:= OrigB, A =:= OrigA, A =/= B ->
|
||||
S1 = drop_from_field(B, pending_inbound, A, State),
|
||||
drop_from_field(A, pending_outbound, B, S1);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
%% Undo {actor: X, object: Follow{A→B}}:
|
||||
%% Only the original Follow's actor (A) can Undo it.
|
||||
%% Drops A↔B from every list on either side.
|
||||
fold_undo(Activity, State) ->
|
||||
case nested_follow_actor_object(Activity) of
|
||||
{ok, X, OrigA, OrigA, OrigB} when X =:= OrigA, OrigA =/= OrigB ->
|
||||
S1 = drop_from_field(OrigA, following, OrigB, State),
|
||||
S2 = drop_from_field(OrigA, pending_outbound, OrigB, S1),
|
||||
S3 = drop_from_field(OrigB, followers, OrigA, S2),
|
||||
drop_from_field(OrigB, pending_inbound, OrigA, S3);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
%% ── Extraction helpers ─────────────────────────────────────────
|
||||
|
||||
follow_actor_object(Activity) ->
|
||||
case envelope:get_field(actor, Activity) of
|
||||
{ok, A} ->
|
||||
case envelope:get_field(object, Activity) of
|
||||
{ok, B} when is_atom(B) -> {ok, A, B};
|
||||
_ -> not_follow
|
||||
end;
|
||||
_ -> not_follow
|
||||
end.
|
||||
|
||||
%% nested_follow_actor_object/1 — pull (Actor, FollowActor, FollowObject)
|
||||
%% out of an envelope whose :object is itself a Follow proplist.
|
||||
%% Returns {ok, OuterActor, InferredPeer, InnerActor, InnerObject}.
|
||||
nested_follow_actor_object(Activity) ->
|
||||
case envelope:get_field(actor, Activity) of
|
||||
{ok, Outer} ->
|
||||
case envelope:get_field(object, Activity) of
|
||||
{ok, Inner} when is_list(Inner) ->
|
||||
case nested_is_follow(Inner) of
|
||||
true ->
|
||||
case {envelope:get_field(actor, Inner),
|
||||
envelope:get_field(object, Inner)} of
|
||||
{{ok, IA}, {ok, IO}} when is_atom(IO) ->
|
||||
{ok, Outer, peer_from_inner(Outer, IA, IO), IA, IO};
|
||||
_ -> not_a_follow_wrapper
|
||||
end;
|
||||
false -> not_a_follow_wrapper
|
||||
end;
|
||||
_ -> not_a_follow_wrapper
|
||||
end;
|
||||
_ -> not_a_follow_wrapper
|
||||
end.
|
||||
|
||||
nested_is_follow(Inner) ->
|
||||
case envelope:get_field(type, Inner) of
|
||||
{ok, follow} -> true;
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
%% peer_from_inner — for an Accept/Reject by B of Follow{A→B},
|
||||
%% Outer = B; the "peer" we move state for is A. For an Undo by A,
|
||||
%% Outer = A; the peer is B. Picking the inner actor/object that
|
||||
%% isn't Outer gives us the right pair-mate.
|
||||
peer_from_inner(Outer, IA, _IO) when Outer =:= IA -> IA;
|
||||
peer_from_inner(_Outer, IA, _IO) -> IA.
|
||||
|
||||
%% ── Entry / field accessors ────────────────────────────────────
|
||||
|
||||
entry_field(ActorId, Field, State) ->
|
||||
case find_keyed(ActorId, State) of
|
||||
{ok, Entry} ->
|
||||
case find_keyed(Field, Entry) of
|
||||
{ok, Val} -> Val;
|
||||
_ -> []
|
||||
end;
|
||||
_ -> []
|
||||
end.
|
||||
|
||||
empty_entry() ->
|
||||
[{following, []},
|
||||
{followers, []},
|
||||
{pending_outbound, []},
|
||||
{pending_inbound, []}].
|
||||
|
||||
ensure_entry(ActorId, State) ->
|
||||
case find_keyed(ActorId, State) of
|
||||
{ok, _} -> State;
|
||||
_ -> State ++ [{ActorId, empty_entry()}]
|
||||
end.
|
||||
|
||||
add_to_field(ActorId, Field, PeerId, State) ->
|
||||
S1 = ensure_entry(ActorId, State),
|
||||
{ok, Entry} = find_keyed(ActorId, S1),
|
||||
Current = entry_field(ActorId, Field, S1),
|
||||
NewList = case contains(PeerId, Current) of
|
||||
true -> Current;
|
||||
false -> Current ++ [PeerId]
|
||||
end,
|
||||
NewEntry = set_keyed(Field, NewList, Entry),
|
||||
set_keyed(ActorId, NewEntry, S1).
|
||||
|
||||
drop_from_field(ActorId, Field, PeerId, State) ->
|
||||
case find_keyed(ActorId, State) of
|
||||
{ok, Entry} ->
|
||||
Current = entry_field(ActorId, Field, State),
|
||||
NewList = remove_member(PeerId, Current),
|
||||
NewEntry = set_keyed(Field, NewList, Entry),
|
||||
set_keyed(ActorId, NewEntry, State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
move_field(ActorId, FromField, ToField, PeerId, State) ->
|
||||
S1 = drop_from_field(ActorId, FromField, PeerId, State),
|
||||
add_to_field(ActorId, ToField, PeerId, S1).
|
||||
|
||||
%% ── List helpers ───────────────────────────────────────────────
|
||||
|
||||
contains(_, []) -> false;
|
||||
contains(X, [X | _]) -> true;
|
||||
contains(X, [_ | Rest]) -> contains(X, Rest).
|
||||
|
||||
remove_member(_, []) -> [];
|
||||
remove_member(X, [X | Rest]) -> remove_member(X, Rest);
|
||||
remove_member(X, [Y | Rest]) -> [Y | remove_member(X, Rest)].
|
||||
|
||||
%% ── Keyed-list helpers ─────────────────────────────────────────
|
||||
|
||||
find_keyed(_, []) -> {error, not_found};
|
||||
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||
|
||||
set_keyed(K, V, []) -> [{K, V}];
|
||||
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)].
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,362 +0,0 @@
|
||||
-module(log).
|
||||
-export([open/2, open_disk/2, open_disk/3,
|
||||
append/2, tip/1, replay/3, entries/1,
|
||||
segments/1]).
|
||||
|
||||
%% Per-actor activity log — the canonical record of everything an
|
||||
%% actor has emitted, in chronological order. Per design §15.2 this
|
||||
%% lives on disk as numbered segment files; v1 started with an
|
||||
%% in-memory backend (Step 3a) so the API + seq-number machinery
|
||||
%% could be locked down before on-disk persistence (Step 3b) and
|
||||
%% segment rotation (Step 3c.a — this revision).
|
||||
%%
|
||||
%% On-disk layout:
|
||||
%% <BasePath>/<ActorId>-NNNNNN.log
|
||||
%%
|
||||
%% NNNNNN is a 6-digit zero-padded segment index (000000..999999) so
|
||||
%% file:list_dir's alphabetical ordering coincides with numeric. Each
|
||||
%% segment file is the concat of length-prefixed frames; each frame
|
||||
%% is `<<Len:32/big>>` + `term_codec:encode(Activity)`.
|
||||
%%
|
||||
%% In-memory state (a property list):
|
||||
%% [{actor, ActorId},
|
||||
%% {base, BasePath}, %% binary | charlist
|
||||
%% {seq, NextSeq}, %% next seq the log will assign
|
||||
%% {entries, [Activity, ...]}, %% flat, append order, oldest first
|
||||
%% {persisted, true|false}, %% does append write through?
|
||||
%% {seg_size, MaxBytes}, %% rotate when active segment > this
|
||||
%% {seg_lens, [N0, N1, ...]}] %% entry count per segment in order
|
||||
%%
|
||||
%% `seg_lens` is the sole bookkeeping needed to compute (a) which
|
||||
%% segment any given seq lives in, and (b) which slice of `entries`
|
||||
%% is the active segment's contents to rewrite on append. The last
|
||||
%% element is the active segment's length.
|
||||
|
||||
%% In-memory only — atoms accepted as BasePath for back-compat with
|
||||
%% Step 3a tests that just want the API surface.
|
||||
open(ActorId, BasePath) ->
|
||||
{ok, [{actor, ActorId}, {base, BasePath},
|
||||
{seq, 0}, {entries, []},
|
||||
{persisted, false}]}.
|
||||
|
||||
%% Disk-backed; default segment size = effectively unlimited (no
|
||||
%% rotation). Use open_disk/3 with {segment_size, N} to enable.
|
||||
open_disk(ActorId, BasePath) ->
|
||||
open_disk(ActorId, BasePath, [{segment_size, 1073741824}]). %% 1 GiB
|
||||
|
||||
open_disk(ActorId, BasePath, Opts) ->
|
||||
SegSize = proplist_get(segment_size, Opts, 1073741824),
|
||||
case load_all_segments(ActorId, BasePath) of
|
||||
{ok, SegEntries} ->
|
||||
%% SegEntries :: [[Entry, ...]] in segment-index order
|
||||
%% (empty list when no segments exist on disk).
|
||||
Lens0 = [length(S) || S <- SegEntries],
|
||||
%% Always have at least one active segment, even if empty.
|
||||
Lens = case Lens0 of
|
||||
[] -> [0];
|
||||
_ -> Lens0
|
||||
end,
|
||||
Flat = flatten_segs(SegEntries),
|
||||
State = [{actor, ActorId}, {base, BasePath},
|
||||
{seq, length(Flat)},
|
||||
{entries, Flat},
|
||||
{persisted, true},
|
||||
{seg_size, SegSize},
|
||||
{seg_lens, Lens}],
|
||||
{ok, State};
|
||||
{error, _} = E ->
|
||||
E
|
||||
end.
|
||||
|
||||
append(LogState, Activity) ->
|
||||
Seq = field(seq, LogState),
|
||||
Entries = field(entries, LogState),
|
||||
case lookup(persisted, LogState) of
|
||||
true ->
|
||||
SegLens = field(seg_lens, LogState),
|
||||
SegSize = field(seg_size, LogState),
|
||||
{NewSegLens, ActiveIdx, ActiveEntries} =
|
||||
place_append(Entries, Activity, SegLens, SegSize),
|
||||
Path = segment_path(field(actor, LogState),
|
||||
field(base, LogState),
|
||||
ActiveIdx),
|
||||
ok = write_segment(Path, ActiveEntries),
|
||||
NewState = replace_field(seq, Seq + 1,
|
||||
replace_field(entries, Entries ++ [Activity],
|
||||
replace_field(seg_lens, NewSegLens, LogState))),
|
||||
{ok, NewState, Seq};
|
||||
_ ->
|
||||
NewState = replace_field(seq, Seq + 1,
|
||||
replace_field(entries, Entries ++ [Activity],
|
||||
LogState)),
|
||||
{ok, NewState, Seq}
|
||||
end.
|
||||
|
||||
tip(LogState) ->
|
||||
field(seq, LogState).
|
||||
|
||||
replay(LogState, InitAcc, Fun) ->
|
||||
Entries = field(entries, LogState),
|
||||
replay_loop(Entries, 0, InitAcc, Fun).
|
||||
|
||||
entries(LogState) ->
|
||||
field(entries, LogState).
|
||||
|
||||
%% Debug accessor: returns the in-memory seg_lens (count per segment
|
||||
%% in index order). Used by rotation tests to assert that rotation
|
||||
%% happened.
|
||||
segments(LogState) ->
|
||||
case lookup(seg_lens, LogState) of
|
||||
undefined -> [];
|
||||
L -> L
|
||||
end.
|
||||
|
||||
%% --- internals ---
|
||||
|
||||
replay_loop([], _, Acc, _) -> Acc;
|
||||
replay_loop([Act | Rest], Seq, Acc, Fun) ->
|
||||
replay_loop(Rest, Seq + 1, Fun(Act, Seq, Acc), Fun).
|
||||
|
||||
%% place_append/4 decides whether the new Activity extends the current
|
||||
%% active segment or opens a fresh one, returning the resulting
|
||||
%% seg_lens, the active segment's index, and the active segment's
|
||||
%% complete entry list (the slice that needs to be (re)written to
|
||||
%% disk).
|
||||
%%
|
||||
%% Rotation rule: if the active segment already on disk is at or past
|
||||
%% the size threshold (encoded_size(OldActive) >= SegSize) AND it
|
||||
%% already holds at least one entry, the new Activity opens a new
|
||||
%% segment. A single entry larger than the threshold therefore lives
|
||||
%% on its own — we never recurse rotating a one-entry segment.
|
||||
%%
|
||||
%% This is decided BEFORE the append (looking at the pre-append size),
|
||||
%% so each segment file is written exactly once per append cycle.
|
||||
place_append(OldEntries, Activity, SegLens, SegSize) ->
|
||||
{Pre, Last} = split_last(SegLens),
|
||||
PreCount = sum(Pre),
|
||||
OldActive = drop(PreCount, OldEntries),
|
||||
OldActiveSize = encoded_size(OldActive),
|
||||
case (OldActiveSize >= SegSize) andalso (Last >= 1) of
|
||||
true ->
|
||||
%% Rotate: new entry starts a brand-new segment.
|
||||
NewSegLens = SegLens ++ [1],
|
||||
NewActiveIdx = length(SegLens),
|
||||
{NewSegLens, NewActiveIdx, [Activity]};
|
||||
false ->
|
||||
%% Stay: extend current active.
|
||||
NewSegLens = Pre ++ [Last + 1],
|
||||
NewActiveIdx = length(Pre),
|
||||
{NewSegLens, NewActiveIdx, OldActive ++ [Activity]}
|
||||
end.
|
||||
|
||||
split_last([X]) -> {[], X};
|
||||
split_last([H | T]) ->
|
||||
{Tl, Last} = split_last(T),
|
||||
{[H | Tl], Last}.
|
||||
|
||||
sum(L) -> sum_(L, 0).
|
||||
sum_([], A) -> A;
|
||||
sum_([H | T], A) -> sum_(T, A + H).
|
||||
|
||||
drop(0, L) -> L;
|
||||
drop(_, []) -> [];
|
||||
drop(N, [_ | T]) -> drop(N - 1, T).
|
||||
|
||||
%% flatten_segs/1 — concat a list of segments (each itself a list of
|
||||
%% entries) into a single flat list, preserving order. Used by
|
||||
%% open_disk to assemble the on-disk activity history from per-
|
||||
%% segment loads. Implemented locally because lists:append/1 isn't
|
||||
%% registered in this port — only lists:append/2.
|
||||
flatten_segs([]) -> [];
|
||||
flatten_segs([Seg | Rest]) -> Seg ++ flatten_segs(Rest).
|
||||
|
||||
encoded_size(Entries) ->
|
||||
byte_size(list_to_binary(
|
||||
[frame(term_codec:encode(E)) || E <- Entries])).
|
||||
|
||||
%% Try to read every segment file under BasePath matching the actor.
|
||||
%% Returns {ok, [[Entry, ...]]} where the outer list is in segment-
|
||||
%% index order. Empty when no segments exist.
|
||||
load_all_segments(ActorId, BasePath) ->
|
||||
%% list_dir returns {ok, [Binary]} of entry names in sorted order
|
||||
%% per fed-prims contract.
|
||||
BaseChars = base_chars(BasePath),
|
||||
case file:list_dir(BaseChars) of
|
||||
{ok, Names} ->
|
||||
%% Erlang string literals are NOT charlists in this port,
|
||||
%% so build prefix/suffix as explicit char-code lists.
|
||||
Prefix = atom_to_list(ActorId) ++ [$-],
|
||||
Suffix = [$., $l, $o, $g],
|
||||
Indices = collect_segment_indices(Names, Prefix, Suffix),
|
||||
read_segments_in_order(Indices, ActorId, BasePath, []);
|
||||
{error, enoent} ->
|
||||
{ok, []};
|
||||
{error, R} ->
|
||||
{error, {read, R}}
|
||||
end.
|
||||
|
||||
collect_segment_indices([], _, _) -> [];
|
||||
collect_segment_indices([Name | Rest], Prefix, Suffix) ->
|
||||
case parse_segment_name(Name, Prefix, Suffix) of
|
||||
{ok, N} ->
|
||||
[N | collect_segment_indices(Rest, Prefix, Suffix)];
|
||||
not_ours ->
|
||||
collect_segment_indices(Rest, Prefix, Suffix)
|
||||
end.
|
||||
|
||||
parse_segment_name(NameBin, Prefix, Suffix) when is_binary(NameBin) ->
|
||||
parse_segment_name(binary_to_list(NameBin), Prefix, Suffix);
|
||||
parse_segment_name(Name, Prefix, Suffix) ->
|
||||
case strip_prefix(Name, Prefix) of
|
||||
{ok, Rest} ->
|
||||
case strip_suffix(Rest, Suffix) of
|
||||
{ok, NumStr} ->
|
||||
case is_all_digits(NumStr) of
|
||||
true -> {ok, list_to_integer(NumStr)};
|
||||
false -> not_ours
|
||||
end;
|
||||
not_ours -> not_ours
|
||||
end;
|
||||
not_ours -> not_ours
|
||||
end.
|
||||
|
||||
strip_prefix(Str, []) -> {ok, Str};
|
||||
strip_prefix([C | Rest], [P | PRest]) ->
|
||||
case C =:= P of
|
||||
true -> strip_prefix(Rest, PRest);
|
||||
false -> not_ours
|
||||
end;
|
||||
strip_prefix(_, _) -> not_ours.
|
||||
|
||||
strip_suffix(Str, Suffix) ->
|
||||
SL = length(Str),
|
||||
XL = length(Suffix),
|
||||
case SL >= XL of
|
||||
true ->
|
||||
Head = take_n_pl(SL - XL, Str),
|
||||
Tail = drop(SL - XL, Str),
|
||||
case Tail =:= Suffix of
|
||||
true -> {ok, Head};
|
||||
false -> not_ours
|
||||
end;
|
||||
false -> not_ours
|
||||
end.
|
||||
|
||||
take_n_pl(0, _) -> [];
|
||||
take_n_pl(_, []) -> [];
|
||||
take_n_pl(N, [H | T]) -> [H | take_n_pl(N - 1, T)].
|
||||
|
||||
is_all_digits([]) -> false;
|
||||
is_all_digits(Chars) -> all_digits(Chars).
|
||||
|
||||
all_digits([]) -> true;
|
||||
all_digits([C | Rest]) when C >= $0, C =< $9 -> all_digits(Rest);
|
||||
all_digits(_) -> false.
|
||||
|
||||
%% read_segments_in_order/4 — fed-prims sorts list_dir alphabetically;
|
||||
%% with 6-digit zero-padded names that coincides with numeric order.
|
||||
%% But we also accept legacy unpadded names, so sort by index to be
|
||||
%% defensive.
|
||||
read_segments_in_order(Indices, ActorId, BasePath, Acc) ->
|
||||
Sorted = isort(Indices),
|
||||
read_each(Sorted, ActorId, BasePath, Acc).
|
||||
|
||||
read_each([], _, _, Acc) ->
|
||||
{ok, lists:reverse(Acc)};
|
||||
read_each([Idx | Rest], ActorId, BasePath, Acc) ->
|
||||
Path = segment_path(ActorId, BasePath, Idx),
|
||||
case try_read_segment(Path) of
|
||||
{ok, Entries} ->
|
||||
read_each(Rest, ActorId, BasePath, [Entries | Acc]);
|
||||
{error, _} = E -> E
|
||||
end.
|
||||
|
||||
%% Tiny insertion sort over a small list of integers.
|
||||
isort([]) -> [];
|
||||
isort([H | T]) -> insert(H, isort(T)).
|
||||
insert(X, []) -> [X];
|
||||
insert(X, [Y | Rest]) when X =< Y -> [X, Y | Rest];
|
||||
insert(X, [Y | Rest]) -> [Y | insert(X, Rest)].
|
||||
|
||||
%% segment_path/3 — charlist path to the Idx'th segment file.
|
||||
segment_path(ActorId, BasePath, Idx) ->
|
||||
base_chars(BasePath) ++ [$/] ++ atom_to_list(ActorId)
|
||||
++ [$-] ++ pad_int(Idx, 6) ++ [$., $l, $o, $g].
|
||||
|
||||
base_chars(B) when is_binary(B) -> binary_to_list(B);
|
||||
base_chars(L) when is_list(L) -> L.
|
||||
|
||||
%% Zero-pad an integer to Width digits as a charlist.
|
||||
pad_int(N, Width) ->
|
||||
Cs = integer_to_list(N),
|
||||
pad_left(Cs, Width).
|
||||
|
||||
pad_left(Cs, Width) ->
|
||||
case length(Cs) >= Width of
|
||||
true -> Cs;
|
||||
false -> pad_left([$0 | Cs], Width)
|
||||
end.
|
||||
|
||||
write_segment(Path, Entries) ->
|
||||
Frames = [frame(term_codec:encode(E)) || E <- Entries],
|
||||
file:write_file(Path, list_to_binary(Frames)).
|
||||
|
||||
%% frame/1 — prepend 4-byte big-endian length to Payload.
|
||||
frame(Payload) when is_binary(Payload) ->
|
||||
L = byte_size(Payload),
|
||||
B3 = (L div 16777216) rem 256,
|
||||
B2 = (L div 65536) rem 256,
|
||||
B1 = (L div 256) rem 256,
|
||||
B0 = L rem 256,
|
||||
[B3, B2, B1, B0, Payload].
|
||||
|
||||
try_read_segment(Path) ->
|
||||
case file:read_file(Path) of
|
||||
{ok, Bin} ->
|
||||
try {ok, decode_frames(binary_to_list(Bin), [])}
|
||||
catch
|
||||
throw:Reason -> {error, {corrupt, Reason}};
|
||||
error:Reason -> {error, {corrupt, Reason}}
|
||||
end;
|
||||
{error, enoent} ->
|
||||
{ok, []};
|
||||
{error, R} ->
|
||||
{error, {read, R}}
|
||||
end.
|
||||
|
||||
decode_frames([], Acc) ->
|
||||
lists:reverse(Acc);
|
||||
decode_frames([B3, B2, B1, B0 | Rest], Acc) ->
|
||||
Len = B3 * 16777216 + B2 * 65536 + B1 * 256 + B0,
|
||||
{Payload, Rest2} = take_n(Len, Rest),
|
||||
case term_codec:decode(list_to_binary(Payload)) of
|
||||
{ok, Term, _} -> decode_frames(Rest2, [Term | Acc]);
|
||||
{error, R} -> throw({decode, R})
|
||||
end;
|
||||
decode_frames(_, _) ->
|
||||
throw(truncated_header).
|
||||
|
||||
take_n(0, R) -> {[], R};
|
||||
take_n(N, [H | T]) ->
|
||||
{Hs, Tl} = take_n(N - 1, T),
|
||||
{[H | Hs], Tl};
|
||||
take_n(_, []) ->
|
||||
throw(truncated_body).
|
||||
|
||||
%% --- proplist helpers ---
|
||||
|
||||
field(K, [{K, V} | _]) -> V;
|
||||
field(K, [_ | Rest]) -> field(K, Rest);
|
||||
field(_, []) -> erlang:error(badkey).
|
||||
|
||||
lookup(K, [{K, V} | _]) -> V;
|
||||
lookup(K, [_ | Rest]) -> lookup(K, Rest);
|
||||
lookup(_, []) -> undefined.
|
||||
|
||||
replace_field(K, V, []) -> [{K, V}];
|
||||
replace_field(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||
replace_field(K, V, [P | Rest]) -> [P | replace_field(K, V, Rest)].
|
||||
|
||||
proplist_get(K, [{K, V} | _], _) -> V;
|
||||
proplist_get(K, [_ | Rest], Default) -> proplist_get(K, Rest, Default);
|
||||
proplist_get(_, [], Default) -> Default.
|
||||
@@ -1,85 +0,0 @@
|
||||
-module(log_server).
|
||||
-behaviour(gen_server).
|
||||
-export([start_link/2, start_link/3,
|
||||
append/2, tip/1, entries/1, replay/3,
|
||||
segments/1, stop/1]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||
|
||||
%% Step 3c.b — gen_server in front of `log` that owns a single
|
||||
%% per-actor disk-backed log state and serialises concurrent
|
||||
%% appenders through `gen_server:call`.
|
||||
%%
|
||||
%% Architecture: the pure `log` module from Step 3c.a remains the
|
||||
%% canonical substrate (open_disk, append, tip, replay, entries,
|
||||
%% segments). This wrapper owns one log state per process; every
|
||||
%% public op (append/tip/entries/replay/segments) routes through
|
||||
%% gen_server:call so that the on-disk segment writer sees one
|
||||
%% append at a time, regardless of how many writer processes are
|
||||
%% pushing concurrently.
|
||||
%%
|
||||
%% Port notes carried from Step 5b's registry_server:
|
||||
%% * `gen_server:start_link/2` returns the raw Pid, not `{ok,Pid}`.
|
||||
%% * Spawned processes don't survive across separate
|
||||
%% `erlang-eval-ast` invocations — every concurrency test has
|
||||
%% to start the server, spin writers, join them, and assert all
|
||||
%% within one eval expression.
|
||||
%%
|
||||
%% API takes the server Pid (not a registered name) so multiple
|
||||
%% per-actor servers can coexist without colliding on the registry.
|
||||
|
||||
%% --- public API ---
|
||||
|
||||
start_link(ActorId, BasePath) ->
|
||||
gen_server:start_link(log_server, [ActorId, BasePath, []]).
|
||||
|
||||
start_link(ActorId, BasePath, Opts) ->
|
||||
gen_server:start_link(log_server, [ActorId, BasePath, Opts]).
|
||||
|
||||
append(Pid, Activity) ->
|
||||
gen_server:call(Pid, {append, Activity}).
|
||||
|
||||
tip(Pid) ->
|
||||
gen_server:call(Pid, tip).
|
||||
|
||||
entries(Pid) ->
|
||||
gen_server:call(Pid, entries).
|
||||
|
||||
replay(Pid, InitAcc, Fun) ->
|
||||
%% The fold runs server-side so the state stays consistent
|
||||
%% with concurrent writers; the caller's Fun is closed over
|
||||
%% the message and shipped opaque through gen_server:call.
|
||||
gen_server:call(Pid, {replay, InitAcc, Fun}).
|
||||
|
||||
segments(Pid) ->
|
||||
gen_server:call(Pid, segments).
|
||||
|
||||
stop(Pid) ->
|
||||
gen_server:call(Pid, '$gen_stop').
|
||||
|
||||
%% --- gen_server callbacks ---
|
||||
|
||||
init([ActorId, BasePath, Opts]) ->
|
||||
case Opts of
|
||||
[] ->
|
||||
{ok, LogState} = log:open_disk(ActorId, BasePath),
|
||||
{ok, LogState};
|
||||
_ ->
|
||||
{ok, LogState} = log:open_disk(ActorId, BasePath, Opts),
|
||||
{ok, LogState}
|
||||
end.
|
||||
|
||||
handle_call({append, Activity}, _From, State) ->
|
||||
{ok, NewState, Seq} = log:append(State, Activity),
|
||||
{reply, {ok, Seq}, NewState};
|
||||
handle_call(tip, _From, State) ->
|
||||
{reply, log:tip(State), State};
|
||||
handle_call(entries, _From, State) ->
|
||||
{reply, log:entries(State), State};
|
||||
handle_call({replay, InitAcc, Fun}, _From, State) ->
|
||||
{reply, log:replay(State, InitAcc, Fun), State};
|
||||
handle_call(segments, _From, State) ->
|
||||
{reply, log:segments(State), State}.
|
||||
|
||||
handle_cast(_, S) -> {noreply, S}.
|
||||
|
||||
handle_info(_, S) -> {noreply, S}.
|
||||
@@ -1,24 +0,0 @@
|
||||
-module(nx_cid).
|
||||
-export([from_sx/1, to_string/1, from_string/1, equals/2]).
|
||||
|
||||
%% The kernel-side CID wrapper. The host BIF `cid:to_string/1` already
|
||||
%% produces a canonical CIDv1 (raw codec, sha2-256 multihash) over the
|
||||
%% deterministic textual form of any term (er-format-value); we expose
|
||||
%% it under the kernel namespace and add the equality + round-trip
|
||||
%% helpers the rest of the kernel needs.
|
||||
%%
|
||||
%% Naming note: the BIF module is `cid`, so we use `nx_cid` to avoid
|
||||
%% shadowing. Plans/fed-sx-milestone-1.md §Step 1 spells the file as
|
||||
%% `cid.erl`; the briefing flags Erlang snippets as illustrative.
|
||||
|
||||
from_sx(V) ->
|
||||
cid:to_string(V).
|
||||
|
||||
to_string(Cid) ->
|
||||
Cid.
|
||||
|
||||
from_string(S) ->
|
||||
S.
|
||||
|
||||
equals(A, B) ->
|
||||
A =:= B.
|
||||
@@ -1,451 +0,0 @@
|
||||
-module(nx_kernel).
|
||||
-behaviour(gen_server).
|
||||
|
||||
%% Pure-functional API
|
||||
-export([new/0, new/3,
|
||||
add_actor/4, has_actor/2, actors/1, actor_count/1,
|
||||
publish/2, publish/3,
|
||||
bootstrap_actor/4,
|
||||
actor_id/1, log_state/1, log_tip/1,
|
||||
key_spec/1, actor_state/1, projections/1, next_published/1,
|
||||
actor_log_state/2, actor_log_tip/2,
|
||||
actor_inbox_state/2, actor_inbox_tip/2,
|
||||
append_to_actor_inbox/3,
|
||||
actor_key_spec/2, actor_state/2, actor_projections/2,
|
||||
actor_next_published/2, actor_bucket/2,
|
||||
with_projections/2, with_actor_projections/3,
|
||||
next_actor_seq/1]).
|
||||
|
||||
%% gen_server API
|
||||
-export([start_link/3, publish/1, query/0, log_tip/0,
|
||||
with_projections/1, stop/0,
|
||||
add_actor/3, publish_to/2, log_tip_for/1, log_state_for/1,
|
||||
inbox_tip_for/1, inbox_state_for/1, append_inbox/2,
|
||||
actors/0, state_for/1, bucket_for/1,
|
||||
with_projections_for/2,
|
||||
bootstrap_actor/3]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||
|
||||
%% Kernel orchestrator — the long-lived runtime state held by the
|
||||
%% running fed-sx instance. Step 1 (m2) refactor: state is now
|
||||
%% per-actor bucketed so one kernel hosts any number of actors.
|
||||
%%
|
||||
%% New state shape (property list):
|
||||
%% [{actors, [{ActorId, ActorBucket}, ...]},
|
||||
%% {next_actor_seq, NextN}]
|
||||
%%
|
||||
%% ActorBucket = [{key_spec, KS},
|
||||
%% {actor_state, AS},
|
||||
%% {log, L},
|
||||
%% {projections, [Name]},
|
||||
%% {next_published, NextSeq}]
|
||||
%%
|
||||
%% Legacy single-actor accessors (actor_id/1, key_spec/1, etc.)
|
||||
%% continue to read from the first registered actor — keeps every
|
||||
%% pre-m2 test passing through bootstrap:start/3.
|
||||
%%
|
||||
%% next_actor_seq is a monotonic counter handed out to add_actor for
|
||||
%% future use (e.g. per-actor URL paths in Step 4). It's not yet
|
||||
%% read by the rest of the kernel.
|
||||
|
||||
%% ── Pure-functional API ──────────────────────────────────────────
|
||||
|
||||
new() ->
|
||||
[{actors, []}, {next_actor_seq, 1}].
|
||||
|
||||
new(ActorId, KeySpec, ActorStateProplist) ->
|
||||
{ok, S} = add_actor(ActorId, KeySpec, ActorStateProplist, new()),
|
||||
S.
|
||||
|
||||
add_actor(ActorId, KeySpec, AS, State) ->
|
||||
Actors = field(actors, State),
|
||||
case has_keyed(ActorId, Actors) of
|
||||
true ->
|
||||
{error, already_present};
|
||||
false ->
|
||||
{ok, L0} = log:open(ActorId, base_stub()),
|
||||
{ok, I0} = log:open(ActorId, inbox_base_stub()),
|
||||
Bucket = [{key_spec, KeySpec},
|
||||
{actor_state, AS},
|
||||
{log, L0},
|
||||
{actor_inbox, I0},
|
||||
{projections, []},
|
||||
{next_published, 1}],
|
||||
Seq = field(next_actor_seq, State),
|
||||
State1 = set(actors, Actors ++ [{ActorId, Bucket}], State),
|
||||
State2 = set(next_actor_seq, Seq + 1, State1),
|
||||
{ok, State2}
|
||||
end.
|
||||
|
||||
has_actor(ActorId, State) ->
|
||||
has_keyed(ActorId, field(actors, State)).
|
||||
|
||||
actors(State) ->
|
||||
[Id || {Id, _Bucket} <- field(actors, State)].
|
||||
|
||||
actor_count(State) ->
|
||||
length(field(actors, State)).
|
||||
|
||||
next_actor_seq(State) ->
|
||||
field(next_actor_seq, State).
|
||||
|
||||
actor_bucket(ActorId, State) ->
|
||||
find_keyed(ActorId, field(actors, State)).
|
||||
|
||||
%% publish/3 — per-actor publish.
|
||||
publish(ActorId, Request, State) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{error, no_actor} ->
|
||||
{error, no_actor, State};
|
||||
{ok, Bucket} ->
|
||||
P = field(next_published, Bucket),
|
||||
Ctx = [{actor_id, ActorId},
|
||||
{published, P},
|
||||
{key_spec, field(key_spec, Bucket)},
|
||||
{actor_state, field(actor_state, Bucket)},
|
||||
{log, field(log, Bucket)},
|
||||
{projections, field(projections, Bucket)}],
|
||||
case outbox:publish(Request, Ctx) of
|
||||
{ok, Result, NewLog} ->
|
||||
B1 = set(log, NewLog, Bucket),
|
||||
B2 = set(next_published, P + 1, B1),
|
||||
NewState = set_bucket(ActorId, B2, State),
|
||||
{ok, Result, NewState};
|
||||
{error, Reason, _} ->
|
||||
{error, Reason, State}
|
||||
end
|
||||
end.
|
||||
|
||||
%% publish/2 — legacy single-actor publish; routes to first actor.
|
||||
publish(Request, State) ->
|
||||
case actors(State) of
|
||||
[] -> {error, no_actor, State};
|
||||
[First | _] -> publish(First, Request, State)
|
||||
end.
|
||||
|
||||
%% bootstrap_actor/4 — register an actor bucket and immediately
|
||||
%% publish a Create{Person|Service|Group} as that actor's first
|
||||
%% activity. Profile carries the object fields plus :public_keys.
|
||||
%% Returns {ok, Result, NewState} where Result has the published
|
||||
%% Create's CID, or {error, Reason, State} on validation halt.
|
||||
|
||||
bootstrap_actor(ActorId, Profile, KeySpec, State) ->
|
||||
PublicKeys = case field(public_keys, Profile) of
|
||||
nil -> [];
|
||||
KS -> KS
|
||||
end,
|
||||
AS = [{public_keys, PublicKeys}],
|
||||
case add_actor(ActorId, KeySpec, AS, State) of
|
||||
{ok, State1} ->
|
||||
ActorType = case field(type, Profile) of
|
||||
nil -> person;
|
||||
T -> T
|
||||
end,
|
||||
Object = [{type, ActorType}] ++ collect_profile_fields(
|
||||
[name, preferredUsername, summary, icon, public_keys],
|
||||
Profile),
|
||||
Request = [{type, create}, {object, Object}],
|
||||
publish(ActorId, Request, State1);
|
||||
{error, Reason} ->
|
||||
{error, Reason, State}
|
||||
end.
|
||||
|
||||
collect_profile_fields([], _) -> [];
|
||||
collect_profile_fields([F | Rest], Profile) ->
|
||||
case field(F, Profile) of
|
||||
nil -> collect_profile_fields(Rest, Profile);
|
||||
V -> [{F, V} | collect_profile_fields(Rest, Profile)]
|
||||
end.
|
||||
|
||||
with_actor_projections(ActorId, Names, State) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{error, no_actor} ->
|
||||
{error, no_actor};
|
||||
{ok, Bucket} ->
|
||||
B1 = set(projections, Names, Bucket),
|
||||
{ok, set_bucket(ActorId, B1, State)}
|
||||
end.
|
||||
|
||||
with_projections(Names, State) ->
|
||||
case actors(State) of
|
||||
[] -> State;
|
||||
[First | _] ->
|
||||
{ok, NewState} = with_actor_projections(First, Names, State),
|
||||
NewState
|
||||
end.
|
||||
|
||||
%% Per-actor accessors
|
||||
|
||||
actor_log_state(ActorId, State) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{ok, B} -> {ok, field(log, B)};
|
||||
{error, _} -> {error, no_actor}
|
||||
end.
|
||||
|
||||
actor_log_tip(ActorId, State) ->
|
||||
case actor_log_state(ActorId, State) of
|
||||
{ok, L} -> log:tip(L);
|
||||
{error, _} -> nil
|
||||
end.
|
||||
|
||||
actor_inbox_state(ActorId, State) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{ok, B} -> {ok, field(actor_inbox, B)};
|
||||
{error, _} -> {error, no_actor}
|
||||
end.
|
||||
|
||||
actor_inbox_tip(ActorId, State) ->
|
||||
case actor_inbox_state(ActorId, State) of
|
||||
{ok, I} -> log:tip(I);
|
||||
{error, _} -> nil
|
||||
end.
|
||||
|
||||
%% append_to_actor_inbox/3 — pure-functional inbox append. Mirrors
|
||||
%% publish/3's bucket-update shape; the activity is already signed
|
||||
%% + validated by the time it lands here (Step 5's pipeline handles
|
||||
%% sig verify + replay before this call).
|
||||
|
||||
append_to_actor_inbox(ActorId, Activity, State) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{error, no_actor} ->
|
||||
{error, no_actor, State};
|
||||
{ok, Bucket} ->
|
||||
Inbox = field(actor_inbox, Bucket),
|
||||
{ok, NewInbox, _Seq} = log:append(Inbox, Activity),
|
||||
B1 = set(actor_inbox, NewInbox, Bucket),
|
||||
{ok, log:tip(NewInbox), set_bucket(ActorId, B1, State)}
|
||||
end.
|
||||
|
||||
actor_key_spec(ActorId, State) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{ok, B} -> {ok, field(key_spec, B)};
|
||||
{error, _} -> {error, no_actor}
|
||||
end.
|
||||
|
||||
actor_state(ActorId, State) when is_list(State), is_atom(ActorId) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{ok, B} -> {ok, field(actor_state, B)};
|
||||
{error, _} -> {error, no_actor}
|
||||
end.
|
||||
|
||||
actor_projections(ActorId, State) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{ok, B} -> {ok, field(projections, B)};
|
||||
{error, _} -> {error, no_actor}
|
||||
end.
|
||||
|
||||
actor_next_published(ActorId, State) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{ok, B} -> {ok, field(next_published, B)};
|
||||
{error, _} -> {error, no_actor}
|
||||
end.
|
||||
|
||||
%% Legacy single-actor accessors — read from first bucket. Keeps
|
||||
%% every M1 test (smoke_app_pure, bootstrap_start, http_publish,
|
||||
%% nx_kernel_server, http_post_format) passing.
|
||||
|
||||
actor_id(State) ->
|
||||
case field(actors, State) of
|
||||
[] -> nil;
|
||||
[{First, _Bucket} | _] -> First
|
||||
end.
|
||||
|
||||
key_spec(State) ->
|
||||
bucket_field(key_spec, State).
|
||||
|
||||
actor_state(State) ->
|
||||
bucket_field(actor_state, State).
|
||||
|
||||
log_state(State) ->
|
||||
bucket_field(log, State).
|
||||
|
||||
log_tip(State) ->
|
||||
log:tip(log_state(State)).
|
||||
|
||||
projections(State) ->
|
||||
case bucket_field(projections, State) of
|
||||
nil -> [];
|
||||
Ps -> Ps
|
||||
end.
|
||||
|
||||
next_published(State) ->
|
||||
bucket_field(next_published, State).
|
||||
|
||||
%% ── Internal helpers ──────────────────────────────────────────────
|
||||
|
||||
base_stub() ->
|
||||
<<98,97,115,101,95,115,116,117,98>>.
|
||||
|
||||
%% "inbox_base_stub" — distinct path stub so the in-memory log
|
||||
%% module's open/2 returns a fresh log state for the per-actor
|
||||
%% inbox bucket. Disk paths will namespace on this once Step 3b
|
||||
%% on-disk persistence is reactivated for inbox buckets.
|
||||
inbox_base_stub() ->
|
||||
<<105,110,98,111,120,95,115,116,117,98>>.
|
||||
|
||||
bucket_field(Key, State) ->
|
||||
case field(actors, State) of
|
||||
[] -> nil;
|
||||
[{_First, Bucket} | _] -> field(Key, Bucket)
|
||||
end.
|
||||
|
||||
set_bucket(ActorId, NewBucket, State) ->
|
||||
Actors = field(actors, State),
|
||||
NewActors = set_keyed(ActorId, NewBucket, Actors),
|
||||
set(actors, NewActors, State).
|
||||
|
||||
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)];
|
||||
set_keyed(_, _, []) -> [].
|
||||
|
||||
has_keyed(_, []) -> false;
|
||||
has_keyed(K, [{K, _} | _]) -> true;
|
||||
has_keyed(K, [_ | Rest]) -> has_keyed(K, Rest).
|
||||
|
||||
find_keyed(_, []) -> {error, no_actor};
|
||||
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||
|
||||
field(K, [{K, V} | _]) -> V;
|
||||
field(K, [_ | Rest]) -> field(K, Rest);
|
||||
field(_, []) -> nil.
|
||||
|
||||
set(K, V, []) -> [{K, V}];
|
||||
set(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||
set(K, V, [P | Rest]) -> [P | set(K, V, Rest)].
|
||||
|
||||
%% ── gen_server wrapper ──────────────────────────────────────────
|
||||
%%
|
||||
%% Mirrors the registry / projection gen_server patterns from
|
||||
%% Steps 5b and 7b. Same port quirks: raw Pid return, no `?MODULE`
|
||||
%% macro, spawned processes don't persist across separate
|
||||
%% erlang-eval-ast calls — tests inline start_link with operations.
|
||||
%%
|
||||
%% Step 1b (m2) adds multi-actor gen_server calls:
|
||||
%% add_actor/3, publish_to/2, log_tip_for/1, actors/0, state_for/1,
|
||||
%% with_projections_for/2 — all delegating to the pure-functional
|
||||
%% bucket APIs. Existing single-actor calls (publish/1, log_tip/0,
|
||||
%% with_projections/1) continue to route through bucket 0.
|
||||
|
||||
start_link(ActorId, KeySpec, ActorStateProplist) ->
|
||||
Pid = gen_server:start_link(nx_kernel,
|
||||
[ActorId, KeySpec, ActorStateProplist]),
|
||||
erlang:register(nx_kernel, Pid),
|
||||
Pid.
|
||||
|
||||
stop() ->
|
||||
R = gen_server:call(nx_kernel, '$gen_stop'),
|
||||
erlang:unregister(nx_kernel),
|
||||
R.
|
||||
|
||||
publish(Request) ->
|
||||
gen_server:call(nx_kernel, {publish, Request}).
|
||||
|
||||
query() ->
|
||||
gen_server:call(nx_kernel, get_state).
|
||||
|
||||
log_tip() ->
|
||||
gen_server:call(nx_kernel, get_log_tip).
|
||||
|
||||
with_projections(Names) ->
|
||||
gen_server:call(nx_kernel, {set_projections, Names}).
|
||||
|
||||
%% Step 1b — multi-actor gen_server calls.
|
||||
|
||||
add_actor(ActorId, KeySpec, AS) ->
|
||||
gen_server:call(nx_kernel, {add_actor, ActorId, KeySpec, AS}).
|
||||
|
||||
publish_to(ActorId, Request) ->
|
||||
gen_server:call(nx_kernel, {publish_to, ActorId, Request}).
|
||||
|
||||
log_tip_for(ActorId) ->
|
||||
gen_server:call(nx_kernel, {log_tip_for, ActorId}).
|
||||
|
||||
log_state_for(ActorId) ->
|
||||
gen_server:call(nx_kernel, {log_state_for, ActorId}).
|
||||
|
||||
inbox_tip_for(ActorId) ->
|
||||
gen_server:call(nx_kernel, {inbox_tip_for, ActorId}).
|
||||
|
||||
inbox_state_for(ActorId) ->
|
||||
gen_server:call(nx_kernel, {inbox_state_for, ActorId}).
|
||||
|
||||
append_inbox(ActorId, Activity) ->
|
||||
gen_server:call(nx_kernel, {append_inbox, ActorId, Activity}).
|
||||
|
||||
actors() ->
|
||||
gen_server:call(nx_kernel, get_actors).
|
||||
|
||||
state_for(ActorId) ->
|
||||
gen_server:call(nx_kernel, {state_for, ActorId}).
|
||||
|
||||
bucket_for(ActorId) ->
|
||||
gen_server:call(nx_kernel, {bucket_for, ActorId}).
|
||||
|
||||
with_projections_for(ActorId, Names) ->
|
||||
gen_server:call(nx_kernel, {set_projections_for, ActorId, Names}).
|
||||
|
||||
bootstrap_actor(ActorId, Profile, KeySpec) ->
|
||||
gen_server:call(nx_kernel, {bootstrap_actor, ActorId, Profile, KeySpec}).
|
||||
|
||||
%% gen_server callbacks
|
||||
|
||||
init([ActorId, KeySpec, AS]) ->
|
||||
{ok, new(ActorId, KeySpec, AS)}.
|
||||
|
||||
handle_call({publish, Request}, _From, State) ->
|
||||
case publish(Request, State) of
|
||||
{ok, Result, NewState} ->
|
||||
{reply, {ok, Result}, NewState};
|
||||
{error, Reason, SameState} ->
|
||||
{reply, {error, Reason}, SameState}
|
||||
end;
|
||||
handle_call(get_state, _From, State) ->
|
||||
{reply, State, State};
|
||||
handle_call(get_log_tip, _From, State) ->
|
||||
{reply, log_tip(State), State};
|
||||
handle_call({set_projections, Names}, _From, State) ->
|
||||
{reply, ok, with_projections(Names, State)};
|
||||
handle_call({add_actor, ActorId, KeySpec, AS}, _From, State) ->
|
||||
case add_actor(ActorId, KeySpec, AS, State) of
|
||||
{ok, NewState} -> {reply, ok, NewState};
|
||||
{error, Reason} -> {reply, {error, Reason}, State}
|
||||
end;
|
||||
handle_call({publish_to, ActorId, Request}, _From, State) ->
|
||||
case publish(ActorId, Request, State) of
|
||||
{ok, Result, NewState} -> {reply, {ok, Result}, NewState};
|
||||
{error, Reason, SameState} -> {reply, {error, Reason}, SameState}
|
||||
end;
|
||||
handle_call({log_tip_for, ActorId}, _From, State) ->
|
||||
{reply, actor_log_tip(ActorId, State), State};
|
||||
handle_call({log_state_for, ActorId}, _From, State) ->
|
||||
{reply, actor_log_state(ActorId, State), State};
|
||||
handle_call({inbox_tip_for, ActorId}, _From, State) ->
|
||||
{reply, actor_inbox_tip(ActorId, State), State};
|
||||
handle_call({inbox_state_for, ActorId}, _From, State) ->
|
||||
{reply, actor_inbox_state(ActorId, State), State};
|
||||
handle_call({append_inbox, ActorId, Activity}, _From, State) ->
|
||||
case append_to_actor_inbox(ActorId, Activity, State) of
|
||||
{ok, Tip, NewState} -> {reply, {ok, Tip}, NewState};
|
||||
{error, Reason, Same} -> {reply, {error, Reason}, Same}
|
||||
end;
|
||||
handle_call(get_actors, _From, State) ->
|
||||
{reply, actors(State), State};
|
||||
handle_call({state_for, ActorId}, _From, State) ->
|
||||
{reply, actor_state(ActorId, State), State};
|
||||
handle_call({bucket_for, ActorId}, _From, State) ->
|
||||
{reply, actor_bucket(ActorId, State), State};
|
||||
handle_call({set_projections_for, ActorId, Names}, _From, State) ->
|
||||
case with_actor_projections(ActorId, Names, State) of
|
||||
{ok, NewState} -> {reply, ok, NewState};
|
||||
{error, Reason} -> {reply, {error, Reason}, State}
|
||||
end;
|
||||
handle_call({bootstrap_actor, ActorId, Profile, KeySpec}, _From, State) ->
|
||||
case bootstrap_actor(ActorId, Profile, KeySpec, State) of
|
||||
{ok, Result, NewState} -> {reply, {ok, Result}, NewState};
|
||||
{error, Reason, SameState} -> {reply, {error, Reason}, SameState}
|
||||
end.
|
||||
|
||||
handle_cast(_, S) -> {noreply, S}.
|
||||
|
||||
handle_info(_, S) -> {noreply, S}.
|
||||
@@ -1,188 +0,0 @@
|
||||
-module(outbox).
|
||||
-export([construct/4, sign/2, cid_of/1, publish/2]).
|
||||
|
||||
%% Outbox envelope construction + signing per design §3.1.
|
||||
%%
|
||||
%% construct/4 builds an unsigned activity envelope from caller-supplied
|
||||
%% (Type, ActorId, Published, Object). The envelope's `:id` field is
|
||||
%% derived from the host `cid:to_string` BIF over a skeleton tag, so
|
||||
%% recipients can address the activity by its content hash. The
|
||||
%% returned property list is the canonical key-sorted form that
|
||||
%% `envelope:canonical_bytes/1` operates on.
|
||||
%%
|
||||
%% sign/2 takes the unsigned envelope plus a KeySpec proplist that
|
||||
%% mirrors a `public_keys` entry: `[{key_id, _}, {algorithm, _},
|
||||
%% {value, KeyMaterial}]`. It computes the v1 HMAC stand-in
|
||||
%% `crypto:hash(sha256, <<KeyMaterial/binary, CanonicalBytes/binary>>)`
|
||||
%% — the same scheme `envelope:verify_signature/2` checks — and
|
||||
%% appends a `:signature` pair.
|
||||
%%
|
||||
%% Real Ed25519 / RSA signing arrives in milestone 2 once
|
||||
%% `crypto:sign_ed25519/2` BIFs land; the API shape doesn't change.
|
||||
|
||||
%% construct/4 — Type and ActorId are atoms; Published is an
|
||||
%% integer timestamp the caller supplies (no clock BIF in this
|
||||
%% port; the HTTP layer / outbox:publish caller injects it).
|
||||
%% Object can be any term, including a property list of inner
|
||||
%% fields.
|
||||
construct(Type, ActorId, Published, Object) ->
|
||||
Skeleton = [{actor, ActorId},
|
||||
{object, Object},
|
||||
{published, Published},
|
||||
{type, Type}],
|
||||
Id = cid:to_string({activity_envelope, Skeleton}),
|
||||
[{actor, ActorId},
|
||||
{id, Id},
|
||||
{object, Object},
|
||||
{published, Published},
|
||||
{type, Type}].
|
||||
|
||||
%% sign/2 — KeySpec carries key_id, algorithm, value (key material).
|
||||
sign(Envelope, KeySpec) ->
|
||||
{ok, KeyId} = envelope:get_field(key_id, KeySpec),
|
||||
{ok, Alg} = envelope:get_field(algorithm, KeySpec),
|
||||
{ok, KM} = envelope:get_field(value, KeySpec),
|
||||
CB = envelope:canonical_bytes(Envelope),
|
||||
SigValue = crypto:hash(sha256, <<KM/binary, CB/binary>>),
|
||||
Sig = [{algorithm, Alg}, {key_id, KeyId}, {value, SigValue}],
|
||||
Envelope ++ [{signature, Sig}].
|
||||
|
||||
%% cid_of/1 — extract the :id field from a constructed envelope.
|
||||
%% Convenience for callers that don't want to thread the CID
|
||||
%% separately when both the envelope and its ID matter.
|
||||
cid_of(Envelope) ->
|
||||
{ok, Id} = envelope:get_field(id, Envelope),
|
||||
Id.
|
||||
|
||||
%% publish/2 — the outbound activity pipeline orchestrator.
|
||||
%%
|
||||
%% Request shape: [{type, T}, {object, O}]
|
||||
%% Context shape: [{actor_id, A}, {published, P}, {key_spec, KS},
|
||||
%% {actor_state, AS}, {log, L}]
|
||||
%%
|
||||
%% Returns:
|
||||
%% {ok, [{cid, Cid}, {activity, Signed}], NewLog} — happy path
|
||||
%% {error, Reason, LogState} — validation halted
|
||||
%%
|
||||
%% Stages run in order: envelope shape, signature, replay. The
|
||||
%% replay check uses the log state pre-append, so if the caller
|
||||
%% publishes the same Request twice with the same Published
|
||||
%% timestamp the second call halts with {error, replay, _}.
|
||||
%%
|
||||
%% Projection-scheduler dispatch (the async fold the design calls
|
||||
%% for) is deferred to Step 7 — once the projection gen_server
|
||||
%% exists, this function will broadcast `Signed` to it.
|
||||
|
||||
publish(Request, Context) ->
|
||||
Type = envelope_field(type, Request),
|
||||
Object = envelope_field(object, Request),
|
||||
ActorId = envelope_field(actor_id, Context),
|
||||
Published = envelope_field(published, Context),
|
||||
KeySpec = envelope_field(key_spec, Context),
|
||||
ActorState = envelope_field(actor_state, Context),
|
||||
LogState = envelope_field(log, Context),
|
||||
Unsigned = construct(Type, ActorId, Published, Object),
|
||||
Signed = sign(Unsigned, KeySpec),
|
||||
Stages = [
|
||||
fun (A) -> pipeline:stage_envelope(A) end,
|
||||
pipeline:stage_signature(ActorState),
|
||||
pipeline:stage_replay(LogState)
|
||||
],
|
||||
case pipeline:run_stages(Signed, Stages) of
|
||||
ok ->
|
||||
{ok, NewLog, _Seq} = log:append(LogState, Signed),
|
||||
broadcast(Signed, envelope_field(projections, Context)),
|
||||
DeliverySet = compute_delivery_set(Request, Signed, Context),
|
||||
dispatch_deliveries(Signed, DeliverySet, Context),
|
||||
Result = [{cid, cid_of(Signed)},
|
||||
{activity, Signed},
|
||||
{delivery_set, DeliverySet}],
|
||||
{ok, Result, NewLog};
|
||||
{error, Reason} ->
|
||||
{error, Reason, LogState}
|
||||
end.
|
||||
|
||||
%% dispatch_deliveries/3 — Step 8d. For each ActorId in the
|
||||
%% delivery_set, enqueue the signed activity onto the matching
|
||||
%% delivery_worker if the worker is registered under that atom.
|
||||
%% Missing workers are silently skipped — lazy creation belongs
|
||||
%% to the kernel manager (later in Step 8). The Context
|
||||
%% `:dispatch_deliveries` field gates the call so existing
|
||||
%% outbox callers that don't yet care about delivery (e.g. all of
|
||||
%% M1's tests) stay back-compat.
|
||||
%%
|
||||
%% No-op when:
|
||||
%% - :dispatch_deliveries is absent or not the atom true
|
||||
%% - delivery_set is []
|
||||
%% - the per-peer worker isn't registered (whereis returns undefined)
|
||||
|
||||
dispatch_deliveries(Activity, DeliverySet, Context) ->
|
||||
case envelope_field(dispatch_deliveries, Context) of
|
||||
true -> enqueue_each(Activity, DeliverySet);
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
enqueue_each(_Activity, []) -> ok;
|
||||
enqueue_each(Activity, [PeerId | Rest]) when is_atom(PeerId) ->
|
||||
case erlang:whereis(PeerId) of
|
||||
undefined -> enqueue_each(Activity, Rest);
|
||||
_ ->
|
||||
delivery_worker:enqueue(PeerId, Activity),
|
||||
enqueue_each(Activity, Rest)
|
||||
end;
|
||||
enqueue_each(Activity, [_ | Rest]) ->
|
||||
enqueue_each(Activity, Rest).
|
||||
|
||||
%% compute_delivery_set/3 — Step 7c. Pulls the audience-resolved
|
||||
%% recipient list off the Request's `:to` / `:cc` fields (the
|
||||
%% envelope itself doesn't carry them — construct/4 only takes
|
||||
%% type / actor / published / object). Context's optional
|
||||
%% `:follower_graph` field carries a follower_graph state for
|
||||
%% `public` / `followers` audience expansion; absent -> empty graph,
|
||||
%% so explicit `:to` / `:cc` lists still resolve. Synthesises a
|
||||
%% recipient-shaped envelope from Request + Signed so the existing
|
||||
%% delivery:delivery_set/3 (which reads `:actor`, `:to`, `:cc`) can
|
||||
%% process it as-is.
|
||||
%%
|
||||
%% Step 8's delivery-queue worker reads `{delivery_set, [ActorId, ...]}`
|
||||
%% off the publish result and routes one HTTP POST per entry.
|
||||
|
||||
compute_delivery_set(Request, Signed, Context) ->
|
||||
Graph = case envelope_field(follower_graph, Context) of
|
||||
nil -> follower_graph:new();
|
||||
G -> G
|
||||
end,
|
||||
Recipients = recipients_envelope(Request, Signed),
|
||||
delivery:delivery_set(Recipients, [], Graph).
|
||||
|
||||
recipients_envelope(Request, Signed) ->
|
||||
Base = case envelope:get_field(actor, Signed) of
|
||||
{ok, A} -> [{actor, A}];
|
||||
_ -> []
|
||||
end,
|
||||
To = case envelope:get_field(to, Request) of
|
||||
{ok, T} -> [{to, T}];
|
||||
_ -> []
|
||||
end,
|
||||
Cc = case envelope:get_field(cc, Request) of
|
||||
{ok, C} -> [{cc, C}];
|
||||
_ -> []
|
||||
end,
|
||||
Base ++ To ++ Cc.
|
||||
|
||||
%% broadcast/2 — fire-and-forget cast to each named projection.
|
||||
%% Missing/nil/empty list is a no-op; the publish API does not
|
||||
%% require projections to exist. Activity is the post-sign Signed
|
||||
%% envelope (same value that landed in the log).
|
||||
broadcast(_Activity, nil) -> ok;
|
||||
broadcast(_Activity, []) -> ok;
|
||||
broadcast(Activity, [Name | Rest]) ->
|
||||
projection:async_fold(Name, Activity),
|
||||
broadcast(Activity, Rest).
|
||||
|
||||
envelope_field(K, PL) ->
|
||||
case envelope:get_field(K, PL) of
|
||||
{ok, V} -> V;
|
||||
not_found -> nil
|
||||
end.
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
-module(peer_actors).
|
||||
-export([new/0, lookup/2, store/3, evict/2, peers/1,
|
||||
lookup_or_fetch/3,
|
||||
start_link/0, start_link/1, stop/0,
|
||||
lookup_srv/1, store_srv/2, lookup_or_fetch_srv/2,
|
||||
peers_srv/0, evict_srv/1]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||
-behaviour(gen_server).
|
||||
|
||||
%% Peer-actors cache. On first inbound from a new peer, the
|
||||
%% federation layer needs the peer's `:public_keys` (and eventually
|
||||
%% other actor-doc fields) to verify the inbound signature. Fetching
|
||||
%% the peer's actor doc on every inbound would be wasteful, so we
|
||||
%% cache the peer-AS keyed by ActorId atom. Per design §13.6 stale-
|
||||
%% key invalidation defers to v3 — for v2 entries are TTL-free.
|
||||
%%
|
||||
%% State shape (pure-functional):
|
||||
%% [{PeerActorId, PeerActorState}, ...]
|
||||
%%
|
||||
%% PeerActorState is the same shape that envelope:verify_signature/2
|
||||
%% reads — a proplist with :public_keys (a list of key proplists).
|
||||
%%
|
||||
%% lookup_or_fetch/3 is the load-bearing entry point: a miss invokes
|
||||
%% the caller-supplied FetchFn (1-arity, takes PeerActorId, returns
|
||||
%% {ok, PeerAS} | {error, Reason}). The cache stores successful
|
||||
%% fetches; errors do NOT poison the cache so the caller can retry.
|
||||
%%
|
||||
%% gen_server wrapper exposes the same API for the http inbox
|
||||
%% handler. Tests inline start_link with operations (same port quirks
|
||||
%% as registry / projection / nx_kernel).
|
||||
|
||||
%% ── Pure-functional API ─────────────────────────────────────────
|
||||
|
||||
new() -> [].
|
||||
|
||||
lookup(PeerId, State) ->
|
||||
case find_keyed(PeerId, State) of
|
||||
{ok, PeerAS} -> {ok, PeerAS};
|
||||
{error, _} -> not_found
|
||||
end.
|
||||
|
||||
store(PeerId, PeerAS, State) ->
|
||||
set_keyed(PeerId, PeerAS, State).
|
||||
|
||||
evict(PeerId, State) ->
|
||||
delete_keyed(PeerId, State).
|
||||
|
||||
peers(State) -> [Id || {Id, _AS} <- State].
|
||||
|
||||
%% lookup_or_fetch/3 — cache hit returns {ok, PeerAS, State}
|
||||
%% unchanged. Cache miss calls FetchFn; success path stores and
|
||||
%% returns {ok, PeerAS, NewState}; failure returns {error, Reason,
|
||||
%% State} so the caller knows the cache state and can retry on
|
||||
%% transient errors.
|
||||
|
||||
lookup_or_fetch(PeerId, FetchFn, State) ->
|
||||
case find_keyed(PeerId, State) of
|
||||
{ok, PeerAS} -> {ok, PeerAS, State};
|
||||
{error, _} ->
|
||||
case FetchFn(PeerId) of
|
||||
{ok, PeerAS} -> {ok, PeerAS, store(PeerId, PeerAS, State)};
|
||||
{error, Reason} -> {error, Reason, State};
|
||||
Other -> {error, {bad_fetch_return, Other}, State}
|
||||
end
|
||||
end.
|
||||
|
||||
%% ── gen_server wrapper ──────────────────────────────────────────
|
||||
%%
|
||||
%% Mirrors registry / projection / nx_kernel patterns. Registered
|
||||
%% name `peer_actors` so callers (http_server inbox handler) can
|
||||
%% find it without threading the Pid through Cfg.
|
||||
|
||||
start_link() ->
|
||||
start_link([]).
|
||||
|
||||
start_link(InitialState) ->
|
||||
Pid = gen_server:start_link(peer_actors, [InitialState]),
|
||||
erlang:register(peer_actors, Pid),
|
||||
Pid.
|
||||
|
||||
stop() ->
|
||||
R = gen_server:call(peer_actors, '$gen_stop'),
|
||||
erlang:unregister(peer_actors),
|
||||
R.
|
||||
|
||||
lookup_srv(PeerId) ->
|
||||
gen_server:call(peer_actors, {lookup, PeerId}).
|
||||
|
||||
store_srv(PeerId, PeerAS) ->
|
||||
gen_server:call(peer_actors, {store, PeerId, PeerAS}).
|
||||
|
||||
%% lookup_or_fetch_srv/2 — same shape as the pure form. FetchFn must
|
||||
%% be a 1-arity fun. Reply is {ok, PeerAS} on hit-or-fetched,
|
||||
%% {error, Reason} on fetch failure.
|
||||
|
||||
lookup_or_fetch_srv(PeerId, FetchFn) ->
|
||||
gen_server:call(peer_actors, {lookup_or_fetch, PeerId, FetchFn}).
|
||||
|
||||
peers_srv() ->
|
||||
gen_server:call(peer_actors, get_peers).
|
||||
|
||||
evict_srv(PeerId) ->
|
||||
gen_server:call(peer_actors, {evict, PeerId}).
|
||||
|
||||
%% gen_server callbacks
|
||||
|
||||
init([InitialState]) ->
|
||||
{ok, InitialState}.
|
||||
|
||||
handle_call({lookup, PeerId}, _From, State) ->
|
||||
{reply, lookup(PeerId, State), State};
|
||||
handle_call({store, PeerId, PeerAS}, _From, State) ->
|
||||
{reply, ok, store(PeerId, PeerAS, State)};
|
||||
handle_call({lookup_or_fetch, PeerId, FetchFn}, _From, State) ->
|
||||
case lookup_or_fetch(PeerId, FetchFn, State) of
|
||||
{ok, PeerAS, NewState} -> {reply, {ok, PeerAS}, NewState};
|
||||
{error, Reason, SameState} -> {reply, {error, Reason}, SameState}
|
||||
end;
|
||||
handle_call(get_peers, _From, State) ->
|
||||
{reply, peers(State), State};
|
||||
handle_call({evict, PeerId}, _From, State) ->
|
||||
{reply, ok, evict(PeerId, State)}.
|
||||
|
||||
handle_cast(_, S) -> {noreply, S}.
|
||||
|
||||
handle_info(_, S) -> {noreply, S}.
|
||||
|
||||
%% ── Internal helpers ────────────────────────────────────────────
|
||||
|
||||
find_keyed(_, []) -> {error, not_found};
|
||||
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||
|
||||
set_keyed(K, V, []) -> [{K, V}];
|
||||
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)].
|
||||
|
||||
delete_keyed(_, []) -> [];
|
||||
delete_keyed(K, [{K, _} | Rest]) -> Rest;
|
||||
delete_keyed(K, [P | Rest]) -> [P | delete_keyed(K, Rest)].
|
||||
@@ -1,167 +0,0 @@
|
||||
-module(pipeline).
|
||||
-export([run_stages/2,
|
||||
validate_inbound/1, validate_inbound/3,
|
||||
validate_outbound/1,
|
||||
inbound_stages/0, inbound_stages/2, outbound_stages/0,
|
||||
stage_envelope/1,
|
||||
stage_signature/1, stage_signature/2,
|
||||
stage_replay/1, stage_replay/2,
|
||||
stage_schema/1, stage_schema/2]).
|
||||
|
||||
%% Validation pipeline per design §14.
|
||||
%%
|
||||
%% A stage is a 1-arity fun `(Activity) -> ok | {error, Reason}`.
|
||||
%% The driver folds the activity through the stage list, halting
|
||||
%% on the first error. The pure-functional driver itself takes a
|
||||
%% stage list directly so tests can inject ad-hoc stage sequences
|
||||
%% without depending on the bundled inbound/outbound lists.
|
||||
%%
|
||||
%% Inbound pipeline (full set per design §14): envelope, signature,
|
||||
%% replay, audience, activity_schema, object_schema, content_validators,
|
||||
%% capabilities, trust. Outbound is a subset (no replay, no trust;
|
||||
%% auth handled at the HTTP layer).
|
||||
%%
|
||||
%% This sub-deliverable (6a) wires only the driver and the empty
|
||||
%% stage lists. Concrete stages land in 6b-6c.
|
||||
|
||||
run_stages(_Activity, []) -> ok;
|
||||
run_stages(Activity, [Stage | Rest]) ->
|
||||
Result = Stage(Activity),
|
||||
case Result of
|
||||
ok -> run_stages(Activity, Rest);
|
||||
{error, _} -> Result
|
||||
end.
|
||||
|
||||
validate_inbound(Activity) ->
|
||||
run_stages(Activity, inbound_stages()).
|
||||
|
||||
%% validate_inbound/3 — Step 5b federation inbound pipeline.
|
||||
%%
|
||||
%% Activity: the signed envelope as received from the peer.
|
||||
%% PeerActorState: the peer's actor-state proplist carrying
|
||||
%% :public_keys for signature verification. Caller
|
||||
%% resolves this — for v2 it's either pre-populated
|
||||
%% from a peer-actors cache (Step 5c) or known from
|
||||
%% a two-instance test fixture.
|
||||
%% InboxLog: the receiving actor's :actor_inbox log state.
|
||||
%% Used by stage_replay to reject duplicate :id.
|
||||
%%
|
||||
%% Stages (per design §13.2 + §14):
|
||||
%% stage_envelope — shape check
|
||||
%% stage_signature(PeerAS) — peer sig verify
|
||||
%% stage_replay(InboxLog) — replay defence against
|
||||
%% receiving actor's inbox
|
||||
%%
|
||||
%% Returns ok | {error, Reason}. The driver halts on first failure.
|
||||
%% Audience / schema / capabilities / trust stages defer to v3.
|
||||
|
||||
validate_inbound(Activity, PeerActorState, InboxLog) ->
|
||||
run_stages(Activity, inbound_stages(PeerActorState, InboxLog)).
|
||||
|
||||
validate_outbound(Activity) ->
|
||||
run_stages(Activity, outbound_stages()).
|
||||
|
||||
inbound_stages() ->
|
||||
[fun (A) -> stage_envelope(A) end].
|
||||
|
||||
%% inbound_stages/2 — the full ordered stage list for federation
|
||||
%% inbound (envelope -> peer sig -> replay against inbox).
|
||||
|
||||
inbound_stages(PeerActorState, InboxLog) ->
|
||||
[fun (A) -> stage_envelope(A) end,
|
||||
stage_signature(PeerActorState),
|
||||
stage_replay(InboxLog)].
|
||||
|
||||
outbound_stages() ->
|
||||
[fun (A) -> stage_envelope(A) end].
|
||||
|
||||
%% ── Concrete stages ─────────────────────────────────────────────
|
||||
|
||||
%% stage_envelope/1 — wrap envelope:validate_shape/1. The pipeline
|
||||
%% driver expects ok | {error, R}; validate_shape returns exactly
|
||||
%% that, so delegation is direct.
|
||||
stage_envelope(Activity) ->
|
||||
envelope:validate_shape(Activity).
|
||||
|
||||
%% stage_signature/2 — direct (Activity, ActorState) check. Wraps
|
||||
%% envelope:verify_signature/2 from Step 2c. Useful for tests and
|
||||
%% for callers that already have ActorState in scope.
|
||||
stage_signature(Activity, ActorState) ->
|
||||
envelope:verify_signature(Activity, ActorState).
|
||||
|
||||
%% stage_signature/1 — factory: takes the ActorState and returns a
|
||||
%% 1-arity stage fun the pipeline driver can fold. This is how
|
||||
%% signature checking gets composed into a stage list at runtime
|
||||
%% (the static `inbound_stages/0` list omits it precisely because
|
||||
%% ActorState isn't available at static-list build time).
|
||||
stage_signature(ActorState) ->
|
||||
fun (Activity) -> envelope:verify_signature(Activity, ActorState) end.
|
||||
|
||||
%% stage_replay/2 — checks the in-memory log for an existing
|
||||
%% activity with the same :id. Returns ok if the activity is new,
|
||||
%% `{error, replay}` if the log already carries it, `{error, no_id}`
|
||||
%% if the activity has no :id field. The check is linear scan of
|
||||
%% log entries; the projection scheduler (Step 7) will eventually
|
||||
%% maintain a CID index that turns this into O(1).
|
||||
stage_replay(Activity, LogState) ->
|
||||
case envelope:get_field(id, Activity) of
|
||||
not_found -> {error, no_id};
|
||||
{ok, Id} ->
|
||||
case log_has_id(Id, log:entries(LogState)) of
|
||||
true -> {error, replay};
|
||||
false -> ok
|
||||
end
|
||||
end.
|
||||
|
||||
stage_replay(LogState) ->
|
||||
fun (Activity) -> stage_replay(Activity, LogState) end.
|
||||
|
||||
log_has_id(_, []) -> false;
|
||||
log_has_id(Id, [Act | Rest]) ->
|
||||
case envelope:get_field(id, Act) of
|
||||
{ok, Id} -> true;
|
||||
_ -> log_has_id(Id, Rest)
|
||||
end.
|
||||
|
||||
%% stage_schema/2 — validates the activity's :object against the
|
||||
%% schema registered for its :type. SchemaLookup is a caller-
|
||||
%% supplied fun (Type) -> {ok, SchemaFn} | not_found; SchemaFn is
|
||||
%% itself a fun (Object) -> bool. Returns:
|
||||
%% ok when the schema accepts the object
|
||||
%% {error, no_type} when the activity has no :type
|
||||
%% {error, schema_mismatch} when SchemaFn returned false
|
||||
%%
|
||||
%% Open-world default: an unregistered Type returns ok so the
|
||||
%% pipeline doesn't block activities the kernel hasn't yet learned
|
||||
%% about. Tightening to strict-world happens later in milestone 2.
|
||||
%%
|
||||
%% Activities with no :object skip the schema check (some verbs
|
||||
%% legitimately carry no object).
|
||||
%%
|
||||
%% The Erlang-fun shape is the substrate-friendly stand-in for the
|
||||
%% SX-source :schema bodies stored in the genesis bundle. Once an
|
||||
%% SX-source eval bridge exists, the same stage shape will dispatch
|
||||
%% through it instead — no API change.
|
||||
stage_schema(Activity, SchemaLookup) ->
|
||||
case envelope:get_field(type, Activity) of
|
||||
not_found -> {error, no_type};
|
||||
{ok, Type} ->
|
||||
case SchemaLookup(Type) of
|
||||
not_found -> ok;
|
||||
{ok, SchemaFn} ->
|
||||
check_object_schema(Activity, SchemaFn)
|
||||
end
|
||||
end.
|
||||
|
||||
check_object_schema(Activity, SchemaFn) ->
|
||||
case envelope:get_field(object, Activity) of
|
||||
not_found -> ok;
|
||||
{ok, Obj} ->
|
||||
case SchemaFn(Obj) of
|
||||
true -> ok;
|
||||
false -> {error, schema_mismatch}
|
||||
end
|
||||
end.
|
||||
|
||||
stage_schema(SchemaLookup) ->
|
||||
fun (Activity) -> stage_schema(Activity, SchemaLookup) end.
|
||||
@@ -1,97 +0,0 @@
|
||||
-module(projection).
|
||||
-behaviour(gen_server).
|
||||
-export([new/2, new/3, fold_activity/2, replay/2,
|
||||
name/1, state/1, fold_fn/1]).
|
||||
-export([start_link/3, async_fold/2, query/1, stop/1]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||
|
||||
%% Pure-functional projection driver per design §10.
|
||||
%%
|
||||
%% A projection is a property list:
|
||||
%% [{name, atom}, {state, term}, {fold, fun}]
|
||||
%%
|
||||
%% The fold function is `fun (Activity, State) -> NewState`. v1
|
||||
%% uses Erlang funs as the fold body — the genesis bundle's SX
|
||||
%% `:fold` bodies are stored as binaries; an SX-source eval
|
||||
%% bridge will plug them into the same projection record once
|
||||
%% it lands (Step 7d). For now, callers supply Erlang funs
|
||||
%% directly when constructing a projection.
|
||||
%%
|
||||
%% `replay/2` is the cold-start primitive: fold an activity
|
||||
%% list (e.g. `log:entries/1`) through the projection from its
|
||||
%% initial state.
|
||||
|
||||
new(Name, InitialState) ->
|
||||
new(Name, InitialState, fun (_Activity, S) -> S end).
|
||||
|
||||
new(Name, InitialState, FoldFn) ->
|
||||
[{name, Name}, {state, InitialState}, {fold, FoldFn}].
|
||||
|
||||
fold_activity(Proj, Activity) ->
|
||||
Fn = fold_fn(Proj),
|
||||
S0 = state(Proj),
|
||||
S1 = Fn(Activity, S0),
|
||||
set_field(state, S1, Proj).
|
||||
|
||||
replay(Proj, Activities) ->
|
||||
fold_each(Proj, Activities).
|
||||
|
||||
fold_each(Proj, []) -> Proj;
|
||||
fold_each(Proj, [A | Rest]) ->
|
||||
fold_each(fold_activity(Proj, A), Rest).
|
||||
|
||||
%% Accessors
|
||||
|
||||
name(Proj) -> field(name, Proj).
|
||||
state(Proj) -> field(state, Proj).
|
||||
fold_fn(Proj) -> field(fold, Proj).
|
||||
|
||||
%% Internal
|
||||
|
||||
field(K, [{K, V} | _]) -> V;
|
||||
field(K, [_ | Rest]) -> field(K, Rest);
|
||||
field(_, []) -> erlang:error(badkey).
|
||||
|
||||
set_field(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||
set_field(K, V, [P | Rest]) -> [P | set_field(K, V, Rest)];
|
||||
set_field(K, V, []) -> [{K, V}].
|
||||
|
||||
%% ── Step 7b: gen_server wrapper ─────────────────────────────────
|
||||
%%
|
||||
%% Each projection runs in its own gen_server, registered under the
|
||||
%% projection's Name atom. `async_fold/2` casts an activity into the
|
||||
%% process; `query/1` synchronously fetches the current state.
|
||||
%%
|
||||
%% Port notes (mirroring Step 5b on the registry): `gen_server:start_link`
|
||||
%% returns the raw Pid; `?MODULE` macro is unsupported; spawned
|
||||
%% processes don't survive across separate `erlang-eval-ast` calls
|
||||
%% so tests must inline start_link with their operations.
|
||||
|
||||
start_link(Name, InitialState, FoldFn) ->
|
||||
Pid = gen_server:start_link(projection, [Name, InitialState, FoldFn]),
|
||||
erlang:register(Name, Pid),
|
||||
Pid.
|
||||
|
||||
async_fold(Name, Activity) ->
|
||||
gen_server:cast(Name, {fold, Activity}).
|
||||
|
||||
query(Name) ->
|
||||
gen_server:call(Name, get_state).
|
||||
|
||||
stop(Name) ->
|
||||
R = gen_server:call(Name, '$gen_stop'),
|
||||
erlang:unregister(Name),
|
||||
R.
|
||||
|
||||
%% gen_server callbacks
|
||||
|
||||
init([Name, InitialState, FoldFn]) ->
|
||||
{ok, new(Name, InitialState, FoldFn)}.
|
||||
|
||||
handle_call(get_state, _From, Proj) ->
|
||||
{reply, state(Proj), Proj}.
|
||||
|
||||
handle_cast({fold, Activity}, Proj) ->
|
||||
{noreply, fold_activity(Proj, Activity)}.
|
||||
|
||||
handle_info(_, Proj) -> {noreply, Proj}.
|
||||
@@ -1,120 +0,0 @@
|
||||
-module(registry).
|
||||
-behaviour(gen_server).
|
||||
-export([new/0, kinds/0, register/4, lookup/3, list/2]).
|
||||
-export([start_link/0, register/3, lookup/2, list/1, stop/0]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||
|
||||
%% Pure-functional registry for the seven bootstrap kinds.
|
||||
%%
|
||||
%% State is a property list keyed by kind atom; each kind's value
|
||||
%% is itself a property list of {Name, Entry} pairs. Entry is
|
||||
%% opaque — typically a proplist with :cid, :schema, :semantics,
|
||||
%% :supersedes fields, but the registry doesn't enforce that here.
|
||||
%%
|
||||
%% A gen_server wrapper (Step 5b) will own the global registry
|
||||
%% process; the pure functions in this module remain the canonical
|
||||
%% API and are usable for tests and for offline projection-replay.
|
||||
%%
|
||||
%% Return shapes:
|
||||
%% new/0 -> State
|
||||
%% kinds/0 -> [Atom, ...]
|
||||
%% register/4 -> {ok, NewState} | {error, unknown_kind}
|
||||
%% lookup/3 -> {ok, Entry} | not_found | {error, unknown_kind}
|
||||
%% list/2 -> [{Name, Entry}, ...] | {error, unknown_kind}
|
||||
|
||||
new() -> [].
|
||||
|
||||
kinds() ->
|
||||
[activity_types, object_types, projections,
|
||||
validators, codecs, sig_suites, audience].
|
||||
|
||||
register(Kind, Name, Entry, State) ->
|
||||
case is_valid_kind(Kind) of
|
||||
false -> {error, unknown_kind};
|
||||
true ->
|
||||
Entries = kind_entries(Kind, State),
|
||||
Updated = put_pair(Name, Entry, Entries),
|
||||
{ok, set_kind_entries(Kind, Updated, State)}
|
||||
end.
|
||||
|
||||
lookup(Kind, Name, State) ->
|
||||
case is_valid_kind(Kind) of
|
||||
false -> {error, unknown_kind};
|
||||
true ->
|
||||
find_pair(Name, kind_entries(Kind, State))
|
||||
end.
|
||||
|
||||
list(Kind, State) ->
|
||||
case is_valid_kind(Kind) of
|
||||
false -> {error, unknown_kind};
|
||||
true -> kind_entries(Kind, State)
|
||||
end.
|
||||
|
||||
%% ── Internal ────────────────────────────────────────────────────
|
||||
|
||||
is_valid_kind(K) -> lists:member(K, kinds()).
|
||||
|
||||
kind_entries(Kind, State) ->
|
||||
case find_pair(Kind, State) of
|
||||
not_found -> [];
|
||||
{ok, V} -> V
|
||||
end.
|
||||
|
||||
set_kind_entries(Kind, Entries, State) ->
|
||||
put_pair(Kind, Entries, State).
|
||||
|
||||
put_pair(K, V, []) -> [{K, V}];
|
||||
put_pair(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||
put_pair(K, V, [P | Rest]) -> [P | put_pair(K, V, Rest)].
|
||||
|
||||
find_pair(_, []) -> not_found;
|
||||
find_pair(K, [{K, V} | _]) -> {ok, V};
|
||||
find_pair(K, [_ | Rest]) -> find_pair(K, Rest).
|
||||
|
||||
%% ── Step 5b: gen_server wrapper ─────────────────────────────────
|
||||
%%
|
||||
%% The named process owns the registry state; concurrent readers
|
||||
%% and writers serialize through gen_server:call. The pure /3 and
|
||||
%% /4 functions remain available for offline projection-replay and
|
||||
%% for tests that don't need a process at all.
|
||||
%%
|
||||
%% Port notes: gen_server:start_link returns the raw Pid (not
|
||||
%% `{ok, Pid}` as in OTP). `?MODULE` macro is unsupported here, so
|
||||
%% the registered name is the literal `registry` atom in every call.
|
||||
|
||||
start_link() ->
|
||||
Pid = gen_server:start_link(registry, []),
|
||||
erlang:register(registry, Pid),
|
||||
Pid.
|
||||
|
||||
stop() ->
|
||||
R = gen_server:call(registry, '$gen_stop'),
|
||||
erlang:unregister(registry),
|
||||
R.
|
||||
|
||||
register(Kind, Name, Entry) ->
|
||||
gen_server:call(registry, {register, Kind, Name, Entry}).
|
||||
|
||||
lookup(Kind, Name) ->
|
||||
gen_server:call(registry, {lookup, Kind, Name}).
|
||||
|
||||
list(Kind) ->
|
||||
gen_server:call(registry, {list, Kind}).
|
||||
|
||||
%% gen_server callbacks
|
||||
|
||||
init(_) -> {ok, new()}.
|
||||
|
||||
handle_call({register, Kind, Name, Entry}, _From, State) ->
|
||||
case register(Kind, Name, Entry, State) of
|
||||
{ok, NewState} -> {reply, ok, NewState};
|
||||
{error, R} -> {reply, {error, R}, State}
|
||||
end;
|
||||
handle_call({lookup, Kind, Name}, _From, State) ->
|
||||
{reply, lookup(Kind, Name, State), State};
|
||||
handle_call({list, Kind}, _From, State) ->
|
||||
{reply, list(Kind, State), State}.
|
||||
|
||||
handle_cast(_, S) -> {noreply, S}.
|
||||
|
||||
handle_info(_, S) -> {noreply, S}.
|
||||
@@ -1,41 +0,0 @@
|
||||
-module(sandbox).
|
||||
-export([eval_pure/2, eval_pure/3]).
|
||||
|
||||
%% Sandboxed evaluation of an Erlang fun.
|
||||
%%
|
||||
%% eval_pure/2(Fun, Arg) -> {ok, Result} | {error, Reason}
|
||||
%% eval_pure/3(Fun, Arg1, Arg2) -> {ok, Result} | {error, Reason}
|
||||
%%
|
||||
%% The 3-arity variant matches the (Activity, State) -> NewState
|
||||
%% shape of projection folds. The projection scheduler can wrap
|
||||
%% every fold call in `sandbox:eval_pure(Fun, Act, State)` to
|
||||
%% ensure a misbehaving fold body can't crash the projection
|
||||
%% gen_server.
|
||||
%%
|
||||
%% v1 sandboxing is just the try/catch envelope: no gas budget,
|
||||
%% no IO denial, no environment stripping. Real sandboxing lands
|
||||
%% with SX-source eval (the fold body would then be an SX form
|
||||
%% evaluated under the spec/harness platform). The API shape is
|
||||
%% stable — callers don't need to change when that arrives.
|
||||
|
||||
%% Port note: this Erlang implementation catches by explicit
|
||||
%% class names (throw, error, exit) rather than the open
|
||||
%% `Class:Reason` pattern. The wrappers below enumerate the three.
|
||||
|
||||
eval_pure(Fun, Arg) ->
|
||||
try Fun(Arg) of
|
||||
Result -> {ok, Result}
|
||||
catch
|
||||
throw:Reason -> {error, {throw, Reason}};
|
||||
error:Reason -> {error, {error, Reason}};
|
||||
exit:Reason -> {error, {exit, Reason}}
|
||||
end.
|
||||
|
||||
eval_pure(Fun, Arg1, Arg2) ->
|
||||
try Fun(Arg1, Arg2) of
|
||||
Result -> {ok, Result}
|
||||
catch
|
||||
throw:Reason -> {error, {throw, Reason}};
|
||||
error:Reason -> {error, {error, Reason}};
|
||||
exit:Reason -> {error, {exit, Reason}}
|
||||
end.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user