oxlint-plugin-jev 0.0.1 → 0.1.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/LICENSE +21 -0
- package/README.md +74 -107
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +296 -0
- package/dist/jev-D-pXMM0s.mjs +63 -0
- package/dist/sync-jev.d.mts +5 -0
- package/dist/sync-jev.mjs +28 -0
- package/dist/types-cmFf_yHJ.d.mts +53 -0
- package/dist/worker.d.mts +1 -0
- package/dist/worker.mjs +40 -0
- package/package.json +46 -25
- package/src/cache.js +0 -21
- package/src/index.js +0 -172
- package/src/jev.js +0 -43
- package/src/options.js +0 -72
- package/src/sync-fetch.js +0 -26
- package/src/types.d.ts +0 -41
- package/src/worker.js +0 -41
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Soriano
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,143 +1,107 @@
|
|
|
1
1
|
# oxlint-plugin-jev
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> [!WARNING]
|
|
4
|
+
> This package is experimental. Use at your own risk.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
[Oxlint](https://oxc.rs/docs/guide/usage/linter.html) rules written in plain English, answered by [TypeSafe Jev](https://typesafe.ai).
|
|
7
|
+
|
|
8
|
+
A rule is a yes/no question about a function, a call, a JSX element, or a whole file. Each match is sent to Jev with the question, and the plugin reports an error when the yes-probability clears your cutoff.
|
|
6
9
|
|
|
7
10
|
## Install
|
|
8
11
|
|
|
9
12
|
```sh
|
|
10
13
|
npm i -D oxlint oxlint-plugin-jev
|
|
11
|
-
export TYPESAFE_API_KEY="..." #
|
|
14
|
+
export TYPESAFE_API_KEY="..." # https://console.typesafe.ai
|
|
12
15
|
```
|
|
13
16
|
|
|
14
|
-
## Config
|
|
17
|
+
## Config
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
Add the plugin and its one rule, `jev/ask`, to `.oxlintrc.json`. Your English rules go in the options.
|
|
17
20
|
|
|
18
21
|
```json
|
|
19
22
|
{
|
|
20
23
|
"jsPlugins": ["oxlint-plugin-jev"],
|
|
21
24
|
"rules": {
|
|
22
|
-
"jev/ask": [
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
25
|
+
"jev/ask": [
|
|
26
|
+
"error",
|
|
27
|
+
{
|
|
28
|
+
"rules": [
|
|
29
|
+
{
|
|
30
|
+
"id": "no-pii-in-logs",
|
|
31
|
+
"target": "call",
|
|
32
|
+
"question": "Does this call write personal data, such as an email or phone number, to a log or console?",
|
|
33
|
+
"cutoff": 0.8
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "name-matches-behavior",
|
|
37
|
+
"target": "function",
|
|
38
|
+
"question": "Does this function's name imply it only reads data, while its body also writes or sends something?",
|
|
39
|
+
"cutoff": 0.6
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
]
|
|
37
44
|
}
|
|
38
45
|
}
|
|
39
46
|
```
|
|
40
47
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
| Field | Type | Meaning |
|
|
44
|
-
| ---------- | --------------------------------------------- | ---------------------------------------------------------- |
|
|
45
|
-
| `id` | string, unique in the list | Shown in the lint message. |
|
|
46
|
-
| `target` | `"function"` \| `"call"` \| `"jsx"` \| `"file"` | What to look at. See the table below. |
|
|
47
|
-
| `question` | string | One English yes/no question. "Yes" means "report an error". |
|
|
48
|
-
| `cutoff` | number in `[0, 1]` | Report when Jev's yes-probability is `>=` this. |
|
|
49
|
-
|
|
50
|
-
Target to AST node mapping.
|
|
48
|
+
Every rule has four fields.
|
|
51
49
|
|
|
52
|
-
|
|
|
53
|
-
|
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
57
|
-
| `
|
|
50
|
+
| Field | What it is |
|
|
51
|
+
| ---------- | ------------------------------------------------------------ |
|
|
52
|
+
| `id` | Shown in the error message. Unique in the list. |
|
|
53
|
+
| `target` | `"function"`, `"call"`, `"jsx"`, or `"file"`. |
|
|
54
|
+
| `question` | A yes/no question. "Yes" means "report this". |
|
|
55
|
+
| `cutoff` | 0 to 1. Report when Jev's yes-probability is at or above it. |
|
|
58
56
|
|
|
59
|
-
|
|
57
|
+
`target` decides what Jev gets to read.
|
|
60
58
|
|
|
61
|
-
|
|
59
|
+
| Target | Jev sees | The error underlines |
|
|
60
|
+
| ------------ | ------------------------------------------------------------------------------------------------------------ | -------------------- |
|
|
61
|
+
| `"function"` | The whole function. An arrow or method includes its name, so `const getUser = () => ...` reads as `getUser`. | The signature line |
|
|
62
|
+
| `"call"` | The whole call expression. | The whole call |
|
|
63
|
+
| `"jsx"` | The whole element, children included. | The opening tag |
|
|
64
|
+
| `"file"` | The whole file. | The first line |
|
|
62
65
|
|
|
63
|
-
|
|
64
|
-
| ------------------- | -------------- | --------------------------------------------------------------------------------------------------- |
|
|
65
|
-
| `ci` | `"skip"` | What to do when Jev cannot be asked (no key, timeout, HTTP error) and `process.env.CI` is set. `"skip"` warns once on stderr and reports nothing. `"fail"` throws, which makes the oxlint run fail. Outside CI the plugin always warns and skips. |
|
|
66
|
-
| `timeoutMs` | `10000` | Per-file request timeout. |
|
|
67
|
-
| `maxMatchesPerFile` | `25` | Cap on snippets sent per file across all rules. Matches past the cap are dropped in source order. |
|
|
68
|
-
| `maxSnippetChars` | `4000` | Snippets longer than this are cut and end with `/* ...truncated */`. |
|
|
69
|
-
| `model` | `"jev-latest"` | TypeSafe model id. |
|
|
66
|
+
These four targets are the whole set.
|
|
70
67
|
|
|
71
|
-
|
|
68
|
+
The wording of the question is the rule, so be precise about what counts. "Does this send personal data" also fires on a legitimate `mailer.send(user.email, ...)`. "To a log or console" does not.
|
|
72
69
|
|
|
73
|
-
|
|
70
|
+
Optional settings, with their defaults.
|
|
74
71
|
|
|
75
|
-
|
|
|
76
|
-
|
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
79
|
-
| `
|
|
72
|
+
| Field | Default | Meaning |
|
|
73
|
+
| ------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
74
|
+
| `ci` | `"skip"` | What happens when Jev can't be asked and `CI` is set. `"skip"` warns once and reports nothing. `"fail"` fails the run. Outside CI it always skips. |
|
|
75
|
+
| `timeoutMs` | `10000` | Per-file request timeout, retries included. |
|
|
76
|
+
| `maxMatchesPerFile` | `25` | Snippets sent per file across all rules. Extra matches are dropped in source order. |
|
|
77
|
+
| `maxSnippetChars` | `4000` | Longer snippets are cut and end with `/* ...truncated */`. |
|
|
78
|
+
| `model` | `"jev-latest"` | TypeSafe model id. Pin a versioned id such as `"jev-1.13.0"` in CI once your cutoffs are tuned, so a new build cannot move them. Every diagnostic names the version that answered. |
|
|
80
79
|
|
|
81
|
-
|
|
80
|
+
Oxlint checks the options against a schema before linting anything, so a typo in `target` or an unknown field fails at startup with a clear message.
|
|
82
81
|
|
|
83
|
-
|
|
82
|
+
Two environment variables matter. `TYPESAFE_API_KEY` is required. `TYPESAFE_BASE_URL` overrides the API host and is mostly for tests.
|
|
84
83
|
|
|
85
|
-
|
|
86
|
-
2. **Key.** On `Program:exit`, compute `sha256(JSON({ v: 1, model, rules, maxSnippetChars, maxMatchesPerFile, text }))`. `text` is the full file source, so any edit invalidates the key. `rules` is reduced to each rule's `target` and `question` in order, so editing a question invalidates the key while tuning a `cutoff` or renaming an `id` reuses the cached verdicts, which do not depend on either.
|
|
87
|
-
3. **Cache lookup.** Look for `node_modules/.cache/oxlint-plugin-jev/<key>.json` under the current working directory. On a hit, skip to step 6.
|
|
88
|
-
4. **Build one request.** All matches go in one body. `state` holds the snippets keyed by ref, and `questions` holds one `noul` (boolean) question per ref that names the ref it is about.
|
|
84
|
+
## How it works
|
|
89
85
|
|
|
90
|
-
|
|
91
|
-
{
|
|
92
|
-
"model": "jev-latest",
|
|
93
|
-
"state": {
|
|
94
|
-
"snippets": {
|
|
95
|
-
"s0": "console.log(\"loaded user\", user.email, user.phone)",
|
|
96
|
-
"s1": "export async function fetchWithRetry(url) {\n for (;;) {\n try {\n return await fetch(url);\n } catch (error) {\n console.warn(\"retrying\", url, error.message);\n }\n }\n}"
|
|
97
|
-
}
|
|
98
|
-
},
|
|
99
|
-
"questions": {
|
|
100
|
-
"s0": { "type": "noul", "instructions": "Consider only snippet \"s0\" in state.snippets. Does this call write a password, token, secret, or API key to a log, console, or error message?" },
|
|
101
|
-
"s1": { "type": "noul", "instructions": "Consider only snippet \"s1\" in state.snippets. Does this function retry a failed operation in a loop with no delay, no backoff, and no attempt limit?" }
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
```
|
|
86
|
+
One request per file. Every match goes into one request body and Jev answers all of the questions at once.
|
|
105
87
|
|
|
106
|
-
|
|
88
|
+
Answers are cached under `node_modules/.cache/oxlint-plugin-jev`, keyed by the request. Changing a snippet or a question re-asks that file. Changing a cutoff or an `id` does not. A cache entry that fails to parse is a miss.
|
|
107
89
|
|
|
108
|
-
|
|
109
|
-
{ "answers": { "s0": { "type": "noul", "noul": 0.93 }, "s1": { "type": "noul", "noul": 0.12 } } }
|
|
110
|
-
```
|
|
90
|
+
Oxlint rules are synchronous, so the request runs on a [`synckit`](https://github.com/un-ts/synckit) worker thread, where the official [`@typesafe-ai/sdk`](https://www.npmjs.com/package/@typesafe-ai/sdk) client sends it and retries rate limits and server errors within `timeoutMs`.
|
|
111
91
|
|
|
112
|
-
|
|
113
|
-
6. **Report.** For each match whose verdict is `>= rule.cutoff`, `context.report({ loc, messageId: "yes", data })`, with `loc` from the target's anchoring rule above, fills the rule's `meta.messages.yes` template, giving
|
|
92
|
+
If Jev can't be asked, because the key is missing, the request times out, or the API errors, the plugin prints one warning and reports nothing for that file. Set `ci: "fail"` to fail the run instead.
|
|
114
93
|
|
|
115
|
-
|
|
116
|
-
[no-pii-in-logs] Jev answered yes (0.97 >= 0.80): Does this call write personal data, such as an email address, phone number, full name, or a credential, to a log or console?
|
|
117
|
-
```
|
|
94
|
+
## In the editor
|
|
118
95
|
|
|
119
|
-
|
|
96
|
+
The oxlint VS Code extension lints as you type. Nearly every keystroke changes a snippet, so nearly every keystroke is a paid request that blocks the language server until Jev answers.
|
|
120
97
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
The oxlint VS Code extension runs JS plugins and lints on type by default. Every keystroke changes the file text, which changes the cache key, so every edit is a cache miss and a paid request that blocks the language server for the whole round trip.
|
|
124
|
-
|
|
125
|
-
Keep `jev/ask` out of the `.oxlintrc.json` your editor picks up, but leave `"jsPlugins": ["oxlint-plugin-jev"]` in it, since the overlay inherits `jsPlugins` through `extends`. Put the rule in an overlay config that `extends` the base one, and point CI and your pre-push hook at the overlay.
|
|
98
|
+
Keep `jev/ask` out of the config your editor reads, and put it in an overlay for CI and pre-push. Leave `jsPlugins` in the base config, since the overlay inherits it.
|
|
126
99
|
|
|
127
100
|
```json
|
|
128
101
|
{
|
|
129
102
|
"extends": [".oxlintrc.json"],
|
|
130
103
|
"rules": {
|
|
131
|
-
"jev/ask": ["error", {
|
|
132
|
-
"rules": [
|
|
133
|
-
{
|
|
134
|
-
"id": "no-pii-in-logs",
|
|
135
|
-
"target": "call",
|
|
136
|
-
"question": "Does this call write personal data, such as an email address, phone number, full name, or a credential, to a log or console?",
|
|
137
|
-
"cutoff": 0.8
|
|
138
|
-
}
|
|
139
|
-
]
|
|
140
|
-
}]
|
|
104
|
+
"jev/ask": ["error", { "rules": [ ... ] }]
|
|
141
105
|
}
|
|
142
106
|
}
|
|
143
107
|
```
|
|
@@ -149,15 +113,13 @@ oxlint -c .oxlintrc.ci.json # CI and pre-push, Jev included
|
|
|
149
113
|
|
|
150
114
|
## Example
|
|
151
115
|
|
|
152
|
-
`example/` has
|
|
116
|
+
`example/` has three rules and two files. `fail.js` gets three errors, `pass.js` gets none. Each rule catches something a pattern-based linter can't express.
|
|
153
117
|
|
|
154
|
-
| Rule
|
|
155
|
-
|
|
|
156
|
-
| `no-pii-in-logs`
|
|
157
|
-
| `name-matches-behavior` |
|
|
158
|
-
| `no-
|
|
159
|
-
| `no-prompt-injection` | function | A prompt built by concatenating a customer message into the system instructions, while passing it as a separate user message passes. |
|
|
160
|
-
| `no-shipped-hacks` | file | A comment admitting the code is a temporary hack, in any wording. |
|
|
118
|
+
| Rule | Fails on | Passes on |
|
|
119
|
+
| ----------------------- | ------------------------------------------------------- | ------------------------------------------- |
|
|
120
|
+
| `no-pii-in-logs` | `console.log("loaded", user.email, user.phone)` | `console.log("notified", { userId: id })` |
|
|
121
|
+
| `name-matches-behavior` | `getUser` that also sends an email | The same body named `notifySignIn` |
|
|
122
|
+
| `no-prompt-injection` | A customer message pasted into the system prompt string | The message passed as a separate user field |
|
|
161
123
|
|
|
162
124
|
```sh
|
|
163
125
|
npm run example
|
|
@@ -165,8 +127,13 @@ npm run example
|
|
|
165
127
|
|
|
166
128
|
## Development
|
|
167
129
|
|
|
130
|
+
TypeScript, built with [Vite+](https://viteplus.dev). ESM only.
|
|
131
|
+
|
|
168
132
|
```sh
|
|
169
|
-
npm
|
|
133
|
+
npm run build # src/ to dist/
|
|
134
|
+
npm run check # format, lint, typecheck
|
|
135
|
+
npm test # builds first, since the worker tests run against dist/
|
|
136
|
+
JEV_LIVE=1 TYPESAFE_API_KEY=... npm test # also runs example/ against the real API
|
|
170
137
|
```
|
|
171
138
|
|
|
172
139
|
## License
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { a as JevPlugin, i as JevOptions, o as JevRule, r as CiBehavior, s as Target } from "./types-cmFf_yHJ.mjs";
|
|
2
|
+
//#region src/index.d.ts
|
|
3
|
+
declare const plugin: JevPlugin;
|
|
4
|
+
//#endregion
|
|
5
|
+
export { type CiBehavior, type JevOptions, type JevPlugin, type JevRule, type Target, plugin as default };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { a as refAt, i as parseVerdicts, n as cacheKey, o as truncateSnippet, r as messageOf, t as buildRequest } from "./jev-D-pXMM0s.mjs";
|
|
2
|
+
import { askJev } from "./sync-jev.mjs";
|
|
3
|
+
import { ENV } from "@typesafe-ai/sdk";
|
|
4
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
//#region src/cache.ts
|
|
7
|
+
const defaultCacheDir = () => path.join(process.cwd(), "node_modules", ".cache", "oxlint-plugin-jev");
|
|
8
|
+
function readCache(dir, key, refs) {
|
|
9
|
+
try {
|
|
10
|
+
const stored = JSON.parse(readFileSync(path.join(dir, `${key}.json`), "utf8"));
|
|
11
|
+
return parseVerdicts(stored, refs);
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function writeCache(dir, key, response) {
|
|
17
|
+
const target = path.join(dir, `${key}.json`);
|
|
18
|
+
const temp = `${target}.tmp-${process.pid}`;
|
|
19
|
+
mkdirSync(dir, { recursive: true });
|
|
20
|
+
writeFileSync(temp, JSON.stringify(response));
|
|
21
|
+
renameSync(temp, target);
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/options.ts
|
|
25
|
+
const TARGET_NODE_TYPES = {
|
|
26
|
+
function: [
|
|
27
|
+
"FunctionDeclaration",
|
|
28
|
+
"FunctionExpression",
|
|
29
|
+
"ArrowFunctionExpression"
|
|
30
|
+
],
|
|
31
|
+
call: ["CallExpression"],
|
|
32
|
+
jsx: ["JSXElement"],
|
|
33
|
+
file: ["Program"]
|
|
34
|
+
};
|
|
35
|
+
const NAMED_PARENTS = {
|
|
36
|
+
VariableDeclarator: "init",
|
|
37
|
+
Property: "value",
|
|
38
|
+
MethodDefinition: "value",
|
|
39
|
+
PropertyDefinition: "value"
|
|
40
|
+
};
|
|
41
|
+
const isNamedParentType = (type) => type in NAMED_PARENTS;
|
|
42
|
+
const ANONYMOUS_FUNCTIONS = /* @__PURE__ */ new Set(["FunctionExpression", "ArrowFunctionExpression"]);
|
|
43
|
+
function snippetNodeFor(node) {
|
|
44
|
+
const parent = node.parent;
|
|
45
|
+
if (parent === null || parent === void 0) return node;
|
|
46
|
+
const parentType = parent.type;
|
|
47
|
+
if (!isNamedParentType(parentType)) return node;
|
|
48
|
+
const field = NAMED_PARENTS[parentType];
|
|
49
|
+
return ANONYMOUS_FUNCTIONS.has(node.type) && parent[field] === node ? parent : node;
|
|
50
|
+
}
|
|
51
|
+
const DEFAULTS = {
|
|
52
|
+
ci: "skip",
|
|
53
|
+
timeoutMs: 1e4,
|
|
54
|
+
maxMatchesPerFile: 25,
|
|
55
|
+
maxSnippetChars: 4e3,
|
|
56
|
+
model: "jev-latest"
|
|
57
|
+
};
|
|
58
|
+
const SCHEMA = {
|
|
59
|
+
type: "object",
|
|
60
|
+
additionalProperties: false,
|
|
61
|
+
properties: {
|
|
62
|
+
ci: { enum: ["skip", "fail"] },
|
|
63
|
+
timeoutMs: {
|
|
64
|
+
type: "integer",
|
|
65
|
+
minimum: 1
|
|
66
|
+
},
|
|
67
|
+
maxMatchesPerFile: {
|
|
68
|
+
type: "integer",
|
|
69
|
+
minimum: 1
|
|
70
|
+
},
|
|
71
|
+
maxSnippetChars: {
|
|
72
|
+
type: "integer",
|
|
73
|
+
minimum: 1
|
|
74
|
+
},
|
|
75
|
+
model: {
|
|
76
|
+
type: "string",
|
|
77
|
+
minLength: 1
|
|
78
|
+
},
|
|
79
|
+
rules: {
|
|
80
|
+
type: "array",
|
|
81
|
+
minItems: 1,
|
|
82
|
+
items: {
|
|
83
|
+
type: "object",
|
|
84
|
+
additionalProperties: false,
|
|
85
|
+
required: [
|
|
86
|
+
"id",
|
|
87
|
+
"target",
|
|
88
|
+
"question",
|
|
89
|
+
"cutoff"
|
|
90
|
+
],
|
|
91
|
+
properties: {
|
|
92
|
+
id: {
|
|
93
|
+
type: "string",
|
|
94
|
+
minLength: 1
|
|
95
|
+
},
|
|
96
|
+
target: { enum: Object.keys(TARGET_NODE_TYPES) },
|
|
97
|
+
question: {
|
|
98
|
+
type: "string",
|
|
99
|
+
minLength: 1
|
|
100
|
+
},
|
|
101
|
+
cutoff: {
|
|
102
|
+
type: "number",
|
|
103
|
+
minimum: 0,
|
|
104
|
+
maximum: 1
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
function fail(message) {
|
|
112
|
+
throw new Error(`oxlint-plugin-jev: ${message}`);
|
|
113
|
+
}
|
|
114
|
+
function checkOptions(raw) {
|
|
115
|
+
if (typeof raw !== "object" || raw === null) fail("options must be an object");
|
|
116
|
+
const options = raw;
|
|
117
|
+
if (!Array.isArray(options.rules)) fail("options.rules must list at least one rule");
|
|
118
|
+
const seen = /* @__PURE__ */ new Set();
|
|
119
|
+
for (const { id } of options.rules) {
|
|
120
|
+
if (seen.has(id)) fail(`rule id "${id}" is used more than once`);
|
|
121
|
+
seen.add(id);
|
|
122
|
+
}
|
|
123
|
+
return options;
|
|
124
|
+
}
|
|
125
|
+
function headOf(text, line, column) {
|
|
126
|
+
const newline = text.indexOf("\n");
|
|
127
|
+
const length = newline === -1 ? text.length : newline;
|
|
128
|
+
return {
|
|
129
|
+
start: {
|
|
130
|
+
line,
|
|
131
|
+
column
|
|
132
|
+
},
|
|
133
|
+
end: {
|
|
134
|
+
line,
|
|
135
|
+
column: column + length
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const REPORT_LOC = {
|
|
140
|
+
function: (node, text) => headOf(text, node.loc.start.line, node.loc.start.column),
|
|
141
|
+
call: (node) => node.loc,
|
|
142
|
+
jsx: (node) => (node.openingElement ?? node).loc,
|
|
143
|
+
file: (_node, text) => headOf(text, 1, 0)
|
|
144
|
+
};
|
|
145
|
+
//#endregion
|
|
146
|
+
//#region src/index.ts
|
|
147
|
+
const optionsByRaw = /* @__PURE__ */ new WeakMap();
|
|
148
|
+
const warnedReasons = /* @__PURE__ */ new Set();
|
|
149
|
+
let missingKeyRaised = false;
|
|
150
|
+
function warnOnce(reason, message) {
|
|
151
|
+
if (warnedReasons.has(reason)) return;
|
|
152
|
+
warnedReasons.add(reason);
|
|
153
|
+
console.warn(`oxlint-plugin-jev: ${message}`);
|
|
154
|
+
}
|
|
155
|
+
const DEFAULT_BASE_URL = "https://api.typesafe.ai";
|
|
156
|
+
const baseURL = () => ((process.env[ENV.baseURL] ?? "").trim() || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
157
|
+
const snippetOf = (sourceCode, node) => node.type === "Program" ? sourceCode.text : sourceCode.getText(node);
|
|
158
|
+
function optionsFor(raw) {
|
|
159
|
+
if (typeof raw !== "object" || raw === null) return checkOptions(raw);
|
|
160
|
+
const cached = optionsByRaw.get(raw);
|
|
161
|
+
if (cached !== void 0) return cached;
|
|
162
|
+
const options = checkOptions(raw);
|
|
163
|
+
optionsByRaw.set(raw, options);
|
|
164
|
+
return options;
|
|
165
|
+
}
|
|
166
|
+
function rulesByNodeType(rules) {
|
|
167
|
+
const byType = /* @__PURE__ */ new Map();
|
|
168
|
+
for (const rule of rules) for (const type of TARGET_NODE_TYPES[rule.target]) byType.set(type, [...byType.get(type) ?? [], rule]);
|
|
169
|
+
return byType;
|
|
170
|
+
}
|
|
171
|
+
function degrade(context, options, reason) {
|
|
172
|
+
if (process.env.CI && options.ci === "fail") throw new Error(`oxlint-plugin-jev: ${reason} (${context.filename})`);
|
|
173
|
+
warnOnce(reason, `${reason} (${context.filename})`);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
function verdictsFor(context, options, matches, apiKey) {
|
|
177
|
+
const dir = defaultCacheDir();
|
|
178
|
+
const url = baseURL();
|
|
179
|
+
const request = buildRequest(options.model, matches);
|
|
180
|
+
const key = cacheKey({
|
|
181
|
+
endpoint: url,
|
|
182
|
+
request
|
|
183
|
+
});
|
|
184
|
+
const refs = matches.map((_, index) => refAt(index));
|
|
185
|
+
const cached = readCache(dir, key, refs);
|
|
186
|
+
if (cached !== null) return cached;
|
|
187
|
+
const result = askJev({
|
|
188
|
+
apiKey,
|
|
189
|
+
baseURL: url,
|
|
190
|
+
request,
|
|
191
|
+
timeoutMs: options.timeoutMs
|
|
192
|
+
});
|
|
193
|
+
if (!result.ok) return degrade(context, options, result.reason);
|
|
194
|
+
let verdicts;
|
|
195
|
+
try {
|
|
196
|
+
verdicts = parseVerdicts(result.json, refs);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
return degrade(context, options, messageOf(error));
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
writeCache(dir, key, result.json);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
warnOnce("cache-write", `could not write cache in ${dir}: ${messageOf(error)}`);
|
|
204
|
+
}
|
|
205
|
+
return verdicts;
|
|
206
|
+
}
|
|
207
|
+
const byTypeByOptions = /* @__PURE__ */ new WeakMap();
|
|
208
|
+
function nodeTypeIndex(options) {
|
|
209
|
+
const cached = byTypeByOptions.get(options);
|
|
210
|
+
if (cached !== void 0) return cached;
|
|
211
|
+
const byType = rulesByNodeType(options.rules);
|
|
212
|
+
byTypeByOptions.set(options, byType);
|
|
213
|
+
return byType;
|
|
214
|
+
}
|
|
215
|
+
function createOnce(context) {
|
|
216
|
+
let pass = null;
|
|
217
|
+
const collect = (type, node) => {
|
|
218
|
+
if (pass === null) return;
|
|
219
|
+
const rules = pass.byType.get(type);
|
|
220
|
+
if (rules === void 0) return;
|
|
221
|
+
for (const rule of rules) {
|
|
222
|
+
if (pass.matches.length >= pass.options.maxMatchesPerFile) return;
|
|
223
|
+
const own = snippetOf(context.sourceCode, node);
|
|
224
|
+
const named = snippetNodeFor(node);
|
|
225
|
+
const text = named === node ? own : context.sourceCode.getText(named);
|
|
226
|
+
pass.matches.push({
|
|
227
|
+
rule,
|
|
228
|
+
loc: REPORT_LOC[rule.target](node, own),
|
|
229
|
+
snippet: truncateSnippet(text, pass.options.maxSnippetChars)
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
const visitors = {};
|
|
234
|
+
for (const type of Object.values(TARGET_NODE_TYPES).flat()) visitors[type] = (node) => collect(type, node);
|
|
235
|
+
visitors.Program = (node) => {
|
|
236
|
+
const options = optionsFor(context.options[0]);
|
|
237
|
+
const apiKey = (process.env[ENV.apiKey] ?? "").trim();
|
|
238
|
+
if (apiKey.length === 0) {
|
|
239
|
+
pass = null;
|
|
240
|
+
if (process.env.CI && options.ci === "fail") {
|
|
241
|
+
if (missingKeyRaised) return;
|
|
242
|
+
missingKeyRaised = true;
|
|
243
|
+
throw new Error("oxlint-plugin-jev: TYPESAFE_API_KEY is not set");
|
|
244
|
+
}
|
|
245
|
+
warnOnce("missing-key", "TYPESAFE_API_KEY is not set, skipping Jev checks");
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
pass = {
|
|
249
|
+
options,
|
|
250
|
+
apiKey,
|
|
251
|
+
byType: nodeTypeIndex(options),
|
|
252
|
+
matches: []
|
|
253
|
+
};
|
|
254
|
+
collect("Program", node);
|
|
255
|
+
};
|
|
256
|
+
visitors["Program:exit"] = () => {
|
|
257
|
+
const collected = pass;
|
|
258
|
+
pass = null;
|
|
259
|
+
if (collected === null || collected.matches.length === 0) return;
|
|
260
|
+
const verdicts = verdictsFor(context, collected.options, collected.matches, collected.apiKey);
|
|
261
|
+
if (verdicts === null) return;
|
|
262
|
+
collected.matches.forEach((match, index) => {
|
|
263
|
+
const score = verdicts.scores[refAt(index)];
|
|
264
|
+
if (score >= match.rule.cutoff) {
|
|
265
|
+
const { id, cutoff, question } = match.rule;
|
|
266
|
+
context.report({
|
|
267
|
+
loc: match.loc,
|
|
268
|
+
messageId: "yes",
|
|
269
|
+
data: {
|
|
270
|
+
id,
|
|
271
|
+
model: verdicts.model,
|
|
272
|
+
score: score.toFixed(2),
|
|
273
|
+
cutoff: cutoff.toFixed(2),
|
|
274
|
+
question
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
return visitors;
|
|
281
|
+
}
|
|
282
|
+
const plugin = {
|
|
283
|
+
meta: { name: "jev" },
|
|
284
|
+
rules: { ask: {
|
|
285
|
+
meta: {
|
|
286
|
+
type: "problem",
|
|
287
|
+
docs: { description: "Ask TypeSafe Jev a plain-English yes/no question about matched code and report when the yes-probability clears the rule's cutoff." },
|
|
288
|
+
schema: [SCHEMA],
|
|
289
|
+
defaultOptions: [DEFAULTS],
|
|
290
|
+
messages: { yes: "[{{id}}] {{model}} answered yes ({{score}} >= {{cutoff}}): {{question}}" }
|
|
291
|
+
},
|
|
292
|
+
createOnce
|
|
293
|
+
} }
|
|
294
|
+
};
|
|
295
|
+
//#endregion
|
|
296
|
+
export { plugin as default };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
//#region src/jev.ts
|
|
3
|
+
const messageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
4
|
+
const refAt = (index) => `s${index}`;
|
|
5
|
+
function truncateSnippet(text, maxChars) {
|
|
6
|
+
return text.length <= maxChars ? text : `${text.slice(0, maxChars)}/* ...truncated */`;
|
|
7
|
+
}
|
|
8
|
+
function buildRequest(model, matches) {
|
|
9
|
+
const snippets = {};
|
|
10
|
+
const questions = {};
|
|
11
|
+
matches.forEach((match, index) => {
|
|
12
|
+
const ref = refAt(index);
|
|
13
|
+
snippets[ref] = match.snippet;
|
|
14
|
+
questions[ref] = {
|
|
15
|
+
type: "noul",
|
|
16
|
+
instructions: `Consider only snippet "${ref}" in state.snippets. ${match.rule.question}`
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
model,
|
|
21
|
+
state: { snippets },
|
|
22
|
+
questions
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function answersOf(json) {
|
|
26
|
+
if (typeof json === "object" && json !== null && "answers" in json) {
|
|
27
|
+
const { answers } = json;
|
|
28
|
+
if (typeof answers === "object" && answers !== null && !Array.isArray(answers)) return answers;
|
|
29
|
+
}
|
|
30
|
+
throw new Error("response has no answers object");
|
|
31
|
+
}
|
|
32
|
+
function modelOf(json) {
|
|
33
|
+
if (typeof json === "object" && json !== null && "model" in json) {
|
|
34
|
+
const { model } = json;
|
|
35
|
+
if (typeof model === "string" && model.length > 0) return model;
|
|
36
|
+
}
|
|
37
|
+
throw new Error("response has no model id");
|
|
38
|
+
}
|
|
39
|
+
const noulOf = (answer) => typeof answer === "object" && answer !== null && "noul" in answer ? answer.noul : void 0;
|
|
40
|
+
function parseVerdicts(json, refs) {
|
|
41
|
+
const model = modelOf(json);
|
|
42
|
+
const answers = answersOf(json);
|
|
43
|
+
const scores = {};
|
|
44
|
+
for (const ref of refs) {
|
|
45
|
+
const noul = noulOf(answers[ref]);
|
|
46
|
+
if (typeof noul !== "number" || !(noul >= 0 && noul <= 1)) throw new Error(`response has no probability in [0, 1] for "${ref}"`);
|
|
47
|
+
scores[ref] = noul;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
model,
|
|
51
|
+
scores
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function cacheKey({ endpoint, request }) {
|
|
55
|
+
const payload = JSON.stringify({
|
|
56
|
+
v: 3,
|
|
57
|
+
endpoint,
|
|
58
|
+
request
|
|
59
|
+
});
|
|
60
|
+
return createHash("sha256").update(payload).digest("hex");
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
export { refAt as a, parseVerdicts as i, cacheKey as n, truncateSnippet as o, messageOf as r, buildRequest as t };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { r as messageOf } from "./jev-D-pXMM0s.mjs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { createSyncFn } from "synckit";
|
|
4
|
+
//#region src/sync-jev.ts
|
|
5
|
+
const BACKSTOP_MS = 6e4;
|
|
6
|
+
const SYNCKIT_TIMEOUT = "Internal error: Atomics.wait() failed: timed-out";
|
|
7
|
+
let call = null;
|
|
8
|
+
function jevCall() {
|
|
9
|
+
call ??= createSyncFn(fileURLToPath(new URL("./worker.mjs", import.meta.url)), { timeout: BACKSTOP_MS });
|
|
10
|
+
return call;
|
|
11
|
+
}
|
|
12
|
+
function askJev(input) {
|
|
13
|
+
try {
|
|
14
|
+
return jevCall()(input);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
const message = messageOf(error);
|
|
17
|
+
if (message === SYNCKIT_TIMEOUT) return {
|
|
18
|
+
ok: false,
|
|
19
|
+
reason: "timeout"
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
ok: false,
|
|
23
|
+
reason: `network: ${message}`
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
export { askJev };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { NoulQuestion } from "@typesafe-ai/sdk";
|
|
2
|
+
//#region src/types.d.ts
|
|
3
|
+
type Target = 'function' | 'call' | 'jsx' | 'file';
|
|
4
|
+
type CiBehavior = 'skip' | 'fail';
|
|
5
|
+
interface JevRule {
|
|
6
|
+
/** Unique within the rule list. Shown in the lint message. */
|
|
7
|
+
id: string;
|
|
8
|
+
target: Target;
|
|
9
|
+
/** One English yes/no question. "Yes" means "report an error". */
|
|
10
|
+
question: string;
|
|
11
|
+
/** Report when Jev's yes-probability is >= this. Between 0 and 1. */
|
|
12
|
+
cutoff: number;
|
|
13
|
+
}
|
|
14
|
+
interface JevOptions {
|
|
15
|
+
ci?: CiBehavior;
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
maxMatchesPerFile?: number;
|
|
18
|
+
maxSnippetChars?: number;
|
|
19
|
+
model?: string;
|
|
20
|
+
rules: JevRule[];
|
|
21
|
+
}
|
|
22
|
+
interface JevRequest {
|
|
23
|
+
model: string;
|
|
24
|
+
state: {
|
|
25
|
+
snippets: Record<string, string>;
|
|
26
|
+
};
|
|
27
|
+
questions: Record<string, NoulQuestion>;
|
|
28
|
+
}
|
|
29
|
+
interface AskInput {
|
|
30
|
+
apiKey: string;
|
|
31
|
+
baseURL: string;
|
|
32
|
+
request: JevRequest;
|
|
33
|
+
timeoutMs: number;
|
|
34
|
+
}
|
|
35
|
+
type AskResult = {
|
|
36
|
+
ok: true;
|
|
37
|
+
json: unknown;
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
reason: string;
|
|
41
|
+
};
|
|
42
|
+
/** The public shape of the default export. Consumers load the plugin by name, so the rule
|
|
43
|
+
* internals are deliberately not part of the published type. */
|
|
44
|
+
interface JevPlugin {
|
|
45
|
+
meta: {
|
|
46
|
+
name: string;
|
|
47
|
+
};
|
|
48
|
+
rules: {
|
|
49
|
+
ask: object;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
export { JevPlugin as a, JevOptions as i, AskResult as n, JevRule as o, CiBehavior as r, Target as s, AskInput as t };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/dist/worker.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { r as messageOf } from "./jev-D-pXMM0s.mjs";
|
|
2
|
+
import { APIError, APITimeoutError, APIUserAbortError, TypeSafeClient } from "@typesafe-ai/sdk";
|
|
3
|
+
import { runAsWorker } from "synckit";
|
|
4
|
+
//#region src/worker.ts
|
|
5
|
+
let cached = null;
|
|
6
|
+
function clientWith(apiKey, baseURL) {
|
|
7
|
+
const key = JSON.stringify([apiKey, baseURL]);
|
|
8
|
+
if (cached === null || cached.key !== key) cached = {
|
|
9
|
+
key,
|
|
10
|
+
client: new TypeSafeClient({
|
|
11
|
+
apiKey,
|
|
12
|
+
baseURL
|
|
13
|
+
})
|
|
14
|
+
};
|
|
15
|
+
return cached.client;
|
|
16
|
+
}
|
|
17
|
+
const withoutStatus = (error) => error.message.replace(new RegExp(`^${error.status} `), "");
|
|
18
|
+
function reasonFor(error) {
|
|
19
|
+
if (error instanceof APIUserAbortError || error instanceof APITimeoutError) return "timeout";
|
|
20
|
+
if (error instanceof APIError) return `http ${error.status}: ${withoutStatus(error)}`;
|
|
21
|
+
return `network: ${messageOf(error)}`;
|
|
22
|
+
}
|
|
23
|
+
runAsWorker(async ({ apiKey, baseURL, request, timeoutMs }) => {
|
|
24
|
+
try {
|
|
25
|
+
return {
|
|
26
|
+
ok: true,
|
|
27
|
+
json: await clientWith(apiKey, baseURL).systemOne(request, {
|
|
28
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
29
|
+
timeout: timeoutMs
|
|
30
|
+
})
|
|
31
|
+
};
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
reason: reasonFor(error)
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
//#endregion
|
|
40
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,40 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxlint-plugin-jev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Oxlint JS plugin that lints code with plain-English rules answered by TypeSafe Jev.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"jev",
|
|
7
|
+
"lint",
|
|
8
|
+
"oxlint",
|
|
9
|
+
"oxlint-plugin",
|
|
10
|
+
"typesafe"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
5
16
|
"type": "module",
|
|
6
17
|
"exports": {
|
|
7
18
|
".": {
|
|
8
|
-
"types": "./
|
|
9
|
-
"default": "./
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
"types": "./src/types.d.ts",
|
|
13
|
-
"files": [
|
|
14
|
-
"src"
|
|
15
|
-
],
|
|
16
|
-
"engines": {
|
|
17
|
-
"node": ">= 20"
|
|
19
|
+
"types": "./dist/index.d.mts",
|
|
20
|
+
"default": "./dist/index.mjs"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
18
23
|
},
|
|
19
24
|
"scripts": {
|
|
20
|
-
"
|
|
21
|
-
"
|
|
25
|
+
"build": "vp pack",
|
|
26
|
+
"dev": "vp pack --watch",
|
|
27
|
+
"test": "vp run build && vp test",
|
|
28
|
+
"check": "vp check",
|
|
29
|
+
"lint": "vp lint",
|
|
30
|
+
"fmt": "vp fmt",
|
|
31
|
+
"example": "vp run build && ./node_modules/oxlint/bin/oxlint -c example/.oxlintrc.json example/",
|
|
32
|
+
"prepublishOnly": "vp run build"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@typesafe-ai/sdk": "^0.6.0",
|
|
36
|
+
"synckit": "^0.11.13"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@oxlint/plugins": "1.83.0",
|
|
40
|
+
"@types/node": "^26.1.1",
|
|
41
|
+
"oxlint": "1.83.0",
|
|
42
|
+
"typescript": "^7.0.2",
|
|
43
|
+
"vite-plus": "^0.2.4"
|
|
22
44
|
},
|
|
23
|
-
"keywords": [
|
|
24
|
-
"oxlint",
|
|
25
|
-
"oxlint-plugin",
|
|
26
|
-
"jev",
|
|
27
|
-
"typesafe",
|
|
28
|
-
"lint"
|
|
29
|
-
],
|
|
30
|
-
"license": "MIT",
|
|
31
45
|
"peerDependencies": {
|
|
32
46
|
"oxlint": ">=1.83.0"
|
|
33
47
|
},
|
|
34
|
-
"
|
|
35
|
-
"
|
|
48
|
+
"overrides": {
|
|
49
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.3"
|
|
36
50
|
},
|
|
37
|
-
"
|
|
38
|
-
"
|
|
51
|
+
"devEngines": {
|
|
52
|
+
"packageManager": {
|
|
53
|
+
"name": "npm",
|
|
54
|
+
"version": ">=11",
|
|
55
|
+
"onFail": "download"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">= 20"
|
|
39
60
|
}
|
|
40
61
|
}
|
package/src/cache.js
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
export const defaultCacheDir = () =>
|
|
5
|
-
path.join(process.cwd(), "node_modules", ".cache", "oxlint-plugin-jev");
|
|
6
|
-
|
|
7
|
-
export function readCache(dir, key) {
|
|
8
|
-
try {
|
|
9
|
-
return JSON.parse(readFileSync(path.join(dir, `${key}.json`), "utf8"));
|
|
10
|
-
} catch {
|
|
11
|
-
return null;
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function writeCache(dir, key, verdicts) {
|
|
16
|
-
const target = path.join(dir, `${key}.json`);
|
|
17
|
-
const temp = `${target}.tmp-${process.pid}`;
|
|
18
|
-
mkdirSync(dir, { recursive: true });
|
|
19
|
-
writeFileSync(temp, JSON.stringify(verdicts));
|
|
20
|
-
renameSync(temp, target);
|
|
21
|
-
}
|
package/src/index.js
DELETED
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
import { defaultCacheDir, readCache, writeCache } from "./cache.js";
|
|
2
|
-
import { buildRequest, cacheKey, parseVerdicts, refAt, truncateSnippet } from "./jev.js";
|
|
3
|
-
import { DEFAULTS, REPORT_LOC, SCHEMA, TARGET_NODE_TYPES, checkOptions } from "./options.js";
|
|
4
|
-
import { syncFetchJson } from "./sync-fetch.js";
|
|
5
|
-
|
|
6
|
-
const optionsByRaw = new WeakMap();
|
|
7
|
-
const warnedReasons = new Set();
|
|
8
|
-
let missingKeyRaised = false;
|
|
9
|
-
|
|
10
|
-
function warnOnce(reason, message) {
|
|
11
|
-
if (warnedReasons.has(reason)) return;
|
|
12
|
-
warnedReasons.add(reason);
|
|
13
|
-
console.warn(`oxlint-plugin-jev: ${message}`);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const endpoint = () =>
|
|
17
|
-
`${(process.env.TYPESAFE_API_URL || "https://api.typesafe.ai").replace(/\/+$/, "")}/v1/systemone`;
|
|
18
|
-
|
|
19
|
-
const snippetOf = (sourceCode, node) =>
|
|
20
|
-
node.type === "Program" ? sourceCode.text : sourceCode.getText(node);
|
|
21
|
-
|
|
22
|
-
function optionsFor(raw) {
|
|
23
|
-
const cached = optionsByRaw.get(raw);
|
|
24
|
-
if (cached !== undefined) return cached;
|
|
25
|
-
const options = checkOptions(raw);
|
|
26
|
-
optionsByRaw.set(raw, options);
|
|
27
|
-
return options;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function rulesByNodeType(rules) {
|
|
31
|
-
const byType = new Map();
|
|
32
|
-
for (const rule of rules) {
|
|
33
|
-
for (const type of TARGET_NODE_TYPES[rule.target]) {
|
|
34
|
-
byType.set(type, [...(byType.get(type) ?? []), rule]);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return byType;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function degrade(context, options, reason) {
|
|
41
|
-
if (process.env.CI && options.ci === "fail") {
|
|
42
|
-
throw new Error(`oxlint-plugin-jev: ${reason} (${context.filename})`);
|
|
43
|
-
}
|
|
44
|
-
warnOnce(reason, `${reason} (${context.filename})`);
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function verdictsFor(context, options, matches, apiKey) {
|
|
49
|
-
const dir = defaultCacheDir();
|
|
50
|
-
const key = cacheKey({
|
|
51
|
-
model: options.model,
|
|
52
|
-
rules: options.rules,
|
|
53
|
-
maxSnippetChars: options.maxSnippetChars,
|
|
54
|
-
maxMatchesPerFile: options.maxMatchesPerFile,
|
|
55
|
-
text: context.sourceCode.text,
|
|
56
|
-
});
|
|
57
|
-
const cached = readCache(dir, key);
|
|
58
|
-
if (cached !== null) return cached;
|
|
59
|
-
|
|
60
|
-
const result = syncFetchJson({
|
|
61
|
-
url: endpoint(),
|
|
62
|
-
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
63
|
-
body: JSON.stringify(buildRequest(options.model, matches)),
|
|
64
|
-
timeoutMs: options.timeoutMs,
|
|
65
|
-
});
|
|
66
|
-
if (!result.ok) return degrade(context, options, result.reason);
|
|
67
|
-
let verdicts;
|
|
68
|
-
try {
|
|
69
|
-
verdicts = parseVerdicts(result.json, matches.map((_, index) => refAt(index)));
|
|
70
|
-
} catch (error) {
|
|
71
|
-
return degrade(context, options, error.message);
|
|
72
|
-
}
|
|
73
|
-
try {
|
|
74
|
-
writeCache(dir, key, verdicts);
|
|
75
|
-
} catch (error) {
|
|
76
|
-
warnOnce("cache-write", `could not write cache in ${dir}: ${error.message}`);
|
|
77
|
-
}
|
|
78
|
-
return verdicts;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const byTypeByOptions = new WeakMap();
|
|
82
|
-
|
|
83
|
-
function nodeTypeIndex(options) {
|
|
84
|
-
const cached = byTypeByOptions.get(options);
|
|
85
|
-
if (cached !== undefined) return cached;
|
|
86
|
-
const byType = rulesByNodeType(options.rules);
|
|
87
|
-
byTypeByOptions.set(options, byType);
|
|
88
|
-
return byType;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function createOnce(context) {
|
|
92
|
-
let options;
|
|
93
|
-
let apiKey;
|
|
94
|
-
let matches = null;
|
|
95
|
-
let byType;
|
|
96
|
-
|
|
97
|
-
const collect = (type, node) => {
|
|
98
|
-
if (matches === null) return;
|
|
99
|
-
const rules = byType.get(type);
|
|
100
|
-
if (rules === undefined) return;
|
|
101
|
-
for (const rule of rules) {
|
|
102
|
-
if (matches.length >= options.maxMatchesPerFile) return;
|
|
103
|
-
const text = snippetOf(context.sourceCode, node);
|
|
104
|
-
matches.push({
|
|
105
|
-
rule,
|
|
106
|
-
loc: REPORT_LOC[rule.target](node, text),
|
|
107
|
-
snippet: truncateSnippet(text, options.maxSnippetChars),
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
const visitors = {};
|
|
113
|
-
for (const type of Object.values(TARGET_NODE_TYPES).flat()) {
|
|
114
|
-
visitors[type] = (node) => collect(type, node);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// `Program` carries the per-file setup as well as the `file` target, because oxlint does not
|
|
118
|
-
// guarantee `before` runs for every file.
|
|
119
|
-
visitors.Program = (node) => {
|
|
120
|
-
options = optionsFor(context.options[0]);
|
|
121
|
-
apiKey = (process.env.TYPESAFE_API_KEY ?? "").trim();
|
|
122
|
-
if (apiKey.length === 0) {
|
|
123
|
-
matches = null;
|
|
124
|
-
if (process.env.CI && options.ci === "fail") {
|
|
125
|
-
if (missingKeyRaised) return;
|
|
126
|
-
missingKeyRaised = true;
|
|
127
|
-
throw new Error("oxlint-plugin-jev: TYPESAFE_API_KEY is not set");
|
|
128
|
-
}
|
|
129
|
-
warnOnce("missing-key", "TYPESAFE_API_KEY is not set, skipping Jev checks");
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
matches = [];
|
|
133
|
-
byType = nodeTypeIndex(options);
|
|
134
|
-
collect("Program", node);
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
visitors["Program:exit"] = () => {
|
|
138
|
-
const collected = matches;
|
|
139
|
-
matches = null;
|
|
140
|
-
if (collected === null || collected.length === 0) return;
|
|
141
|
-
const verdicts = verdictsFor(context, options, collected, apiKey);
|
|
142
|
-
if (verdicts === null) return;
|
|
143
|
-
collected.forEach((match, index) => {
|
|
144
|
-
const score = verdicts[refAt(index)];
|
|
145
|
-
if (score >= match.rule.cutoff) {
|
|
146
|
-
const { id, cutoff, question } = match.rule;
|
|
147
|
-
context.report({
|
|
148
|
-
loc: match.loc,
|
|
149
|
-
messageId: "yes",
|
|
150
|
-
data: { id, score: score.toFixed(2), cutoff: cutoff.toFixed(2), question },
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
};
|
|
155
|
-
return visitors;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const meta = {
|
|
159
|
-
type: "problem",
|
|
160
|
-
docs: {
|
|
161
|
-
description:
|
|
162
|
-
"Ask TypeSafe Jev a plain-English yes/no question about matched code and report when the yes-probability clears the rule's cutoff.",
|
|
163
|
-
},
|
|
164
|
-
schema: [SCHEMA],
|
|
165
|
-
defaultOptions: [DEFAULTS],
|
|
166
|
-
messages: { yes: "[{{id}}] Jev answered yes ({{score}} >= {{cutoff}}): {{question}}" },
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
export default {
|
|
170
|
-
meta: { name: "jev" },
|
|
171
|
-
rules: { ask: { meta, createOnce } },
|
|
172
|
-
};
|
package/src/jev.js
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
|
|
3
|
-
export const refAt = (index) => `s${index}`;
|
|
4
|
-
|
|
5
|
-
export function truncateSnippet(text, maxChars) {
|
|
6
|
-
return text.length <= maxChars ? text : `${text.slice(0, maxChars)}/* ...truncated */`;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function buildRequest(model, matches) {
|
|
10
|
-
const snippets = {};
|
|
11
|
-
const questions = {};
|
|
12
|
-
matches.forEach((match, index) => {
|
|
13
|
-
const ref = refAt(index);
|
|
14
|
-
snippets[ref] = match.snippet;
|
|
15
|
-
questions[ref] = {
|
|
16
|
-
type: "noul",
|
|
17
|
-
instructions: `Consider only snippet "${ref}" in state.snippets. ${match.rule.question}`,
|
|
18
|
-
};
|
|
19
|
-
});
|
|
20
|
-
return { model, state: { snippets }, questions };
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function parseVerdicts(json, refs) {
|
|
24
|
-
const answers = json?.answers;
|
|
25
|
-
if (answers === null || typeof answers !== "object") {
|
|
26
|
-
throw new Error("response has no answers object");
|
|
27
|
-
}
|
|
28
|
-
const verdicts = {};
|
|
29
|
-
for (const ref of refs) {
|
|
30
|
-
const noul = answers[ref]?.noul;
|
|
31
|
-
if (typeof noul !== "number" || !Number.isFinite(noul)) {
|
|
32
|
-
throw new Error(`response has no numeric answer for "${ref}"`);
|
|
33
|
-
}
|
|
34
|
-
verdicts[ref] = noul;
|
|
35
|
-
}
|
|
36
|
-
return verdicts;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function cacheKey({ model, rules, maxSnippetChars, maxMatchesPerFile, text }) {
|
|
40
|
-
const asked = rules.map(({ target, question }) => ({ target, question }));
|
|
41
|
-
const payload = JSON.stringify({ v: 1, model, rules: asked, maxSnippetChars, maxMatchesPerFile, text });
|
|
42
|
-
return createHash("sha256").update(payload).digest("hex");
|
|
43
|
-
}
|
package/src/options.js
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
export const TARGET_NODE_TYPES = {
|
|
2
|
-
function: ["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"],
|
|
3
|
-
call: ["CallExpression"],
|
|
4
|
-
jsx: ["JSXElement"],
|
|
5
|
-
file: ["Program"],
|
|
6
|
-
};
|
|
7
|
-
|
|
8
|
-
export const DEFAULTS = {
|
|
9
|
-
ci: "skip",
|
|
10
|
-
timeoutMs: 10000,
|
|
11
|
-
maxMatchesPerFile: 25,
|
|
12
|
-
maxSnippetChars: 4000,
|
|
13
|
-
model: "jev-latest",
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
// `rules` is absent from DEFAULTS and oxlint validates DEFAULTS against this schema at
|
|
17
|
-
// plugin load, so `required: ["rules"]` here would stop the plugin loading at all.
|
|
18
|
-
export const SCHEMA = {
|
|
19
|
-
type: "object",
|
|
20
|
-
additionalProperties: false,
|
|
21
|
-
properties: {
|
|
22
|
-
ci: { enum: ["skip", "fail"] },
|
|
23
|
-
timeoutMs: { type: "integer", minimum: 1 },
|
|
24
|
-
maxMatchesPerFile: { type: "integer", minimum: 1 },
|
|
25
|
-
maxSnippetChars: { type: "integer", minimum: 1 },
|
|
26
|
-
model: { type: "string", minLength: 1 },
|
|
27
|
-
rules: {
|
|
28
|
-
type: "array",
|
|
29
|
-
minItems: 1,
|
|
30
|
-
items: {
|
|
31
|
-
type: "object",
|
|
32
|
-
additionalProperties: false,
|
|
33
|
-
required: ["id", "target", "question", "cutoff"],
|
|
34
|
-
properties: {
|
|
35
|
-
id: { type: "string", minLength: 1 },
|
|
36
|
-
target: { enum: Object.keys(TARGET_NODE_TYPES) },
|
|
37
|
-
question: { type: "string", minLength: 1 },
|
|
38
|
-
cutoff: { type: "number", minimum: 0, maximum: 1 },
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
|
-
},
|
|
42
|
-
},
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
function fail(message) {
|
|
46
|
-
throw new Error(`oxlint-plugin-jev: ${message}`);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function checkOptions(options) {
|
|
50
|
-
if (!Array.isArray(options?.rules)) {
|
|
51
|
-
fail("options.rules must list at least one rule");
|
|
52
|
-
}
|
|
53
|
-
const seen = new Set();
|
|
54
|
-
for (const { id } of options.rules) {
|
|
55
|
-
if (seen.has(id)) fail(`rule id "${id}" is used more than once`);
|
|
56
|
-
seen.add(id);
|
|
57
|
-
}
|
|
58
|
-
return options;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function headOf(text, line, column) {
|
|
62
|
-
const newline = text.indexOf("\n");
|
|
63
|
-
const length = newline === -1 ? text.length : newline;
|
|
64
|
-
return { start: { line, column }, end: { line, column: column + length } };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export const REPORT_LOC = {
|
|
68
|
-
function: (node, text) => headOf(text, node.loc.start.line, node.loc.start.column),
|
|
69
|
-
call: (node) => node.loc,
|
|
70
|
-
jsx: (node) => node.openingElement.loc,
|
|
71
|
-
file: (node, text) => headOf(text, 1, 0),
|
|
72
|
-
};
|
package/src/sync-fetch.js
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { fileURLToPath } from "node:url";
|
|
2
|
-
import { createSyncFn } from "synckit";
|
|
3
|
-
|
|
4
|
-
// synckit fixes its timeout when the sync fn is created, but ours is per call, so the
|
|
5
|
-
// worker owns the real deadline via AbortSignal.timeout and this is only a backstop.
|
|
6
|
-
const BACKSTOP_MS = 60000;
|
|
7
|
-
const SYNCKIT_TIMEOUT = "Internal error: Atomics.wait() failed: timed-out";
|
|
8
|
-
|
|
9
|
-
let call = null;
|
|
10
|
-
|
|
11
|
-
function jevCall() {
|
|
12
|
-
call ??= createSyncFn(fileURLToPath(new URL("./worker.js", import.meta.url)), {
|
|
13
|
-
timeout: BACKSTOP_MS,
|
|
14
|
-
});
|
|
15
|
-
return call;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function syncFetchJson({ url, headers, body, timeoutMs }) {
|
|
19
|
-
try {
|
|
20
|
-
return jevCall()({ url, headers, body, timeoutMs });
|
|
21
|
-
} catch (error) {
|
|
22
|
-
const message = error?.message ?? String(error);
|
|
23
|
-
if (message === SYNCKIT_TIMEOUT) return { ok: false, reason: "timeout" };
|
|
24
|
-
return { ok: false, reason: `network: ${message}` };
|
|
25
|
-
}
|
|
26
|
-
}
|
package/src/types.d.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
export type Target = "function" | "call" | "jsx" | "file";
|
|
2
|
-
|
|
3
|
-
export type CiBehavior = "skip" | "fail";
|
|
4
|
-
|
|
5
|
-
export interface JevRule {
|
|
6
|
-
/** Unique within the rule list. Shown in the lint message. */
|
|
7
|
-
id: string;
|
|
8
|
-
target: Target;
|
|
9
|
-
/** One English yes/no question. "Yes" means "report an error". */
|
|
10
|
-
question: string;
|
|
11
|
-
/** Report when Jev's yes-probability is >= this. Between 0 and 1. */
|
|
12
|
-
cutoff: number;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface JevOptions {
|
|
16
|
-
ci?: CiBehavior;
|
|
17
|
-
timeoutMs?: number;
|
|
18
|
-
maxMatchesPerFile?: number;
|
|
19
|
-
maxSnippetChars?: number;
|
|
20
|
-
model?: string;
|
|
21
|
-
rules: JevRule[];
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface JevPlugin {
|
|
25
|
-
meta: { name: "jev" };
|
|
26
|
-
rules: {
|
|
27
|
-
ask: {
|
|
28
|
-
meta: {
|
|
29
|
-
type: "problem";
|
|
30
|
-
docs: { description: string };
|
|
31
|
-
schema: [object];
|
|
32
|
-
defaultOptions: [Omit<JevOptions, "rules">];
|
|
33
|
-
messages: { yes: string };
|
|
34
|
-
};
|
|
35
|
-
createOnce(context: { options: [JevOptions] }): Record<string, (node: unknown) => void>;
|
|
36
|
-
};
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
declare const plugin: JevPlugin;
|
|
41
|
-
export default plugin;
|
package/src/worker.js
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { runAsWorker } from "synckit";
|
|
2
|
-
|
|
3
|
-
const failure = (error) =>
|
|
4
|
-
error?.name === "TimeoutError" || error?.cause?.name === "TimeoutError"
|
|
5
|
-
? { ok: false, reason: "timeout" }
|
|
6
|
-
: { ok: false, reason: `network: ${error?.message ?? String(error)}` };
|
|
7
|
-
|
|
8
|
-
function tryParse(text) {
|
|
9
|
-
try {
|
|
10
|
-
return { ok: true, value: JSON.parse(text) };
|
|
11
|
-
} catch {
|
|
12
|
-
return { ok: false };
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
async function run({ url, headers, body, timeoutMs }) {
|
|
17
|
-
const response = await fetch(url, {
|
|
18
|
-
method: "POST",
|
|
19
|
-
headers,
|
|
20
|
-
body,
|
|
21
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
22
|
-
});
|
|
23
|
-
const text = await response.text();
|
|
24
|
-
const parsed = tryParse(text);
|
|
25
|
-
if (!response.ok) {
|
|
26
|
-
const detail = parsed.value?.detail?.message ?? text.slice(0, 200);
|
|
27
|
-
return { ok: false, reason: `http ${response.status}: ${detail}` };
|
|
28
|
-
}
|
|
29
|
-
if (!parsed.ok) {
|
|
30
|
-
return { ok: false, reason: "invalid json" };
|
|
31
|
-
}
|
|
32
|
-
return { ok: true, status: response.status, json: parsed.value };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
runAsWorker(async (payload) => {
|
|
36
|
-
try {
|
|
37
|
-
return await run(payload);
|
|
38
|
-
} catch (error) {
|
|
39
|
-
return failure(error);
|
|
40
|
-
}
|
|
41
|
-
});
|