oxlint-plugin-jev 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # oxlint-plugin-jev
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.
4
+
5
+ Jev is a judgement model, not a text generator. It returns a calibrated probability, which is what makes a cutoff meaningful.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm i -D oxlint oxlint-plugin-jev
11
+ export TYPESAFE_API_KEY="..." # from https://console.typesafe.ai
12
+ ```
13
+
14
+ ## Config shape
15
+
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.
17
+
18
+ ```json
19
+ {
20
+ "jsPlugins": ["oxlint-plugin-jev"],
21
+ "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
+ }]
37
+ }
38
+ }
39
+ ```
40
+
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.
51
+
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. |
58
+
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.
60
+
61
+ Plugin-level fields. All optional.
62
+
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. |
70
+
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.
72
+
73
+ Environment.
74
+
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. |
80
+
81
+ ## Request flow
82
+
83
+ One file, one round trip.
84
+
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.
89
+
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
+ ```
105
+
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
107
+
108
+ ```json
109
+ { "answers": { "s0": { "type": "noul", "noul": 0.93 }, "s1": { "type": "noul", "noul": 0.12 } } }
110
+ ```
111
+
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
114
+
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
+ ```
118
+
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.
120
+
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.
126
+
127
+ ```json
128
+ {
129
+ "extends": [".oxlintrc.json"],
130
+ "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
+ }]
141
+ }
142
+ }
143
+ ```
144
+
145
+ ```sh
146
+ oxlint # editor and local runs, no Jev
147
+ oxlint -c .oxlintrc.ci.json # CI and pre-push, Jev included
148
+ ```
149
+
150
+ ## Example
151
+
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.
153
+
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. |
161
+
162
+ ```sh
163
+ npm run example
164
+ ```
165
+
166
+ ## Development
167
+
168
+ ```sh
169
+ npm test # unit tests plus an end-to-end run of oxlint against a mock Jev server
170
+ ```
171
+
172
+ ## License
173
+
174
+ MIT
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "oxlint-plugin-jev",
3
+ "version": "0.0.1",
4
+ "description": "Oxlint JS plugin that lints code with plain-English rules answered by TypeSafe Jev.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
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"
18
+ },
19
+ "scripts": {
20
+ "test": "node --test \"test/*.test.js\"",
21
+ "example": "oxlint -c example/.oxlintrc.json example/"
22
+ },
23
+ "keywords": [
24
+ "oxlint",
25
+ "oxlint-plugin",
26
+ "jev",
27
+ "typesafe",
28
+ "lint"
29
+ ],
30
+ "license": "MIT",
31
+ "peerDependencies": {
32
+ "oxlint": ">=1.83.0"
33
+ },
34
+ "devDependencies": {
35
+ "oxlint": "1.83.0"
36
+ },
37
+ "dependencies": {
38
+ "synckit": "^0.11.13"
39
+ }
40
+ }
package/src/cache.js ADDED
@@ -0,0 +1,21 @@
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 ADDED
@@ -0,0 +1,172 @@
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 ADDED
@@ -0,0 +1,43 @@
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 ADDED
@@ -0,0 +1,72 @@
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
+ };
@@ -0,0 +1,26 @@
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 ADDED
@@ -0,0 +1,41 @@
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 ADDED
@@ -0,0 +1,41 @@
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
+ });