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 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
- An [Oxlint](https://oxc.rs/docs/guide/usage/linter.html) JS plugin that lints code with plain-English rules. Each rule is one yes/no question. For every matching node the plugin sends a short snippet plus the question to [TypeSafe Jev](https://typesafe.ai) and reports a lint error when Jev's yes-probability clears the rule's cutoff.
3
+ > [!WARNING]
4
+ > This package is experimental. Use at your own risk.
4
5
 
5
- Jev is a judgement model, not a text generator. It returns a calibrated probability, which is what makes a cutoff meaningful.
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="..." # from https://console.typesafe.ai
14
+ export TYPESAFE_API_KEY="..." # https://console.typesafe.ai
12
15
  ```
13
16
 
14
- ## Config shape
17
+ ## Config
15
18
 
16
- Everything lives in `.oxlintrc.json`. The plugin exposes one rule, `jev/ask`. Its options carry the plugin settings and the list of English rules.
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": ["error", {
23
- "ci": "fail",
24
- "timeoutMs": 10000,
25
- "maxMatchesPerFile": 25,
26
- "maxSnippetChars": 4000,
27
- "model": "jev-latest",
28
- "rules": [
29
- {
30
- "id": "no-pii-in-logs",
31
- "target": "call",
32
- "question": "Does this call write personal data, such as an email address, phone number, full name, or a credential, to a log or console?",
33
- "cutoff": 0.8
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
- Per-rule fields. All four are required.
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
- | Target | ESTree node types | Snippet sent to Jev |
53
- | ------------ | ------------------------------------------------------------------ | --------------------------------- |
54
- | `"function"` | `FunctionDeclaration`, `FunctionExpression`, `ArrowFunctionExpression` | The whole function text. |
55
- | `"call"` | `CallExpression` | The whole call expression text. |
56
- | `"jsx"` | `JSXElement` | The whole element, children included. |
57
- | `"file"` | `Program` | The whole file text. |
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
- A diagnostic is anchored to the head of the match, not its whole extent. A function is underlined on its signature line, a file on its first line, a JSX element on its opening tag, and a call in full.
57
+ `target` decides what Jev gets to read.
60
58
 
61
- Plugin-level fields. All optional.
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
- | Field | Default | Meaning |
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
- Oxlint validates the whole options object against the rule's JSON schema before any file is linted, so a wrong `target` or an unknown field fails at config load with oxlint's own error rather than mid-run. The defaults above live in `meta.defaultOptions`, which oxlint merges underneath whatever you set.
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
- Environment.
70
+ Optional settings, with their defaults.
74
71
 
75
- | Variable | Meaning |
76
- | ------------------ | ----------------------------------------------------------------------- |
77
- | `TYPESAFE_API_KEY` | Required to ask Jev. Missing key follows the `ci` setting above. |
78
- | `TYPESAFE_API_URL` | Base URL, default `https://api.typesafe.ai`. Used by the tests to point at a mock. |
79
- | `CI` | Any non-empty value marks the run as CI. |
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
- ## Request flow
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
- One file, one round trip.
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
- 1. **Collect.** The rule uses oxlint's `createOnce` API, so its visitors are built once for the whole process, one for every node type a target can map to. Per-file setup lives in the `Program` visitor rather than a `before` hook, as the oxlint docs recommend, and it parses the options once per distinct options object. Each visit pushes a match `{ rule, node, snippet }` until `maxMatchesPerFile` is reached.
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
- ```json
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
- 5. **Send synchronously.** Oxlint runs JS rules synchronously, so the rule cannot `await`. The request runs on a [`synckit`](https://github.com/un-ts/synckit) worker thread, the same mechanism `oxlint-plugin-oxfmt` and `eslint-plugin-prettier` use to run async work inside a synchronous rule. The worker does `fetch` with `AbortSignal.timeout(timeoutMs)` and a `Bearer` header, and the rule blocks until the worker answers. The response is
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
- ```json
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
- Verdicts `{ s0: 0.93, s1: 0.12 }` are written to the cache file (write to a temp name, then rename, so a crashed run never leaves a half-written entry).
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
- Failure handling. A missing key, a non-2xx status, a timeout, or a malformed response is a single stderr warning per cause per run and no reports, unless `CI` is set and `ci` is `"fail"`, in which case the rule throws and oxlint fails.
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
- ## Editor use
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 a config with five rules and two sample files. Each rule asks something a pattern-based linter cannot express. `fail.js` should produce five errors, `pass.js` none.
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 | Target | Catches |
155
- | --- | --- | --- |
156
- | `no-pii-in-logs` | call | A log line that prints an email address and phone number, while a log of `{ userId }` passes. |
157
- | `name-matches-behavior` | function | A `getUser` that also updates the row and sends an email, while the same work under `recordSignIn` passes. |
158
- | `no-retry-storm` | function | A retry loop with no delay, backoff, or attempt limit, while a bounded loop with exponential backoff passes. |
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 test # unit tests plus an end-to-end run of oxlint against a mock Jev server
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
@@ -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,5 @@
1
+ import { n as AskResult, t as AskInput } from "./types-cmFf_yHJ.mjs";
2
+ //#region src/sync-jev.d.ts
3
+ declare function askJev(input: AskInput): AskResult;
4
+ //#endregion
5
+ export { askJev };
@@ -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 {}
@@ -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.0.1",
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": "./src/types.d.ts",
9
- "default": "./src/index.js"
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
- "test": "node --test \"test/*.test.js\"",
21
- "example": "oxlint -c example/.oxlintrc.json example/"
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
- "devDependencies": {
35
- "oxlint": "1.83.0"
48
+ "overrides": {
49
+ "vite": "npm:@voidzero-dev/vite-plus-core@0.3.3"
36
50
  },
37
- "dependencies": {
38
- "synckit": "^0.11.13"
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
- });