feat(manifest): read cross: into EmbManifest.cross

EmbManifest.fromMap now parses the cross: block into a typed
EmbManifest.cross (CrossTarget?), so a package's own emb.yaml can
self-describe its cross toolchain/sysroot instead of needing a separate
file. Parsing never throws — a malformed cross: (e.g. a multi-target
block whose provider lives only under targets) yields null rather than
breaking the manifest load for unrelated commands (deps/sync/doctor).
For a multi-target block it surfaces the shared base; CrossProjectResolver
still does per-target merging.

Closes the 'Surfacing cross:' gap noted in examples/cross/README.md.

Signed-off-by: Joel Winarske <joel.winarske@gmail.com>
This commit is contained in:
Joel Winarske
2026-06-18 15:12:42 -07:00
parent a363691d29
commit 06bbbeed85
3 changed files with 80 additions and 12 deletions
+10 -12
View File
@@ -192,16 +192,14 @@ concerns (the `DepRule` shape), not toolchain identity, so they live outside
## Surfacing `cross:` on the manifest
`EmbManifest.fromMap` doesn't read `cross:` yet. One field wires it in:
`EmbManifest.fromMap` reads the `cross:` block into a typed
`EmbManifest.cross` (`CrossTarget?`), so a package's own `emb.yaml` can
self-describe its cross toolchain/sysroot. It's null when there's no `cross:`
block or it has no resolvable provider, and parsing never throws — a malformed
`cross:` won't break unrelated commands (`deps`/`sync`/`doctor`). For a
multi-target block (`cross.targets`) it's the shared base; `CrossProjectResolver`
does per-target merging.
```dart
// in EmbManifest
final CrossTarget? cross;
// in fromMap(...)
cross: map['cross'] is Map
? CrossTarget.fromMap(map['cross'] as Map<dynamic, dynamic>)
: null,
```
Until then, the test reads `cross:` directly from the YAML.
The example test above parses each `cross:` block directly with
`CrossTarget.fromMap` to assert per-field; consumers should prefer
`EmbManifest.cross`.
+26
View File
@@ -1,3 +1,4 @@
import 'package:emb_cli/src/cross/cross_target.dart';
import 'package:emb_cli/src/manifest/build_config.dart';
import 'package:emb_cli/src/manifest/host_deps.dart';
import 'package:emb_cli/src/manifest/source_repo.dart';
@@ -22,6 +23,7 @@ class EmbManifest {
required this.src,
required this.deps,
this.build,
this.cross,
this.flutterVersion,
this.sourcePath,
this.raw = const {},
@@ -53,6 +55,7 @@ class EmbManifest {
build: map['build'] is Map
? BuildConfig.fromMap(map['build'] as Map<dynamic, dynamic>)
: null,
cross: _parseCross(map),
flutterVersion: map['flutter_version'] as String?,
sourcePath: sourcePath,
raw: map,
@@ -87,6 +90,13 @@ class EmbManifest {
/// Build configuration (`build:`), when this package is buildable.
final BuildConfig? build;
/// Parsed `cross:` block, when present and well-formed — so a package's own
/// `emb.yaml` can self-describe its cross toolchain/sysroot. For a
/// multi-target block (`cross.targets`) this is the shared base (provider +
/// shared fields); per-target merging is done by `CrossProjectResolver`.
/// Null when there is no `cross:` block, or it has no resolvable provider.
final CrossTarget? cross;
/// Pinned Flutter version, when declared.
final String? flutterVersion;
@@ -97,6 +107,22 @@ class EmbManifest {
/// gclient_config, qemu/docker/remote platform blocks).
final Map<String, dynamic> raw;
/// Parse the `cross:` block into a [CrossTarget], or null when absent or
/// without a resolvable provider (e.g. a `cross.targets` block whose provider
/// lives only under a target). Never throws — a malformed `cross:` must not
/// break the manifest load for unrelated commands (deps, sync, doctor, …).
static CrossTarget? _parseCross(Map<String, dynamic> map) {
final block = map['cross'];
if (block is! Map) return null;
try {
return CrossTarget.fromMap(Map<dynamic, dynamic>.from(block));
// fromMap throws ArgumentError on a missing/unknown provider token.
// ignore: avoid_catching_errors
} on ArgumentError {
return null;
}
}
static HostDeps _parseDeps(Map<String, dynamic> map) {
// New structured schema takes precedence when present.
final structured = map['deps'];
@@ -152,4 +152,48 @@ emb:
expect(loadOf(1), isTrue);
});
});
group('cross: block', () {
test('a well-formed cross: block parses into EmbManifest.cross', () {
final m = EmbManifest.fromMap(<String, dynamic>{
'id': 'ivi-homescreen',
'cross': {
'provider': 'yocto-sdk',
'triple': 'aarch64-agl-linux',
'host_build_tools': true,
},
});
expect(m.cross, isNotNull);
expect(m.cross!.provider.token, 'yocto-sdk');
expect(m.cross!.triple, 'aarch64-agl-linux');
expect(m.cross!.hostTools, isTrue);
});
test('a multi-target block exposes the shared base (provider)', () {
final m = EmbManifest.fromMap(<String, dynamic>{
'id': 'rpi',
'cross': {
'provider': 'arm-gnu',
'targets': {
'rpi5': {'image_url': 'https://example/x.img.xz'},
},
},
});
expect(m.cross?.provider.token, 'arm-gnu');
});
test('no cross: block → null', () {
expect(EmbManifest.fromMap(<String, dynamic>{'id': 'x'}).cross, isNull);
});
test('a malformed cross: (no provider) → null, does not throw', () {
expect(
EmbManifest.fromMap(<String, dynamic>{
'id': 'x',
'cross': {'triple': 'aarch64-agl-linux'},
}).cross,
isNull,
);
});
});
}