Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased
<!-- Add all new changes here. They will be moved under a version at release -->
* `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
Expand Down
14 changes: 14 additions & 0 deletions meta/3rd/devsper/.luarc.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
122 changes: 122 additions & 0 deletions meta/3rd/devsper/library/devsper.lua
Original file line number Diff line number Diff line change
@@ -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<string,string> Response headers keyed by header name.

---@class devsper.HttpLib
---Fetch a URL and return the response.
---@field get fun(url: string, opts?: { headers?: table<string,string>, timeout?: integer }): devsper.HttpResponse
---Perform an HTTP POST request.
---@field post fun(url: string, body: string|table, opts?: { headers?: table<string,string>, 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<string, devsper.ParamSpec>? 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
92 changes: 92 additions & 0 deletions script/core/diagnostics/devsper-depends.lua
Original file line number Diff line number Diff line change
@@ -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 `<receiverName>.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)
Comment on lines +38 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The diagnostic performs two full traversals of all function calls in the AST using guide.eachSourceType. This is inefficient. You can optimize this by performing a single traversal to collect both the declared IDs and the dependency lists that need validation, then performing the validation in a separate loop over the collected data.


-- 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)
Comment on lines +59 to +91
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This section contains two significant issues:

  1. Correctness Bug: The current implementation iterates over the depends_on table and expects string nodes as direct children. However, in the Lua Language Server AST, array elements in a table are wrapped in tableexp nodes. Consequently, child.type == 'string' will always be false, and the diagnostic will never trigger for unknown task IDs.
  2. Performance Issue: The sortedIds list is being rebuilt and sorted inside the eachSourceType callback for every function call found in the AST. This is inefficient and should be computed once before starting the second pass.
    -- Pass 2: check depends_on entries in each wf.task spec
    local sortedIds = {}
    for k in pairs(declaredIds) do
        sortedIds[#sortedIds + 1] = k
    end
    table.sort(sortedIds)
    local declaredIdsStr = table.concat(sortedIds, ', ')

    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

        -- Iterate direct children of the depends_on array table
        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 children (variables, function calls) are silently skipped
        end
    end)

end
104 changes: 104 additions & 0 deletions script/core/diagnostics/devsper-model.lua
Original file line number Diff line number Diff line change
@@ -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 `<receiverName>.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.<methodName>(...)`
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
Loading
Loading