biome-plugin-suitescript 0.1.0 → 0.2.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.
Files changed (36) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +208 -149
  3. package/biome-manifest.json +95 -3
  4. package/configs/suitescript2.json +5 -5
  5. package/dist/bin/biome-plugin-suitescript.js +23 -23
  6. package/dist/cli/analyze.js +33 -3
  7. package/dist/lib/module-script-types.js +70 -0
  8. package/dist/lib/script-side.js +83 -0
  9. package/dist/lib/script-types.js +12 -0
  10. package/dist/lib/suitescript.js +23 -0
  11. package/dist/rules/noAmdName.grit +9 -9
  12. package/dist/rules/noBeforeSubmitRecordReload.js +19 -0
  13. package/dist/rules/noBrowserGlobals.js +23 -0
  14. package/dist/rules/noClientLog.js +14 -0
  15. package/dist/rules/noDynamicSuiteQL.js +24 -0
  16. package/dist/rules/noEmptyEntryPoints.js +19 -0
  17. package/dist/rules/noEval.js +21 -0
  18. package/dist/rules/noGlobalShadowing.js +34 -0
  19. package/dist/rules/noHardcodedSecrets.js +38 -0
  20. package/dist/rules/noHeaderUnderscores.js +23 -0
  21. package/dist/rules/noInsecureHttpsUrl.js +20 -0
  22. package/dist/rules/noLogModule.js +4 -1
  23. package/dist/rules/noMapReduceSearchResults.js +33 -0
  24. package/dist/rules/noMissingModules.js +26 -0
  25. package/dist/rules/noModuleExtensions.grit +12 -12
  26. package/dist/rules/noModuleScopeApiCalls.js +39 -0
  27. package/dist/rules/noNativeIterators.js +16 -0
  28. package/dist/rules/noQueryColumnFieldAndFormula.js +19 -0
  29. package/dist/rules/noRecordInLoop.js +31 -0
  30. package/dist/rules/noSuiteScript1Api.js +10 -0
  31. package/dist/rules/noUserEventContextSave.js +12 -0
  32. package/dist/rules/noWrongScriptTypeModules.js +28 -0
  33. package/dist/rules/requireClientValidateReturn.js +27 -0
  34. package/dist/rules/requireCustpagePrefix.js +22 -0
  35. package/package.json +83 -83
  36. package/schema.json +21 -21
@@ -1,6 +1,7 @@
1
1
  // Node-only analysis of a single SuiteScript source file (ESTree via oxc-parser).
2
2
  import { parseSync, } from "oxc-parser";
3
3
  import { declaredProblems, inferredProblems, inferScriptType, typeByName, } from "../lib/inference.js";
4
+ import { misplacedModule, sideOf, unsupportedModule, } from "../lib/module-script-types.js";
4
5
  const API_VERSION = "2.1";
5
6
  const UPGRADABLE = new Set(["1.0", "2.0", "2.x"]);
