From ddf1deac68e698c75fb9c9f2f143c1355c0505bb Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 7 Sep 2026 10:51:43 -0400 Subject: [PATCH 1/3] fix: reject incompatible Apple runtime companions --- AGENTS.md | 6 + CHANGELOG.md | 4 + README.md | 5 + hook/build.dart | 182 ++++++++++--- pubspec.yaml | 1 + ...build_hook_litert_lm_integration_test.dart | 256 +++++++++++++++++- website/docs/changelog/recent-releases.md | 4 + website/docs/getting-started/installation.md | 5 + .../docs/maintainers/native-and-web-sync.md | 14 + 9 files changed, 436 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 77e76854d..570ed4394 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,6 +212,12 @@ flow in ownership order: ## Native And Web Asset Sync +Apple llama.cpp process lookup requires the resolved companion's package +identity and SwiftPM pin to match the core native pin. Preserve this guard and +its metadata cache dependencies when changing sync or hook behavior; declared +version constraints and core native overrides are not ABI evidence. Unverified +local companion `Artifacts` overrides must fail closed. + Prefer the repository workflow for native version and binding updates: `.github/workflows/sync_native_bindings.yml`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0660d28cd..07df1161b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Unreleased +* Fail Apple builds before native symbol lookup when the resolved llama.cpp + companion does not match the core native runtime, with an actionable upgrade + diagnostic instead of allowing ABI-incompatible frameworks. + * Updated the default llama.cpp native runtime pin to `leehack/llamadart-native@v0.4.0`, regenerated matching Dart FFI bindings, refreshed the `llamadart_llama_cpp_flutter` Apple SwiftPM checksum, and diff --git a/README.md b/README.md index b42babb1b..0655bbb1d 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,11 @@ The development example below requires both path overrides to the same checkout. Publish companion `0.0.18` only with the matching next core release, and replace these temporary overrides/version constraints during that coordinated release. +Apple builds verify the resolved companion's SwiftPM runtime pin before native +symbol lookup. Incompatible companions or unverified local `Artifacts` +overrides fail the build; resolve the matching companion and rerun +`flutter pub get`. Core native overrides do not replace SPM frameworks. + ```yaml dependencies: llamadart: ^0.8.22 diff --git a/hook/build.dart b/hook/build.dart index ca197edd9..4b639cc67 100644 --- a/hook/build.dart +++ b/hook/build.dart @@ -1,6 +1,6 @@ import 'dart:async' show Completer, StreamSubscription, Timer, TimeoutException, unawaited; -import 'dart:convert' show utf8; +import 'dart:convert' show jsonDecode, utf8; import 'dart:io'; import 'package:archive/archive.dart'; @@ -10,6 +10,7 @@ import 'package:hooks/hooks.dart'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import 'package:path/path.dart' as path; +import 'package:yaml/yaml.dart'; import 'package:llamadart/src/hook/native_bundle_config.dart'; @@ -254,6 +255,7 @@ void main(List args) async { final appleSpmRuntimes = _flutterAppleCompanionRuntimes( input: input, code: code, + output: output, log: log, ); var selectedRuntimes = @@ -520,6 +522,7 @@ bool _isAppleTarget(OS os) => os == OS.iOS || os == OS.macOS; List? _flutterAppleCompanionRuntimes({ required BuildInput input, required CodeConfig code, + required BuildOutputBuilder output, required Logger log, }) { if (!_isAppleTarget(code.targetOS)) { @@ -551,6 +554,7 @@ List? _flutterAppleCompanionRuntimes({ } final pubspecSource = pubspec.readAsStringSync(); + output.dependencies.add(pubspec.uri); final isFlutter = _pubspecDeclaresFlutter(pubspecSource); if (!isFlutter) { log.info( @@ -563,6 +567,7 @@ List? _flutterAppleCompanionRuntimes({ final dependencies = _pubspecDependencyNames(pubspecSource); final runtimes = []; if (dependencies.contains(_llamaCppFlutterPackageName)) { + _validateAppleLlamaCompanion(consumerRoot, output); runtimes.add(nativeRuntimeLlamaCpp); } if (dependencies.contains(_liteRtLmFlutterPackageName)) { @@ -579,6 +584,125 @@ List? _flutterAppleCompanionRuntimes({ return runtimes; } +void _validateAppleLlamaCompanion( + Directory consumerRoot, + BuildOutputBuilder output, +) { + Never reject(String reason) => throw StateError( + 'Incompatible Apple llama.cpp companion: $reason ' + 'Resolve $_llamaCppFlutterPackageName with a Package.swift pin matching ' + '$_nativeRepoSlug@$_llamaCppTag and rerun flutter pub get. ' + 'For native v0.4.0 use companion 0.0.18 with the matching core; ' + 'native tag/path overrides do not replace SPM frameworks. ' + 'No in-process native asset was emitted.', + ); + + try { + var directory = consumerRoot; + File? configuration; + while (true) { + final candidate = File( + path.join(directory.path, _dartToolDir, 'package_config.json'), + ); + if (candidate.existsSync()) { + configuration = candidate; + break; + } + final parent = directory.parent; + if (_sameDirectory(parent, directory)) break; + directory = parent; + } + if (configuration == null) { + reject('Resolved package configuration missing.'); + } + output.dependencies.add(configuration.uri); + final config = jsonDecode(configuration.readAsStringSync()); + if (config is! Map || + config['configVersion'] != 2 || + config['packages'] is! List) { + reject('Resolved package configuration is malformed.'); + } + final entries = config['packages'] as List; + if (entries.any((entry) => entry is! Map)) { + reject('Resolved package configuration contains malformed entries.'); + } + final companions = entries + .where((entry) => (entry as Map)['name'] == _llamaCppFlutterPackageName) + .toList(); + if (companions.length != 1) { + reject('Expected exactly one resolved llama.cpp companion.'); + } + final root = (companions.single as Map)['rootUri']; + if (root is! String || root.isEmpty) reject('Companion root URI missing.'); + final uri = configuration.uri.resolve(root); + if (uri.scheme != 'file' || uri.hasQuery || uri.hasFragment) { + reject('Companion root must be a local package directory.'); + } + final companionRoot = Directory.fromUri(uri); + final pubspec = File(path.join(companionRoot.path, 'pubspec.yaml')); + final manifest = File( + path.join( + companionRoot.path, + 'darwin', + _llamaCppFlutterPackageName, + 'Package.swift', + ), + ); + output.dependencies.addAll([pubspec.uri, manifest.uri]); + if (!pubspec.existsSync() || !manifest.existsSync()) { + reject('Resolved companion package metadata is missing.'); + } + final metadata = loadYaml(pubspec.readAsStringSync()); + if (metadata is! Map || + metadata['name'] != _llamaCppFlutterPackageName || + metadata['version'] is! String || + !RegExp( + r'^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$', + ).hasMatch(metadata['version'] as String)) { + reject('Resolved companion identity is malformed.'); + } + final source = manifest.readAsStringSync(); + final pins = RegExp( + r'^let llamaCppTag = "([^"\r\n]+)"\s*$', + multiLine: true, + ).allMatches(source).toList(); + if (pins.length != 1 || pins.single.group(1) != _llamaCppTag) { + reject( + 'Resolved companion ${metadata['version']} does not uniquely pin ' + 'the required native runtime $_llamaCppTag.', + ); + } + // Only the maintained tag-driven remote target contract is recognized. + // A copied tag declaration must not authorize a different target pin. + if (!RegExp( + r'repository:\s*"leehack/llamadart-native",', + ).hasMatch(source) || + !source.contains( + 'artifactName: "llamadart-native-apple-xcframework-' + r'\(llamaCppTag).zip"', + ) || + RegExp(r'tag:\s*llamaCppTag,').allMatches(source).length != 1) { + reject('Companion SwiftPM target does not use the supported native pin.'); + } + final artifacts = Directory(path.join(manifest.parent.path, 'Artifacts')); + // The supported manifest can prefer local binaries over its remote pin. + // Such binaries have no verified ABI contract and must not inherit trust + // from the tag string. Track the directory so local additions invalidate + // an otherwise cached successful hook result. + output.dependencies.add(artifacts.uri); + if (artifacts.existsSync()) { + reject( + 'Local Artifacts overrides cannot establish framework ABI ' + 'compatibility; remove them and use the pinned framework.', + ); + } + } on FileSystemException { + reject('Unable to read resolved companion metadata.'); + } on FormatException { + reject('Resolved companion metadata is malformed.'); + } +} + Directory? _consumerPackageRoot(BuildInput input) { final fromUserDefines = _consumerPackageRootFromUserDefines(input); if (fromUserDefines != null) { @@ -652,49 +776,29 @@ bool _sameDirectory(Directory a, Directory b) { } bool _pubspecDeclaresFlutter(String source) { - final lines = source.split('\n'); - for (final rawLine in lines) { - final line = rawLine.split('#').first; - if (RegExp(r'^\s*sdk\s*:\s*flutter\s*$').hasMatch(line)) { - return true; - } - } - return false; + final pubspec = _readConsumerPubspec(source); + if (pubspec is! Map) return false; + final dependencies = pubspec['dependencies']; + if (dependencies is! Map) return false; + final flutter = dependencies['flutter']; + return flutter is Map && flutter['sdk'] == 'flutter'; } Set _pubspecDependencyNames(String source) { - final dependencies = {}; - String? section; - int? dependencyIndent; - for (final rawLine in source.split('\n')) { - final line = rawLine.split('#').first; - if (line.trim().isEmpty) { - continue; - } - - final topLevel = RegExp(r'^([A-Za-z_][A-Za-z0-9_]*)\s*:').firstMatch(line); - if (topLevel != null) { - section = topLevel.group(1); - dependencyIndent = null; - continue; - } - - if (section != 'dependencies') { - continue; - } + final pubspec = _readConsumerPubspec(source); + if (pubspec is! Map || pubspec['dependencies'] is! Map) return {}; + return (pubspec['dependencies'] as Map).keys.whereType().toSet(); +} - final dependency = RegExp( - r'^(\s+)([A-Za-z_][A-Za-z0-9_]*)\s*:', - ).firstMatch(line); - if (dependency != null) { - final indent = dependency.group(1)!.length; - dependencyIndent ??= indent; - if (indent == dependencyIndent) { - dependencies.add(dependency.group(2)!); - } - } +Object? _readConsumerPubspec(String source) { + try { + return loadYaml(source); + } on FormatException { + throw StateError( + 'Cannot validate Apple runtime selection: malformed ' + 'consumer pubspec. Fix pubspec.yaml and rerun flutter pub get.', + ); } - return dependencies; } Future _emitLiteRtLmAssets({ diff --git a/pubspec.yaml b/pubspec.yaml index 57392d8a9..2810493b1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,6 +32,7 @@ dependencies: code_assets: '>=1.0.0 <3.0.0' hooks: '>=1.0.0 <3.0.0' logging: ^1.3.0 + yaml: ^3.1.3 # jinja_analyzer.dart imports dinja's private src/lexer.dart, src/parser.dart # and src/ast/nodes.dart. This bound only rules out a silently resolved 1.1.x; # a 1.0.x patch can move those files just as freely, and consumers of released diff --git a/test/unit/hook/build_hook_litert_lm_integration_test.dart b/test/unit/hook/build_hook_litert_lm_integration_test.dart index 507e006bc..f62789224 100644 --- a/test/unit/hook/build_hook_litert_lm_integration_test.dart +++ b/test/unit/hook/build_hook_litert_lm_integration_test.dart @@ -1,6 +1,7 @@ @TestOn('vm') library; +import 'dart:convert'; import 'dart:io'; import 'package:code_assets/code_assets.dart'; @@ -329,6 +330,200 @@ void main() { }, ); + for (final target in [OS.iOS, OS.macOS]) { + test( + 'Apple $target rejects resolved old ABI despite native overrides', + () async { + final defines = await _flutterAppleUserDefines( + dependencies: const ['llamadart_llama_cpp_flutter'], + companionTag: 'v0.3.0', + companionVersion: '0.0.17', + defines: { + 'llamadart_native_tag': 'v0.4.0', + 'llamadart_native_path': './new-runtime', + 'llamadart_native_runtimes': ['litert_lm'], + }, + ); + var emitted = false; + await expectLater( + testCodeBuildHook( + mainMethod: build_hook.main, + targetOS: target, + targetArchitecture: Architecture.arm64, + targetIOSSdk: target == OS.iOS ? IOSSdk.iPhoneOS : null, + userDefines: defines, + check: (_, _) => emitted = true, + ), + throwsA( + predicate( + (error) => error.toString().contains( + 'Incompatible Apple llama.cpp companion', + ), + ), + ), + ); + expect(emitted, isFalse); + }, + ); + } + + for (final scenario in [ + 'missing', + 'duplicate', + 'local', + 'false-version', + 'malformed-config', + 'malformed-pubspec', + 'wrong-name', + 'missing-pin', + 'duplicate-pin', + 'wrong-target', + 'missing-manifest', + ]) { + test('Apple companion rejects $scenario metadata before lookup', () async { + final defines = await _flutterAppleUserDefines( + dependencies: const ['llamadart_llama_cpp_flutter'], + missingConfiguration: scenario == 'missing', + duplicateCompanion: scenario == 'duplicate', + localArtifacts: scenario == 'local', + companionTag: scenario == 'false-version' ? 'v0.3.0' : null, + mutate: (root) { + final config = File( + path.join(root.path, '.dart_tool', 'package_config.json'), + ); + final metadata = File( + path.join(root.path, 'resolved companion', 'pubspec.yaml'), + ); + final manifest = File( + path.join( + root.path, + 'resolved companion', + 'darwin', + 'llamadart_llama_cpp_flutter', + 'Package.swift', + ), + ); + switch (scenario) { + case 'malformed-config': + config.writeAsStringSync('{'); + case 'malformed-pubspec': + metadata.writeAsStringSync('name: ['); + case 'wrong-name': + metadata.writeAsStringSync('name: other\nversion: 0.0.18'); + case 'missing-pin': + manifest.writeAsStringSync('// no pin'); + case 'duplicate-pin': + manifest.writeAsStringSync( + '${manifest.readAsStringSync()}\nlet llamaCppTag = "v0.4.0"\n', + ); + case 'wrong-target': + manifest.writeAsStringSync( + manifest.readAsStringSync().replaceFirst( + 'tag: llamaCppTag,', + 'tag: "v0.3.0",', + ), + ); + case 'missing-manifest': + manifest.deleteSync(); + } + }, + ); + var emitted = false; + await expectLater( + testCodeBuildHook( + mainMethod: build_hook.main, + targetOS: OS.iOS, + targetArchitecture: Architecture.arm64, + targetIOSSdk: IOSSdk.iPhoneOS, + userDefines: defines, + check: (_, _) => emitted = true, + ), + throwsA( + predicate( + (error) => error.toString().contains( + 'Incompatible Apple llama.cpp companion', + ), + ), + ), + ); + expect(emitted, isFalse); + }); + } + + test( + 'workspace-resolved matching companion tracks all metadata for caching', + () async { + final defines = await _flutterAppleUserDefines( + dependencies: const ['llamadart_llama_cpp_flutter'], + workspaceMember: true, + ); + await testCodeBuildHook( + mainMethod: build_hook.main, + targetOS: OS.macOS, + targetArchitecture: Architecture.arm64, + userDefines: defines, + check: (_, output) { + expect( + output.assets.encodedAssets.single.asCodeAsset.linkMode, + isA(), + ); + final dependencies = output.dependencies + .map((uri) => uri.toFilePath()) + .toList(); + expect( + dependencies.where((entry) => entry.endsWith('pubspec.yaml')), + hasLength(2), + ); + expect( + dependencies.any((entry) => entry.endsWith('package_config.json')), + isTrue, + ); + expect( + dependencies.any((entry) => entry.endsWith('Package.swift')), + isTrue, + ); + expect( + dependencies.any((entry) => entry.endsWith('Artifacts/')), + isTrue, + ); + }, + ); + }, + ); + + test( + 'flow YAML and dependency overrides cannot hide old resolved ABI', + () async { + final defines = await _flutterAppleUserDefines( + dependencies: const ['llamadart_llama_cpp_flutter'], + companionTag: 'v0.3.0', + mutate: (root) => + File(path.join(root.path, 'pubspec.yaml')).writeAsStringSync(''' +name: consumer +dependencies: {flutter: {sdk: flutter}, llamadart: ^0.8.22, llamadart_llama_cpp_flutter: ^0.0.18} +dependency_overrides: {llamadart_llama_cpp_flutter: {path: resolved companion}} +'''), + ); + await expectLater( + testCodeBuildHook( + mainMethod: build_hook.main, + targetOS: OS.iOS, + targetArchitecture: Architecture.arm64, + targetIOSSdk: IOSSdk.iPhoneOS, + userDefines: defines, + check: (_, _) => fail('Must not emit in-process assets'), + ), + throwsA( + predicate( + (error) => error.toString().contains( + 'Incompatible Apple llama.cpp companion', + ), + ), + ), + ); + }, + ); + test('build hook ignores native source overrides for Apple SPM', () async { final userDefines = await _flutterAppleUserDefines( dependencies: const ['llamadart_llama_cpp_flutter'], @@ -731,6 +926,13 @@ Future _flutterAppleUserDefines({ required List dependencies, Map defines = const {}, String dependenciesYaml = '', + String? companionTag, + String companionVersion = '0.0.18', + bool missingConfiguration = false, + bool duplicateCompanion = false, + bool localArtifacts = false, + bool workspaceMember = false, + void Function(Directory)? mutate, }) async { final dir = await Directory.systemTemp.createTemp( 'llamadart_apple_consumer_', @@ -741,7 +943,11 @@ Future _flutterAppleUserDefines({ } }); - final pubspec = File(path.join(dir.path, 'pubspec.yaml')); + final consumer = workspaceMember + ? Directory(path.join(dir.path, 'app')) + : dir; + await consumer.create(recursive: true); + final pubspec = File(path.join(consumer.path, 'pubspec.yaml')); await pubspec.writeAsString(''' name: llamadart_apple_consumer publish_to: none @@ -751,12 +957,58 @@ environment: flutter: ^3.38.0 dependencies: + llamadart: ^0.8.22 flutter: sdk: flutter ${dependenciesYaml.trimRight()} -${dependencies.map((dependency) => ' $dependency: ^0.8.0').join('\n')} +${dependencies.map((dependency) => ' $dependency: ^0.0.17').join('\n')} '''); + if (dependencies.contains('llamadart_llama_cpp_flutter') && + !missingConfiguration) { + final companion = Directory(path.join(dir.path, 'resolved companion')); + await companion.create(); + await File(path.join(companion.path, 'pubspec.yaml')).writeAsString( + 'name: llamadart_llama_cpp_flutter\nversion: $companionVersion\n', + ); + final manifest = File( + path.join( + companion.path, + 'darwin', + 'llamadart_llama_cpp_flutter', + 'Package.swift', + ), + ); + await manifest.parent.create(recursive: true); + await manifest.writeAsString( + File( + 'packages/llamadart_llama_cpp_flutter/' + 'darwin/llamadart_llama_cpp_flutter/Package.swift', + ).readAsStringSync().replaceFirst( + 'let llamaCppTag = "${_readHookConst('_llamaCppTag')}"', + 'let llamaCppTag = "${companionTag ?? _readHookConst('_llamaCppTag')}"', + ), + ); + if (localArtifacts) { + await Directory(path.join(manifest.parent.path, 'Artifacts')).create(); + } + final config = File( + path.join(dir.path, '.dart_tool', 'package_config.json'), + ); + await config.parent.create(); + final entry = { + 'name': 'llamadart_llama_cpp_flutter', + 'rootUri': '../resolved%20companion', + }; + await config.writeAsString( + jsonEncode({ + 'configVersion': 2, + 'packages': [entry, if (duplicateCompanion) entry], + }), + ); + } + mutate?.call(dir); + return PackageUserDefines( workspacePubspec: PackageUserDefinesSource( defines: defines, diff --git a/website/docs/changelog/recent-releases.md b/website/docs/changelog/recent-releases.md index f85508c6f..b6e3c39f3 100644 --- a/website/docs/changelog/recent-releases.md +++ b/website/docs/changelog/recent-releases.md @@ -9,6 +9,10 @@ For canonical full release notes, use: ## Unreleased +- Fail Apple builds before native symbol lookup when the resolved llama.cpp + companion does not match the core native runtime, with actionable upgrade + guidance. + - Adopted native llama.cpp v0.4.0 with matching bindings and multimodal calls. Saved native sessions from older runtimes must be regenerated. Prepared Apple companion `0.0.18` for the matching native runtime. diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index 98d4c2e4d..f2802952b 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -40,6 +40,11 @@ The development example below requires both path overrides to the same checkout. Publish companion `0.0.18` only with the matching next core release, and replace these temporary overrides/version constraints during that coordinated release. +Apple builds verify the resolved companion's SwiftPM runtime pin before native +symbol lookup. Incompatible companions or unverified local `Artifacts` +overrides fail the build; resolve the matching companion and rerun +`flutter pub get`. Core native overrides do not replace SPM frameworks. + ```yaml dependencies: llamadart: ^0.8.22 diff --git a/website/docs/maintainers/native-and-web-sync.md b/website/docs/maintainers/native-and-web-sync.md index eeb8251ec..7cc6250e7 100644 --- a/website/docs/maintainers/native-and-web-sync.md +++ b/website/docs/maintainers/native-and-web-sync.md @@ -3,6 +3,20 @@ title: Native and Web Sync Flows description: Follow the correct workflow when syncing native bindings, companion package pins, or published web bridge assets. --- +## Apple companion compatibility + +Apple llama.cpp companion selection validates the **resolved** package from +the consumer/workspace `package_config.json`, including path and dependency +overrides. Its package identity and maintained SwiftPM native pin must match +the core hook pin before any in-process native asset is emitted. Updating a +declared dependency constraint alone is insufficient: rerun `flutter pub get` +and resolve the matching companion. Core native tag/path/backend overrides do +not replace SwiftPM frameworks and cannot bypass this check. + +Local `Artifacts` overrides in that companion are rejected because their ABI +provenance is unverified. Remove the override and use the pinned remote +framework. Non-Apple native-assets and LiteRT selection are unchanged. + ## Native sync flow When native behavior or bindings need updates: From 603e2c1388a8809c71b237477d12ba9ab1034638 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 7 Sep 2026 10:59:17 -0400 Subject: [PATCH 2/3] fix: bind Apple companion framework target contract --- AGENTS.md | 2 ++ hook/build.dart | 28 +++++++++++-------- ...build_hook_litert_lm_integration_test.dart | 16 +++++++++++ .../docs/maintainers/native-and-web-sync.md | 5 ++++ 4 files changed, 40 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 570ed4394..34a24c1d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -217,6 +217,8 @@ identity and SwiftPM pin to match the core native pin. Preserve this guard and its metadata cache dependencies when changing sync or hook behavior; declared version constraints and core native overrides are not ABI evidence. Unverified local companion `Artifacts` overrides must fail closed. +Changes to the companion SwiftPM implementation require reviewing/updating the +hook's normalized manifest template contract; tag/checksum-only syncs do not. Prefer the repository workflow for native version and binding updates: `.github/workflows/sync_native_bindings.yml`. diff --git a/hook/build.dart b/hook/build.dart index 4b639cc67..ece6a272f 100644 --- a/hook/build.dart +++ b/hook/build.dart @@ -19,6 +19,11 @@ const _nativeRepoSlug = 'leehack/llamadart-native'; const _packageName = 'llamadart'; const _llamaCppFlutterPackageName = 'llamadart_llama_cpp_flutter'; +// Bind the maintained SwiftPM code, not just a tag declaration that arbitrary +// Swift could ignore. Only the release tag/checksum and CRLF are normalized. +// Changes to the companion manifest implementation require contract review. +const _appleCompanionManifestTemplateSha256 = + '6f047f32a768fb3afd2eb4b0488768b591f254fe94e2bb472c5d70b34ff52a86'; const _liteRtLmFlutterPackageName = 'llamadart_litert_lm_flutter'; const _thirdPartyDir = 'third_party'; const _binDir = 'bin'; @@ -592,7 +597,7 @@ void _validateAppleLlamaCompanion( 'Incompatible Apple llama.cpp companion: $reason ' 'Resolve $_llamaCppFlutterPackageName with a Package.swift pin matching ' '$_nativeRepoSlug@$_llamaCppTag and rerun flutter pub get. ' - 'For native v0.4.0 use companion 0.0.18 with the matching core; ' + 'Upgrade the core and companion together to a matching released pair; ' 'native tag/path overrides do not replace SPM frameworks. ' 'No in-process native asset was emitted.', ); @@ -672,16 +677,17 @@ void _validateAppleLlamaCompanion( 'the required native runtime $_llamaCppTag.', ); } - // Only the maintained tag-driven remote target contract is recognized. - // A copied tag declaration must not authorize a different target pin. - if (!RegExp( - r'repository:\s*"leehack/llamadart-native",', - ).hasMatch(source) || - !source.contains( - 'artifactName: "llamadart-native-apple-xcframework-' - r'\(llamaCppTag).zip"', - ) || - RegExp(r'tag:\s*llamaCppTag,').allMatches(source).length != 1) { + final normalizedSource = source.replaceAll('\r\n', '\n'); + final checksum = RegExp(r'checksum: "[0-9a-f]{64}"'); + final template = normalizedSource + .replaceFirst( + RegExp(r'^let llamaCppTag = "[^"\r\n]+"$', multiLine: true), + 'let llamaCppTag = "PIN"', + ) + .replaceFirst(checksum, 'checksum: "CHECKSUM"'); + if (checksum.allMatches(normalizedSource).length != 1 || + sha256.convert(utf8.encode(template)).toString() != + _appleCompanionManifestTemplateSha256) { reject('Companion SwiftPM target does not use the supported native pin.'); } final artifacts = Directory(path.join(manifest.parent.path, 'Artifacts')); diff --git a/test/unit/hook/build_hook_litert_lm_integration_test.dart b/test/unit/hook/build_hook_litert_lm_integration_test.dart index f62789224..db68b5c08 100644 --- a/test/unit/hook/build_hook_litert_lm_integration_test.dart +++ b/test/unit/hook/build_hook_litert_lm_integration_test.dart @@ -378,6 +378,8 @@ void main() { 'missing-pin', 'duplicate-pin', 'wrong-target', + 'hardcoded-url', + 'comment-decoy', 'missing-manifest', ]) { test('Apple companion rejects $scenario metadata before lookup', () async { @@ -425,6 +427,20 @@ void main() { ); case 'missing-manifest': manifest.deleteSync(); + case 'hardcoded-url': + manifest.writeAsStringSync( + manifest.readAsStringSync().replaceFirst( + r'url: "https://github.com/\(repository)/releases/download/\(tag)/\(artifactName)"', + 'url: "https://github.com/leehack/llamadart-native/releases/download/v0.3.0/llamadart-native-apple-xcframework-v0.3.0.zip"', + ), + ); + case 'comment-decoy': + manifest.writeAsStringSync( + manifest.readAsStringSync().replaceFirst( + 'tag: llamaCppTag,', + '// tag: llamaCppTag,\n tag: "v0.3.0",', + ), + ); } }, ); diff --git a/website/docs/maintainers/native-and-web-sync.md b/website/docs/maintainers/native-and-web-sync.md index 7cc6250e7..0549c228e 100644 --- a/website/docs/maintainers/native-and-web-sync.md +++ b/website/docs/maintainers/native-and-web-sync.md @@ -5,6 +5,11 @@ description: Follow the correct workflow when syncing native bindings, companion ## Apple companion compatibility +The hook binds the complete maintained SwiftPM template, normalizing only its +release tag, checksum and CRLF line endings. A companion manifest code change +requires a reviewed hook contract update; copied tag declarations cannot +authorize alternate framework URLs or target code. + Apple llama.cpp companion selection validates the **resolved** package from the consumer/workspace `package_config.json`, including path and dependency overrides. Its package identity and maintained SwiftPM native pin must match From 93966bf0268e20b83eb3348297ad1e1eeb44b0f3 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 7 Sep 2026 11:04:13 -0400 Subject: [PATCH 3/3] test: make Apple cache dependency check portable --- test/unit/hook/build_hook_litert_lm_integration_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/hook/build_hook_litert_lm_integration_test.dart b/test/unit/hook/build_hook_litert_lm_integration_test.dart index db68b5c08..bad5fb1ec 100644 --- a/test/unit/hook/build_hook_litert_lm_integration_test.dart +++ b/test/unit/hook/build_hook_litert_lm_integration_test.dart @@ -499,7 +499,7 @@ void main() { isTrue, ); expect( - dependencies.any((entry) => entry.endsWith('Artifacts/')), + output.dependencies.any((uri) => uri.path.endsWith('/Artifacts/')), isTrue, ); },