@@ -28,11 +28,14 @@ defmodule Unified.SpecCompliance.Evidence do
2828
2929 @ spec run_with_cache ( [ map ( ) ] , String . t ( ) , Keyword . t ( ) , String . t ( ) , map ( ) ) :: { [ map ( ) ] , map ( ) }
3030 def run_with_cache ( evidence , root , opts , requirement_id , cache ) do
31- run_commands? = Keyword . get ( opts , :run_commands , true )
31+ command_opts = % {
32+ run_commands: Keyword . get ( opts , :run_commands , true ) ,
33+ shell: Keyword . get ( opts , :shell )
34+ }
3235
3336 Enum . reduce ( evidence , { [ ] , cache } , fn item , { findings , acc_cache } ->
3437 { item_findings , next_cache } =
35- run_item_with_cache ( item , root , run_commands? , requirement_id , acc_cache )
38+ run_item_with_cache ( item , root , command_opts , requirement_id , acc_cache )
3639
3740 { findings ++ item_findings , next_cache }
3841 end )
@@ -101,7 +104,7 @@ defmodule Unified.SpecCompliance.Evidence do
101104 ]
102105 end
103106
104- defp run_item ( % { "kind" => "path_exists" , "path" => path } , root , _run_commands? , requirement_id ) do
107+ defp run_item ( % { "kind" => "path_exists" , "path" => path } , root , _command_opts , requirement_id ) do
105108 absolute_path = Path . expand ( path , root )
106109
107110 if File . exists? ( absolute_path ) do
@@ -119,7 +122,7 @@ defmodule Unified.SpecCompliance.Evidence do
119122 end
120123 end
121124
122- defp run_item ( % { "kind" => "path_absent" , "path" => path } , root , _run_commands? , requirement_id ) do
125+ defp run_item ( % { "kind" => "path_absent" , "path" => path } , root , _command_opts , requirement_id ) do
123126 absolute_path = Path . expand ( path , root )
124127
125128 if File . exists? ( absolute_path ) do
@@ -140,7 +143,7 @@ defmodule Unified.SpecCompliance.Evidence do
140143 defp run_item (
141144 % { "kind" => "path_glob_nonempty" , "glob" => glob } ,
142145 root ,
143- _run_commands? ,
146+ _command_opts ,
144147 requirement_id
145148 ) do
146149 if root |> Path . join ( glob ) |> Path . wildcard ( ) |> Enum . any? ( ) do
@@ -158,7 +161,7 @@ defmodule Unified.SpecCompliance.Evidence do
158161 end
159162 end
160163
161- defp run_item ( % { "kind" => "command" } = item , _root , false , requirement_id ) do
164+ defp run_item ( % { "kind" => "command" } = item , _root , % { run_commands: false } , requirement_id ) do
162165 [
163166 % {
164167 code: "command_skipped" ,
@@ -171,81 +174,101 @@ defmodule Unified.SpecCompliance.Evidence do
171174 ]
172175 end
173176
174- defp run_item ( % { "kind" => "command" } = item , root , true , requirement_id ) do
177+ defp run_item (
178+ % { "kind" => "command" } = item ,
179+ root ,
180+ % { run_commands: true , shell: shell_override } ,
181+ requirement_id
182+ ) do
175183 cwd = Path . expand ( item [ "cwd" ] || "." , root )
176184 expected_exit_status = item [ "expect_exit_status" ] || 0
177185 expected_stdout = item [ "expect_stdout_contains" ]
178186
179- { output , exit_status } =
180- System . cmd ( "zsh" , [ "-lc" , item [ "run" ] ] , cd: cwd , stderr_to_stdout: true )
181-
182- findings = [ ]
183-
184- findings =
185- if exit_status == expected_exit_status do
186- findings
187- else
188- [
189- % {
190- code: "command_exit_status_mismatch" ,
191- severity: :error ,
192- requirement_id: requirement_id ,
193- command: item [ "run" ] ,
194- message:
195- "Expected command #{ inspect ( item [ "run" ] ) } to exit with #{ expected_exit_status } , got #{ exit_status } "
196- }
197- | findings
198- ]
199- end
200-
201- findings =
202- if is_binary ( expected_stdout ) and not String . contains? ( output , expected_stdout ) do
187+ case command_shell ( root , shell_override ) do
188+ { :ok , shell } ->
189+ { output , exit_status } =
190+ System . cmd ( shell , [ "-c" , item [ "run" ] ] , cd: cwd , stderr_to_stdout: true )
191+
192+ findings = [ ]
193+
194+ findings =
195+ if exit_status == expected_exit_status do
196+ findings
197+ else
198+ [
199+ % {
200+ code: "command_exit_status_mismatch" ,
201+ severity: :error ,
202+ requirement_id: requirement_id ,
203+ command: item [ "run" ] ,
204+ message:
205+ "Expected command #{ inspect ( item [ "run" ] ) } to exit with #{ expected_exit_status } , got #{ exit_status } "
206+ }
207+ | findings
208+ ]
209+ end
210+
211+ findings =
212+ if is_binary ( expected_stdout ) and not String . contains? ( output , expected_stdout ) do
213+ [
214+ % {
215+ code: "command_stdout_mismatch" ,
216+ severity: :error ,
217+ requirement_id: requirement_id ,
218+ command: item [ "run" ] ,
219+ message:
220+ "Expected command #{ inspect ( item [ "run" ] ) } output to include #{ inspect ( expected_stdout ) } "
221+ }
222+ | findings
223+ ]
224+ else
225+ findings
226+ end
227+
228+ Enum . reverse ( findings )
229+
230+ { :error , message } ->
203231 [
204232 % {
205- code: "command_stdout_mismatch " ,
233+ code: "command_shell_unavailable " ,
206234 severity: :error ,
207235 requirement_id: requirement_id ,
208236 command: item [ "run" ] ,
209- message:
210- "Expected command #{ inspect ( item [ "run" ] ) } output to include #{ inspect ( expected_stdout ) } "
237+ message: message
211238 }
212- | findings
213239 ]
214- else
215- findings
216- end
217-
218- Enum . reverse ( findings )
240+ end
219241 end
220242
221- defp run_item ( _item , _root , _run_commands? , _requirement_id ) , do: [ ]
243+ defp run_item ( _item , _root , _command_opts , _requirement_id ) , do: [ ]
222244
223245 defp run_item_with_cache (
224246 % { "kind" => "command" } = item ,
225247 root ,
226- run_commands? ,
248+ command_opts ,
227249 requirement_id ,
228250 cache
229251 ) do
230- key = command_cache_key ( item , root , run_commands? )
252+ key = command_cache_key ( item , root , command_opts )
231253
232254 case Map . fetch ( cache , key ) do
233255 { :ok , cached_findings } ->
234256 { restore_requirement_id ( cached_findings , requirement_id ) , cache }
235257
236258 :error ->
237- findings = run_item ( item , root , run_commands? , requirement_id )
259+ findings = run_item ( item , root , command_opts , requirement_id )
238260 { findings , Map . put ( cache , key , strip_requirement_id ( findings ) ) }
239261 end
240262 end
241263
242- defp run_item_with_cache ( item , root , run_commands? , requirement_id , cache ) do
243- { run_item ( item , root , run_commands? , requirement_id ) , cache }
264+ defp run_item_with_cache ( item , root , command_opts , requirement_id , cache ) do
265+ { run_item ( item , root , command_opts , requirement_id ) , cache }
244266 end
245267
246- defp command_cache_key ( item , root , run_commands? ) do
268+ defp command_cache_key ( item , root , command_opts ) do
247269 {
248- run_commands? ,
270+ command_opts . run_commands ,
271+ command_opts . shell ,
249272 Path . expand ( item [ "cwd" ] || "." , root ) ,
250273 item [ "run" ] ,
251274 item [ "expect_exit_status" ] || 0 ,
@@ -261,6 +284,51 @@ defmodule Unified.SpecCompliance.Evidence do
261284 Enum . map ( findings , & Map . put ( & 1 , :requirement_id , requirement_id ) )
262285 end
263286
287+ defp command_shell ( root , shell_override ) do
288+ candidates =
289+ [
290+ shell_override ,
291+ System . get_env ( "SHELL" ) ,
292+ System . find_executable ( "zsh" ) ,
293+ System . find_executable ( "bash" ) ,
294+ System . find_executable ( "sh" )
295+ ]
296+ |> Enum . flat_map ( & normalize_shell_candidate ( & 1 , root ) )
297+ |> Enum . uniq ( )
298+
299+ case Enum . find ( candidates , & File . exists? / 1 ) do
300+ nil ->
301+ { :error ,
302+ "No supported command shell was found. Expected one of SHELL, zsh, bash, or sh to be available" }
303+
304+ shell ->
305+ { :ok , shell }
306+ end
307+ end
308+
309+ defp normalize_shell_candidate ( nil , _root ) , do: [ ]
310+ defp normalize_shell_candidate ( "" , _root ) , do: [ ]
311+
312+ defp normalize_shell_candidate ( shell , root ) do
313+ shell
314+ |> String . trim ( )
315+ |> case do
316+ "" ->
317+ [ ]
318+
319+ value ->
320+ expanded =
321+ if Path . type ( value ) == :absolute do
322+ value
323+ else
324+ Path . expand ( value , root )
325+ end
326+
327+ [ value , System . find_executable ( value ) , expanded ]
328+ |> Enum . filter ( & ( & 1 != "" ) )
329+ end
330+ end
331+
264332 defp require_string ( map , key , file , requirement_id , code ) do
265333 case map [ key ] do
266334 value when is_binary ( value ) and value != "" ->
0 commit comments