feat(cross): cross-file extends — app layer over a project target

`cross.extends` gains a second form alongside the board reference:
  - <board>        : a target in emb's shipped board library (hardware).
  - <dir>#<target> : a target in another emb project at <dir> (the project
                     layer) — e.g. a Flutter app extending an ivi-homescreen
                     target, so plugin config (DISABLE_PLUGINS/PLUGINS_DIR,
                     plugin -dev packages) lives with the app.

This completes the layering: app -> project -> board. <dir> is resolved relative
to the extending manifest's project root (the .emb/ parent in .emb/ mode, else
the manifest's dir); the referenced project's own extends chain is applied via
resolve(), and the same merge rules apply (maps deep-merge, backends/cpu_flags
replace, sysroot.dev_packages union). Reference cycles across projects are
detected and reported.

Tests cover the three-layer merge (hardware from board, backends from project,
plugins from app, dev_packages unioned across all three) plus unknown-target
and missing-project errors.

Signed-off-by: Joel Winarske <joel.winarske@gmail.com>
This commit is contained in:
Joel Winarske
2026-06-20 06:46:28 -07:00
parent 74af17ac46
commit c45952bd58
2 changed files with 168 additions and 11 deletions
+77 -11
View File
@@ -99,6 +99,10 @@ class CrossProjectResolver {
/// Lazily-loaded board registry: board name -> its hardware `cross:` map.
Map<String, Map<String, dynamic>>? _boards;
/// Project dirs currently being resolved through a cross-project `extends`,
/// to break `app -> project -> app` reference cycles.
final Set<String> _resolvingProjects = {};
/// The base file searched for inside a `.emb/` directory.
static const baseFileName = 'base.emb.yaml';
@@ -307,9 +311,15 @@ class CrossProjectResolver {
if (e.key != 'targets') e.key: e.value,
};
/// Resolve a `cross.extends: <board>` reference: deep-merge [cross] (the
/// project/app layer) over the named base board (emb's hardware layer),
/// recursing for chains. No-op when there is no `extends`.
/// Resolve a `cross.extends` reference: deep-merge [cross] (the derived
/// project/app layer) over the resolved base it names. Two forms:
/// - `<board>` — a target in emb's shipped board library (hardware).
/// - `<dir>#<target>` — a target in another emb project at <dir> (the
/// project layer; e.g. an app extending an
/// ivi-homescreen target). <dir> is relative to the
/// extending manifest's project root.
/// Chains resolve recursively (app -> project -> board). No-op without
/// `extends`.
Map<String, dynamic> _applyExtends(
Map<String, dynamic> cross,
String? sourcePath, [
@@ -317,24 +327,80 @@ class CrossProjectResolver {
]) {
final ext = cross['extends'];
if (ext == null) return cross;
final baseName = ext.toString();
if (seen.contains(baseName)) {
final ref = ext.toString();
if (seen.contains(ref)) {
throw CrossProjectException(
'extends: cycle through "$baseName" (in ${sourcePath ?? "manifest"}).',
'extends: cycle through "$ref" (in ${sourcePath ?? "manifest"}).',
);
}
final base = ref.contains('#')
? _projectExtendsBase(ref, sourcePath)
: _boardExtendsBase(ref, sourcePath, seen);
final derived = {...cross}..remove('extends');
return _mergeCross(base, derived);
}
/// The resolved `cross:` of a board named [name] from the shipped board
/// library, with its own `extends` chain applied.
Map<String, dynamic> _boardExtendsBase(
String name,
String? sourcePath,
Set<String> seen,
) {
final registry = _boardRegistry();
final base = registry[baseName];
final base = registry[name];
if (base == null) {
final known = registry.keys.isEmpty ? 'none' : registry.keys.join(', ');
throw CrossProjectException(
'extends: unknown board "$baseName" (in ${sourcePath ?? "manifest"}). '
'extends: unknown board "$name" (in ${sourcePath ?? "manifest"}). '
'Known boards: $known.',
);
}
final resolvedBase = _applyExtends(base, sourcePath, {...seen, baseName});
final derived = {...cross}..remove('extends');
return _mergeCross(resolvedBase, derived);
return _applyExtends(base, sourcePath, {...seen, name});
}
/// The resolved `cross:` of `<dir>#<target>` — a target from another emb
/// project at <dir> (relative to the extending manifest's project root). That
/// project's own `extends` chain is applied by [resolve].
Map<String, dynamic> _projectExtendsBase(String ref, String? sourcePath) {
final i = ref.lastIndexOf('#');
final dirRef = ref.substring(0, i);
final name = ref.substring(i + 1);
final root = _projectRootOf(sourcePath);
final dir = p.normalize(p.join(root, dirRef));
final abs = p.absolute(dir);
if (!_resolvingProjects.add(abs)) {
throw CrossProjectException(
'extends: project reference cycle at "$dir".',
);
}
try {
final project = resolve(dir);
if (project == null) {
throw CrossProjectException(
'extends: no emb project at "$dir" (from "$ref").',
);
}
final target = project.targets[name];
if (target == null) {
throw CrossProjectException(
'extends: project "$dir" has no target "$name" (from "$ref"). '
'Available: ${project.targets.keys.join(", ")}.',
);
}
return target.cross;
} finally {
_resolvingProjects.remove(abs);
}
}
/// The project root for a manifest at [sourcePath]: the `.emb/` parent in
/// `.emb/` mode, else the manifest file's own directory. Cross-project
/// `extends` paths are resolved relative to this.
String _projectRootOf(String? sourcePath) {
if (sourcePath == null) return '.';
final dir = p.dirname(sourcePath);
return p.basename(dir) == '.emb' ? p.dirname(dir) : dir;
}
/// Merge the [over] (derived) layer onto [base], with the cross-layer rules:
+91
View File
@@ -322,5 +322,96 @@ cross:
throwsA(isA<CrossProjectException>()),
);
});
test('app extends a project target which extends a board (3 layers)', () {
// project: tmp/proj/.emb/rpi.emb.yaml — extends the board, adds backends.
Directory(p.join(tmp.path, 'proj', '.emb')).createSync(recursive: true);
File(p.join(tmp.path, 'proj', '.emb', 'rpi.emb.yaml')).writeAsStringSync(
'''
id: ivi-homescreen
cross:
targets:
rpi5-bookworm:
extends: rpi5-bookworm
backends:
drm-kms-egl: {BUILD_BACKEND_DRM_KMS_EGL: 'ON'}
sysroot:
dev_packages: [libproject-dev]
''',
);
// app: a flat manifest extending the project target, adding plugins.
final app = File(p.join(tmp.path, 'app.emb.yaml'))
..writeAsStringSync('''
id: myapp
cross:
targets:
rpi5-bookworm:
extends: 'proj#rpi5-bookworm'
defines: {DISABLE_PLUGINS: 'OFF'}
sysroot:
dev_packages: [libapp-dev]
''');
final project = CrossProjectResolver(
const ManifestLoader(),
boards,
).resolve(app.path)!;
final cross = project.targets['rpi5-bookworm']!.cross;
// hardware from the board layer:
expect(cross['toolchain_version'], '12.3.rel1');
expect(cross['cpu_flags'], ['-mcpu=cortex-a76']);
// backends from the project layer:
expect((cross['backends'] as Map).keys, ['drm-kms-egl']);
// plugin defines from the app layer:
expect((cross['defines'] as Map)['DISABLE_PLUGINS'], 'OFF');
// dev_packages union across all three layers:
expect(
(cross['sysroot'] as Map)['dev_packages'],
containsAll(['libegl-dev', 'libproject-dev', 'libapp-dev']),
);
expect(cross.containsKey('extends'), isFalse);
});
test('extends an unknown project target throws', () {
Directory(p.join(tmp.path, 'proj', '.emb')).createSync(recursive: true);
File(p.join(tmp.path, 'proj', '.emb', 'rpi.emb.yaml')).writeAsStringSync(
'''
id: ivi-homescreen
cross:
targets:
rpi5-bookworm: {extends: rpi5-bookworm}
''',
);
final app = File(p.join(tmp.path, 'app.emb.yaml'))
..writeAsStringSync('''
id: myapp
cross:
targets:
x: {extends: 'proj#no-such-target'}
''');
expect(
() => CrossProjectResolver(
const ManifestLoader(),
boards,
).resolve(app.path),
throwsA(isA<CrossProjectException>()),
);
});
test('extends a missing project dir throws', () {
final app = File(p.join(tmp.path, 'app.emb.yaml'))
..writeAsStringSync('''
id: myapp
cross:
targets:
x: {extends: 'no-such-proj#rpi5-bookworm'}
''');
expect(
() => CrossProjectResolver(
const ManifestLoader(),
boards,
).resolve(app.path),
throwsA(isA<CrossProjectException>()),
);
});
});
}