hector-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hector/pipeline.py ADDED
@@ -0,0 +1,693 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """
3
+ The conductor: CLI parsing and the per-job pipeline that ties the modules together.
4
+
5
+ Phases per job:
6
+ 1 build module types (modules.py) + run the global build steps
7
+ 1.5 instantiate peripherals, resolve mappings + connections, finalize repl/commands
8
+ 2 generate one emulation resc; then simulate / debug / test
9
+ 3 sweep artifacts
10
+ """
11
+
12
+ import copy
13
+ import os
14
+ import shlex
15
+ import sys
16
+ from dataclasses import dataclass, field
17
+
18
+ from . import connections as conn
19
+ from . import core as _core
20
+ from . import mappings as maps
21
+ from . import peripherals as periph
22
+ from .core import (ARTIFACTS_DIR, DEFAULT_QUANTUM, SUPPORTED_SCHEMA_VERSIONS,
23
+ RuntimeOptions, as_lines, expand_matrix, interpolate, load_yaml,
24
+ resolve_arguments, to_container_path)
25
+ from .dependencies import framework_vars, prepare_dependencies
26
+ from .docker import build_renode_argv, run_renode
27
+ from . import generator as gen
28
+ from .generator import (collect_artifacts, finalize_commands, generate_emulation_resc,
29
+ snapshot_resc)
30
+ from .hubs import HUBS
31
+ from .modules import BuildContext, build_modules
32
+ from .reporters import emit
33
+ from .runners import RunnerContext, run_build_steps, run_tests
34
+ from .validator import print_issues, validate_config
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Shared engine
39
+ #
40
+ # The CLI surface (argparse subcommands) lives in hector.commands; each command
41
+ # is a small module that parses its own flags and then calls the helpers below to
42
+ # load the config, assemble a JobConfig, and run the matrix. Adding a command is
43
+ # adding one module — no changes here.
44
+ # ---------------------------------------------------------------------------
45
+
46
+ def apply_global_flags(args):
47
+ """Honour --workspace-mount / --no-docker before anything touches paths."""
48
+ if getattr(args, "workspace_mount", None):
49
+ _core.WORKSPACE_MOUNT = args.workspace_mount
50
+ if getattr(args, "no_docker", False):
51
+ _core.NO_DOCKER = True
52
+ _core.WORKSPACE_MOUNT = os.getcwd()
53
+
54
+
55
+ def load_config_or_exit():
56
+ """Load and sanity-check ./.hector.yaml, or print an error and exit."""
57
+ config_path = ".hector.yaml"
58
+ if not os.path.exists(config_path):
59
+ print(f"[ERROR] No '{config_path}' in the current directory ({os.getcwd()}).\n"
60
+ " Run hector from a project that has a .hector.yaml, or create one "
61
+ "with 'hector init'.", file=sys.stderr)
62
+ sys.exit(1)
63
+ try:
64
+ config = load_yaml(config_path)
65
+ except Exception as exc:
66
+ print(f"[ERROR] Could not parse '{config_path}': {exc}", file=sys.stderr)
67
+ sys.exit(1)
68
+ if not isinstance(config, dict):
69
+ print(f"[ERROR] '{config_path}' must be a YAML mapping (got "
70
+ f"{type(config).__name__}).", file=sys.stderr)
71
+ sys.exit(1)
72
+ v = config.get("version")
73
+ if v is not None and str(v) not in SUPPORTED_SCHEMA_VERSIONS:
74
+ print(f"[WARN] .hector.yaml 'version: {v}' is not in supported versions "
75
+ f"({', '.join(sorted(SUPPORTED_SCHEMA_VERSIONS))}). Proceeding anyway.")
76
+ return config
77
+
78
+
79
+ def resolve_renode_image(config, args):
80
+ """Pin the Renode version from --renode-version or the config; return
81
+ (git_tag, docker_image)."""
82
+ renode_version = getattr(args, "renode_version", None) or config.get("renode_version")
83
+ if not renode_version:
84
+ raise ValueError("Top-level 'renode_version' is required (e.g. '1.16.1'), "
85
+ "or pass --renode-version on the command line.")
86
+ normalized = str(renode_version).lstrip("v")
87
+ return f"v{normalized}", f"antmicro/renode:{normalized}"
88
+
89
+
90
+ def _resolve_set_overrides(args):
91
+ overrides = {}
92
+ for kv in (getattr(args, "set_args", None) or []):
93
+ if "=" not in kv:
94
+ print(f"[ERROR] --set: '{kv}' is not KEY=VALUE", file=sys.stderr)
95
+ sys.exit(1)
96
+ k, _, v = kv.partition("=")
97
+ overrides[k.strip()] = v
98
+ return overrides
99
+
100
+
101
+ def _needs_renode_source(config):
102
+ """The Renode source / verilator-integration checkouts are only needed to build
103
+ `modules:` (they compile against Renode) or to interpolate ${RENODE_DIR} /
104
+ ${INTEGRATION_DIR}. The Renode binary itself comes from the Docker image, so plain
105
+ machines, build/shell steps and firmware URLs need neither checkout."""
106
+ if config.get("modules"):
107
+ return True
108
+ blob = str(config)
109
+ return "RENODE_DIR" in blob or "INTEGRATION_DIR" in blob
110
+
111
+
112
+ def build_job_config(action, config, args, *, test_mode=False):
113
+ """Assemble a JobConfig from parsed args — the shared run / test / export path:
114
+ pin the image, prepare the framework (lazily), resolve arguments + artifacts."""
115
+ git_tag, docker_image = resolve_renode_image(config, args)
116
+
117
+ # Clone Renode only when the config actually needs the source; otherwise skip it
118
+ # entirely (a ~200 MB clone) and leave the framework vars empty.
119
+ if _needs_renode_source(config):
120
+ renode_dir, integration_dir = prepare_dependencies(
121
+ git_tag,
122
+ renode_dir=getattr(args, "renode_dir", None),
123
+ integration_dir=getattr(args, "renode_integration_dir", None),
124
+ )
125
+ fw_vars = framework_vars(renode_dir, integration_dir)
126
+ fw_vars["_integration_dir_host"] = integration_dir
127
+ else:
128
+ fw_vars = {"RENODE_DIR": "", "INTEGRATION_DIR": "", "_integration_dir_host": ""}
129
+
130
+ resolved_args = resolve_arguments(config.get("arguments", {}),
131
+ overrides=_resolve_set_overrides(args))
132
+ artifacts_cfg = (args.artifacts if getattr(args, "artifacts", None)
133
+ else as_lines(config.get("artifacts", [])))
134
+ return JobConfig(
135
+ config=config,
136
+ fw_vars=fw_vars,
137
+ resolved_args=resolved_args,
138
+ docker_image=docker_image,
139
+ action=action,
140
+ test_mode=test_mode,
141
+ debug_args=getattr(args, "debug", None) or [],
142
+ test_file=getattr(args, "test_file", None),
143
+ reporter_names=_resolve_reporters(getattr(args, "reporters", None)),
144
+ renode_args=shlex.split(getattr(args, "renode_args", "") or ""),
145
+ renode_test_args=shlex.split(getattr(args, "renode_test_args", "") or ""),
146
+ test_name=getattr(args, "test_name", None),
147
+ live=getattr(args, "live", False),
148
+ fail_fast=getattr(args, "fail_fast", False),
149
+ snapshot=getattr(args, "snapshot", None),
150
+ output_dir=getattr(args, "output", "results"),
151
+ gather_metrics=getattr(args, "gather_execution_metrics", None),
152
+ artifacts=artifacts_cfg,
153
+ )
154
+
155
+
156
+ def execute_jobs(cfg, args):
157
+ """Run exactly one matrix combination and return a process exit code.
158
+
159
+ hector does NOT auto-expand the matrix — iterating combinations is the CI's job.
160
+ A single invocation runs one combination: with no matrix there is one (empty) job;
161
+ with a matrix, --job KEY=VALUE must pin it to exactly one combination."""
162
+ jobs = expand_matrix(cfg.config.get("matrix"))
163
+ if getattr(args, "job", None):
164
+ jobs = filter_jobs(jobs, args.job)
165
+
166
+ if len(jobs) != 1:
167
+ listing = "\n ".join(_job_label(j) for j in jobs)
168
+ print(f"[ERROR] The matrix produces {len(jobs)} combinations; a run targets one. "
169
+ "Select it with --job KEY=VALUE (e.g. --job BOARD=stm32f4).\n"
170
+ f" Combinations:\n {listing}", file=sys.stderr)
171
+ return 2
172
+
173
+ result = run_job(0, 1, jobs[0], cfg)
174
+ if cfg.test_mode:
175
+ print_summary([result], cfg.test_mode)
176
+ return 1 if not result["passed"] else 0
177
+
178
+
179
+ def snapshot_fastpath(args):
180
+ """`run --snapshot --renode-version` with no .hector.yaml: write a snapshot
181
+ loader resc and boot it directly, skipping config/dependency machinery."""
182
+ if not os.path.isfile(args.snapshot):
183
+ print(f"[ERROR] Snapshot not found: {args.snapshot}", file=sys.stderr)
184
+ sys.exit(1)
185
+ normalized = args.renode_version.lstrip("v")
186
+ docker_image = f"antmicro/renode:{normalized}"
187
+ resc_root = os.path.join(ARTIFACTS_DIR, "resc")
188
+ os.makedirs(resc_root, exist_ok=True)
189
+ # No .hector.yaml here, so debug machine names come straight from --debug.
190
+ debug_config = parse_debug_flag(getattr(args, "debug", None), {}, 1, validate_nodes=False)
191
+ snap_resc = os.path.join(resc_root, "snapshot.resc")
192
+ with open(snap_resc, "w") as f:
193
+ f.write(snapshot_resc(to_container_path(args.snapshot), debug_config))
194
+ print(f"[SNAP] {args.snapshot}"
195
+ + (f" (+GDB: {debug_config})" if debug_config else ""))
196
+ run_renode(snap_resc, docker_image, ports=list(debug_config.values()) or None,
197
+ extra_args=shlex.split(args.renode_args or ""), output_dir=args.output)
198
+
199
+
200
+ def _run_validate():
201
+ """Load .hector.yaml and report all config errors, then exit."""
202
+ config_path = ".hector.yaml"
203
+ if not os.path.exists(config_path):
204
+ print(f"[VALIDATE] {config_path} not found in current directory.")
205
+ sys.exit(1)
206
+ try:
207
+ config = load_yaml(config_path)
208
+ except Exception as exc:
209
+ print(f"[VALIDATE] Could not parse {config_path}: {exc}")
210
+ sys.exit(1)
211
+
212
+ print(f"[VALIDATE] Checking {config_path} ...")
213
+ errors, warnings = validate_config(config)
214
+ has_errors = print_issues(errors, warnings)
215
+ if has_errors:
216
+ print(f"\n[VALIDATE] FAILED — {len(errors)} error(s), {len(warnings)} warning(s).")
217
+ sys.exit(1)
218
+ elif warnings:
219
+ print(f"\n[VALIDATE] OK — 0 errors, {len(warnings)} warning(s).")
220
+ else:
221
+ print("[VALIDATE] OK")
222
+ sys.exit(0)
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Helpers
227
+ # ---------------------------------------------------------------------------
228
+
229
+ def _resolve_reporters(reporters):
230
+ """Flatten the repeatable --reporters flag into an ordered, de-duplicated list.
231
+
232
+ Defaults to ['junit'] when the flag is absent. Each value may still contain
233
+ commas (e.g. --reporters junit,json) so older invocations keep working.
234
+ """
235
+ names = []
236
+ for entry in (reporters or ["junit"]):
237
+ for name in str(entry).split(","):
238
+ name = name.strip()
239
+ if name and name not in names:
240
+ names.append(name)
241
+ return names or ["junit"]
242
+
243
+
244
+ def build_hubs_registry(hubs_cfg):
245
+ registry = {}
246
+ for hub_name, hub_spec in (hubs_cfg or {}).items():
247
+ hub_spec = hub_spec or {}
248
+ htype = hub_spec.get("type")
249
+ if not htype:
250
+ raise ValueError(f"Hub '{hub_name}' is missing required 'type' "
251
+ f"({', '.join(sorted(HUBS.keys()))}).")
252
+ if htype not in HUBS:
253
+ raise ValueError(f"Hub '{hub_name}': unknown type '{htype}'. "
254
+ f"Available: {', '.join(sorted(HUBS.keys()))}.")
255
+ registry[hub_name] = htype
256
+ return registry
257
+
258
+
259
+ def parse_debug_flag(debug_args, concrete_nodes, job_index, validate_nodes=True):
260
+ debug_config = {}
261
+ for entry in (debug_args or []):
262
+ if ":" not in entry:
263
+ raise ValueError(f"--debug: '{entry}' is not NODE:PORT (e.g. boardA:3333).")
264
+ node_name, _, port_str = entry.partition(":")
265
+ if not port_str.isdigit():
266
+ raise ValueError(f"--debug: port in '{entry}' must be a number.")
267
+ # In the snapshot fast path there is no .hector.yaml to validate against; the
268
+ # machine name is taken from the snapshot, so skip the membership check.
269
+ if validate_nodes and node_name not in concrete_nodes:
270
+ print(f"[WARN] --debug: '{node_name}' not in machines for job {job_index}, skipping.")
271
+ continue
272
+ debug_config[node_name] = int(port_str)
273
+ print(f"[DEBUG] {node_name} -> GDB on port {port_str} (CPU will be halted)")
274
+ return debug_config
275
+
276
+
277
+ def _job_label(job):
278
+ """Human-readable matrix combination, e.g. 'BOARD=stm32f4 FW=debug.elf'."""
279
+ return " ".join(f"{k}={v}" for k, v in job.items()) or "(no matrix variables)"
280
+
281
+
282
+ def filter_jobs(jobs, spec):
283
+ """Filter the expanded jobs by --job SPEC: KEY=VALUE[,KEY=VALUE,...], matched against
284
+ the matrix variable values. Values compare as strings, so --job RATE=2 matches a
285
+ numeric 2 in the matrix."""
286
+ filters = {}
287
+ for part in spec.split(","):
288
+ part = part.strip()
289
+ if "=" not in part:
290
+ raise ValueError(f"--job: '{part}' is not KEY=VALUE.")
291
+ k, v = part.split("=", 1)
292
+ filters[k.strip()] = v.strip()
293
+
294
+ filtered = [j for j in jobs
295
+ if all(str(j.get(k)) == v for k, v in filters.items())]
296
+ if not filtered:
297
+ available = "\n ".join(_job_label(j) for j in jobs) if jobs else "(none)"
298
+ raise ValueError(
299
+ f"--job: no matrix combination matches {filters}.\n"
300
+ f" Combinations:\n {available}")
301
+ return filtered
302
+
303
+
304
+ def _apply_metrics(concrete_nodes, gather_metrics, output_dir, job_index):
305
+ """Inject cpu EnableProfiler commands into node configs for requested machines."""
306
+ all_machines = None in gather_metrics
307
+ target_nodes = (list(concrete_nodes.keys()) if all_machines
308
+ else [n for n in gather_metrics if n is not None])
309
+ for node_name in target_nodes:
310
+ if node_name not in concrete_nodes:
311
+ print(f"[WARN] --gather-execution-metrics: '{node_name}' not in "
312
+ f"nodes for job {job_index}, skipping.")
313
+ continue
314
+ node_config = concrete_nodes[node_name]
315
+ metrics_file = os.path.join(output_dir, f"metrics_{node_name}_job_{job_index}.bin")
316
+ os.makedirs(output_dir, exist_ok=True)
317
+ cmd = f"cpu EnableProfiler @{to_container_path(metrics_file)}"
318
+ existing = node_config.get("commands", "")
319
+ if existing:
320
+ if isinstance(existing, list):
321
+ existing = "\n".join(str(c) for c in existing)
322
+ node_config["commands"] = existing.strip() + f"\n{cmd}"
323
+ else:
324
+ node_config["commands"] = cmd
325
+ print(f"[METRICS] {node_name} -> {metrics_file}")
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Per-job pipeline
330
+ # ---------------------------------------------------------------------------
331
+
332
+ @dataclass
333
+ class JobConfig:
334
+ config: dict
335
+ fw_vars: dict
336
+ resolved_args: dict
337
+ docker_image: str
338
+ # PHASE-2 behaviour for this command (simulate / test / export). run_job builds
339
+ # every job up to a generated resc, then hands a JobState to this callable.
340
+ action: object
341
+ debug_args: list = field(default_factory=list)
342
+ # Only the test command lays out results test-by-test; drives the summary format.
343
+ test_mode: bool = False
344
+ test_file: str = None
345
+ reporter_names: list = field(default_factory=lambda: ["junit"])
346
+ renode_args: list = field(default_factory=list)
347
+ renode_test_args: list = field(default_factory=list)
348
+ test_name: list = None
349
+ live: bool = False
350
+ fail_fast: bool = False
351
+ snapshot: str = None
352
+ output_dir: str = "results"
353
+ gather_metrics: list = None
354
+ artifacts: list = field(default_factory=list)
355
+
356
+
357
+ @dataclass
358
+ class JobState:
359
+ """Everything a command's PHASE-2 action needs once a job has been assembled
360
+ into a generated emulation resc."""
361
+ resc_filename: str
362
+ runtime: RuntimeOptions
363
+ debug_config: dict
364
+ tests: list
365
+ concrete_nodes: dict
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # PHASE-2 actions (one per command; selected via JobConfig.action)
370
+ # ---------------------------------------------------------------------------
371
+
372
+ def _resolve_active_resc(state, cfg):
373
+ """The resc a simulation/export should boot: the generated one, or a snapshot
374
+ loader written on the fly when --snapshot is given."""
375
+ if not cfg.snapshot:
376
+ return state.resc_filename
377
+ if not os.path.isfile(cfg.snapshot):
378
+ raise FileNotFoundError(f"Snapshot not found: {cfg.snapshot}")
379
+ snap_resc = state.resc_filename.replace(".resc", "_snapshot.resc")
380
+ with open(snap_resc, "w") as f:
381
+ f.write(snapshot_resc(to_container_path(cfg.snapshot), state.debug_config))
382
+ print(f"[SNAP] {cfg.snapshot}"
383
+ + (f" (+GDB: {state.debug_config})" if state.debug_config else ""))
384
+ return snap_resc
385
+
386
+
387
+ def action_simulate(state, cfg, result):
388
+ """`run`: boot the emulation interactively in Renode."""
389
+ if not state.resc_filename:
390
+ raise ValueError("`hector run` needs at least one machine in 'machines:'.")
391
+ run_renode(_resolve_active_resc(state, cfg), cfg.docker_image,
392
+ ports=list(state.debug_config.values()) or None,
393
+ extra_args=cfg.renode_args, output_dir=cfg.output_dir,
394
+ runtime=state.runtime)
395
+
396
+
397
+ def action_export(state, cfg, result):
398
+ """`export`: generate the files, then print the command that would run instead
399
+ of running it (a `docker run ... renode ...`, or a bare `renode ...` under
400
+ --no-docker)."""
401
+ if not state.resc_filename:
402
+ raise ValueError("`hector export` needs at least one machine in 'machines:'.")
403
+ argv = build_renode_argv(_resolve_active_resc(state, cfg), cfg.docker_image,
404
+ ports=list(state.debug_config.values()) or None,
405
+ extra_args=cfg.renode_args, runtime=state.runtime)
406
+ line = shlex.join(argv)
407
+ print(line)
408
+ result.setdefault("exported", []).append(line)
409
+
410
+
411
+ def action_test(state, cfg, result):
412
+ """`test`: run the resolved `tests:` section and record pass/fail."""
413
+ effective_tests = state.tests
414
+ if cfg.test_file:
415
+ effective_tests = [{"name": os.path.basename(cfg.test_file),
416
+ "type": "robot", "file": cfg.test_file}]
417
+ if cfg.test_name:
418
+ effective_tests = [t for t in effective_tests
419
+ if any(n in t.get("name", "") for n in cfg.test_name)]
420
+ if not effective_tests:
421
+ print(f"[TEST] No tests matching {cfg.test_name}.")
422
+ if not effective_tests:
423
+ print("[TEST] No tests defined in 'tests:'; nothing to run.")
424
+ return
425
+ ctx = RunnerContext(
426
+ resc_host_path=state.resc_filename,
427
+ job_index=result["job_index"],
428
+ image=cfg.docker_image,
429
+ reporters=cfg.reporter_names,
430
+ renode_test_args=cfg.renode_test_args,
431
+ live=cfg.live,
432
+ snapshot=cfg.snapshot or "",
433
+ results_dir=cfg.output_dir,
434
+ runtime=state.runtime,
435
+ )
436
+ # run_job combines these with the build results and emits the reports once.
437
+ result["test_results"] = run_tests(effective_tests, ctx, fail_fast=cfg.fail_fast)
438
+
439
+
440
+ def run_job(i, total_jobs, job_context, cfg):
441
+ """Execute one matrix job. Returns a result dict."""
442
+ label = " ".join(f"{k}={v}" for k, v in job_context.items()) if job_context else "(no matrix variables)"
443
+ if total_jobs > 1:
444
+ print(f"\n[JOB {i+1}/{total_jobs}] {label}")
445
+
446
+ context = {**cfg.fw_vars, **cfg.resolved_args, **job_context}
447
+ modules_cfg = interpolate(copy.deepcopy(cfg.config.get("modules", {})), context)
448
+ concrete_nodes = interpolate(copy.deepcopy(cfg.config.get("machines", {})), context)
449
+ hubs_cfg = interpolate(copy.deepcopy(cfg.config.get("hubs", {})), context)
450
+ top_connections = as_lines(interpolate(copy.deepcopy(cfg.config.get("connections", "")), context))
451
+ mappings = as_lines(interpolate(copy.deepcopy(cfg.config.get("mappings", "")), context))
452
+ tests = interpolate(copy.deepcopy(cfg.config.get("tests", [])), context)
453
+
454
+ hubs_registry = build_hubs_registry(hubs_cfg)
455
+ result = {"job_index": i + 1, "label": label, "passed": True, "test_results": None, "error": None}
456
+
457
+ # Execution-environment needs (docker caps/devices/root) declared by components
458
+ # (mappings, hubs, modules) during parsing; mapped to run flags at execution.
459
+ runtime = RuntimeOptions()
460
+
461
+ try:
462
+ # ---- PHASE 1: build modules, run the global build steps ----
463
+ build_ctx = BuildContext(
464
+ renode_dir_container=cfg.fw_vars["RENODE_DIR"],
465
+ integration_dir_host=context.get("_integration_dir_host", ""),
466
+ image=cfg.docker_image,
467
+ )
468
+ modules_registry = build_modules(modules_cfg, build_ctx)
469
+ for mod in modules_registry.values():
470
+ runtime.merge(mod.get("runtime"))
471
+
472
+ # Global build steps (compile firmware, fetch/generate files, ...) — run once per
473
+ # job, in containers, before the machines are built. Outputs land in the mounted
474
+ # project so the sim/tests pick them up. They report like tests (one StepResult
475
+ # each) and abort the job on the first failure. cfg.docker_image is the default.
476
+ build_blocks = interpolate(copy.deepcopy(cfg.config.get("build", [])), context)
477
+ build_results = run_build_steps(build_blocks, cfg.docker_image, cfg.output_dir,
478
+ live=cfg.live)
479
+ build_ok = all(r.passed for r in build_results)
480
+
481
+ for node_name, node_config in concrete_nodes.items():
482
+ if node_config.get("backend", "renode") != "renode":
483
+ raise ValueError(
484
+ f"Node '{node_name}': unknown backend '{node_config.get('backend')}'. "
485
+ "Only 'renode' is supported."
486
+ )
487
+
488
+ # ---- PHASE 1.5: collect ----
489
+ # Pre-pass: classify ALL connections as intra-machine or hub connections.
490
+ # Intra-machine lines (neither side is a hub) are injected directly into
491
+ # each node's _repl_signals so the generator sees them inline on the peripheral
492
+ # definition — no separate merge step needed.
493
+ # Hub connections are collected for resolve_connections (cross-machine wiring).
494
+ known_nodes = set(concrete_nodes)
495
+ hub_connections = []
496
+
497
+ for node_name, _nc in concrete_nodes.items():
498
+ intra, hub = [], []
499
+ for line in as_lines(_nc.get("connections", "")):
500
+ (hub if conn.is_hub_line(line, hubs_registry) else intra).append(line)
501
+ if intra:
502
+ existing = _nc.get("_repl_signals", "")
503
+ _nc["_repl_signals"] = ("\n".join([existing] + intra) if existing
504
+ else "\n".join(intra))
505
+ hub_connections.extend(
506
+ conn.qualify_node_connection(l, node_name, hubs_registry, known_nodes)
507
+ for l in hub)
508
+
509
+ # Top-level connections: hub lines go to resolve_connections; intra-machine
510
+ # top-level lines are stripped of their 'node.' prefix and injected into the
511
+ # owning node's _repl_signals.
512
+ for line in top_connections:
513
+ if conn.is_hub_line(line, hubs_registry):
514
+ hub_connections.append(line)
515
+ else:
516
+ if "->" not in line:
517
+ raise ValueError(
518
+ f"Top-level non-hub connection '{line}' must use '->'.")
519
+ src_tok = line.split("->", 1)[0].strip()
520
+ node = src_tok.split(".")[0] if "." in src_tok else None
521
+ if node not in concrete_nodes:
522
+ raise ValueError(
523
+ f"Top-level connection '{line}': cross-machine links require "
524
+ "a hub declared under 'hubs:'.")
525
+ pfx = node + "."
526
+ a, b = (t.strip() for t in line.split("->", 1))
527
+ stripped = (f"{a[len(pfx):]if a.startswith(pfx)else a} -> "
528
+ f"{b[len(pfx):]if b.startswith(pfx)else b}")
529
+ _nc = concrete_nodes[node]
530
+ existing = _nc.get("_repl_signals", "")
531
+ _nc["_repl_signals"] = (f"{existing}\n{stripped}" if existing else stripped)
532
+
533
+ # Build NodeDescriptors: _repl_signals is now fully populated before this call.
534
+ descriptors = periph.resolve_peripherals(concrete_nodes, modules_registry)
535
+
536
+ # C# DLL imports must precede any `mach create` in the resc.
537
+ seen_dlls = set()
538
+ for desc in descriptors.values():
539
+ seen_dlls.update(desc.csharp_dlls)
540
+ emulation_setup = [f"i @{to_container_path(dll)}" for dll in seen_dlls]
541
+
542
+ # so_commands go into _gen_commands before mappings to preserve [so, map, user] order.
543
+ for node_name, descriptor in descriptors.items():
544
+ for cmd in descriptor.so_commands:
545
+ concrete_nodes[node_name].setdefault("_gen_commands", []).append(cmd)
546
+
547
+ if mappings:
548
+ map_lines, map_runtime = maps.resolve_mappings(mappings, concrete_nodes)
549
+ emulation_setup += map_lines
550
+ runtime.merge(map_runtime)
551
+
552
+ cross_connections = (
553
+ conn.resolve_connections(hub_connections, concrete_nodes, hubs_registry)
554
+ if hub_connections else []
555
+ )
556
+ for spec in cross_connections:
557
+ runtime.merge(HUBS.get(hubs_registry[spec["hub"]]).runtime_options(spec))
558
+
559
+ # ---- PHASE 1.75: generate ----
560
+ # Write one .repl per node, then append its path to node_config["platform"].
561
+ for node_name, descriptor in descriptors.items():
562
+ repl_path = gen.generate_node_repl(descriptor, i + 1)
563
+ if repl_path:
564
+ concrete_nodes[node_name].setdefault("platform", []).append(
565
+ to_container_path(repl_path))
566
+
567
+ finalize_commands(concrete_nodes)
568
+
569
+ quantum_cfg = cfg.config.get("quantum")
570
+ if quantum_cfg is not None:
571
+ quantum_value = str(quantum_cfg)
572
+ elif hubs_registry:
573
+ quantum_value = DEFAULT_QUANTUM
574
+ print(f"[QUANTUM] hub(s) present -> SetGlobalQuantum {quantum_value}s "
575
+ "(default; set top-level 'quantum:' to override)")
576
+ else:
577
+ quantum_value = None
578
+
579
+ debug_config = parse_debug_flag(cfg.debug_args, concrete_nodes, i + 1)
580
+ if cfg.gather_metrics is not None:
581
+ _apply_metrics(concrete_nodes, cfg.gather_metrics, cfg.output_dir, i + 1)
582
+
583
+ # ---- PHASE 2: generate resc (if any machines); simulate / test ----
584
+ # A resc is only produced when machines are defined. The action still runs
585
+ # without one: `run`/`export` error (they need a machine), while `test` runs
586
+ # any sim-independent tests (requires_sim: false) and skips the rest.
587
+ resc_filename = None
588
+ if concrete_nodes:
589
+ resc_root = os.path.join(ARTIFACTS_DIR, "resc")
590
+ os.makedirs(resc_root, exist_ok=True)
591
+ resc_filename = os.path.join(resc_root, f"job_{i+1}.resc")
592
+ generate_emulation_resc(
593
+ list(concrete_nodes.items()), hubs_registry, cross_connections,
594
+ quantum_value, debug_config, resc_filename,
595
+ emulation_setup=emulation_setup,
596
+ )
597
+
598
+ # A failed build aborts before the sim/tests — but its results are still
599
+ # reported, so the failure shows up in junit/manifest/html like a test.
600
+ if build_ok:
601
+ state = JobState(resc_filename=resc_filename, runtime=runtime,
602
+ debug_config=debug_config, tests=tests,
603
+ concrete_nodes=concrete_nodes)
604
+ cfg.action(state, cfg, result)
605
+ else:
606
+ print("\n[BUILD] a build step failed — skipping simulation/tests.")
607
+
608
+ # ---- PHASE 2.5: report (build steps + tests, together) ----
609
+ step_results = list(build_results) + (result.get("test_results") or [])
610
+ result["test_results"] = step_results
611
+ if step_results:
612
+ emit(step_results, cfg.output_dir, cfg.reporter_names)
613
+ result["passed"] = result["passed"] and all(r.passed for r in step_results)
614
+
615
+ # ---- PHASE 3: sweep artifacts ----
616
+ # Global glob patterns (top-level 'artifacts:' or --artifacts), interpolated
617
+ # with this job's context so matrix variables resolve.
618
+ artifact_patterns = as_lines(interpolate(list(cfg.artifacts), context))
619
+ collect_artifacts(artifact_patterns, cfg.output_dir, i + 1)
620
+
621
+ except Exception as exc:
622
+ result["passed"] = False
623
+ result["error"] = str(exc)
624
+ print(f"\n[ERROR] Job {i+1} failed: {exc}")
625
+
626
+ return result
627
+
628
+
629
+ # ---------------------------------------------------------------------------
630
+ # Aggregate results table
631
+ # ---------------------------------------------------------------------------
632
+
633
+ def print_summary(all_results, test_mode):
634
+ n_jobs = len(all_results)
635
+ W = 62
636
+
637
+ # Single job in test mode: show test-level results directly — no confusing
638
+ # job-vs-test count mismatch.
639
+ if n_jobs == 1 and test_mode:
640
+ r = all_results[0]
641
+ t = r["test_results"] or []
642
+ n_t = len(t)
643
+ n_tp = sum(1 for s in t if s.passed)
644
+ print(f"\n{'═'*W}")
645
+ if r["error"]:
646
+ print(f"FAILED ERROR: {r['error']}")
647
+ else:
648
+ print(f"RESULTS {n_tp}/{n_t} tests passed")
649
+ print(f"{'─'*W}")
650
+ for s in t:
651
+ mark = "✓" if s.passed else "✗"
652
+ print(f" {mark} [{s.type}] {s.name}")
653
+ print(f"{'═'*W}")
654
+ return
655
+
656
+ # Matrix / multi-job: job-level header + per-job test breakdown.
657
+ n_pass = sum(1 for r in all_results if r["passed"])
658
+ print(f"\n{'═'*W}")
659
+ print(f"JOB SUMMARY {n_pass}/{n_jobs} jobs passed")
660
+ print(f"{'─'*W}")
661
+ for r in all_results:
662
+ mark = "✓" if r["passed"] else "✗"
663
+ label = r["label"] or f"job {r['job_index']}"
664
+ if r["error"]:
665
+ detail = f" ERROR: {r['error']}"
666
+ elif test_mode and r["test_results"] is not None:
667
+ t = r["test_results"]
668
+ n_tp = sum(1 for s in t if s.passed)
669
+ failed = [s.name for s in t if not s.passed]
670
+ detail = f" {n_tp}/{len(t)} tests"
671
+ if failed:
672
+ detail += " ← " + ", ".join(failed)
673
+ else:
674
+ detail = ""
675
+ print(f" {mark} JOB {r['job_index']} {label}{detail}")
676
+ print(f"{'═'*W}")
677
+
678
+
679
+ # ---------------------------------------------------------------------------
680
+ # Entry point
681
+ # ---------------------------------------------------------------------------
682
+
683
+ def main():
684
+ if hasattr(sys.stdout, "reconfigure"):
685
+ sys.stdout.reconfigure(line_buffering=True)
686
+
687
+ # Subcommands are discovered from the COMMANDS registry; each parses its own
688
+ # flags and runs via the shared engine above.
689
+ from .commands import build_parser
690
+
691
+ parser = build_parser()
692
+ args = parser.parse_args()
693
+ sys.exit(args._command.execute(args) or 0)