norn-cli 1.4.0 → 1.4.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/.norn-cache/swagger-body-intellisense.json +1 -1
- package/CHANGELOG.md +16 -0
- package/out/chatParticipant.js +722 -0
- package/out/cli.js +99 -36
- package/out/codeLensProvider.js +14 -20
- package/out/completionProvider.js +543 -25
- package/out/coverageCalculator.js +250 -169
- package/out/coveragePanel.js +7 -4
- package/out/diagnosticProvider.js +135 -2
- package/out/environmentProvider.js +96 -27
- package/out/extension.js +98 -9
- package/out/nornPrompt.js +580 -0
- package/out/swaggerBodyIntellisenseCache.js +147 -0
- package/out/swaggerParser.js +154 -74
- package/out/test/coverageCalculator.test.js +100 -0
- package/out/testProvider.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.registerChatParticipant = registerChatParticipant;
|
|
37
|
+
const vscode = __importStar(require("vscode"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const nornPrompt_1 = require("./nornPrompt");
|
|
40
|
+
const swaggerParser_1 = require("./swaggerParser");
|
|
41
|
+
const sequenceRunner_1 = require("./sequenceRunner");
|
|
42
|
+
const parser_1 = require("./parser");
|
|
43
|
+
const environmentProvider_1 = require("./environmentProvider");
|
|
44
|
+
const httpClient_1 = require("./httpClient");
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Constants
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
/** Maximum character budget for file contents sent as LLM context. */
|
|
49
|
+
const MAX_CONTEXT_CHARS = 30_000;
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Tool definitions — inline tools the LLM can call to edit/create files
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
const NORN_TOOLS = [
|
|
54
|
+
{
|
|
55
|
+
name: 'norn_applyEdit',
|
|
56
|
+
description: 'Apply an edit to the user\'s currently open Norn file (.norn, .nornapi, or .nornenv). ' +
|
|
57
|
+
'Can replace specific text, insert at a line, or append to the end of the file.',
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: 'object',
|
|
60
|
+
properties: {
|
|
61
|
+
action: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
enum: ['replace', 'insert', 'append'],
|
|
64
|
+
description: 'The type of edit: "replace" finds and replaces text, "insert" adds before a line number, "append" adds to end of file',
|
|
65
|
+
},
|
|
66
|
+
content: {
|
|
67
|
+
type: 'string',
|
|
68
|
+
description: 'The new content to insert, append, or use as the replacement',
|
|
69
|
+
},
|
|
70
|
+
searchText: {
|
|
71
|
+
type: 'string',
|
|
72
|
+
description: 'For "replace" action only: the exact text to find in the file and replace with content',
|
|
73
|
+
},
|
|
74
|
+
line: {
|
|
75
|
+
type: 'number',
|
|
76
|
+
description: 'For "insert" action only: the 1-based line number to insert before',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
required: ['action', 'content'],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'norn_createFile',
|
|
84
|
+
description: 'Create a new Norn file (.norn, .nornapi, or .nornenv) in the same directory as the currently open file, or in the workspace root.',
|
|
85
|
+
inputSchema: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
properties: {
|
|
88
|
+
filename: {
|
|
89
|
+
type: 'string',
|
|
90
|
+
description: 'The filename to create (e.g., "my-test.norn", "api.nornapi", ".nornenv"). Relative to the current file\'s directory.',
|
|
91
|
+
},
|
|
92
|
+
content: {
|
|
93
|
+
type: 'string',
|
|
94
|
+
description: 'The full content for the new file',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
required: ['filename', 'content'],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'norn_readWorkspaceFile',
|
|
102
|
+
description: 'Read the contents of a Norn file (.norn, .nornapi, or .nornenv) from the workspace. ' +
|
|
103
|
+
'Use this to inspect existing tests, API definitions, or environment configurations.',
|
|
104
|
+
inputSchema: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
pattern: {
|
|
108
|
+
type: 'string',
|
|
109
|
+
description: 'A filename or glob pattern to search for (e.g., "login.norn", "*.nornapi", "shared/*.norn"). Relative to workspace root.',
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
required: ['pattern'],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'norn_getDiagnostics',
|
|
117
|
+
description: 'Get the current diagnostics (errors and warnings) for Norn files in the workspace. ' +
|
|
118
|
+
'Use this to understand what problems exist so you can help fix them.',
|
|
119
|
+
inputSchema: {
|
|
120
|
+
type: 'object',
|
|
121
|
+
properties: {
|
|
122
|
+
filePath: {
|
|
123
|
+
type: 'string',
|
|
124
|
+
description: 'Optional: a specific file path to get diagnostics for. If omitted, returns diagnostics for all open Norn files.',
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
required: [],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'norn_readSwagger',
|
|
132
|
+
description: 'Fetch and parse an OpenAPI/Swagger specification from a URL. Returns the API title, version, base URL, and a list of endpoints grouped by section/tag. ' +
|
|
133
|
+
'Use this to understand an API before generating tests.',
|
|
134
|
+
inputSchema: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
properties: {
|
|
137
|
+
url: {
|
|
138
|
+
type: 'string',
|
|
139
|
+
description: 'The URL of the Swagger/OpenAPI JSON spec (e.g., "https://petstore.swagger.io/v2/swagger.json")',
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
required: ['url'],
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'norn_runTest',
|
|
147
|
+
description: 'Run a Norn test sequence from a file in the workspace and return the results. ' +
|
|
148
|
+
'Use this to verify that a test passes, check assertion results, or debug failures. ' +
|
|
149
|
+
'IMPORTANT: If the .nornenv has environment-specific variables (under [env:name] sections), ' +
|
|
150
|
+
'you MUST pass the "environment" parameter with the exact env name (e.g., "dev", "prod") ' +
|
|
151
|
+
'for those variables to resolve. Each call is independent — switching environments between runs requires passing the env name each time.',
|
|
152
|
+
inputSchema: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
properties: {
|
|
155
|
+
filePath: {
|
|
156
|
+
type: 'string',
|
|
157
|
+
description: 'Path to the .norn file containing the test sequence. Relative to workspace root.',
|
|
158
|
+
},
|
|
159
|
+
sequenceName: {
|
|
160
|
+
type: 'string',
|
|
161
|
+
description: 'Optional: the name of a specific sequence to run. If omitted, runs the first test sequence found.',
|
|
162
|
+
},
|
|
163
|
+
environment: {
|
|
164
|
+
type: 'string',
|
|
165
|
+
description: 'The environment name from the .nornenv file to use (e.g., "dev", "prod", "staging"). ' +
|
|
166
|
+
'Pass this to ensure environment-specific variables resolve correctly. ' +
|
|
167
|
+
'If omitted, auto-selects the first available environment.',
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
required: ['filePath'],
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Registration
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
/**
|
|
178
|
+
* Registers the @norn Copilot Chat participant.
|
|
179
|
+
*
|
|
180
|
+
* Gracefully handles the case where GitHub Copilot is not installed —
|
|
181
|
+
* the rest of the extension continues to work normally.
|
|
182
|
+
*/
|
|
183
|
+
function registerChatParticipant(context) {
|
|
184
|
+
try {
|
|
185
|
+
if (!vscode.chat?.createChatParticipant) {
|
|
186
|
+
// vscode.chat API not available (Copilot not installed or old VS Code)
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const participant = vscode.chat.createChatParticipant('norn.chat', nornChatHandler);
|
|
190
|
+
participant.iconPath = vscode.Uri.joinPath(context.extensionUri, 'images', 'icon.png');
|
|
191
|
+
participant.followupProvider = {
|
|
192
|
+
provideFollowups(result, _context, _token) {
|
|
193
|
+
const meta = result.metadata;
|
|
194
|
+
if (meta?.command === 'generate') {
|
|
195
|
+
return [
|
|
196
|
+
{ prompt: 'Add assertions for the response', label: 'Add assertions' },
|
|
197
|
+
{ prompt: 'Make this a parameterized test with @data', label: 'Parameterize' },
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
if (meta?.command === 'explain') {
|
|
201
|
+
return [
|
|
202
|
+
{ prompt: 'How can I improve this test?', label: 'Improve test' },
|
|
203
|
+
{ prompt: 'What assertions should I add?', label: 'Suggest assertions' },
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
// General follow-ups
|
|
207
|
+
return [
|
|
208
|
+
{ prompt: 'Show me how to write assertions', label: 'Assertion examples' },
|
|
209
|
+
{ prompt: 'How do I set up environments?', label: 'Environment setup' },
|
|
210
|
+
{ prompt: 'How do I run tests in CI/CD?', label: 'CLI / CI usage' },
|
|
211
|
+
];
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
context.subscriptions.push(participant);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Chat API not available — silently continue
|
|
218
|
+
console.log('Norn: Chat participant not registered (GitHub Copilot may not be installed)');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Handler
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
const MAX_TOOL_ROUNDS = 5; // safety limit to prevent infinite tool-call loops
|
|
225
|
+
const nornChatHandler = async (request, context, stream, token) => {
|
|
226
|
+
const command = request.command; // 'generate' | 'explain' | 'convert' | undefined
|
|
227
|
+
// Build the prompt messages
|
|
228
|
+
const messages = [];
|
|
229
|
+
// 1. Norn system-level grounding (sent as User role — LM API has no System role)
|
|
230
|
+
messages.push(vscode.LanguageModelChatMessage.User(nornPrompt_1.NORN_SYSTEM_PROMPT));
|
|
231
|
+
// 2. Conversation history — include prior turns for multi-turn context
|
|
232
|
+
for (const turn of context.history) {
|
|
233
|
+
if (turn instanceof vscode.ChatRequestTurn) {
|
|
234
|
+
messages.push(vscode.LanguageModelChatMessage.User(turn.prompt));
|
|
235
|
+
}
|
|
236
|
+
else if (turn instanceof vscode.ChatResponseTurn) {
|
|
237
|
+
const text = turn.response
|
|
238
|
+
.map(part => (part instanceof vscode.ChatResponseMarkdownPart ? part.value.value : ''))
|
|
239
|
+
.join('');
|
|
240
|
+
if (text) {
|
|
241
|
+
messages.push(vscode.LanguageModelChatMessage.Assistant(text));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// 3. Active editor context — include the user's current file if it's a Norn file
|
|
246
|
+
const activeFileContext = getActiveNornFileContext();
|
|
247
|
+
if (activeFileContext) {
|
|
248
|
+
messages.push(vscode.LanguageModelChatMessage.User(activeFileContext.prompt));
|
|
249
|
+
stream.reference(activeFileContext.uri);
|
|
250
|
+
}
|
|
251
|
+
// 4. Build the user prompt based on the slash command
|
|
252
|
+
const userPrompt = buildUserPrompt(command, request.prompt);
|
|
253
|
+
messages.push(vscode.LanguageModelChatMessage.User(userPrompt));
|
|
254
|
+
// Show progress
|
|
255
|
+
stream.progress('Thinking...');
|
|
256
|
+
// Request options — include tools so the LLM can edit/create files
|
|
257
|
+
const requestOptions = {
|
|
258
|
+
tools: NORN_TOOLS,
|
|
259
|
+
};
|
|
260
|
+
// Tool-calling loop: the LLM may request tool calls, we execute them and feed results back
|
|
261
|
+
try {
|
|
262
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
263
|
+
if (token.isCancellationRequested) {
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
const chatResponse = await request.model.sendRequest(messages, requestOptions, token);
|
|
267
|
+
// Collect text and tool calls from the response stream
|
|
268
|
+
const toolCalls = [];
|
|
269
|
+
let hasText = false;
|
|
270
|
+
for await (const part of chatResponse.stream) {
|
|
271
|
+
if (token.isCancellationRequested) {
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
if (part instanceof vscode.LanguageModelTextPart) {
|
|
275
|
+
stream.markdown(part.value);
|
|
276
|
+
hasText = true;
|
|
277
|
+
}
|
|
278
|
+
else if (part instanceof vscode.LanguageModelToolCallPart) {
|
|
279
|
+
toolCalls.push(part);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// If no tool calls or cancelled, we're done
|
|
283
|
+
if (toolCalls.length === 0 || token.isCancellationRequested) {
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
// Add assistant message with the tool calls to conversation
|
|
287
|
+
messages.push(vscode.LanguageModelChatMessage.Assistant(toolCalls));
|
|
288
|
+
// Execute each tool call and feed results back
|
|
289
|
+
for (const toolCall of toolCalls) {
|
|
290
|
+
if (token.isCancellationRequested) {
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
const result = await executeNornTool(toolCall, stream);
|
|
294
|
+
messages.push(vscode.LanguageModelChatMessage.User([
|
|
295
|
+
new vscode.LanguageModelToolResultPart(toolCall.callId, [
|
|
296
|
+
new vscode.LanguageModelTextPart(result),
|
|
297
|
+
]),
|
|
298
|
+
]));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
handleLlmError(err, stream);
|
|
304
|
+
}
|
|
305
|
+
return { metadata: { command } };
|
|
306
|
+
};
|
|
307
|
+
async function executeNornTool(toolCall, stream) {
|
|
308
|
+
switch (toolCall.name) {
|
|
309
|
+
case 'norn_applyEdit':
|
|
310
|
+
return executeApplyEdit(toolCall.input, stream);
|
|
311
|
+
case 'norn_createFile':
|
|
312
|
+
return executeCreateFile(toolCall.input, stream);
|
|
313
|
+
case 'norn_readWorkspaceFile':
|
|
314
|
+
return executeReadWorkspaceFile(toolCall.input, stream);
|
|
315
|
+
case 'norn_getDiagnostics':
|
|
316
|
+
return executeGetDiagnostics(toolCall.input, stream);
|
|
317
|
+
case 'norn_readSwagger':
|
|
318
|
+
return executeReadSwagger(toolCall.input, stream);
|
|
319
|
+
case 'norn_runTest':
|
|
320
|
+
return executeRunTest(toolCall.input, stream);
|
|
321
|
+
default:
|
|
322
|
+
return `Unknown tool: ${toolCall.name}`;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async function executeApplyEdit(input, stream) {
|
|
326
|
+
const editor = vscode.window.activeTextEditor;
|
|
327
|
+
if (!editor) {
|
|
328
|
+
return 'Error: No active editor. Please open a .norn, .nornapi, or .nornenv file first.';
|
|
329
|
+
}
|
|
330
|
+
const doc = editor.document;
|
|
331
|
+
const lang = doc.languageId;
|
|
332
|
+
if (lang !== 'norn' && lang !== 'nornapi' && lang !== 'nornenv') {
|
|
333
|
+
return `Error: Active file is not a Norn file (it's "${lang}"). Please open a .norn, .nornapi, or .nornenv file.`;
|
|
334
|
+
}
|
|
335
|
+
const edit = new vscode.WorkspaceEdit();
|
|
336
|
+
switch (input.action) {
|
|
337
|
+
case 'replace': {
|
|
338
|
+
if (!input.searchText) {
|
|
339
|
+
return 'Error: "replace" action requires "searchText" to find the text to replace.';
|
|
340
|
+
}
|
|
341
|
+
const text = doc.getText();
|
|
342
|
+
const idx = text.indexOf(input.searchText);
|
|
343
|
+
if (idx === -1) {
|
|
344
|
+
return `Error: Could not find the text to replace. The searchText was not found in the file.`;
|
|
345
|
+
}
|
|
346
|
+
// Warn if the searchText matches more than one location
|
|
347
|
+
const secondIdx = text.indexOf(input.searchText, idx + 1);
|
|
348
|
+
if (secondIdx !== -1) {
|
|
349
|
+
return `Error: The searchText was found multiple times in the file. Please provide more specific text that uniquely identifies the location to replace.`;
|
|
350
|
+
}
|
|
351
|
+
const startPos = doc.positionAt(idx);
|
|
352
|
+
const endPos = doc.positionAt(idx + input.searchText.length);
|
|
353
|
+
edit.replace(doc.uri, new vscode.Range(startPos, endPos), input.content);
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
case 'insert': {
|
|
357
|
+
const lineNum = (input.line ?? 1) - 1; // convert to 0-based
|
|
358
|
+
const clampedLine = Math.max(0, Math.min(lineNum, doc.lineCount));
|
|
359
|
+
const position = new vscode.Position(clampedLine, 0);
|
|
360
|
+
const contentWithNewline = input.content.endsWith('\n') ? input.content : input.content + '\n';
|
|
361
|
+
edit.insert(doc.uri, position, contentWithNewline);
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
case 'append': {
|
|
365
|
+
const lastLine = doc.lineCount - 1;
|
|
366
|
+
const lastChar = doc.lineAt(lastLine).text.length;
|
|
367
|
+
const position = new vscode.Position(lastLine, lastChar);
|
|
368
|
+
const prefix = lastChar > 0 ? '\n' : '';
|
|
369
|
+
edit.insert(doc.uri, position, prefix + input.content);
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
default:
|
|
373
|
+
return `Error: Unknown action "${input.action}". Use "replace", "insert", or "append".`;
|
|
374
|
+
}
|
|
375
|
+
const success = await vscode.workspace.applyEdit(edit);
|
|
376
|
+
if (!success) {
|
|
377
|
+
return 'Error: Failed to apply the edit. The file may be read-only.';
|
|
378
|
+
}
|
|
379
|
+
stream.progress('Applied edit to ' + path.basename(doc.uri.fsPath));
|
|
380
|
+
return `Successfully applied ${input.action} edit to ${path.basename(doc.uri.fsPath)}.`;
|
|
381
|
+
}
|
|
382
|
+
async function executeCreateFile(input, stream) {
|
|
383
|
+
// Determine the directory: use the active file's directory, or workspace root
|
|
384
|
+
let targetDir;
|
|
385
|
+
const activeEditor = vscode.window.activeTextEditor;
|
|
386
|
+
if (activeEditor) {
|
|
387
|
+
targetDir = vscode.Uri.file(path.dirname(activeEditor.document.uri.fsPath));
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
const workspaceFolders = vscode.workspace.workspaceFolders;
|
|
391
|
+
if (!workspaceFolders || workspaceFolders.length === 0) {
|
|
392
|
+
return 'Error: No workspace folder open and no active file. Cannot determine where to create the file.';
|
|
393
|
+
}
|
|
394
|
+
targetDir = workspaceFolders[0].uri;
|
|
395
|
+
}
|
|
396
|
+
const fileUri = vscode.Uri.joinPath(targetDir, input.filename);
|
|
397
|
+
// Check if file already exists
|
|
398
|
+
try {
|
|
399
|
+
await vscode.workspace.fs.stat(fileUri);
|
|
400
|
+
return `Error: File "${input.filename}" already exists at ${fileUri.fsPath}. Use norn_applyEdit to modify existing files.`;
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
// File doesn't exist — good, we can create it
|
|
404
|
+
}
|
|
405
|
+
await vscode.workspace.fs.writeFile(fileUri, Buffer.from(input.content, 'utf8'));
|
|
406
|
+
// Open the newly created file
|
|
407
|
+
const doc = await vscode.workspace.openTextDocument(fileUri);
|
|
408
|
+
await vscode.window.showTextDocument(doc, { preview: false });
|
|
409
|
+
stream.progress('Created ' + input.filename);
|
|
410
|
+
return `Successfully created file "${input.filename}" at ${fileUri.fsPath} and opened it in the editor.`;
|
|
411
|
+
}
|
|
412
|
+
async function executeReadWorkspaceFile(input, stream) {
|
|
413
|
+
const workspaceFolders = vscode.workspace.workspaceFolders;
|
|
414
|
+
if (!workspaceFolders || workspaceFolders.length === 0) {
|
|
415
|
+
return 'Error: No workspace folder open.';
|
|
416
|
+
}
|
|
417
|
+
// Search for files matching the pattern — normalize to avoid double `**/` prefixes
|
|
418
|
+
const rawPattern = input.pattern.replace(/^\/+/, ''); // strip leading slashes
|
|
419
|
+
const pattern = rawPattern.startsWith('**/') ? rawPattern : `**/${rawPattern}`;
|
|
420
|
+
const files = await vscode.workspace.findFiles(pattern, '**/node_modules/**', 10);
|
|
421
|
+
if (files.length === 0) {
|
|
422
|
+
return `No files found matching pattern "${pattern}". Try a different pattern like "*.norn" or "tests/**/*.norn".`;
|
|
423
|
+
}
|
|
424
|
+
const results = [];
|
|
425
|
+
let totalSize = 0;
|
|
426
|
+
for (const fileUri of files) {
|
|
427
|
+
if (totalSize >= MAX_CONTEXT_CHARS) {
|
|
428
|
+
results.push(`\n... (${files.length - results.length} more files truncated to stay within size limits)`);
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
const bytes = await vscode.workspace.fs.readFile(fileUri);
|
|
432
|
+
const content = Buffer.from(bytes).toString('utf8');
|
|
433
|
+
const relativePath = vscode.workspace.asRelativePath(fileUri);
|
|
434
|
+
if (totalSize + content.length > MAX_CONTEXT_CHARS) {
|
|
435
|
+
const remaining = MAX_CONTEXT_CHARS - totalSize;
|
|
436
|
+
results.push(`--- ${relativePath} (truncated) ---\n${content.substring(0, remaining)}\n...`);
|
|
437
|
+
totalSize = MAX_CONTEXT_CHARS;
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
results.push(`--- ${relativePath} ---\n${content}`);
|
|
441
|
+
totalSize += content.length;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
stream.progress(`Read ${Math.min(files.length, results.length)} file(s)`);
|
|
445
|
+
return results.join('\n\n');
|
|
446
|
+
}
|
|
447
|
+
async function executeGetDiagnostics(input, _stream) {
|
|
448
|
+
const allDiagnostics = vscode.languages.getDiagnostics();
|
|
449
|
+
const nornExtensions = ['.norn', '.nornapi', '.nornenv'];
|
|
450
|
+
const relevantDiagnostics = [];
|
|
451
|
+
for (const [uri, diagnostics] of allDiagnostics) {
|
|
452
|
+
const filePath = uri.fsPath;
|
|
453
|
+
const isNornFile = nornExtensions.some(ext => filePath.endsWith(ext));
|
|
454
|
+
if (!isNornFile) {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
// If a specific file was requested, filter to just that file
|
|
458
|
+
if (input.filePath && !filePath.endsWith(input.filePath) && !filePath.includes(input.filePath)) {
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (diagnostics.length > 0) {
|
|
462
|
+
relevantDiagnostics.push({
|
|
463
|
+
file: vscode.workspace.asRelativePath(uri),
|
|
464
|
+
diagnostics,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (relevantDiagnostics.length === 0) {
|
|
469
|
+
return input.filePath
|
|
470
|
+
? `No diagnostics found for "${input.filePath}". The file may have no errors or warnings.`
|
|
471
|
+
: 'No diagnostics found for any Norn files. Everything looks clean!';
|
|
472
|
+
}
|
|
473
|
+
const lines = [];
|
|
474
|
+
for (const { file, diagnostics } of relevantDiagnostics) {
|
|
475
|
+
lines.push(`## ${file}`);
|
|
476
|
+
for (const d of diagnostics) {
|
|
477
|
+
const severity = d.severity === vscode.DiagnosticSeverity.Error ? 'ERROR'
|
|
478
|
+
: d.severity === vscode.DiagnosticSeverity.Warning ? 'WARNING'
|
|
479
|
+
: 'INFO';
|
|
480
|
+
lines.push(` Line ${d.range.start.line + 1}: [${severity}] ${d.message}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return lines.join('\n');
|
|
484
|
+
}
|
|
485
|
+
async function executeReadSwagger(input, stream) {
|
|
486
|
+
try {
|
|
487
|
+
stream.progress('Fetching Swagger spec...');
|
|
488
|
+
const spec = await (0, swaggerParser_1.parseSwaggerSpec)(input.url);
|
|
489
|
+
const lines = [
|
|
490
|
+
`# ${spec.title} v${spec.version}`,
|
|
491
|
+
`Base URL: ${spec.baseUrl}`,
|
|
492
|
+
'',
|
|
493
|
+
];
|
|
494
|
+
for (const section of spec.sections) {
|
|
495
|
+
lines.push(`## ${section.name}`);
|
|
496
|
+
if (section.description) {
|
|
497
|
+
lines.push(section.description);
|
|
498
|
+
}
|
|
499
|
+
for (const endpoint of section.endpoints) {
|
|
500
|
+
const params = endpoint.parameters.length > 0
|
|
501
|
+
? ` (params: ${endpoint.parameters.join(', ')})`
|
|
502
|
+
: '';
|
|
503
|
+
lines.push(` ${endpoint.method.toUpperCase()} ${endpoint.path}${params}${endpoint.name ? ` — ${endpoint.name}` : ''}`);
|
|
504
|
+
}
|
|
505
|
+
lines.push('');
|
|
506
|
+
}
|
|
507
|
+
stream.progress('Parsed Swagger spec');
|
|
508
|
+
return lines.join('\n');
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
512
|
+
return `Error fetching Swagger spec from "${input.url}": ${message}`;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
async function executeRunTest(input, stream) {
|
|
516
|
+
const workspaceFolders = vscode.workspace.workspaceFolders;
|
|
517
|
+
if (!workspaceFolders || workspaceFolders.length === 0) {
|
|
518
|
+
return 'Error: No workspace folder open.';
|
|
519
|
+
}
|
|
520
|
+
// Resolve the file path
|
|
521
|
+
const workspaceRoot = workspaceFolders[0].uri.fsPath;
|
|
522
|
+
const absolutePath = path.isAbsolute(input.filePath)
|
|
523
|
+
? input.filePath
|
|
524
|
+
: path.resolve(workspaceRoot, input.filePath);
|
|
525
|
+
// Read the file
|
|
526
|
+
let fileContent;
|
|
527
|
+
try {
|
|
528
|
+
const uri = vscode.Uri.file(absolutePath);
|
|
529
|
+
const bytes = await vscode.workspace.fs.readFile(uri);
|
|
530
|
+
fileContent = Buffer.from(bytes).toString('utf8');
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
return `Error: Could not read file "${input.filePath}". Make sure the path is correct.`;
|
|
534
|
+
}
|
|
535
|
+
// Extract sequences
|
|
536
|
+
const sequences = (0, sequenceRunner_1.extractSequences)(fileContent);
|
|
537
|
+
if (sequences.length === 0) {
|
|
538
|
+
return `No sequences found in "${input.filePath}". Make sure the file contains a "sequence" or "test sequence" block.`;
|
|
539
|
+
}
|
|
540
|
+
// Find the target sequence
|
|
541
|
+
let target;
|
|
542
|
+
if (input.sequenceName) {
|
|
543
|
+
const found = sequences.find(s => s.name.toLowerCase() === input.sequenceName.toLowerCase());
|
|
544
|
+
if (!found) {
|
|
545
|
+
const available = sequences.map(s => s.name).join(', ');
|
|
546
|
+
return `Sequence "${input.sequenceName}" not found. Available sequences: ${available}`;
|
|
547
|
+
}
|
|
548
|
+
target = found;
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
// Pick the first test sequence, or first sequence if none are tests
|
|
552
|
+
target = sequences.find(s => s.isTest) ?? sequences[0];
|
|
553
|
+
}
|
|
554
|
+
// Gather variables and environment
|
|
555
|
+
const fileDir = path.dirname(absolutePath);
|
|
556
|
+
// Ensure an environment is active so env-specific variables (e.g. baseUrl) resolve.
|
|
557
|
+
// Priority: explicit input.environment > already-active env > first available env.
|
|
558
|
+
const previousEnv = (0, environmentProvider_1.getActiveEnvironment)();
|
|
559
|
+
const effectiveEnv = input.environment
|
|
560
|
+
?? previousEnv
|
|
561
|
+
?? (0, environmentProvider_1.getAvailableEnvironments)(absolutePath)[0]; // auto-pick first if nothing is set
|
|
562
|
+
if (effectiveEnv && effectiveEnv !== previousEnv) {
|
|
563
|
+
(0, environmentProvider_1.setActiveEnvironment)(effectiveEnv);
|
|
564
|
+
}
|
|
565
|
+
const envVars = (0, environmentProvider_1.getEnvironmentVariables)(absolutePath);
|
|
566
|
+
const fileVars = (0, parser_1.extractVariables)(fileContent);
|
|
567
|
+
const allVariables = { ...envVars, ...fileVars };
|
|
568
|
+
// Resolve imports for header groups and endpoints
|
|
569
|
+
const importResult = await (0, parser_1.resolveImports)(fileContent, fileDir, async (p) => {
|
|
570
|
+
const bytes = await vscode.workspace.fs.readFile(vscode.Uri.file(p));
|
|
571
|
+
return Buffer.from(bytes).toString('utf8');
|
|
572
|
+
});
|
|
573
|
+
// Combine imported variables
|
|
574
|
+
const importedVars = (0, parser_1.extractVariables)(importResult.importedContent);
|
|
575
|
+
Object.assign(allVariables, importedVars);
|
|
576
|
+
const apiDefinitions = importResult.headerGroups.length > 0 || importResult.endpoints.length > 0
|
|
577
|
+
? { headerGroups: importResult.headerGroups, endpoints: importResult.endpoints }
|
|
578
|
+
: undefined;
|
|
579
|
+
const cookieJar = (0, httpClient_1.createCookieJar)();
|
|
580
|
+
// Restore original environment after gathering variables
|
|
581
|
+
if (effectiveEnv !== previousEnv) {
|
|
582
|
+
(0, environmentProvider_1.setActiveEnvironment)(previousEnv);
|
|
583
|
+
}
|
|
584
|
+
// Run the sequence
|
|
585
|
+
// Report which environment is active for transparency
|
|
586
|
+
const availableEnvs = (0, environmentProvider_1.getAvailableEnvironments)(absolutePath);
|
|
587
|
+
stream.progress(`Running "${target.name}" with env: ${effectiveEnv ?? 'none'}...`);
|
|
588
|
+
try {
|
|
589
|
+
const result = await (0, sequenceRunner_1.runSequenceWithJar)(target.content, allVariables, cookieJar, fileDir, fileContent, undefined, // no progress callback
|
|
590
|
+
undefined, // no call stack
|
|
591
|
+
undefined, // no sequence args
|
|
592
|
+
apiDefinitions, undefined, // no tag filter
|
|
593
|
+
importResult.sequenceSources);
|
|
594
|
+
// Format the results
|
|
595
|
+
const lines = [
|
|
596
|
+
`# Test Result: ${result.name || target.name}`,
|
|
597
|
+
`**Environment**: ${effectiveEnv ?? 'none'} (available: ${availableEnvs.length > 0 ? availableEnvs.join(', ') : 'no .nornenv found'})`,
|
|
598
|
+
`**Status**: ${result.success ? 'PASSED ✅' : 'FAILED ❌'}`,
|
|
599
|
+
`**Duration**: ${result.duration}ms`,
|
|
600
|
+
'',
|
|
601
|
+
];
|
|
602
|
+
// If there were unresolved variables, flag them for debugging
|
|
603
|
+
const unresolvedVars = Object.entries(allVariables).length === 0 && availableEnvs.length > 0;
|
|
604
|
+
if (unresolvedVars) {
|
|
605
|
+
lines.push('⚠️ **Warning**: No variables were resolved. Make sure you pass the `environment` parameter matching one of: ' + availableEnvs.join(', '));
|
|
606
|
+
lines.push('');
|
|
607
|
+
}
|
|
608
|
+
// Assertion results
|
|
609
|
+
if (result.assertionResults.length > 0) {
|
|
610
|
+
lines.push('## Assertions');
|
|
611
|
+
for (const a of result.assertionResults) {
|
|
612
|
+
const icon = a.passed ? '✅' : '❌';
|
|
613
|
+
lines.push(` ${icon} ${a.expression}`);
|
|
614
|
+
if (!a.passed && a.error) {
|
|
615
|
+
lines.push(` → ${a.error}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
lines.push('');
|
|
619
|
+
}
|
|
620
|
+
// HTTP responses summary
|
|
621
|
+
if (result.responses.length > 0) {
|
|
622
|
+
lines.push('## HTTP Responses');
|
|
623
|
+
for (let i = 0; i < result.responses.length; i++) {
|
|
624
|
+
const r = result.responses[i];
|
|
625
|
+
lines.push(` ${i + 1}. ${r.status} ${r.statusText} (${r.duration}ms)`);
|
|
626
|
+
}
|
|
627
|
+
lines.push('');
|
|
628
|
+
}
|
|
629
|
+
// Errors
|
|
630
|
+
if (result.errors.length > 0) {
|
|
631
|
+
lines.push('## Errors');
|
|
632
|
+
for (const e of result.errors) {
|
|
633
|
+
lines.push(` ❌ ${e}`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return lines.join('\n');
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
640
|
+
const envInfo = availableEnvs.length > 0
|
|
641
|
+
? ` (environment: ${effectiveEnv ?? 'none'}, available: ${availableEnvs.join(', ')})`
|
|
642
|
+
: '';
|
|
643
|
+
return `Error running sequence "${target.name}"${envInfo}: ${message}`;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
function getActiveNornFileContext() {
|
|
647
|
+
const editor = vscode.window.activeTextEditor;
|
|
648
|
+
if (!editor) {
|
|
649
|
+
return undefined;
|
|
650
|
+
}
|
|
651
|
+
const doc = editor.document;
|
|
652
|
+
const lang = doc.languageId;
|
|
653
|
+
if (lang !== 'norn' && lang !== 'nornapi' && lang !== 'nornenv') {
|
|
654
|
+
return undefined;
|
|
655
|
+
}
|
|
656
|
+
const text = doc.getText();
|
|
657
|
+
// Skip very large files to stay within token budget
|
|
658
|
+
if (text.length > MAX_CONTEXT_CHARS) {
|
|
659
|
+
const truncated = text.substring(0, MAX_CONTEXT_CHARS);
|
|
660
|
+
return {
|
|
661
|
+
prompt: `The user has the following .${lang} file open (truncated):\n\`\`\`${lang}\n${truncated}\n\`\`\``,
|
|
662
|
+
uri: doc.uri,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
return {
|
|
666
|
+
prompt: `The user has the following .${lang} file open:\n\`\`\`${lang}\n${text}\n\`\`\``,
|
|
667
|
+
uri: doc.uri,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Build the final user prompt, adjusting instructions based on the slash command.
|
|
672
|
+
*/
|
|
673
|
+
function buildUserPrompt(command, prompt) {
|
|
674
|
+
switch (command) {
|
|
675
|
+
case 'generate':
|
|
676
|
+
return [
|
|
677
|
+
'The user wants you to generate Norn code. Based on their description, produce a complete, valid .norn snippet.',
|
|
678
|
+
'Include appropriate assertions (status, body values, types) and use best practices.',
|
|
679
|
+
'If the description mentions an API or endpoint, generate a test sequence with realistic assertions.',
|
|
680
|
+
'Wrap the output in ```norn code fences.',
|
|
681
|
+
'',
|
|
682
|
+
`User description: ${prompt}`,
|
|
683
|
+
].join('\n');
|
|
684
|
+
case 'explain':
|
|
685
|
+
return [
|
|
686
|
+
'The user wants you to explain Norn code. Analyze the code in their active file (provided above) or in their message.',
|
|
687
|
+
'Explain step by step what the code does, covering: requests, variable assignments, assertions, control flow, and any imports.',
|
|
688
|
+
'If there are potential issues or improvements, mention them.',
|
|
689
|
+
'',
|
|
690
|
+
`User request: ${prompt || 'Explain the current file'}`,
|
|
691
|
+
].join('\n');
|
|
692
|
+
case 'convert':
|
|
693
|
+
return [
|
|
694
|
+
'The user wants to convert something to Norn syntax. This could be:',
|
|
695
|
+
'- A cURL command → .norn request',
|
|
696
|
+
'- A Postman collection snippet → .norn file',
|
|
697
|
+
'- Raw HTTP → .norn request',
|
|
698
|
+
'- Another format → equivalent Norn syntax',
|
|
699
|
+
'Produce valid .norn output. Preserve headers, body, and method. Wrap in ```norn code fences.',
|
|
700
|
+
'',
|
|
701
|
+
`User input: ${prompt}`,
|
|
702
|
+
].join('\n');
|
|
703
|
+
default:
|
|
704
|
+
// General question — no extra framing needed, the system prompt is enough
|
|
705
|
+
return prompt;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
function handleLlmError(err, stream) {
|
|
709
|
+
if (err instanceof vscode.LanguageModelError) {
|
|
710
|
+
console.error('Norn Chat: LLM error', err.message, err.code);
|
|
711
|
+
if (err.cause instanceof Error && err.cause.message.includes('off_topic')) {
|
|
712
|
+
stream.markdown('I can only help with **Norn REST client** questions — writing requests, sequences, assertions, environments, and CLI usage. Please ask something related to Norn.');
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
stream.markdown(`Something went wrong communicating with the language model. Please try again.\n\n*Error: ${err.message}*`);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
throw err;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
//# sourceMappingURL=chatParticipant.js.map
|