eslint-plugin-rulvar 1.37.0 → 1.39.0

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/dist/index.d.ts CHANGED
@@ -12,6 +12,28 @@ interface RulvarLintDiagnostic {
12
12
  }
13
13
  declare function toJsonDiagnostics(messages: readonly Linter.LintMessage[]): RulvarLintDiagnostic[];
14
14
  //#endregion
15
+ //#region src/dialect-scan.d.ts
16
+ /** Where a finding sits in the ORIGINAL source (line and column counted from 1). */
17
+ interface DialectFinding {
18
+ kind: "eval" | "function-constructor" | "constructor-access";
19
+ line: number;
20
+ column: number;
21
+ }
22
+ /**
23
+ * Structural scan for compileScript: every dynamic code generation form the
24
+ * dialect rejects, as findings positioned in the original source. Covers bare
25
+ * `eval`/`Function` calls and `new`, `globalThis.eval`/`globalThis.Function`,
26
+ * and every constructor reconstruction form the shared predicates recognize.
27
+ * Member access on other objects (`response.eval`, `parser.Function`) and a
28
+ * property NAMED constructor in an object LITERAL are not code generation and
29
+ * are left alone.
30
+ *
31
+ * Takes a parsed ESTree Program, typed `unknown` so a caller needs neither the
32
+ * estree types nor a specific parser in its own public surface; any ESTree
33
+ * compatible parser (espree in the lint pass, acorn in compileScript) works.
34
+ */
35
+ declare function scanDialect(program: unknown): DialectFinding[];
36
+ //#endregion
15
37
  //#region src/index.d.ts
16
38
  declare const rules: Record<string, Rule.RuleModule>;
17
39
  declare const plugin: ESLint.Plugin;
@@ -21,4 +43,4 @@ declare const plugin: ESLint.Plugin;
21
43
  */
22
44
  declare const workflowsConfig: Linter.Config;
23
45
  //#endregion
24
- export { type RulvarLintDiagnostic, plugin as default, rules, toJsonDiagnostics, workflowsConfig };
46
+ export { type DialectFinding, type RulvarLintDiagnostic, plugin as default, rules, scanDialect, toJsonDiagnostics, workflowsConfig };
package/dist/index.js CHANGED
@@ -151,6 +151,219 @@ const noProcessEnv = {
151
151
  }
152
152
  };
153
153
  //#endregion
