1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
(** Pipeline script analysis and environment management for t_make.

    This module provides:
    - Static analysis of a pipeline entrypoint (src/pipeline.t) to determine
      whether it calls populate_pipeline / build_pipeline and what build
      intent the user expressed.
    - Filename validation to enforce src/pipeline.t as the canonical
      entrypoint.
    - Environment reload helpers that clear stale user bindings between
      successive t_make evaluations of the same entrypoint. *)

open Ast

let ordered_unique_strings names =
  let rec go seen acc = function
    | [] -> List.rev acc
    | name :: rest when String_set.mem name seen -> go seen acc rest
    | name :: rest -> go (String_set.add name seen) (name :: acc) rest
  in
  go String_set.empty [] names

let pipeline_entry_bindings_key = "__pipeline_entry_bindings__"

let is_internal_key name =
  Import_registry.is_internal_key name
  || name = pipeline_entry_bindings_key

type t_make_pipeline_contract =
  | MissingPipelineBuildCall
  | PopulateWithoutBuild
  | PopulateBuildUnknown
  | BuildRequested

let sanitize_pipeline_entry_binding_names names =
  List.filter (fun name -> not (is_internal_key name)) names
  |> ordered_unique_strings

let combine_t_make_pipeline_contract left right =
  match left, right with
  | BuildRequested, _
  | _, BuildRequested -> BuildRequested
  | PopulateBuildUnknown, _
  | _, PopulateBuildUnknown -> PopulateBuildUnknown
  | PopulateWithoutBuild, _
  | _, PopulateWithoutBuild -> PopulateWithoutBuild
  | MissingPipelineBuildCall, MissingPipelineBuildCall -> MissingPipelineBuildCall

let extract_bool_literal = function
  | { node = Value (VBool b); _ } -> Some b
  | _ -> None

let populate_pipeline_contract args =
  let rec find_named_build = function
    | [] -> None
    | (Some "build", expr) :: _ -> Some (extract_bool_literal expr)
    | _ :: rest -> find_named_build rest
  in
  match find_named_build args with
  | Some (Some true) -> BuildRequested
  | Some (Some false) -> PopulateWithoutBuild
  | Some None -> PopulateBuildUnknown
  | None ->
      let positional_args =
        List.filter_map (fun (name_opt, expr) ->
          match name_opt with
          | None -> Some expr
          | Some _ -> None) args
      in
      match positional_args with
      | _pipeline_arg :: build_arg :: _ ->
          begin match extract_bool_literal build_arg with
          | Some true -> BuildRequested
          | Some false -> PopulateWithoutBuild
          | None -> PopulateBuildUnknown
          end
      | _ ->
          (* `populate_pipeline` defaults `build` to false when the second
             positional or named argument is omitted. *)
          PopulateWithoutBuild

let rec analyze_expr_for_pipeline_call expr =
  match expr.node with
  | Call { fn = { node = Var "build_pipeline"; _ }; args } ->
      List.fold_left
        (fun acc (_, arg) -> combine_t_make_pipeline_contract acc (analyze_expr_for_pipeline_call arg))
        BuildRequested
        args
  | Call { fn = { node = Var "populate_pipeline"; _ }; args } ->
      List.fold_left
        (fun acc (_, arg) -> combine_t_make_pipeline_contract acc (analyze_expr_for_pipeline_call arg))
        (populate_pipeline_contract args)
        args
  | Call { fn; args } ->
      List.fold_left
        (fun acc (_, arg) -> combine_t_make_pipeline_contract acc (analyze_expr_for_pipeline_call arg))
        (analyze_expr_for_pipeline_call fn)
        args
  | BinOp { left; right; _ }
  | BroadcastOp { left; right; _ } ->
      combine_t_make_pipeline_contract
        (analyze_expr_for_pipeline_call left)
        (analyze_expr_for_pipeline_call right)
  | IfElse { cond; then_; else_ } ->
      combine_t_make_pipeline_contract
        (analyze_expr_for_pipeline_call cond)
        (combine_t_make_pipeline_contract
           (analyze_expr_for_pipeline_call then_)
           (analyze_expr_for_pipeline_call else_))
  | Match { scrutinee; cases } ->
      List.fold_left
        (fun acc (_, body) -> combine_t_make_pipeline_contract acc (analyze_expr_for_pipeline_call body))
        (analyze_expr_for_pipeline_call scrutinee)
        cases
  | Lambda { body; _ } -> analyze_expr_for_pipeline_call body
  | ListLit items ->
      List.fold_left
        (fun acc (_, item) -> combine_t_make_pipeline_contract acc (analyze_expr_for_pipeline_call item))
        MissingPipelineBuildCall
        items
  | DictLit pairs ->
      List.fold_left
        (fun acc (_, item) -> combine_t_make_pipeline_contract acc (analyze_expr_for_pipeline_call item))
        MissingPipelineBuildCall
        pairs
  | UnOp { operand; _ }
  | DotAccess { target = operand; _ }
  | Unquote operand
  | UnquoteSplice operand ->
      analyze_expr_for_pipeline_call operand
  | PipelineDef nodes
  | IntentDef nodes ->
      List.fold_left
        (fun acc (_, item) -> combine_t_make_pipeline_contract acc (analyze_expr_for_pipeline_call item))
        MissingPipelineBuildCall
        nodes
  | ListComp { expr; clauses } ->
      let clause_contract =
        List.fold_left
          (fun acc clause ->
            let clause_expr =
              match clause with
              | CFor { iter; _ } -> analyze_expr_for_pipeline_call iter
              | CFilter filter_expr -> analyze_expr_for_pipeline_call filter_expr
            in
            combine_t_make_pipeline_contract acc clause_expr)
          MissingPipelineBuildCall
          clauses
      in
      combine_t_make_pipeline_contract clause_contract (analyze_expr_for_pipeline_call expr)
  | Block stmts ->
      List.fold_left
        (fun acc stmt -> combine_t_make_pipeline_contract acc (analyze_stmt_for_pipeline_call stmt))
        MissingPipelineBuildCall
        stmts
  | Value _
  | Var _
  | ColumnRef _
  | RawCode _
  | ShellExpr _ -> MissingPipelineBuildCall