6
7
  function isCallback(node) {
@@ -97,6 +98,7 @@ function exportedNames(node) {
97
98
  }
98
99
  function inspectProgram(program) {
99
100
  let isScript = false;
101
+ const modules = [];
100
102
  const returned = [];
101
103
  let opaque = false;
102
104
  // Only trust `returned` when an inline define() callback or ES syntax was seen;
@@ -106,8 +108,11 @@ function inspectProgram(program) {
106
108
  if (node.type === "ImportDeclaration" ||
107
109
  node.type === "ExportNamedDeclaration")
108
110
  known = true;
109
- if (node.type === "ImportDeclaration" && /^N(\/|$)/.test(node.source.value))
111
+ if (node.type === "ImportDeclaration" &&
112
+ /^N(\/|$)/.test(node.source.value)) {
110
113
  isScript = true;
114
+ modules.push(node.source.value);
115
+ }
111
116
  if (node.type === "ExportNamedDeclaration")
112
117
  returned.push(...exportedNames(node));
113
118
  if (node.type === "ExpressionStatement" &&
@@ -115,6 +120,14 @@ function inspectProgram(program) {
115
120
  const call = node.expression;
116
121
  if (call.callee.type === "Identifier" && call.callee.name === "define") {
117
122
  isScript = true;
123
+ const dependencies = call.arguments.at(-2);
124
+ if (dependencies?.type === "ArrayExpression") {
125
+ for (const element of dependencies.elements) {
126
+ if (element?.type === "Literal" &&
127
+ typeof element.value === "string")
128
+ modules.push(element.value);
129
+ }
130
+ }
118
131
  const callback = call.arguments.at(-1);
119
132
  if (isCallback(callback)) {
120
133
  const names = amdReturnedNames(callback);
@@ -132,7 +145,11 @@ function inspectProgram(program) {
132
145
  }
133
146
  }
134
147
  }
135
- return { isScript, returned: opaque || !known ? undefined : returned };
148
+ return {
149
+ isScript,
150
+ modules,
151
+ returned: opaque || !known ? undefined : returned,
152
+ };
136
153
  }
137
154
  function findTag(comments, name) {
138
155
  const pattern = new RegExp(`${name}(?:[ \\t]+(\\S+))?`);
@@ -182,7 +199,7 @@ export function analyzeSource(filename, source) {
182
199
  }
183
200
  const apiVersion = findTag(parsed.comments, "@NApiVersion");
184
201
  const scriptType = findTag(parsed.comments, "@NScriptType");
185
- const { isScript, returned } = inspectProgram(parsed.program);
202
+ const { isScript, modules, returned } = inspectProgram(parsed.program);
186
203
  const inferred = isScript && returned ? inferScriptType(returned) : undefined;
187
204
  // Not a script: no tags and nothing that implies an entry point.
188
205
  if (!apiVersion && !scriptType && !inferred)
@@ -223,5 +240,18 @@ export function analyzeSource(filename, source) {
223
240
  for (const message of problems)
224
241
  report("entryPoints", message, scriptType?.offset ?? 0);
225
242
  }
243
+ // The declared type wins; otherwise fall back to what the entry points imply.
244
+ const declaredType = declared ? typeByName(declared) : undefined;
245
+ const side = sideOf(declaredType ? [declaredType] : (inferred?.types ?? []));
246
+ const single = declaredType ??
247
+ (inferred?.types.length === 1 ? inferred.types[0] : undefined);
248
+ if (side || single) {
249
+ for (const module of modules) {
250
+ const message = (side && misplacedModule(side, module)) ||
251
+ (single && unsupportedModule(single.name, module));
252
+ if (message)
253
+ report("modules", message, scriptType?.offset ?? 0);
254
+ }
255
+ }
226
256
  return { diagnostics, upgrade };
227
257
  }
@@ -0,0 +1,70 @@
1
+ // Which side of NetSuite a script type runs on, and the N/ modules that only
2
+ // work on one side. From the "Supported Script Types" column of Oracle's SuiteScript
3
+ // module reference; loading a module on the wrong side fails at runtime.
4
+ // Pure logic shared by the Biome rules and the CLI.
5
+ export const CLIENT_ONLY_MODULES = new Set([
6
+ "N/currentRecord",
7
+ "N/portlet",
8
+ "N/ui/dialog",
9
+ "N/ui/message",
10
+ ]);
11
+ export const SERVER_ONLY_MODULES = new Set([
12
+ "N/auth",
13
+ "N/cache",
14
+ "N/certificateControl",
15
+ "N/compress",
16
+ "N/config",
17
+ "N/crypto",
18
+ "N/crypto/certificate",
19
+ "N/dataset",
20
+ "N/documentCapture",
21
+ "N/encode",
22
+ "N/error",
23
+ "N/file",
24
+ "N/https/clientCertificate",
25
+ "N/keyControl",
26
+ "N/llm",
27
+ "N/machineTranslation",
28
+ "N/pgp",
29
+ "N/piremoval",
30
+ "N/plugin",
31
+ "N/redirect",
32
+ "N/render",
33
+ "N/sftp",
34
+ "N/task",
35
+ "N/task/accounting/recognition",
36
+ "N/ui/serverWidget",
37
+ "N/workbook",
38
+ "N/workflow",
39
+ ]);
40
+ /** The side shared by every candidate script type, or undefined when unknown or mixed. */
41
+ export function sideOf(types) {
42
+ if (types.length === 0)
43
+ return undefined;
44
+ const clients = types.filter((t) => t.name === "ClientScript").length;
45
+ if (clients === types.length)
46
+ return "client";
47
+ return clients === 0 ? "server" : undefined;
48
+ }
49
+ /** A message when `module` cannot be loaded by a script on `side`. */
50
+ export function misplacedModule(side, module) {
51
+ if (side === "server" && CLIENT_ONLY_MODULES.has(module))
52
+ return `${module} is only available in client scripts.`;
53
+ if (side === "client" && SERVER_ONLY_MODULES.has(module))
54
+ return `${module} is only available in server scripts.`;
55
+ return undefined;
56
+ }
57
+ const CUSTOM_TOOL_UNSUPPORTED = new Set([
58
+ "N/http",
59
+ "N/https",
60
+ "N/llm",
61
+ "N/sftp",
62
+ ]);
63
+ /** A message when a single script type cannot load `module` at all. */
64
+ export function unsupportedModule(scriptType, module) {
65
+ if (scriptType === "CustomTool" && CUSTOM_TOOL_UNSUPPORTED.has(module))
66
+ return `${module} is not supported in custom tool scripts.`;
67
+ if (scriptType === "BundleInstallationScript" && !/^N(\/|$)/.test(module))
68
+ return "Custom modules are not supported in bundle installation scripts.";
69
+ return undefined;
70
+ }
@@ -0,0 +1,83 @@
1
+ import { esExportNames } from "./biome-modules.js";
2
+ import { parseDefine } from "./define.js";
3
+ import { amdReturnedNames } from "./entry-points.js";
4
+ import { inferScriptType } from "./inference.js";
5
+ import { sideOf } from "./module-script-types.js";
6
+ /**
7
+ * Script types the module containing `node` (a define() call or an ES module)
8
+ * could be, judged from the entry points it returns. Undefined for libraries
9
+ * and for entry points that cannot be analysed.
10
+ */
11
+ export function inferredTypes(node) {
12
+ for (const candidate of [node, ...node.ancestors()]) {
13
+ let names;
14
+ if (candidate.kind === "JS_CALL_EXPRESSION") {
15
+ const def = parseDefine(candidate);
16
+ if (!def)
17
+ continue;
18
+ names = amdReturnedNames(def);
19
+ }
20
+ else if (candidate.kind === "JS_MODULE") {
21
+ names = esExportNames(candidate);
22
+ }
23
+ else {
24
+ continue;
25
+ }
26
+ return (names && inferScriptType(names))?.types;
27
+ }
28
+ return undefined;
29
+ }
30
+ /** Whether that module is a client or server script. Undefined when unknown or mixed. */
31
+ export function scriptSide(node) {
32
+ const types = inferredTypes(node);
33
+ return types && sideOf(types);
34
+ }
35
+ const FUNCTION_KINDS = [
36
+ "JS_FUNCTION_DECLARATION",
37
+ "JS_FUNCTION_EXPRESSION",
38
+ "JS_ARROW_FUNCTION_EXPRESSION",
39
+ "JS_METHOD_OBJECT_MEMBER",
40
+ ];
41
+ const unquote = (text) => text.replace(/^['"]|['"]$/g, "");
42
+ /** The name a function is bound to: its declaration, variable, object key or `o.name =` target. */
43
+ export function functionName(fn) {
44
+ if (fn.kind === "JS_FUNCTION_DECLARATION")
45
+ return fn.children().find((c) => c.kind === "JS_IDENTIFIER_BINDING")?.text;
46
+ if (fn.kind === "JS_METHOD_OBJECT_MEMBER") {
47
+ const name = fn.children()[0]?.text;
48
+ return name ? unquote(name) : undefined;
49
+ }
50
+ const parent = fn.parent;
51
+ if (parent?.kind === "JS_PROPERTY_OBJECT_MEMBER") {
52
+ const name = parent.children()[0]?.text;
53
+ return name ? unquote(name) : undefined;
54
+ }
55
+ if (parent?.kind === "JS_INITIALIZER_CLAUSE")
56
+ return parent.parent
57
+ ?.children()
58
+ .find((c) => c.kind === "JS_IDENTIFIER_BINDING")?.text;
59
+ if (parent?.kind === "JS_ASSIGNMENT_EXPRESSION") {
60
+ const target = parent.children()[0];
61
+ if (target?.kind === "JS_STATIC_MEMBER_ASSIGNMENT")
62
+ return target.children()[1]?.text;
63
+ }
64
+ return undefined;
65
+ }
66
+ /** Names of the functions enclosing `node`, innermost first. */
67
+ export function enclosingFunctionNames(node) {
68
+ return node
69
+ .ancestors()
70
+ .filter((a) => FUNCTION_KINDS.includes(a.kind))
71
+ .map(functionName)
72
+ .filter((n) => !!n);
73
+ }
74
+ export function isFunction(node) {
75
+ return FUNCTION_KINDS.includes(node.kind);
76
+ }
77
+ /** Every node below `node`, depth first. */
78
+ export function* descendants(node) {
79
+ for (const child of node.children()) {
80
+ yield child;
81
+ yield* descendants(child);
82
+ }
83
+ }
@@ -37,6 +37,10 @@ export const SCRIPT_TYPES = [
37
37
  entryPoints: ["customizeGlImpact"],
38
38
  name: "CustomGLPlugin",
39
39
  },
40
+ {
41
+ entryPoints: [],
42
+ name: "CustomTool",
43
+ },
40
44
  {
41
45
  entryPoints: ["createDataset"],
42
46
  name: "DatasetBuilderPlugin",
@@ -86,6 +90,14 @@ export const SCRIPT_TYPES = [
86
90
  entryPoints: ["run"],
87
91
  name: "SDFInstallationScript",
88
92
  },
93
+ {
94
+ entryPoints: ["run"],
95
+ name: "SpaClientScript",
96
+ },
97
+ {
98
+ entryPoints: ["initializeSpa"],
99
+ name: "SpaServerScript",
100
+ },
89
101
  {
90
102
  entryPoints: ["onRequest"],
91
103
  name: "Suitelet",
@@ -0,0 +1,23 @@
1
+ import { importsNetSuite } from "./biome-modules.js";
2
+ import { parseDefine } from "./define.js";
3
+ /** Whether a node is inside an AMD `define([...], fn)` or an ES module importing `N/` modules. */
4
+ export function inSuiteScriptModule(node) {
5
+ return node.ancestors().some((a) => {
6
+ if (a.kind === "JS_CALL_EXPRESSION")
7
+ return !!parseDefine(a);
8
+ return a.kind === "JS_MODULE" && importsNetSuite(a);
9
+ });
10
+ }
11
+ /** Whether an expression is built only from string literals (and `+`). */
12
+ export function isStaticString(node) {
13
+ if (!node)
14
+ return false;
15
+ if (node.kind === "JS_STRING_LITERAL_EXPRESSION")
16
+ return true;
17
+ if (node.kind === "JS_TEMPLATE_EXPRESSION")
18
+ return !node.text.includes("${");
19
+ if (node.kind === "JS_BINARY_EXPRESSION" ||
20
+ node.kind === "JS_PARENTHESIZED_EXPRESSION")
21
+ return node.children().every(isStaticString);
22
+ return false;
23
+ }
@@ -1,9 +1,9 @@
1
- engine biome(1.0)
2
- language js(typescript, jsx)
3
-
4
- // Anonymous AMD modules only: NetSuite/bundlers assign the module ID.
5
- `define($id, $deps, $cb)` as $call where {
6
- $id <: JsStringLiteralExpression(),
7
- register_diagnostic(span=$id, message="No AMD module name should be provided.", severity="error", fix_kind="safe"),
8
- $call => `define($deps, $cb)`
9
- }
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // Anonymous AMD modules only: NetSuite/bundlers assign the module ID.
5
+ `define($id, $deps, $cb)` as $call where {
6
+ $id <: JsStringLiteralExpression(),
7
+ register_diagnostic(span=$id, message="No AMD module name should be provided.", severity="error", fix_kind="safe"),
8
+ $call => `define($deps, $cb)`
9
+ }
@@ -0,0 +1,19 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { enclosingFunctionNames } from "../lib/script-side.js";
3
+ const RECORD_CALLS = ["record.load", "record.submitFields", "record.delete"];
4
+ // Reloading or editing the record being saved from its own beforeSubmit fails
5
+ // with "Record has been changed". Only calls whose id is the context's
6
+ // newRecord.id are reported; other records are fine.
7
+ export const noBeforeSubmitRecordReload = defineRule({
8
+ query: ast("JS_CALL_EXPRESSION"),
9
+ run(node) {
10
+ if (!RECORD_CALLS.includes(node.callee?.text ?? ""))
11
+ return;
12
+ const options = node.arguments?.children()[0]?.children()[0];
13
+ if (!options || !/\bid\s*:\s*[\w.]*newRecord\.id\b/.test(options.text))
14
+ return;
15
+ if (!enclosingFunctionNames(node).includes("beforeSubmit"))
16
+ return;
17
+ registerDiagnostic(node, "error", `${node.callee?.text}() on the record being saved is not allowed in beforeSubmit.`);
18
+ },
19
+ });
@@ -0,0 +1,23 @@
1
+ import { defineRule, registerDiagnostic, semantic, } from "@biomejs/runtime/plugin";
2
+ import { scriptSide } from "../lib/script-side.js";
3
+ const BROWSER_GLOBALS = new Set([
4
+ "document",
5
+ "window",
6
+ "localStorage",
7
+ "sessionStorage",
8
+ "XMLHttpRequest",
9
+ "alert",
10
+ "navigator",
11
+ ]);
12
+ // Server scripts run outside a browser, but TypeScript's DOM library lets
13
+ // these names compile. Locals that reuse a name resolve to a binding and are left alone.
14
+ export const noBrowserGlobals = defineRule({
15
+ query: semantic("JS_REFERENCE_IDENTIFIER"),
16
+ run(node, { model }) {
17
+ if (!BROWSER_GLOBALS.has(node.text) || model.binding(node))
18
+ return;
19
+ if (scriptSide(node) !== "server")
20
+ return;
21
+ registerDiagnostic(node, "error", `'${node.text}' does not exist in server scripts.`);
22
+ },
23
+ });
@@ -0,0 +1,14 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { logCall } from "../lib/log.js";
3
+ import { scriptSide } from "../lib/script-side.js";
4
+ // Oracle (N/log): in a client script attached to a form, "calls to any log
5
+ // function ... are ignored". It cannot tell whether a form is attached.
6
+ export const noClientLog = defineRule({
7
+ query: ast("JS_CALL_EXPRESSION"),
8
+ run(node) {
9
+ const info = logCall(node);
10
+ if (!info || scriptSide(node) !== "client")
11
+ return;
12
+ registerDiagnostic(node, "warning", `log.${info.prop}() is ignored in client scripts attached to a form; use console.`);
13
+ },
14
+ });
@@ -0,0 +1,24 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { isStaticString } from "../lib/suitescript.js";
3
+ // Matches query.runSuiteQL, runSuiteQLPaged and their .promise forms.
4
+ const SUITEQL = /(^|\.)runSuiteQL(Paged)?(\.promise)?$/;
5
+ // Building SuiteQL from variables invites injection and defeats query plan
6
+ // caching; pass values through `params` placeholders instead. A bare variable
7
+ // is not flagged because its contents are unknown.
8
+ export const noDynamicSuiteQL = defineRule({
9
+ query: ast("JS_CALL_EXPRESSION"),
10
+ run(node) {
11
+ if (!SUITEQL.test(node.callee?.text ?? ""))
12
+ return;
13
+ let sql = node.arguments?.children()[0]?.children()[0];
14
+ if (sql?.kind === "JS_OBJECT_EXPRESSION") {
15
+ const member = (sql.children()[0]?.children() ?? []).find((m) => m.children()[0]?.text.replace(/^['"]|['"]$/g, "") === "query");
16
+ sql = member?.children()[1];
17
+ }
18
+ if (sql &&
19
+ !isStaticString(sql) &&
20
+ sql.kind !== "JS_IDENTIFIER_EXPRESSION") {
21
+ registerDiagnostic(sql, "warning", "Build SuiteQL with ? placeholders and params, not string concatenation.");
22
+ }
23
+ },
24
+ });
@@ -0,0 +1,19 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { ALL_ENTRY_POINTS, GENERIC_ENTRY_POINTS } from "../lib/inference.js";
3
+ import { functionName, scriptSide } from "../lib/script-side.js";
4
+ // Oracle: "Do not deploy scripts with blank entry functions. They still load and
5
+ // may affect performance without doing any work." Only modules whose script type
6
+ // is inferred are checked, so libraries with an empty `get` or `run` are ignored.
7
+ export const noEmptyEntryPoints = defineRule({
8
+ query: ast("JS_FUNCTION_DECLARATION", "JS_FUNCTION_EXPRESSION", "JS_ARROW_FUNCTION_EXPRESSION", "JS_METHOD_OBJECT_MEMBER"),
9
+ run(node) {
10
+ const name = functionName(node);
11
+ if (!name || !ALL_ENTRY_POINTS.has(name) || GENERIC_ENTRY_POINTS.has(name))
12
+ return;
13
+ const body = node.children().find((c) => c.kind === "JS_FUNCTION_BODY");
14
+ const empty = body?.children().every((c) => c.children().length === 0);
15
+ if (!empty || !scriptSide(node))
16
+ return;
17
+ registerDiagnostic(node, "warning", `Entry point '${name}' is empty.`);
18
+ },
19
+ });
@@ -0,0 +1,21 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ const TIMERS = ["setTimeout", "setInterval"];
3
+ // eval(), new Function() and string-form timers run arbitrary code.
4
+ export const noEval = defineRule({
5
+ query: ast("JS_CALL_EXPRESSION", "JS_NEW_EXPRESSION"),
6
+ run(node) {
7
+ const callee = node.callee?.text;
8
+ if (!callee)
9
+ return;
10
+ if (callee === "eval" || callee === "Function") {
11
+ registerDiagnostic(node, "error", `Do not use ${callee}().`);
12
+ }
13
+ else if (node.kind === "JS_CALL_EXPRESSION" && TIMERS.includes(callee)) {
14
+ const first = node.arguments?.children()[0]?.children()[0];
15
+ if (first?.kind === "JS_STRING_LITERAL_EXPRESSION" ||
16
+ first?.kind === "JS_TEMPLATE_EXPRESSION") {
17
+ registerDiagnostic(node, "error", `Do not pass a string to ${callee}(); it is evaluated as code.`);
18
+ }
19
+ }
20
+ },
21
+ });
@@ -0,0 +1,34 @@
1
+ import { ast, defineRule, registerDiagnostic, } from "@biomejs/runtime/plugin";
2
+ import { dependencies } from "../lib/biome-modules.js";
3
+ import { parseDefine } from "../lib/define.js";
4
+ // `log` and `util` are SuiteScript 2.1 globals. The one allowed binding is the
5
+ // module they come from: N/log as `log`, N/util as `util`. `error` is not listed
6
+ // because moduleVars names the N/error identifier `error`.
7
+ const GLOBALS = { log: "N/log", util: "N/util" };
8
+ function isModuleBinding(node, module) {
9
+ const ancestors = node.ancestors();
10
+ for (const ancestor of ancestors) {
11
+ if (ancestor.kind === "JS_CALL_EXPRESSION") {
12
+ const def = parseDefine(ancestor);
13
+ if (!def)
14
+ continue;
15
+ const index = def.params.findIndex((p) => p?.text === node.text);
16
+ const isParameter = ancestors.some((a) => a.kind === "JS_FORMAL_PARAMETER");
17
+ const name = def.elements[index]?.text.slice(1, -1);
18
+ return isParameter && index >= 0 && name === module;
19
+ }
20
+ if (ancestor.kind === "JS_IMPORT") {
21
+ return dependencies(ancestor).some((d) => d.name === module);
22
+ }
23
+ }
24
+ return false;
25
+ }
26
+ export const noGlobalShadowing = defineRule({
27
+ query: ast("JS_IDENTIFIER_BINDING"),
28
+ run(node) {
29
+ const module = GLOBALS[node.text];
30
+ if (module && !isModuleBinding(node, module)) {
31
+ registerDiagnostic(node, "warning", `'${node.text}' shadows the global ${node.text}.`);
32
+ }
33
+ },
34
+ });
@@ -0,0 +1,38 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ const SECRET_NAMES = /^(password|passwd|pwd|secret|api_?key|client_?secret|token_?secret|consumer_?secret)$/i;
3
+ const unquote = (text) => text.replace(/^['"]|['"]$/g, "");
4
+ // Oracle: "Do not hard-code any passwords in scripts." A heuristic on names, so
5
+ // no preset includes it; enable it explicitly. Only exact secret-like names with
6
+ // a non-empty string literal are flagged (not `passwordId` or `tokenName`).
7
+ export const noHardcodedSecrets = defineRule({
8
+ query: ast("JS_VARIABLE_DECLARATOR", "JS_PROPERTY_OBJECT_MEMBER", "JS_ASSIGNMENT_EXPRESSION"),
9
+ run(node) {
10
+ const children = node.children();
11
+ let name;
12
+ let value;
13
+ if (node.kind === "JS_VARIABLE_DECLARATOR") {
14
+ name = children.find((c) => c.kind === "JS_IDENTIFIER_BINDING")?.text;
15
+ value = children
16
+ .find((c) => c.kind === "JS_INITIALIZER_CLAUSE")
17
+ ?.children()[0];
18
+ }
19
+ else if (node.kind === "JS_PROPERTY_OBJECT_MEMBER") {
20
+ name = children[0] && unquote(children[0].text);
21
+ value = children[1];
22
+ }
23
+ else {
24
+ const target = children[0];
25
+ name =
26
+ target?.kind === "JS_STATIC_MEMBER_ASSIGNMENT"
27
+ ? target.children()[1]?.text
28
+ : target?.text;
29
+ value = children[1];
30
+ }
31
+ if (name &&
32
+ SECRET_NAMES.test(name) &&
33
+ value?.kind === "JS_STRING_LITERAL_EXPRESSION" &&
34
+ value.text.length > 2) {
35
+ registerDiagnostic(value, "error", `Do not hard-code '${name}'; read it from a script parameter or credentials.`);
36
+ }
37
+ },
38
+ });
@@ -0,0 +1,23 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ // Oracle: "When you define custom headers, don't include underscores in the name."
3
+ export const noHeaderUnderscores = defineRule({
4
+ query: ast("JS_CALL_EXPRESSION"),
5
+ run(node) {
6
+ if (!/^https\.[a-zA-Z]+(\.promise)?$/.test(node.callee?.text ?? ""))
7
+ return;
8
+ const options = node.arguments?.children()[0]?.children()[0];
9
+ if (options?.kind !== "JS_OBJECT_EXPRESSION")
10
+ return;
11
+ for (const member of options.children()[0]?.children() ?? []) {
12
+ const [name, headers] = member.children();
13
+ if (name?.text !== "headers" || headers?.kind !== "JS_OBJECT_EXPRESSION")
14
+ continue;
15
+ for (const header of headers.children()[0]?.children() ?? []) {
16
+ const key = header.children()[0];
17
+ if (key?.text.replace(/^['"]|['"]$/g, "").includes("_")) {
18
+ registerDiagnostic(key, "warning", "Header names must not contain underscores.");
19
+ }
20
+ }
21
+ }
22
+ },
23
+ });
@@ -0,0 +1,20 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ // Oracle: the N/https module "does not allow the HTTP protocol".
3
+ export const noInsecureHttpsUrl = defineRule({
4
+ query: ast("JS_CALL_EXPRESSION"),
5
+ run(node) {
6
+ if (!/^https\.[a-zA-Z]+(\.promise)?$/.test(node.callee?.text ?? ""))
7
+ return;
8
+ const options = node.arguments?.children()[0]?.children()[0];
9
+ if (options?.kind !== "JS_OBJECT_EXPRESSION")
10
+ return;
11
+ for (const member of options.children()[0]?.children() ?? []) {
12
+ const [name, value] = member.children();
13
+ if (name?.text === "url" &&
14
+ value?.kind === "JS_STRING_LITERAL_EXPRESSION" &&
15
+ /^['"]http:\/\//i.test(value.text)) {
16
+ registerDiagnostic(value, "error", "N/https does not allow http:// URLs.");
17
+ }
18
+ }
19
+ },
20
+ });
@@ -1,6 +1,9 @@
1
1
  import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
2
  import { dependencies } from "../lib/biome-modules.js";
3
- // Use the global `log` instead of loading N/log. The global is not available in
3
+ // Opt-in style: use the global `log` instead of loading N/log. It is the opposite
4
+ // of noMissingModules, which requires loading N/log, so enable only one of them.
5
+ // Oracle's skills disagree on this (upgrade: the global exists but importing is
6
+ // recommended; learning: it is not global). The global is not available in
4
7
  // client scripts, so scope this rule away from them with the plugin `includes`.
5
8
  export const noLogModule = defineRule({
6
9
  query: ast("JS_CALL_EXPRESSION", "JS_IMPORT"),
@@ -0,0 +1,33 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { descendants, enclosingFunctionNames, isFunction, } from "../lib/script-side.js";
3
+ const RESULTS = /\.getRange\(/;
4
+ // Oracle: running a search in getInputData and returning its results risks a
5
+ // timeout; return the search.create()/search.load() object instead. Detects
6
+ // `.getRange()` results returned directly or through a local variable.
7
+ export const noMapReduceSearchResults = defineRule({
8
+ query: ast("JS_RETURN_STATEMENT"),
9
+ run(node) {
10
+ if (!enclosingFunctionNames(node).includes("getInputData"))
11
+ return;
12
+ const argument = node.children()[0];
13
+ if (!argument)
14
+ return;
15
+ let flagged = RESULTS.test(argument.text);
16
+ if (!flagged && argument.kind === "JS_IDENTIFIER_EXPRESSION") {
17
+ const fn = node.ancestors().find(isFunction);
18
+ for (const d of fn ? descendants(fn) : []) {
19
+ if (d.kind !== "JS_VARIABLE_DECLARATOR")
20
+ continue;
21
+ const id = d.children().find((c) => c.kind === "JS_IDENTIFIER_BINDING");
22
+ const init = d
23
+ .children()
24
+ .find((c) => c.kind === "JS_INITIALIZER_CLAUSE");
25
+ if (id?.text === argument.text && init && RESULTS.test(init.text))
26
+ flagged = true;
27
+ }
28
+ }
29
+ if (flagged) {
30
+ registerDiagnostic(argument, "warning", "Return the search object from getInputData instead of its results.");
31
+ }
32
+ },
33
+ });
@@ -0,0 +1,26 @@
1
+ import { defineRule, registerDiagnostic, semantic, } from "@biomejs/runtime/plugin";
2
+ import { MODULES } from "../lib/modules.js";
3
+ import { inSuiteScriptModule } from "../lib/suitescript.js";
4
+ // `util` is a global in SuiteScript 2.1, so it never needs loading. `log` is
5
+ // treated like every other module: load N/log when you call it.
6
+ const ALIASES = new Map(Object.entries(MODULES)
7
+ .filter(([, identifier]) => identifier !== "util")
8
+ .map(([name, identifier]) => [identifier, name]));
9
+ // Reports `record.load()` when `record` resolves to no binding, meaning the
10
+ // module was never added to `define()` or imported. Locals that shadow the
11
+ // name resolve to a binding and are left alone.
12
+ export const noMissingModules = defineRule({
13
+ query: semantic("JS_REFERENCE_IDENTIFIER"),
14
+ run(node, { model }) {
15
+ const module = ALIASES.get(node.text);
16
+ if (!module || model.binding(node))
17
+ return;
18
+ const member = node.parent?.parent;
19
+ if (member?.kind !== "JS_STATIC_MEMBER_EXPRESSION" ||
20
+ member.children()[0]?.text !== node.text)
21
+ return;
22
+ if (!inSuiteScriptModule(node))
23
+ return;
24
+ registerDiagnostic(node, "error", `'${node.text}' is used but ${module} is not loaded.`);
25
+ },
26
+ });
@@ -1,12 +1,12 @@
1
- engine biome(1.0)
2
- language js(typescript, jsx)
3
-
4
- // Dependencies in the define() array must not carry a `.js` extension.
5
- `define($args)` where {
6
- $args <: contains JsArrayExpression() as $deps,
7
- $deps <: contains JsStringLiteralExpression() as $dep where {
8
- $dep <: r"(['\"])(.*)\.js(['\"])"($q1, $name, $q2),
9
- register_diagnostic(span=$dep, message="Module name should not include file extension.", severity="error", fix_kind="safe"),
10
- $dep => `$q1$name$q2`
11
- }
12
- }
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // Dependencies in the define() array must not carry a `.js` extension.
5
+ `define($args)` where {
6
+ $args <: contains JsArrayExpression() as $deps,
7
+ $deps <: contains JsStringLiteralExpression() as $dep where {
8
+ $dep <: r"(['\"])(.*)\.js(['\"])"($q1, $name, $q2),
9
+ register_diagnostic(span=$dep, message="Module name should not include file extension.", severity="error", fix_kind="safe"),
10
+ $dep => `$q1$name$q2`
11
+ }
12
+ }