154
+ //#region src/dialect-scan.ts
155
+ function positionOf(node) {
156
+ const start = node.loc?.start;
157
+ return {
158
+ line: start?.line ?? 1,
159
+ column: (start?.column ?? 0) + 1
160
+ };
161
+ }
162
+ /**
163
+ * Folds an expression that evaluates to a string to its constant value, or
164
+ * undefined when it is not statically constant. Handles string literals, templates whose
165
+ * every interpolation folds, and `+` concatenations of foldable operands.
166
+ * This is deliberately conservative: anything it cannot prove constant (a
167
+ * variable, a call, an index) folds to undefined and is left to the runtime.
168
+ */
169
+ function foldStaticString(node) {
170
+ if (node === null || node === void 0) return;
171
+ if (node.type === "Literal") return typeof node.value === "string" ? node.value : void 0;
172
+ if (node.type === "TemplateLiteral") {
173
+ let out = "";
174
+ for (let i = 0; i < node.quasis.length; i += 1) {
175
+ out += node.quasis[i].value.cooked ?? node.quasis[i].value.raw;
176
+ if (i < node.expressions.length) {
177
+ const piece = foldStaticString(node.expressions[i]);
178
+ if (piece === void 0) return;
179
+ out += piece;
180
+ }
181
+ }
182
+ return out;
183
+ }
184
+ if (node.type === "BinaryExpression" && node.operator === "+") {
185
+ const left = node.left.type === "PrivateIdentifier" ? void 0 : foldStaticString(node.left);
186
+ const right = foldStaticString(node.right);
187
+ return left === void 0 || right === void 0 ? void 0 : left + right;
188
+ }
189
+ }
190
+ /** `fn.constructor` or `fn["constructor"]` (any statically constant key). */
191
+ function isConstructorMemberExpression(node) {
192
+ if (!node.computed) return node.property.type === "Identifier" && node.property.name === "constructor";
193
+ return foldStaticString(node.property) === "constructor";
194
+ }
195
+ /** A destructuring property that binds the `constructor` slot of its source. */
196
+ function isConstructorPatternProperty(node) {
197
+ if (!node.computed) {
198
+ if (node.key.type === "Identifier") return node.key.name === "constructor";
199
+ if (node.key.type === "Literal") return node.key.value === "constructor";
200
+ return false;
201
+ }
202
+ return foldStaticString(node.key) === "constructor";
203
+ }
204
+ /** `Reflect.get(fn, "constructor")` with a statically constant key. */
205
+ function isReflectConstructorGet(node) {
206
+ const callee = node.callee;
207
+ if (callee.type !== "MemberExpression" || callee.computed || callee.object.type !== "Identifier" || callee.object.name !== "Reflect" || callee.property.type !== "Identifier" || callee.property.name !== "get") return false;
208
+ const keyArg = node.arguments[1];
209
+ return keyArg !== void 0 && keyArg.type !== "SpreadElement" && foldStaticString(keyArg) === "constructor";
210
+ }
211
+ /** A bare global `eval`/`Function` used as a call or `new` target. */
212
+ function bareCodegenCallee(callee) {
213
+ if (callee.type === "Identifier") {
214
+ if (callee.name === "eval") return "eval";
215
+ if (callee.name === "Function") return "function-constructor";
216
+ return null;
217
+ }
218
+ if (callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && callee.object.name === "globalThis" && callee.property.type === "Identifier") {
219
+ if (callee.property.name === "eval") return "eval";
220
+ if (callee.property.name === "Function") return "function-constructor";
221
+ }
222
+ return null;
223
+ }
224
+ const SKIP_KEYS = /* @__PURE__ */ new Set([
225
+ "loc",
226
+ "range",
227
+ "start",
228
+ "end",
229
+ "parent",
230
+ "comments",
231
+ "tokens",
232
+ "leadingComments",
233
+ "trailingComments"
234
+ ]);
235
+ function walk(root, visit) {
236
+ const stack = [root];
237
+ while (stack.length > 0) {
238
+ const current = stack.pop();
239
+ if (current === null || typeof current !== "object") continue;
240
+ if (Array.isArray(current)) {
241
+ for (const item of current) stack.push(item);
242
+ continue;
243
+ }
244
+ const node = current;
245
+ if (typeof node.type !== "string") continue;
246
+ visit(node);
247
+ for (const [key, value] of Object.entries(node)) if (!SKIP_KEYS.has(key)) stack.push(value);
248
+ }
249
+ }
250
+ /**
251
+ * Structural scan for compileScript: every dynamic code generation form the
252
+ * dialect rejects, as findings positioned in the original source. Covers bare
253
+ * `eval`/`Function` calls and `new`, `globalThis.eval`/`globalThis.Function`,
254
+ * and every constructor reconstruction form the shared predicates recognize.
255
+ * Member access on other objects (`response.eval`, `parser.Function`) and a
256
+ * property NAMED constructor in an object LITERAL are not code generation and
257
+ * are left alone.
258
+ *
259
+ * Takes a parsed ESTree Program, typed `unknown` so a caller needs neither the
260
+ * estree types nor a specific parser in its own public surface; any ESTree
261
+ * compatible parser (espree in the lint pass, acorn in compileScript) works.
262
+ */
263
+ function scanDialect(program) {
264
+ const findings = [];
265
+ walk(program, (node) => {
266
+ switch (node.type) {
267
+ case "CallExpression":
268
+ case "NewExpression": {
269
+ const codegen = bareCodegenCallee(node.callee);
270
+ if (codegen !== null) findings.push({
271
+ kind: codegen,
272
+ ...positionOf(node)
273
+ });
274
+ if (node.type === "CallExpression" && isReflectConstructorGet(node)) findings.push({
275
+ kind: "constructor-access",
276
+ ...positionOf(node)
277
+ });
278
+ break;
279
+ }
280
+ case "MemberExpression":
281
+ if (isConstructorMemberExpression(node)) findings.push({
282
+ kind: "constructor-access",
283
+ ...positionOf(node)
284
+ });
285
+ break;
286
+ case "ObjectPattern":
287
+ for (const property of node.properties) if (property.type === "Property" && isConstructorPatternProperty(property)) findings.push({
288
+ kind: "constructor-access",
289
+ ...positionOf(property)
290
+ });
291
+ break;
292
+ default: break;
293
+ }
294
+ });
295
+ return findings;
296
+ }
297
+ //#endregion
298
+ //#region src/rules/dialect.ts
299
+ const noCodeGeneration = {
300
+ meta: {
301
+ type: "problem",
302
+ docs: { description: "ban dynamic code generation (eval, the Function constructor, and constructor reconstruction) in workflow modules; it reopens the import allowlist and the ambient capability surface the sandbox dialect closes" },
303
+ messages: {
304
+ noEval: "eval is not part of the workflow dialect; it compiles code in a fresh scope that reopens import() and ambient capability. Stay on the ctx surface.",
305
+ noFunctionConstructor: "the Function constructor is not part of the workflow dialect; it compiles arbitrary code that bypasses the import allowlist. Stay on the ctx surface.",
306
+ noConstructorAccess: "constructor access reaches the Function constructor from any function value and reopens dynamic code generation; it is not part of the workflow dialect."
307
+ },
308
+ schema: []
309
+ },
310
+ create(context) {
311
+ const checkCallee = (node, callee) => {
312
+ if (callee.type === "Identifier" && isGlobalReference(context, callee)) {
313
+ if (callee.name === "eval") {
314
+ context.report({
315
+ node,
316
+ messageId: "noEval"
317
+ });
318
+ return;
319
+ }
320
+ if (callee.name === "Function") {
321
+ context.report({
322
+ node,
323
+ messageId: "noFunctionConstructor"
324
+ });
325
+ return;
326
+ }
327
+ }
328
+ if (isGlobalMemberCall(context, callee, "globalThis", "eval")) {
329
+ context.report({
330
+ node,
331
+ messageId: "noEval"
332
+ });
333
+ return;
334
+ }
335
+ if (isGlobalMemberCall(context, callee, "globalThis", "Function")) context.report({
336
+ node,
337
+ messageId: "noFunctionConstructor"
338
+ });
339
+ };
340
+ return {
341
+ CallExpression(node) {
342
+ checkCallee(node, node.callee);
343
+ if (isReflectConstructorGet(node)) context.report({
344
+ node,
345
+ messageId: "noConstructorAccess"
346
+ });
347
+ },
348
+ NewExpression(node) {
349
+ checkCallee(node, node.callee);
350
+ },
351
+ MemberExpression(node) {
352
+ if (isConstructorMemberExpression(node)) context.report({
353
+ node,
354
+ messageId: "noConstructorAccess"
355
+ });
356
+ },
357
+ ObjectPattern(node) {
358
+ for (const property of node.properties) if (property.type === "Property" && isConstructorPatternProperty(property)) context.report({
359
+ node: property,
360
+ messageId: "noConstructorAccess"
361
+ });
362
+ }
363
+ };
364
+ }
365
+ };
366
+ //#endregion
154
367
  //#region src/rules/scheduling.ts
