Skip to main content

lex_vcs/
operation.rs

1//! The `Operation` enum + `OperationRecord` (operation plus its
2//! causal parents and resulting `OpId`).
3//!
4//! See `lib.rs` for the design context and #129 for the issue.
5
6use indexmap::IndexSet;
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9
10use crate::canonical;
11
12/// Signature identity of a function or type — the part that stays
13/// stable across body edits. Wraps the same string identity
14/// `lex-store` uses; we keep it as `String` here so this crate has
15/// no dependency on `lex-store`'s internals.
16pub type SigId = String;
17
18/// Content hash of a single stage (function body, type def, ...).
19/// Same string identity as the file under `<root>/stages/<SigId>/
20/// implementations/<StageId>.ast.json`.
21pub type StageId = String;
22
23/// Identity of an operation. `(kind, payload, parents)` SHA-256 in
24/// lowercase hex (64 chars). Two operations with identical payloads
25/// and parent sets produce identical `OpId`s; the store dedupes on
26/// this.
27pub type OpId = String;
28
29/// Sorted set of effect-kind strings (e.g. `["fs_write", "io"]`).
30/// `BTreeSet` so the canonical form is order-independent for
31/// hashing.
32pub type EffectSet = BTreeSet<String>;
33
34/// Reference to an imported module — either a stdlib name
35/// (`std.io`) or a local path (`./helpers`). Kept as a string so
36/// this crate doesn't pull in `lex-syntax`'s parser.
37pub type ModuleRef = String;
38
39/// Content hash of a blob in the store's `blobs/` dir (#1007): lowercase
40/// hex SHA-256 of its exact bytes. A `SetFiles` op names its manifest by one.
41pub type BlobId = String;
42
43/// Whether an `AddImport`/`RemoveImport` `module` is a **local** import — a
44/// path to a sibling file in the same package (`./error`, `../shared/util`,
45/// `/abs/x`) — rather than a stdlib module (`std.io`) or a package
46/// (`lex-nt/lib`) (#909).
47///
48/// A local import is recorded in the op-log only as metadata for `export-git`
49/// (the alias the source spelled it under). The mangler has already flattened it
50/// out of the program, so it is not an import *edge* in any sense a type-check
51/// gate, dependency resolver, or head reconstruction cares about: every reader
52/// that turns a head's imports into `Stage::Import`s must skip these, or a
53/// `./error` would be handed to a resolver/loader as though it named a
54/// registry package. Mirrors the loader's own path-import test.
55pub fn is_local_import(module: &str) -> bool {
56    module.starts_with("./") || module.starts_with("../") || module.starts_with('/')
57}
58
59/// The alias a module binds to when the import writes no explicit
60/// `as` — the module reference's last path segment, splitting on
61/// either `.` (stdlib, `std.sql` → `sql`) or `/` (local/package,
62/// `./error` → `error`, `lex-web/lib` → `lib`). Lex actually requires
63/// an explicit alias on every import, so this is not a language
64/// default; it is the convention `AddImport` uses to decide when an
65/// alias can be omitted from the op (keeping the `OpId` stable) and
66/// `export-git` uses to reconstruct it. The two MUST agree, so both
67/// call this one function.
68pub fn default_import_alias(module: &str) -> String {
69    module
70        .rsplit(['.', '/'])
71        .find(|seg| !seg.is_empty())
72        .unwrap_or(module)
73        .to_string()
74}
75
76/// Version tag for the operation canonical form (#244).
77///
78/// The pre-image bytes hashed to derive an `OpId` are not stable
79/// across schema evolutions: adding a field to `OperationKind` or
80/// changing its serde representation rotates every existing `OpId`.
81/// This enum tags the encoding used so a long-lived store can detect
82/// mismatches and migrate explicitly via [`crate::migrate`].
83///
84/// **Today only [`Self::V1`] is in production.** Adding a future
85/// variant requires:
86///
87/// 1. A new arm in [`Operation::canonical_bytes_in`].
88/// 2. An update to the canonical-form spec in [`crate::canonical`].
89/// 3. A `CHANGELOG.md` entry under `### Internal` calling out the
90///    `OpId` rotation.
91/// 4. A migration recipe via [`crate::migrate::plan_migration`] —
92///    the mechanism is encoder-agnostic, but each new variant needs
93///    its own `canonical_bytes_in` arm.
94#[derive(
95    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
96)]
97#[serde(rename_all = "lowercase")]
98pub enum OperationFormat {
99    #[default]
100    V1,
101}
102
103impl OperationFormat {
104    /// The format every newly-emitted op uses today.
105    pub const CURRENT: OperationFormat = OperationFormat::V1;
106
107    /// `true` for the implicit format (V1). Used by the
108    /// `skip_serializing_if` hook on [`OperationRecord::format_version`]
109    /// so existing V1 stores keep byte-identical on-disk JSON —
110    /// adding the version field doesn't itself rotate any `OpId`.
111    pub fn is_implicit(&self) -> bool {
112        matches!(self, OperationFormat::V1)
113    }
114}
115
116/// Effect of applying an operation on a stage's content-addressed
117/// identity. Used as the `produces` field of an [`OperationRecord`]
118/// so consumers can answer "after this op, what's the head stage
119/// for this SigId?" without rerunning the apply step.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(tag = "kind", rename_all = "snake_case")]
122pub enum StageTransition {
123    /// New SigId; produces a stage that didn't exist before.
124    Create { sig_id: SigId, stage_id: StageId },
125    /// Existing SigId; replaces its head stage.
126    Replace { sig_id: SigId, from: StageId, to: StageId },
127    /// SigId removed; no head stage afterwards.
128    Remove { sig_id: SigId, last: StageId },
129    /// SigId renamed; same body hash, different signature identity.
130    Rename { from: SigId, to: SigId, body_stage_id: StageId },
131    /// Import-only change; doesn't touch any stage.
132    ImportOnly,
133    /// Merge op result. `entries` pins every sig the merge **decided**
134    /// (every sig either side touched since the merge base) to the value
135    /// the merge resolved it to: `Some(stage_id)` sets the head; `None`
136    /// removes the sig. Sigs neither side touched are not listed.
137    ///
138    /// Merge ops written before #1062 list only the sigs whose head
139    /// changed relative to dst, so a sig kept as dst had it (`take_ours`)
140    /// is absent and the replay order of the two parallel histories
141    /// decided it. They still load and replay — to one answer, see
142    /// `OpLog::walk_forward` — but their resolution cannot be recovered.
143    /// Note that `parents` are sorted by op id, so neither position says
144    /// which parent was dst; that is why the entries are absolute values,
145    /// not a delta.
146    ///
147    /// **Canonical-form contract:** `BTreeMap` is load-bearing —
148    /// iteration is sorted by `SigId`, so on-disk JSON for two
149    /// callers that resolved the same conflicts in different
150    /// orders produces byte-identical output. Switching to
151    /// `HashMap` here would break canonical stability of the
152    /// `OperationRecord` JSON file and is rejected by the
153    /// canonical-form spec in `crate::canonical`.
154    Merge {
155        entries: BTreeMap<SigId, Option<StageId>>,
156    },
157    /// Files-only change (#1007): a [`OperationKind::SetFiles`] op. The
158    /// sig→stage map is untouched.
159    FilesOnly,
160}
161
162impl StageTransition {
163    /// Every stage id this transition references — the content-addressed
164    /// blobs a peer needs alongside the op record to render or replay it.
165    /// Used by `op push`/`pull` to sync stage objects, not just op records.
166    pub fn stage_ids(&self) -> Vec<StageId> {
167        match self {
168            StageTransition::Create { stage_id, .. } => vec![stage_id.clone()],
169            StageTransition::Replace { from, to, .. } => vec![from.clone(), to.clone()],
170            StageTransition::Remove { last, .. } => vec![last.clone()],
171            StageTransition::Rename { body_stage_id, .. } => vec![body_stage_id.clone()],
172            StageTransition::ImportOnly | StageTransition::FilesOnly => Vec::new(),
173            StageTransition::Merge { entries } => entries.values().flatten().cloned().collect(),
174        }
175    }
176
177    /// Every `(sig_id, stage_id)` pair this transition references (#986).
178    ///
179    /// A `StageId` hashes the structural signature plus the implementation and
180    /// deliberately **not** the name (#826), so two functions differing only in
181    /// name share one StageId while having two distinct SigIds — and two
182    /// separate ASTs, one stored under each sig. Rendering therefore resolves a
183    /// stage through [`crate`]'s `(sig, stage)` pair, never the id alone.
184    ///
185    /// [`Self::stage_ids`] is consequently not enough for object sync: asking a
186    /// peer "do you have this stage id?" can answer yes while the variant the
187    /// head actually names is absent. Sync paths should use these pairs.
188    pub fn stage_pairs(&self) -> Vec<(SigId, StageId)> {
189        match self {
190            StageTransition::Create { sig_id, stage_id } => {
191                vec![(sig_id.clone(), stage_id.clone())]
192            }
193            StageTransition::Replace { sig_id, from, to } => vec![
194                (sig_id.clone(), from.clone()),
195                (sig_id.clone(), to.clone()),
196            ],
197            StageTransition::Remove { sig_id, last } => vec![(sig_id.clone(), last.clone())],
198            // Only `to` (#992). A SigId covers the declaration's identity, so
199            // the renamed body's AST hashes to the *new* sig and a store files
200            // it there and only there — `(from, body_stage_id)` is a pair no
201            // store can hold. Asking for it made the reconciler demand a blob
202            // that cannot exist and refuse an otherwise valid push. The
203            // transition agrees: `apply_transition` drops `from` and inserts
204            // `to → body_stage_id`, so the head never names `from` either.
205            StageTransition::Rename { to, body_stage_id, .. } => {
206                vec![(to.clone(), body_stage_id.clone())]
207            }
208            StageTransition::ImportOnly | StageTransition::FilesOnly => Vec::new(),
209            StageTransition::Merge { entries } => entries
210                .iter()
211                .filter_map(|(sig, stage)| stage.as_ref().map(|st| (sig.clone(), st.clone())))
212                .collect(),
213        }
214    }
215}
216
217/// The kinds of operations that produce stage transitions. Mirrors
218/// the initial set in #129; new kinds (`MoveBetweenFiles`,
219/// `SplitFunction`, `ExtractType`) can be added later as long as
220/// they're appended at the end of this enum or use explicit
221/// `#[serde(rename = "...")]` tags so existing `OpId`s stay stable.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(tag = "op", rename_all = "snake_case")]
224pub enum OperationKind {
225    /// New function published. `effects` is the effect set declared
226    /// in the signature; tracked here (not just inside the stage)
227    /// so #130's write-time gate has a cheap path to check effect
228    /// changes without rehydrating the AST.
229    ///
230    /// `budget_cost` (#247) records the function's declared
231    /// `[budget(N)]` cost. Optional with `skip_serializing_if`, so
232    /// pre-#247 ops without a declared budget continue to hash to
233    /// their original `OpId` (additive serialization, same trick
234    /// `intent_id` uses). `None` means the function declared no
235    /// budget effect; `Some(n)` is the literal `n` from
236    /// `[budget(n)]`.
237    AddFunction {
238        sig_id: SigId,
239        stage_id: StageId,
240        effects: EffectSet,
241        #[serde(default, skip_serializing_if = "Option::is_none")]
242        budget_cost: Option<u64>,
243        /// The package source file this declaration came from
244        /// (`src/schema.lex`), when published as part of a multi-module
245        /// package. `None` for a single-file publish — so those ops
246        /// serialize byte-identically and keep their `OpId` (same
247        /// additive trick as `budget_cost`). Lets `export-git` de-flatten
248        /// a mangled package back into its `src/*.lex` tree (#894) and
249        /// gives `lex blame` per-file provenance.
250        #[serde(default, skip_serializing_if = "Option::is_none")]
251        in_file: Option<String>,
252    },
253    /// Function removed; `last_stage_id` is the head before the
254    /// remove (so blame can walk the predecessor without scanning).
255    RemoveFunction {
256        sig_id: SigId,
257        last_stage_id: StageId,
258    },
259    /// Function body changed; signature unchanged.
260    ///
261    /// `from_budget` / `to_budget` (#247) record the declared
262    /// `[budget(N)]` on each side. Same `Option` + `skip` discipline
263    /// as `AddFunction.budget_cost` — pre-#247 ops keep their
264    /// `OpId`s. The pair is what `lex op log --budget-drift` reads
265    /// to surface "budget grew/shrank" diffs without rehydrating
266    /// stages.
267    ModifyBody {
268        sig_id: SigId,
269        from_stage_id: StageId,
270        to_stage_id: StageId,
271        #[serde(default, skip_serializing_if = "Option::is_none")]
272        from_budget: Option<u64>,
273        #[serde(default, skip_serializing_if = "Option::is_none")]
274        to_budget: Option<u64>,
275        /// The SigId the declaration moves **to**, when the modification
276        /// changed the signature itself (#992) — same field, same reason, as
277        /// [`OperationKind::ChangeEffectSig::to_sig_id`].
278        ///
279        /// A SigId covers the input/output types and the signature-level
280        /// `examples`, not just the name, so a same-name edit to any of those
281        /// is a new sig. The publish diff keys declarations by name and read
282        /// it as a plain modification, so the head kept the *old* sig bound to
283        /// the *new* stage: a pair no store can hold. `None` for a body-only
284        /// change — and for every op written before this field — keeping the
285        /// in-place `Replace` and byte-identical OpIds.
286        #[serde(default, skip_serializing_if = "Option::is_none")]
287        to_sig_id: Option<SigId>,
288    },
289    /// Symbol renamed. The body hash is preserved (`body_stage_id`)
290    /// so two renames of the same body collapse to the same OpId
291    /// and `lex blame` walks the rename as a single causal event
292    /// rather than `delete + add`.
293    RenameSymbol {
294        from: SigId,
295        to: SigId,
296        body_stage_id: StageId,
297        /// The package source file the declaration lives in **after** the
298        /// rename, recorded only when that differs from where it was (#1060).
299        ///
300        /// A multi-module package mangles each declaration with a prefix
301        /// derived from its file's path, so moving `src/util.lex` to
302        /// `src/lib/util.lex` renames every declaration in it. The rename
303        /// leaves the body alone, so without this the head kept the *old*
304        /// `in_file` and `export-git` rendered the moved declaration back
305        /// into the old file. `None` for an in-place rename — and for every op
306        /// written before this field — so those keep their `OpId`.
307        #[serde(default, skip_serializing_if = "Option::is_none")]
308        in_file: Option<String>,
309    },
310    /// Effect signature changed. Captures both old and new effect
311    /// sets so the write-time gate (#130) can verify importers
312    /// haven't silently broken.
313    ///
314    /// `from_budget` / `to_budget` (#247) capture the declared
315    /// `[budget(N)]` on each side. ChangeEffectSig usually fires
316    /// because the effect *list* changed; #247 makes budget drift
317    /// visible without forcing a full effect-set diff.
318    ChangeEffectSig {
319        sig_id: SigId,
320        from_stage_id: StageId,
321        to_stage_id: StageId,
322        from_effects: EffectSet,
323        to_effects: EffectSet,
324        #[serde(default, skip_serializing_if = "Option::is_none")]
325        from_budget: Option<u64>,
326        #[serde(default, skip_serializing_if = "Option::is_none")]
327        to_budget: Option<u64>,
328        /// The SigId the declaration moves **to** (#992).
329        ///
330        /// A SigId covers the effect row, so changing a function's effects
331        /// changes its sig — the op's own name says as much. Without this the
332        /// transition was a `Replace`, which keeps the *old* sig pointing at
333        /// the *new* stage. But a store files an implementation under the sig
334        /// its own AST hashes to, and that AST declares the new effects, so
335        /// the head entry was unsatisfiable by construction: no store could
336        /// ever hold `(old_sig, new_stage)`. The head then could not be
337        /// rendered, and any release cut from it was born broken —
338        /// `lex-web@0.4.0` is exactly that.
339        ///
340        /// `None` is how every op written before this field existed decodes,
341        /// and it keeps their original `Replace` behaviour so historical logs
342        /// replay unchanged. `skip_serializing_if` keeps those ops
343        /// byte-identical, so their OpIds do not move.
344        #[serde(default, skip_serializing_if = "Option::is_none")]
345        to_sig_id: Option<SigId>,
346    },
347    /// Import added to a file. `in_file` is the canonical path
348    /// (relative to the repo root, forward-slashes) so two
349    /// machines hashing the same edit get the same OpId.
350    AddImport {
351        in_file: String,
352        module: ModuleRef,
353        /// The binding the module is imported under (`import "std.sql"
354        /// as sql` → `"sql"`). Lex requires an alias on every import,
355        /// but this is `None` whenever it equals the module's default
356        /// alias (the last path segment) — the common case — so those
357        /// `AddImport`s serialize exactly as before and keep their
358        /// original `OpId` (additive serialization, same trick as
359        /// `budget_cost`). Only a non-default alias (`import "./error"
360        /// as e`) is carried explicitly. Without it, `export-git`
361        /// cannot reconstruct a compilable module — the reference alone
362        /// doesn't say what name the body binds.
363        #[serde(default, skip_serializing_if = "Option::is_none")]
364        alias: Option<String>,
365    },
366    RemoveImport {
367        in_file: String,
368        module: ModuleRef,
369    },
370    AddType {
371        sig_id: SigId,
372        stage_id: StageId,
373        /// Source file this type came from, for multi-module packages;
374        /// `None` (and omitted) for a single-file publish. See
375        /// `AddFunction::in_file`.
376        #[serde(default, skip_serializing_if = "Option::is_none")]
377        in_file: Option<String>,
378    },
379    RemoveType {
380        sig_id: SigId,
381        last_stage_id: StageId,
382    },
383    ModifyType {
384        sig_id: SigId,
385        from_stage_id: StageId,
386        to_stage_id: StageId,
387        /// The SigId the type moves **to** when its params changed (#992).
388        /// See [`OperationKind::ModifyBody::to_sig_id`].
389        #[serde(default, skip_serializing_if = "Option::is_none")]
390        to_sig_id: Option<SigId>,
391    },
392    /// Merge of two branch heads. Carries only an informational count
393    /// of resolved sigs so two structurally identical merges of
394    /// different sizes don't collide on op_id; the per-sig deltas live
395    /// in `OperationRecord::produces` (`StageTransition::Merge`).
396    Merge {
397        resolved: usize,
398    },
399    /// Typed transform: inlined a `let x := v; body` by
400    /// substituting `v` for every unshadowed `x` in `body`, then
401    /// replacing the entire `Let` node with the substituted body
402    /// (#280). The op records the let-binding's position and the
403    /// inlined name; the actual substituted value lives in the
404    /// content-addressed `to_stage_id` so the op_id stays compact.
405    InlineLet {
406        sig_id: SigId,
407        from_stage_id: StageId,
408        to_stage_id: StageId,
409        let_node: String,
410        binding_name: String,
411        #[serde(default, skip_serializing_if = "Option::is_none")]
412        from_budget: Option<u64>,
413        #[serde(default, skip_serializing_if = "Option::is_none")]
414        to_budget: Option<u64>,
415    },
416    /// Typed transform: renamed a `let`-bound local within a fn
417    /// body (#280). Records the old/new identifiers and the position
418    /// of the let-binding in the AST. Body-shape-stable: the renamed
419    /// stage typically hashes near the original.
420    RenameLocal {
421        sig_id: SigId,
422        from_stage_id: StageId,
423        to_stage_id: StageId,
424        /// Path-style NodeId of the `Let` expression at the time of
425        /// the transform.
426        let_node: String,
427        old_name: String,
428        new_name: String,
429        #[serde(default, skip_serializing_if = "Option::is_none")]
430        from_budget: Option<u64>,
431        #[serde(default, skip_serializing_if = "Option::is_none")]
432        to_budget: Option<u64>,
433    },
434    /// Typed transform: replaced one arm's body in a `Match`
435    /// expression (#280). Semantically a `ModifyBody`, but the op
436    /// records *which* arm changed and *where* in the AST — so the
437    /// op log reads as a semantic edit history rather than as
438    /// opaque hash-to-hash bytes.
439    ///
440    /// `match_node` is the [`lex_ast::ids::NodeId`] of the Match
441    /// expression at the time of the transform. NodeIds aren't
442    /// stable across structural edits — they're audit-trail metadata,
443    /// not re-derivation keys. The authoritative record of the new
444    /// stage is `to_stage_id` (content-addressed).
445    ///
446    /// `from_budget`/`to_budget` follow the same `skip_if_none`
447    /// discipline as [`Self::ModifyBody`]: pre-#280 ops continue
448    /// hashing to their original `OpId`s.
449    ReplaceMatchArm {
450        sig_id: SigId,
451        from_stage_id: StageId,
452        to_stage_id: StageId,
453        /// Path-style NodeId of the Match expression that was
454        /// modified, captured at transform time. See
455        /// [`lex_ast::ids::NodeId`] for the format.
456        match_node: String,
457        arm_index: usize,
458        #[serde(default, skip_serializing_if = "Option::is_none")]
459        from_budget: Option<u64>,
460        #[serde(default, skip_serializing_if = "Option::is_none")]
461        to_budget: Option<u64>,
462    },
463    /// Multi-agent coordination: a stage proposed for a sig
464    /// without advancing the branch (#294). Multiple agents can
465    /// land `Candidate` ops on the same sig concurrently without
466    /// contention — they all chain off the current head and don't
467    /// move it. Used together with [`Self::Promote`] to model
468    /// bake-offs: several agents propose, one is promoted.
469    ///
470    /// The `Operation`'s `intent_id` is expected to be set so
471    /// downstream consumers can distinguish proposals by author.
472    /// (The schema doesn't enforce this; the gate does.)
473    Candidate {
474        sig_id: SigId,
475        stage_id: StageId,
476    },
477    /// Multi-agent coordination: promotes a previously-landed
478    /// [`Self::Candidate`] op as the new head for its sig (#294).
479    /// Carries the list of *other* candidates this Promote
480    /// supersedes so the op log explicitly records the bake-off
481    /// shape.
482    ///
483    /// Acts as a `ModifyBody` (or `AddFunction` when the sig has
484    /// no head) for branch-head purposes — `transition_for_kind`
485    /// returns the appropriate `StageTransition`.
486    Promote {
487        sig_id: SigId,
488        /// Op id of the [`Self::Candidate`] being promoted.
489        winner_candidate: OpId,
490        /// Stage id of the winner (duplicates the candidate's
491        /// `stage_id` for fast lookup; saves a log round-trip
492        /// for `lex op show`).
493        winner_stage_id: StageId,
494        /// Every other live `Candidate` for `sig_id` at the time
495        /// of promotion. Sorted by op_id for canonical-form
496        /// stability. After this `Promote` lands, none of these
497        /// op_ids appear in [`Store::list_candidates`].
498        supersedes: Vec<OpId>,
499        /// Current branch head stage for `sig_id`, or `None` if
500        /// the sig had no head (the Promote is creating it).
501        /// `None` is serialized as missing for canonical stability
502        /// across "first promote on a sig" vs "later promote".
503        #[serde(default, skip_serializing_if = "Option::is_none")]
504        from_stage_id: Option<StageId>,
505        #[serde(default, skip_serializing_if = "Option::is_none")]
506        from_budget: Option<u64>,
507        #[serde(default, skip_serializing_if = "Option::is_none")]
508        to_budget: Option<u64>,
509    },
510    /// Non-semantic (#1007): set the repository's non-op-log files —
511    /// README, `lex.toml`, `tests/`, CI config — to the full snapshot named
512    /// by `manifest` (a blob holding a canonical files manifest, like a git
513    /// tree). Recorded, ordered and content-addressed with the rest of the
514    /// history, but never replayed, type-checked or gated as code; the
515    /// sig→stage map is untouched ([`StageTransition::FilesOnly`]).
516    SetFiles { manifest: BlobId },
517}
518
519impl OperationKind {
520    /// Whether this op changes the program (#1007). `false` only for
521    /// [`Self::SetFiles`], which replay, gates and replay coverage skip by
522    /// kind: a file snapshot has no stage to regenerate or type-check.
523    pub fn is_semantic(&self) -> bool {
524        !matches!(self, OperationKind::SetFiles { .. })
525    }
526
527    /// The `(SigId, Option<StageId>)` an op kind targets, as used by
528    /// `StageTransition::Merge::entries`. Used by the merge-commit
529    /// path (#134) to translate a `Resolution::Custom { op }` into
530    /// the head-map delta the merge op records:
531    ///
532    /// * Adds → `(sig, Some(stage_id))`
533    /// * Modifies → `(sig, Some(to_stage_id))`
534    /// * Removes → `(sig, None)`
535    /// * Renames → `(to_sig, Some(body_stage_id))`
536    /// * `AddImport` / `RemoveImport` / nested `Merge` → `None`
537    ///   (no single sig→stage delta)
538    pub fn merge_target(&self) -> Option<(SigId, Option<StageId>)> {
539        use OperationKind::*;
540        match self {
541            AddFunction { sig_id, stage_id, .. }
542            | AddType { sig_id, stage_id, .. }
543                => Some((sig_id.clone(), Some(stage_id.clone()))),
544            // #992: a sig-moving modification lands under the sig it moves to.
545            ModifyBody { sig_id, to_stage_id, to_sig_id, .. }
546            | ChangeEffectSig { sig_id, to_stage_id, to_sig_id, .. }
547            | ModifyType { sig_id, to_stage_id, to_sig_id, .. }
548                => Some((to_sig_id.as_ref().unwrap_or(sig_id).clone(), Some(to_stage_id.clone()))),
549            ReplaceMatchArm { sig_id, to_stage_id, .. }
550            | RenameLocal { sig_id, to_stage_id, .. }
551            | InlineLet { sig_id, to_stage_id, .. }
552                => Some((sig_id.clone(), Some(to_stage_id.clone()))),
553            Promote { sig_id, winner_stage_id, .. }
554                => Some((sig_id.clone(), Some(winner_stage_id.clone()))),
555            RemoveFunction { sig_id, .. }
556            | RemoveType { sig_id, .. }
557                => Some((sig_id.clone(), None)),
558            RenameSymbol { to, body_stage_id, .. }
559                => Some((to.clone(), Some(body_stage_id.clone()))),
560            AddImport { .. } | RemoveImport { .. } | Merge { .. } | SetFiles { .. } => None,
561            // Candidate ops don't advance the branch head; they
562            // don't fit the (sig, Option<stage_id>) head-delta
563            // shape that `merge_target` describes.
564            Candidate { .. } => None,
565        }
566    }
567
568    /// `(from_budget, to_budget)` for ops that carry a budget delta
569    /// (#247). `(None, None)` for ops where the budget isn't part
570    /// of the canonical payload — `RemoveFunction`, `RenameSymbol`,
571    /// imports, and merges. `AddFunction` reports `(None,
572    /// Some(cost))` for "this is the initial cost." Used by `lex op
573    /// show`, `lex op log --budget-drift`, and `lex audit --budget`.
574    pub fn budget_delta(&self) -> (Option<u64>, Option<u64>) {
575        use OperationKind::*;
576        match self {
577            AddFunction { budget_cost, .. } => (None, *budget_cost),
578            ModifyBody { from_budget, to_budget, .. }
579            | ChangeEffectSig { from_budget, to_budget, .. }
580            | ReplaceMatchArm { from_budget, to_budget, .. }
581            | RenameLocal { from_budget, to_budget, .. }
582            | InlineLet { from_budget, to_budget, .. }
583            | Promote { from_budget, to_budget, .. } => (*from_budget, *to_budget),
584            _ => (None, None),
585        }
586    }
587
588    /// The `SigId` an op touches if it carries a budget — used for
589    /// per-sig audit rollups in `lex audit --budget`. Returns `None`
590    /// for ops without a relevant budget (the same set as the
591    /// `_ => (None, None)` arm of [`Self::budget_delta`]).
592    pub fn budget_sig(&self) -> Option<&SigId> {
593        use OperationKind::*;
594        match self {
595            AddFunction { sig_id, .. }
596            | ModifyBody { sig_id, .. }
597            | ChangeEffectSig { sig_id, .. }
598            | ReplaceMatchArm { sig_id, .. }
599            | RenameLocal { sig_id, .. }
600            | InlineLet { sig_id, .. }
601            | Promote { sig_id, .. } => Some(sig_id),
602            _ => None,
603        }
604    }
605}
606
607/// Extract the declared `[budget(N)]` integer from an [`EffectSet`],
608/// if any (#247).
609///
610/// Effect labels in [`EffectSet`] are produced by
611/// [`crate::compute_diff::effect_label`]: a `[budget(50)]`
612/// declaration becomes the literal string `"budget(50)"`. This
613/// helper parses that literal back to the integer; bare `"budget"`
614/// (no arg) returns `None` because the magnitude is unknown. A
615/// stage with multiple budget declarations — which the type-
616/// checker should reject anyway — picks the smallest, conservative
617/// answer for `lex audit --budget`.
618pub fn budget_from_effects(effects: &EffectSet) -> Option<u64> {
619    let mut min_cost: Option<u64> = None;
620    for label in effects {
621        let Some(rest) = label.strip_prefix("budget(") else { continue };
622        let Some(inner) = rest.strip_suffix(')') else { continue };
623        let Ok(n) = inner.parse::<u64>() else { continue };
624        min_cost = Some(min_cost.map(|c| c.min(n)).unwrap_or(n));
625    }
626    min_cost
627}
628
629/// The operation as a whole — its kind and the causal predecessors
630/// it assumes. The `OpId` is computed from this plus a sorted view
631/// of `parents`.
632///
633/// Operations without parents are valid and represent "applies to
634/// the empty repository" or "applies to the synthetic genesis
635/// state." `lex store migrate v1→v2` will produce parentless ops
636/// for stages it can't trace back to a clear predecessor.
637#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
638pub struct Operation {
639    #[serde(flatten)]
640    pub kind: OperationKind,
641    /// Operations whose `produces` this op assumes. Sorted before
642    /// hashing for canonical form. Empty for ops against the empty
643    /// repo.
644    #[serde(default, skip_serializing_if = "Vec::is_empty")]
645    pub parents: Vec<OpId>,
646    /// The intent that caused this op, if known. Optional because
647    /// operations produced outside an agent harness (e.g. a human
648    /// running `lex publish` directly) don't have one.
649    ///
650    /// Including the intent in the canonical hash means the same
651    /// logical change made under different intents produces
652    /// different `OpId`s — causally distinct events should hash
653    /// distinctly. Ops with `intent_id: None` keep their existing
654    /// hashes (the field is omitted from the canonical JSON via
655    /// `skip_serializing_if`), so this is backwards-compatible
656    /// for stores written before #131.
657    #[serde(default, skip_serializing_if = "Option::is_none")]
658    pub intent_id: Option<crate::intent::IntentId>,
659}
660
661impl Operation {
662    /// Construct an operation against zero or more parents. Caller
663    /// supplies parents in any order; canonicalization sorts them
664    /// before hashing.
665    pub fn new(kind: OperationKind, parents: impl IntoIterator<Item = OpId>) -> Self {
666        let mut parents: Vec<OpId> = parents.into_iter().collect();
667        parents.sort();
668        parents.dedup();
669        Self { kind, parents, intent_id: None }
670    }
671
672    /// Tag this operation with the intent that produced it. The
673    /// builder shape keeps existing call sites untouched; agent
674    /// harnesses that record intent call this once before
675    /// applying the op.
676    pub fn with_intent(mut self, intent_id: impl Into<crate::intent::IntentId>) -> Self {
677        self.intent_id = Some(intent_id.into());
678        self
679    }
680
681    /// Compute this operation's content-addressed identity under the
682    /// current production canonical form ([`OperationFormat::CURRENT`]).
683    ///
684    /// Stable across runs and machines: same `(kind, payload,
685    /// sorted parents, intent_id)` produces the same `OpId`. The
686    /// invariant #129's automatic-dedup behavior relies on.
687    pub fn op_id(&self) -> OpId {
688        self.op_id_in(OperationFormat::CURRENT)
689    }
690
691    /// Compute the `OpId` under a specific canonical-form version.
692    ///
693    /// Used by [`crate::migrate`] to derive new `OpId`s when porting
694    /// a store across format versions. Production code should call
695    /// [`Self::op_id`].
696    pub fn op_id_in(&self, format: OperationFormat) -> OpId {
697        canonical::hash_bytes(&self.canonical_bytes_in(format))
698    }
699
700    /// The byte sequence that gets hashed to produce [`Self::op_id`]
701    /// under the current canonical form. Equivalent to
702    /// `self.canonical_bytes_in(OperationFormat::CURRENT)`.
703    ///
704    /// Exposed (not just consumed by `op_id`) so golden tests can pin
705    /// the exact pre-image. **Not** equal to `serde_json::to_vec(&op)`
706    /// in general — the on-disk JSON skips empty `parents` and
707    /// `None` `intent_id`, while the canonical form always emits a
708    /// (sorted, deduped) `parents` array. See `canonical.rs` for the
709    /// full V1 canonical-form spec.
710    pub fn canonical_bytes(&self) -> Vec<u8> {
711        self.canonical_bytes_in(OperationFormat::CURRENT)
712    }
713
714    /// The pre-image hashed under a specific canonical-form version.
715    ///
716    /// Today every `OperationFormat` variant routes to V1's encoder
717    /// (only V1 exists in production). When V2 lands, this match
718    /// gains an arm and the migration tool's encoder closure routes
719    /// here.
720    pub fn canonical_bytes_in(&self, format: OperationFormat) -> Vec<u8> {
721        match format {
722            OperationFormat::V1 => self.canonical_bytes_v1(),
723        }
724    }
725
726    fn canonical_bytes_v1(&self) -> Vec<u8> {
727        // Build a transient hashable view rather than hashing
728        // `self` directly so the parent ordering is canonical
729        // even if a caller hand-constructs an `Operation` with
730        // unsorted parents.
731        let canonical = CanonicalView {
732            kind: &self.kind,
733            parents: self.parents.iter().collect::<IndexSet<_>>().into_iter().collect::<BTreeSet<_>>(),
734            intent_id: self.intent_id.as_deref(),
735        };
736        serde_json::to_vec(&canonical).expect("canonical serialization")
737    }
738}
739
740/// Hashable shadow of [`Operation`] with parents in a `BTreeSet` so
741/// the serialization is order-independent regardless of how the
742/// caller constructed the live operation. Never persisted; lives
743/// only as a transient for hashing.
744#[derive(Serialize)]
745struct CanonicalView<'a> {
746    #[serde(flatten)]
747    kind: &'a OperationKind,
748    parents: BTreeSet<&'a OpId>,
749    /// `skip_serializing_if = "Option::is_none"` keeps existing
750    /// `OpId`s stable for ops without an intent — the field is
751    /// omitted from the canonical JSON entirely.
752    #[serde(skip_serializing_if = "Option::is_none")]
753    intent_id: Option<&'a str>,
754}
755
756/// An operation paired with its computed `OpId` and the resulting
757/// stage transition. This is what gets persisted under
758/// `<root>/ops/<OpId>.json`.
759///
760/// `format_version` records the canonical form the `op_id` was
761/// computed under. Pre-#244 stores didn't emit this field; reading
762/// such records deserializes to [`OperationFormat::V1`] (the
763/// implicit pre-versioning format), and writing V1 records continues
764/// to omit it (`skip_serializing_if = is_implicit`) so adding the
765/// field doesn't rotate any existing `OpId` or change any on-disk
766/// byte. Records written under a future format will explicitly
767/// carry their version tag.
768#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
769pub struct OperationRecord {
770    pub op_id: OpId,
771    #[serde(default, skip_serializing_if = "OperationFormat::is_implicit")]
772    pub format_version: OperationFormat,
773    #[serde(flatten)]
774    pub op: Operation,
775    pub produces: StageTransition,
776}
777
778impl OperationRecord {
779    pub fn new(op: Operation, produces: StageTransition) -> Self {
780        let op_id = op.op_id();
781        Self { op_id, format_version: OperationFormat::CURRENT, op, produces }
782    }
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788
789    #[test]
790    fn local_imports_are_told_from_stdlib_and_packages() {
791        for local in ["./error", "../shared/util", "/abs/mod", "./util/strings"] {
792            assert!(is_local_import(local), "{local}");
793        }
794        for other in ["std.io", "lex-nt/lib", "lex-nt", "lonely"] {
795            assert!(!is_local_import(other), "{other}");
796        }
797    }
798
799    fn add_factorial() -> OperationKind {
800        OperationKind::AddFunction {
801            sig_id: "fac::Int->Int".into(),
802            stage_id: "abc123".into(),
803            effects: BTreeSet::new(),
804            budget_cost: None,
805            in_file: None,
806        }
807    }
808
809    #[test]
810    fn identical_operations_have_identical_op_ids() {
811        let a = Operation::new(add_factorial(), []);
812        let b = Operation::new(add_factorial(), []);
813        assert_eq!(a.op_id(), b.op_id());
814    }
815
816    #[test]
817    fn different_operations_have_different_op_ids() {
818        let a = Operation::new(add_factorial(), []);
819        let b = Operation::new(
820            OperationKind::AddFunction {
821                sig_id: "double::Int->Int".into(),
822                stage_id: "abc123".into(),
823                effects: BTreeSet::new(),
824                budget_cost: None,
825                in_file: None,
826            },
827            [],
828        );
829        assert_ne!(a.op_id(), b.op_id());
830    }
831
832    #[test]
833    fn parent_set_changes_op_id() {
834        let no_parent = Operation::new(add_factorial(), []);
835        let with_parent = Operation::new(add_factorial(), ["op-parent-1".into()]);
836        assert_ne!(no_parent.op_id(), with_parent.op_id());
837    }
838
839    #[test]
840    fn parent_order_does_not_affect_op_id() {
841        let a = Operation::new(add_factorial(), ["b".into(), "a".into(), "c".into()]);
842        let b = Operation::new(add_factorial(), ["c".into(), "a".into(), "b".into()]);
843        assert_eq!(a.op_id(), b.op_id());
844        // and the stored form is sorted.
845        assert_eq!(a.parents, vec!["a".to_string(), "b".to_string(), "c".to_string()]);
846    }
847
848    #[test]
849    fn duplicate_parents_are_deduped() {
850        let with_dups = Operation::new(
851            add_factorial(),
852            ["a".into(), "a".into(), "b".into()],
853        );
854        let no_dups = Operation::new(
855            add_factorial(),
856            ["a".into(), "b".into()],
857        );
858        assert_eq!(with_dups.op_id(), no_dups.op_id());
859        assert_eq!(with_dups.parents, vec!["a".to_string(), "b".to_string()]);
860    }
861
862    #[test]
863    fn rename_with_same_body_hashes_equal_across_runs() {
864        // Two independent runs producing the same rename against the
865        // same parent should produce the same OpId — this is the
866        // automatic-dedup property #129 relies on for distributed
867        // agents.
868        let kind = OperationKind::RenameSymbol {
869            from: "parse::Str->Int".into(),
870            to: "parse_int::Str->Int".into(),
871            body_stage_id: "abc123".into(),
872            in_file: None,
873        };
874        let a = Operation::new(kind.clone(), ["op-parent".into()]);
875        let b = Operation::new(kind, ["op-parent".into()]);
876        assert_eq!(a.op_id(), b.op_id());
877    }
878
879    /// #1060: `in_file` is additive. A rename without it serializes and hashes
880    /// exactly as it did before the field existed, and a record written by an
881    /// older client (no key) still loads.
882    #[test]
883    fn rename_in_file_is_additive_and_defaults_to_none() {
884        let old_json = serde_json::json!({
885            "op": "rename_symbol",
886            "from": "parse::Str->Int",
887            "to": "parse_int::Str->Int",
888            "body_stage_id": "abc123",
889        });
890        let kind: OperationKind = serde_json::from_value(old_json.clone()).expect("pre-#1060 record loads");
891        assert!(matches!(&kind, OperationKind::RenameSymbol { in_file: None, .. }));
892        assert_eq!(serde_json::to_value(&kind).unwrap(), old_json, "None adds no key");
893
894        let moved = OperationKind::RenameSymbol {
895            from: "parse::Str->Int".into(),
896            to: "parse_int::Str->Int".into(),
897            body_stage_id: "abc123".into(),
898            in_file: Some("src/lib/parse.lex".into()),
899        };
900        let with = Operation::new(moved.clone(), ["op-parent".into()]).op_id();
901        let without = Operation::new(kind, ["op-parent".into()]).op_id();
902        assert_ne!(with, without, "the destination file is part of the op's identity");
903        let round: OperationKind = serde_json::from_value(serde_json::to_value(&moved).unwrap()).unwrap();
904        assert_eq!(round, moved);
905    }
906
907    #[test]
908    fn rename_does_not_collide_with_delete_plus_add() {
909        // The whole point of `RenameSymbol` is that it's a different
910        // OpId from the (semantically-equivalent) `RemoveFunction +
911        // AddFunction` pair. Causal history sees one event, not two.
912        let rename = Operation::new(
913            OperationKind::RenameSymbol {
914                from: "parse::Str->Int".into(),
915                to: "parse_int::Str->Int".into(),
916                body_stage_id: "abc123".into(),
917                in_file: None,
918            },
919            ["op-parent".into()],
920        );
921        let remove = Operation::new(
922            OperationKind::RemoveFunction {
923                sig_id: "parse::Str->Int".into(),
924                last_stage_id: "abc123".into(),
925            },
926            ["op-parent".into()],
927        );
928        let add = Operation::new(
929            OperationKind::AddFunction {
930                sig_id: "parse_int::Str->Int".into(),
931                stage_id: "abc123".into(),
932                effects: BTreeSet::new(),
933                budget_cost: None,
934                in_file: None,
935            },
936            ["op-parent".into()],
937        );
938        assert_ne!(rename.op_id(), remove.op_id());
939        assert_ne!(rename.op_id(), add.op_id());
940    }
941
942    #[test]
943    fn effect_set_order_does_not_affect_op_id() {
944        // Effects are a BTreeSet so iteration is sorted. Build two
945        // ops via different insertion orders and confirm the
946        // canonical form is identical.
947        let a_effects: EffectSet = ["io".into(), "fs_write".into()].into_iter().collect();
948        let b_effects: EffectSet = ["fs_write".into(), "io".into()].into_iter().collect();
949        let a = Operation::new(
950            OperationKind::AddFunction {
951                sig_id: "x".into(), stage_id: "s".into(), effects: a_effects,
952                budget_cost: None,
953                in_file: None,
954            },
955            [],
956        );
957        let b = Operation::new(
958            OperationKind::AddFunction {
959                sig_id: "x".into(), stage_id: "s".into(), effects: b_effects,
960                budget_cost: None,
961                in_file: None,
962            },
963            [],
964        );
965        assert_eq!(a.op_id(), b.op_id());
966    }
967
968    #[test]
969    fn op_id_is_64_char_lowercase_hex() {
970        let id = Operation::new(add_factorial(), []).op_id();
971        assert_eq!(id.len(), 64);
972        assert!(id.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
973    }
974
975    #[test]
976    fn round_trip_through_serde_json() {
977        let op = Operation::new(
978            OperationKind::ChangeEffectSig {
979                sig_id: "f".into(),
980                from_stage_id: "old".into(),
981                to_stage_id: "new".into(),
982                from_effects: BTreeSet::new(),
983                to_effects: ["io".into()].into_iter().collect(),
984                from_budget: None,
985                to_budget: None,
986                to_sig_id: None,
987            },
988            ["op-parent".into()],
989        );
990        let json = serde_json::to_string(&op).expect("serialize");
991        let back: Operation = serde_json::from_str(&json).expect("deserialize");
992        assert_eq!(op, back);
993        assert_eq!(op.op_id(), back.op_id());
994    }
995
996    #[test]
997    fn operation_record_carries_op_id() {
998        let op = Operation::new(add_factorial(), []);
999        let expected = op.op_id();
1000        let rec = OperationRecord::new(
1001            op,
1002            StageTransition::Create {
1003                sig_id: "fac::Int->Int".into(),
1004                stage_id: "abc123".into(),
1005            },
1006        );
1007        assert_eq!(rec.op_id, expected);
1008    }
1009
1010    #[test]
1011    fn intent_id_is_part_of_op_id_canonical_hash() {
1012        // The dedup property: same `(kind, parents, intent_id)`
1013        // produces the same OpId. Different intent_ids on
1014        // otherwise-identical ops produce different OpIds, so
1015        // causally distinct events (different prompts) hash
1016        // distinctly.
1017        let no_intent = Operation::new(add_factorial(), []);
1018        let with_intent_a = Operation::new(add_factorial(), [])
1019            .with_intent("intent-a");
1020        let with_intent_b = Operation::new(add_factorial(), [])
1021            .with_intent("intent-b");
1022        let with_intent_a_again = Operation::new(add_factorial(), [])
1023            .with_intent("intent-a");
1024
1025        // No-intent op is distinct from any intent-tagged variant.
1026        assert_ne!(no_intent.op_id(), with_intent_a.op_id());
1027        // Different intents → different OpIds.
1028        assert_ne!(with_intent_a.op_id(), with_intent_b.op_id());
1029        // Same intent → same OpId (the load-bearing dedup invariant).
1030        assert_eq!(with_intent_a.op_id(), with_intent_a_again.op_id());
1031    }
1032
1033    #[test]
1034    fn op_without_intent_keeps_pre_intent_op_id() {
1035        // Backwards-compat invariant: an op constructed without an
1036        // intent must hash to the same value as it would have
1037        // before #131 added the field. The golden test below pins
1038        // the exact hash; this one asserts that adding then
1039        // resetting to None doesn't drift.
1040        let mut op = Operation::new(add_factorial(), []);
1041        let baseline = op.op_id();
1042        op.intent_id = Some("transient".into());
1043        let with_intent = op.op_id();
1044        assert_ne!(baseline, with_intent);
1045        op.intent_id = None;
1046        let back = op.op_id();
1047        assert_eq!(baseline, back);
1048    }
1049
1050    /// Golden hash. If this changes, the canonical form has shifted
1051    /// and *every* op_id in every existing store has changed too —
1052    /// that's a major-version event for the data model and should
1053    /// be a deliberate decision, not an accident from reordering
1054    /// fields. Update with care.
1055    #[test]
1056    fn canonical_form_is_stable_for_a_known_input() {
1057        let op = Operation::new(
1058            OperationKind::AddFunction {
1059                sig_id: "fac::Int->Int".into(),
1060                stage_id: "abc123".into(),
1061                effects: BTreeSet::new(),
1062                budget_cost: None,
1063                in_file: None,
1064            },
1065            [],
1066        );
1067        assert_eq!(
1068            op.op_id(),
1069            "f112990d31ef2a63f3e5ca5680637ed36a54bc7e8230510ae0c0e93fcb39d104"
1070        );
1071    }
1072
1073    #[test]
1074    fn merge_kind_round_trips() {
1075        let op = Operation::new(
1076            OperationKind::Merge { resolved: 3 },
1077            ["op-a".into(), "op-b".into()],
1078        );
1079        let json = serde_json::to_string(&op).expect("ser");
1080        let back: Operation = serde_json::from_str(&json).expect("de");
1081        assert_eq!(op, back);
1082        assert_eq!(op.op_id(), back.op_id());
1083    }
1084
1085    #[test]
1086    fn merge_stage_transition_round_trips() {
1087        let mut entries = BTreeMap::new();
1088        entries.insert("sig-a".to_string(), Some("stage-a".to_string()));
1089        entries.insert("sig-b".to_string(), None); // removed by merge
1090        let t = StageTransition::Merge { entries };
1091        let json = serde_json::to_string(&t).expect("ser");
1092        let back: StageTransition = serde_json::from_str(&json).expect("de");
1093        assert_eq!(t, back);
1094    }
1095
1096    #[test]
1097    fn merge_resolved_count_changes_op_id() {
1098        // Two merges with the same parents but different resolved counts
1099        // must hash differently — keeps structurally distinct merges from
1100        // colliding on op_id.
1101        let parents: Vec<OpId> = vec!["op-a".into(), "op-b".into()];
1102        let one = Operation::new(OperationKind::Merge { resolved: 1 }, parents.clone());
1103        let two = Operation::new(OperationKind::Merge { resolved: 2 }, parents);
1104        assert_ne!(one.op_id(), two.op_id());
1105    }
1106
1107    #[test]
1108    fn existing_add_function_op_id_is_unchanged_after_merge_added() {
1109        // Constructing the new Merge variant in the same enum must not
1110        // perturb the canonical bytes of existing variants. The golden
1111        // hash test below checks the literal value; this one verifies
1112        // the property holds even after a Merge op has been built.
1113        let _merge = Operation::new(
1114            OperationKind::Merge { resolved: 0 },
1115            ["op-x".into(), "op-y".into()],
1116        );
1117        let op = Operation::new(add_factorial(), []);
1118        assert_eq!(
1119            op.op_id(),
1120            "f112990d31ef2a63f3e5ca5680637ed36a54bc7e8230510ae0c0e93fcb39d104"
1121        );
1122    }
1123}