From 38e7cec09811b081e3927c35babc96da4c3fff09 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 19 Aug 2026 15:32:43 -0500 Subject: [PATCH] fix(ts): preserve component import build parity --- build.zig | 26 ++++++++++ build/app.zig | 43 +++++++++++++--- src/app_runner/ts_core_main.zig | 5 +- src/app_runner/ts_core_mobile.zig | 4 +- src/primitives/canvas/ui_markup.zig | 19 ++++++- src/primitives/canvas/ui_markup_tests.zig | 37 +++++++++++++ tools/native-sdk/main.zig | 2 +- tools/native-sdk/markup.zig | 63 ++++++++++++++++++++--- 8 files changed, 179 insertions(+), 20 deletions(-) diff --git a/build.zig b/build.zig index 8b79613d..109ccb57 100644 --- a/build.zig +++ b/build.zig @@ -126,17 +126,43 @@ test "root TypeScript markup discovery classification and resolver budgets" { test "generated TypeScript runners install the compiled root markup view" { const desktop = @embedFile("src/app_runner/ts_core_main.zig"); try std.testing.expect(std.mem.indexOf(u8, desktop, "TsUiAppWithFeatures(core, .{ .runtime_markup = dev })") != null); + try std.testing.expect(std.mem.indexOf(u8, desktop, "if (dev) void else @import(\"app_markup_root\")") != null); try std.testing.expect(std.mem.indexOf(u8, desktop, "CompiledMarkupImports(core.Model, core.Msg, \"app.native\", &app_markup_sources)") != null); try std.testing.expect(std.mem.indexOf(u8, desktop, ".view = CompiledAppView.build") != null); const mobile = @embedFile("src/app_runner/ts_core_mobile.zig"); try std.testing.expect(std.mem.indexOf(u8, mobile, "pub const features: native_sdk.UiAppFeatures = .{ .runtime_markup = false }") != null); try std.testing.expect(std.mem.indexOf(u8, mobile, "TsUiAppWithFeatures(core, features)") != null); + try std.testing.expect(std.mem.indexOf(u8, mobile, "const app_markup_root = @import(\"app_markup_root\")") != null); try std.testing.expect(std.mem.indexOf(u8, mobile, "CompiledMarkupImports(core.Model, core.Msg, \"app.native\", &app_markup_sources)") != null); try std.testing.expect(std.mem.indexOf(u8, mobile, ".view = CompiledAppView.build") != null); try std.testing.expect(std.mem.indexOf(u8, mobile, ".markup =") == null); } +test "Debug TypeScript root markup stays outside the staged app module" { + const source = @embedFile("build/app.zig"); + try std.testing.expect(std.mem.indexOf( + u8, + source, + "_ = staged.addCopyFile(b.path(appPath(b, app_root, \"src/app.native\")), \"app.native\");", + ) == null); + try std.testing.expect(std.mem.indexOf( + u8, + source, + "_ = release_markup.addCopyFile(b.path(appPath(b, app_root, \"src/app.native\")), \"app.native\");", + ) != null); + try std.testing.expect(std.mem.indexOf(u8, source, "if (optimize != .Debug)") != null); +} + +test "native check preserves the app markup root for component files" { + const source = @embedFile("tools/native-sdk/main.zig"); + try std.testing.expect(std.mem.indexOf( + u8, + source, + "checkFiles(allocator, io, markup_files.items, .{ .import_root = \"src\" })", + ) != null); +} + pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const host_target = b.graph.host; diff --git a/build/app.zig b/build/app.zig index bb55b8c6..ee1fad91 100644 --- a/build/app.zig +++ b/build/app.zig @@ -483,6 +483,10 @@ const TsCoreStage = struct { /// mirror/registry files): the embed static library's `app` /// module roots here on iOS/Android targets. mobile_root: std.Build.LazyPath, + /// Release/mobile-only module embedding src/app.native. It lives in a + /// separate generated directory so a Debug app-code module never gains + /// the root markup as a transitive file input; Debug links markup_c. + app_markup_root: std.Build.LazyPath, /// The compiled-core archive: the app module links it (with libc, /// for the toolchain's runtime) beside the staged mirror. archive: std.Build.LazyPath, @@ -1310,12 +1314,12 @@ fn tsCoreStage( \\ , .{ service_carrier, service_pool_workers })); _ = staged.addCopyFile(migrations_zig, "migrations.zig"); - // Release desktop and mobile compile the complete root markup closure at - // comptime, so stage the root and every non-window import beside the - // generated runner. Debug desktop still reads the root through the small - // linked C data object below, preserving link-only rebuilds for ordinary - // edits while using these staged imports for the runtime resolver. - _ = staged.addCopyFile(b.path(appPath(b, app_root, "src/app.native")), "app.native"); + // Every imported file is an input to Debug's embedded source resolver and + // the release/mobile compiled view, so stage those beside the runner. + // Keep the ROOT out of this directory: changing any file in one WriteFiles + // output changes its directory identity, and the runner lives here. Root + // markup gets a separate release-only module below; Debug keeps the small + // linked C data object, preserving its link-only edit path. for (app_markup_sources.files) |source| { _ = staged.addCopyFile(b.path(appPath(b, app_root, source.source_path)), source.staged_path); } @@ -1338,9 +1342,17 @@ fn tsCoreStage( // registry, same carrier constant — only the shell differs (the embed // host's AppDef contract instead of a process `main`). const mobile_root = staged.addCopyFile(dep.path("src/app_runner/ts_core_mobile.zig"), "mobile.zig"); + const release_markup = b.addWriteFiles(); + _ = release_markup.addCopyFile(b.path(appPath(b, app_root, "src/app.native")), "app.native"); + const app_markup_root = release_markup.add("app_markup_root.zig", + \\//! Generated release/mobile embedding for the TypeScript root view. + \\pub const source = @embedFile("app.native"); + \\ + ); return .{ .main_root = main_root, .mobile_root = mobile_root, + .app_markup_root = app_markup_root, .archive = archive, .service_exe = service_exe, .service_archive = service_archive, @@ -1579,6 +1591,8 @@ pub const MobileLibOptions = struct { pub const MobileTsCore = struct { /// The staged mobile wiring (mobile.zig beside the generated mirror). main_root: std.Build.LazyPath, + /// Separate root-markup module consumed by the compiled mobile view. + app_markup_root: std.Build.LazyPath, /// The compiled-core archive; merged into the embed library. archive: std.Build.LazyPath, /// The in-process service archive, when src/services exists. @@ -1647,6 +1661,11 @@ fn addMobileLibWithTarget(b: *std.Build, dep: *std.Build.Dependency, target: std .optimize = optimize, }); mod.addImport("app_manifest_zon", ts.manifest_mod); + mod.addImport("app_markup_root", b.createModule(.{ + .root_source_file = ts.app_markup_root, + .target = target, + .optimize = optimize, + })); break :ts_app mod; } else localModule(b, target, optimize, options.main); app_mod.addImport("native_sdk", native_sdk_mod); @@ -1836,6 +1855,7 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A .max_image_pixel_bytes = app_config.max_image_pixel_bytes, .ts_core = if (ts_stage) |stage| .{ .main_root = stage.mobile_root, + .app_markup_root = stage.app_markup_root, .archive = stage.archive, .service_archive = stage.service_archive, .manifest_mod = appManifestModule(b, app_options.app_root, manifest_name), @@ -2184,6 +2204,17 @@ fn appModule(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.Resolv // toolchain's runtime needs libc. app_mod.link_libc = true; app_mod.addObjectFile(stage.archive); + // Debug reads src/app.native only through the separately linked C + // data object. Supplying this generated module in Debug would put + // the authored root back into the app-code dependency graph even + // though the runner's comptime branch never imports it. + if (optimize != .Debug) { + app_mod.addImport("app_markup_root", b.createModule(.{ + .root_source_file = stage.app_markup_root, + .target = target, + .optimize = optimize, + })); + } // The in-process service archive links beside it (distinct symbol // prefix; its runtime internals are localized). if (stage.service_archive) |service_archive| app_mod.addObjectFile(service_archive); diff --git a/src/app_runner/ts_core_main.zig b/src/app_runner/ts_core_main.zig index 9c2dd342..9f3593f0 100644 --- a/src/app_runner/ts_core_main.zig +++ b/src/app_runner/ts_core_main.zig @@ -95,8 +95,9 @@ pub fn appMarkup() []const u8 { // Debug keeps root markup in the separately linked data object above, so an // edit is a C-data compile + relink and the runtime interpreter owns imports. // Release embeds the same closure and compiles it to direct view code. -const app_markup_sources = [_]native_sdk.canvas.ui_markup.SourceFile{ - .{ .path = "app.native", .source = @embedFile("app.native") }, +const app_markup_root = if (dev) void else @import("app_markup_root"); +const app_markup_sources = if (dev) void else [_]native_sdk.canvas.ui_markup.SourceFile{ + .{ .path = "app.native", .source = app_markup_root.source }, } ++ app_sources.sources; const CompiledAppView = if (dev) void diff --git a/src/app_runner/ts_core_mobile.zig b/src/app_runner/ts_core_mobile.zig index a3d5b148..cebf6ddc 100644 --- a/src/app_runner/ts_core_mobile.zig +++ b/src/app_runner/ts_core_mobile.zig @@ -40,6 +40,7 @@ pub const core = @import("core.zig"); const services = @import("services.zig"); const service_carrier = @import("service_carrier.zig"); const app_sources = @import("app_sources.zig"); +const app_markup_root = @import("app_markup_root"); /// Shared with the embed host: its UiApp type must use the same feature set /// as this module's TypeScript adapter or their Options types are distinct. @@ -51,9 +52,8 @@ const Adapter = native_sdk.TsUiAppWithFeatures(core, features); pub const Model = core.Model; pub const Msg = core.Msg; -pub const app_markup = @embedFile("app.native"); const app_markup_sources = [_]native_sdk.canvas.ui_markup.SourceFile{ - .{ .path = "app.native", .source = app_markup }, + .{ .path = "app.native", .source = app_markup_root.source }, } ++ app_sources.sources; const CompiledAppView = native_sdk.canvas.CompiledMarkupImports(core.Model, core.Msg, "app.native", &app_markup_sources); diff --git a/src/primitives/canvas/ui_markup.zig b/src/primitives/canvas/ui_markup.zig index 7d0e3547..69dd0563 100644 --- a/src/primitives/canvas/ui_markup.zig +++ b/src/primitives/canvas/ui_markup.zig @@ -3899,10 +3899,27 @@ pub fn resolveImports( root_source: []const u8, loader: ImportLoader, diagnostic: *MarkupErrorInfo, +) ResolveError!MarkupDocument { + return resolveImportsFromRoot(arena, dirnamePath(root_name), root_name, root_source, loader, diagnostic); +} + +/// Resolve an import closure against an explicit markup root. App-wide +/// tooling uses this form while checking component files independently: +/// every file under `src/` keeps the app root, so a component may import a +/// sibling directory exactly as it does when reached from `src/app.native`. +/// Direct `native markup check ` continues through `resolveImports` +/// above, where the checked file's own directory is the root. +pub fn resolveImportsFromRoot( + arena: std.mem.Allocator, + root_dir: []const u8, + root_name: []const u8, + root_source: []const u8, + loader: ImportLoader, + diagnostic: *MarkupErrorInfo, ) ResolveError!MarkupDocument { var resolver = ImportResolver{ .arena = arena, - .root_dir = dirnamePath(root_name), + .root_dir = root_dir, .loader = loader, .diagnostic = diagnostic, }; diff --git a/src/primitives/canvas/ui_markup_tests.zig b/src/primitives/canvas/ui_markup_tests.zig index d7e5999b..390118e8 100644 --- a/src/primitives/canvas/ui_markup_tests.zig +++ b/src/primitives/canvas/ui_markup_tests.zig @@ -1450,6 +1450,43 @@ test "an absolute root path resolves imports the same as a relative one" { try testing.expectEqualStrings(markup.import_src_escape_message, escaped.message); } +test "an explicit app root lets an independently checked component import a sibling directory" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const set = [_]markup.SourceFile{ + .{ + .path = "src/components/card.native", + .source = "\n", + }, + .{ + .path = "src/shared/base.native", + .source = "", + }, + }; + var loader = markup.SourceSetLoader{ .set = &set }; + var diagnostic: markup.MarkupErrorInfo = .{}; + const document = try markup.resolveImportsFromRoot( + arena, + "src", + set[0].path, + set[0].source, + loader.loader(), + &diagnostic, + ); + try testing.expectEqual(@as(usize, 2), document.templates.len); + try testing.expectEqualStrings("base", document.templates[0].attr("name").?); + try testing.expectEqualStrings("card", document.templates[1].attr("name").?); + + // The direct-file resolver deliberately retains its narrower boundary. + try testing.expectError( + error.MarkupImport, + markup.resolveImports(arena, set[0].path, set[0].source, loader.loader(), &diagnostic), + ); + try testing.expectEqualStrings(markup.import_src_escape_message, diagnostic.message); +} + test ".native is the one markup extension" { try testing.expect(markup.hasMarkupExtension("view.native")); try testing.expect(!markup.hasMarkupExtension("view.html")); diff --git a/tools/native-sdk/main.zig b/tools/native-sdk/main.zig index b74a80c6..7d611d81 100644 --- a/tools/native-sdk/main.zig +++ b/tools/native-sdk/main.zig @@ -715,7 +715,7 @@ fn runCheck(allocator: std.mem.Allocator, io: std.Io, env_map: *std.process.Envi try collectMarkupFiles(allocator, io, "src", &markup_files); var outcome = markup_cli.CheckOutcome{}; if (markup_files.items.len > 0) { - outcome = try markup_cli.checkFiles(allocator, io, markup_files.items); + outcome = try markup_cli.checkFiles(allocator, io, markup_files.items, .{ .import_root = "src" }); if (outcome.failures > 0) return error.MarkupCheckFailed; } diff --git a/tools/native-sdk/markup.zig b/tools/native-sdk/markup.zig index 7404094a..e4d60a75 100644 --- a/tools/native-sdk/markup.zig +++ b/tools/native-sdk/markup.zig @@ -36,7 +36,7 @@ pub fn run(allocator: std.mem.Allocator, io: std.Io, args: []const []const u8) ! return error.MarkupCommandFailed; } - const outcome = try checkFiles(allocator, io, files.items); + const outcome = try checkFiles(allocator, io, files.items, .{}); // Exit directly: the diagnostics above are the whole story, and a // returned error would bury them under the CLI's own return trace. if (outcome.failures > 0) std.process.exit(1); @@ -55,13 +55,21 @@ pub const CheckOutcome = struct { contract_checked: bool = false, }; +pub const CheckOptions = struct { + /// Shared import boundary for every checked file. `native check` sets + /// this to `src`, matching the generated app resolver; the standalone + /// markup command leaves it null so each explicitly named file remains + /// rooted at its own directory. + import_root: ?[]const u8 = null, +}; + /// The `check` body shared by `native markup check` and `native check`: /// structural validation of every file, plus — when the working directory /// is an app with a FRESH model-contract artifact — the model-aware /// contract pass and the dead-state lint. A missing, stale, or unreadable /// artifact degrades to structural checking with a note, never a false /// pass. -pub fn checkFiles(allocator: std.mem.Allocator, io: std.Io, files: []const []const u8) !CheckOutcome { +pub fn checkFiles(allocator: std.mem.Allocator, io: std.Io, files: []const []const u8, options: CheckOptions) !CheckOutcome { var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); @@ -104,6 +112,7 @@ pub fn checkFiles(allocator: std.mem.Allocator, io: std.Io, files: []const []con .contract = if (contract_value) |*parsed| parsed else null, .usage = if (usage_state) |*live_usage| live_usage else null, .arena = arena, + .import_root = options.import_root, }) catch { outcome.failures += 1; printOrphanHint(arena, io, file_path, &embedded_basenames); @@ -194,6 +203,41 @@ test "orphanNote: Zig track hints only for unembedded files" { try std.testing.expect(std.mem.indexOf(u8, note, "@embedFile") != null); } +test "app-wide checking preserves src as the component import root" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const io = std.testing.io; + try tmp.dir.createDirPath(io, "src/components"); + try tmp.dir.createDirPath(io, "src/shared"); + try tmp.dir.writeFile(io, .{ + .sub_path = "src/components/card.native", + .data = + \\ + \\ + , + }); + try tmp.dir.writeFile(io, .{ + .sub_path = "src/shared/base.native", + .data = "\n", + }); + + var root_buffer: [256]u8 = undefined; + const root = try std.fmt.bufPrint(&root_buffer, ".zig-cache/tmp/{s}/src", .{tmp.sub_path[0..]}); + var path_buffer: [320]u8 = undefined; + const component_path = try std.fmt.bufPrint(&path_buffer, "{s}/components/card.native", .{root}); + const checked = try checkFile(std.testing.allocator, io, component_path, .{ + .arena = std.testing.allocator, + .import_root = root, + }); + try std.testing.expect(!checked.had_view); + + // An explicitly named standalone component keeps its own directory as + // the root; reaching a sibling remains an escape in that narrower mode. + try std.testing.expectError(error.MarkupImport, checkFile(std.testing.allocator, io, component_path, .{ + .arena = std.testing.allocator, + })); +} + /// The basename of every `@embedFile("...")` argument across the .zig /// sources under src/, each file read once. fn collectEmbeddedBasenames(arena: std.mem.Allocator, io: std.Io) ![]const []const u8 { @@ -339,6 +383,7 @@ const FileCheckContext = struct { /// Session arena for contract-check messages, which outlive the /// per-file arena (the dead-state summary prints after all files). arena: std.mem.Allocator, + import_root: ?[]const u8 = null, }; const FileCheckResult = struct { @@ -356,14 +401,16 @@ fn checkFile(allocator: std.mem.Allocator, io: std.Io, file_path: []const u8, co var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); - // Resolve the import closure from disk, rooted at the checked file's - // directory (the markup root): checking a view checks its imports, and - // a broken import reports at the importing file's position. A file - // that is all templates (no view root) is a valid component file — - // it checks standalone and as an import target. + // Resolve the import closure from disk. Standalone checks root at the + // checked file's directory; app-wide `native check` supplies the app's + // shared `src/` root so independently checked component files preserve + // the same sibling-import boundary as src/app.native. var disk_loader = DiskLoader{ .io = io }; var diagnostic: ui_markup.MarkupErrorInfo = .{}; - const document = ui_markup.resolveImports(arena_state.allocator(), file_path, source, disk_loader.loader(), &diagnostic) catch |err| { + const document = (if (context.import_root) |root| + ui_markup.resolveImportsFromRoot(arena_state.allocator(), root, file_path, source, disk_loader.loader(), &diagnostic) + else + ui_markup.resolveImports(arena_state.allocator(), file_path, source, disk_loader.loader(), &diagnostic)) catch |err| { const path = if (diagnostic.path.len > 0) diagnostic.path else file_path; std.debug.print("{s}:{d}:{d}: error: {s}\n", .{ path, diagnostic.line, diagnostic.column, diagnostic.message }); printStaleBinaryHint(diagnostic.message);