155
368
  const PROMISE_COMBINATORS = /* @__PURE__ */ new Set([
156
369
  "all",
@@ -245,6 +458,7 @@ const rules = {
245
458
  "no-bare-random": noBareRandom,
246
459
  "no-fetch": noFetch,
247
460
  "no-process-env": noProcessEnv,
461
+ "no-code-generation": noCodeGeneration,
248
462
  "no-promise-all-over-ctx": noPromiseAllOverCtx,
249
463
  "duplicate-identical-call": duplicateIdenticalCall
250
464
  };
@@ -264,10 +478,11 @@ const workflowsConfig = {
264
478
  "rulvar/no-bare-random": "error",
265
479
  "rulvar/no-fetch": "error",
266
480
  "rulvar/no-process-env": "error",
481
+ "rulvar/no-code-generation": "error",
267
482
  "rulvar/no-promise-all-over-ctx": "error",
268
483
  "rulvar/duplicate-identical-call": "warn"
269
484
  }
270
485
  };
271
486
  plugin.configs = { workflows: workflowsConfig };
272
487
  //#endregion
273
- export { plugin as default, rules, toJsonDiagnostics, workflowsConfig };
488
+ export { plugin as default, rules, scanDialect, toJsonDiagnostics, workflowsConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-rulvar",
3
- "version": "1.37.0",
3
+ "version": "1.39.0",
4
4
  "description": "Rulvar determinism lint rules with structural JSON diagnostics for the planner self-repair loop.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",