@dot-agent/language-server 0.4.1 → 0.5.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +27 -8
- package/features/completions.js +3 -7
- package/features/definition.js +9 -13
- package/features/diagnostics.js +83 -179
- package/features/formatting.js +1 -5
- package/features/hover.js +2 -6
- package/features/links.js +6 -10
- package/features/references.js +2 -6
- package/features/rename.js +2 -6
- package/features/symbols.js +2 -6
- package/merge-graph.js +129 -0
- package/package.json +32 -8
- package/parser.js +23 -29
- package/server.js +45 -28
- package/tests/diagnostics.test.js +209 -0
- package/tests/formatting.test.js +87 -0
- package/tests/hover.test.js +87 -0
- package/tests/parser.test.js +215 -0
- package/tests/references.test.js +52 -0
- package/tests/rename.test.js +47 -0
- package/tests/symbols.test.js +68 -0
- package/.github/workflows/publish.yml +0 -28
- package/features/graph.js +0 -53
package/merge-graph.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Danilo Borges (https://github.com/daniloborges)
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Workspace-discovery for the merge graph. This is IDE/tooling concern (it
|
|
18
|
+
// scans a directory nobody explicitly pointed us at), not compiler concern
|
|
19
|
+
// (the compiler only resolves imports from a known entry point, in
|
|
20
|
+
// consolidate()) — but it still belongs in the *server*, not the VS Code
|
|
21
|
+
// extension: the extension is a thin stdio transport with no fs access of
|
|
22
|
+
// its own (see apps/vscode-extension/extension.js), and the server already
|
|
23
|
+
// reads sibling files directly off disk for `agent/behaviorGraph`. That's
|
|
24
|
+
// the standard LSP shape — tsserver/rust-analyzer scan their own project
|
|
25
|
+
// tree server-side too, bounded by `workspaceFolders` from `initialize`.
|
|
26
|
+
|
|
27
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
28
|
+
import { join, dirname, resolve, relative, normalize, isAbsolute } from 'node:path';
|
|
29
|
+
import { parseBehaviorFile, initBehaviorParser } from '@dot-agent/compiler';
|
|
30
|
+
|
|
31
|
+
let workspaceRoots = [];
|
|
32
|
+
|
|
33
|
+
/** Called once from onInitialize with the client's workspaceFolders/rootUri, as absolute fs paths. */
|
|
34
|
+
export function setWorkspaceRoots(roots) {
|
|
35
|
+
workspaceRoots = roots.map(r => normalize(resolve(r)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Safety bound for the upward walk when no workspace boundary is known
|
|
39
|
+
// (e.g. a single file opened outside any workspace folder).
|
|
40
|
+
const MAX_UPWARD_HOPS = 8;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Finds the agent root for `startDir`: the nearest ancestor directory
|
|
44
|
+
* containing a `*.description` file, mirroring the convention pack.ts's
|
|
45
|
+
* discoverDescriptionFile() uses to define an agent bundle. The walk never
|
|
46
|
+
* goes above a known workspace folder. Returns null if no manifest is found
|
|
47
|
+
* within bounds.
|
|
48
|
+
*/
|
|
49
|
+
export async function findAgentRoot(startDir) {
|
|
50
|
+
let dir = normalize(resolve(startDir));
|
|
51
|
+
const boundary = workspaceRoots.find(root => !relative(root, dir).startsWith('..')) ?? null;
|
|
52
|
+
|
|
53
|
+
for (let hops = 0; hops < MAX_UPWARD_HOPS; hops++) {
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = await readdir(dir);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (entries.some(f => f.endsWith('.description'))) return dir;
|
|
61
|
+
if (boundary && dir === boundary) return null;
|
|
62
|
+
const parent = dirname(dir);
|
|
63
|
+
if (parent === dir) return null; // filesystem root
|
|
64
|
+
dir = parent;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function collectBehaviorFiles(root, subdir = '', depth = 0, maxDepth = 6, out = []) {
|
|
70
|
+
if (depth > maxDepth) return out;
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = await readdir(join(root, subdir), { withFileTypes: true });
|
|
74
|
+
} catch {
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
79
|
+
const rel = subdir ? `${subdir}/${entry.name}` : entry.name;
|
|
80
|
+
if (entry.isDirectory()) {
|
|
81
|
+
await collectBehaviorFiles(root, rel, depth + 1, maxDepth, out);
|
|
82
|
+
} else if (entry.name.endsWith('.behavior')) {
|
|
83
|
+
out.push(rel);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Walks merge edges backward from `currentFile` (path relative to
|
|
91
|
+
* `agentRoot`) to find the root of its merge component. Returns null if no
|
|
92
|
+
* `.behavior` file under `agentRoot` merges `currentFile` (directly or
|
|
93
|
+
* transitively).
|
|
94
|
+
*/
|
|
95
|
+
export async function findMergeRoot(agentRoot, currentFile) {
|
|
96
|
+
// parseBehaviorFile() below calls straight into the WASM behavior-parser;
|
|
97
|
+
// unlike lintBehavior()/consolidate(), this function can run before
|
|
98
|
+
// anything else has triggered that init, so it must guard it itself.
|
|
99
|
+
await initBehaviorParser();
|
|
100
|
+
const normRoot = normalize(resolve(agentRoot));
|
|
101
|
+
const behaviorFiles = await collectBehaviorFiles(normRoot);
|
|
102
|
+
|
|
103
|
+
const parentOf = new Map();
|
|
104
|
+
for (const relEntry of behaviorFiles) {
|
|
105
|
+
const entryAbs = join(normRoot, relEntry);
|
|
106
|
+
let fileText;
|
|
107
|
+
try {
|
|
108
|
+
fileText = await readFile(entryAbs, 'utf-8');
|
|
109
|
+
} catch {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const merges = parseBehaviorFile(fileText).ok?.merges ?? [];
|
|
113
|
+
for (const mergePath of merges) {
|
|
114
|
+
if (isAbsolute(mergePath)) continue;
|
|
115
|
+
const mergeRel = relative(normRoot, resolve(dirname(entryAbs), mergePath));
|
|
116
|
+
if (mergeRel.startsWith('..')) continue; // merge escapes the agent root — not this lookup's concern
|
|
117
|
+
if (!parentOf.has(mergeRel)) parentOf.set(mergeRel, relEntry);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let node = currentFile;
|
|
122
|
+
const seen = new Set([node]);
|
|
123
|
+
while (parentOf.has(node)) {
|
|
124
|
+
node = parentOf.get(node);
|
|
125
|
+
if (seen.has(node)) return null; // circular merge — let consolidate() report E013
|
|
126
|
+
seen.add(node);
|
|
127
|
+
}
|
|
128
|
+
return node === currentFile ? null : node;
|
|
129
|
+
}
|
package/package.json
CHANGED
|
@@ -1,37 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dot-agent/language-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-alpha.2",
|
|
4
4
|
"description": "LSP server for .agent DSL files",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
-
"author":
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Danilo Borges",
|
|
8
|
+
"email": "contato@daniloborg.es",
|
|
9
|
+
"url": "https://daniloborg.es"
|
|
10
|
+
},
|
|
11
|
+
"contributors": [
|
|
12
|
+
{
|
|
13
|
+
"name": "Danilo Borges",
|
|
14
|
+
"email": "contato@daniloborg.es",
|
|
15
|
+
"url": "https://daniloborg.es"
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://dot-agent.ai",
|
|
7
19
|
"repository": {
|
|
8
20
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/dot-agent-spec/
|
|
21
|
+
"url": "https://github.com/dot-agent-spec/platform",
|
|
22
|
+
"directory": "packages/language-server"
|
|
10
23
|
},
|
|
11
|
-
"homepage": "https://github.com/dot-agent-spec/language-server#readme",
|
|
12
24
|
"bugs": {
|
|
13
|
-
"url": "https://github.com/dot-agent-spec/
|
|
25
|
+
"url": "https://github.com/dot-agent-spec/platform/issues",
|
|
26
|
+
"email": "contato@daniloborg.es"
|
|
14
27
|
},
|
|
15
28
|
"keywords": [
|
|
16
29
|
"lsp",
|
|
17
30
|
"language-server",
|
|
18
31
|
"agent",
|
|
32
|
+
"agents",
|
|
33
|
+
"llm",
|
|
19
34
|
"dsl",
|
|
20
|
-
"
|
|
35
|
+
"dot-agent"
|
|
21
36
|
],
|
|
37
|
+
"type": "module",
|
|
22
38
|
"main": "server.js",
|
|
23
39
|
"engines": {
|
|
24
40
|
"node": ">=18"
|
|
25
41
|
},
|
|
26
42
|
"scripts": {
|
|
27
|
-
"start": "node server.js --stdio"
|
|
43
|
+
"start": "node server.js --stdio",
|
|
44
|
+
"build": "echo 'language-server ships JS source directly, no build step needed'",
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"test:watch": "vitest"
|
|
28
47
|
},
|
|
29
48
|
"dependencies": {
|
|
30
|
-
"@dot-agent/
|
|
49
|
+
"@dot-agent/parser-dsl": "0.5.0-alpha.1",
|
|
50
|
+
"@dot-agent/compiler": "0.5.0-alpha.2",
|
|
51
|
+
"@dot-agent/tree-sitter": "0.5.0-alpha.1",
|
|
31
52
|
"vscode-languageserver": "^10.0.0",
|
|
32
53
|
"vscode-languageserver-textdocument": "^1.0.12",
|
|
33
54
|
"web-tree-sitter": "^0.26.9"
|
|
34
55
|
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"vitest": "^1.6.0"
|
|
58
|
+
},
|
|
35
59
|
"publishConfig": {
|
|
36
60
|
"access": "public"
|
|
37
61
|
}
|
package/parser.js
CHANGED
|
@@ -14,19 +14,20 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
import { resolve } from 'path';
|
|
18
|
+
import { createRequire } from 'module';
|
|
19
|
+
import { Parser, Language } from 'web-tree-sitter';
|
|
18
20
|
|
|
19
|
-
const
|
|
20
|
-
const { Parser, Language } = require('web-tree-sitter');
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
21
22
|
const grammar = require('@dot-agent/tree-sitter');
|
|
22
23
|
|
|
23
24
|
// path.resolve ensures absolute paths survive vsix packaging and cwd changes
|
|
24
|
-
const DESCRIPTION_WASM =
|
|
25
|
-
const BEHAVIOR_WASM =
|
|
25
|
+
const DESCRIPTION_WASM = resolve(grammar.descriptionWasmPath);
|
|
26
|
+
const BEHAVIOR_WASM = resolve(grammar.behaviorWasmPath);
|
|
26
27
|
|
|
27
28
|
let descriptionParser, behaviorParser;
|
|
28
29
|
|
|
29
|
-
async function initParsers() {
|
|
30
|
+
export async function initParsers() {
|
|
30
31
|
await Parser.init();
|
|
31
32
|
const DescriptionLang = await Language.load(DESCRIPTION_WASM);
|
|
32
33
|
const Behavior = await Language.load(BEHAVIOR_WASM);
|
|
@@ -39,21 +40,27 @@ async function initParsers() {
|
|
|
39
40
|
// Cache: uri → { version, tree }
|
|
40
41
|
const cache = new Map();
|
|
41
42
|
|
|
42
|
-
function parse(uri, langId, text, version) {
|
|
43
|
+
export function parse(uri, langId, text, version) {
|
|
43
44
|
const prev = cache.get(uri);
|
|
44
45
|
if (prev?.version === version) return prev.tree;
|
|
45
46
|
const parser = langId === 'behavior' ? behaviorParser : descriptionParser;
|
|
46
47
|
if (!parser) return null;
|
|
47
|
-
|
|
48
|
+
// Full reparse: reusing prev.tree here would be tree-sitter's incremental
|
|
49
|
+
// mode, which requires calling tree.edit() with the exact change range
|
|
50
|
+
// beforehand. We don't have that range (TextDocuments only hands us the
|
|
51
|
+
// post-change text), so passing the stale tree as a hint corrupts node
|
|
52
|
+
// byte ranges — .text on a shifted node silently returns garbled or
|
|
53
|
+
// empty strings (surfaces as "name must not be falsy" in documentSymbol).
|
|
54
|
+
const tree = parser.parse(text);
|
|
48
55
|
cache.set(uri, { version, tree });
|
|
49
56
|
return tree;
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
function evict(uri) {
|
|
59
|
+
export function evict(uri) {
|
|
53
60
|
cache.delete(uri);
|
|
54
61
|
}
|
|
55
62
|
|
|
56
|
-
function parseText(langId, text) {
|
|
63
|
+
export function parseText(langId, text) {
|
|
57
64
|
const parser = langId === 'behavior' ? behaviorParser : descriptionParser;
|
|
58
65
|
if (!parser) return null;
|
|
59
66
|
return parser.parse(text);
|
|
@@ -61,18 +68,18 @@ function parseText(langId, text) {
|
|
|
61
68
|
|
|
62
69
|
// ── AST helpers ──────────────────────────────────────────────────────────────
|
|
63
70
|
|
|
64
|
-
function nodesOfType(tree, type) {
|
|
71
|
+
export function nodesOfType(tree, type) {
|
|
65
72
|
if (!tree) return [];
|
|
66
73
|
return tree.rootNode.descendantsOfType(type);
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
function nodeAtOffset(tree, offset) {
|
|
76
|
+
export function nodeAtOffset(tree, offset) {
|
|
70
77
|
if (!tree) return null;
|
|
71
78
|
return tree.rootNode.descendantForIndex(offset);
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
// Convert a tree-sitter {row, column} position to an LSP Range
|
|
75
|
-
function nodeToRange(node) {
|
|
82
|
+
export function nodeToRange(node) {
|
|
76
83
|
return {
|
|
77
84
|
start: { line: node.startPosition.row, character: node.startPosition.column },
|
|
78
85
|
end: { line: node.endPosition.row, character: node.endPosition.column },
|
|
@@ -80,7 +87,7 @@ function nodeToRange(node) {
|
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
// Convert LSP position (line, character) to a byte offset in text
|
|
83
|
-
function positionToOffset(text, line, character) {
|
|
90
|
+
export function positionToOffset(text, line, character) {
|
|
84
91
|
let offset = 0;
|
|
85
92
|
for (let i = 0; i < line; i++) {
|
|
86
93
|
const nl = text.indexOf('\n', offset);
|
|
@@ -90,7 +97,7 @@ function positionToOffset(text, line, character) {
|
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
// Extract the word (identifier chars including dots) around position
|
|
93
|
-
function wordAtPosition(text, line, character) {
|
|
100
|
+
export function wordAtPosition(text, line, character) {
|
|
94
101
|
const lines = text.split('\n');
|
|
95
102
|
const lineText = lines[line] || '';
|
|
96
103
|
let start = character, end = character;
|
|
@@ -100,7 +107,7 @@ function wordAtPosition(text, line, character) {
|
|
|
100
107
|
}
|
|
101
108
|
|
|
102
109
|
// Walk up the tree from offset, skipping ERROR/MISSING nodes
|
|
103
|
-
function getContextNode(tree, offset) {
|
|
110
|
+
export function getContextNode(tree, offset) {
|
|
104
111
|
let node = nodeAtOffset(tree, offset);
|
|
105
112
|
while (node && (node.isError || node.isMissing)) {
|
|
106
113
|
node = node.parent;
|
|
@@ -118,16 +125,3 @@ function getContextNode(tree, offset) {
|
|
|
118
125
|
}
|
|
119
126
|
return node ?? tree?.rootNode;
|
|
120
127
|
}
|
|
121
|
-
|
|
122
|
-
module.exports = {
|
|
123
|
-
initParsers,
|
|
124
|
-
parse,
|
|
125
|
-
parseText,
|
|
126
|
-
evict,
|
|
127
|
-
nodesOfType,
|
|
128
|
-
nodeAtOffset,
|
|
129
|
-
nodeToRange,
|
|
130
|
-
positionToOffset,
|
|
131
|
-
wordAtPosition,
|
|
132
|
-
getContextNode,
|
|
133
|
-
};
|
package/server.js
CHANGED
|
@@ -14,39 +14,48 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const {
|
|
17
|
+
import {
|
|
20
18
|
createConnection,
|
|
21
19
|
TextDocuments,
|
|
22
20
|
ProposedFeatures,
|
|
23
21
|
TextDocumentSyncKind,
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
22
|
+
} from 'vscode-languageserver/node';
|
|
23
|
+
import { TextDocument } from 'vscode-languageserver-textdocument';
|
|
24
|
+
import { init as bpInit, get_graph } from '@dot-agent/parser-dsl';
|
|
25
|
+
import { consolidate } from '@dot-agent/compiler';
|
|
26
|
+
import { fileURLToPath } from 'url';
|
|
27
|
+
import { dirname, relative } from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { initParsers, parse, evict, nodesOfType } from './parser.js';
|
|
30
|
+
import { setWorkspaceRoots, findAgentRoot } from './merge-graph.js';
|
|
31
|
+
|
|
32
|
+
import { provideHover } from './features/hover.js';
|
|
33
|
+
import { provideCompletions } from './features/completions.js';
|
|
34
|
+
import { diagnose } from './features/diagnostics.js';
|
|
35
|
+
import { provideDocumentSymbols } from './features/symbols.js';
|
|
36
|
+
import { provideDefinition } from './features/definition.js';
|
|
37
|
+
import { provideReferences } from './features/references.js';
|
|
38
|
+
import { provideRenameEdits } from './features/rename.js';
|
|
39
|
+
import { format } from './features/formatting.js';
|
|
40
|
+
import { provideDocumentLinks } from './features/links.js';
|
|
40
41
|
|
|
41
42
|
const connection = createConnection(ProposedFeatures.all);
|
|
42
43
|
const documents = new TextDocuments(TextDocument);
|
|
43
44
|
|
|
44
45
|
// ── Initialization ───────────────────────────────────────────────────────────
|
|
45
46
|
|
|
46
|
-
// web-tree-sitter
|
|
47
|
-
// so no feature handler fires before parsers are ready.
|
|
48
|
-
connection.onInitialize(async () => {
|
|
47
|
+
// web-tree-sitter and behavior-parser are strictly async — await before
|
|
48
|
+
// advertising capabilities so no feature handler fires before parsers are ready.
|
|
49
|
+
connection.onInitialize(async (params) => {
|
|
49
50
|
await initParsers();
|
|
51
|
+
await bpInit();
|
|
52
|
+
const roots = (params.workspaceFolders ?? [])
|
|
53
|
+
.map(f => { try { return fileURLToPath(f.uri); } catch { return null; } })
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
if (roots.length === 0 && params.rootUri) {
|
|
56
|
+
try { roots.push(fileURLToPath(params.rootUri)); } catch { /* non-file root, ignore */ }
|
|
57
|
+
}
|
|
58
|
+
setWorkspaceRoots(roots);
|
|
50
59
|
return {
|
|
51
60
|
capabilities: {
|
|
52
61
|
textDocumentSync: TextDocumentSyncKind.Incremental,
|
|
@@ -70,11 +79,10 @@ function getTree(doc) {
|
|
|
70
79
|
|
|
71
80
|
// ── Diagnostics (push on change) ─────────────────────────────────────────────
|
|
72
81
|
|
|
73
|
-
function validate(doc) {
|
|
82
|
+
async function validate(doc) {
|
|
74
83
|
const langId = doc.languageId;
|
|
75
84
|
if (langId !== 'description' && langId !== 'behavior') return;
|
|
76
|
-
const
|
|
77
|
-
const diagnostics = diagnose(langId, tree, doc.getText(), doc.uri);
|
|
85
|
+
const diagnostics = await diagnose(doc.uri, langId, doc.getText());
|
|
78
86
|
connection.sendDiagnostics({ uri: doc.uri, diagnostics });
|
|
79
87
|
}
|
|
80
88
|
|
|
@@ -166,11 +174,20 @@ connection.onDocumentFormatting(({ textDocument }) => {
|
|
|
166
174
|
|
|
167
175
|
// ── Behavior Graph (custom request) ──────────────────────────────────────────
|
|
168
176
|
|
|
169
|
-
connection.onRequest('agent/behaviorGraph', ({ uri }) => {
|
|
177
|
+
connection.onRequest('agent/behaviorGraph', async ({ uri }) => {
|
|
170
178
|
const doc = documents.get(uri);
|
|
171
179
|
if (!doc || doc.languageId !== 'behavior') return null;
|
|
172
|
-
const
|
|
173
|
-
|
|
180
|
+
const text = doc.getText();
|
|
181
|
+
if (/^\s*merge\s+/m.test(text)) {
|
|
182
|
+
try {
|
|
183
|
+
let filePath;
|
|
184
|
+
try { filePath = fileURLToPath(uri); } catch { filePath = uri; }
|
|
185
|
+
const agentRoot = (await findAgentRoot(dirname(filePath))) ?? dirname(filePath);
|
|
186
|
+
const { mergedText } = await consolidate(agentRoot, relative(agentRoot, filePath));
|
|
187
|
+
return get_graph(mergedText);
|
|
188
|
+
} catch { /* fallback to single-file graph */ }
|
|
189
|
+
}
|
|
190
|
+
return get_graph(text);
|
|
174
191
|
});
|
|
175
192
|
|
|
176
193
|
// ── Current State at Position (custom request) ────────────────────────────────
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Copyright (c) 2026 Danilo Borges (https://github.com/daniloborges)
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
10
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { pathToFileURL } from 'node:url'
|
|
14
|
+
import { diagnose } from '../features/diagnostics.js'
|
|
15
|
+
import { setWorkspaceRoots } from '../merge-graph.js'
|
|
16
|
+
|
|
17
|
+
// file:// URIs so fileURLToPath doesn't throw
|
|
18
|
+
const BEHAVIOR_URI = 'file:///test.behavior'
|
|
19
|
+
const DESC_URI = 'file:///test.description'
|
|
20
|
+
|
|
21
|
+
// ── behavior ──────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
// Valid oriented state: goal + interact + on intent (block) + on offtopic (block)
|
|
24
|
+
const VALID_BEHAVIOR = `\
|
|
25
|
+
state init
|
|
26
|
+
transition to responsive
|
|
27
|
+
|
|
28
|
+
state responsive
|
|
29
|
+
goal "How can I help?"
|
|
30
|
+
interact
|
|
31
|
+
on intent "go"
|
|
32
|
+
transition to init
|
|
33
|
+
on offtopic
|
|
34
|
+
transition to responsive
|
|
35
|
+
`
|
|
36
|
+
|
|
37
|
+
describe('diagnose — behavior — valid', () => {
|
|
38
|
+
it('returns no errors for well-formed behavior', async () => {
|
|
39
|
+
const diags = await diagnose(BEHAVIOR_URI, 'behavior', VALID_BEHAVIOR)
|
|
40
|
+
const errors = diags.filter(d => d.severity === 1) // DiagnosticSeverity.Error
|
|
41
|
+
expect(errors).toHaveLength(0)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('diagnose — behavior — E005 dangling transition', () => {
|
|
46
|
+
it('reports error for transition to undefined state', async () => {
|
|
47
|
+
const text = 'state init\n transition to ghost\n'
|
|
48
|
+
const diags = await diagnose(BEHAVIOR_URI, 'behavior', text)
|
|
49
|
+
const e005 = diags.filter(d => d.message.includes('E005'))
|
|
50
|
+
expect(e005.length).toBeGreaterThan(0)
|
|
51
|
+
expect(e005[0].severity).toBe(1) // Error
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('diagnose — behavior — W002 long text', () => {
|
|
56
|
+
it('warns when goal text exceeds 280 characters', async () => {
|
|
57
|
+
const long = 'a'.repeat(281)
|
|
58
|
+
const text = `state init\n goal "${long}"\n transition to init\n`
|
|
59
|
+
const diags = await diagnose(BEHAVIOR_URI, 'behavior', text)
|
|
60
|
+
const w002 = diags.filter(d => d.message.includes('W002'))
|
|
61
|
+
expect(w002.length).toBeGreaterThan(0)
|
|
62
|
+
expect(w002[0].severity).toBe(2) // Warning
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// ── description ───────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
const VALID_DESCRIPTION = `\
|
|
69
|
+
agent Doctor
|
|
70
|
+
domain health.example.com
|
|
71
|
+
license MIT
|
|
72
|
+
|
|
73
|
+
description
|
|
74
|
+
Clinical diagnostic agent.
|
|
75
|
+
|
|
76
|
+
capabilities
|
|
77
|
+
DiagnoseAction "Emit a structured diagnosis"
|
|
78
|
+
|
|
79
|
+
input Patient
|
|
80
|
+
output Prescription
|
|
81
|
+
`
|
|
82
|
+
|
|
83
|
+
describe('diagnose — description — valid', () => {
|
|
84
|
+
it('returns no errors for well-formed description', async () => {
|
|
85
|
+
const diags = await diagnose(DESC_URI, 'description', VALID_DESCRIPTION)
|
|
86
|
+
const errors = diags.filter(d => d.severity === 1)
|
|
87
|
+
expect(errors).toHaveLength(0)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('diagnose — description — W003 default domain', () => {
|
|
92
|
+
it('warns when domain is still "example.com"', async () => {
|
|
93
|
+
const text = VALID_DESCRIPTION.replace('health.example.com', 'example.com')
|
|
94
|
+
const diags = await diagnose(DESC_URI, 'description', text)
|
|
95
|
+
const w003 = diags.filter(d => d.message.includes('W003'))
|
|
96
|
+
expect(w003.length).toBeGreaterThan(0)
|
|
97
|
+
expect(w003[0].severity).toBe(2) // Warning
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('diagnose — behavior — E016 missing init state', () => {
|
|
102
|
+
it('reports error when no init state exists', async () => {
|
|
103
|
+
const text = 'state lobby\n goal "Welcome"\n interact\n'
|
|
104
|
+
const diags = await diagnose(BEHAVIOR_URI, 'behavior', text)
|
|
105
|
+
const e016 = diags.filter(d => d.message.includes('E016'))
|
|
106
|
+
expect(e016.length).toBeGreaterThan(0)
|
|
107
|
+
expect(e016[0].severity).toBe(1) // Error
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// ── unknown langId ────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
describe('diagnose — unknown langId', () => {
|
|
114
|
+
it('returns empty array', async () => {
|
|
115
|
+
const diags = await diagnose(BEHAVIOR_URI, 'unknown', 'anything')
|
|
116
|
+
expect(diags).toHaveLength(0)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// ── merge: fragment files & line-shift regressions ─────────────────────────────
|
|
121
|
+
//
|
|
122
|
+
// Reproduces the real-world bug: an agent with `main.behavior` (has `merge`)
|
|
123
|
+
// and a fragment file it merges in that has no `merge` of its own. These
|
|
124
|
+
// tests need real files on disk because findAgentRoot/findMergeRoot/
|
|
125
|
+
// consolidate all read the agent directory, unlike the in-memory cases above.
|
|
126
|
+
|
|
127
|
+
let tmpDir
|
|
128
|
+
|
|
129
|
+
async function makeMergedAgent(files) {
|
|
130
|
+
tmpDir = await mkdtemp(join(tmpdir(), 'language-server-diagnostics-test-'))
|
|
131
|
+
setWorkspaceRoots([tmpDir])
|
|
132
|
+
await writeFile(
|
|
133
|
+
join(tmpDir, 'agent.description'),
|
|
134
|
+
'agent Tour\n domain example.com\n license MIT\n\ndescription\n Tour agent.\n\nbehavior main.behavior\n'
|
|
135
|
+
)
|
|
136
|
+
for (const [name, content] of Object.entries(files)) {
|
|
137
|
+
await writeFile(join(tmpDir, name), content)
|
|
138
|
+
}
|
|
139
|
+
return tmpDir
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function uriFor(dir, name) {
|
|
143
|
+
return pathToFileURL(join(dir, name)).toString()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
afterEach(async () => {
|
|
147
|
+
setWorkspaceRoots([])
|
|
148
|
+
if (tmpDir) {
|
|
149
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
150
|
+
tmpDir = ''
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const MAIN_BEHAVIOR = `\
|
|
155
|
+
merge "fragment.behavior"
|
|
156
|
+
|
|
157
|
+
state init
|
|
158
|
+
transition to responsive
|
|
159
|
+
|
|
160
|
+
state responsive
|
|
161
|
+
goal "Welcome to the tour."
|
|
162
|
+
interact
|
|
163
|
+
on intent "start" transition to frag_state_a
|
|
164
|
+
on intent "start" transition to responsive
|
|
165
|
+
on offtopic transition to responsive
|
|
166
|
+
`
|
|
167
|
+
|
|
168
|
+
const FRAGMENT_BEHAVIOR = `\
|
|
169
|
+
state frag_state_a
|
|
170
|
+
goal "Fragment step."
|
|
171
|
+
interact
|
|
172
|
+
on intent "next" transition to responsive
|
|
173
|
+
on offtopic transition to responsive
|
|
174
|
+
`
|
|
175
|
+
|
|
176
|
+
describe('diagnose — merge — fragment file merged by another file', () => {
|
|
177
|
+
it('does not report E005 for a transition target defined in the parent file', async () => {
|
|
178
|
+
const dir = await makeMergedAgent({ 'main.behavior': MAIN_BEHAVIOR, 'fragment.behavior': FRAGMENT_BEHAVIOR })
|
|
179
|
+
const diags = await diagnose(uriFor(dir, 'fragment.behavior'), 'behavior', FRAGMENT_BEHAVIOR)
|
|
180
|
+
const e005 = diags.filter(d => d.message.includes('E005'))
|
|
181
|
+
expect(e005).toHaveLength(0)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('does not report E016 (missing init) on a fragment that has no init of its own', async () => {
|
|
185
|
+
const dir = await makeMergedAgent({ 'main.behavior': MAIN_BEHAVIOR, 'fragment.behavior': FRAGMENT_BEHAVIOR })
|
|
186
|
+
const diags = await diagnose(uriFor(dir, 'fragment.behavior'), 'behavior', FRAGMENT_BEHAVIOR)
|
|
187
|
+
const e016 = diags.filter(d => d.message.includes('E016'))
|
|
188
|
+
expect(e016).toHaveLength(0)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('still reports whole-tree E016 when neither the root nor the fragment declares init', async () => {
|
|
192
|
+
const noInitMain = MAIN_BEHAVIOR.replace('state init\n transition to responsive\n\n', '')
|
|
193
|
+
const dir = await makeMergedAgent({ 'main.behavior': noInitMain, 'fragment.behavior': FRAGMENT_BEHAVIOR })
|
|
194
|
+
const diags = await diagnose(uriFor(dir, 'main.behavior'), 'behavior', noInitMain)
|
|
195
|
+
const e016 = diags.filter(d => d.message.includes('E016'))
|
|
196
|
+
expect(e016.length).toBeGreaterThan(0)
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('diagnose — merge — local diagnostics keep this file\'s own line numbers', () => {
|
|
201
|
+
it('reports W008 (duplicate intent) at its real line in main.behavior, not shifted by the merged fragment', async () => {
|
|
202
|
+
const dir = await makeMergedAgent({ 'main.behavior': MAIN_BEHAVIOR, 'fragment.behavior': FRAGMENT_BEHAVIOR })
|
|
203
|
+
const diags = await diagnose(uriFor(dir, 'main.behavior'), 'behavior', MAIN_BEHAVIOR)
|
|
204
|
+
const w008 = diags.filter(d => d.message.includes('W008'))
|
|
205
|
+
expect(w008).toHaveLength(1)
|
|
206
|
+
// line 10 (1-indexed) is the duplicate `on intent "start"` inside `state responsive`
|
|
207
|
+
expect(w008[0].range.start.line).toBe(9)
|
|
208
|
+
})
|
|
209
|
+
})
|