biome-plugin-suitescript 0.2.0 → 0.3.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 (69) hide show
  1. package/README.md +148 -77
  2. package/biome-manifest.json +179 -17
  3. package/configs/all.json +29 -0
  4. package/configs/minimal.json +14 -0
  5. package/configs/recommended.json +20 -0
  6. package/dist/lib/define.js +35 -5
  7. package/dist/lib/headers.js +12 -0
  8. package/dist/lib/module-calls.js +65 -0
  9. package/dist/lib/module-script-types.js +7 -1
  10. package/dist/lib/options.js +35 -0
  11. package/dist/lib/required-options.js +14 -0
  12. package/dist/lib/script-types.js +25 -14
  13. package/dist/lib/suiteql.js +16 -0
  14. package/dist/lib/typings-data.js +4327 -0
  15. package/dist/lib/typings.js +2 -0
  16. package/dist/rules/logRequireDetails.js +4 -0
  17. package/dist/rules/logRequireTitle.js +1 -0
  18. package/dist/rules/noAuthorizationHeaderInInternalRequest.js +24 -0
  19. package/dist/rules/noBlockedHttpsHeader.js +32 -0
  20. package/dist/rules/noBlockedResponseHeader.js +35 -0
  21. package/dist/rules/noBrowserGlobals.js +2 -0
  22. package/dist/rules/noConflictingClientScriptProps.js +36 -0
  23. package/dist/rules/noDynamicRequire.js +23 -0
  24. package/dist/rules/noDynamicSuiteQL.js +11 -6
  25. package/dist/rules/noExtraDeps.js +16 -0
  26. package/dist/rules/noExtraParams.js +17 -0
  27. package/dist/rules/noGlobalShadowing.js +6 -2
  28. package/dist/rules/noHardcodedAuthPasswords.js +27 -0
  29. package/dist/rules/noHardcodedSecrets.js +7 -2
  30. package/dist/rules/noInnerHtml.js +41 -0
  31. package/dist/rules/noInsecureCookie.js +27 -0
  32. package/dist/rules/noInvalidDefine.js +47 -0
  33. package/dist/rules/noInvalidEnumMember.js +24 -0
  34. package/dist/rules/noLocationHeader.js +20 -0
  35. package/dist/rules/noLogModule.js +8 -5
  36. package/dist/rules/noMissingModules.js +6 -3
  37. package/dist/rules/noModuleScopeApiCalls.js +14 -4
  38. package/dist/rules/noMultipleDefine.js +14 -0
  39. package/dist/rules/noNativeIterators.js +8 -5
  40. package/dist/rules/noPositionalOptions.js +26 -0
  41. package/dist/rules/noPostMessageWildcard.js +15 -0
  42. package/dist/rules/noPublicFileUpload.js +20 -0
  43. package/dist/rules/noRecordInLoop.js +8 -5
  44. package/dist/rules/noRequireInsteadOfDefine.js +25 -0
  45. package/dist/rules/noSecretKeyGuidAndSecret.js +16 -0
  46. package/dist/rules/noSensitiveBrowserStorage.js +24 -0
  47. package/dist/rules/noStackInResponse.js +21 -0
  48. package/dist/rules/noSuiteQLSelectStar.js +18 -0
  49. package/dist/rules/noSuiteQLSystemNoteJoin.js +19 -0
  50. package/dist/rules/noSuiteQLTypeInSearch.js +41 -0
  51. package/dist/rules/noSuiteScript1Api.js +77 -3
  52. package/dist/rules/noUnknownModuleMember.js +23 -0
  53. package/dist/rules/noUnknownOptions.js +31 -0
  54. package/dist/rules/noUnsupportedServerWidgetFieldType.js +40 -0
  55. package/dist/rules/noWeakHash.js +17 -0
  56. package/dist/rules/noWildcardCors.js +16 -0
  57. package/dist/rules/noWrongScriptTypeModules.js +3 -1
  58. package/dist/rules/requireCustpagePrefix.js +14 -13
  59. package/dist/rules/requireEachReturn.js +38 -0
  60. package/dist/rules/requireLogModule.js +25 -0
  61. package/dist/rules/requireOptions.js +26 -0
  62. package/dist/rules/requireOrderByForPagedSuiteQL.js +17 -0
  63. package/dist/rules/requireQueryOptions.js +53 -0
  64. package/dist/rules/requireServerResponseOptions.js +33 -0
  65. package/dist/rules/requireServerWidgetOptions.js +37 -0
  66. package/package.json +3 -1
  67. package/dist/rules/noEval.js +0 -21
  68. package/dist/rules/noExtraModules.js +0 -23
  69. package/dist/rules/noUserEventContextSave.js +0 -12
