diff options
Diffstat (limited to 'src/extension_loader.zig')
| -rw-r--r-- | src/extension_loader.zig | 1181 |
1 files changed, 415 insertions, 766 deletions
diff --git a/src/extension_loader.zig b/src/extension_loader.zig index 87eb609..4620a2e 100644 --- a/src/extension_loader.zig +++ b/src/extension_loader.zig @@ -1,45 +1,37 @@ -//! Extension and tool discovery: walk well-known directories, locate Lua -//! files, and load each into a long-lived `LuaRuntime`. +//! Extension discovery: walk well-known directories (plus any extra +//! `paths` and installed `rocks`), evaluate each Lua source to learn what +//! extension *entries* it declares, then activate the surviving ones into a +//! long-lived `LuaRuntime`. //! -//! Two parallel namespaces are scanned, each at three scopes — base, -//! user, and project. Project shadows user shadows base. +//! Sources, in precedence order. Cross-layer: project > user > base. +//! Within a layer: `rocks < paths < dir` (the canonical dir is most +//! authoritative locally, a rock least). The scanned dirs are: //! -//! Extensions (full-featured; call `panto.ext.register_tool` from a script -//! that may register many tools): -//! 1. `<data home>/agent/extensions/` ("base") -//! 2. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/extensions/` ("user") -//! 3. `./.panto/extensions/` ("project") +//! <data home>/agent/{extensions,tools}/ ("base") +//! ${XDG_CONFIG_HOME:-$HOME/.config}/panto/{extensions,tools}/ ("user") +//! ./.panto/{extensions,tools}/ ("project") //! -//! Tools (ergonomic single-tool form; the script returns one table -//! shaped like the argument to `panto.ext.register_tool`): -//! 1. `<data home>/agent/tools/` ("base") -//! 2. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/tools/` ("user") -//! 3. `./.panto/tools/` ("project") +//! The `base` layer is staged at bootstrap from files embedded into the +//! binary (see `build/gen_agent_embed.zig`). `extensions/` and `tools/` are +//! no longer distinct namespaces — both just contribute entries. //! -//! The `base` layer is populated at bootstrap from files embedded -//! into the panto binary (see `build/gen_agent_embed.zig` and -//! `luarocks_runtime.stageAgentTree`). It is panto's "batteries" tier: -//! ships in the binary, but every entry is individually shadowable -//! from the user or project layer. +//! Layout per directory: +//! - `<file>.lua` -- single-file source. +//! - `<dir>/init.lua` -- directory source; the directory is added to +//! `package.path` so it can `require` siblings. //! -//! Layout per directory (identical for extensions and tools): -//! - `<name>.lua` -- single-file entry; the logical name is the -//! basename without the `.lua` suffix. -//! - `<name>/init.lua` -- directory entry; the logical name is the -//! directory name. The directory is added to -//! the script's `package.path` so it can -//! `require` sibling Lua files. +//! Each source is eval'd (side-effect-free) and must return an *entry* +//! `{ name, activate }`, the sugar tool form `{ name, handler, schema, ... }`, +//! or a list of those. Identity is the declared `name`, not the filename. //! -//! Conflict rules: -//! - Within one directory, two entries with the same logical name -//! are an error. -//! - Project shadows user shadows base *within the same kind* -//! (extension or tool). Extensions and tools live in distinct -//! namespaces for shadowing; the registered *tool names* still -//! share one global namespace across both, and collisions there -//! are still an error. +//! Resolution (two passes): +//! 1. Availability — eval every source, collect `name → entry`. Later +//! (higher-precedence) source wins for the same name; same-precedence +//! duplicate is an error. +//! 2. Activation — for every permitted name (per the `[extensions]` +//! policy), call `entry.activate()`. //! -//! Symlinks: followed normally. Dotfiles: skipped. +//! Symlinks: followed normally. Dotfiles and `_`-prefixed files: skipped. const std = @import("std"); const panto = @import("panto"); @@ -49,224 +41,233 @@ const config_file = @import("config_file.zig"); const Allocator = std.mem.Allocator; const Io = std.Io; const LuaRuntime = lua_runtime.LuaRuntime; +const Entry = LuaRuntime.Entry; -/// Availability policies for the two namespaces. Both are optional; a -/// null policy permits everything. `extensions` gates extension/tool -/// *entries* by their logical (file/dir) name before loading; `tools` -/// gates the *registered tool names* (e.g. `std.read`) after loading. -pub const Policies = struct { - extensions: ?*const config_file.Policy = null, - tools: ?*const config_file.Policy = null, +pub const Layer = enum(u8) { base = 0, user = 1, project = 2 }; + +/// Within-layer origin, ordered least → most authoritative. +pub const Origin = enum(u8) { rocks = 0, paths = 1, dir = 2 }; + +/// Where a source came from, and thus its precedence. Higher `rank()` wins +/// when two sources declare the same entry name. +pub const Source = struct { + layer: Layer, + origin: Origin, + + pub fn rank(self: Source) u8 { + return (@as(u8, @intFromEnum(self.layer)) << 2) | @intFromEnum(self.origin); + } + + pub fn label(self: Source) []const u8 { + return switch (self.layer) { + .base => "base", + .user => "user", + .project => "project", + }; + } }; -fn policyPermits(p: ?*const config_file.Policy, name: []const u8) bool { - const pol = p orelse return true; - return pol.permits(name); -} +/// One directory to scan, tagged with its source precedence. +pub const ScanDir = struct { + path: []const u8, + source: Source, +}; -/// Free-function form of `Policy.permits` taking the policy by pointer, -/// matching the `fn(ctx, name) bool` shape `LuaRuntime.filterTools` wants. -fn permitsByPtr(pol: *const config_file.Policy, name: []const u8) bool { - return pol.permits(name); -} +/// An installed rock to load as an extension source: the Lua module name to +/// `require`, tagged with its source precedence. +pub const RockSource = struct { + module: []const u8, + source: Source, +}; -/// A discovered extension or tool before loading. Owns its strings. +/// A discovered Lua source file before evaluation. Owns its strings. const Found = struct { - /// Logical name (basename without `.lua`, or directory name). - name: []u8, - /// Absolute path to the Lua script to execute. script_path: []u8, - /// For directory-style entries, the directory containing the script. + /// For directory-style entries, the directory added to `package.path`. package_root: ?[]u8, - /// Which search-path source this came from. source: Source, - /// Whether this is a full extension or a single-tool script. - kind: Kind, pub fn deinit(self: *Found, allocator: Allocator) void { - allocator.free(self.name); allocator.free(self.script_path); if (self.package_root) |p| allocator.free(p); } }; -pub const Source = enum { - /// Staged at bootstrap from files embedded in the panto binary. - /// Lowest priority — shadowed by user and project. - base, - user, - project, - - pub fn label(self: Source) []const u8 { - return switch (self) { - .base => "base", - .user => "user", - .project => "project", - }; - } +/// A runtime `Entry` paired with the source it came from, for shadowing and +/// policy resolution. +const Candidate = struct { + entry: Entry, + source: Source, + script_path: []const u8, // borrowed from a `Found`, for diagnostics }; -pub const Kind = enum { - extension, - tool, +fn policyPermits(p: ?*const config_file.Policy, name: []const u8) bool { + const pol = p orelse return true; + return pol.permits(name); +} - pub fn label(self: Kind) []const u8 { - return switch (self) { - .extension => "extension", - .tool => "tool", - }; - } -}; +/// Log at `err` in production but `warn` under test — the Zig test runner +/// fails any test that logs an error, and several tests deliberately +/// exercise error paths. +fn logConflict(comptime fmt: []const u8, args: anytype) void { + if (@import("builtin").is_test) std.log.warn(fmt, args) else std.log.err(fmt, args); +} -/// Discover and load every extension and tool found in the standard -/// paths into `runtime`. Returns the number of registered tools added -/// to the runtime by this call. -/// -/// `base_agent_dir`, when non-null, is the path under which embedded -/// base tools/extensions have been staged — typically -/// `<data home>/agent/`. Pass `null` to skip the base layer entirely -/// (mostly useful for tests). +/// Discover and load every extension into `runtime`, returning the number of +/// registered tools this call added. /// -/// `environ_map` is consulted for `HOME` and `XDG_CONFIG_HOME`. The -/// project directories are always `cwd()/.panto/{extensions,tools}`. +/// `base_agent_dir`, when non-null, is where embedded base sources have been +/// staged (typically `<data home>/agent/`); pass `null` to skip base. +/// `environ_map` is consulted for `HOME`/`XDG_CONFIG_HOME`; project dirs are +/// `cwd()/.panto/{extensions,tools}`. pub fn discoverAndLoad( allocator: Allocator, io: Io, environ_map: *const std.process.Environ.Map, base_agent_dir: ?[]const u8, runtime: *LuaRuntime, - policies: Policies, + policy: ?*const config_file.Policy, + /// Extra dirs from `extensions.paths`, each tagged with its config layer. + extra_paths: []const config_file.LayeredStr, + /// Installed rocks from `extensions.rocks` (raw dependency specs), each + /// tagged with its config layer. The Lua module `require`d is the spec's + /// first whitespace-delimited token (the rock name). Installation happens + /// upstream (see `main`); this only loads already-installed modules. + rocks: []const config_file.LayeredStr, ) !usize { - const sys_ext = if (base_agent_dir) |d| - try std.fs.path.join(allocator, &.{ d, kindSubdir(.extension) }) - else - null; - defer if (sys_ext) |d| allocator.free(d); - const sys_tool = if (base_agent_dir) |d| - try std.fs.path.join(allocator, &.{ d, kindSubdir(.tool) }) - else - null; - defer if (sys_tool) |d| allocator.free(d); + var dirs: std.array_list.Managed(ScanDir) = .init(allocator); + defer { + for (dirs.items) |d| allocator.free(d.path); + dirs.deinit(); + } - const user_ext = try userKindDir(allocator, environ_map, .extension); - defer if (user_ext) |d| allocator.free(d); - const user_tool = try userKindDir(allocator, environ_map, .tool); - defer if (user_tool) |d| allocator.free(d); + const cwd = try std.process.currentPathAlloc(io, allocator); + defer allocator.free(cwd); - const project_ext = try projectKindDir(allocator, io, .extension); - defer allocator.free(project_ext); - const project_tool = try projectKindDir(allocator, io, .tool); - defer allocator.free(project_tool); + // base: <data home>/agent/{extensions,tools} + if (base_agent_dir) |d| { + try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ d, "extensions" }), .source = .{ .layer = .base, .origin = .dir } }); + try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ d, "tools" }), .source = .{ .layer = .base, .origin = .dir } }); + } + // user: $XDG_CONFIG_HOME/panto/{extensions,tools} + if (try userConfigDir(allocator, environ_map)) |base| { + defer allocator.free(base); + try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ base, "extensions" }), .source = .{ .layer = .user, .origin = .dir } }); + try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ base, "tools" }), .source = .{ .layer = .user, .origin = .dir } }); + } + // project: cwd/.panto/{extensions,tools} + try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ cwd, ".panto", "extensions" }), .source = .{ .layer = .project, .origin = .dir } }); + try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ cwd, ".panto", "tools" }), .source = .{ .layer = .project, .origin = .dir } }); - return loadFromDirs( - allocator, - io, - runtime, - .{ - .base_extensions = sys_ext, - .user_extensions = user_ext, - .project_extensions = project_ext, - .base_tools = sys_tool, - .user_tools = user_tool, - .project_tools = project_tool, - }, - policies, - ); + // extensions.paths: extra dirs at their config layer's precedence, + // origin=paths (below the canonical dir, above rocks). Relative paths + // resolve against cwd. + for (extra_paths) |p| { + const path = if (std.fs.path.isAbsolute(p.value)) + try allocator.dupe(u8, p.value) + else + try std.fs.path.join(allocator, &.{ cwd, p.value }); + try dirs.append(.{ .path = path, .source = .{ .layer = configLayer(p.layer), .origin = .paths } }); + } + + // extensions.rocks: require each rock's module (its name = first token). + var rock_srcs: std.array_list.Managed(RockSource) = .init(allocator); + defer rock_srcs.deinit(); + for (rocks) |r| { + var it = std.mem.tokenizeAny(u8, r.value, " \t"); + const module = it.next() orelse continue; + try rock_srcs.append(.{ .module = module, .source = .{ .layer = configLayer(r.layer), .origin = .rocks } }); + } + + return loadFromDirs(allocator, io, runtime, dirs.items, rock_srcs.items, policy); } -/// Set of search paths consumed by `loadFromDirs`. Any field may be -/// null; missing directories on disk are silently skipped. -/// -/// Scan order is base → user → project. `applyShadowing` keeps the -/// *last* occurrence of each (kind, name), so project entries win, -/// then user, then base. -pub const DirSet = struct { - base_extensions: ?[]const u8 = null, - user_extensions: ?[]const u8 = null, - project_extensions: ?[]const u8 = null, - base_tools: ?[]const u8 = null, - user_tools: ?[]const u8 = null, - project_tools: ?[]const u8 = null, -}; +/// Map a config layer index (base=0, user=1, project=2, local=3) to a loader +/// precedence layer. The `local` layer shares `project` precedence. +fn configLayer(layer: u16) Layer { + return switch (layer) { + 0 => .base, + 1 => .user, + else => .project, + }; +} -/// Lower-level entry point: load from explicit user/project paths. -/// Either path may be null; missing directories are silently skipped. +/// Lower-level entry point: scan an explicit list of dirs (each tagged with +/// its source precedence), then eval → shadow → filter → activate. pub fn loadFromDirs( allocator: Allocator, io: Io, runtime: *LuaRuntime, - dirs: DirSet, - policies: Policies, + dirs: []const ScanDir, + rocks: []const RockSource, + policy: ?*const config_file.Policy, ) !usize { var found: std.array_list.Managed(Found) = .init(allocator); defer { for (found.items) |*f| f.deinit(allocator); found.deinit(); } + for (dirs) |d| try scanDir(allocator, io, d.path, d.source, &found); - if (dirs.base_extensions) |d| try scanDir(allocator, io, d, .base, .extension, &found); - if (dirs.user_extensions) |d| try scanDir(allocator, io, d, .user, .extension, &found); - if (dirs.project_extensions) |d| try scanDir(allocator, io, d, .project, .extension, &found); - if (dirs.base_tools) |d| try scanDir(allocator, io, d, .base, .tool, &found); - if (dirs.user_tools) |d| try scanDir(allocator, io, d, .user, .tool, &found); - if (dirs.project_tools) |d| try scanDir(allocator, io, d, .project, .tool, &found); - - try applyShadowing(allocator, &found); - - // Gate *entries* by the extensions policy (matched on logical name). - // This drops whole scripts before they run — a denied extension - // never executes, never registers tools. The tools policy is - // applied post-load against registered tool names. - if (policies.extensions) |_| { - var keep: std.array_list.Managed(Found) = .init(allocator); - errdefer { - for (keep.items) |*f| f.deinit(allocator); - keep.deinit(); + // ---- Pass 1: eval every source, collect candidate entries. ---- + var cands: std.array_list.Managed(Candidate) = .init(allocator); + defer { + // Any candidate still here at scope exit was neither activated nor + // dropped (an error path); reclaim its Lua ref + name. + for (cands.items) |cnd| runtime.dropEntry(cnd.entry); + cands.deinit(); + } + for (found.items) |f| { + var entries: std.array_list.Managed(Entry) = .init(allocator); + defer entries.deinit(); + try runtime.evalEntries(f.script_path, f.package_root, &entries); + for (entries.items) |e| { + try cands.append(.{ .entry = e, .source = f.source, .script_path = f.script_path }); } - for (found.items) |*f| { - if (policyPermits(policies.extensions, f.name)) { - try keep.append(f.*); - } else { - std.log.debug("{s}: '{s}' denied by extensions policy", .{ f.kind.label(), f.name }); - f.deinit(allocator); - } + } + // Rocks: `require` each installed module. A rock that fails to load + // (not installed, bad shape) is logged and skipped rather than aborting + // startup — one bad optional rock should not kill the REPL. + for (rocks) |r| { + var entries: std.array_list.Managed(Entry) = .init(allocator); + defer entries.deinit(); + runtime.evalEntriesFromModule(r.module, &entries) catch |err| { + logConflict("rock '{s}' failed to load: {t}", .{ r.module, err }); + for (entries.items) |e| runtime.dropEntry(e); + continue; + }; + for (entries.items) |e| { + try cands.append(.{ .entry = e, .source = r.source, .script_path = r.module }); } - found.clearRetainingCapacity(); - try found.appendSlice(keep.items); - keep.deinit(); } + // ---- Shadowing: keep the highest-precedence entry per name. ---- + try applyShadowing(allocator, runtime, &cands); + + // ---- Pass 2: activate permitted survivors. ---- const before = runtime.toolCount(); - for (found.items) |f| { - const load_result = switch (f.kind) { - .extension => runtime.loadExtension(f.script_path, f.package_root), - .tool => runtime.loadTool(f.script_path, f.package_root), - }; - load_result catch |err| { - if (@import("builtin").is_test) { - std.log.warn( - "{s} '{s}' ({s}: {s}) failed to load: {t}", - .{ f.kind.label(), f.name, f.source.label(), f.script_path, err }, - ); - } else { - std.log.err( - "{s} '{s}' ({s}: {s}) failed to load: {t}", - .{ f.kind.label(), f.name, f.source.label(), f.script_path, err }, - ); - } + var i: usize = 0; + while (i < cands.items.len) : (i += 1) { + const cnd = cands.items[i]; + if (!policyPermits(policy, cnd.entry.name)) { + std.log.debug("extension: '{s}' denied by policy", .{cnd.entry.name}); + runtime.dropEntry(cnd.entry); + continue; + } + runtime.activateEntry(cnd.entry) catch |err| { + logConflict( + "extension '{s}' ({s}: {s}) failed to activate: {t}", + .{ cnd.entry.name, cnd.source.label(), cnd.script_path, err }, + ); + // activateEntry consumed the entry already. Drop the rest. + var j = i + 1; + while (j < cands.items.len) : (j += 1) runtime.dropEntry(cands.items[j].entry); + cands.clearRetainingCapacity(); return err; }; - std.log.debug( - "{s}: loaded '{s}' ({s})", - .{ f.kind.label(), f.name, f.source.label() }, - ); - } - - // Apply the tools policy to *registered tool names* (e.g. `std.read`). - if (policies.tools) |pol| { - const dropped = runtime.filterTools(pol, permitsByPtr); - if (dropped > 0) std.log.debug("tools: {d} tool(s) removed by tools policy", .{dropped}); + std.log.debug("extension: activated '{s}' ({s})", .{ cnd.entry.name, cnd.source.label() }); } + cands.clearRetainingCapacity(); return runtime.toolCount() - before; } @@ -275,34 +276,17 @@ pub fn loadFromDirs( // Path resolution // --------------------------------------------------------------------------- -fn kindSubdir(kind: Kind) []const u8 { - return switch (kind) { - .extension => "extensions", - .tool => "tools", - }; -} - -fn userKindDir( - allocator: Allocator, - environ_map: *const std.process.Environ.Map, - kind: Kind, -) !?[]u8 { - const sub = kindSubdir(kind); +/// `${XDG_CONFIG_HOME:-$HOME/.config}/panto`, or null if neither is set. +fn userConfigDir(allocator: Allocator, environ_map: *const std.process.Environ.Map) !?[]u8 { if (environ_map.get("XDG_CONFIG_HOME")) |xdg| { - return try std.fs.path.join(allocator, &.{ xdg, "panto", sub }); + return try std.fs.path.join(allocator, &.{ xdg, "panto" }); } if (environ_map.get("HOME")) |home| { - return try std.fs.path.join(allocator, &.{ home, ".config", "panto", sub }); + return try std.fs.path.join(allocator, &.{ home, ".config", "panto" }); } return null; } -fn projectKindDir(allocator: Allocator, io: Io, kind: Kind) ![]u8 { - const cwd = try std.process.currentPathAlloc(io, allocator); - defer allocator.free(cwd); - return try std.fs.path.join(allocator, &.{ cwd, ".panto", kindSubdir(kind) }); -} - // --------------------------------------------------------------------------- // Directory scanning // --------------------------------------------------------------------------- @@ -312,7 +296,6 @@ fn scanDir( io: Io, dir_path: []const u8, source: Source, - kind: Kind, out: *std.array_list.Managed(Found), ) !void { var dir = Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch |err| switch (err) { @@ -321,44 +304,16 @@ fn scanDir( }; defer dir.close(io); - var local_names: std.StringHashMap(void) = .init(allocator); - defer { - var it = local_names.keyIterator(); - while (it.next()) |k| allocator.free(k.*); - local_names.deinit(); - } - var iter = dir.iterate(); while (try iter.next(io)) |entry| { if (entry.name.len == 0 or entry.name[0] == '.') continue; const maybe_found: ?Found = switch (entry.kind) { - .file, .sym_link => try classifyFile(allocator, dir_path, entry.name, source, kind), - .directory => try classifyDirectory(allocator, io, dir, dir_path, entry.name, source, kind), + .file, .sym_link => try classifyFile(allocator, dir_path, entry.name, source), + .directory => try classifyDirectory(allocator, io, dir, dir_path, entry.name, source), else => null, }; - const f = maybe_found orelse continue; - - const gop = try local_names.getOrPut(f.name); - if (gop.found_existing) { - if (@import("builtin").is_test) { - std.log.warn( - "{s} name '{s}' is provided by multiple entries in {s}", - .{ kind.label(), f.name, dir_path }, - ); - } else { - std.log.err( - "{s} name '{s}' is provided by multiple entries in {s}", - .{ kind.label(), f.name, dir_path }, - ); - } - var dup = f; - dup.deinit(allocator); - return error.DuplicateExtensionInDirectory; - } - gop.key_ptr.* = try allocator.dupe(u8, f.name); - - try out.append(f); + if (maybe_found) |f| try out.append(f); } } @@ -367,7 +322,6 @@ fn classifyFile( dir_path: []const u8, entry_name: []const u8, source: Source, - kind: Kind, ) !?Found { if (!std.mem.endsWith(u8, entry_name, ".lua")) return null; const base = entry_name[0 .. entry_name.len - ".lua".len]; @@ -376,18 +330,10 @@ fn classifyFile( const script_path = try std.fs.path.join(allocator, &.{ dir_path, entry_name }); errdefer allocator.free(script_path); - const name = try allocator.dupe(u8, base); - errdefer allocator.free(name); const package_root = try allocator.dupe(u8, dir_path); errdefer allocator.free(package_root); - return Found{ - .name = name, - .script_path = script_path, - .package_root = package_root, - .source = source, - .kind = kind, - }; + return Found{ .script_path = script_path, .package_root = package_root, .source = source }; } fn classifyDirectory( @@ -397,93 +343,75 @@ fn classifyDirectory( dir_path: []const u8, entry_name: []const u8, source: Source, - kind: Kind, ) !?Found { var sub = parent.openDir(io, entry_name, .{}) catch return null; defer sub.close(io); - sub.access(io, "init.lua", .{}) catch |err| switch (err) { - error.FileNotFound => return null, - else => return null, - }; + sub.access(io, "init.lua", .{}) catch return null; const package_root = try std.fs.path.join(allocator, &.{ dir_path, entry_name }); errdefer allocator.free(package_root); const script_path = try std.fs.path.join(allocator, &.{ package_root, "init.lua" }); errdefer allocator.free(script_path); - const name = try allocator.dupe(u8, entry_name); - errdefer allocator.free(name); - return Found{ - .name = name, - .script_path = script_path, - .package_root = package_root, - .source = source, - .kind = kind, - }; + return Found{ .script_path = script_path, .package_root = package_root, .source = source }; } // --------------------------------------------------------------------------- // Shadowing // --------------------------------------------------------------------------- -/// Shadowing key combines (kind, name) so a tool named `foo` does not -/// shadow an extension named `foo` (or vice versa). Tool-name collisions -/// across these are still caught later by the runtime/registry. -const ShadowKey = struct { - kind: Kind, - name: []const u8, -}; - -const ShadowKeyCtx = struct { - pub fn hash(_: ShadowKeyCtx, k: ShadowKey) u64 { - var hasher = std.hash.Wyhash.init(0); - hasher.update(&[_]u8{@intFromEnum(k.kind)}); - hasher.update(k.name); - return hasher.final(); - } - pub fn eql(_: ShadowKeyCtx, a: ShadowKey, b: ShadowKey) bool { - return a.kind == b.kind and std.mem.eql(u8, a.name, b.name); - } -}; +/// Keep the highest-precedence candidate per declared name; drop the rest +/// (their Lua refs are reclaimed). Two candidates with the same name AND the +/// same source precedence are an ambiguous duplicate — an error. +fn applyShadowing( + allocator: Allocator, + runtime: *LuaRuntime, + cands: *std.array_list.Managed(Candidate), +) !void { + // name -> index of current winner in `cands`. + var winners: std.StringHashMap(usize) = .init(allocator); + defer winners.deinit(); -fn applyShadowing(allocator: Allocator, list: *std.array_list.Managed(Found)) !void { - const keep = try allocator.alloc(bool, list.items.len); + const keep = try allocator.alloc(bool, cands.items.len); defer allocator.free(keep); - @memset(keep, false); + @memset(keep, true); - { - var latest: std.HashMap(ShadowKey, usize, ShadowKeyCtx, std.hash_map.default_max_load_percentage) = .init(allocator); - defer latest.deinit(); - - for (list.items, 0..) |f, i| { - try latest.put(.{ .kind = f.kind, .name = f.name }, i); + for (cands.items, 0..) |cnd, i| { + const gop = try winners.getOrPut(cnd.entry.name); + if (!gop.found_existing) { + gop.value_ptr.* = i; + continue; } - - for (list.items, 0..) |f, i| { - const winner = latest.get(.{ .kind = f.kind, .name = f.name }).?; - if (winner == i) { - keep[i] = true; - } else { - std.log.debug( - "{s}: '{s}' from {s} shadowed by {s}", - .{ f.kind.label(), f.name, f.source.label(), list.items[winner].source.label() }, - ); - } + const prev = gop.value_ptr.*; + const prev_rank = cands.items[prev].source.rank(); + const cur_rank = cnd.source.rank(); + if (cur_rank == prev_rank) { + logConflict( + "extension name '{s}' declared by two same-precedence sources: {s} and {s}", + .{ cnd.entry.name, cands.items[prev].script_path, cnd.script_path }, + ); + return error.DuplicateExtensionName; + } else if (cur_rank > prev_rank) { + keep[prev] = false; + std.log.debug("extension: '{s}' from {s} shadowed by {s}", .{ cnd.entry.name, cands.items[prev].source.label(), cnd.source.label() }); + gop.value_ptr.* = i; + } else { + keep[i] = false; + std.log.debug("extension: '{s}' from {s} shadowed by {s}", .{ cnd.entry.name, cnd.source.label(), cands.items[prev].source.label() }); } } var write: usize = 0; - for (list.items, keep) |f, k| { + for (cands.items, keep) |cnd, k| { if (k) { - list.items[write] = f; + cands.items[write] = cnd; write += 1; } else { - var dropped = f; - dropped.deinit(allocator); + runtime.dropEntry(cnd.entry); } } - list.shrinkRetainingCapacity(write); + cands.shrinkRetainingCapacity(write); } // --------------------------------------------------------------------------- @@ -521,543 +449,264 @@ fn makeDir(dir: Io.Dir, sub_path: []const u8) !void { try dir.createDirPath(testing.io, sub_path); } -test "scanDir picks up single-file and directory-style extensions" { - var tmp = testing.tmpDir(.{ .iterate = true }); - defer tmp.cleanup(); - - try makeDir(tmp.dir, "ext_root"); - try makeDir(tmp.dir, "ext_root/beta"); - try writeFile(tmp.dir, "ext_root/alpha.lua", "-- alpha\n"); - try writeFile(tmp.dir, "ext_root/_helper.lua", "-- helper module\n"); - try writeFile(tmp.dir, "ext_root/beta/init.lua", "-- beta init\n"); - try writeFile(tmp.dir, "ext_root/beta/helper.lua", "-- helper\n"); - try writeFile(tmp.dir, "ext_root/.ignored.lua", "-- hidden\n"); - try writeFile(tmp.dir, "ext_root/readme.txt", "noise\n"); - - var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const ext_root_len = try tmp.dir.realPathFile(testing.io, "ext_root", &path_buf); - const ext_root = path_buf[0..ext_root_len]; - - var list: std.array_list.Managed(Found) = .init(testing.allocator); - defer { - for (list.items) |*f| f.deinit(testing.allocator); - list.deinit(); - } - try scanDir(testing.allocator, testing.io, ext_root, .user, .extension, &list); - - try testing.expectEqual(@as(usize, 2), list.items.len); - - std.mem.sort(Found, list.items, {}, struct { - fn lt(_: void, a: Found, b: Found) bool { - return std.mem.lessThan(u8, a.name, b.name); - } - }.lt); - - try testing.expectEqualStrings("alpha", list.items[0].name); - try testing.expect(list.items[0].package_root != null); - try testing.expect(std.mem.endsWith(u8, list.items[0].script_path, "alpha.lua")); - - try testing.expectEqualStrings("beta", list.items[1].name); - try testing.expect(list.items[1].package_root != null); - try testing.expect(std.mem.endsWith(u8, list.items[1].script_path, "init.lua")); -} - -test "duplicate name in same directory is an error" { - var tmp = testing.tmpDir(.{ .iterate = true }); - defer tmp.cleanup(); - - try makeDir(tmp.dir, "ext_root/foo"); - try writeFile(tmp.dir, "ext_root/foo.lua", "-- single\n"); - try writeFile(tmp.dir, "ext_root/foo/init.lua", "-- dir\n"); - - var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const n = try tmp.dir.realPathFile(testing.io, "ext_root", &path_buf); - const ext_root = path_buf[0..n]; - - var list: std.array_list.Managed(Found) = .init(testing.allocator); - defer { - for (list.items) |*f| f.deinit(testing.allocator); - list.deinit(); - } - - const result = scanDir(testing.allocator, testing.io, ext_root, .project, .extension, &list); - try testing.expectError(error.DuplicateExtensionInDirectory, result); -} - -test "applyShadowing keeps the latest occurrence" { - var list: std.array_list.Managed(Found) = .init(testing.allocator); - defer { - for (list.items) |*f| f.deinit(testing.allocator); - list.deinit(); - } - - inline for (.{ - .{ "shared", "/u/shared.lua", Source.user }, - .{ "only_user", "/u/only_user.lua", Source.user }, - .{ "shared", "/p/shared.lua", Source.project }, - .{ "only_project", "/p/only_project.lua", Source.project }, - }) |row| { - try list.append(.{ - .name = try testing.allocator.dupe(u8, row[0]), - .script_path = try testing.allocator.dupe(u8, row[1]), - .package_root = null, - .source = row[2], - .kind = .extension, - }); - } - - try applyShadowing(testing.allocator, &list); - try testing.expectEqual(@as(usize, 3), list.items.len); - - var shared_count: usize = 0; - var shared_source: ?Source = null; - for (list.items) |f| { - if (std.mem.eql(u8, f.name, "shared")) { - shared_count += 1; - shared_source = f.source; - } - } - try testing.expectEqual(@as(usize, 1), shared_count); - try testing.expectEqual(@as(?Source, .project), shared_source); +/// Resolve an absolute path for a subdirectory of the tmp dir. +fn realDir(tmp: *testing.TmpDir, sub: []const u8, buf: []u8) ![]const u8 { + const n = try tmp.dir.realPathFile(testing.io, sub, buf); + return buf[0..n]; } -test "loadFromDirs: project shadows user end-to-end (via long-lived runtime)" { - var tmp = testing.tmpDir(.{ .iterate = true }); - defer tmp.cleanup(); - - try makeDir(tmp.dir, "user_ext"); - try makeDir(tmp.dir, "project_ext"); - - try writeFile(tmp.dir, "user_ext/greet.lua", - \\local panto = require("panto") - \\panto.ext.register_tool { - \\ name = "greet", description = "user version", - \\ schema = { type = "object" }, - \\ handler = function(input) return "USER" end, - \\} - ); - try writeFile(tmp.dir, "project_ext/greet.lua", - \\local panto = require("panto") - \\panto.ext.register_tool { - \\ name = "greet", description = "project version", - \\ schema = { type = "object" }, - \\ handler = function(input) return "PROJECT" end, - \\} - ); - - var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const user_len = try tmp.dir.realPathFile(testing.io, "user_ext", &path_buf); - const user_path = try testing.allocator.dupe(u8, path_buf[0..user_len]); - defer testing.allocator.free(user_path); - - const proj_len = try tmp.dir.realPathFile(testing.io, "project_ext", &path_buf); - const proj_path = try testing.allocator.dupe(u8, path_buf[0..proj_len]); - defer testing.allocator.free(proj_path); - - var rt = try LuaRuntime.create(testing.allocator); - defer rt.deinit(); - - const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{ - .user_extensions = user_path, - .project_extensions = proj_path, - }, .{}); - try testing.expectEqual(@as(usize, 1), n_tools); - - // Invoke the tool through the source and verify the project handler ran. +fn invokeOne(rt: *LuaRuntime, name: []const u8, input: []const u8) !panto.ToolCallResult { var src = rt.toolSource(); - const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }}; + const calls = [_]panto.ToolCall{.{ .tool_name = name, .input = input }}; var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer xfreeResults(&results); - try testing.expectEqualStrings("PROJECT", xokText(results[0])); + return results[0]; } -test "loadFromDirs: tool-name collision between extensions errors" { +test "loadFromDirs: activates a sugar tool and an entry extension" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); - try makeDir(tmp.dir, "ext"); - try writeFile(tmp.dir, "ext/alpha.lua", - \\local panto = require("panto") - \\panto.ext.register_tool { - \\ name = "clash", description = "a", - \\ schema = { type = "object" }, - \\ handler = function(input) return "a" end, - \\} - ); - try writeFile(tmp.dir, "ext/beta.lua", - \\local panto = require("panto") - \\panto.ext.register_tool { - \\ name = "clash", description = "b", - \\ schema = { type = "object" }, - \\ handler = function(input) return "b" end, - \\} - ); - - var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const n = try tmp.dir.realPathFile(testing.io, "ext", &path_buf); - const ext_path = try testing.allocator.dupe(u8, path_buf[0..n]); - defer testing.allocator.free(ext_path); - - var rt = try LuaRuntime.create(testing.allocator); - defer rt.deinit(); - - const result = loadFromDirs(testing.allocator, testing.io, rt, .{ - .project_extensions = ext_path, - }, .{}); - try testing.expectError(error.DuplicateTool, result); -} - -test "loadFromDirs: tools/ directory — single-file tool form" { - var tmp = testing.tmpDir(.{ .iterate = true }); - defer tmp.cleanup(); - - try makeDir(tmp.dir, "tools"); - try writeFile(tmp.dir, "tools/echo.lua", + try makeDir(tmp.dir, "d"); + // Sugar tool form (return a table with handler). + try writeFile(tmp.dir, "d/echo.lua", \\return { - \\ name = "echo", description = "Echo back input.", - \\ schema = { type = "object", properties = { msg = { type = "string" } } }, - \\ handler = function(input) return "echo: " .. input.msg end, + \\ name = "echo", description = "e", + \\ schema = { type = "object", properties = { m = { type = "string" } } }, + \\ handler = function(input) return "echo: " .. input.m end, \\} ); - - var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const n = try tmp.dir.realPathFile(testing.io, "tools", &path_buf); - const tools_path = try testing.allocator.dupe(u8, path_buf[0..n]); - defer testing.allocator.free(tools_path); - - var rt = try LuaRuntime.create(testing.allocator); - defer rt.deinit(); - - const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{ - .user_tools = tools_path, - }, .{}); - try testing.expectEqual(@as(usize, 1), n_tools); - - var src = rt.toolSource(); - const calls = [_]panto.ToolCall{.{ .tool_name = "echo", .input = "{\"msg\":\"hi\"}" }}; - var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; - try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer xfreeResults(&results); - try testing.expectEqualStrings("echo: hi", xokText(results[0])); -} - -test "loadFromDirs: tools/ directory — directory-style tool with sibling require" { - var tmp = testing.tmpDir(.{ .iterate = true }); - defer tmp.cleanup(); - - try makeDir(tmp.dir, "tools/shout"); - try writeFile(tmp.dir, "tools/shout/util.lua", - \\local M = {} - \\function M.shout(s) return s:upper() .. "!" end - \\return M - ); - try writeFile(tmp.dir, "tools/shout/init.lua", - \\local util = require("util") - \\return { - \\ name = "shout", description = "uppercase + bang", - \\ schema = { type = "object", properties = { text = { type = "string" } } }, - \\ handler = function(input) return util.shout(input.text) end, - \\} + // Entry form with deferred activate(). + try writeFile(tmp.dir, "d/greet.lua", + \\local panto = require("panto") + \\return { name = "greet", activate = function() + \\ panto.ext.register_tool { + \\ name = "greet", description = "g", + \\ schema = { type = "object" }, + \\ handler = function(input) return "hi" end, + \\ } + \\end } ); - var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const n = try tmp.dir.realPathFile(testing.io, "tools", &path_buf); - const tools_path = try testing.allocator.dupe(u8, path_buf[0..n]); - defer testing.allocator.free(tools_path); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const d = try realDir(&tmp, "d", &buf); var rt = try LuaRuntime.create(testing.allocator); defer rt.deinit(); - const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{ - .project_tools = tools_path, - }, .{}); - try testing.expectEqual(@as(usize, 1), n_tools); + const n = try loadFromDirs(testing.allocator, testing.io, rt, &.{ + .{ .path = d, .source = .{ .layer = .project, .origin = .dir } }, + }, &.{}, null); + try testing.expectEqual(@as(usize, 2), n); - var src = rt.toolSource(); - const calls = [_]panto.ToolCall{.{ .tool_name = "shout", .input = "{\"text\":\"hi\"}" }}; - var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; - try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer xfreeResults(&results); - try testing.expectEqualStrings("HI!", xokText(results[0])); + const r = try invokeOne(rt, "echo", "{\"m\":\"hi\"}"); + defer xfreeResults(@constCast(&[_]panto.ToolCallResult{r})); + try testing.expectEqualStrings("echo: hi", xokText(r)); } -test "loadFromDirs: project tool shadows user tool of the same name" { +test "loadFromDirs: higher precedence shadows same name" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); - try makeDir(tmp.dir, "user_tools"); - try makeDir(tmp.dir, "project_tools"); - - try writeFile(tmp.dir, "user_tools/greet.lua", - \\return { - \\ name = "greet", description = "user version", - \\ schema = { type = "object" }, - \\ handler = function(input) return "USER" end, - \\} + try makeDir(tmp.dir, "u"); + try makeDir(tmp.dir, "p"); + try writeFile(tmp.dir, "u/greet.lua", + \\return { name = "greet", description = "u", schema = { type = "object" }, + \\ handler = function(input) return "USER" end } ); - try writeFile(tmp.dir, "project_tools/greet.lua", - \\return { - \\ name = "greet", description = "project version", - \\ schema = { type = "object" }, - \\ handler = function(input) return "PROJECT" end, - \\} + try writeFile(tmp.dir, "p/greet.lua", + \\return { name = "greet", description = "p", schema = { type = "object" }, + \\ handler = function(input) return "PROJECT" end } ); - var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const user_len = try tmp.dir.realPathFile(testing.io, "user_tools", &path_buf); - const user_path = try testing.allocator.dupe(u8, path_buf[0..user_len]); - defer testing.allocator.free(user_path); - - const proj_len = try tmp.dir.realPathFile(testing.io, "project_tools", &path_buf); - const proj_path = try testing.allocator.dupe(u8, path_buf[0..proj_len]); - defer testing.allocator.free(proj_path); + var ubuf: [std.fs.max_path_bytes]u8 = undefined; + var pbuf: [std.fs.max_path_bytes]u8 = undefined; + const u = try realDir(&tmp, "u", &ubuf); + const p = try realDir(&tmp, "p", &pbuf); var rt = try LuaRuntime.create(testing.allocator); defer rt.deinit(); - const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{ - .user_tools = user_path, - .project_tools = proj_path, - }, .{}); - try testing.expectEqual(@as(usize, 1), n_tools); + const n = try loadFromDirs(testing.allocator, testing.io, rt, &.{ + .{ .path = u, .source = .{ .layer = .user, .origin = .dir } }, + .{ .path = p, .source = .{ .layer = .project, .origin = .dir } }, + }, &.{}, null); + try testing.expectEqual(@as(usize, 1), n); - var src = rt.toolSource(); - const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }}; - var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; - try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer xfreeResults(&results); - try testing.expectEqualStrings("PROJECT", xokText(results[0])); + const r = try invokeOne(rt, "greet", "{}"); + defer xfreeResults(@constCast(&[_]panto.ToolCallResult{r})); + try testing.expectEqualStrings("PROJECT", xokText(r)); } -test "loadFromDirs: project tool shadows user shadows base" { +test "loadFromDirs: within a layer, dir shadows paths shadows rocks" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); - try makeDir(tmp.dir, "base_tools"); - try makeDir(tmp.dir, "user_tools"); - try makeDir(tmp.dir, "project_tools"); - - try writeFile(tmp.dir, "base_tools/greet.lua", - \\return { - \\ name = "greet", description = "base version", - \\ schema = { type = "object" }, - \\ handler = function(input) return "BASE" end, - \\} - ); - try writeFile(tmp.dir, "user_tools/greet.lua", - \\return { - \\ name = "greet", description = "user version", - \\ schema = { type = "object" }, - \\ handler = function(input) return "USER" end, - \\} - ); - try writeFile(tmp.dir, "project_tools/greet.lua", - \\return { - \\ name = "greet", description = "project version", - \\ schema = { type = "object" }, - \\ handler = function(input) return "PROJECT" end, - \\} - ); + try makeDir(tmp.dir, "rock"); + try makeDir(tmp.dir, "path"); + try makeDir(tmp.dir, "dir"); + inline for (.{ .{ "rock", "ROCK" }, .{ "path", "PATH" }, .{ "dir", "DIR" } }) |row| { + try writeFile(tmp.dir, row[0] ++ "/w.lua", "return { name = \"w\", description = \"d\", schema = { type = \"object\" }," ++ + " handler = function(input) return \"" ++ row[1] ++ "\" end }"); + } - var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const sys_len = try tmp.dir.realPathFile(testing.io, "base_tools", &path_buf); - const sys_path = try testing.allocator.dupe(u8, path_buf[0..sys_len]); - defer testing.allocator.free(sys_path); - const user_len = try tmp.dir.realPathFile(testing.io, "user_tools", &path_buf); - const user_path = try testing.allocator.dupe(u8, path_buf[0..user_len]); - defer testing.allocator.free(user_path); - const proj_len = try tmp.dir.realPathFile(testing.io, "project_tools", &path_buf); - const proj_path = try testing.allocator.dupe(u8, path_buf[0..proj_len]); - defer testing.allocator.free(proj_path); + var b1: [std.fs.max_path_bytes]u8 = undefined; + var b2: [std.fs.max_path_bytes]u8 = undefined; + var b3: [std.fs.max_path_bytes]u8 = undefined; + const rock = try realDir(&tmp, "rock", &b1); + const path = try realDir(&tmp, "path", &b2); + const dir = try realDir(&tmp, "dir", &b3); var rt = try LuaRuntime.create(testing.allocator); defer rt.deinit(); - const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{ - .base_tools = sys_path, - .user_tools = user_path, - .project_tools = proj_path, - }, .{}); - try testing.expectEqual(@as(usize, 1), n_tools); + // All at the same layer; only within-layer origin differs. + const n = try loadFromDirs(testing.allocator, testing.io, rt, &.{ + .{ .path = rock, .source = .{ .layer = .user, .origin = .rocks } }, + .{ .path = path, .source = .{ .layer = .user, .origin = .paths } }, + .{ .path = dir, .source = .{ .layer = .user, .origin = .dir } }, + }, &.{}, null); + try testing.expectEqual(@as(usize, 1), n); - var src = rt.toolSource(); - const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }}; - var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; - try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer xfreeResults(&results); - try testing.expectEqualStrings("PROJECT", xokText(results[0])); + const r = try invokeOne(rt, "w", "{}"); + defer xfreeResults(@constCast(&[_]panto.ToolCallResult{r})); + try testing.expectEqualStrings("DIR", xokText(r)); } -test "loadFromDirs: user tool shadows base tool when no project entry" { +test "loadFromDirs: same-precedence duplicate name is an error" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); - try makeDir(tmp.dir, "base_tools"); - try makeDir(tmp.dir, "user_tools"); - - try writeFile(tmp.dir, "base_tools/greet.lua", - \\return { - \\ name = "greet", description = "base", - \\ schema = { type = "object" }, - \\ handler = function(input) return "BASE" end, - \\} + try makeDir(tmp.dir, "a"); + try makeDir(tmp.dir, "b"); + try writeFile(tmp.dir, "a/x.lua", + \\return { name = "dup", description = "a", schema = { type = "object" }, + \\ handler = function(input) return "a" end } ); - try writeFile(tmp.dir, "user_tools/greet.lua", - \\return { - \\ name = "greet", description = "user", - \\ schema = { type = "object" }, - \\ handler = function(input) return "USER" end, - \\} + try writeFile(tmp.dir, "b/y.lua", + \\return { name = "dup", description = "b", schema = { type = "object" }, + \\ handler = function(input) return "b" end } ); - var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const sys_len = try tmp.dir.realPathFile(testing.io, "base_tools", &path_buf); - const sys_path = try testing.allocator.dupe(u8, path_buf[0..sys_len]); - defer testing.allocator.free(sys_path); - const user_len = try tmp.dir.realPathFile(testing.io, "user_tools", &path_buf); - const user_path = try testing.allocator.dupe(u8, path_buf[0..user_len]); - defer testing.allocator.free(user_path); + var ab: [std.fs.max_path_bytes]u8 = undefined; + var bb: [std.fs.max_path_bytes]u8 = undefined; + const a = try realDir(&tmp, "a", &ab); + const b = try realDir(&tmp, "b", &bb); var rt = try LuaRuntime.create(testing.allocator); defer rt.deinit(); - const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{ - .base_tools = sys_path, - .user_tools = user_path, - }, .{}); - try testing.expectEqual(@as(usize, 1), n_tools); - - var src = rt.toolSource(); - const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }}; - var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; - try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer xfreeResults(&results); - try testing.expectEqualStrings("USER", xokText(results[0])); + // Same layer AND same origin → same rank → ambiguous. + const result = loadFromDirs(testing.allocator, testing.io, rt, &.{ + .{ .path = a, .source = .{ .layer = .project, .origin = .dir } }, + .{ .path = b, .source = .{ .layer = .project, .origin = .dir } }, + }, &.{}, null); + try testing.expectError(error.DuplicateExtensionName, result); } -test "loadFromDirs: extension and tool share a *file* name independently" { - // The shadow-key is (kind, name) so an extension named `foo` and a - // tool named `foo` coexist — as long as their *registered* tool names - // don't collide. +test "loadFromDirs: tool-name collision across entries errors" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); - try makeDir(tmp.dir, "ext"); - try makeDir(tmp.dir, "tools"); - - try writeFile(tmp.dir, "ext/foo.lua", + try makeDir(tmp.dir, "d"); + // Two distinct entry names, but both register the same tool name. + try writeFile(tmp.dir, "d/a.lua", \\local panto = require("panto") - \\panto.ext.register_tool { - \\ name = "ext_foo", description = "e", - \\ schema = { type = "object" }, - \\ handler = function(input) return "ext" end, - \\} + \\return { name = "a", activate = function() + \\ panto.ext.register_tool { name = "clash", description = "a", + \\ schema = { type = "object" }, handler = function() return "a" end } + \\end } ); - try writeFile(tmp.dir, "tools/foo.lua", - \\return { - \\ name = "tool_foo", description = "t", - \\ schema = { type = "object" }, - \\ handler = function(input) return "tool" end, - \\} + try writeFile(tmp.dir, "d/b.lua", + \\local panto = require("panto") + \\return { name = "b", activate = function() + \\ panto.ext.register_tool { name = "clash", description = "b", + \\ schema = { type = "object" }, handler = function() return "b" end } + \\end } ); - var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const e_len = try tmp.dir.realPathFile(testing.io, "ext", &path_buf); - const ext_path = try testing.allocator.dupe(u8, path_buf[0..e_len]); - defer testing.allocator.free(ext_path); - - const t_len = try tmp.dir.realPathFile(testing.io, "tools", &path_buf); - const tools_path = try testing.allocator.dupe(u8, path_buf[0..t_len]); - defer testing.allocator.free(tools_path); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const d = try realDir(&tmp, "d", &buf); var rt = try LuaRuntime.create(testing.allocator); defer rt.deinit(); - const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{ - .user_extensions = ext_path, - .user_tools = tools_path, - }, .{}); - try testing.expectEqual(@as(usize, 2), n_tools); + const result = loadFromDirs(testing.allocator, testing.io, rt, &.{ + .{ .path = d, .source = .{ .layer = .project, .origin = .dir } }, + }, &.{}, null); + try testing.expectError(error.DuplicateTool, result); } -test "loadFromDirs: tools policy removes a denied registered tool name" { +test "loadFromDirs: policy denies a name — its activate() never runs" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); - try makeDir(tmp.dir, "tools"); - try writeFile(tmp.dir, "tools/reader.lua", - \\return { - \\ name = "std.read", description = "r", - \\ schema = { type = "object" }, - \\ handler = function(input) return "READ" end, - \\} + try makeDir(tmp.dir, "d"); + // activate() would crash if it ran; the deny must prevent activation. + // eval (the top-level return) is always safe. + try writeFile(tmp.dir, "d/danger.lua", + \\return { name = "danger", activate = function() + \\ error("activate must not run") + \\end } ); - try writeFile(tmp.dir, "tools/sheller.lua", - \\return { - \\ name = "std.shell", description = "s", - \\ schema = { type = "object" }, - \\ handler = function(input) return "SHELL" end, - \\} + try writeFile(tmp.dir, "d/ok.lua", + \\return { name = "ok", description = "o", schema = { type = "object" }, + \\ handler = function(input) return "ok" end } ); - var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const n = try tmp.dir.realPathFile(testing.io, "tools", &path_buf); - const tools_path = try testing.allocator.dupe(u8, path_buf[0..n]); - defer testing.allocator.free(tools_path); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const d = try realDir(&tmp, "d", &buf); var rt = try LuaRuntime.create(testing.allocator); defer rt.deinit(); - var deny = try testing.allocator.alloc([]const u8, 1); - deny[0] = try testing.allocator.dupe(u8, "std.shell"); - const tools_policy: config_file.Policy = .{ .allow = &.{}, .deny = deny }; - defer tools_policy.deinit(testing.allocator); - - // Both tools register, but std.shell is filtered out post-load. - const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{ - .user_tools = tools_path, - }, .{ .tools = &tools_policy }); - try testing.expectEqual(@as(usize, 1), n_tools); + var rules = try testing.allocator.alloc(config_file.Rule, 1); + rules[0] = .{ .pattern = try testing.allocator.dupe(u8, "danger"), .verdict = .deny, .layer = 0, .spec = @import("glob.zig").specificity("danger") }; + const policy: config_file.Policy = .{ .rules = rules }; + defer policy.deinit(testing.allocator); - var src = rt.toolSource(); - const calls = [_]panto.ToolCall{.{ .tool_name = "std.read", .input = "{}" }}; - var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; - try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); - defer xfreeResults(&results); - try testing.expectEqualStrings("READ", xokText(results[0])); + const n = try loadFromDirs(testing.allocator, testing.io, rt, &.{ + .{ .path = d, .source = .{ .layer = .project, .origin = .dir } }, + }, &.{}, &policy); + // Only "ok" activates; "danger" is denied (its activate never runs). + try testing.expectEqual(@as(usize, 1), n); } -test "loadFromDirs: extensions policy denies a whole entry before it loads" { +test "loadFromDirs: a source returning a list registers multiple entries" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); - try makeDir(tmp.dir, "tools"); - // This tool would crash if its script ran (calls a nil global). The - // extensions policy must stop it from loading at all. - try writeFile(tmp.dir, "danger.lua", - \\error("this script must never run") - ); - try makeDir(tmp.dir, "td"); - try writeFile(tmp.dir, "td/danger.lua", - \\error("this script must never run") + try makeDir(tmp.dir, "d"); + try writeFile(tmp.dir, "d/multi.lua", + \\local panto = require("panto") + \\local function tool(nm) + \\ return { name = nm, activate = function() + \\ panto.ext.register_tool { name = nm, description = nm, + \\ schema = { type = "object" }, handler = function() return nm end } + \\ end } + \\end + \\return { tool("agent.skills"), tool("agent.rules") } ); - var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const n = try tmp.dir.realPathFile(testing.io, "td", &path_buf); - const tools_path = try testing.allocator.dupe(u8, path_buf[0..n]); - defer testing.allocator.free(tools_path); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const d = try realDir(&tmp, "d", &buf); var rt = try LuaRuntime.create(testing.allocator); defer rt.deinit(); - var deny = try testing.allocator.alloc([]const u8, 1); - deny[0] = try testing.allocator.dupe(u8, "danger"); - const ext_policy: config_file.Policy = .{ .allow = &.{}, .deny = deny }; - defer ext_policy.deinit(testing.allocator); + // Deny one of the two namespaces; the other still activates. + var rules = try testing.allocator.alloc(config_file.Rule, 1); + rules[0] = .{ .pattern = try testing.allocator.dupe(u8, "agent.rules"), .verdict = .deny, .layer = 0, .spec = @import("glob.zig").specificity("agent.rules") }; + const policy: config_file.Policy = .{ .rules = rules }; + defer policy.deinit(testing.allocator); + + const n = try loadFromDirs(testing.allocator, testing.io, rt, &.{ + .{ .path = d, .source = .{ .layer = .project, .origin = .dir } }, + }, &.{}, &policy); + try testing.expectEqual(@as(usize, 1), n); - const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{ - .user_tools = tools_path, - }, .{ .extensions = &ext_policy }); - try testing.expectEqual(@as(usize, 0), n_tools); + const r = try invokeOne(rt, "agent.skills", "{}"); + defer xfreeResults(@constCast(&[_]panto.ToolCallResult{r})); + try testing.expectEqualStrings("agent.skills", xokText(r)); } + |
