feat(plugins): harden the user-command protocol
- fix: script-command verbs were routed AFTER parsing, which rejects
unknown verbs — .zyq commands could never run; they now route before
the parser with raw macro arguments
- .zyq scripts: ado-style 'args name…' declaration with backtick macro
substitution (`0', `k', `name'); scratch-variable scripts verified
end-to-end against the built-in path
- ZQPluginRegistry: verb-keyed native plugin registry; verbs shadowing
built-ins rejected at session start; parsed commands validated against
the declared ZQSyntaxSpec (varlist/if/in/weights/options)
- mutation gating: plugins return a replacement dataset via
ZQResult.replacementFrame, installed only under 'mutates: true' — a
non-mutating plugin returning one is an error and the dataset is
untouched (ZQContext is now a plain value, no writable back-channel)
- ZScorePlugin ships as the reference native plugin, registered by the
app ('zscore varname' -> standardized z_varname)
- CLAUDE.md: document the mlx-swift build quirks (xcodebuild for GPU
tests, -skipPackagePluginValidation everywhere)
- 87 tests green (swift test and xcodebuild with GPU suites); app builds
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 6 changed files with +425 and −30
modified
CLAUDE.md
+1 −0
@@ -345,6 +345,7 @@ Metrika/ | ||
| 345 | 345 | ``` |
| 346 | 346 | |
| 347 | 347 | - **Pre-commit hook** runs `scripts/check_headers.sh`: rejects any staged source file (`.swift`, `.metal`, `.sh`, `.py`, `.zyq`) missing the `Author: Simon-Pierre Boucher` / `Contact: contact@spboucher.ai` header. |
| 348 | +- **Build quirks (mlx-swift)**: SwiftPM CLI cannot compile Metal shaders, so `swift test` skips the GPU suites (CPU fallback via metallib detection); run `xcodebuild test -scheme MetrikaKit-Package -destination 'platform=macOS' -skipPackagePluginValidation` from `MetrikaKit/` for full coverage. All `xcodebuild` invocations (app and tests) need `-skipPackagePluginValidation` for mlx-swift's CudaBuild plugin. | |
| 348 | 349 | - Commits: Conventional Commits (`feat(parser): factor variable expansion`). |
| 349 | 350 | - Branches: `main` (protected), `dev`, feature branches `feat/*`. |
| 350 | 351 | - CI: GitHub Actions on self-hosted arm64 macOS runner — build, test, bench, header check. |
modified
Metrika/Sources/SessionModel.swift
+4 −1
@@ -12,6 +12,7 @@ import SwiftUI | ||
| 12 | 12 | import ZQData |
| 13 | 13 | import ZQEngine |
| 14 | 14 | import ZQGraphics |
| 15 | +import ZQPlugins | |
| 15 | 16 | |
| 16 | 17 | /// Observable session state for the UI. Every command — typed in the |
| 17 | 18 | /// console or replayed from a do-file — goes through the one `ZQSession` |
@@ -45,7 +46,9 @@ final class SessionModel { | ||
| 45 | 46 | |
| 46 | 47 | init() { |
| 47 | 48 | do { |
| 48 | − session = try ZQSession() | |
| 49 | + // Built-in native plugins ship with the app; user .zyq script | |
| 50 | + // commands are discovered from Application Support. | |
| 51 | + session = try ZQSession(plugins: [ZScorePlugin()]) | |
| 49 | 52 | } catch { |
| 50 | 53 | entries.append(ConsoleEntry( |
| 51 | 54 | command: "", |
modified
MetrikaKit/Sources/ZQEngine/Session.swift
+82 −17
@@ -34,24 +34,56 @@ public actor ZQSession { | ||
| 34 | 34 | public private(set) var timeVariable: String? |
| 35 | 35 | |
| 36 | 36 | private let store: ZQDataStore |
| 37 | − private let parser = ZQCommandParser() | |
| 37 | + private let parser: ZQCommandParser | |
| 38 | 38 | private let planner = ZQPlanner(gpuAvailable: ZQGPUBootstrap.isAvailable) |
| 39 | 39 | private let scriptCommands: [String: ZQScriptCommand] |
| 40 | + private let pluginRegistry: ZQPluginRegistry | |
| 40 | 41 | private var logFileURL: URL? |
| 41 | 42 | private var scriptDepth = 0 |
| 42 | 43 | |
| 43 | − public init(discoverUserCommands: Bool = true) throws { | |
| 44 | + /// - Parameters: | |
| 45 | + /// - discoverUserCommands: scan the commands directory for `.zyq` | |
| 46 | + /// script commands. | |
| 47 | + /// - commandsDirectory: override of the discovery location (tests). | |
| 48 | + /// - plugins: SPM-compiled-in native plugins to register. A plugin | |
| 49 | + /// whose verb shadows a built-in command is rejected. | |
| 50 | + public init( | |
| 51 | + discoverUserCommands: Bool = true, | |
| 52 | + commandsDirectory: URL? = nil, | |
| 53 | + plugins: [any ZQCommandPlugin] = [] | |
| 54 | + ) throws { | |
| 44 | 55 | self.store = try ZQDataStore() |
| 45 | 56 | self.frame = try ZQDataFrame() |
| 57 | + | |
| 58 | + var commands: [String: ZQScriptCommand] = [:] | |
| 46 | 59 | if discoverUserCommands { |
| 47 | − var commands: [String: ZQScriptCommand] = [:] | |
| 48 | − for script in ZQPluginDiscovery.scriptCommands() { | |
| 60 | + let scripts = commandsDirectory.map { | |
| 61 | + ZQPluginDiscovery.scriptCommands(in: $0) | |
| 62 | + } ?? ZQPluginDiscovery.scriptCommands() | |
| 63 | + for script in scripts { | |
| 49 | 64 | commands[script.verb] = script |
| 50 | 65 | } |
| 51 | − self.scriptCommands = commands | |
| 52 | − } else { | |
| 53 | − self.scriptCommands = [:] | |
| 54 | 66 | } |
| 67 | + self.scriptCommands = commands | |
| 68 | + | |
| 69 | + let builtinVerbs = Set(ZQVerbTable.builtin.verbs.keys) | |
| 70 | + self.pluginRegistry = try ZQPluginRegistry( | |
| 71 | + plugins: plugins, reservedVerbs: builtinVerbs | |
| 72 | + ) | |
| 73 | + | |
| 74 | + // Plugin verbs join the grammar so their commands parse with full | |
| 75 | + // varlist/if/in/options structure (no abbreviations for plugins). | |
| 76 | + var verbs = ZQVerbTable.builtin.verbs | |
| 77 | + for verb in pluginRegistry.verbs { | |
| 78 | + verbs[verb] = verb.count | |
| 79 | + } | |
| 80 | + self.parser = ZQCommandParser(verbTable: ZQVerbTable( | |
| 81 | + verbs: verbs, | |
| 82 | + fileVerbs: ZQVerbTable.builtin.fileVerbs, | |
| 83 | + assignmentVerbs: ZQVerbTable.builtin.assignmentVerbs, | |
| 84 | + prefixVerbs: ZQVerbTable.builtin.prefixVerbs, | |
| 85 | + compoundVerbs: ZQVerbTable.builtin.compoundVerbs | |
| 86 | + )) | |
| 55 | 87 | } |
| 56 | 88 | |
| 57 | 89 | // MARK: - Entry point |
@@ -91,28 +123,61 @@ public actor ZQSession { | ||
| 91 | 123 | } |
| 92 | 124 | |
| 93 | 125 | private func run(_ line: String) async throws -> ZQResult { |
| 126 | + // Script commands are routed BEFORE the parser: their verbs are | |
| 127 | + // not in the grammar and their arguments are raw macro text. | |
| 128 | + let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 129 | + let verbWord = String(trimmed.prefix { !$0.isWhitespace }) | |
| 130 | + if let script = scriptCommands[verbWord] { | |
| 131 | + guard scriptDepth < 8 else { | |
| 132 | + throw ZQEngineError("user command recursion too deep") | |
| 133 | + } | |
| 134 | + scriptDepth += 1 | |
| 135 | + defer { scriptDepth -= 1 } | |
| 136 | + let arguments = String(trimmed.dropFirst(verbWord.count)) | |
| 137 | + .trimmingCharacters(in: .whitespaces) | |
| 138 | + let body = script.expandedLines(arguments: arguments) | |
| 139 | + .joined(separator: "\n") | |
| 140 | + return try await executeScript(body) | |
| 141 | + } | |
| 142 | + | |
| 94 | 143 | guard let command = try parser.parse(line) else { |
| 95 | 144 | return ZQResult(text: "") |
| 96 | 145 | } |
| 97 | 146 | |
| 98 | − // User script commands shadow nothing built-in; check after parse | |
| 99 | − // failure would be better UX, but the verb table already resolved. | |
| 147 | + if let registered = pluginRegistry[command.verb] { | |
| 148 | + return try await executePlugin(registered, command: command) | |
| 149 | + } | |
| 150 | + | |
| 100 | 151 | let plan = planner.plan(command, rowCount: frame.rowCount) |
| 101 | 152 | return try await dispatch(plan.command, backend: plan.backend) |
| 102 | 153 | } |
| 103 | 154 | |
| 104 | − private func dispatch( | |
| 105 | − _ command: ZQCommand, backend: ZQBackend = .cpu | |
| 155 | + /// Runs a native plugin: validates the parsed command against its | |
| 156 | + /// declared syntax, hands it a value copy of the dataset, and installs | |
| 157 | + /// a returned replacement frame only when `mutates: true`. | |
| 158 | + private func executePlugin( | |
| 159 | + _ registered: ZQPluginRegistry.Registered, command: ZQCommand | |
| 106 | 160 | ) async throws -> ZQResult { |
| 107 | − if let script = scriptCommands[command.verb] { | |
| 108 | − guard scriptDepth < 8 else { | |
| 109 | − throw ZQEngineError("user command recursion too deep") | |
| 161 | + if let violation = pluginRegistry.validate(command, against: registered.syntax) { | |
| 162 | + throw ZQEngineError("\(registered.verb): \(violation)") | |
| 163 | + } | |
| 164 | + let context = ZQContext(command: command, frame: frame) | |
| 165 | + var result = try await registered.plugin.execute(context) | |
| 166 | + if let replacement = result.replacementFrame { | |
| 167 | + guard registered.syntax.mutates else { | |
| 168 | + throw ZQEngineError( | |
| 169 | + "\(registered.verb): plugin returned a dataset but does not declare 'mutates'" | |
| 170 | + ) | |
| 110 | 171 | } |
| 111 | − scriptDepth += 1 | |
| 112 | − defer { scriptDepth -= 1 } | |
| 113 | − return try await executeScript(script.lines.joined(separator: "\n")) | |
| 172 | + frame = replacement | |
| 173 | + result.replacementFrame = nil | |
| 114 | 174 | } |
| 175 | + return result | |
| 176 | + } | |
| 115 | 177 | |
| 178 | + private func dispatch( | |
| 179 | + _ command: ZQCommand, backend: ZQBackend = .cpu | |
| 180 | + ) async throws -> ZQResult { | |
| 116 | 181 | switch command.verb { |
| 117 | 182 | case "use": return try await handleUse(command) |
| 118 | 183 | case "save": return try await handleSave(command) |
modified
MetrikaKit/Sources/ZQPlugins/PluginProtocol.swift
+127 −12
@@ -42,32 +42,35 @@ public struct ZQSyntaxSpec: Equatable, Sendable { | ||
| 42 | 42 | } |
| 43 | 43 | |
| 44 | 44 | /// Result of executing a command: rendered console text plus named scalar |
| 45 | −/// results (Stata's `r()` analog). | |
| 45 | +/// results (Stata's `r()` analog). Mutating plugins return the new | |
| 46 | +/// working dataset via `replacementFrame`; the engine installs it only | |
| 47 | +/// when the plugin's syntax declares `mutates: true`. | |
| 46 | 48 | public struct ZQResult: Equatable, Sendable { |
| 47 | 49 | public var text: String |
| 48 | 50 | public var scalars: [String: Double] |
| 51 | + public var replacementFrame: ZQDataFrame? | |
| 49 | 52 | |
| 50 | − public init(text: String, scalars: [String: Double] = [:]) { | |
| 53 | + public init( | |
| 54 | + text: String, | |
| 55 | + scalars: [String: Double] = [:], | |
| 56 | + replacementFrame: ZQDataFrame? = nil | |
| 57 | + ) { | |
| 51 | 58 | self.text = text |
| 52 | 59 | self.scalars = scalars |
| 60 | + self.replacementFrame = replacementFrame | |
| 53 | 61 | } |
| 54 | 62 | } |
| 55 | 63 | |
| 56 | −/// Execution context handed to a plugin: the parsed command and a view of | |
| 57 | −/// the working dataset. The frame is a value copy — mutations stay local | |
| 58 | −/// unless the plugin declares `mutates: true` and returns a replacement | |
| 59 | −/// via `ZQContext.replacementFrame`. | |
| 64 | +/// Execution context handed to a plugin: the parsed command and a value | |
| 65 | +/// copy of the working dataset — mutations stay local unless returned | |
| 66 | +/// through `ZQResult.replacementFrame` by a `mutates: true` plugin. | |
| 60 | 67 | public struct ZQContext: Sendable { |
| 61 | 68 | public var command: ZQCommand |
| 62 | 69 | public var frame: ZQDataFrame |
| 63 | − /// Set by mutating plugins; the engine installs it as the new working | |
| 64 | − /// dataset after successful execution. | |
| 65 | − public var replacementFrame: ZQDataFrame? | |
| 66 | 70 | |
| 67 | 71 | public init(command: ZQCommand, frame: ZQDataFrame) { |
| 68 | 72 | self.command = command |
| 69 | 73 | self.frame = frame |
| 70 | − self.replacementFrame = nil | |
| 71 | 74 | } |
| 72 | 75 | } |
| 73 | 76 | |
@@ -80,16 +83,128 @@ public protocol ZQCommandPlugin: Sendable { | ||
| 80 | 83 | } |
| 81 | 84 | |
| 82 | 85 | /// Tier-1 user command: a `.zyq` script (ado-file analog) — a sequence of |
| 83 | −/// ZQL lines the engine replays. | |
| 86 | +/// ZQL lines the engine replays. An optional leading `args name1 name2 …` | |
| 87 | +/// declaration names positional arguments; script lines reference them | |
| 88 | +/// Stata-style: `` `1' ``, `` `name' ``, and `` `0' `` (everything). | |
| 84 | 89 | public struct ZQScriptCommand: Equatable, Sendable { |
| 85 | 90 | public var verb: String |
| 86 | 91 | public var fileURL: URL |
| 92 | + /// Body lines with the `args` declaration already stripped. | |
| 87 | 93 | public var lines: [String] |
| 94 | + /// Names from the `args` declaration, in positional order. | |
| 95 | + public var parameterNames: [String] | |
| 88 | 96 | |
| 89 | 97 | public init(verb: String, fileURL: URL, lines: [String]) { |
| 90 | 98 | self.verb = verb |
| 91 | 99 | self.fileURL = fileURL |
| 92 | − self.lines = lines | |
| 100 | + | |
| 101 | + // Peel a leading `args` declaration (blank/comment lines may | |
| 102 | + // precede it). | |
| 103 | + var parameterNames: [String] = [] | |
| 104 | + var body = lines | |
| 105 | + for (index, line) in lines.enumerated() { | |
| 106 | + let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 107 | + if trimmed.isEmpty || trimmed.hasPrefix("//") || trimmed.hasPrefix("*") { | |
| 108 | + continue | |
| 109 | + } | |
| 110 | + if trimmed.hasPrefix("args ") || trimmed.hasPrefix("args\t") { | |
| 111 | + parameterNames = trimmed.dropFirst(4) | |
| 112 | + .split(separator: " ", omittingEmptySubsequences: true) | |
| 113 | + .map(String.init) | |
| 114 | + body.remove(at: index) | |
| 115 | + } | |
| 116 | + break | |
| 117 | + } | |
| 118 | + self.lines = body | |
| 119 | + self.parameterNames = parameterNames | |
| 120 | + } | |
| 121 | + | |
| 122 | + /// Substitutes `` `0' ``, `` `k' ``, and `` `name' `` macros with the | |
| 123 | + /// invocation arguments (whitespace-split remainder of the command). | |
| 124 | + public func expandedLines(arguments rawArguments: String) -> [String] { | |
| 125 | + let arguments = rawArguments | |
| 126 | + .split(separator: " ", omittingEmptySubsequences: true) | |
| 127 | + .map(String.init) | |
| 128 | + var substitutions: [String: String] = ["0": rawArguments] | |
| 129 | + for (index, value) in arguments.enumerated() { | |
| 130 | + substitutions["\(index + 1)"] = value | |
| 131 | + if index < parameterNames.count { | |
| 132 | + substitutions[parameterNames[index]] = value | |
| 133 | + } | |
| 134 | + } | |
| 135 | + // Declared but unsupplied parameters expand to nothing. | |
| 136 | + for name in parameterNames where substitutions[name] == nil { | |
| 137 | + substitutions[name] = "" | |
| 138 | + } | |
| 139 | + | |
| 140 | + return lines.map { line in | |
| 141 | + var expanded = line | |
| 142 | + for (key, value) in substitutions { | |
| 143 | + expanded = expanded.replacingOccurrences(of: "`\(key)'", with: value) | |
| 144 | + } | |
| 145 | + return expanded | |
| 146 | + } | |
| 147 | + } | |
| 148 | +} | |
| 149 | + | |
| 150 | +/// Immutable registry of native plugins, verb-keyed. Built once at session | |
| 151 | +/// start; verbs that would shadow a built-in are rejected outright. | |
| 152 | +public struct ZQPluginRegistry: Sendable { | |
| 153 | + public struct Registered: Sendable { | |
| 154 | + public let verb: String | |
| 155 | + public let syntax: ZQSyntaxSpec | |
| 156 | + public let plugin: any ZQCommandPlugin | |
| 157 | + } | |
| 158 | + | |
| 159 | + public struct ShadowingError: Error, CustomStringConvertible { | |
| 160 | + public let verb: String | |
| 161 | + public var description: String { | |
| 162 | + "plugin verb '\(verb)' shadows a built-in command" | |
| 163 | + } | |
| 164 | + } | |
| 165 | + | |
| 166 | + private var byVerb: [String: Registered] = [:] | |
| 167 | + | |
| 168 | + public init( | |
| 169 | + plugins: [any ZQCommandPlugin], | |
| 170 | + reservedVerbs: Set<String> | |
| 171 | + ) throws { | |
| 172 | + for plugin in plugins { | |
| 173 | + let type = type(of: plugin) | |
| 174 | + let verb = type.verb | |
| 175 | + guard !reservedVerbs.contains(verb) else { | |
| 176 | + throw ShadowingError(verb: verb) | |
| 177 | + } | |
| 178 | + byVerb[verb] = Registered(verb: verb, syntax: type.syntax, plugin: plugin) | |
| 179 | + } | |
| 180 | + } | |
| 181 | + | |
| 182 | + public var verbs: [String] { Array(byVerb.keys) } | |
| 183 | + public var isEmpty: Bool { byVerb.isEmpty } | |
| 184 | + | |
| 185 | + public subscript(verb: String) -> Registered? { byVerb[verb] } | |
| 186 | + | |
| 187 | + /// Pre-execution validation of a parsed command against the plugin's | |
| 188 | + /// declared syntax. | |
| 189 | + public func validate(_ command: ZQCommand, against syntax: ZQSyntaxSpec) -> String? { | |
| 190 | + if !syntax.acceptsVarlist && !command.varlist.isEmpty { | |
| 191 | + return "does not accept a varlist" | |
| 192 | + } | |
| 193 | + if !syntax.acceptsCondition && command.condition != nil { | |
| 194 | + return "does not accept an 'if' qualifier" | |
| 195 | + } | |
| 196 | + if !syntax.acceptsRange && command.range != nil { | |
| 197 | + return "does not accept an 'in' range" | |
| 198 | + } | |
| 199 | + if !syntax.acceptsWeight && command.weight != nil { | |
| 200 | + return "does not accept weights" | |
| 201 | + } | |
| 202 | + if !syntax.options.isEmpty { | |
| 203 | + for option in command.options where !syntax.options.contains(option.name) { | |
| 204 | + return "option '\(option.name)' not allowed" | |
| 205 | + } | |
| 206 | + } | |
| 207 | + return nil | |
| 93 | 208 | } |
| 94 | 209 | } |
| 95 | 210 | |
added
MetrikaKit/Sources/ZQPlugins/ZScorePlugin.swift
+69 −0
@@ -0,0 +1,69 @@ | ||
| 1 | +// | |
| 2 | +// ZScorePlugin.swift | |
| 3 | +// Metrika | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved. | |
| 8 | +// | |
| 9 | + | |
| 10 | +import ZQData | |
| 11 | +import ZQParser | |
| 12 | + | |
| 13 | +/// Reference native plugin (CLAUDE.md §4): `zscore varname` generates | |
| 14 | +/// `z_varname`, the standardized version of a numeric variable. Declares | |
| 15 | +/// `mutates: true` and returns the updated dataset via | |
| 16 | +/// `ZQResult.replacementFrame` — the pattern SPM-compiled-in user | |
| 17 | +/// plugins follow. | |
| 18 | +public struct ZScorePlugin: ZQCommandPlugin { | |
| 19 | + public static let verb = "zscore" | |
| 20 | + public static let syntax = ZQSyntaxSpec( | |
| 21 | + acceptsVarlist: true, | |
| 22 | + acceptsCondition: false, | |
| 23 | + acceptsRange: false, | |
| 24 | + acceptsWeight: false, | |
| 25 | + options: [], | |
| 26 | + mutates: true | |
| 27 | + ) | |
| 28 | + | |
| 29 | + public init() {} | |
| 30 | + | |
| 31 | + public func execute(_ ctx: ZQContext) async throws -> ZQResult { | |
| 32 | + let names = ctx.command.varlist.flatMap(\.referencedNames) | |
| 33 | + guard names.count == 1, let name = names.first else { | |
| 34 | + throw ZQDataError("zscore: syntax is 'zscore varname'") | |
| 35 | + } | |
| 36 | + let (values, missing) = try ctx.frame.requireNumeric(name) | |
| 37 | + | |
| 38 | + var count = 0 | |
| 39 | + var sum = 0.0 | |
| 40 | + for i in 0..<values.count where !missing[i] { | |
| 41 | + sum += values[i] | |
| 42 | + count += 1 | |
| 43 | + } | |
| 44 | + guard count > 1 else { throw ZQDataError("zscore: insufficient observations") } | |
| 45 | + let mean = sum / Double(count) | |
| 46 | + var squared = 0.0 | |
| 47 | + for i in 0..<values.count where !missing[i] { | |
| 48 | + squared += (values[i] - mean) * (values[i] - mean) | |
| 49 | + } | |
| 50 | + let sd = (squared / Double(count - 1)).squareRoot() | |
| 51 | + guard sd > 0 else { throw ZQDataError("zscore: '\(name)' is constant") } | |
| 52 | + | |
| 53 | + var scores = [Double](repeating: .nan, count: values.count) | |
| 54 | + for i in 0..<values.count where !missing[i] { | |
| 55 | + scores[i] = (values[i] - mean) / sd | |
| 56 | + } | |
| 57 | + | |
| 58 | + var frame = ctx.frame | |
| 59 | + try frame.addColumn(ZQColumn( | |
| 60 | + name: "z_\(name)", | |
| 61 | + data: .float64(values: scores, missing: missing) | |
| 62 | + )) | |
| 63 | + return ZQResult( | |
| 64 | + text: "(variable z_\(name) generated: mean \(mean), sd \(sd))", | |
| 65 | + scalars: ["mean": mean, "sd": sd], | |
| 66 | + replacementFrame: frame | |
| 67 | + ) | |
| 68 | + } | |
| 69 | +} | |
added
MetrikaKit/Tests/MetrikaKitTests/PluginTests.swift
+142 −0
@@ -0,0 +1,142 @@ | ||
| 1 | +// | |
| 2 | +// PluginTests.swift | |
| 3 | +// Metrika | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved. | |
| 8 | +// | |
| 9 | + | |
| 10 | +import Foundation | |
| 11 | +import Testing | |
| 12 | +import ZQData | |
| 13 | +import ZQEngine | |
| 14 | +import ZQParser | |
| 15 | +import ZQPlugins | |
| 16 | + | |
| 17 | +/// User-command tests: `.zyq` script commands with `args` substitution, | |
| 18 | +/// and native ZQCommandPlugin registration, validation, and mutation | |
| 19 | +/// gating. | |
| 20 | +@Suite("Plugin protocol", .serialized) | |
| 21 | +struct PluginTests { | |
| 22 | + let fixtures: Fixtures | |
| 23 | + let commandsDirectory: URL | |
| 24 | + | |
| 25 | + init() throws { | |
| 26 | + self.fixtures = try Fixtures() | |
| 27 | + self.commandsDirectory = FileManager.default.temporaryDirectory | |
| 28 | + .appendingPathComponent("metrika_commands_\(UUID().uuidString)") | |
| 29 | + try FileManager.default.createDirectory( | |
| 30 | + at: commandsDirectory, withIntermediateDirectories: true | |
| 31 | + ) | |
| 32 | + let script = """ | |
| 33 | + // robust regression of a logged response — ado-file analog | |
| 34 | + args response predictor | |
| 35 | + gen __log_response = ln(`response') | |
| 36 | + reg __log_response `predictor', robust | |
| 37 | + drop __log_response | |
| 38 | + """ | |
| 39 | + try Data(script.utf8).write( | |
| 40 | + to: commandsDirectory.appendingPathComponent("logreg.zyq") | |
| 41 | + ) | |
| 42 | + } | |
| 43 | + | |
| 44 | + private func makeSession(plugins: [any ZQCommandPlugin] = []) async throws -> ZQSession { | |
| 45 | + let session = try ZQSession( | |
| 46 | + discoverUserCommands: true, | |
| 47 | + commandsDirectory: commandsDirectory, | |
| 48 | + plugins: plugins | |
| 49 | + ) | |
| 50 | + _ = try await session.execute("use \(fixtures.datasetURL.path)") | |
| 51 | + return session | |
| 52 | + } | |
| 53 | + | |
| 54 | + @Test("script command with args substitution matches the built-in path") | |
| 55 | + func scriptCommand() async throws { | |
| 56 | + let session = try await makeSession() | |
| 57 | + let result = try await session.execute("logreg revenue price") | |
| 58 | + // Same estimate as running the commands directly. | |
| 59 | + expectClose( | |
| 60 | + try #require(result.scalars["b_price"]), | |
| 61 | + fixtures["ols_b_price"], "b[price] via script command" | |
| 62 | + ) | |
| 63 | + // The scratch variable was dropped by the script. | |
| 64 | + let count = try await session.execute("count") | |
| 65 | + #expect(count.scalars["N"] == 60) | |
| 66 | + await #expect(throws: (any Error).self) { | |
| 67 | + _ = try await session.execute("summarize __log_response") | |
| 68 | + } | |
| 69 | + } | |
| 70 | + | |
| 71 | + @Test("args parsing strips the declaration and names parameters") | |
| 72 | + func argsDeclaration() throws { | |
| 73 | + let script = ZQScriptCommand( | |
| 74 | + verb: "demo", | |
| 75 | + fileURL: URL(fileURLWithPath: "/tmp/demo.zyq"), | |
| 76 | + lines: ["* comment first", "args a b", "reg `a' `b'", "count if `a' > `2'"] | |
| 77 | + ) | |
| 78 | + #expect(script.parameterNames == ["a", "b"]) | |
| 79 | + #expect(script.lines == ["* comment first", "reg `a' `b'", "count if `a' > `2'"]) | |
| 80 | + #expect(script.expandedLines(arguments: "y x") == [ | |
| 81 | + "* comment first", "reg y x", "count if y > x", | |
| 82 | + ]) | |
| 83 | + } | |
| 84 | + | |
| 85 | + @Test("native plugin executes and its mutation is installed") | |
| 86 | + func nativePlugin() async throws { | |
| 87 | + let session = try await makeSession(plugins: [ZScorePlugin()]) | |
| 88 | + let result = try await session.execute("zscore revenue") | |
| 89 | + #expect(result.text.contains("z_revenue")) | |
| 90 | + | |
| 91 | + // The generated column exists and is standardized. | |
| 92 | + let summary = try await session.execute("summarize z_revenue") | |
| 93 | + #expect(abs(try #require(summary.scalars["mean"])) < 1e-12) | |
| 94 | + expectClose(try #require(summary.scalars["sd"]), 1, "sd = 1") | |
| 95 | + } | |
| 96 | + | |
| 97 | + @Test("plugin syntax validation rejects undeclared qualifiers") | |
| 98 | + func syntaxValidation() async throws { | |
| 99 | + let session = try await makeSession(plugins: [ZScorePlugin()]) | |
| 100 | + // ZScorePlugin declares acceptsCondition: false. | |
| 101 | + await #expect(throws: ZQEngineError.self) { | |
| 102 | + _ = try await session.execute("zscore revenue if price > 10") | |
| 103 | + } | |
| 104 | + } | |
| 105 | + | |
| 106 | + @Test("a plugin shadowing a built-in verb is rejected at startup") | |
| 107 | + func shadowingRejected() { | |
| 108 | + struct Impostor: ZQCommandPlugin { | |
| 109 | + static let verb = "regress" | |
| 110 | + static let syntax = ZQSyntaxSpec() | |
| 111 | + func execute(_ ctx: ZQContext) async throws -> ZQResult { | |
| 112 | + ZQResult(text: "hijacked") | |
| 113 | + } | |
| 114 | + } | |
| 115 | + #expect(throws: ZQPluginRegistry.ShadowingError.self) { | |
| 116 | + _ = try ZQSession( | |
| 117 | + discoverUserCommands: false, plugins: [Impostor()] | |
| 118 | + ) | |
| 119 | + } | |
| 120 | + } | |
| 121 | + | |
| 122 | + @Test("non-mutating plugin cannot smuggle a replacement frame") | |
| 123 | + func mutationGating() async throws { | |
| 124 | + struct Sneaky: ZQCommandPlugin { | |
| 125 | + static let verb = "sneaky" | |
| 126 | + static let syntax = ZQSyntaxSpec(mutates: false) | |
| 127 | + func execute(_ ctx: ZQContext) async throws -> ZQResult { | |
| 128 | + ZQResult( | |
| 129 | + text: "", | |
| 130 | + replacementFrame: try ZQDataFrame(columns: []) | |
| 131 | + ) | |
| 132 | + } | |
| 133 | + } | |
| 134 | + let session = try await makeSession(plugins: [Sneaky()]) | |
| 135 | + await #expect(throws: ZQEngineError.self) { | |
| 136 | + _ = try await session.execute("sneaky") | |
| 137 | + } | |
| 138 | + // Working dataset untouched. | |
| 139 | + let count = try await session.execute("count") | |
| 140 | + #expect(count.scalars["N"] == 60) | |
| 141 | + } | |
| 142 | +} | |
| 143 | ||