@@ -0,0 +1,26 @@
1
+ import { defineRule, registerDiagnostic, semantic, } from "@biomejs/runtime/plugin";
2
+ import { moduleCall } from "../lib/module-calls.js";
3
+ import { callArguments } from "../lib/options.js";
4
+ const LITERALS = [
5
+ "JS_STRING_LITERAL_EXPRESSION",
6
+ "JS_NUMBER_LITERAL_EXPRESSION",
7
+ "JS_BOOLEAN_LITERAL_EXPRESSION",
8
+ "JS_NULL_LITERAL_EXPRESSION",
9
+ "JS_ARRAY_EXPRESSION",
10
+ ];
11
+ // The type definitions (Tier 2) show that a module-level function takes one
12
+ // options object. Passing a literal instead (`record.load('customer', 1)`) leaves
13
+ // every required option missing at runtime. Only a literal first argument is
14
+ // flagged; a variable may hold the options object.
15
+ export const noPositionalOptions = defineRule({
16
+ query: semantic("JS_CALL_EXPRESSION"),
17
+ run(node, { model }) {
18
+ const call = moduleCall(node, model);
19
+ if (!call || call.alternatives === 0)
20
+ return;
21
+ const first = callArguments(node)[0];
22
+ if (first && LITERALS.includes(first.kind)) {
23
+ registerDiagnostic(first, "error", `${call.callee}() takes an options object, not positional arguments.`);
24
+ }
25
+ },
26
+ });
@@ -0,0 +1,15 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { callArguments, stringValue } from "../lib/options.js";
3
+ // Oracle's OWASP skill: "`postMessage` uses specific origin (never `'*'`)".
4
+ // Sending with a wildcard target origin lets any website receive the message.
5
+ export const noPostMessageWildcard = defineRule({
6
+ query: ast("JS_CALL_EXPRESSION"),
7
+ run(node) {
8
+ if (!/\.postMessage$/.test(node.callee?.text ?? ""))
9
+ return;
10
+ const origin = callArguments(node)[1];
11
+ if (stringValue(origin) === "*") {
12
+ registerDiagnostic(origin ?? node, "warning", "Pass postMessage() a specific target origin, never '*'.");
13
+ }
14
+ },
15
+ });
@@ -0,0 +1,20 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ // Tier 0: docs/adr/0005-guessing-rules-are-in-no-preset.md.
3
+ // Oracle's OWASP skill: uploads are stored in restricted folders and
4
+ // `isOnline: false // Not publicly accessible by default`; its bad example sets
5
+ // `fileObj.isOnline = true; // Publicly accessible!`. LOW CONFIDENCE, so no preset
6
+ // includes it: public assets legitimately set isOnline.
7
+ export const noPublicFileUpload = defineRule({
8
+ query: ast("JS_PROPERTY_OBJECT_MEMBER", "JS_ASSIGNMENT_EXPRESSION"),
9
+ run(node) {
10
+ const [target, value] = node.children();
11
+ const name = node.kind === "JS_ASSIGNMENT_EXPRESSION"
12
+ ? target?.kind === "JS_STATIC_MEMBER_ASSIGNMENT"
13
+ ? target.children()[1]?.text
14
+ : undefined
15
+ : target?.text;
16
+ if (name === "isOnline" && value?.text === "true") {
17
+ registerDiagnostic(value, "warning", "isOnline: true makes the file publicly accessible.");
18
+ }
19
+ },
20
+ });
@@ -7,13 +7,16 @@ const LOOPS = [
7
7
  "JS_DO_WHILE_STATEMENT",
8
8
  ];
9
9
  const ITERATORS = /\.(forEach|each|map)$/;
