gennady 0.5.0 → 0.5.1-next.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/README.md +60 -7
- package/index.d.ts +3 -0
- package/index.js +4 -0
- package/llm.md +82 -0
- package/package.json +2 -1
- package/src/ai/ai-core.js +46 -117
- package/src/ai/ai-model.d.ts +37 -0
- package/src/ai/ai-model.js +179 -0
- package/src/ai/ai-model.test.js +113 -0
- package/src/git/git-diff.js +6 -0
- package/src/rc/rc-config.d.ts +30 -0
- package/src/rc/rc-config.js +122 -0
- package/src/rc/rc-config.test.js +61 -0
- package/src/review-gen/review-gen.test.js +1 -1
- package/src/utils/unguard.d.ts +9 -0
- package/src/utils/unguard.js +57 -0
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ npx gennady cat <path1> <path2> ...
|
|
|
20
20
|
- 🤖 [**Commit Message**](#-commit-messages): Automatically generate clear, descriptive git commit messages from your staged changes.
|
|
21
21
|
- 📝 [**review**](#-review): Instantly review your staged git changes for critical issues (logic, runtime, security).
|
|
22
22
|
- 🐱 [**cat**](#-cat): Quickly display the contents of multiple files or directories at once, filtered by allowed extensions (default: .js, .ts, .tsx).
|
|
23
|
+
- 🛠️ **TypeScript Support**: Full TypeScript type definitions for seamless integration into TypeScript projects.
|
|
23
24
|
|
|
24
25
|
---
|
|
25
26
|
|
|
@@ -116,17 +117,69 @@ ollama serve
|
|
|
116
117
|
Create `~/.gennadyrc` configuration file:
|
|
117
118
|
|
|
118
119
|
```json
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
120
|
+
{
|
|
121
|
+
"models": [
|
|
122
|
+
{
|
|
123
|
+
"model": "gpt-3.5-turbo-0125",
|
|
124
|
+
"url": "https://api.openai.com/v1/chat/completions",
|
|
125
|
+
"key": "...",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"model": "llama3:8b",
|
|
129
|
+
"url": "http://127.0.0.1:11434/api/generate",
|
|
130
|
+
}
|
|
131
|
+
]
|
|
132
|
+
}
|
|
126
133
|
```
|
|
127
134
|
|
|
128
135
|
---
|
|
129
136
|
|
|
137
|
+
## 🔌 API
|
|
138
|
+
|
|
139
|
+
Gennady provides a powerful JavaScript/TypeScript API for programmatic usage in your projects.
|
|
140
|
+
|
|
141
|
+
### Installation
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
npm install gennady
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Basic Usage
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
import { GennadyRc } from 'gennady/src/rc/rc-config';
|
|
151
|
+
import { AiModel } from 'gennady/src/ai/ai-model';
|
|
152
|
+
|
|
153
|
+
// Load configuration
|
|
154
|
+
const rc = new GennadyRc();
|
|
155
|
+
|
|
156
|
+
// Get available AI models
|
|
157
|
+
const models = rc.getModels();
|
|
158
|
+
|
|
159
|
+
// Create AI model instance
|
|
160
|
+
const aiModel = new AiModel(models[0]);
|
|
161
|
+
|
|
162
|
+
// Generate text
|
|
163
|
+
const [response, error] = await aiModel.generate('Hello, world!', {
|
|
164
|
+
temperature: 0.7,
|
|
165
|
+
timeout: 10000
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (error) {
|
|
169
|
+
console.error('Error:', error);
|
|
170
|
+
} else {
|
|
171
|
+
console.log('Response:', response);
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Available Modules
|
|
176
|
+
|
|
177
|
+
- `AiModel`: Core AI model interaction
|
|
178
|
+
- `GennadyRc`: Configuration management
|
|
179
|
+
- `unguard`: Utility functions for error handling
|
|
180
|
+
|
|
181
|
+
For complete API documentation, check the source code with detailed JSDoc comments.
|
|
182
|
+
|
|
130
183
|
## 🎉 Happy Coding with Gennady!
|
|
131
184
|
|
|
132
185
|
> Made with 🤖 by Konstantin Lebedev
|
package/index.d.ts
ADDED
package/index.js
CHANGED
package/llm.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Gennady API: Using AiModel.generate
|
|
2
|
+
|
|
3
|
+
This guide demonstrates best practices for calling `AiModel.generate` and handling its results, particularly when using TypeScript.
|
|
4
|
+
|
|
5
|
+
## 1. Basic Usage of `AiModel.generate`
|
|
6
|
+
|
|
7
|
+
The `AiModel.generate` method returns a Promise that resolves to a tuple: `[result, error]`. You need to handle both potential outcomes.
|
|
8
|
+
|
|
9
|
+
### ✅ GOOD: Handling the Result Tuple
|
|
10
|
+
|
|
11
|
+
This example assumes `aiModel` is an initialized instance of `AiModel`.
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { AiModel } from 'gennady/src/ai/ai-model';
|
|
15
|
+
|
|
16
|
+
async function generateTextWithTupleHandling(aiModel: AiModel, prompt: string) {
|
|
17
|
+
const [response, error] = await aiModel.generate(prompt, {
|
|
18
|
+
temperature: 0.7,
|
|
19
|
+
timeout: 10000, // Optional: timeout in milliseconds
|
|
20
|
+
});
|
|
21
|
+
if (error) {
|
|
22
|
+
throw new Error(`[GENERATE_TEXT_WITH_TUPLE_ERROR] [${aiModel.name}] Generate failed`, {cause: error})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return response;
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
**Reasoning:** This approach explicitly checks for an error before attempting to use the response. This is the fundamental way to interact with functions designed with this tuple-based error handling pattern.
|
|
29
|
+
|
|
30
|
+
## 2. Using `AiModel.generate` with `unguardOrThrow` for Cleaner Code
|
|
31
|
+
|
|
32
|
+
For scenarios where you want to simplify error handling and prefer exceptions for the error path, especially in a sequence of operations, `unguardOrThrow` is recommended.
|
|
33
|
+
|
|
34
|
+
### ❌ BAD: Multiple Manual Error Checks in Sequential Operations
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
// ... imports and aiModel initialization ...
|
|
38
|
+
async function sequentialGenerationBad(aiModel: AiModel) {
|
|
39
|
+
const [response1, error1] = await aiModel.generate("First prompt");
|
|
40
|
+
if (error1) {
|
|
41
|
+
throw new Error(`[SEQUENTIAL_GENERATION_BAD_ERROR_1] [${aiModel.name}] Generate failed`, {cause: error1})
|
|
42
|
+
}
|
|
43
|
+
console.log("First response:", response1);
|
|
44
|
+
|
|
45
|
+
const [response2, error2] = await aiModel.generate("Second prompt using: " + response1.substring(0, 20));
|
|
46
|
+
if (error2) {
|
|
47
|
+
throw new Error(`[SEQUENTIAL_GENERATION_BAD_ERROR_2] [${aiModel.name}] Generate failed`, {cause: error2})
|
|
48
|
+
}
|
|
49
|
+
console.log("Second response:", response2);
|
|
50
|
+
// ... and so on
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
**Reasoning:** This becomes verbose and repetitive. Each step requires its own error check and handling logic, making the main flow harder to read.
|
|
54
|
+
|
|
55
|
+
### ✅ GOOD: Using `unguardOrThrow` for Concise Sequential Operations
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
import { AiModel } from 'gennady/src/ai/ai-model';
|
|
59
|
+
import { unguardOrThrow } from 'gennady/src/utils/unguard';
|
|
60
|
+
|
|
61
|
+
async function sequentialGenerationGood(aiModel: AiModel) {
|
|
62
|
+
try {
|
|
63
|
+
console.log("Attempting first generation...");
|
|
64
|
+
const response1 = await unguardOrThrow(aiModel.generate("Write a short poem about coding."));
|
|
65
|
+
console.log("First response:\n", response1);
|
|
66
|
+
|
|
67
|
+
console.log("\nAttempting second generation based on the first...");
|
|
68
|
+
const response2 = await unguardOrThrow(aiModel.generate(`Write a haiku based on this line: "${response1.split('\n')[0]}"`));
|
|
69
|
+
console.log("Second response (haiku):\n", response2);
|
|
70
|
+
|
|
71
|
+
// You can continue the chain of operations here
|
|
72
|
+
// const response3 = await unguardOrThrow(aiModel.generate(...));
|
|
73
|
+
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new Error(`[SEQUENTIAL_GENERATION_GOOD_ERROR] [${aiModel.name}] Generate failed`, {cause: error})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
**Reasoning:** `unguardOrThrow` unwraps the success value or throws the error if present. This allows you to write cleaner, more linear code for the success path and handle all errors in a single `catch` block. This is particularly useful for chained asynchronous operations where an error at any point should halt the entire chain.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
Remember to always initialize `AiModel` with valid configuration, typically loaded via `GennadyRc`. The examples above focus on the `generate` call itself, assuming `aiModel` is ready.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gennady",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1-next.1",
|
|
4
4
|
"author": "Konstantin Lebedev <ibnrubaxa@gmail.com>",
|
|
5
5
|
"description": "Gennady — Generate Next-level Automated Description Yntelligence",
|
|
6
6
|
"keywords": [
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"type": "module",
|
|
20
20
|
"bin": "./cli/gennady.js",
|
|
21
21
|
"main": "index.js",
|
|
22
|
+
"types": "index.d.ts",
|
|
22
23
|
"scripts": {
|
|
23
24
|
"test": "node --test"
|
|
24
25
|
}
|
package/src/ai/ai-core.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
const DEFAULT_MODEL = 'llama3:8b';
|
|
5
|
-
const DEFAULT_API_URL = 'http://127.0.0.1:11434/api/generate';
|
|
6
|
-
|
|
7
|
-
const GENNADY_RC_FILENAME = '.gennadyrc';
|
|
1
|
+
import { AiModel } from './ai-model.js';
|
|
2
|
+
import { GennadyRc } from '../rc/rc-config.js';
|
|
3
|
+
import { unguardOrThrow } from '../utils/unguard.js';
|
|
8
4
|
|
|
5
|
+
/** @deprecated */
|
|
9
6
|
export class AiCore {
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
/**
|
|
8
|
+
* List of AI Models
|
|
9
|
+
* @type {AiModel[]} array of #AI_MODEL_CLASS
|
|
10
|
+
*/
|
|
11
|
+
#models = [];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Active AI Model
|
|
15
|
+
* @type {AiModel|null}
|
|
16
|
+
*/
|
|
17
|
+
#activeModel = null;
|
|
12
18
|
|
|
13
19
|
constructor(init) {
|
|
14
20
|
this.init = {
|
|
@@ -18,39 +24,23 @@ export class AiCore {
|
|
|
18
24
|
...init,
|
|
19
25
|
};
|
|
20
26
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
join(process.env.HOME, GENNADY_RC_FILENAME),
|
|
25
|
-
].find((file) => {
|
|
26
|
-
try {
|
|
27
|
-
if (existsSync(file)) {
|
|
28
|
-
const items = JSON.parse(readFileSync(file).toString());
|
|
29
|
-
if (Array.isArray(items)) {
|
|
30
|
-
this.apiList.push(...items);
|
|
31
|
-
} else {
|
|
32
|
-
this.logger.warn(`Invalid "${file}" config:`, items);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
} catch (err) {
|
|
36
|
-
this.logger.error(`Parse "${file}" error:`, err);
|
|
27
|
+
GennadyRc.getDefaults().forEach((rc) => {
|
|
28
|
+
if (rc.isValid()) {
|
|
29
|
+
this.#models.push(...rc.getModels().map(model => new AiModel(model)));
|
|
37
30
|
}
|
|
38
31
|
});
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
key: this.init.key,
|
|
44
|
-
model: this.init.model || DEFAULT_MODEL,
|
|
45
|
-
});
|
|
32
|
+
|
|
33
|
+
if (!this.#models.length) {
|
|
34
|
+
this.#models.push(AiModel.getDefault());
|
|
35
|
+
}
|
|
46
36
|
}
|
|
47
37
|
|
|
48
38
|
get model() {
|
|
49
|
-
return this
|
|
39
|
+
return this.#models[0]?.name;
|
|
50
40
|
}
|
|
51
41
|
|
|
52
42
|
get apiUrl() {
|
|
53
|
-
return this
|
|
43
|
+
return this.#models[0]?.url
|
|
54
44
|
}
|
|
55
45
|
|
|
56
46
|
get maxInputTokens() {
|
|
@@ -92,96 +82,35 @@ export class AiCore {
|
|
|
92
82
|
|
|
93
83
|
async generate(prompt, context) {
|
|
94
84
|
try {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return await this._callGenerateApi(api, prompt, context);
|
|
101
|
-
} catch (e) {
|
|
102
|
-
this.logger.error(`Failed to generate LLM response:`, e);
|
|
85
|
+
const model = await unguardOrThrow(this.#choiceModel());
|
|
86
|
+
const result = await unguardOrThrow(model.generate(prompt, context));
|
|
87
|
+
return result;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
this.logger.error(`[AI_CORE_ERROR_GENERATE] Failed to generate LLM response:`, error);
|
|
103
90
|
return '';
|
|
104
91
|
}
|
|
105
92
|
}
|
|
106
93
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
setTimeout(() => ctrl.abort(new Error('Module timeout')), 500);
|
|
117
|
-
|
|
118
|
-
const resp = await fetch(api.url, {method: 'HEAD', signal: ctrl.signal});
|
|
119
|
-
if (resp.status >= 200 && resp.status < 500) {
|
|
120
|
-
this.api = api;
|
|
121
|
-
return api;
|
|
122
|
-
}
|
|
123
|
-
} catch {}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return this.api;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
async _callGenerateApi(api, prompt, context) {
|
|
131
|
-
const req = await fetch(api.url, {
|
|
132
|
-
method: 'POST',
|
|
133
|
-
headers: {'Content-Type': 'application/json'},
|
|
134
|
-
body: JSON.stringify({
|
|
135
|
-
model: api.model,
|
|
136
|
-
stream: false,
|
|
137
|
-
prompt,
|
|
138
|
-
context,
|
|
139
|
-
}),
|
|
140
|
-
});
|
|
141
|
-
const data = await req.json();
|
|
142
|
-
return data.response || '';
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
async _callCompletionsApi(api, prompt, context) {
|
|
146
|
-
const messages = [];
|
|
147
|
-
|
|
148
|
-
if (context) {
|
|
149
|
-
messages.push({ role: 'system', content: context });
|
|
94
|
+
/**
|
|
95
|
+
* Choose active model
|
|
96
|
+
* @anchor AI_CORE_CHOICE_MODEL
|
|
97
|
+
* @returns {Promise<[AiModel, null] | [null, Error]>}
|
|
98
|
+
*/
|
|
99
|
+
async #choiceModel() {
|
|
100
|
+
if (this.#activeModel) {
|
|
101
|
+
return [this.#activeModel, null];
|
|
150
102
|
}
|
|
151
103
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
body: JSON.stringify({
|
|
161
|
-
model: api.model,
|
|
162
|
-
messages: messages,
|
|
163
|
-
temperature: 0.1,
|
|
164
|
-
stream: false,
|
|
165
|
-
timeout: this.init.timeout,
|
|
166
|
-
}),
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
if (!req.ok) {
|
|
170
|
-
let errorBody = '';
|
|
171
|
-
try {
|
|
172
|
-
errorBody = await req.text();
|
|
173
|
-
} catch (e) { /* ignore */ }
|
|
174
|
-
|
|
175
|
-
throw new Error(`LLM completions request failed with status ${req.status}: ${errorBody}`);
|
|
104
|
+
for (const model of this.#models) {
|
|
105
|
+
const [ok, error] = await model.ping();
|
|
106
|
+
if (ok) {
|
|
107
|
+
this.#activeModel = model;
|
|
108
|
+
return [model, null];
|
|
109
|
+
} else {
|
|
110
|
+
this.logger.warn(`[AI_CORE_ERROR_PING_MODEL_FAIL] [${model.name}] Ping failed:`, error);
|
|
111
|
+
}
|
|
176
112
|
}
|
|
177
113
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (data.choices && data.choices.length > 0 && data.choices[0].message) {
|
|
181
|
-
return data.choices[0].message.content || '';
|
|
182
|
-
} else {
|
|
183
|
-
this.logger.warn(style.yellow('LLM completions response structure unexpected:'), data);
|
|
184
|
-
return '';
|
|
185
|
-
}
|
|
114
|
+
return [null, new Error(`[AI_CORE_ERROR_PING_FAIL] No available models`)];
|
|
186
115
|
}
|
|
187
116
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** AI Model (LLM) init params #AI_MODEL_INIT */
|
|
2
|
+
export type AiModelInit = {
|
|
3
|
+
model: string;
|
|
4
|
+
url: string;
|
|
5
|
+
key?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** AI Model (LLM) #AI_MODEL_CLASS */
|
|
9
|
+
export class AiModel {
|
|
10
|
+
/** Default AI Model #AI_MODEL_DEFAULT */
|
|
11
|
+
static getDefault(): AiModel;
|
|
12
|
+
|
|
13
|
+
/** Constructor #AI_MODEL_CONSTRUCTOR */
|
|
14
|
+
constructor(init: AiModelInit);
|
|
15
|
+
|
|
16
|
+
/** Model identifier/name #AI_MODEL_MODEL_NAME */
|
|
17
|
+
readonly name: string;
|
|
18
|
+
|
|
19
|
+
/** API endpoint URL #AI_MODEL_URL */
|
|
20
|
+
readonly url: string;
|
|
21
|
+
|
|
22
|
+
/** API authentication key #AI_MODEL_KEY */
|
|
23
|
+
readonly key: string | undefined;
|
|
24
|
+
|
|
25
|
+
/** Ping AI Model #AI_MODEL_PING */
|
|
26
|
+
ping(timeout?: number): Promise<[boolean, null] | [null, Error]>;
|
|
27
|
+
|
|
28
|
+
/** Generate LLM response #AI_MODEL_GENERATE */
|
|
29
|
+
generate(
|
|
30
|
+
prompt: string,
|
|
31
|
+
init?: {
|
|
32
|
+
context?: string;
|
|
33
|
+
temperature?: number;
|
|
34
|
+
timeout?: number;
|
|
35
|
+
}
|
|
36
|
+
): Promise<[string, null] | [null, Error]>;
|
|
37
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { unguardOrThrow } from "../utils/unguard.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AI Model (LLM) init params
|
|
5
|
+
* @SUPER_TOKEN AI_MODEL_INIT
|
|
6
|
+
* @typedef {Object} AiModelInit
|
|
7
|
+
* @property {string} model - Model identifier/name
|
|
8
|
+
* @property {string} url - API endpoint URL
|
|
9
|
+
* @property {string} [key] - Optional API authentication key
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* AI Model (LLM)
|
|
14
|
+
* @SUPER_TOKEN AI_MODEL_CLASS
|
|
15
|
+
*/
|
|
16
|
+
export class AiModel {
|
|
17
|
+
/**
|
|
18
|
+
* Default AI Model
|
|
19
|
+
* @anchor AI_MODEL_DEFAULT
|
|
20
|
+
* @returns {AiModel} #AI_MODEL_CLASS
|
|
21
|
+
*/
|
|
22
|
+
static getDefault() {
|
|
23
|
+
return new AiModel({
|
|
24
|
+
model: 'llama3:8b',
|
|
25
|
+
url: 'http://127.0.0.1:11434/api/generate',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Constructor
|
|
31
|
+
* @anchor AI_MODEL_CONSTRUCTOR
|
|
32
|
+
* @param {AiModelInit} init - Configuration object (#AI_MODEL_INIT)
|
|
33
|
+
*/
|
|
34
|
+
constructor(init) {
|
|
35
|
+
this.#init = {
|
|
36
|
+
...this.#init,
|
|
37
|
+
...init,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Configuration object
|
|
43
|
+
* @type {AiModelInit} #AI_MODEL_INIT
|
|
44
|
+
*/
|
|
45
|
+
#init = {};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Model identifier/name
|
|
49
|
+
* @anchor AI_MODEL_MODEL_NAME
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
get name() {
|
|
53
|
+
return this.#init.model;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* API endpoint URL
|
|
58
|
+
* @anchor AI_MODEL_URL
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
get url() {
|
|
62
|
+
return this.#init.url;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* API authentication key
|
|
67
|
+
* @anchor AI_MODEL_KEY
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
get key() {
|
|
71
|
+
return this.#init.key;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Ping AI Model
|
|
76
|
+
* @anchor AI_MODEL_PING
|
|
77
|
+
* @param {number} [timeout] - Optional ping request timeout
|
|
78
|
+
* @returns {Promise<[boolean, null] | [null, Error]>} True if ping successful
|
|
79
|
+
*/
|
|
80
|
+
async ping(timeout = 5e3) {
|
|
81
|
+
try {
|
|
82
|
+
const answer = await unguardOrThrow(this.generate('Say one token "OK"', {timeout}));
|
|
83
|
+
return [`${answer}`.toUpperCase() === 'OK', null];
|
|
84
|
+
} catch (cause) {
|
|
85
|
+
return [null, new Error(`[AI_MODEL_ERROR_PING] [${this.name}] Ping failed`, {cause})];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Generate LLM response
|
|
91
|
+
* @anchor AI_MODEL_GENERATE
|
|
92
|
+
* @param {string} prompt - Prompt text
|
|
93
|
+
* @param {Object} [init] - Optional configuration object
|
|
94
|
+
* @property {string} [init.context] - Optional context text
|
|
95
|
+
* @property {string} [init.temperature] - Optional temperature generation
|
|
96
|
+
* @property {string} [init.timeout] - Optional request timeout
|
|
97
|
+
* @returns {Promise<[string, null] | [null, Error]>} Generated LLM response
|
|
98
|
+
*/
|
|
99
|
+
async generate(prompt, init = {}) {
|
|
100
|
+
// Prepare fetch params
|
|
101
|
+
const params = this.url.includes('completions')
|
|
102
|
+
? {
|
|
103
|
+
temperature: init.temperature ?? 0.2,
|
|
104
|
+
messages: [].concat(
|
|
105
|
+
init.context ? { role: 'system', content: init.context } : [],
|
|
106
|
+
{ role: 'user', content: prompt },
|
|
107
|
+
),
|
|
108
|
+
}
|
|
109
|
+
: {
|
|
110
|
+
context: init.context,
|
|
111
|
+
prompt,
|
|
112
|
+
}
|
|
113
|
+
;
|
|
114
|
+
|
|
115
|
+
// Call LLM API with params
|
|
116
|
+
const data = await unguardOrThrow(this.#fetchAsJson(params, init.timeout));
|
|
117
|
+
|
|
118
|
+
// Generate API Response
|
|
119
|
+
if (data && 'response' in data) {
|
|
120
|
+
return [data.response || '', null];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Completions API Response
|
|
124
|
+
if (data && data.choices && data.choices.length > 0 && data.choices[0].message) {
|
|
125
|
+
return [data.choices[0].message.content || '', null];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return [null, new Error(
|
|
129
|
+
`[AI_MODEL_ERROR_GENERATE] [${this.name}] Response structure unexpected`,
|
|
130
|
+
{cause: data},
|
|
131
|
+
)];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Fetch data from LLM API
|
|
136
|
+
* @anchor AI_MODEL_FETCH_AS_JSON
|
|
137
|
+
* @param {Object} params - Request parameters
|
|
138
|
+
* @param {number} [timeout] - Optional request timeout
|
|
139
|
+
* @returns {Promise<[unknown, null] | [null, Error]>} API response
|
|
140
|
+
*/
|
|
141
|
+
async #fetchAsJson(params, timeout = 120e3) {
|
|
142
|
+
const controller = new AbortController();
|
|
143
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const req = await fetch(this.url, {
|
|
147
|
+
signal: controller.signal,
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: {
|
|
150
|
+
'Content-Type': 'application/json',
|
|
151
|
+
'Authorization': this.key ? `Bearer ${this.key}` : undefined,
|
|
152
|
+
},
|
|
153
|
+
body: JSON.stringify({
|
|
154
|
+
...params,
|
|
155
|
+
model: this.name,
|
|
156
|
+
stream: false,
|
|
157
|
+
}),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (!req.ok) {
|
|
161
|
+
let errorBody = '';
|
|
162
|
+
try {
|
|
163
|
+
errorBody = await req.text();
|
|
164
|
+
} catch (_) { /* ignore */ }
|
|
165
|
+
|
|
166
|
+
return [null, new Error(
|
|
167
|
+
`[AI_MODEL_ERROR_FETCH_NOT_OK] [${this.name}] Fetch failed with status "${req.status}"`,
|
|
168
|
+
{cause: errorBody},
|
|
169
|
+
)];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return [await req.json(), null];
|
|
173
|
+
} catch (cause) {
|
|
174
|
+
return [null, new Error(`[AI_MODEL_ERROR_FETCH] [${this.name}] Fetch failed`, {cause})];
|
|
175
|
+
} finally {
|
|
176
|
+
clearTimeout(timeoutId);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, test, mock } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { AiModel } from './ai-model.js';
|
|
4
|
+
|
|
5
|
+
const defaultInit = {
|
|
6
|
+
model: 'test-model',
|
|
7
|
+
url: 'http://localhost/api',
|
|
8
|
+
key: 'test-key',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe('AiModel', () => {
|
|
12
|
+
describe('constructor & getters', () => {
|
|
13
|
+
test('model, url, key', () => {
|
|
14
|
+
const ai = new AiModel(defaultInit);
|
|
15
|
+
assert.strictEqual(ai.name, defaultInit.model);
|
|
16
|
+
assert.strictEqual(ai.url, defaultInit.url);
|
|
17
|
+
assert.strictEqual(ai.key, defaultInit.key);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('generate()', () => {
|
|
22
|
+
test('response with .response field', async () => {
|
|
23
|
+
const mockedFetch = mock.method(global, 'fetch', async () => ({
|
|
24
|
+
ok: true,
|
|
25
|
+
json: async () => ({ response: 'hello' })
|
|
26
|
+
}));
|
|
27
|
+
try {
|
|
28
|
+
const ai = new AiModel(defaultInit);
|
|
29
|
+
const [result, error] = await ai.generate('prompt');
|
|
30
|
+
assert.strictEqual(error, null);
|
|
31
|
+
assert.strictEqual(result, 'hello');
|
|
32
|
+
} finally {
|
|
33
|
+
mockedFetch.mock.restore();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('response with choices[0].message', async () => {
|
|
38
|
+
const mockedFetch = mock.method(global, 'fetch', async () => ({
|
|
39
|
+
ok: true,
|
|
40
|
+
json: async () => ({ choices: [{ message: { content: 'hi!' } }] })
|
|
41
|
+
}));
|
|
42
|
+
try {
|
|
43
|
+
const ai = new AiModel({ ...defaultInit, url: 'http://localhost/api/completions' });
|
|
44
|
+
const [result, error] = await ai.generate('prompt');
|
|
45
|
+
assert.strictEqual(error, null);
|
|
46
|
+
assert.strictEqual(result, 'hi!');
|
|
47
|
+
} finally {
|
|
48
|
+
mockedFetch.mock.restore();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('unexpected structure', async () => {
|
|
53
|
+
const mockedFetch = mock.method(global, 'fetch', async () => ({
|
|
54
|
+
ok: true,
|
|
55
|
+
json: async () => ({ foo: 'bar' })
|
|
56
|
+
}));
|
|
57
|
+
try {
|
|
58
|
+
const ai = new AiModel(defaultInit);
|
|
59
|
+
const [result, error] = await ai.generate('prompt');
|
|
60
|
+
assert(error instanceof Error);
|
|
61
|
+
assert.strictEqual(result, null);
|
|
62
|
+
assert(String(error.message).includes('Response structure unexpected'));
|
|
63
|
+
} finally {
|
|
64
|
+
mockedFetch.mock.restore();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('ping()', () => {
|
|
70
|
+
test('returns true if model responds OK', async () => {
|
|
71
|
+
const mockedFetch = mock.method(global, 'fetch', async () => ({
|
|
72
|
+
ok: true,
|
|
73
|
+
json: async () => ({ response: 'OK' })
|
|
74
|
+
}));
|
|
75
|
+
try {
|
|
76
|
+
const ai = new AiModel(defaultInit);
|
|
77
|
+
const [ok, err] = await ai.ping();
|
|
78
|
+
assert.strictEqual(err, null);
|
|
79
|
+
assert.strictEqual(ok, true);
|
|
80
|
+
} finally {
|
|
81
|
+
mockedFetch.mock.restore();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('returns false if model responds NOT OK', async () => {
|
|
86
|
+
const mockedFetch = mock.method(global, 'fetch', async () => ({
|
|
87
|
+
ok: true,
|
|
88
|
+
json: async () => ({ response: 'NOPE' })
|
|
89
|
+
}));
|
|
90
|
+
try {
|
|
91
|
+
const ai = new AiModel(defaultInit);
|
|
92
|
+
const [ok, err] = await ai.ping();
|
|
93
|
+
assert.strictEqual(err, null);
|
|
94
|
+
assert.strictEqual(ok, false);
|
|
95
|
+
} finally {
|
|
96
|
+
mockedFetch.mock.restore();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('returns error if fetch fails', async () => {
|
|
101
|
+
const mockedFetch = mock.method(global, 'fetch', async () => { throw new Error('fail'); });
|
|
102
|
+
try {
|
|
103
|
+
const ai = new AiModel(defaultInit);
|
|
104
|
+
const [ok, err] = await ai.ping();
|
|
105
|
+
assert(err instanceof Error);
|
|
106
|
+
assert.strictEqual(ok, null);
|
|
107
|
+
assert(`${err}`.includes('Ping failed'));
|
|
108
|
+
} finally {
|
|
109
|
+
mockedFetch.mock.restore();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
package/src/git/git-diff.js
CHANGED
|
@@ -52,6 +52,12 @@ const getCategory = (filename, metadata) => {
|
|
|
52
52
|
return category;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Parse git diff
|
|
57
|
+
* @anchor GIT_DIFF_PARSE
|
|
58
|
+
* @param {string} diffText - Git diff text
|
|
59
|
+
* @returns {Object[]} Parsed git diff
|
|
60
|
+
*/
|
|
55
61
|
export const parseGitDiff = (diffText) => {
|
|
56
62
|
const lines = diffText.split('\n');
|
|
57
63
|
const result = [];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Gennady RC data #GENNADY_RC_DATA */
|
|
2
|
+
export type RcModel = {
|
|
3
|
+
model: string;
|
|
4
|
+
url: string;
|
|
5
|
+
key?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** Gennady RC (configuration) #GENNADY_RC_CLASS */
|
|
9
|
+
export class GennadyRc {
|
|
10
|
+
/** Default Gennady RC filename #GENNADY_RC_DEFAULT_FILENAME */
|
|
11
|
+
static readonly DEFAULT_FILENAME: string;
|
|
12
|
+
|
|
13
|
+
/** Get default rc configs #GENNADY_RC_GET_DEFAULTS */
|
|
14
|
+
static getDefaults(): GennadyRc[];
|
|
15
|
+
|
|
16
|
+
/** Load rc config #GENNADY_RC_LOAD */
|
|
17
|
+
static load(path?: string): GennadyRc;
|
|
18
|
+
|
|
19
|
+
/** Constructor #GENNADY_RC_CONSTRUCTOR */
|
|
20
|
+
constructor(dir?: string, filename?: string);
|
|
21
|
+
|
|
22
|
+
/** Check if config is valid #GENNADY_RC_METHOD_IS_VALID */
|
|
23
|
+
isValid(): boolean;
|
|
24
|
+
|
|
25
|
+
/** Get AI Models config #GENNADY_RC_METHOD_GET_MODELS */
|
|
26
|
+
getModels(): RcModel[];
|
|
27
|
+
|
|
28
|
+
/** Get config parse error #GENNADY_RC_GET_ERROR */
|
|
29
|
+
getError(): Error | null;
|
|
30
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join as pathJoin } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Gennady RC data
|
|
6
|
+
* @SUPER_TOKEN GENNADY_RC_DATA
|
|
7
|
+
* @typedef {Object} GennadyRcData
|
|
8
|
+
* @property {AiModelInit[]} models - AI Models configs #AI_MODEL_INIT
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Gennady RC (configuration)
|
|
13
|
+
* @SUPER_TOKEN GENNADY_RC_CLASS
|
|
14
|
+
*/
|
|
15
|
+
export class GennadyRc {
|
|
16
|
+
/**
|
|
17
|
+
* Default Gennady RC filename
|
|
18
|
+
* @anchor GENNADY_RC_DEFAULT_FILENAME
|
|
19
|
+
* @constant {string}
|
|
20
|
+
*/
|
|
21
|
+
static DEFAULT_FILENAME = '.gennadyrc';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get default rc configs
|
|
25
|
+
* @anchor GENNADY_RC_GET_DEFAULTS
|
|
26
|
+
* @returns {GennadyRc[]}
|
|
27
|
+
*/
|
|
28
|
+
static getDefaults() {
|
|
29
|
+
return [process.cwd(), process.env.HOME].map((dir) => {
|
|
30
|
+
return new GennadyRc(dir);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Config filename
|
|
36
|
+
* @anchor GENNADY_RC_FILENAME
|
|
37
|
+
* @type {string}
|
|
38
|
+
*/
|
|
39
|
+
#filename = '';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Config data
|
|
43
|
+
* @anchor GENNADY_RC_DATA
|
|
44
|
+
* @type {GennadyRcData} #GENNADY_RC_DATA
|
|
45
|
+
*/
|
|
46
|
+
#data = {
|
|
47
|
+
models: [],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Config parse error
|
|
52
|
+
* @anchor GENNADY_RC_ERROR
|
|
53
|
+
* @type {Error|null}
|
|
54
|
+
*/
|
|
55
|
+
#error = null;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Constructor
|
|
59
|
+
* @anchor GENNADY_RC_CONSTRUCTOR
|
|
60
|
+
* @param {string} [dir] - Optional rc-config directory (default process.cwd())
|
|
61
|
+
* @param {string} [filename] - Optional rc-config filename (default #GENNADY_RC_FILENAME)
|
|
62
|
+
*/
|
|
63
|
+
constructor(
|
|
64
|
+
dir = process.cwd(),
|
|
65
|
+
name = GennadyRc.DEFAULT_FILENAME,
|
|
66
|
+
) {
|
|
67
|
+
this.#filename = pathJoin(dir, name);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
if (existsSync(this.#filename)) {
|
|
71
|
+
const data = JSON.parse(readFileSync(this.#filename).toString());
|
|
72
|
+
|
|
73
|
+
if (Array.isArray(data)) {
|
|
74
|
+
// Legacy format
|
|
75
|
+
this.#data.models = data;
|
|
76
|
+
} else if (data && Array.isArray(data.models)) {
|
|
77
|
+
this.#data = {
|
|
78
|
+
...this.#data,
|
|
79
|
+
...data,
|
|
80
|
+
};
|
|
81
|
+
} else {
|
|
82
|
+
this.#error = new Error(
|
|
83
|
+
`[GENNADY_RC_ERROR_CONFIG] Invalid "${this.#filename}" config data`,
|
|
84
|
+
{cause: data},
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
this.#error = new Error(
|
|
90
|
+
`[GENNADY_RC_ERROR_PARSE] Read "${this.#filename}" config failed`,
|
|
91
|
+
{cause: err},
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check if config is valid
|
|
98
|
+
* @anchor GENNADY_RC_METHOD_IS_VALID
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
isValid() {
|
|
102
|
+
return !this.#error;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get AI Models config
|
|
107
|
+
* @anchor GENNADY_RC_METHOD_GET_MODELS
|
|
108
|
+
* @returns {AiModelInit[]} array of #AI_MODEL_INIT
|
|
109
|
+
*/
|
|
110
|
+
getModels() {
|
|
111
|
+
return this.#data.models;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get config parse error
|
|
116
|
+
* @anchor GENNADY_RC_GET_ERROR
|
|
117
|
+
* @returns {Error|null}
|
|
118
|
+
*/
|
|
119
|
+
getError() {
|
|
120
|
+
return this.#error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, test, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { GennadyRc } from './rc-config.js';
|
|
7
|
+
|
|
8
|
+
let tempDir;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tempDir = mkdtempSync(join(tmpdir(), 'gennadyrc-test-'));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
try {
|
|
16
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
17
|
+
} catch {}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('GennadyRC', () => {
|
|
21
|
+
test('Valid config: object with models', () => {
|
|
22
|
+
const config = { models: [{ model: 'test-model', url: 'http://localhost' }] };
|
|
23
|
+
const file = join(tempDir, '.gennadyrc');
|
|
24
|
+
writeFileSync(file, JSON.stringify(config));
|
|
25
|
+
const rc = new GennadyRc(tempDir);
|
|
26
|
+
assert.strictEqual(rc.isValid(), true, 'Config should be valid');
|
|
27
|
+
assert.deepStrictEqual(rc.getModels(), config.models, 'Models should match');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('Legacy config: array of models', () => {
|
|
31
|
+
const config = [{ model: 'legacy', url: 'http://legacy' }];
|
|
32
|
+
const file = join(tempDir, '.gennadyrc');
|
|
33
|
+
writeFileSync(file, JSON.stringify(config));
|
|
34
|
+
const rc = new GennadyRc(tempDir);
|
|
35
|
+
assert.strictEqual(rc.isValid(), true, 'Legacy config should be valid');
|
|
36
|
+
assert.deepStrictEqual(rc.getModels(), config, 'Legacy models should match');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('Invalid JSON', () => {
|
|
40
|
+
const file = join(tempDir, '.gennadyrc');
|
|
41
|
+
writeFileSync(file, '{ invalid json');
|
|
42
|
+
const rc = new GennadyRc(tempDir);
|
|
43
|
+
assert.strictEqual(rc.isValid(), false, 'Config with invalid JSON should be invalid');
|
|
44
|
+
assert(rc.getError() instanceof Error, 'Should have error');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('Invalid structure: object without models', () => {
|
|
48
|
+
const config = { foo: 'bar' };
|
|
49
|
+
const file = join(tempDir, '.gennadyrc');
|
|
50
|
+
writeFileSync(file, JSON.stringify(config));
|
|
51
|
+
const rc = new GennadyRc(tempDir);
|
|
52
|
+
assert.strictEqual(rc.isValid(), false, 'Config without models should be invalid');
|
|
53
|
+
assert(rc.getError() instanceof Error, 'Should have error');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('Missing file', () => {
|
|
57
|
+
const rc = new GennadyRc(tempDir);
|
|
58
|
+
assert.strictEqual(rc.isValid(), true, 'Config should be valid if file is missing');
|
|
59
|
+
assert.deepStrictEqual(rc.getModels(), [], 'Models should be empty if file is missing');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -5,7 +5,7 @@ import { test, describe } from 'node:test';
|
|
|
5
5
|
import assert from 'node:assert';
|
|
6
6
|
import { ReviewGen } from './review-gen.js';
|
|
7
7
|
|
|
8
|
-
describe('review', async () => {
|
|
8
|
+
describe.skip('review', async () => {
|
|
9
9
|
const reviewGen = new ReviewGen();
|
|
10
10
|
const fixtures = getFixture();
|
|
11
11
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Synchronously unwraps a result tuple #UNGUARD_FUNCTION */
|
|
2
|
+
declare function unguard<TResult>(result: [TResult, null] | [null, Error]): TResult;
|
|
3
|
+
|
|
4
|
+
/** Asynchronously unwraps a result, which can be either a direct tuple `[result, error]` #UNGUARD_OR_THROW_FUNCTION */
|
|
5
|
+
declare function unguardOrThrow<TResult>(
|
|
6
|
+
resultOrPromise: [TResult, null] | [null, Error] | Promise<[TResult, null] | [null, Error]>
|
|
7
|
+
): Promise<TResult>;
|
|
8
|
+
|
|
9
|
+
export { unguard, unguardOrThrow };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronously unwraps a result tuple.
|
|
3
|
+
* If the tuple is malformed or indicates an error (assuming the error is already an Error instance),
|
|
4
|
+
* this function throws that error. Otherwise, it returns the successful result.
|
|
5
|
+
*
|
|
6
|
+
* @template TResult The type of the result expected if the tuple represents a successful outcome.
|
|
7
|
+
*
|
|
8
|
+
* @param {unknown} resultTuple - The input, expected to be a tuple `[result, error]`.
|
|
9
|
+
* Specifically, `[TResult, null]` for success, or `[null, Error]` for failure.
|
|
10
|
+
*
|
|
11
|
+
* @returns {TResult} The unwrapped result if the tuple represents a successful outcome.
|
|
12
|
+
* @throws {Error} - Throws the error from `resultTuple[1]` if present.
|
|
13
|
+
* - Throws a generic `Error` if `resultTuple` is not a valid 2-element array.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const unguard = (result) => {
|
|
17
|
+
if (Array.isArray(result) && result.length === 2) {
|
|
18
|
+
const error = result[1];
|
|
19
|
+
|
|
20
|
+
if (error === null) {
|
|
21
|
+
return result[0];
|
|
22
|
+
} else {
|
|
23
|
+
throw error instanceof Error
|
|
24
|
+
? error
|
|
25
|
+
: new TypeError(`[UNGUARD_ERROR_TYPE] Error "${error}"`, {cause: error})
|
|
26
|
+
;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
throw new SyntaxError('[UNGUARD_ERROR_SYNTAX] Invalid input: not a valid [result, error] tuple.', { cause: result });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Asynchronously unwraps a result, which can be either a direct tuple `[result, error]`
|
|
35
|
+
* or a Promise resolving to such a tuple (typically from `guardedCall`).
|
|
36
|
+
* If the outcome indicates an error (either directly or after the Promise resolves),
|
|
37
|
+
* this function throws that error. Otherwise, it returns the successful result.
|
|
38
|
+
*
|
|
39
|
+
* @async
|
|
40
|
+
* @template TResult The type of the result expected if the operation was successful.
|
|
41
|
+
*
|
|
42
|
+
* @param {[TResult, null] | [null, Error] | Promise<([TResult, null] | [null, Error])>} resultOrPromise
|
|
43
|
+
* The outcome to unwrap. This can be:
|
|
44
|
+
* 1. A direct tuple: `[successfulResult, null]`
|
|
45
|
+
* 2. A direct tuple: `[null, errorInstance]`
|
|
46
|
+
* 3. A Promise that resolves to one of the above tuples.
|
|
47
|
+
*
|
|
48
|
+
* @returns {Promise<TResult>} A Promise that resolves to the unwrapped successful result.
|
|
49
|
+
* @throws {Error} - Throws the error from the tuple if present (either directly or after
|
|
50
|
+
* the Promise resolves).
|
|
51
|
+
* - Throws a generic `Error` if the resolved value from a Promise is not
|
|
52
|
+
* a valid 2-element `[result, error]` tuple.
|
|
53
|
+
*/
|
|
54
|
+
export const unguardOrThrow = async (resultOrPromise) => {
|
|
55
|
+
const result = await resultOrPromise;
|
|
56
|
+
return unguard(result);
|
|
57
|
+
};
|