and analyze_stmt_for_pipeline_call stmt =
  match stmt.node with
  | Expression expr
  | Assignment { expr; _ }
  | Reassignment { expr; _ } ->
      analyze_expr_for_pipeline_call expr
  | Import _
  | ImportPackage _
  | ImportFrom _
  | ImportFileFrom _ -> MissingPipelineBuildCall

let analyze_program_for_pipeline_call (program : program) =
  List.fold_left
    (fun acc stmt -> combine_t_make_pipeline_contract acc (analyze_stmt_for_pipeline_call stmt))
    MissingPipelineBuildCall
    program

(** Extract user-authored top-level bindings from a script so pipeline entry
    reloads can clear them before reevaluation. Internal framework keys are
    excluded, and names are deduplicated while preserving their first-seen
    order to keep cleanup predictable. *)
let top_level_assigned_names (program : program) =
  List.filter_map (fun stmt ->
    match stmt.node with
    | Assignment { name; _ }
    | Reassignment { name; _ } when not (is_internal_key name) ->
        Some name
    | _ -> None
  ) program
  |> sanitize_pipeline_entry_binding_names

let normalize_relative_path filename =
  if not (Filename.is_relative filename) then
    None
  else
    let normalized_separators =
      String.map (fun c -> if c = '\\' then '/' else c) filename
    in
    let components =
      String.split_on_char '/' normalized_separators
    in
    let rec normalize acc = function
      | [] -> Some (List.rev acc)
      | "" :: rest
      | "." :: rest -> normalize acc rest
      | ".." :: rest ->
          begin match acc with
          | [] -> None
          | _ :: acc_rest -> normalize acc_rest rest
          end
      | part :: rest -> normalize (part :: acc) rest
    in
    match normalize [] components with
    | Some parts -> Some (String.concat Filename.dir_sep parts)
    | None -> None

let is_pipeline_entry_file filename =
  let expected = Filename.concat "src" "pipeline.t" in
  match normalize_relative_path filename with
  | Some normalized -> normalized = expected
  | None -> false

let get_pipeline_entry_binding_names (env : environment) =
  match Env.find_opt pipeline_entry_bindings_key env with
  | Some (VList values) ->
      values
      |> List.filter_map (fun (_, value) ->
        match value with
        | VString name -> Some name
        | _ -> None)
      |> sanitize_pipeline_entry_binding_names
  | _ -> []

let set_pipeline_entry_binding_names (env : environment) names =
  Env.add pipeline_entry_bindings_key
    (VList (sanitize_pipeline_entry_binding_names names |> List.map (fun name -> (None, VString name))))
    env

let reload_env_for_pipeline_entry ~filename (program : program) (env : environment) =
  if is_pipeline_entry_file filename then
    let names_to_remove =
      ordered_unique_strings
        (get_pipeline_entry_binding_names env @ top_level_assigned_names program)
    in
    List.fold_left (fun acc name -> Env.remove name acc) env names_to_remove
  else
    env

let remember_pipeline_entry_bindings ~filename (program : program) (env : environment) =
  if is_pipeline_entry_file filename then
    set_pipeline_entry_binding_names env (top_level_assigned_names program)
  else
    env

let validate_t_make_filename filename =
  if is_pipeline_entry_file filename then
    Ok ()
  else
    Error "Function `t_make` requires the pipeline entrypoint to be `src/pipeline.t`."

(** Validate that the parsed pipeline program contains an explicit build or
    populate call.  Returns:
    - [Ok None]           — proceed, no diagnostic needed (build requested).
    - [Ok (Some warning)] — proceed, but emit [warning] on stderr.
    - [Error msg]         — abort with [msg] as a ValueError. *)
let validate_t_make_program (program : program) =
  match analyze_program_for_pipeline_call program with
  | BuildRequested -> Ok None
  | PopulateBuildUnknown ->
      Ok
        (Some
           "Warning: `t_make()` found `populate_pipeline(...)` with a non-literal `build` argument, so it could not confirm whether a build was requested. Use `build=true` or `build_pipeline(...)` to make the build intent explicit.\n")
  | PopulateWithoutBuild ->
      Ok
        (Some
           "Warning: `t_make()` found `populate_pipeline(...)` without `build=true`, so the pipeline will only be populated. Use `populate_pipeline(..., build=true)` or `build_pipeline(...)` to request a build.\n")
  | MissingPipelineBuildCall ->
      Error
        "Function `t_make` requires `src/pipeline.t` to call `populate_pipeline(...)` or `build_pipeline(...)`."