diff --git a/changelog.md b/changelog.md index 42d0d59c5..7bab579d6 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,11 @@ ## Unreleased +* `NEW` Add devsper language support for `.devsper` workflow and tool files: type library (`meta/3rd/devsper/`) with full `---@class` annotations for the `devsper` global, `WorkflowBuilder`, and all spec types — enabling autocomplete and hover docs in any editor using this LSP +* `NEW` Add `devsper-schema` diagnostic: warns on missing required fields (`prompt` in `wf.task`, `source` in `wf.plugin`, `description`/`run` in `devsper.tool`), unknown `builtin:` plugin names, and missing spec arguments +* `NEW` Add `devsper-depends` diagnostic: cross-references `depends_on` task IDs against declared tasks in the same file; `"*"` wildcard always valid +* `NEW` Add `devsper-model` diagnostic: hints on unrecognized literal model strings; skips variables and known provider prefixes (`claude-*`, `gpt-*`, `gemini-*`, `llama*`, etc.) +* `NEW` Add VS Code extension scaffold (`tools/vscode/`) with `.devsper` file association, TextMate grammar inheriting Lua base, and five snippets (`workflow`, `task`, `plugin`, `input`, `tool`) ## 3.18.2 * `CHG` `duplicate-set-field` diagnostic now supports linked suppression: when any occurrence of a duplicate field is suppressed with `---@diagnostic disable` or `---@diagnostic disable-next-line`, all warnings for that field name will be suppressed diff --git a/meta/3rd/devsper/.luarc.json b/meta/3rd/devsper/.luarc.json new file mode 100644 index 000000000..d35dcda47 --- /dev/null +++ b/meta/3rd/devsper/.luarc.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://raw.githubusercontent.com/sumneko/vscode-lua/master/setting/schema.json", + "runtime": { + "version": "Lua 5.4", + "extension": { ".devsper": "lua" } + }, + "workspace": { + "library": ["${3rd}/devsper/library"], + "checkThirdParty": false + }, + "diagnostics": { + "globals": ["devsper"] + } +} diff --git a/meta/3rd/devsper/library/devsper.lua b/meta/3rd/devsper/library/devsper.lua new file mode 100644 index 000000000..f09fc2b10 --- /dev/null +++ b/meta/3rd/devsper/library/devsper.lua @@ -0,0 +1,122 @@ +---@meta devsper + +--- Result returned by devsper.exec(). +---@class devsper.ExecResult +---@field code integer Exit code of the process. +---@field stdout string Standard output captured from the process. +---@field stderr string Standard error captured from the process. + +--- HTTP response returned by devsper.http methods. +---@class devsper.HttpResponse +---@field status integer HTTP status code (e.g. 200, 404). +---@field body string Response body as a string. +---@field headers table Response headers keyed by header name. + +---@class devsper.HttpLib +---Fetch a URL and return the response. +---@field get fun(url: string, opts?: { headers?: table, timeout?: integer }): devsper.HttpResponse +---Perform an HTTP POST request. +---@field post fun(url: string, body: string|table, opts?: { headers?: table, timeout?: integer }): devsper.HttpResponse + +--- Context object passed to tool run functions. +---@class devsper.ToolCtx +---@field workflow_id string ID of the parent workflow. +---@field run_id string ID of the current run. +local ToolCtx = {} + +--- Emit a structured log message from within a tool. +---@param level "debug"|"info"|"warn"|"error" Log level. +---@param msg string Log message text. +function ToolCtx:log(level, msg) end + +--- Parameter specification for a tool parameter. +---@class devsper.ParamSpec +---@field type "string"|"number"|"boolean"|"object" JSON-schema-style type. +---@field required boolean? Whether the parameter is required. Defaults to false. +---@field default any? Default value when the parameter is omitted. + +--- Configuration for the evolutionary/speculative execution engine. +---@class devsper.EvolutionConfig +---@field allow_mutations boolean? Allow the swarm to mutate task prompts at runtime. Defaults to false. +---@field max_depth integer? Maximum recursion/mutation depth. Defaults to 3. +---@field speculative boolean? Enable speculative (parallel branch) execution. Defaults to false. + +--- Top-level configuration passed to devsper.workflow(). +---@class devsper.WorkflowConfig +---@field name string Human-readable workflow name (required). +---@field model string? Default LLM model ID for tasks in this workflow. +---@field workers integer? Number of parallel worker goroutines/processes. +---@field bus "memory"|"redis"|"kafka"? Message bus backend. Defaults to "memory". +---@field evolution devsper.EvolutionConfig? Optional evolutionary execution configuration. + +--- Specification for a single workflow task. +---@class devsper.TaskSpec +---@field prompt string Prompt sent to the LLM for this task (required). +---@field model string? Override the workflow-level model for this task. +---@field can_mutate boolean? Allow this task's prompt to be mutated by the evolution engine. +---@field depends_on? (string|"*")[] Task IDs that must complete first. Use "*" to depend on all other tasks. + +--- Specification for a workflow plugin. +---@class devsper.PluginSpec +---@field source string Path or import specifier for the plugin module (required). + +--- Specification for a workflow input parameter. +---@class devsper.InputSpec +---@field type "string"|"number"|"boolean"|"object"? Type of the input value. +---@field required boolean? Whether this input must be supplied at run time. +---@field default any? Default value used when the input is not supplied. + +--- Specification passed to devsper.tool(). +---@class devsper.ToolSpec +---@field description string Human-readable description of the tool (required). +---@field params table? Map of parameter name to its spec. +---@field run fun(ctx: devsper.ToolCtx, args: table): any, string? Tool implementation (required). Returns a result and an optional error string. + +--- Fluent builder returned by devsper.workflow(). +---@class devsper.WorkflowBuilder +local WorkflowBuilder = {} + +--- Register a task in the workflow. +---@param id string Unique task identifier within the workflow. +---@param spec devsper.TaskSpec Task configuration. +---@return devsper.WorkflowBuilder Returns the builder for chaining. +function WorkflowBuilder:task(id, spec) end + +--- Register a plugin in the workflow. +---@param name string Plugin name used to reference it within tasks. +---@param spec devsper.PluginSpec Plugin configuration. +---@return devsper.WorkflowBuilder Returns the builder for chaining. +function WorkflowBuilder:plugin(name, spec) end + +--- Declare an input parameter for the workflow. +---@param name string Input parameter name. +---@param spec devsper.InputSpec Input specification. +---@return devsper.WorkflowBuilder Returns the builder for chaining. +function WorkflowBuilder:input(name, spec) end + +--- The devsper global injected into every .devsper file. +---@class devsper +---@field http devsper.HttpLib HTTP client for use inside tools or workflows. +devsper = {} + +--- Define and register a workflow. Returns a builder for adding tasks, plugins, and inputs. +---@param config devsper.WorkflowConfig Top-level workflow configuration. +---@return devsper.WorkflowBuilder +function devsper.workflow(config) end + +--- Define and register a tool that the swarm can invoke. +---@param name string Unique tool name. +---@param spec devsper.ToolSpec Tool specification including description, params, and run function. +---@return nil +function devsper.tool(name, spec) end + +--- Execute a subprocess and return its result. +---@param cmd string Executable to run. +---@param args string[]? Arguments to pass to the executable. +---@return devsper.ExecResult +function devsper.exec(cmd, args) end + +--- Emit a structured log message at the workflow/global level. +---@param level "debug"|"info"|"warn"|"error" Log level. +---@param msg string Log message text. +function devsper.log(level, msg) end diff --git a/script/core/diagnostics/devsper-depends.lua b/script/core/diagnostics/devsper-depends.lua new file mode 100644 index 000000000..c603775a3 --- /dev/null +++ b/script/core/diagnostics/devsper-depends.lua @@ -0,0 +1,92 @@ +local files = require 'files' +local guide = require 'parser.guide' +local await = require 'await' + +-- Returns the value node of a named field in a table AST node, or nil. +local function tableGet(tblNode, key) + if not tblNode or tblNode.type ~= 'table' then return nil end + for i = 1, #tblNode do + local child = tblNode[i] + if child and child.type == 'tablefield' and child.field and child.field[1] == key then + return child.value + end + end + return nil +end + +-- Returns true if `node` is a getfield call like `.methodName(...)` +local function isLocalMethod(source, receiverName, methodName) + if not source or source.type ~= 'call' then return false end + local callee = source.node + if not callee or callee.type ~= 'getfield' then return false end + if not callee.field or callee.field[1] ~= methodName then return false end + local receiver = callee.node + if not receiver or receiver.type ~= 'getlocal' then return false end + return receiver[1] == receiverName +end + +return function (uri, callback) + -- Only run on .devsper files + if not uri:match('%.devsper$') then return end + + local state = files.getState(uri) + if not state then return end + + -- Pass 1: collect all declared task IDs from wf.task(id, spec) calls + local declaredIds = {} + + guide.eachSourceType(state.ast, 'call', function (source) + await.delay() + if not isLocalMethod(source, 'wf', 'task') then return end + local args = source.args + if not args then return end + local idNode = args[1] + if not idNode or idNode.type ~= 'string' then return end + local id = idNode[1] + if id then + declaredIds[id] = { start = idNode.start, finish = idNode.finish } + end + end) + + -- Build sorted ID list once for error messages + local sortedIds = {} + for k in pairs(declaredIds) do + sortedIds[#sortedIds + 1] = k + end + table.sort(sortedIds) + local declaredIdsStr = table.concat(sortedIds, ', ') + + -- Pass 2: check depends_on entries in each wf.task spec + guide.eachSourceType(state.ast, 'call', function (source) + await.delay() + if not isLocalMethod(source, 'wf', 'task') then return end + local args = source.args + if not args then return end + local spec = args[2] + if not spec or spec.type ~= 'table' then return end + + local dependsOn = tableGet(spec, 'depends_on') + if not dependsOn or dependsOn.type ~= 'table' then return end + + -- Array table elements are wrapped in tableexp nodes in the luals AST + for i = 1, #dependsOn do + local exp = dependsOn[i] + if exp and exp.type == 'tableexp' then + local child = exp.value + if child and child.type == 'string' then + local val = child[1] + if val and val ~= '*' and not declaredIds[val] then + callback { + start = child.start, + finish = child.finish, + message = ("devsper: unknown task id '%s' in depends_on — declared ids: %s"):format( + val, declaredIdsStr + ), + } + end + end + end + -- Non-string/non-literal children silently skipped + end + end) +end diff --git a/script/core/diagnostics/devsper-model.lua b/script/core/diagnostics/devsper-model.lua new file mode 100644 index 000000000..ef1b0c3bd --- /dev/null +++ b/script/core/diagnostics/devsper-model.lua @@ -0,0 +1,104 @@ +local files = require 'files' +local guide = require 'parser.guide' +local await = require 'await' + +-- Returns the value node of a named field in a table AST node, or nil. +local function tableGet(tblNode, key) + if not tblNode or tblNode.type ~= 'table' then return nil end + for i = 1, #tblNode do + local child = tblNode[i] + if child and child.type == 'tablefield' and child.field and child.field[1] == key then + return child.value + end + end + return nil +end + +-- Returns true if `node` is a getfield call like `.methodName(...)` +local function isLocalMethod(source, receiverName, methodName) + if not source or source.type ~= 'call' then return false end + local callee = source.node + if not callee or callee.type ~= 'getfield' then return false end + if not callee.field or callee.field[1] ~= methodName then return false end + local receiver = callee.node + if not receiver or receiver.type ~= 'getlocal' then return false end + return receiver[1] == receiverName +end + +-- Returns true if `callNode` is `devsper.(...)` +local function isDevsperGlobalMethod(callNode, methodName) + local callee = callNode.node + if not callee then return false end + if callee.type ~= 'getfield' then return false end + if not callee.field then return false end + if callee.field[1] ~= methodName then return false end + local receiver = callee.node + if not receiver then return false end + if receiver.type ~= 'getglobal' then return false end + return receiver[1] == 'devsper' +end + +local KNOWN_PREFIXES = { + '^claude%-', + '^gpt%-', + '^o%d', -- o1, o3, etc. + '^gemini%-', + '^llama', + '^mistral', + '^phi', + '^qwen', + '^deepseek', + '^gemma', + '^falcon', +} + +local KNOWN_EXACT = { auto = true } + +local function isKnownModel(modelStr) + if KNOWN_EXACT[modelStr] then return true end + for _, pattern in ipairs(KNOWN_PREFIXES) do + if modelStr:match(pattern) then return true end + end + return false +end + +local function checkModelValue(modelNode, callback) + if not modelNode then return end + if modelNode.type ~= 'string' then return end -- skip non-literal values + local modelStr = modelNode[1] + if not modelStr then return end + if not isKnownModel(modelStr) then + callback { + start = modelNode.start, + finish = modelNode.finish, + message = ("devsper: unrecognized model '%s' — verify this provider is configured"):format(modelStr), + } + end +end + +return function (uri, callback) + -- Only run on .devsper files + if not uri:match('%.devsper$') then return end + + local state = files.getState(uri) + if not state then return end + + guide.eachSourceType(state.ast, 'call', function (source) + await.delay() + + -- devsper.workflow(config) — check config.model + if isDevsperGlobalMethod(source, 'workflow') then + local config = source.args and source.args[1] + if config and config.type == 'table' then + checkModelValue(tableGet(config, 'model'), callback) + end + + -- wf.task(id, spec) — check spec.model + elseif isLocalMethod(source, 'wf', 'task') then + local spec = source.args and source.args[2] + if spec and spec.type == 'table' then + checkModelValue(tableGet(spec, 'model'), callback) + end + end + end) +end diff --git a/script/core/diagnostics/devsper-schema.lua b/script/core/diagnostics/devsper-schema.lua new file mode 100644 index 000000000..950feff5e --- /dev/null +++ b/script/core/diagnostics/devsper-schema.lua @@ -0,0 +1,142 @@ +local files = require 'files' +local guide = require 'parser.guide' +local await = require 'await' + +-- Returns the value node of a named field in a table AST node, or nil. +local function tableGet(tblNode, key) + if not tblNode or tblNode.type ~= 'table' then return nil end + for i = 1, #tblNode do + local child = tblNode[i] + if child and child.type == 'tablefield' and child.field and child.field[1] == key then + return child.value + end + end + return nil +end + +-- Returns true if `node` is a getfield call like `.methodName(...)` +local function isLocalMethod(source, receiverName, methodName) + if not source or source.type ~= 'call' then return false end + local callee = source.node + if not callee or callee.type ~= 'getfield' then return false end + if not callee.field or callee.field[1] ~= methodName then return false end + local receiver = callee.node + if not receiver or receiver.type ~= 'getlocal' then return false end + return receiver[1] == receiverName +end + +-- Returns true if `callNode` is `devsper.(...)` +local function isDevsperGlobalMethod(callNode, methodName) + local callee = callNode.node + if not callee then return false end + if callee.type ~= 'getfield' then return false end + if not callee.field then return false end + if callee.field[1] ~= methodName then return false end + local receiver = callee.node + if not receiver then return false end + if receiver.type ~= 'getglobal' then return false end + return receiver[1] == 'devsper' +end + +local validBuiltins = { + git = true, + search = true, + http = true, + code = true, + filesystem = true, +} + +return function (uri, callback) + -- Only run on .devsper files + if not uri:match('%.devsper$') then return end + + local state = files.getState(uri) + if not state then return end + + guide.eachSourceType(state.ast, 'call', function (source) + await.delay() + local args = source.args + if not args then return end + + -- wf.task(id, spec) — spec must have a `prompt` key + if isLocalMethod(source, 'wf', 'task') then + local spec = source.args and source.args[2] + if not spec or spec.type == 'nil' then + callback { + start = source.start, + finish = source.finish, + message = "devsper: wf.task() requires a spec table as second argument", + } + return -- no further checks possible + end + if spec.type ~= 'table' then return end -- non-literal, skip + if not tableGet(spec, 'prompt') then + callback { + start = spec.start, + finish = spec.finish, + message = 'devsper: wf.task() spec is missing required field "prompt"', + } + end + + -- wf.plugin(name, spec) — spec must have a `source` key; builtin: prefix validated + elseif isLocalMethod(source, 'wf', 'plugin') then + local spec = source.args and source.args[2] + if not spec or spec.type == 'nil' then + callback { + start = source.start, + finish = source.finish, + message = "devsper: wf.plugin() requires a spec table as second argument", + } + return -- no further checks possible + end + if spec.type ~= 'table' then return end -- non-literal, skip + local sourceVal = tableGet(spec, 'source') + if not sourceVal then + callback { + start = spec.start, + finish = spec.finish, + message = 'devsper: wf.plugin() spec is missing required field "source"', + } + elseif sourceVal.type == 'string' then + local strVal = sourceVal[1] + if strVal then + local builtinName = strVal:match('^builtin:(.+)$') + if builtinName and not validBuiltins[builtinName] then + callback { + start = sourceVal.start, + finish = sourceVal.finish, + message = ('devsper: unknown builtin plugin %q (valid: git, search, http, code, filesystem)'):format(builtinName), + } + end + end + end + + -- devsper.tool(name, spec) — spec must have `description` and `run` keys + elseif isDevsperGlobalMethod(source, 'tool') then + local spec = source.args and source.args[2] + if not spec or spec.type == 'nil' then + callback { + start = source.start, + finish = source.finish, + message = "devsper: devsper.tool() requires a spec table as second argument", + } + return -- no further checks possible + end + if spec.type ~= 'table' then return end -- non-literal, skip + if not tableGet(spec, 'description') then + callback { + start = spec.start, + finish = spec.finish, + message = 'devsper: devsper.tool() spec is missing required field "description"', + } + end + if not tableGet(spec, 'run') then + callback { + start = spec.start, + finish = spec.finish, + message = 'devsper: devsper.tool() spec is missing required field "run"', + } + end + end + end) +end diff --git a/script/proto/diagnostic.lua b/script/proto/diagnostic.lua index 72761493e..b743992bb 100644 --- a/script/proto/diagnostic.lua +++ b/script/proto/diagnostic.lua @@ -227,6 +227,30 @@ m.register { status = 'Any', } +m.register { + 'devsper-schema', +} { + group = 'devsper', + severity = 'Warning', + status = 'Any', +} + +m.register { + 'devsper-depends', +} { + group = 'devsper', + severity = 'Warning', + status = 'Any', +} + +m.register { + 'devsper-model', +} { + group = 'devsper', + severity = 'Hint', + status = 'Any', +} + ---@return table function m.getDefaultSeverity() local severity = {} diff --git a/tools/vscode/language-configuration.json b/tools/vscode/language-configuration.json new file mode 100644 index 000000000..d720f1655 --- /dev/null +++ b/tools/vscode/language-configuration.json @@ -0,0 +1,26 @@ +{ + "comments": { + "lineComment": "--", + "blockComment": ["--[[", "]]"] + }, + "brackets": [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "(", "close": ")" }, + { "open": "\"", "close": "\"" }, + { "open": "'", "close": "'" }, + { "open": "--[[", "close": "]]" } + ], + "surroundingPairs": [ + ["{", "}"], + ["[", "]"], + ["(", ")"], + ["\"", "\""], + ["'", "'"] + ] +} diff --git a/tools/vscode/package.json b/tools/vscode/package.json new file mode 100644 index 000000000..05fa77c8f --- /dev/null +++ b/tools/vscode/package.json @@ -0,0 +1,32 @@ +{ + "name": "devsper-lsp", + "displayName": "Devsper Language Support", + "description": "Autocomplete, hover docs, and diagnostics for .devsper workflow and tool files", + "version": "0.1.0", + "publisher": "devsper", + "engines": { "vscode": "^1.85.0" }, + "categories": ["Programming Languages", "Snippets"], + "contributes": { + "languages": [ + { + "id": "devsper", + "aliases": ["Devsper", "devsper"], + "extensions": [".devsper"], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "devsper", + "scopeName": "source.devsper", + "path": "./syntaxes/devsper.tmLanguage.json" + } + ], + "snippets": [ + { + "language": "devsper", + "path": "./snippets/devsper.json" + } + ] + } +} diff --git a/tools/vscode/snippets/devsper.json b/tools/vscode/snippets/devsper.json new file mode 100644 index 000000000..2fbc6eb25 --- /dev/null +++ b/tools/vscode/snippets/devsper.json @@ -0,0 +1,61 @@ +{ + "Workflow": { + "prefix": "workflow", + "body": [ + "local wf = devsper.workflow({", + "\tname = \"${1:my-workflow}\",", + "\tmodel = \"${2:auto}\",", + "\tworkers = ${3:4},", + "})", + "", + "wf.task(\"${4:task-1}\", {", + "\tprompt = \"${5:Describe what this task should do.}\",", + "})", + "", + "return wf" + ], + "description": "New devsper workflow" + }, + "Task": { + "prefix": "task", + "body": [ + "wf.task(\"${1:task-name}\", {", + "\tprompt = \"${2:Describe what this task should do.}\",", + "\tdepends_on = { ${3} },", + "})" + ], + "description": "Add a task to the workflow DAG" + }, + "Plugin": { + "prefix": "plugin", + "body": [ + "wf.plugin(\"${1:git}\", { source = \"builtin:${1:git}\" })" + ], + "description": "Load a builtin plugin (git, search, http, code, filesystem)" + }, + "Input": { + "prefix": "input", + "body": [ + "wf.input(\"${1:param_name}\", {", + "\ttype = \"${2|string,number,boolean,object|}\",", + "\trequired = ${3|true,false|},", + "})" + ], + "description": "Declare a typed workflow input" + }, + "Tool": { + "prefix": "tool", + "body": [ + "devsper.tool(\"${1:tool.name}\", {", + "\tdescription = \"${2:What this tool does.}\",", + "\tparams = {", + "\t\t${3:param} = { type = \"${4|string,number,boolean,object|}\", required = ${5|true,false|} },", + "\t},", + "\trun = function(ctx, args)", + "\t\t${6:-- implementation}", + "\tend,", + "})" + ], + "description": "Register a new devsper tool" + } +} diff --git a/tools/vscode/syntaxes/devsper.tmLanguage.json b/tools/vscode/syntaxes/devsper.tmLanguage.json new file mode 100644 index 000000000..ea95b1c1d --- /dev/null +++ b/tools/vscode/syntaxes/devsper.tmLanguage.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "Devsper", + "scopeName": "source.devsper", + "patterns": [ + { "include": "#devsper-keywords" }, + { "include": "source.lua" } + ], + "repository": { + "devsper-keywords": { + "patterns": [ + { + "comment": "devsper global and wf builder", + "match": "\\b(devsper|wf)\\b(?=\\.)", + "name": "support.class.devsper" + }, + { + "comment": "builtin: plugin name prefix", + "match": "\"builtin:[a-zA-Z0-9_-]+\"", + "name": "support.constant.devsper" + }, + { + "comment": "devsper method names", + "match": "\\.(workflow|tool|exec|log|http|get|post|task|plugin|input)\\b", + "name": "support.function.devsper" + } + ] + } + } +}