10
- const RECORD_CALLS = ["record.load", "record.create"];
11
- // Loading or creating a record per iteration burns governance; search or
12
- // submitFields in bulk instead. Opt-in: Map/Reduce `map` handles one record per call.
10
+ const LOOKUPS = /^(record\.load|query\.runSuiteQL(Paged)?|search\.(create|load))(\.promise)?$/;
11
+ // Oracle's SAFE guide: "NEVER run queries inside loops" (the N+1 problem) and
12
+ // "Loading records in loops: use searches or bulk operations." Loading a record or
13
+ // running a query or search per iteration burns governance. Only record.load is
14
+ // named for records, so record.create and submitFields are not flagged. Map/Reduce
15
+ // `map` handles one record per call, so keep this rule out of those files.
13
16
  export const noRecordInLoop = defineRule({
14
17
  query: ast("JS_CALL_EXPRESSION"),
15
18
  run(node) {
16
- if (!RECORD_CALLS.includes(node.callee?.text ?? ""))
19
+ if (!LOOKUPS.test(node.callee?.text ?? ""))
17
20
  return;
18
21
  const inLoop = node.ancestors().some((a) => {
19
22
  if (LOOPS.includes(a.kind))
@@ -25,7 +28,7 @@ export const noRecordInLoop = defineRule({
25
28
  ITERATORS.test(call.children()[0]?.text ?? ""));
26
29
  });
27
30
  if (inLoop) {
28
- registerDiagnostic(node, "warning", `${node.callee?.text}() inside a loop; use a search or submitFields instead.`);
31
+ registerDiagnostic(node, "warning", `${node.callee?.text}() inside a loop; fetch what you need with one search or query instead.`);
29
32
  }
30
33
  },
31
34
  });
@@ -0,0 +1,25 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { callArguments } from "../lib/options.js";
3
+ import { descendants } from "../lib/script-side.js";
4
+ // Oracle (entry point script validation): "The following script is invalid
5
+ // because it uses a require statement instead of a define statement. If you try
6
+ // to upload a script that includes a require statement, the system returns the
7
+ // error 'SuiteScript 2.0 entry point scripts must implement one script type
8
+ // function.'" Only a top-level require([...], callback) in a file with no
9
+ // define() is flagged; require inside a define() callback is legitimate.
10
+ export const noRequireInsteadOfDefine = defineRule({
11
+ query: ast("JS_MODULE", "JS_SCRIPT"),
12
+ run(node) {
13
+ const calls = [...descendants(node)].filter((n) => n.kind === "JS_CALL_EXPRESSION");
14
+ if (calls.some((c) => c.callee?.text === "define"))
15
+ return;
16
+ for (const call of calls) {
17
+ if (call.callee?.text !== "require")
18
+ continue;
19
+ const [deps, callback] = callArguments(call);
20
+ if (deps?.kind === "JS_ARRAY_EXPRESSION" && callback) {
21
+ registerDiagnostic(call, "error", "Use define() instead of require(); NetSuite rejects an entry point script without a define call.");
22
+ }
23
+ }
24
+ },
25
+ });
@@ -0,0 +1,16 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { definedOptions } from "../lib/required-options.js";
3
+ // Oracle (https.createSecretKey): "You cannot use both the options.guid parameter
4
+ // and secret parameter in combination." The type definitions (N/https.d.ts) carry
5
+ // the same comment and make both options optional.
6
+ export const noSecretKeyGuidAndSecret = defineRule({
7
+ query: ast("JS_CALL_EXPRESSION"),
8
+ run(node) {
9
+ if (!/^https\.createSecretKey(\.promise)?$/.test(node.callee?.text ?? ""))
10
+ return;
11
+ const defined = definedOptions(node);
12
+ if (defined?.has("guid") && defined.has("secret")) {
13
+ registerDiagnostic(node, "error", "Pass createSecretKey() either 'guid' or 'secret', not both.");
14
+ }
15
+ },
16
+ });
@@ -0,0 +1,24 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { callArguments, stringValue } from "../lib/options.js";
3
+ import { scriptSide } from "../lib/script-side.js";
4
+ const SENSITIVE = /token|password|secret|api[_-]?key|ssn|credit|card|auth/i;
5
+ // Tier 0: docs/adr/0005-guessing-rules-are-in-no-preset.md.
6
+ // Oracle's OWASP skill: "`localStorage` and `sessionStorage` are accessible to any
7
+ // JavaScript running on the same origin. If an XSS vulnerability exists, stored
8
+ // tokens, PII, or session data can be exfiltrated", and "Never store tokens or PII
9
+ // in localStorage". LOW CONFIDENCE, so no preset includes it: it guesses from the
10
+ // key name.
11
+ export const noSensitiveBrowserStorage = defineRule({
12
+ query: ast("JS_CALL_EXPRESSION"),
13
+ run(node) {
14
+ if (!/^(localStorage|sessionStorage)\.setItem$/.test(node.callee?.text ?? ""))
15
+ return;
16
+ if (scriptSide(node) === "server")
17
+ return;
18
+ const key = callArguments(node)[0];
19
+ const name = stringValue(key);
20
+ if (name && SENSITIVE.test(name)) {
21
+ registerDiagnostic(key ?? node, "warning", `Do not keep '${name}' in browser storage; any script on the page can read it.`);
22
+ }
23
+ },
24
+ });
@@ -0,0 +1,21 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { descendants } from "../lib/script-side.js";
3
+ // Tier 0: docs/adr/0005-guessing-rules-are-in-no-preset.md.
4
+ // Oracle's OWASP skill: "Returning stack traces, internal IDs, script file paths,
5
+ // or record structure details in error responses gives attackers a map of the
6
+ // system." LOW CONFIDENCE, so no preset includes it: only a `.stack` read inside
7
+ // the arguments of `response.write`/`writeLine` is detected, not one returned from
8
+ // a RESTlet.
9
+ export const noStackInResponse = defineRule({
10
+ query: ast("JS_CALL_EXPRESSION"),
11
+ run(node) {
12
+ if (!/response\.(write|writeLine)$/i.test(node.callee?.text ?? ""))
13
+ return;
14
+ for (const child of descendants(node)) {
15
+ if (child.kind === "JS_STATIC_MEMBER_EXPRESSION" &&
16
+ child.children()[1]?.text === "stack") {
17
+ registerDiagnostic(child, "warning", "Do not send a stack trace to the client.");
18
+ }
19
+ }
20
+ },
21
+ });
@@ -0,0 +1,18 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { SUITEQL, staticSuiteql } from "../lib/suiteql.js";
3
+ // Tier 0: docs/adr/0005-guessing-rules-are-in-no-preset.md.
4
+ // Oracle's SAFE guide: "Avoid SELECT *" and "Always specify needed columns."
5
+ // LOW CONFIDENCE, so no preset includes it: the guide itself recommends a
6
+ // `SELECT * ... WHERE ROWNUM <= 1` probe to discover column names, and the rule
7
+ // only sees a query built from literals.
8
+ export const noSuiteQLSelectStar = defineRule({
9
+ query: ast("JS_CALL_EXPRESSION"),
10
+ run(node) {
11
+ if (!SUITEQL.test(node.callee?.text ?? ""))
12
+ return;
13
+ const sql = staticSuiteql(node);
14
+ if (sql && /\bSELECT\s+(DISTINCT\s+)?\*/i.test(sql)) {
15
+ registerDiagnostic(node, "warning", "Name the columns instead of SELECT *.");
16
+ }
17
+ },
18
+ });
@@ -0,0 +1,19 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { SUITEQL, staticSuiteql } from "../lib/suiteql.js";
3
+ // Tier 0: docs/adr/0005-guessing-rules-are-in-no-preset.md.
4
+ // Oracle's SAFE guide: "`SystemNote` is an extremely high-volume table ... Joining
5
+ // it in SuiteQL queries causes severe performance degradation and frequent query
6
+ // timeouts", "never as a JOIN against a large result set". LOW CONFIDENCE, so no
7
+ // preset includes it: a tightly filtered join is fine, and the rule only sees a
8
+ // query built from literals.
9
+ export const noSuiteQLSystemNoteJoin = defineRule({
10
+ query: ast("JS_CALL_EXPRESSION"),
11
+ run(node) {
12
+ if (!SUITEQL.test(node.callee?.text ?? ""))
13
+ return;
14
+ const sql = staticSuiteql(node);
15
+ if (sql && /\b(JOIN\s+|,\s*)SystemNote\b/i.test(sql)) {
16
+ registerDiagnostic(node, "warning", "Do not join SystemNote in a SuiteQL query; query it separately with filters.");
17
+ }
18
+ },
19
+ });
@@ -0,0 +1,41 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { optionsObject, propertyValue, stringValue } from "../lib/options.js";
3
+ // SuiteQL abbreviations that are not search types. `Estimate` and `CashSale` are
4
+ // left out: their lowercase form is a valid search type.
5
+ const ABBREVIATIONS = new Set([
6
+ "PurchOrd",
7
+ "SalesOrd",
8
+ "CustInvc",
9
+ "ItemRcpt",
10
+ "ItemShip",
11
+ "VendBill",
12
+ "VendPymt",
13
+ "CustPymt",
14
+ "Journal",
15
+ "CustCred",
16
+ "RtnAuth",
17
+ "TrnfrOrd",
18
+ "WorkOrd",
19
+ "ExpRept",
20
+ "InvAdjst",
21
+ "VendCred",
22
+ ]);
23
+ // Oracle's SAFE guide: "Passing a SuiteQL abbreviation to `search.create()` throws
24
+ // a runtime error: `The record type [PURCHORD] is invalid.` Always use
25
+ // `search.Type.*` enum values (or their lowercase string equivalents) in N/search,
26
+ // and SuiteQL abbreviations only inside SuiteQL query strings." The failure claim
27
+ // comes only from this skill and has not been checked against Oracle's
28
+ // documentation."
29
+ export const noSuiteQLTypeInSearch = defineRule({
30
+ query: ast("JS_CALL_EXPRESSION"),
31
+ run(node) {
32
+ if (!/^search\.create(\.promise)?$/.test(node.callee?.text ?? ""))
33
+ return;
34
+ const options = optionsObject(node);
35
+ const type = options && propertyValue(options, "type");
36
+ const value = stringValue(type);
37
+ if (type && value && ABBREVIATIONS.has(value)) {
38
+ registerDiagnostic(type, "error", `'${value}' is a SuiteQL abbreviation, not a search type; use search.Type.`);
39
+ }
40
+ },
41
+ });
@@ -1,10 +1,84 @@
1
1
  import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
2
  // SuiteScript 2.1 only: nlapiXxx functions and nlobjXxx objects are 1.0 leftovers.
3
+ // Oracle's upgrade skill lists "Any `nlapi[A-Z]` function call" and "Any
4
+ // `nlobj[A-Z]` constructor or instanceof" as Critical, and also "Old getter/setter
5
+ // methods" (`.getFieldValue()`, `.setFieldValue()`) and "Old error handling"
6
+ // (`e.getCode()`). Only method names that exist in no 2.1 module are flagged, so a
7
+ // 2.1 method of the same name on another object is never hit.
8
+ const GLOBAL_OBJECTS = ["window", "globalThis", "self", "top", "this"];
9
+ const V1_METHODS = new Set([
10
+ "getLineItemCount",
11
+ "getLineItemValue",
12
+ "getLineItemValues",
13
+ "getLineItemText",
14
+ "getLineItemField",
15
+ "setLineItemValue",
16
+ "setLineItemValues",
17
+ "insertLineItem",
18
+ "removeLineItem",
19
+ "selectLineItem",
20
+ "selectNewLineItem",
21
+ "commitLineItem",
22
+ "findLineItemValue",
23
+ "getCurrentLineItemValue",
24
+ "getCurrentLineItemValues",
25
+ "setCurrentLineItemValue",
26
+ "getFieldValue",
27
+ "getFieldValues",
28
+ "setFieldValue",
29
+ "setFieldValues",
30
+ "getFieldText",
31
+ "getFieldTexts",
32
+ "setFieldText",
33
+ "setFieldTexts",
34
+ "addSubList",
35
+ "getSubList",
36
+ "insertSubList",
37
+ "addSubTab",
38
+ "getSubTab",
39
+ "insertSubTab",
40
+ ]);
41
+ const V1_ERROR_METHODS = new Set(["getCode", "getDetails", "getStackTrace"]);
42
+ const V1_NAME = /^(nlapi|nlobj)[A-Z]/;
3
43
  export const noSuiteScript1Api = defineRule({
4
- query: ast("JS_REFERENCE_IDENTIFIER"),
44
+ query: ast("JS_REFERENCE_IDENTIFIER", "JS_STATIC_MEMBER_EXPRESSION", "JS_CALL_EXPRESSION"),
5
45
  run(node) {
6
- if (/^(nlapi|nlobj)[A-Z]/.test(node.text)) {
7
- registerDiagnostic(node, "error", `'${node.text}' is SuiteScript 1.0; use the SuiteScript 2.1 N/ modules.`);
46
+ if (node.kind === "JS_REFERENCE_IDENTIFIER") {
47
+ if (V1_NAME.test(node.text)) {
48
+ registerDiagnostic(node, "error", `'${node.text}' is SuiteScript 1.0; use the SuiteScript 2.1 N/ modules.`);
49
+ }
50
+ return;
51
+ }
52
+ if (node.kind === "JS_STATIC_MEMBER_EXPRESSION") {
53
+ const [object, member] = node.children();
54
+ if (object &&
55
+ member &&
56
+ GLOBAL_OBJECTS.includes(object.text) &&
57
+ V1_NAME.test(member.text)) {
58
+ registerDiagnostic(node, "error", `'${member.text}' is SuiteScript 1.0; use the SuiteScript 2.1 N/ modules.`);
59
+ }
60
+ return;
61
+ }
62
+ const callee = node.callee;
63
+ if (callee?.kind !== "JS_STATIC_MEMBER_EXPRESSION")
64
+ return;
65
+ const [object, member] = callee.children();
66
+ if (!object || !member)
67
+ return;
68
+ if (V1_METHODS.has(member.text)) {
69
+ registerDiagnostic(node, "error", `'${member.text}()' is a SuiteScript 1.0 method; use the SuiteScript 2.1 equivalent.`);
70
+ }
71
+ else if (V1_ERROR_METHODS.has(member.text) &&
72
+ isCatchParameter(node, object.text)) {
73
+ registerDiagnostic(node, "error", `'${member.text}()' is SuiteScript 1.0; in 2.1 the error exposes a property.`);
8
74
  }
9
75
  },
10
76
  });
77
+ function isCatchParameter(node, name) {
78
+ for (const ancestor of node.ancestors()) {
79
+ if (ancestor.kind === "JS_CATCH_CLAUSE") {
80
+ return new RegExp(`^catch\\s*\\(\\s*${name}\\b`).test(ancestor.text);
81
+ }
82
+ }
83
+ return false;
84
+ }
@@ -0,0 +1,23 @@
1
+ import { defineRule, registerDiagnostic, semantic, } from "@biomejs/runtime/plugin";
2
+ import { moduleOfReceiver, typingsOf } from "../lib/module-calls.js";
3
+ const IGNORED = ["default", "prototype", "constructor", "__esModule"];
4
+ // Reading something the type definitions (Tier 2) do not export from an N/ module
5
+ // (`record.loadRecord`) gives undefined or throws at runtime. The module is
6
+ // resolved from the define() or import binding of the receiver. Modules that
7
+ // re-export everything are skipped because their exports cannot be listed, and the
8
+ // definitions can lag a release, so a member Oracle added recently may be reported.
9
+ export const noUnknownModuleMember = defineRule({
10
+ query: semantic("JS_STATIC_MEMBER_EXPRESSION"),
11
+ run(node, { model }) {
12
+ const [object, member] = node.children();
13
+ if (!object || !member)
14
+ return;
15
+ const module = moduleOfReceiver(object, model);
16
+ const exports = typingsOf(module)?.exports;
17
+ if (exports &&
18
+ !exports.includes(member.text) &&
19
+ !IGNORED.includes(member.text)) {
20
+ registerDiagnostic(member, "error", `${module} has no export '${member.text}'.`);
21
+ }
22
+ },
23
+ });
@@ -0,0 +1,31 @@
1
+ import { defineRule, registerDiagnostic, semantic, } from "@biomejs/runtime/plugin";
2
+ import { moduleCall } from "../lib/module-calls.js";
3
+ import { objectMembers, optionsObject } from "../lib/options.js";
4
+ // An option the type definitions (Tier 2) do not list for a module-level function
5
+ // is ignored at runtime, so a typo in an optional option goes unnoticed. A typo in
6
+ // a required option is also caught by requireOptions. Functions whose options
7
+ // type accepts any key are skipped.
8
+ export const noUnknownOptions = defineRule({
9
+ query: semantic("JS_CALL_EXPRESSION"),
10
+ run(node, { model }) {
11
+ const call = moduleCall(node, model);
12
+ if (!call || call.alternatives === 0)
13
+ return;
14
+ if (call.alternatives.some((alt) => alt[2]))
15
+ return;
16
+ const options = optionsObject(node);
17
+ if (!options)
18
+ return;
19
+ const known = new Set(call.alternatives.flatMap(([, names]) => names));
20
+ for (const member of objectMembers(options)) {
21
+ if (member.kind === "JS_SPREAD")
22
+ return;
23
+ if (member.kind !== "JS_PROPERTY_OBJECT_MEMBER")
24
+ continue;
25
+ const name = member.children()[0]?.text.replace(/^['"]|['"]$/g, "");
26
+ if (name && !known.has(name)) {
27
+ registerDiagnostic(member, "warning", `${call.callee}() does not take an '${name}' option.`);
28
+ }
29
+ }
30
+ },
31
+ });
@@ -0,0 +1,40 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { optionsObject, propertyValue } from "../lib/options.js";
3
+ const COLUMN_UNSUPPORTED = new Set([
4
+ "CHECKBOX",
5
+ "DATETIME",
6
+ "DATETIMETZ",
7
+ "FILE",
8
+ "HELP",
9
+ "INLINEHTML",
10
+ "LABEL",
11
+ "MULTISELECT",
12
+ "SELECT",
13
+ "RADIO",
14
+ ]);
15
+ // Oracle (serverWidget.FieldType): "The DATETIME field type is not supported with
16
+ // addField methods, you must specify DATETIMETZ." The type definitions
17
+ // (N/ui/serverWidget.d.ts) have no DATETIME member in FieldType at all. Oracle also
18
+ // says "The CHECKBOX, DATETIME,
19
+ // DATETIMETZ, FILE, HELP, INLINEHTML, LABEL, MULTISELECT, SELECT, and RADIO field
20
+ // types are not supported with List.addColumn(options)." Sublist.addField has its
21
+ // own list but needs the receiver's type, so it is not checked.
22
+ export const noUnsupportedServerWidgetFieldType = defineRule({
23
+ query: ast("JS_CALL_EXPRESSION"),
24
+ run(node) {
25
+ const method = /\.(addField|addColumn)$/.exec(node.callee?.text ?? "")?.[1];
26
+ if (!method)
27
+ return;
28
+ const options = optionsObject(node);
29
+ const type = options && propertyValue(options, "type");
30
+ const member = /(^|\.)FieldType\.(\w+)$/.exec(type?.text ?? "")?.[2];
31
+ if (!type || !member)
32
+ return;
33
+ if ((method === "addField" && member === "DATETIME") ||
34
+ (method === "addColumn" && COLUMN_UNSUPPORTED.has(member))) {
35
+ registerDiagnostic(type, "error", method === "addField"
36
+ ? "DATETIME is not supported by addField; use DATETIMETZ."
37
+ : `${member} is not supported by List.addColumn.`);
38
+ }
39
+ },
40
+ });
@@ -0,0 +1,17 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ // Tier 0: docs/adr/0005-guessing-rules-are-in-no-preset.md.
3
+ // Oracle's OWASP skill: "MD5 and SHA-1 are cryptographically broken" and "Use
4
+ // SHA-256 as the minimum hashing algorithm. MD5 and SHA-1 are broken and must
5
+ // never be used for security-sensitive operations." LOW CONFIDENCE, so no preset
6
+ // includes it: it cannot tell a security use from a vendor integration that
7
+ // requires MD5 or SHA-1. The type definitions (N/crypto.d.ts) list only SHA256 and
8
+ // SHA512 in crypto.HashAlg, so the MD5 and SHA1 member names come from the skill
9
+ // alone; the skill outranks the type definitions, so the rule stays.
10
+ export const noWeakHash = defineRule({
11
+ query: ast("JS_STATIC_MEMBER_EXPRESSION"),
12
+ run(node) {
13
+ if (/(^|\.)HashAlg\.(MD5|SHA1)$/.test(node.text)) {
14
+ registerDiagnostic(node, "warning", "MD5 and SHA-1 are broken; use SHA-256 or stronger for security-sensitive hashing.");
15
+ }
16
+ },
17
+ });
@@ -0,0 +1,16 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { headerCall } from "../lib/headers.js";
3
+ import { stringValue } from "../lib/options.js";
4
+ // Oracle's OWASP skill: "Setting `Access-Control-Allow-Origin: *` allows any
5
+ // website to make cross-origin requests to the RESTlet, enabling data theft from
6
+ // authenticated sessions", and "Use explicit CORS origin allowlist (never `*`)".
7
+ export const noWildcardCors = defineRule({
8
+ query: ast("JS_CALL_EXPRESSION"),
9
+ run(node) {
10
+ const header = headerCall(node);
11
+ if (header?.name === "access-control-allow-origin" &&
12
+ stringValue(header.value) === "*") {
13
+ registerDiagnostic(header.value ?? node, "warning", "Do not allow every origin with Access-Control-Allow-Origin: *; list the allowed origins.");
14
+ }
15
+ },
16
+ });
@@ -5,7 +5,9 @@ import { inferredTypes } from "../lib/script-side.js";
5
5
  // Loading a client-only module in a server script (or the reverse), or a module
6
6
  // a script type does not support, fails at runtime. The type comes from the
7
7
  // entry points the module returns, so libraries and uninferable modules are
8
- // never flagged.
8
+ // never flagged. The lists follow Oracle's "Supported Script Types" table, except
9
+ // N/ui/message, which the table marks client-only but which server scripts load to
10
+ // build the message that Form.addPageInitMessage shows (see module-script-types.ts).
9
11
  export const noWrongScriptTypeModules = defineRule({
10
12
  query: ast("JS_CALL_EXPRESSION", "JS_IMPORT"),
11
13
  run(node) {
@@ -1,22 +1,23 @@
1
1
  import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
- const ADDERS = /\.(addField|addFieldGroup|addSublist|addTab|addSubtab|addButton)$/;
3
- // Oracle: the internal ID of a UI object added to a page "must be prefixed with
4
- // custpage". Only a string literal `id` is checked.
2
+ import { optionsObject, propertyValue } from "../lib/options.js";
3
+ // Receiver `context.form` (or `scriptContext.form`) is the form of the page being
4
+ // loaded, an existing NetSuite page. Forms made with serverWidget.createForm are
5
+ // standalone pages, and Oracle's own samples use unprefixed ids on them.
6
+ const ADDERS = /\.form\.(addField|addButton|addSublist|addTab|addSubtab|addCredentialField|addSecretKeyField)$/;
7
+ // Oracle (N/ui/serverWidget): "When you add a UI object to an existing NetSuite
8
+ // page, to minimize the occurrence of field/object name conflicts, the internal ID
9
+ // that references the object must be prefixed with custpage." Only a string
10
+ // literal `id` is checked.
5
11
  export const requireCustpagePrefix = defineRule({
6
12
  query: ast("JS_CALL_EXPRESSION"),
7
13
  run(node) {
8
14
  if (!ADDERS.test(node.callee?.text ?? ""))
9
15
  return;
10
- const options = node.arguments?.children()[0]?.children()[0];
11
- if (options?.kind !== "JS_OBJECT_EXPRESSION")
12
- return;
13
- for (const member of options.children()[0]?.children() ?? []) {
14
- const [name, value] = member.children();
15
- if (name?.text === "id" &&
16
- value?.kind === "JS_STRING_LITERAL_EXPRESSION" &&
17
- !/^['"]custpage/.test(value.text)) {
18
- registerDiagnostic(value, "warning", "The id of a UI object must start with 'custpage'.");
19
- }
16
+ const options = optionsObject(node);
17
+ const id = options && propertyValue(options, "id");
18
+ if (id?.kind === "JS_STRING_LITERAL_EXPRESSION" &&
19
+ !/^['"]custpage/.test(id.text)) {
20
+ registerDiagnostic(id, "warning", "The id of a UI object added to an existing page must start with 'custpage'.");
20
21
  }
21
22
  },
22
23
  });
@@ -0,0 +1,38 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { callArguments } from "../lib/options.js";
3
+ import { isFunction } from "../lib/script-side.js";
4
+ const EACH = /\.(run|iterator)\(\)\.each$/;
5
+ /** Whether a function body returns a value, ignoring nested functions. */
6
+ function returnsValue(node) {
7
+ for (const child of node.children()) {
8
+ if (isFunction(child))
9
+ continue;
10
+ if (child.kind === "JS_RETURN_STATEMENT" && child.children()[0])
11
+ return true;
12
+ if (returnsValue(child))
13
+ return true;
14
+ }
15
+ return false;
16
+ }
17
+ // Oracle's upgrade skill: "`.each()` callback must return `true` to continue
18
+ // (stops on `false`)". A callback that never returns a value stops after the first
19
+ // row without an error. Only `.run().each` and `.iterator().each` are checked. A
20
+ // block body must return some value; an expression body that is a call (such as
21
+ // `ids.push(id)`) returns whatever the call does, which is not `true`.
22
+ export const requireEachReturn = defineRule({
23
+ query: ast("JS_CALL_EXPRESSION"),
24
+ run(node) {
25
+ if (!EACH.test(node.callee?.text ?? ""))
26
+ return;
27
+ const callback = callArguments(node)[0];
28
+ if (!callback || !isFunction(callback))
29
+ return;
30
+ const body = callback.children().find((c) => c.kind === "JS_FUNCTION_BODY");
31
+ const flagged = body
32
+ ? !returnsValue(body)
33
+ : callback.children().at(-1)?.kind === "JS_CALL_EXPRESSION";
34
+ if (flagged) {
35
+ registerDiagnostic(callback, "warning", "The each() callback must return true to continue to the next row.");
36
+ }
37
+ },
38
+ });
@@ -0,0 +1,25 @@
1
+ import { defineRule, registerDiagnostic, semantic, } from "@biomejs/runtime/plugin";
2
+ import { inSuiteScriptModule } from "../lib/suitescript.js";
3
+ // Tier 0: docs/adr/0006-log-is-a-global-with-two-opposite-styles.md.
4
+ // Load N/log when you call log.*. Oracle's upgrade skill says `log` is global but
5
+ // "explicitly including it in `define()` makes dependencies clearer and is
6
+ // recommended", and that practice is what this rule enforces. The help center's
7
+ // "NetSuite loads the log Object by default for all script types. You do not need
8
+ // to load it manually" is why noMissingModules leaves `log` alone. The learning
9
+ // skill's "not globally available" loses: the documentation and the higher-ranked
10
+ // upgrade skill both say it is global. This is the opposite style to noLogModule,
11
+ // so enable only one of them.
12
+ export const requireLogModule = defineRule({
13
+ query: semantic("JS_REFERENCE_IDENTIFIER"),
14
+ run(node, { model }) {
15
+ if (node.text !== "log" || model.binding(node))
16
+ return;
17
+ const member = node.parent?.parent;
18
+ if (member?.kind !== "JS_STATIC_MEMBER_EXPRESSION" ||
19
+ member.children()[0]?.text !== "log")
20
+ return;
21
+ if (!inSuiteScriptModule(node))
22
+ return;
23
+ registerDiagnostic(node, "warning", "'log' is used but N/log is not loaded.");
24
+ },
25
+ });
@@ -0,0 +1,26 @@
1
+ import { defineRule, registerDiagnostic, semantic, } from "@biomejs/runtime/plugin";
2
+ import { moduleCall } from "../lib/module-calls.js";
3
+ import { definedOptions } from "../lib/required-options.js";
4
+ // The type definitions (Tier 2) say which options each module-level function
5
+ // requires, and a missing one makes the call fail at runtime (Oracle's method
6
+ // pages list it as SSS_MISSING_REQD_ARGUMENT). The receiver is resolved from the
7
+ // define() or import binding, so `record.load` is checked only when `record` is
8
+ // N/record. Only an options object literal without a spread is checked, and a
9
+ // function with several call shapes passes when any shape is satisfied.
10
+ export const requireOptions = defineRule({
11
+ query: semantic("JS_CALL_EXPRESSION"),
12
+ run(node, { model }) {
13
+ const call = moduleCall(node, model);
14
+ if (!call || call.alternatives === 0)
15
+ return;
16
+ const defined = definedOptions(node);
17
+ if (!defined)
18
+ return;
19
+ const missing = call.alternatives
20
+ .map(([required]) => required.filter((name) => !defined.has(name)))
21
+ .sort((a, b) => a.length - b.length)[0];
22
+ for (const name of missing ?? []) {
23
+ registerDiagnostic(node, "error", `${call.callee}() needs the '${name}' option.`);
24
+ }
25
+ },
26
+ });