@dot-agent/language-server 0.4.0 → 0.5.0-alpha.1

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/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.4.0",
3
+ "version": "0.5.0-alpha.1",
4
4
  "description": "LSP server for .agent DSL files",
5
5
  "license": "Apache-2.0",
6
- "author": "Danilo Borges <contato@daniloborg.es>",
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/language-server.git"
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/language-server/issues"
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
- "flow"
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/tree-sitter": "^0.3.4",
49
+ "@dot-agent/parser-dsl": "*",
50
+ "@dot-agent/compiler": "*",
51
+ "@dot-agent/tree-sitter": "*",
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
- 'use strict';
17
+ import { resolve } from 'path';
18
+ import { createRequire } from 'module';
19
+ import { Parser, Language } from 'web-tree-sitter';
18
20
 
19
- const path = require('path');
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 = path.resolve(grammar.agentWasmPath);
25
- const BEHAVIOR_WASM = path.resolve(grammar.behaviorWasmPath);
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,34 +40,46 @@ 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
- const tree = parser.parse(text, prev?.tree); // incremental reuse when possible
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
 
63
+ export function parseText(langId, text) {
64
+ const parser = langId === 'behavior' ? behaviorParser : descriptionParser;
65
+ if (!parser) return null;
66
+ return parser.parse(text);
67
+ }
68
+
56
69
  // ── AST helpers ──────────────────────────────────────────────────────────────
57
70
 
58
- function nodesOfType(tree, type) {
71
+ export function nodesOfType(tree, type) {
59
72
  if (!tree) return [];
60
73
  return tree.rootNode.descendantsOfType(type);
61
74
  }
62
75
 
63
- function nodeAtOffset(tree, offset) {
76
+ export function nodeAtOffset(tree, offset) {
64
77
  if (!tree) return null;
65
78
  return tree.rootNode.descendantForIndex(offset);
66
79
  }
67
80
 
68
81
  // Convert a tree-sitter {row, column} position to an LSP Range
69
- function nodeToRange(node) {
82
+ export function nodeToRange(node) {
70
83
  return {
71
84
  start: { line: node.startPosition.row, character: node.startPosition.column },
72
85
  end: { line: node.endPosition.row, character: node.endPosition.column },
@@ -74,7 +87,7 @@ function nodeToRange(node) {
74
87
  }
75
88
 
76
89
  // Convert LSP position (line, character) to a byte offset in text
77
- function positionToOffset(text, line, character) {
90
+ export function positionToOffset(text, line, character) {
78
91
  let offset = 0;
79
92
  for (let i = 0; i < line; i++) {
80
93
  const nl = text.indexOf('\n', offset);
@@ -84,7 +97,7 @@ function positionToOffset(text, line, character) {
84
97
  }
85
98
 
86
99
  // Extract the word (identifier chars including dots) around position
87
- function wordAtPosition(text, line, character) {
100
+ export function wordAtPosition(text, line, character) {
88
101
  const lines = text.split('\n');
89
102
  const lineText = lines[line] || '';
90
103
  let start = character, end = character;
@@ -94,7 +107,7 @@ function wordAtPosition(text, line, character) {
94
107
  }
95
108
 
96
109
  // Walk up the tree from offset, skipping ERROR/MISSING nodes
97
- function getContextNode(tree, offset) {
110
+ export function getContextNode(tree, offset) {
98
111
  let node = nodeAtOffset(tree, offset);
99
112
  while (node && (node.isError || node.isMissing)) {
100
113
  node = node.parent;
@@ -112,15 +125,3 @@ function getContextNode(tree, offset) {
112
125
  }
113
126
  return node ?? tree?.rootNode;
114
127
  }
115
-
116
- module.exports = {
117
- initParsers,
118
- parse,
119
- evict,
120
- nodesOfType,
121
- nodeAtOffset,
122
- nodeToRange,
123
- positionToOffset,
124
- wordAtPosition,
125
- getContextNode,
126
- };
package/server.js CHANGED
@@ -14,38 +14,48 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
- 'use strict';
18
-
19
- const {
17
+ import {
20
18
  createConnection,
21
19
  TextDocuments,
22
20
  ProposedFeatures,
23
21
  TextDocumentSyncKind,
24
- SymbolKind,
25
- } = require('vscode-languageserver/node');
26
- const { TextDocument } = require('vscode-languageserver-textdocument');
27
-
28
- const { initParsers, parse, evict } = require('./parser');
29
-
30
- const { provideHover } = require('./features/hover');
31
- const { provideCompletions } = require('./features/completions');
32
- const { diagnose } = require('./features/diagnostics');
33
- const { provideDocumentSymbols }= require('./features/symbols');
34
- const { provideDefinition } = require('./features/definition');
35
- const { provideReferences } = require('./features/references');
36
- const { provideRenameEdits } = require('./features/rename');
37
- const { format } = require('./features/formatting');
38
- const { provideDocumentLinks } = require('./features/links');
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';
39
41
 
40
42
  const connection = createConnection(ProposedFeatures.all);
41
43
  const documents = new TextDocuments(TextDocument);
42
44
 
43
45
  // ── Initialization ───────────────────────────────────────────────────────────
44
46
 
45
- // web-tree-sitter is strictly async — await before advertising capabilities
46
- // so no feature handler fires before parsers are ready.
47
- 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) => {
48
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);
49
59
  return {
50
60
  capabilities: {
51
61
  textDocumentSync: TextDocumentSyncKind.Incremental,
@@ -69,11 +79,10 @@ function getTree(doc) {
69
79
 
70
80
  // ── Diagnostics (push on change) ─────────────────────────────────────────────
71
81
 
72
- function validate(doc) {
82
+ async function validate(doc) {
73
83
  const langId = doc.languageId;
74
84
  if (langId !== 'description' && langId !== 'behavior') return;
75
- const tree = getTree(doc);
76
- const diagnostics = diagnose(langId, tree, doc.getText());
85
+ const diagnostics = await diagnose(doc.uri, langId, doc.getText());
77
86
  connection.sendDiagnostics({ uri: doc.uri, diagnostics });
78
87
  }
79
88
 
@@ -163,6 +172,42 @@ connection.onDocumentFormatting(({ textDocument }) => {
163
172
  return format(doc.languageId, doc.getText());
164
173
  });
165
174
 
175
+ // ── Behavior Graph (custom request) ──────────────────────────────────────────
176
+
177
+ connection.onRequest('agent/behaviorGraph', async ({ uri }) => {
178
+ const doc = documents.get(uri);
179
+ if (!doc || doc.languageId !== 'behavior') return null;
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);
191
+ });
192
+
193
+ // ── Current State at Position (custom request) ────────────────────────────────
194
+
195
+ connection.onRequest('agent/currentState', ({ uri, position }) => {
196
+ const doc = documents.get(uri);
197
+ if (!doc || doc.languageId !== 'behavior') return null;
198
+ const tree = getTree(doc);
199
+ if (!tree) return null;
200
+ const line = position.line;
201
+ let result = null;
202
+ for (const node of nodesOfType(tree, 'state_decl')) {
203
+ if (node.startPosition.row > line) break;
204
+ if (node.startPosition.row <= line && node.endPosition.row >= line) {
205
+ result = node.childForFieldName('name')?.text ?? null;
206
+ }
207
+ }
208
+ return result;
209
+ });
210
+
166
211
  // ── Start ────────────────────────────────────────────────────────────────────
167
212
 
168
213
  documents.listen(connection);
@@ -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
+ })