fix(ts): preserve component import build parity

This commit is contained in:
Chris Tate
2026-08-19 15:32:43 -05:00
parent e0138ae058
commit 38e7cec098
8 changed files with 179 additions and 20 deletions
+26
View File
@@ -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;
+37 -6
View File
@@ -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);
+3 -2
View File
@@ -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
+2 -2
View File
@@ -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);
+18 -1
View File
@@ -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 <file>` 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,
};
+37
View File
@@ -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 = "<import src=\"../shared/base.native\"/>\n<template name=\"card\"><use template=\"base\" /></template>",
},
.{
.path = "src/shared/base.native",
.source = "<template name=\"base\"><text>shared</text></template>",
},
};
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"));
+1 -1
View File
@@ -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;
}
+55 -8
View File
@@ -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 =
\\<import src="../shared/base.native"/>
\\<template name="card"><column><use template="base" /></column></template>
,
});
try tmp.dir.writeFile(io, .{
.sub_path = "src/shared/base.native",
.data = "<template name=\"base\"><text>shared</text></template>\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);