@patronage/software-factory 1.0.0-alpha.34 → 1.0.0-alpha.36
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 +2 -0
- package/dist/{chunk-pbuEa-1d.js → chunk-DrSxFLj_.js} +1 -0
- package/dist/index.d.ts +573 -185
- package/dist/index.js +4594 -2530
- package/dist/oxlint-jev/bridge-B4Sx95ZO.js +1007 -0
- package/dist/oxlint-jev/index.d.ts +6 -0
- package/dist/oxlint-jev/index.js +581 -0
- package/dist/oxlint-jev/worker.d.ts +1 -0
- package/dist/oxlint-jev/worker.js +49 -0
- package/dist/schemas.d.ts +177 -177
- package/package.json +8 -2
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
import { i as buildJevRequests, l as noul, n as spawnJevBridge, o as defaultJevCacheDirectory } from "./bridge-B4Sx95ZO.js";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
//#region src/oxlint-jev/pass.ts
|
|
6
|
+
/** The environment variable that turns spending on. */
|
|
7
|
+
const JEV_MODE_ENV = "PATRONAGE_JEV_MODE";
|
|
8
|
+
/**
|
|
9
|
+
* The mode this run is in.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately an environment variable rather than a rule option. The Oxlint
|
|
12
|
+
* config that carries the questions is a checked-in file: a `mode: "live"` in
|
|
13
|
+
* it would be one merge away from every runner with HQ credentials spending on
|
|
14
|
+
* every invocation, including whichever tool picks the config up next. An
|
|
15
|
+
* environment variable is set per invocation by whoever is paying, and it is
|
|
16
|
+
* bounded by the same credentials — `live` without `HQ_INGEST_URL` and the
|
|
17
|
+
* Cloudflare Access pair buys nothing, it reports that it could not ask. A
|
|
18
|
+
* value that is neither mode is refused rather than rounded to the safe one,
|
|
19
|
+
* because "I set it and nothing happened" is how an operator ends up believing
|
|
20
|
+
* a run asked something it did not.
|
|
21
|
+
*/
|
|
22
|
+
const resolveJevMode = (env) => {
|
|
23
|
+
const raw = env[JEV_MODE_ENV]?.trim();
|
|
24
|
+
if (raw === void 0 || raw === "") return "record";
|
|
25
|
+
if (raw === "live" || raw === "record") return raw;
|
|
26
|
+
throw new Error(`jev: ${JEV_MODE_ENV} must be "record" or "live"; got "${raw}"`);
|
|
27
|
+
};
|
|
28
|
+
/** Where recorded request bodies are written. */
|
|
29
|
+
const recordDirectoryFor = (cacheDirectory) => path.join(cacheDirectory, "record");
|
|
30
|
+
const describe = (error) => error instanceof Error ? error.message : String(error);
|
|
31
|
+
/** The filesystem's own short constant (`ENOTDIR`, `EACCES`), never a message. */
|
|
32
|
+
const errnoOf = (error) => error?.code ?? "an unknown error";
|
|
33
|
+
/**
|
|
34
|
+
* Whether a canonical path is the canonical root or sits under it.
|
|
35
|
+
*
|
|
36
|
+
* Compared on canonical paths, so neither side can be a symlink, and with a
|
|
37
|
+
* separator appended so `/repo-secrets` is not read as being inside `/repo`.
|
|
38
|
+
*/
|
|
39
|
+
const isInside = (canonicalRoot, canonicalPath) => canonicalPath === canonicalRoot || canonicalPath.startsWith(`${canonicalRoot}${path.sep}`);
|
|
40
|
+
/**
|
|
41
|
+
* The reference files for each question that matched in this file, read whole.
|
|
42
|
+
*
|
|
43
|
+
* Their content rides the request, so it is part of the request hash and
|
|
44
|
+
* therefore part of the cache key: editing a reference re-asks the question
|
|
45
|
+
* rather than returning yesterday's answer about yesterday's file. A reference
|
|
46
|
+
* that cannot be read makes its question's matches unavailable — the question
|
|
47
|
+
* was written to be read against that file, and answering it without the file
|
|
48
|
+
* would be answering a different question.
|
|
49
|
+
*
|
|
50
|
+
* Every reference is canonicalized before it is opened, and one outside the
|
|
51
|
+
* canonical repository root is refused unread. `resolveJevQuestions` rejects an
|
|
52
|
+
* absolute path and a `..` that climbs out, but that check is lexical and
|
|
53
|
+
* `readFileSync` follows symlinks: a repository-relative path whose last
|
|
54
|
+
* component — or any parent directory — is a symlink out of the checkout would
|
|
55
|
+
* otherwise be read and, in `live` mode, sent to HQ. The refusal names the
|
|
56
|
+
* configured reference and never the path it resolved to, which is the value
|
|
57
|
+
* that must not travel.
|
|
58
|
+
*/
|
|
59
|
+
const readReferences = (root, questions) => {
|
|
60
|
+
const sharedEntries = [];
|
|
61
|
+
const failed = /* @__PURE__ */ new Map();
|
|
62
|
+
const canonicalRoot = realpathSync(root);
|
|
63
|
+
for (const question of questions) {
|
|
64
|
+
const references = question.references ?? [];
|
|
65
|
+
if (references.length === 0) continue;
|
|
66
|
+
const contentEntries = [];
|
|
67
|
+
for (const reference of references) try {
|
|
68
|
+
const canonical = realpathSync(path.join(root, reference));
|
|
69
|
+
if (!isInside(canonicalRoot, canonical)) {
|
|
70
|
+
failed.set(question.id, `reference ${reference} resolves outside the repository; it was not read`);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
contentEntries.push([reference, readFileSync(canonical, "utf-8")]);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
failed.set(question.id, `reference ${reference} could not be read: ${error.code ?? describe(error)}`);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
if (!failed.has(question.id)) sharedEntries.push([question.id, Object.fromEntries(contentEntries)]);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
failed,
|
|
82
|
+
shared: Object.fromEntries(sharedEntries)
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* The state one match contributes. A `file` match adds nothing but its target:
|
|
87
|
+
* the file's whole text is already the shared state, and sending it twice
|
|
88
|
+
* would buy the same tokens again.
|
|
89
|
+
*/
|
|
90
|
+
const stateFor = (match) => match.question.target === "file" ? { target: "file" } : {
|
|
91
|
+
snippet: match.snippet,
|
|
92
|
+
target: match.question.target
|
|
93
|
+
};
|
|
94
|
+
const toJevFile = (filePath, source, references, matches) => {
|
|
95
|
+
const jevMatches = matches.map((match) => ({
|
|
96
|
+
key: match.key,
|
|
97
|
+
questions: { [match.key]: noul(match.question.question) },
|
|
98
|
+
state: stateFor(match)
|
|
99
|
+
}));
|
|
100
|
+
const shared = { source };
|
|
101
|
+
if (Object.keys(references).length > 0) shared.references = references;
|
|
102
|
+
return {
|
|
103
|
+
matches: jevMatches,
|
|
104
|
+
path: filePath,
|
|
105
|
+
shared
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Writes the exact bytes the live mode would send, one file per request, named
|
|
110
|
+
* by the request hash so the same request always lands in the same place.
|
|
111
|
+
* Pretty-printed, because the point of recording is that a reviewer reads it.
|
|
112
|
+
*/
|
|
113
|
+
const writeRecords = (directory, file, unavailable) => {
|
|
114
|
+
const recorded = /* @__PURE__ */ new Map();
|
|
115
|
+
const plan = buildJevRequests({ files: [file] });
|
|
116
|
+
for (const entry of plan.unavailable) unavailable.set(entry.key, entry.reason);
|
|
117
|
+
try {
|
|
118
|
+
mkdirSync(directory, { recursive: true });
|
|
119
|
+
} catch (error) {
|
|
120
|
+
for (const match of file.matches) unavailable.set(match.key, `the Jev request could not be recorded: creating ${directory} failed with ${errnoOf(error)}`);
|
|
121
|
+
return recorded;
|
|
122
|
+
}
|
|
123
|
+
for (const request of plan.requests) {
|
|
124
|
+
const target = path.join(directory, `${request.hash}.json`);
|
|
125
|
+
try {
|
|
126
|
+
writeFileSync(target, `${JSON.stringify({
|
|
127
|
+
body: request.body,
|
|
128
|
+
estimatedTokens: request.estimatedTokens,
|
|
129
|
+
matchKeys: request.matchKeys,
|
|
130
|
+
path: request.path,
|
|
131
|
+
requestHash: request.hash
|
|
132
|
+
}, null, 2)}\n`, "utf-8");
|
|
133
|
+
} catch (error) {
|
|
134
|
+
for (const key of request.matchKeys) unavailable.set(key, `the Jev request could not be recorded: writing ${target} failed with ${errnoOf(error)}`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
for (const key of request.matchKeys) recorded.set(key, target);
|
|
138
|
+
}
|
|
139
|
+
return recorded;
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* Two matches sharing a key is a bug, not a configuration error.
|
|
143
|
+
*
|
|
144
|
+
* The key is an answer name and a dictionary key in the request body, so a
|
|
145
|
+
* collision does not produce a smaller request — it produces a request that
|
|
146
|
+
* silently asks about one of the colliding matches and answers all of them
|
|
147
|
+
* from it. Nothing downstream can detect that, so it is refused here, where
|
|
148
|
+
* both keys are still in hand.
|
|
149
|
+
*/
|
|
150
|
+
const assertDistinctKeys = (matches) => {
|
|
151
|
+
const seen = /* @__PURE__ */ new Set();
|
|
152
|
+
for (const match of matches) {
|
|
153
|
+
if (seen.has(match.key)) throw new Error(`jev: two matches share the key ${match.key}. A match key must identify one node; this is a bug in the plugin, not in the configuration.`);
|
|
154
|
+
seen.add(match.key);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
/** Builds the request bodies and writes them; nothing is sent. */
|
|
158
|
+
const recordPass = (cacheDirectory, file, asked) => {
|
|
159
|
+
const recorded = [];
|
|
160
|
+
const unavailable = [];
|
|
161
|
+
const notRecorded = /* @__PURE__ */ new Map();
|
|
162
|
+
const written = writeRecords(recordDirectoryFor(cacheDirectory), file, notRecorded);
|
|
163
|
+
for (const match of asked) {
|
|
164
|
+
const cause = notRecorded.get(match.key);
|
|
165
|
+
if (cause === void 0) recorded.push({
|
|
166
|
+
id: match.question.id,
|
|
167
|
+
loc: match.loc,
|
|
168
|
+
requestPath: written.get(match.key) ?? "",
|
|
169
|
+
textHash: match.question.textHash
|
|
170
|
+
});
|
|
171
|
+
else unavailable.push({
|
|
172
|
+
cause,
|
|
173
|
+
id: match.question.id,
|
|
174
|
+
loc: match.loc
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
findings: [],
|
|
179
|
+
recorded,
|
|
180
|
+
unavailable
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
/** Sends the request through the bridge and reads the answers back. */
|
|
184
|
+
const livePass = (bridge, cacheDirectory, file, asked) => {
|
|
185
|
+
const findings = [];
|
|
186
|
+
const unavailable = [];
|
|
187
|
+
const result = bridge({
|
|
188
|
+
cacheDirectory,
|
|
189
|
+
files: [file]
|
|
190
|
+
});
|
|
191
|
+
const modelByKey = /* @__PURE__ */ new Map();
|
|
192
|
+
for (const request of result.requests) for (const key of request.matchKeys) modelByKey.set(key, request.model ?? "unknown");
|
|
193
|
+
for (const match of asked) {
|
|
194
|
+
const answer = result.answersByMatchKey[match.key]?.[match.key];
|
|
195
|
+
if (answer === void 0 || answer.type !== "noul") {
|
|
196
|
+
unavailable.push({
|
|
197
|
+
cause: result.unavailableByMatchKey[match.key] ?? "the Jev run returned no answer and no reason for this match",
|
|
198
|
+
id: match.question.id,
|
|
199
|
+
loc: match.loc
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (answer.noul >= match.question.cutoff) findings.push({
|
|
204
|
+
cutoff: match.question.cutoff,
|
|
205
|
+
id: match.question.id,
|
|
206
|
+
loc: match.loc,
|
|
207
|
+
model: modelByKey.get(match.key) ?? "unknown",
|
|
208
|
+
question: match.question.question,
|
|
209
|
+
score: answer.noul,
|
|
210
|
+
textHash: match.question.textHash
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
findings,
|
|
215
|
+
recorded: [],
|
|
216
|
+
unavailable
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* Runs one file's matches: reads the references, builds the request, and
|
|
221
|
+
* either records it or sends it.
|
|
222
|
+
*/
|
|
223
|
+
const runJevPass = ({ bridge = spawnJevBridge, cacheDirectory = defaultJevCacheDirectory(process.cwd()), filePath, matches, mode, root, source }) => {
|
|
224
|
+
const unavailable = [];
|
|
225
|
+
if (matches.length === 0) return {
|
|
226
|
+
findings: [],
|
|
227
|
+
recorded: [],
|
|
228
|
+
unavailable
|
|
229
|
+
};
|
|
230
|
+
const { failed, shared } = readReferences(root, [...new Map(matches.map((match) => [match.question.id, match.question])).values()]);
|
|
231
|
+
const asked = matches.filter((match) => !failed.has(match.question.id));
|
|
232
|
+
for (const match of matches) {
|
|
233
|
+
const cause = failed.get(match.question.id);
|
|
234
|
+
if (cause !== void 0) unavailable.push({
|
|
235
|
+
cause,
|
|
236
|
+
id: match.question.id,
|
|
237
|
+
loc: match.loc
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (asked.length === 0) return {
|
|
241
|
+
findings: [],
|
|
242
|
+
recorded: [],
|
|
243
|
+
unavailable
|
|
244
|
+
};
|
|
245
|
+
assertDistinctKeys(asked);
|
|
246
|
+
const file = toJevFile(filePath, source, shared, asked);
|
|
247
|
+
const run = mode === "record" ? recordPass(cacheDirectory, file, asked) : livePass(bridge, cacheDirectory, file, asked);
|
|
248
|
+
return {
|
|
249
|
+
...run,
|
|
250
|
+
unavailable: [...unavailable, ...run.unavailable]
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region src/oxlint-jev/questions.ts
|
|
255
|
+
/**
|
|
256
|
+
* What a Jev question is, and the one place a caller's configuration becomes
|
|
257
|
+
* one.
|
|
258
|
+
*
|
|
259
|
+
* A question is **configuration, not code** (epic #1234): an id, an AST
|
|
260
|
+
* target, the question text, a cutoff, and optionally a `contains` pattern and
|
|
261
|
+
* a list of repository-relative reference files. There is no hook, no
|
|
262
|
+
* check-as-code contract, and no escape to a callback — a case that code can
|
|
263
|
+
* decide is an ordinary Oxlint rule, not a Jev question.
|
|
264
|
+
*
|
|
265
|
+
* Oxlint validates `context.options` against `JEV_ASK_OPTIONS_SCHEMA` before a
|
|
266
|
+
* rule sees them, so the checks here are only the ones a JSON schema cannot
|
|
267
|
+
* express: that ids are unique, that a `contains` pattern compiles, and that a
|
|
268
|
+
* reference path stays inside the repository. Each of them throws, which
|
|
269
|
+
* Oxlint turns into a plugin error and a non-zero exit — a malformed question
|
|
270
|
+
* set is never quietly a smaller question set.
|
|
271
|
+
*/
|
|
272
|
+
/** The AST shapes a question can be asked about. */
|
|
273
|
+
const JEV_TARGETS = [
|
|
274
|
+
"call",
|
|
275
|
+
"file",
|
|
276
|
+
"function",
|
|
277
|
+
"jsx"
|
|
278
|
+
];
|
|
279
|
+
/**
|
|
280
|
+
* The option schema Oxlint enforces. `additionalProperties: false` is the
|
|
281
|
+
* control that refuses the options this design deliberately does not have — a
|
|
282
|
+
* `mode`, an `exceptions` list, a `ci` behavior, a match cap — instead of
|
|
283
|
+
* ignoring them and leaving a consumer believing they took effect.
|
|
284
|
+
*/
|
|
285
|
+
const JEV_ASK_OPTIONS_SCHEMA = {
|
|
286
|
+
additionalProperties: false,
|
|
287
|
+
properties: { questions: {
|
|
288
|
+
items: {
|
|
289
|
+
additionalProperties: false,
|
|
290
|
+
properties: {
|
|
291
|
+
contains: {
|
|
292
|
+
minLength: 1,
|
|
293
|
+
type: "string"
|
|
294
|
+
},
|
|
295
|
+
cutoff: {
|
|
296
|
+
maximum: 1,
|
|
297
|
+
minimum: 0,
|
|
298
|
+
type: "number"
|
|
299
|
+
},
|
|
300
|
+
id: {
|
|
301
|
+
minLength: 1,
|
|
302
|
+
type: "string"
|
|
303
|
+
},
|
|
304
|
+
question: {
|
|
305
|
+
minLength: 1,
|
|
306
|
+
type: "string"
|
|
307
|
+
},
|
|
308
|
+
references: {
|
|
309
|
+
items: {
|
|
310
|
+
minLength: 1,
|
|
311
|
+
type: "string"
|
|
312
|
+
},
|
|
313
|
+
type: "array",
|
|
314
|
+
uniqueItems: true
|
|
315
|
+
},
|
|
316
|
+
target: { enum: [...JEV_TARGETS] }
|
|
317
|
+
},
|
|
318
|
+
required: [
|
|
319
|
+
"id",
|
|
320
|
+
"target",
|
|
321
|
+
"question",
|
|
322
|
+
"cutoff"
|
|
323
|
+
],
|
|
324
|
+
type: "object"
|
|
325
|
+
},
|
|
326
|
+
minItems: 1,
|
|
327
|
+
type: "array"
|
|
328
|
+
} },
|
|
329
|
+
required: ["questions"],
|
|
330
|
+
type: "object"
|
|
331
|
+
};
|
|
332
|
+
const fail = (detail) => {
|
|
333
|
+
throw new Error(`jev/ask: ${detail}`);
|
|
334
|
+
};
|
|
335
|
+
/** The short hash of a question's text that every finding carries. */
|
|
336
|
+
const questionTextHash = (question) => createHash("sha256").update(question).digest("hex").slice(0, 8);
|
|
337
|
+
/**
|
|
338
|
+
* A reference must name a file inside the repository. An absolute path or one
|
|
339
|
+
* that climbs out of the checkout would make the request depend on the machine
|
|
340
|
+
* that ran the lint, and the request is the cache key — two machines would
|
|
341
|
+
* disagree about what the same configuration asked.
|
|
342
|
+
*/
|
|
343
|
+
const checkReference = (id, reference) => {
|
|
344
|
+
if (path.isAbsolute(reference)) fail(`question "${id}" references an absolute path (${reference}); references are repository-relative`);
|
|
345
|
+
const normalized = path.normalize(reference);
|
|
346
|
+
if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) fail(`question "${id}" references a path outside the repository (${reference})`);
|
|
347
|
+
};
|
|
348
|
+
/**
|
|
349
|
+
* Turns the rule's options into the questions this file's pass will ask.
|
|
350
|
+
*
|
|
351
|
+
* Called once per file, so it stays cheap: the only work is compiling
|
|
352
|
+
* `contains` and hashing the question text.
|
|
353
|
+
*/
|
|
354
|
+
const resolveJevQuestions = (raw) => {
|
|
355
|
+
if (raw === null || typeof raw !== "object") fail("the rule takes one options object with a `questions` array; configure it as `\"jev/ask\": [\"warn\", { \"questions\": [ … ] }]`");
|
|
356
|
+
const { questions } = raw;
|
|
357
|
+
if (!Array.isArray(questions) || questions.length === 0) fail("`questions` must list at least one question");
|
|
358
|
+
const seen = /* @__PURE__ */ new Set();
|
|
359
|
+
return questions.map((question) => {
|
|
360
|
+
if (seen.has(question.id)) fail(`question id "${question.id}" is used more than once`);
|
|
361
|
+
seen.add(question.id);
|
|
362
|
+
for (const reference of question.references ?? []) checkReference(question.id, reference);
|
|
363
|
+
let pattern;
|
|
364
|
+
if (question.contains !== void 0) try {
|
|
365
|
+
pattern = new RegExp(question.contains, "u");
|
|
366
|
+
} catch {
|
|
367
|
+
fail(`question "${question.id}" has a \`contains\` that is not a valid regular expression: ${question.contains}`);
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
...question,
|
|
371
|
+
pattern,
|
|
372
|
+
textHash: questionTextHash(question.question)
|
|
373
|
+
};
|
|
374
|
+
});
|
|
375
|
+
};
|
|
376
|
+
//#endregion
|
|
377
|
+
//#region src/oxlint-jev/targets.ts
|
|
378
|
+
const TARGET_NODE_TYPES = {
|
|
379
|
+
call: ["CallExpression"],
|
|
380
|
+
file: ["Program"],
|
|
381
|
+
function: [
|
|
382
|
+
"ArrowFunctionExpression",
|
|
383
|
+
"FunctionDeclaration",
|
|
384
|
+
"FunctionExpression"
|
|
385
|
+
],
|
|
386
|
+
jsx: ["JSXElement"]
|
|
387
|
+
};
|
|
388
|
+
/** Every node type any target selects, so the visitor is registered once. */
|
|
389
|
+
const ALL_TARGET_NODE_TYPES = [...new Set(JEV_TARGETS.flatMap((target) => TARGET_NODE_TYPES[target]))];
|
|
390
|
+
/** Node type back to the target that selected it. */
|
|
391
|
+
const TARGET_BY_NODE_TYPE = new Map(JEV_TARGETS.flatMap((target) => TARGET_NODE_TYPES[target].map((type) => [type, target])));
|
|
392
|
+
/**
|
|
393
|
+
* The fields in which an anonymous function inherits a name from its parent.
|
|
394
|
+
* `const handler = () => …` reads as "handler", and sending only `() => …`
|
|
395
|
+
* would ask Jev about a snippet with the one identifying word removed.
|
|
396
|
+
*/
|
|
397
|
+
const NAMED_PARENTS = {
|
|
398
|
+
MethodDefinition: (parent) => parent.value,
|
|
399
|
+
Property: (parent) => parent.value,
|
|
400
|
+
PropertyDefinition: (parent) => parent.value,
|
|
401
|
+
VariableDeclarator: (parent) => parent.init
|
|
402
|
+
};
|
|
403
|
+
const ANONYMOUS_FUNCTIONS = new Set(["ArrowFunctionExpression", "FunctionExpression"]);
|
|
404
|
+
/**
|
|
405
|
+
* The node whose text is sent for this match: the node itself, unless it is an
|
|
406
|
+
* anonymous function sitting in a field that names it, in which case the
|
|
407
|
+
* named parent is sent instead.
|
|
408
|
+
*/
|
|
409
|
+
const snippetNodeFor = (node) => {
|
|
410
|
+
const visited = node;
|
|
411
|
+
const { parent } = visited;
|
|
412
|
+
if (parent === null || parent === void 0) return node;
|
|
413
|
+
const namedField = NAMED_PARENTS[parent.type];
|
|
414
|
+
if (namedField === void 0 || !ANONYMOUS_FUNCTIONS.has(visited.type)) return node;
|
|
415
|
+
return namedField(parent) === node ? parent : node;
|
|
416
|
+
};
|
|
417
|
+
/**
|
|
418
|
+
* The first line of `text`, as a location starting at `line`/`column`. A
|
|
419
|
+
* function or a whole file spans far more than a reviewer wants underlined;
|
|
420
|
+
* the signature line — or the file's first line — is what identifies it.
|
|
421
|
+
*/
|
|
422
|
+
const headOf = (text, line, column) => {
|
|
423
|
+
const newline = text.indexOf("\n");
|
|
424
|
+
return {
|
|
425
|
+
end: {
|
|
426
|
+
column: column + (newline === -1 ? text.length : newline),
|
|
427
|
+
line
|
|
428
|
+
},
|
|
429
|
+
start: {
|
|
430
|
+
column,
|
|
431
|
+
line
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
};
|
|
435
|
+
/** Where the diagnostic for one match lands. */
|
|
436
|
+
const reportLocationFor = (target, node, ownText) => {
|
|
437
|
+
const visited = node;
|
|
438
|
+
if (target === "file") return headOf(ownText, 1, 0);
|
|
439
|
+
if (target === "function") return headOf(ownText, visited.loc.start.line, visited.loc.start.column);
|
|
440
|
+
if (target === "jsx") return (visited.openingElement ?? visited).loc;
|
|
441
|
+
return visited.loc;
|
|
442
|
+
};
|
|
443
|
+
//#endregion
|
|
444
|
+
//#region src/oxlint-jev/index.ts
|
|
445
|
+
let currentPass = null;
|
|
446
|
+
/** The file `jev/unavailable` is enabled for, set at its `Program` handler. */
|
|
447
|
+
let unavailableArmedFor = null;
|
|
448
|
+
const passFor = (context) => currentPass !== null && currentPass.filename === context.filename ? currentPass : null;
|
|
449
|
+
const resolvePass = (pass, context) => {
|
|
450
|
+
pass.result ??= runJevPass({
|
|
451
|
+
filePath: path.relative(process.cwd(), context.filename),
|
|
452
|
+
matches: pass.matches,
|
|
453
|
+
mode: resolveJevMode(process.env),
|
|
454
|
+
root: process.cwd(),
|
|
455
|
+
source: context.sourceCode.text
|
|
456
|
+
});
|
|
457
|
+
return pass.result;
|
|
458
|
+
};
|
|
459
|
+
/**
|
|
460
|
+
* A match's key: the question's id and the selected node's full source range.
|
|
461
|
+
*
|
|
462
|
+
* The range, not the report location. The key is an answer name and a
|
|
463
|
+
* dictionary key in the request body, so it has to identify one node — and a
|
|
464
|
+
* report location does not. Every `CallExpression` in `a().b().c()` starts at
|
|
465
|
+
* the same line and column, as does every call in `f()()`, so keying on the
|
|
466
|
+
* start collapsed a whole chain onto one entry: the request asked about the
|
|
467
|
+
* innermost call alone and its score was reported on every call in the chain,
|
|
468
|
+
* with nothing saying work had been dropped. Start and end together are unique
|
|
469
|
+
* per node, because two distinct nodes cannot span exactly the same text.
|
|
470
|
+
*/
|
|
471
|
+
const matchKeyFor = (id, node) => {
|
|
472
|
+
const [start, end] = node.range;
|
|
473
|
+
return `${id}@${start}-${end}`;
|
|
474
|
+
};
|
|
475
|
+
const askMeta = {
|
|
476
|
+
docs: { description: "Ask Jev a configured question about matched code, through HQ, and report the matches whose probability clears the question's cutoff." },
|
|
477
|
+
messages: {
|
|
478
|
+
finding: "[{{id}} {{hash}}] {{model}} answered {{score}} against a {{cutoff}} cutoff: {{question}}",
|
|
479
|
+
recorded: "[{{id}} {{hash}}] record mode: this would be asked. The request is in {{request}}."
|
|
480
|
+
},
|
|
481
|
+
schema: [JEV_ASK_OPTIONS_SCHEMA],
|
|
482
|
+
type: "problem"
|
|
483
|
+
};
|
|
484
|
+
const unavailableMeta = {
|
|
485
|
+
docs: { description: "Report a match Jev could not answer, naming the cause. Set it to `error`: an incomplete run must exit non-zero." },
|
|
486
|
+
messages: { unavailable: "[{{id}}] Jev could not answer: {{cause}}" },
|
|
487
|
+
schema: [],
|
|
488
|
+
type: "problem"
|
|
489
|
+
};
|
|
490
|
+
const createAsk = (context) => {
|
|
491
|
+
const collect = (nodeType, node) => {
|
|
492
|
+
const pass = currentPass;
|
|
493
|
+
const target = TARGET_BY_NODE_TYPE.get(nodeType);
|
|
494
|
+
if (pass === null || target === void 0) return;
|
|
495
|
+
for (const question of pass.questions) {
|
|
496
|
+
if (question.target !== target) continue;
|
|
497
|
+
const ownText = target === "file" ? context.sourceCode.text : context.sourceCode.getText(node);
|
|
498
|
+
const named = snippetNodeFor(node);
|
|
499
|
+
const snippet = named === node ? ownText : context.sourceCode.getText(named);
|
|
500
|
+
if (question.pattern !== void 0 && !question.pattern.test(snippet)) continue;
|
|
501
|
+
const loc = reportLocationFor(target, node, ownText);
|
|
502
|
+
pass.matches.push({
|
|
503
|
+
key: matchKeyFor(question.id, node),
|
|
504
|
+
loc,
|
|
505
|
+
question,
|
|
506
|
+
snippet
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
const visitors = {};
|
|
511
|
+
for (const nodeType of ALL_TARGET_NODE_TYPES) visitors[nodeType] = (node) => collect(nodeType, node);
|
|
512
|
+
visitors.Program = (node) => {
|
|
513
|
+
currentPass = {
|
|
514
|
+
filename: context.filename,
|
|
515
|
+
matches: [],
|
|
516
|
+
questions: resolveJevQuestions(context.options[0]),
|
|
517
|
+
result: void 0
|
|
518
|
+
};
|
|
519
|
+
collect("Program", node);
|
|
520
|
+
};
|
|
521
|
+
visitors["Program:exit"] = () => {
|
|
522
|
+
const pass = passFor(context);
|
|
523
|
+
if (pass === null) return;
|
|
524
|
+
const result = resolvePass(pass, context);
|
|
525
|
+
for (const finding of result.findings) context.report({
|
|
526
|
+
data: {
|
|
527
|
+
cutoff: finding.cutoff.toFixed(2),
|
|
528
|
+
hash: finding.textHash,
|
|
529
|
+
id: finding.id,
|
|
530
|
+
model: finding.model,
|
|
531
|
+
question: finding.question,
|
|
532
|
+
score: finding.score.toFixed(2)
|
|
533
|
+
},
|
|
534
|
+
loc: finding.loc,
|
|
535
|
+
messageId: "finding"
|
|
536
|
+
});
|
|
537
|
+
for (const record of result.recorded) context.report({
|
|
538
|
+
data: {
|
|
539
|
+
hash: record.textHash,
|
|
540
|
+
id: record.id,
|
|
541
|
+
request: record.requestPath
|
|
542
|
+
},
|
|
543
|
+
loc: record.loc,
|
|
544
|
+
messageId: "recorded"
|
|
545
|
+
});
|
|
546
|
+
if (result.unavailable.length > 0 && unavailableArmedFor !== context.filename) throw new Error(`jev/ask: ${result.unavailable.length} match(es) could not be answered and the jev/unavailable rule is not enabled, so nothing would report them. Enable it as an error in the Jev config. First cause: ${result.unavailable[0]?.cause ?? ""}`);
|
|
547
|
+
};
|
|
548
|
+
return visitors;
|
|
549
|
+
};
|
|
550
|
+
const createUnavailable = (context) => ({
|
|
551
|
+
Program: () => {
|
|
552
|
+
unavailableArmedFor = context.filename;
|
|
553
|
+
},
|
|
554
|
+
"Program:exit": () => {
|
|
555
|
+
const pass = passFor(context);
|
|
556
|
+
if (pass === null) return;
|
|
557
|
+
for (const entry of resolvePass(pass, context).unavailable) context.report({
|
|
558
|
+
data: {
|
|
559
|
+
cause: entry.cause,
|
|
560
|
+
id: entry.id
|
|
561
|
+
},
|
|
562
|
+
loc: entry.loc,
|
|
563
|
+
messageId: "unavailable"
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
const plugin = {
|
|
568
|
+
meta: { name: "jev" },
|
|
569
|
+
rules: {
|
|
570
|
+
ask: {
|
|
571
|
+
createOnce: createAsk,
|
|
572
|
+
meta: askMeta
|
|
573
|
+
},
|
|
574
|
+
unavailable: {
|
|
575
|
+
createOnce: createUnavailable,
|
|
576
|
+
meta: unavailableMeta
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
//#endregion
|
|
581
|
+
export { plugin as default };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { a as resolveJevGateway, r as runJevFiles, s as openJevCache, t as allUnavailable } from "./bridge-B4Sx95ZO.js";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
//#region src/oxlint-jev/worker.ts
|
|
4
|
+
/**
|
|
5
|
+
* The subprocess behind the synchronous bridge. It reads one
|
|
6
|
+
* `JevBridgeRequest` from stdin, runs it through the shared Jev layer, and
|
|
7
|
+
* prints the `JevRunResult` to stdout as JSON.
|
|
8
|
+
*
|
|
9
|
+
* It prints exactly one thing, always a result, even when it fails. A throw
|
|
10
|
+
* escaping here would reach the plugin as a dead subprocess with no match keys
|
|
11
|
+
* attached, and every match in the file would lose its own reason; instead
|
|
12
|
+
* every throw becomes the same shape as every other Jev failure — each match
|
|
13
|
+
* unavailable, named with its cause.
|
|
14
|
+
*/
|
|
15
|
+
const describe = (error) => error instanceof Error ? error.message : String(error);
|
|
16
|
+
const readRequest = () => {
|
|
17
|
+
let parsed;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(readFileSync(0, "utf-8"));
|
|
20
|
+
} catch {
|
|
21
|
+
throw new Error("the Jev worker could not read its request. The input is not reported: it can quote repository source.");
|
|
22
|
+
}
|
|
23
|
+
if (!Array.isArray(parsed.files)) throw new TypeError("the Jev worker was given no files to ask about");
|
|
24
|
+
return parsed;
|
|
25
|
+
};
|
|
26
|
+
const run = async (request) => {
|
|
27
|
+
const gateway = await resolveJevGateway({ env: process.env });
|
|
28
|
+
const cache = gateway.status === "ready" ? openJevCache({
|
|
29
|
+
directory: request.cacheDirectory,
|
|
30
|
+
endpoint: gateway.endpoint
|
|
31
|
+
}) : void 0;
|
|
32
|
+
return await runJevFiles({
|
|
33
|
+
...cache === void 0 ? {} : { cache },
|
|
34
|
+
files: request.files,
|
|
35
|
+
gateway
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
let files = [];
|
|
39
|
+
let result;
|
|
40
|
+
try {
|
|
41
|
+
const request = readRequest();
|
|
42
|
+
({files} = request);
|
|
43
|
+
result = await run(request);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
result = allUnavailable(files, describe(error));
|
|
46
|
+
}
|
|
47
|
+
process.stdout.write(JSON.stringify(result));
|
|
48
|
+
//#endregion
|
|
49
|
+
export {};
|