SPB Git

spb/metrika

Public

Stata-class statistics, GPU-accelerated by Apple Silicon. Native Swift — no Electron, no Python runtime, no compromises.

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>
simon-pierre boucher committed (Aug 5, 2026) parent d891c8b Browse files

6 changed files +425 −30

modified CLAUDE.md +1 −0
@@ -345,6 +345,7 @@ Metrika/
345 ``` 345 ```
346 346
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. 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 - Commits: Conventional Commits (`feat(parser): factor variable expansion`). 349 - Commits: Conventional Commits (`feat(parser): factor variable expansion`).
349 - Branches: `main` (protected), `dev`, feature branches `feat/*`. 350 - Branches: `main` (protected), `dev`, feature branches `feat/*`.
350 - CI: GitHub Actions on self-hosted arm64 macOS runner — build, test, bench, header check. 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 import ZQData 12 import ZQData
13 import ZQEngine 13 import ZQEngine
14 import ZQGraphics 14 import ZQGraphics
15+import ZQPlugins
15 16
16 /// Observable session state for the UI. Every command — typed in the 17 /// Observable session state for the UI. Every command — typed in the
17 /// console or replayed from a do-file — goes through the one `ZQSession` 18 /// console or replayed from a do-file — goes through the one `ZQSession`
@@ -45,7 +46,9 @@ final class SessionModel {
45 46
46 init() { 47 init() {
47 do { 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 } catch { 52 } catch {
50 entries.append(ConsoleEntry( 53 entries.append(ConsoleEntry(
51 command: "", 54 command: "",
modified MetrikaKit/Sources/ZQEngine/Session.swift +82 −17
@@ -34,24 +34,56 @@ public actor ZQSession {
34 public private(set) var timeVariable: String? 34 public private(set) var timeVariable: String?
35 35
36 private let store: ZQDataStore 36 private let store: ZQDataStore
37− private let parser = ZQCommandParser() 37+ private let parser: ZQCommandParser
38 private let planner = ZQPlanner(gpuAvailable: ZQGPUBootstrap.isAvailable) 38 private let planner = ZQPlanner(gpuAvailable: ZQGPUBootstrap.isAvailable)
39 private let scriptCommands: [String: ZQScriptCommand] 39 private let scriptCommands: [String: ZQScriptCommand]
40+ private let pluginRegistry: ZQPluginRegistry
40 private var logFileURL: URL? 41 private var logFileURL: URL?
41 private var scriptDepth = 0 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 self.store = try ZQDataStore() 55 self.store = try ZQDataStore()
45 self.frame = try ZQDataFrame() 56 self.frame = try ZQDataFrame()
57+
58+ var commands: [String: ZQScriptCommand] = [:]
46 if discoverUserCommands { 59 if discoverUserCommands {
47− var commands: [String: ZQScriptCommand] = [:] 60+ let scripts = commandsDirectory.map {
48− for script in ZQPluginDiscovery.scriptCommands() { 61+ ZQPluginDiscovery.scriptCommands(in: $0)
62+ } ?? ZQPluginDiscovery.scriptCommands()
63+ for script in scripts {
49 commands[script.verb] = script 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 // MARK: - Entry point 89 // MARK: - Entry point
@@ -91,28 +123,61 @@ public actor ZQSession {
91 } 123 }
92 124
93 private func run(_ line: String) async throws -> ZQResult { 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 guard let command = try parser.parse(line) else { 143 guard let command = try parser.parse(line) else {
95 return ZQResult(text: "") 144 return ZQResult(text: "")
96 } 145 }
97 146
98− // User script commands shadow nothing built-in; check after parse 147+ if let registered = pluginRegistry[command.verb] {
99− // failure would be better UX, but the verb table already resolved. 148+ return try await executePlugin(registered, command: command)
149+ }
150+
100 let plan = planner.plan(command, rowCount: frame.rowCount) 151 let plan = planner.plan(command, rowCount: frame.rowCount)
101 return try await dispatch(plan.command, backend: plan.backend) 152 return try await dispatch(plan.command, backend: plan.backend)
102 } 153 }
103 154
104− private func dispatch( 155+ /// Runs a native plugin: validates the parsed command against its
105− _ command: ZQCommand, backend: ZQBackend = .cpu 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 ) async throws -> ZQResult { 160 ) async throws -> ZQResult {
107− if let script = scriptCommands[command.verb] { 161+ if let violation = pluginRegistry.validate(command, against: registered.syntax) {
108− guard scriptDepth < 8 else { 162+ throw ZQEngineError("\(registered.verb): \(violation)")
109− throw ZQEngineError("user command recursion too deep") 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 172+ frame = replacement
112− defer { scriptDepth -= 1 } 173+ result.replacementFrame = nil
113− return try await executeScript(script.lines.joined(separator: "\n"))
114 } 174 }
175+ return result
176+ }
115 177
178+ private func dispatch(
179+ _ command: ZQCommand, backend: ZQBackend = .cpu
180+ ) async throws -> ZQResult {
116 switch command.verb { 181 switch command.verb {
117 case "use": return try await handleUse(command) 182 case "use": return try await handleUse(command)
118 case "save": return try await handleSave(command) 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 /// Result of executing a command: rendered console text plus named scalar 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 public struct ZQResult: Equatable, Sendable { 48 public struct ZQResult: Equatable, Sendable {
47 public var text: String 49 public var text: String
48 public var scalars: [String: Double] 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 self.text = text 58 self.text = text
52 self.scalars = scalars 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 64+/// Execution context handed to a plugin: the parsed command and a value
57−/// the working dataset. The frame is a value copy — mutations stay local 65+/// copy of the working dataset — mutations stay local unless returned
58−/// unless the plugin declares `mutates: true` and returns a replacement 66+/// through `ZQResult.replacementFrame` by a `mutates: true` plugin.
59−/// via `ZQContext.replacementFrame`.
60 public struct ZQContext: Sendable { 67 public struct ZQContext: Sendable {
61 public var command: ZQCommand 68 public var command: ZQCommand
62 public var frame: ZQDataFrame 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 public init(command: ZQCommand, frame: ZQDataFrame) { 71 public init(command: ZQCommand, frame: ZQDataFrame) {
68 self.command = command 72 self.command = command
69 self.frame = frame 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 /// Tier-1 user command: a `.zyq` script (ado-file analog) — a sequence of 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 public struct ZQScriptCommand: Equatable, Sendable { 89 public struct ZQScriptCommand: Equatable, Sendable {
85 public var verb: String 90 public var verb: String
86 public var fileURL: URL 91 public var fileURL: URL
92+ /// Body lines with the `args` declaration already stripped.
87 public var lines: [String] 93 public var lines: [String]
94+ /// Names from the `args` declaration, in positional order.
95+ public var parameterNames: [String]
88 96
89 public init(verb: String, fileURL: URL, lines: [String]) { 97 public init(verb: String, fileURL: URL, lines: [String]) {
90 self.verb = verb 98 self.verb = verb
91 self.fileURL = fileURL 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
Menu

Theme