biome-plugin-suitescript 0.1.1 → 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.
- package/README.md +75 -16
- package/biome-manifest.json +95 -3
- package/dist/cli/analyze.js +33 -3
- package/dist/lib/module-script-types.js +70 -0
- package/dist/lib/script-side.js +83 -0
- package/dist/lib/script-types.js +12 -0
- package/dist/lib/suitescript.js +23 -0
- package/dist/rules/noBeforeSubmitRecordReload.js +19 -0
- package/dist/rules/noBrowserGlobals.js +23 -0
- package/dist/rules/noClientLog.js +14 -0
- package/dist/rules/noDynamicSuiteQL.js +24 -0
- package/dist/rules/noEmptyEntryPoints.js +19 -0
- package/dist/rules/noEval.js +21 -0
- package/dist/rules/noGlobalShadowing.js +34 -0
- package/dist/rules/noHardcodedSecrets.js +38 -0
- package/dist/rules/noHeaderUnderscores.js +23 -0
- package/dist/rules/noInsecureHttpsUrl.js +20 -0
- package/dist/rules/noLogModule.js +4 -1
- package/dist/rules/noMapReduceSearchResults.js +33 -0
- package/dist/rules/noMissingModules.js +26 -0
- package/dist/rules/noModuleScopeApiCalls.js +39 -0
- package/dist/rules/noNativeIterators.js +16 -0
- package/dist/rules/noQueryColumnFieldAndFormula.js +19 -0
- package/dist/rules/noRecordInLoop.js +31 -0
- package/dist/rules/noSuiteScript1Api.js +10 -0
- package/dist/rules/noUserEventContextSave.js +12 -0
- package/dist/rules/noWrongScriptTypeModules.js +28 -0
- package/dist/rules/requireClientValidateReturn.js +27 -0
- package/dist/rules/requireCustpagePrefix.js +22 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,28 +37,86 @@ npx biome-plugin-suitescript check
|
|
|
37
37
|
Each rule has one fixed severity: `error` for things that fail at runtime or on upload, `warn` for conventions.
|
|
38
38
|
Biome plugins cannot be reconfigured per project, so you choose by enabling rules, scoping them, or generating one.
|
|
39
39
|
|
|
40
|
-
| Rule | Severity | Description
|
|
41
|
-
| ------------------------------ | -------- |
|
|
42
|
-
| `noAmdName` (fixable) | error | No module id as the first `define` argument
|
|
43
|
-
| `noExtraModules` | error | Dependency array and callback parameters must match in count
|
|
44
|
-
| `noInvalidModules` | error | Unknown `N/...` modules
|
|
45
|
-
| `noModuleExtensions` (fixable) | error | No `.js` in `define` dependency names
|
|
46
|
-
| `moduleVars` | warn | Conventional identifier per `N/` module (`N/record` → `record`)
|
|
47
|
-
| `logRequireTitle` | warn | `log.debug/audit/error/emergency({...})` needs `title`
|
|
48
|
-
| `logRequireDetails` | warn | ...needs `details`
|
|
49
|
-
| `noLogModule` | warn |
|
|
50
|
-
| `entryPoints` | error | Returned entry points must belong to one script type and include its required ones
|
|
40
|
+
| Rule | Severity | Description | ESLint equivalent |
|
|
41
|
+
| ------------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------- |
|
|
42
|
+
| `noAmdName` (fixable) | error | No module id as the first `define` argument | `no-amd-name` |
|
|
43
|
+
| `noExtraModules` | error | Dependency array and callback parameters must match in count | `no-extra-modules` |
|
|
44
|
+
| `noInvalidModules` | error | Unknown `N/...` modules | `no-invalid-modules` |
|
|
45
|
+
| `noModuleExtensions` (fixable) | error | No `.js` in `define` dependency names | `no-module-extensions` |
|
|
46
|
+
| `moduleVars` | warn | Conventional identifier per `N/` module (`N/record` → `record`) | `module-vars` |
|
|
47
|
+
| `logRequireTitle` | warn | `log.debug/audit/error/emergency({...})` needs `title` | `log-args` `requireTitle` |
|
|
48
|
+
| `logRequireDetails` | warn | ...needs `details` | `log-args` `requireDetails` |
|
|
49
|
+
| `noLogModule` | warn | Opt-in opposite of `noMissingModules`: use the global `log`, not `N/log` | `no-log-module` |
|
|
50
|
+
| `entryPoints` | error | Returned entry points must belong to one script type and include its required ones | `entry-points` |
|
|
51
|
+
| `noMissingModules` | error | A module used (`record.load()`, `log.debug()`) must be loaded in `define`/`import` | none (from Oracle's skills) |
|
|
52
|
+
| `noGlobalShadowing` | warn | No variable named `log` or `util` other than the N/log, N/util module itself | none (from Oracle's skills) |
|
|
53
|
+
| `noEval` | error | No `eval`, `new Function` or string-form `setTimeout`/`setInterval` | none (OWASP skill) |
|
|
54
|
+
| `noDynamicSuiteQL` | warn | `runSuiteQL` queries use `?` placeholders, not concatenation or template literals | none (OWASP skill) |
|
|
55
|
+
| `noSuiteScript1Api` | error | No `nlapi*`/`nlobj*` (SuiteScript 1.0) leftovers | none (upgrade skill) |
|
|
56
|
+
| `noModuleScopeApiCalls` | warn | No `N/` API calls while the `define` callback runs; initialize from entry points | none (SAFE guide) |
|
|
57
|
+
| `noRecordInLoop` | warn | No `record.load`/`record.create` inside loops or `forEach`/`each`/`map` callbacks | none (SAFE guide) |
|
|
58
|
+
| `noNativeIterators` | warn | No generator functions or `Symbol.iterator` (GraalJS) | none (SAFE guide) |
|
|
59
|
+
| `noWrongScriptTypeModules` | error | No client-only module (`N/currentRecord`, `N/ui/dialog`) in a server script, or a server-only one in a client script | none (Oracle docs) |
|
|
60
|
+
| `noBrowserGlobals` | error | No `document`, `window`, `localStorage`, `XMLHttpRequest`, `alert` or `navigator` in server scripts | none (Oracle docs) |
|
|
61
|
+
| `noBeforeSubmitRecordReload` | error | No `record.load`/`submitFields`/`delete` of `context.newRecord.id` inside `beforeSubmit` | none (Oracle docs) |
|
|
62
|
+
| `noEmptyEntryPoints` | warn | A returned entry point with an empty body | none (Oracle docs) |
|
|
63
|
+
| `noMapReduceSearchResults` | warn | `getInputData` returns the search object, not `getRange()` results | none (Oracle docs) |
|
|
64
|
+
| `noHardcodedSecrets` | error | Opt-in only: no string literal assigned to `password`, `secret`, `apiKey`, ... | none (Oracle docs) |
|
|
65
|
+
| `noUserEventContextSave` | error | No `.save()` on `context.newRecord` or `context.oldRecord` in user events | none (Oracle docs) |
|
|
66
|
+
| `noInsecureHttpsUrl` | error | No `http://` URL passed to `https.*` (N/https refuses plain HTTP) | none (Oracle docs) |
|
|
67
|
+
| `requireCustpagePrefix` | warn | The `id` of a UI object added to a form (`addField`, `addTab`, ...) starts with `custpage` | none (Oracle docs) |
|
|
68
|
+
| `noHeaderUnderscores` | warn | No underscore in a custom `https.*` header name | none (Oracle docs) |
|
|
69
|
+
| `noQueryColumnFieldAndFormula` | error | A query column cannot set both `fieldId` and `formula` | none (Oracle docs) |
|
|
70
|
+
| `requireClientValidateReturn` | warn | `saveRecord` and `validate*` return a value (only a body with no `return <value>` is flagged) | none (Oracle docs) |
|
|
71
|
+
| `noClientLog` | warn | `log.*` is ignored in client scripts attached to a form; use `console` | none (Oracle docs) |
|
|
51
72
|
|
|
52
73
|
`noAmdName`, `noExtraModules` and `noModuleExtensions` only apply to `define()`. The rest cover `import` too.
|
|
53
74
|
|
|
54
|
-
| Preset | Contents
|
|
55
|
-
| --------------------- |
|
|
56
|
-
| `presets/recommended` | every rule above except `noLogModule` and `
|
|
57
|
-
| `presets/minimal` | `noExtraModules`, `noInvalidModules`
|
|
58
|
-
| `presets/all` | `recommended`
|
|
75
|
+
| Preset | Contents |
|
|
76
|
+
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
77
|
+
| `presets/recommended` | every rule above except the opt-in `noLogModule` and `noHardcodedSecrets` and the ones `presets/all` adds |
|
|
78
|
+
| `presets/minimal` | `noExtraModules`, `noInvalidModules` |
|
|
79
|
+
| `presets/all` | `recommended` plus `entryPoints`, `noRecordInLoop`, `noNativeIterators`, `noBeforeSubmitRecordReload`, `noEmptyEntryPoints`, `noMapReduceSearchResults`, `requireCustpagePrefix`, `noHeaderUnderscores`, `noQueryColumnFieldAndFormula`, `requireClientValidateReturn` and `noClientLog` (never `noLogModule` or `noHardcodedSecrets`) |
|
|
59
80
|
|
|
60
81
|
Suppress a diagnostic with `// biome-ignore lint/plugin: reason`.
|
|
61
82
|
|
|
83
|
+
### Opt-in rules and typings
|
|
84
|
+
|
|
85
|
+
`noHardcodedSecrets` is in no preset. It guesses from names (`password`, `passwd`, `pwd`, `secret`, `apiKey`,
|
|
86
|
+
`clientSecret`, `tokenSecret`, `consumerSecret`, exact match only) rather than knowing a value is a credential, so it
|
|
87
|
+
can misfire on look-alike names, and you should decide whether that trade is worth it: add
|
|
88
|
+
`"biome-plugin-suitescript/noHardcodedSecrets"` to `plugins` yourself. Oracle says "Do not hard-code any passwords
|
|
89
|
+
in scripts."
|
|
90
|
+
|
|
91
|
+
The rules assume you type-check against [`@hitc/netsuite-types`](https://github.com/headintheclouddev/typings-suitescript-2.0),
|
|
92
|
+
which already rejects wrong option names, missing parameters and positional-argument calls to N/ methods (their
|
|
93
|
+
signatures take an options object). Plain JavaScript projects get none of that from these rules.
|
|
94
|
+
|
|
95
|
+
### Logging and `N/log`
|
|
96
|
+
|
|
97
|
+
Oracle's own agent skills disagree on whether `N/log` must be loaded: one says the global exists but loading is
|
|
98
|
+
recommended, another says it is not global. This package takes the consistent position that a module you use
|
|
99
|
+
should be loaded, so `noMissingModules` requires `N/log` when `log.` is called. `noLogModule` is the opposite style
|
|
100
|
+
for projects that prefer the global, so enable only one of the two (`presets/all` omits `noLogModule`).
|
|
101
|
+
|
|
102
|
+
### Rules drawn from Oracle's documentation
|
|
103
|
+
|
|
104
|
+
`noWrongScriptTypeModules`, `noBrowserGlobals`, `noBeforeSubmitRecordReload`, `noEmptyEntryPoints` and
|
|
105
|
+
`noMapReduceSearchResults`, plus the later additions listed in the table above marked Oracle docs, come from Oracle's SuiteScript help-center pages and cover what type checking against
|
|
106
|
+
`@hitc/netsuite-types` cannot (the typings accept every signature involved). The client-only and server-only module
|
|
107
|
+
lists are copied from Oracle's module reference table, and `noWrongScriptTypeModules` and `noBrowserGlobals` only fire
|
|
108
|
+
when the script type can be inferred from the returned entry points. `noBeforeSubmitRecordReload` only matches calls
|
|
109
|
+
whose `id` is `...newRecord.id`. `noMapReduceSearchResults` only detects `.getRange()` results.
|
|
110
|
+
|
|
111
|
+
### Rules drawn from Oracle's agent skills
|
|
112
|
+
|
|
113
|
+
`noMissingModules`, `noGlobalShadowing`, `noEval`, `noDynamicSuiteQL`, `noSuiteScript1Api`, `noModuleScopeApiCalls`,
|
|
114
|
+
`noRecordInLoop` and `noNativeIterators` come from Oracle's
|
|
115
|
+
[agent skills](https://github.com/oracle/netsuite-suitecloud-sdk/tree/master/packages/agent-skills) (SuiteScript
|
|
116
|
+
upgrade, OWASP secure coding and the SAFE guide). `noMissingModules` leaves `util` alone because it is a global.
|
|
117
|
+
`noDynamicSuiteQL` does not flag a bare variable, whose contents it cannot see. `noRecordInLoop` is opt-in because
|
|
118
|
+
a Map/Reduce `map` entry point legitimately handles one record per call.
|
|
119
|
+
|
|
62
120
|
### `entryPoints` and script types
|
|
63
121
|
|
|
64
122
|
Biome plugins cannot read comments, so the script type is **inferred from what the module returns**
|
|
@@ -98,6 +156,7 @@ It reports:
|
|
|
98
156
|
|
|
99
157
|
- `@NApiVersion` other than `2.1` (2.0, 2.x, 1.0 and invalid values), or missing when the file is a script.
|
|
100
158
|
- `@NScriptType` with no or an invalid value, or missing when the returned entry points imply one.
|
|
159
|
+
- A module from the wrong side of the declared script type (for example `N/file` in a client script), or one the declared type does not support at all (`N/http`, `N/https`, `N/llm` and `N/sftp` in a custom tool; custom modules in a bundle installation script).
|
|
101
160
|
- A declared script type that does not match the returned entry points, or lacks its required ones.
|
|
102
161
|
|
|
103
162
|
Files with no tags and no implied entry points (libraries) are skipped. For scripts still on 1.0, 2.0 or 2.x, the
|
package/biome-manifest.json
CHANGED
|
@@ -15,8 +15,27 @@
|
|
|
15
15
|
"moduleVars",
|
|
16
16
|
"logRequireTitle",
|
|
17
17
|
"logRequireDetails",
|
|
18
|
-
"
|
|
19
|
-
"
|
|
18
|
+
"noMissingModules",
|
|
19
|
+
"noGlobalShadowing",
|
|
20
|
+
"noEval",
|
|
21
|
+
"noDynamicSuiteQL",
|
|
22
|
+
"noSuiteScript1Api",
|
|
23
|
+
"noModuleScopeApiCalls",
|
|
24
|
+
"noWrongScriptTypeModules",
|
|
25
|
+
"noBrowserGlobals",
|
|
26
|
+
"noUserEventContextSave",
|
|
27
|
+
"noInsecureHttpsUrl",
|
|
28
|
+
"entryPoints",
|
|
29
|
+
"noRecordInLoop",
|
|
30
|
+
"noNativeIterators",
|
|
31
|
+
"noBeforeSubmitRecordReload",
|
|
32
|
+
"noEmptyEntryPoints",
|
|
33
|
+
"noMapReduceSearchResults",
|
|
34
|
+
"requireCustpagePrefix",
|
|
35
|
+
"noHeaderUnderscores",
|
|
36
|
+
"noQueryColumnFieldAndFormula",
|
|
37
|
+
"requireClientValidateReturn",
|
|
38
|
+
"noClientLog"
|
|
20
39
|
],
|
|
21
40
|
"minimal": ["noExtraModules", "noInvalidModules"],
|
|
22
41
|
"recommended": [
|
|
@@ -26,7 +45,17 @@
|
|
|
26
45
|
"noModuleExtensions",
|
|
27
46
|
"moduleVars",
|
|
28
47
|
"logRequireTitle",
|
|
29
|
-
"logRequireDetails"
|
|
48
|
+
"logRequireDetails",
|
|
49
|
+
"noMissingModules",
|
|
50
|
+
"noGlobalShadowing",
|
|
51
|
+
"noEval",
|
|
52
|
+
"noDynamicSuiteQL",
|
|
53
|
+
"noSuiteScript1Api",
|
|
54
|
+
"noModuleScopeApiCalls",
|
|
55
|
+
"noWrongScriptTypeModules",
|
|
56
|
+
"noBrowserGlobals",
|
|
57
|
+
"noUserEventContextSave",
|
|
58
|
+
"noInsecureHttpsUrl"
|
|
30
59
|
]
|
|
31
60
|
},
|
|
32
61
|
"rules": [
|
|
@@ -45,17 +74,80 @@
|
|
|
45
74
|
{
|
|
46
75
|
"noAmdName": "./dist/rules/noAmdName.grit"
|
|
47
76
|
},
|
|
77
|
+
{
|
|
78
|
+
"noBeforeSubmitRecordReload": "./dist/rules/noBeforeSubmitRecordReload.js"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"noBrowserGlobals": "./dist/rules/noBrowserGlobals.js"
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"noClientLog": "./dist/rules/noClientLog.js"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"noDynamicSuiteQL": "./dist/rules/noDynamicSuiteQL.js"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"noEmptyEntryPoints": "./dist/rules/noEmptyEntryPoints.js"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"noEval": "./dist/rules/noEval.js"
|
|
94
|
+
},
|
|
48
95
|
{
|
|
49
96
|
"noExtraModules": "./dist/rules/noExtraModules.js"
|
|
50
97
|
},
|
|
98
|
+
{
|
|
99
|
+
"noGlobalShadowing": "./dist/rules/noGlobalShadowing.js"
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"noHardcodedSecrets": "./dist/rules/noHardcodedSecrets.js"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"noHeaderUnderscores": "./dist/rules/noHeaderUnderscores.js"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"noInsecureHttpsUrl": "./dist/rules/noInsecureHttpsUrl.js"
|
|
109
|
+
},
|
|
51
110
|
{
|
|
52
111
|
"noInvalidModules": "./dist/rules/noInvalidModules.js"
|
|
53
112
|
},
|
|
54
113
|
{
|
|
55
114
|
"noLogModule": "./dist/rules/noLogModule.js"
|
|
56
115
|
},
|
|
116
|
+
{
|
|
117
|
+
"noMapReduceSearchResults": "./dist/rules/noMapReduceSearchResults.js"
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"noMissingModules": "./dist/rules/noMissingModules.js"
|
|
121
|
+
},
|
|
57
122
|
{
|
|
58
123
|
"noModuleExtensions": "./dist/rules/noModuleExtensions.grit"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"noModuleScopeApiCalls": "./dist/rules/noModuleScopeApiCalls.js"
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"noNativeIterators": "./dist/rules/noNativeIterators.js"
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"noQueryColumnFieldAndFormula": "./dist/rules/noQueryColumnFieldAndFormula.js"
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"noRecordInLoop": "./dist/rules/noRecordInLoop.js"
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"noSuiteScript1Api": "./dist/rules/noSuiteScript1Api.js"
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"noUserEventContextSave": "./dist/rules/noUserEventContextSave.js"
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"noWrongScriptTypeModules": "./dist/rules/noWrongScriptTypeModules.js"
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"requireClientValidateReturn": "./dist/rules/requireClientValidateReturn.js"
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"requireCustpagePrefix": "./dist/rules/requireCustpagePrefix.js"
|
|
59
151
|
}
|
|
60
152
|
]
|
|
61
153
|
},
|
package/dist/cli/analyze.js
CHANGED
|
@@ -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" &&
|
|
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 {
|
|
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
|
+
}
|
package/dist/lib/script-types.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
-
//
|
|
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
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ast, defineRule, registerDiagnostic, } from "@biomejs/runtime/plugin";
|
|
2
|
+
import { pairs, parseDefine } from "../lib/define.js";
|
|
3
|
+
const FUNCTIONS = [
|
|
4
|
+
"JS_FUNCTION_EXPRESSION",
|
|
5
|
+
"JS_ARROW_FUNCTION_EXPRESSION",
|
|
6
|
+
"JS_FUNCTION_DECLARATION",
|
|
7
|
+
"JS_METHOD_OBJECT_MEMBER",
|
|
8
|
+
"JS_METHOD_CLASS_MEMBER",
|
|
9
|
+
];
|
|
10
|
+
// N/ APIs called while the define() callback runs (module scope) can fail on
|
|
11
|
+
// deploy; initialize lazily from an entry point instead. N/log is exempt.
|
|
12
|
+
export const noModuleScopeApiCalls = defineRule({
|
|
13
|
+
query: ast("JS_CALL_EXPRESSION"),
|
|
14
|
+
run(node) {
|
|
15
|
+
const def = parseDefine(node);
|
|
16
|
+
if (!def?.body)
|
|
17
|
+
return;
|
|
18
|
+
const aliases = new Set(pairs(def)
|
|
19
|
+
.filter((d) => d.name?.startsWith("N/") && d.name !== "N/log")
|
|
20
|
+
.map((d) => d.param?.text));
|
|
21
|
+
const visit = (n) => {
|
|
22
|
+
if (FUNCTIONS.includes(n.kind))
|
|
23
|
+
return;
|
|
24
|
+
if (n.kind === "JS_CALL_EXPRESSION") {
|
|
25
|
+
const callee = n.children()[0];
|
|
26
|
+
const object = callee?.kind === "JS_STATIC_MEMBER_EXPRESSION"
|
|
27
|
+
? callee.children()[0]?.text
|
|
28
|
+
: undefined;
|
|
29
|
+
if (object && aliases.has(object)) {
|
|
30
|
+
registerDiagnostic(n, "warning", `${callee?.text}() runs at module scope; call it from an entry point.`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const child of n.children())
|
|
34
|
+
visit(child);
|
|
35
|
+
};
|
|
36
|
+
for (const child of def.body.children())
|
|
37
|
+
visit(child);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
|
|
2
|
+
// NetSuite runs GraalJS; Oracle's guidance is to use array methods, not
|
|
3
|
+
// generators or Symbol.iterator.
|
|
4
|
+
export const noNativeIterators = defineRule({
|
|
5
|
+
query: ast("JS_FUNCTION_DECLARATION", "JS_FUNCTION_EXPRESSION", "JS_STATIC_MEMBER_EXPRESSION"),
|
|
6
|
+
run(node) {
|
|
7
|
+
if (node.kind === "JS_STATIC_MEMBER_EXPRESSION") {
|
|
8
|
+
if (node.text === "Symbol.iterator") {
|
|
9
|
+
registerDiagnostic(node, "warning", "Avoid Symbol.iterator.");
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
else if (node.starToken) {
|
|
13
|
+
registerDiagnostic(node, "warning", "Avoid generator functions.");
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
|
|
2
|
+
import { hasProperty } from "../lib/log.js";
|
|
3
|
+
// Oracle (N/query): a column's `formula` and `fieldId` "cannot be set at the same time."
|
|
4
|
+
export const noQueryColumnFieldAndFormula = defineRule({
|
|
5
|
+
query: ast("JS_OBJECT_EXPRESSION"),
|
|
6
|
+
run(node) {
|
|
7
|
+
if (!hasProperty(node, "fieldId") || !hasProperty(node, "formula"))
|
|
8
|
+
return;
|
|
9
|
+
const inColumn = node.ancestors().some((a) => {
|
|
10
|
+
if (a.kind === "JS_CALL_EXPRESSION")
|
|
11
|
+
return /createColumn$/.test(a.children()[0]?.text ?? "");
|
|
12
|
+
return (a.kind === "JS_PROPERTY_OBJECT_MEMBER" &&
|
|
13
|
+
a.children()[0]?.text === "columns");
|
|
14
|
+
});
|
|
15
|
+
if (inColumn) {
|
|
16
|
+
registerDiagnostic(node, "error", "A query column cannot set both fieldId and formula.");
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
|
|
2
|
+
const LOOPS = [
|
|
3
|
+
"JS_FOR_STATEMENT",
|
|
4
|
+
"JS_FOR_OF_STATEMENT",
|
|
5
|
+
"JS_FOR_IN_STATEMENT",
|
|
6
|
+
"JS_WHILE_STATEMENT",
|
|
7
|
+
"JS_DO_WHILE_STATEMENT",
|
|
8
|
+
];
|
|
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.
|
|
13
|
+
export const noRecordInLoop = defineRule({
|
|
14
|
+
query: ast("JS_CALL_EXPRESSION"),
|
|
15
|
+
run(node) {
|
|
16
|
+
if (!RECORD_CALLS.includes(node.callee?.text ?? ""))
|
|
17
|
+
return;
|
|
18
|
+
const inLoop = node.ancestors().some((a) => {
|
|
19
|
+
if (LOOPS.includes(a.kind))
|
|
20
|
+
return true;
|
|
21
|
+
if (a.kind !== "JS_CALL_ARGUMENT_LIST")
|
|
22
|
+
return false;
|
|
23
|
+
const call = a.parent?.parent;
|
|
24
|
+
return (call?.kind === "JS_CALL_EXPRESSION" &&
|
|
25
|
+
ITERATORS.test(call.children()[0]?.text ?? ""));
|
|
26
|
+
});
|
|
27
|
+
if (inLoop) {
|
|
28
|
+
registerDiagnostic(node, "warning", `${node.callee?.text}() inside a loop; use a search or submitFields instead.`);
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
|
|
2
|
+
// SuiteScript 2.1 only: nlapiXxx functions and nlobjXxx objects are 1.0 leftovers.
|
|
3
|
+
export const noSuiteScript1Api = defineRule({
|
|
4
|
+
query: ast("JS_REFERENCE_IDENTIFIER"),
|
|
5
|
+
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.`);
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
|
|
2
|
+
// Oracle: "You can't use record.Record.save() here" for the newRecord and
|
|
3
|
+
// oldRecord of a user event context; load the record to change it instead.
|
|
4
|
+
export const noUserEventContextSave = defineRule({
|
|
5
|
+
query: ast("JS_CALL_EXPRESSION"),
|
|
6
|
+
run(node) {
|
|
7
|
+
const callee = node.callee?.text ?? "";
|
|
8
|
+
if (/(^|\.)(newRecord|oldRecord)\.save$/.test(callee)) {
|
|
9
|
+
registerDiagnostic(node, "error", `${callee}() is not allowed; the context record is read-only.`);
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
|
|
2
|
+
import { dependencies } from "../lib/biome-modules.js";
|
|
3
|
+
import { misplacedModule, sideOf, unsupportedModule, } from "../lib/module-script-types.js";
|
|
4
|
+
import { inferredTypes } from "../lib/script-side.js";
|
|
5
|
+
// Loading a client-only module in a server script (or the reverse), or a module
|
|
6
|
+
// a script type does not support, fails at runtime. The type comes from the
|
|
7
|
+
// entry points the module returns, so libraries and uninferable modules are
|
|
8
|
+
// never flagged.
|
|
9
|
+
export const noWrongScriptTypeModules = defineRule({
|
|
10
|
+
query: ast("JS_CALL_EXPRESSION", "JS_IMPORT"),
|
|
11
|
+
run(node) {
|
|
12
|
+
const deps = dependencies(node);
|
|
13
|
+
if (deps.length === 0)
|
|
14
|
+
return;
|
|
15
|
+
const types = inferredTypes(node);
|
|
16
|
+
if (!types)
|
|
17
|
+
return;
|
|
18
|
+
const side = sideOf(types);
|
|
19
|
+
const single = types.length === 1 ? types[0]?.name : undefined;
|
|
20
|
+
for (const { name, nameNode } of deps) {
|
|
21
|
+
const message = name &&
|
|
22
|
+
((side && misplacedModule(side, name)) ||
|
|
23
|
+
(single && unsupportedModule(single, name)));
|
|
24
|
+
if (message && nameNode)
|
|
25
|
+
registerDiagnostic(nameNode, "error", message);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
|
|
2
|
+
import { descendants, functionName, scriptSide } from "../lib/script-side.js";
|
|
3
|
+
const VALIDATORS = [
|
|
4
|
+
"saveRecord",
|
|
5
|
+
"validateField",
|
|
6
|
+
"validateLine",
|
|
7
|
+
"validateInsert",
|
|
8
|
+
"validateDelete",
|
|
9
|
+
];
|
|
10
|
+
// Oracle: saveRecord and validateField return "true ... false otherwise". Only a
|
|
11
|
+
// body with no `return <value>` at all is flagged; code paths are not analysed.
|
|
12
|
+
export const requireClientValidateReturn = defineRule({
|
|
13
|
+
query: ast("JS_FUNCTION_DECLARATION", "JS_FUNCTION_EXPRESSION", "JS_ARROW_FUNCTION_EXPRESSION", "JS_METHOD_OBJECT_MEMBER"),
|
|
14
|
+
run(node) {
|
|
15
|
+
const name = functionName(node);
|
|
16
|
+
if (!name || !VALIDATORS.includes(name))
|
|
17
|
+
return;
|
|
18
|
+
const body = node.children().find((c) => c.kind === "JS_FUNCTION_BODY");
|
|
19
|
+
if (!body || scriptSide(node) !== "client")
|
|
20
|
+
return;
|
|
21
|
+
for (const d of descendants(body)) {
|
|
22
|
+
if (d.kind === "JS_RETURN_STATEMENT" && d.children().length > 0)
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
registerDiagnostic(node, "warning", `'${name}' must return true or false.`);
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
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.
|
|
5
|
+
export const requireCustpagePrefix = defineRule({
|
|
6
|
+
query: ast("JS_CALL_EXPRESSION"),
|
|
7
|
+
run(node) {
|
|
8
|
+
if (!ADDERS.test(node.callee?.text ?? ""))
|
|
9
|
+
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
|
+
}
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
});
|
package/package.json
CHANGED