From c45952bd58d85d2d2b2d760ae0399a7eb36f3f22 Mon Sep 17 00:00:00 2001 From: Joel Winarske Date: Sat, 20 Jun 2026 06:46:28 -0700 Subject: [PATCH] =?UTF-8?q?feat(cross):=20cross-file=20extends=20=E2=80=94?= =?UTF-8?q?=20app=20layer=20over=20a=20project=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cross.extends` gains a second form alongside the board reference: - : a target in emb's shipped board library (hardware). - # : a target in another emb project at (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. 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 --- lib/src/cross/cross_project.dart | 88 +++++++++++++++++++++---- test/src/cross/cross_project_test.dart | 91 ++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 11 deletions(-) diff --git a/lib/src/cross/cross_project.dart b/lib/src/cross/cross_project.dart index c8c2bd7..eda94cf 100644 --- a/lib/src/cross/cross_project.dart +++ b/lib/src/cross/cross_project.dart @@ -99,6 +99,10 @@ class CrossProjectResolver { /// Lazily-loaded board registry: board name -> its hardware `cross:` map. Map>? _boards; + /// Project dirs currently being resolved through a cross-project `extends`, + /// to break `app -> project -> app` reference cycles. + final Set _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: ` 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: + /// - `` — a target in emb's shipped board library (hardware). + /// - `#` — a target in another emb project at (the + /// project layer; e.g. an app extending an + /// ivi-homescreen target). is relative to the + /// extending manifest's project root. + /// Chains resolve recursively (app -> project -> board). No-op without + /// `extends`. Map _applyExtends( Map 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 _boardExtendsBase( + String name, + String? sourcePath, + Set 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 `#` — a target from another emb + /// project at (relative to the extending manifest's project root). That + /// project's own `extends` chain is applied by [resolve]. + Map _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: diff --git a/test/src/cross/cross_project_test.dart b/test/src/cross/cross_project_test.dart index 8d8f309..0c0c1e6 100644 --- a/test/src/cross/cross_project_test.dart +++ b/test/src/cross/cross_project_test.dart @@ -322,5 +322,96 @@ cross: throwsA(isA()), ); }); + + 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()), + ); + }); + + 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()), + ); + }); }); }