biome-plugin-suitescript 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eric Nemchik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,149 @@
1
- # Temporary Holding Version
1
+ # biome-plugin-suitescript
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [Biome](https://biomejs.dev) lint plugins and a tag checker for NetSuite **SuiteScript 2.1**, ported from
4
+ [acdvs/eslint-plugin-suitescript](https://github.com/acdvs/eslint-plugin-suitescript) and
5
+ [btonasse/eslint-plugin-suitescript](https://github.com/btonasse/eslint-plugin-suitescript).
6
+
7
+ Requires Biome **>= 2.6.4** (npm-distributed plugins use `biome-manifest.json`) and Node >= 22 for the CLI.
8
+ Rules work on both AMD `define()` and ES `import`/`export` source, in `.js` and `.ts`.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ npm i -D @biomejs/biome biome-plugin-suitescript
14
+ ```
15
+
16
+ ## Use
17
+
18
+ ```jsonc
19
+ // biome.json
20
+ {
21
+ "extends": ["biome-plugin-suitescript/configs/suitescript2"], // globals: define, require, log, util
22
+ "plugins": [
23
+ "biome-plugin-suitescript/presets/recommended",
24
+ "biome-plugin-suitescript/noLogModule" // add individual rules on top of a preset
25
+ ]
26
+ }
27
+ ```
28
+
29
+ Run the tag checker beside `biome check` (see [Tag checker](#tag-checker)):
30
+
31
+ ```sh
32
+ npx biome-plugin-suitescript check
33
+ ```
34
+
35
+ ## Rules
36
+
37
+ Each rule has one fixed severity: `error` for things that fail at runtime or on upload, `warn` for conventions.
38
+ Biome plugins cannot be reconfigured per project, so you choose by enabling rules, scoping them, or generating one.
39
+
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 | 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
+
52
+ `noAmdName`, `noExtraModules` and `noModuleExtensions` only apply to `define()`. The rest cover `import` too.
53
+
54
+ | Preset | Contents |
55
+ | --------------------- | ----------------------------------------------------------------------------------------------------------- |
56
+ | `presets/recommended` | every rule above except `noLogModule` and `entryPoints` (no false positives on libraries or client scripts) |
57
+ | `presets/minimal` | `noExtraModules`, `noInvalidModules` |
58
+ | `presets/all` | `recommended` + `noLogModule` + `entryPoints` |
59
+
60
+ Suppress a diagnostic with `// biome-ignore lint/plugin: reason`.
61
+
62
+ ### `entryPoints` and script types
63
+
64
+ Biome plugins cannot read comments, so the script type is **inferred from what the module returns**
65
+ (`beforeLoad` → User Event, `onRequest` → Suitelet, ...). The rule reports mixed entry points
66
+ (`pageInit` with `execute`) and missing required ones (Map/Reduce needs `getInputData` and `map` or `reduce`).
67
+ Modules that return no distinctive entry point are libraries and are ignored. Generic names such as
68
+ `get`, `post`, `map` and `run` alone never make a module a script.
69
+
70
+ ### Choosing what to enforce
71
+
72
+ - **Pick rules.** `log-args`' two options are the separate `logRequireTitle` / `logRequireDetails` rules.
73
+ - **Scope with `includes`.** For example, the global `log` is not available in client scripts:
74
+
75
+ ```jsonc
76
+ { "path": "biome-plugin-suitescript/noLogModule", "includes": ["src/**", "!src/**/client/**"] }
77
+ ```
78
+ - **Custom module identifiers.** Generate a rule with your own mapping (and omit `moduleVars`):
79
+
80
+ ```sh
81
+ npx biome-plugin-suitescript generate module-vars --set N/record=nRecord --out plugins/module-vars.js
82
+ ```
83
+
84
+ then add `"./plugins/module-vars.js"` to `plugins`.
85
+
86
+ ## Tag checker
87
+
88
+ `@NApiVersion` and `@NScriptType` live in comments, which no Biome plugin can read, so a small CLI checks them.
89
+ It is **report-only**: it never edits files.
90
+
91
+ ```sh
92
+ npx biome-plugin-suitescript check # uses biome-plugin-suitescript.json
93
+ npx biome-plugin-suitescript check "src/**/*.ts" # or explicit globs
94
+ npx biome-plugin-suitescript check --format github # text (default) | json | github
95
+ ```
96
+
97
+ It reports:
98
+
99
+ - `@NApiVersion` other than `2.1` (2.0, 2.x, 1.0 and invalid values), or missing when the file is a script.
100
+ - `@NScriptType` with no or an invalid value, or missing when the returned entry points imply one.
101
+ - A declared script type that does not match the returned entry points, or lacks its required ones.
102
+
103
+ Files with no tags and no implied entry points (libraries) are skipped. For scripts still on 1.0, 2.0 or 2.x, the
104
+ output points to Oracle's
105
+ [`netsuite-suitescript-upgrade`](https://github.com/oracle/netsuite-suitecloud-sdk/tree/master/packages/agent-skills/netsuite-suitescript-upgrade)
106
+ agent skill for the conversion.
107
+
108
+ Exit codes: `0` clean, `1` problems found, `2` usage or configuration error.
109
+
110
+ ### Configuration
111
+
112
+ List only the **script sources**: files deployed to the file cabinet, or the sources they are transpiled from (not
113
+ the transpiled output, and not build files such as `swc.mts`). The CLI refuses to run without a list rather than
114
+ guessing from `deploy.xml`.
115
+
116
+ ```jsonc
117
+ // biome-plugin-suitescript.json
118
+ {
119
+ "$schema": "./node_modules/biome-plugin-suitescript/schema.json",
120
+ "includes": ["src/TypeScripts/**/*.{js,ts}", "!**/*.d.ts", "!**/*.test.*"]
121
+ }
122
+ ```
123
+
124
+ `!` negation works as in Biome's `includes`. Reuse the same globs in the plugin `includes` in `biome.json` to scope the
125
+ Biome rules identically.
126
+
127
+ ## Development
128
+
129
+ The source is TypeScript under `src/`, compiled by `tsc` to `dist/` (the only code that is published). Scripts and
130
+ tests are TypeScript too and run directly on Node. Node and npm versions come from `devEngines` in `package.json`
131
+ (also in `.nvmrc` and `.tool-versions`).
132
+
133
+ ```sh
134
+ npm install
135
+ npm run build # tsc -> dist/, copies GritQL rules, regenerates biome-manifest.json
136
+ npm run typecheck # tsc --noEmit over src, scripts and tests
137
+ npm run check # biome check (npm run format applies fixes)
138
+ npm run sync-modules # refreshes the N/ module list from @hitc/netsuite-types
139
+ npm test # builds first, then tests the built package
140
+ ```
141
+
142
+ Git hooks (lefthook) run Biome on staged files before commit and reinstall dependencies after checkout or merge.
143
+
144
+ Commits follow [Conventional Commits](https://www.conventionalcommits.org); release-please opens a release PR and
145
+ publishes to npm when it is merged, using npm trusted publishing (OIDC, no token; the package settings on npmjs.com
146
+ allow this repo and `release.yml`). Dependencies are managed by Renovate: dev
147
+ dependencies are pinned, the `@biomejs/biome` peer range stays a caret, and Biome and `@biomejs/runtime` update together.
148
+
149
+ MIT licensed. Module and script-type data and rule behavior derive from the MIT-licensed ESLint plugins above.
@@ -0,0 +1,63 @@
1
+ {
2
+ "$schema": "./node_modules/@biomejs/biome/manifest_schema.json",
3
+ "configs": [
4
+ {
5
+ "suitescript2": "./configs/suitescript2.json"
6
+ }
7
+ ],
8
+ "plugins": {
9
+ "presets": {
10
+ "all": [
11
+ "noAmdName",
12
+ "noExtraModules",
13
+ "noInvalidModules",
14
+ "noModuleExtensions",
15
+ "moduleVars",
16
+ "logRequireTitle",
17
+ "logRequireDetails",
18
+ "noLogModule",
19
+ "entryPoints"
20
+ ],
21
+ "minimal": ["noExtraModules", "noInvalidModules"],
22
+ "recommended": [
23
+ "noAmdName",
24
+ "noExtraModules",
25
+ "noInvalidModules",
26
+ "noModuleExtensions",
27
+ "moduleVars",
28
+ "logRequireTitle",
29
+ "logRequireDetails"
30
+ ]
31
+ },
32
+ "rules": [
33
+ {
34
+ "entryPoints": "./dist/rules/entryPoints.js"
35
+ },
36
+ {
37
+ "logRequireDetails": "./dist/rules/logRequireDetails.js"
38
+ },
39
+ {
40
+ "logRequireTitle": "./dist/rules/logRequireTitle.js"
41
+ },
42
+ {
43
+ "moduleVars": "./dist/rules/moduleVars.js"
44
+ },
45
+ {
46
+ "noAmdName": "./dist/rules/noAmdName.grit"
47
+ },
48
+ {
49
+ "noExtraModules": "./dist/rules/noExtraModules.js"
50
+ },
51
+ {
52
+ "noInvalidModules": "./dist/rules/noInvalidModules.js"
53
+ },
54
+ {
55
+ "noLogModule": "./dist/rules/noLogModule.js"
56
+ },
57
+ {
58
+ "noModuleExtensions": "./dist/rules/noModuleExtensions.grit"
59
+ }
60
+ ]
61
+ },
62
+ "version": 1
63
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "javascript": {
3
+ "globals": ["define", "require", "log", "util"]
4
+ }
5
+ }
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { relative, resolve } from "node:path";
4
+ import { glob } from "tinyglobby";
5
+ import { analyzeSource } from "../cli/analyze.js";
6
+ import { format } from "../cli/format.js";
7
+ import { MODULES } from "../lib/modules.js";
8
+ const USAGE = `Usage:
9
+ biome-plugin-suitescript check [globs...] [--config file] [--format text|json|github]
10
+ biome-plugin-suitescript generate module-vars [--set N/module=identifier]... [--out file.js]
11
+
12
+ check reads "includes" from biome-plugin-suitescript.json unless globs are given.
13
+ Exit codes: 0 clean, 1 problems found, 2 usage or configuration error.`;
14
+ function fail(message) {
15
+ console.error(`${message}\n\n${USAGE}`);
16
+ process.exit(2);
17
+ }
18
+ function parseFlags(args, valued) {
19
+ const flags = {};
20
+ const positional = [];
21
+ for (let i = 0; i < args.length; i++) {
22
+ const arg = args[i];
23
+ if (!arg.startsWith("--")) {
24
+ positional.push(arg);
25
+ continue;
26
+ }
27
+ const name = arg.slice(2);
28
+ if (!valued.includes(name))
29
+ fail(`Unknown option --${name}`);
30
+ const value = args[i + 1];
31
+ if (value === undefined)
32
+ fail(`--${name} needs a value`);
33
+ flags[name] = [...(flags[name] ?? []), value];
34
+ i++;
35
+ }
36
+ return { flags, positional };
37
+ }
38
+ const isOutputFormat = (value) => value === "text" || value === "json" || value === "github";
39
+ function readIncludes(configPath) {
40
+ if (!existsSync(configPath)) {
41
+ fail(`No globs given and no ${relative(process.cwd(), configPath)} found. List your script sources in "includes".`);
42
+ }
43
+ let config;
44
+ try {
45
+ config = JSON.parse(readFileSync(configPath, "utf8"));
46
+ }
47
+ catch (error) {
48
+ fail(`Could not read ${configPath}: ${error instanceof Error ? error.message : error}`);
49
+ }
50
+ const includes = config.includes;
51
+ if (!Array.isArray(includes) ||
52
+ includes.length === 0 ||
53
+ includes.some((g) => typeof g !== "string")) {
54
+ fail(`${configPath}: "includes" must be a non-empty array of glob strings`);
55
+ }
56
+ return includes;
57
+ }
58
+ async function check(args) {
59
+ const { flags, positional } = parseFlags(args, ["config", "format"]);
60
+ const kind = flags.format?.[0] ?? "text";
61
+ if (!isOutputFormat(kind))
62
+ fail(`Unknown format '${kind}'`);
63
+ const includes = positional.length > 0
64
+ ? positional
65
+ : readIncludes(resolve(flags.config?.[0] ?? "biome-plugin-suitescript.json"));
66
+ const files = (await glob(includes, { ignore: ["**/node_modules/**"], onlyFiles: true })).sort();
67
+ if (files.length === 0)
68
+ fail(`No files matched: ${includes.join(", ")}`);
69
+ const results = [];
70
+ let upgrade = false;
71
+ for (const file of files) {
72
+ const analysis = analyzeSource(file, readFileSync(file, "utf8"));
73
+ upgrade ||= analysis.upgrade;
74
+ results.push({ diagnostics: analysis.diagnostics, file });
75
+ }
76
+ const output = format(kind, results, { files: files.length, upgrade });
77
+ if (output)
78
+ console.log(output);
79
+ process.exit(results.some((r) => r.diagnostics.length) ? 1 : 0);
80
+ }
81
+ // Biome cannot resolve bare package imports from plugins, so the generated rule
82
+ // inlines the compiled parsers from this package.
83
+ function inline(file) {
84
+ return readFileSync(new URL(`../lib/${file}`, import.meta.url), "utf8")
85
+ .replace(/^import .*\n/gm, "")
86
+ .replace(/^export \{ stringValue \};\n?/m, "")
87
+ .replaceAll("export ", "");
88
+ }
89
+ function generate(args) {
90
+ const [kind, ...rest] = args;
91
+ if (kind !== "module-vars")
92
+ fail("generate supports: module-vars");
93
+ const { flags } = parseFlags(rest, ["set", "out"]);
94
+ const map = { ...MODULES };
95
+ for (const entry of flags.set ?? []) {
96
+ const [name, id] = entry.split("=");
97
+ if (!name || !id)
98
+ fail("--set expects N/module=identifier");
99
+ map[name] = id;
100
+ }
101
+ const out = flags.out?.[0] ?? "suitescript-module-vars.js";
102
+ writeFileSync(out, `// Generated by biome-plugin-suitescript. Add to biome.json "plugins".
103
+ import { defineRule, ast, registerDiagnostic } from "@biomejs/runtime/plugin";
104
+
105
+ ${inline("define.js")}
106
+ ${inline("biome-modules.js")}
107
+ const MODULES = ${JSON.stringify(map, null, 2)};
108
+
109
+ export const moduleVars = defineRule({
110
+ \tquery: ast("JS_CALL_EXPRESSION", "JS_IMPORT"),
111
+ \trun(node) {
112
+ \t\tfor (const { name, param } of dependencies(node)) {
113
+ \t\t\tconst expected = name && Object.hasOwn(MODULES, name) ? MODULES[name] : undefined;
114
+ \t\t\tif (expected && param && param.text !== expected) {
115
+ \t\t\t\tregisterDiagnostic(param, "warning", \`Identifier for \${name} should be '\${expected}'.\`);
116
+ \t\t\t}
117
+ \t\t}
118
+ \t},
119
+ });
120
+ `);
121
+ console.log(`Wrote ${out}`);
122
+ }
123
+ const [command, ...args] = process.argv.slice(2);
124
+ if (command === "check")
125
+ await check(args);
126
+ else if (command === "generate")
127
+ generate(args);
128
+ else
129
+ fail(command ? `Unknown command '${command}'` : "Missing command");
@@ -0,0 +1,227 @@
1
+ // Node-only analysis of a single SuiteScript source file (ESTree via oxc-parser).
2
+ import { parseSync, } from "oxc-parser";
3
+ import { declaredProblems, inferredProblems, inferScriptType, typeByName, } from "../lib/inference.js";
4
+ const API_VERSION = "2.1";
5
+ const UPGRADABLE = new Set(["1.0", "2.0", "2.x"]);
6
+ function isCallback(node) {
7
+ return (node?.type === "FunctionExpression" ||
8
+ node?.type === "ArrowFunctionExpression");
9
+ }
10
+ function objectNames(object) {
11
+ const names = [];
12
+ for (const property of object.properties) {
13
+ if (property.type === "SpreadElement")
14
+ return undefined;
15
+ const key = property.key;
16
+ if (key.type === "Identifier")
17
+ names.push(key.name);
18
+ else if (key.type === "Literal")
19
+ names.push(String(key.value));
20
+ }
21
+ return names;
22
+ }
23
+ function unwrap(node) {
24
+ let current = node;
25
+ while (current?.type === "ParenthesizedExpression")
26
+ current = current.expression;
27
+ return current;
28
+ }
29
+ function amdReturnedNames(callback) {
30
+ const body = callback.body;
31
+ if (!body)
32
+ return undefined;
33
+ let returned;
34
+ let statements = [];
35
+ if (body.type === "BlockStatement") {
36
+ statements = body.body;
37
+ const ret = statements.find((s) => s.type === "ReturnStatement");
38
+ returned =
39
+ ret?.type === "ReturnStatement" ? unwrap(ret.argument) : undefined;
40
+ }
41
+ else {
42
+ returned = unwrap(body);
43
+ }
44
+ if (!returned)
45
+ return [];
46
+ if (returned.type === "ObjectExpression")
47
+ return objectNames(returned);
48
+ if (returned.type !== "Identifier")
49
+ return undefined;
50
+ const variable = returned.name;
51
+ const names = [];
52
+ for (const statement of statements) {
53
+ if (statement.type === "VariableDeclaration") {
54
+ for (const declarator of statement.declarations) {
55
+ const init = unwrap(declarator.init);
56
+ if (declarator.id.type === "Identifier" &&
57
+ declarator.id.name === variable &&
58
+ init?.type === "ObjectExpression") {
59
+ const found = objectNames(init);
60
+ if (!found)
61
+ return undefined;
62
+ names.push(...found);
63
+ }
64
+ }
65
+ }
66
+ else if (statement.type === "ExpressionStatement" &&
67
+ statement.expression.type === "AssignmentExpression") {
68
+ const left = statement.expression.left;
69
+ if (left.type === "MemberExpression" &&
70
+ !left.computed &&
71
+ left.object.type === "Identifier" &&
72
+ left.object.name === variable &&
73
+ left.property.type === "Identifier") {
74
+ names.push(left.property.name);
75
+ }
76
+ }
77
+ }
78
+ return names;
79
+ }
80
+ function exportedNames(node) {
81
+ const names = [];
82
+ const declaration = node.declaration;
83
+ if (declaration?.type === "FunctionDeclaration" && declaration.id) {
84
+ names.push(declaration.id.name);
85
+ }
86
+ if (declaration?.type === "VariableDeclaration") {
87
+ for (const d of declaration.declarations) {
88
+ if (d.id.type === "Identifier")
89
+ names.push(d.id.name);
90
+ }
91
+ }
92
+ for (const specifier of node.specifiers) {
93
+ const exported = specifier.exported;
94
+ names.push(exported.type === "Identifier" ? exported.name : String(exported.value));
95
+ }
96
+ return names;
97
+ }
98
+ function inspectProgram(program) {
99
+ let isScript = false;
100
+ const returned = [];
101
+ let opaque = false;
102
+ // Only trust `returned` when an inline define() callback or ES syntax was seen;
103
+ // factory references and UMD wrappers cannot be analysed statically.
104
+ let known = false;
105
+ for (const node of program.body) {
106
+ if (node.type === "ImportDeclaration" ||
107
+ node.type === "ExportNamedDeclaration")
108
+ known = true;
109
+ if (node.type === "ImportDeclaration" && /^N(\/|$)/.test(node.source.value))
110
+ isScript = true;
111
+ if (node.type === "ExportNamedDeclaration")
112
+ returned.push(...exportedNames(node));
113
+ if (node.type === "ExpressionStatement" &&
114
+ node.expression.type === "CallExpression") {
115
+ const call = node.expression;
116
+ if (call.callee.type === "Identifier" && call.callee.name === "define") {
117
+ isScript = true;
118
+ const callback = call.arguments.at(-1);
119
+ if (isCallback(callback)) {
120
+ const names = amdReturnedNames(callback);
121
+ if (names) {
122
+ returned.push(...names);
123
+ known = true;
124
+ }
125
+ else {
126
+ opaque = true;
127
+ }
128
+ }
129
+ else {
130
+ opaque = true;
131
+ }
132
+ }
133
+ }
134
+ }
135
+ return { isScript, returned: opaque || !known ? undefined : returned };
136
+ }
137
+ function findTag(comments, name) {
138
+ const pattern = new RegExp(`${name}(?:[ \\t]+(\\S+))?`);
139
+ for (const comment of comments) {
140
+ if (comment.type !== "Block")
141
+ continue;
142
+ const match = pattern.exec(comment.value);
143
+ if (match)
144
+ return { offset: comment.start + 2 + match.index, value: match[1] };
145
+ }
146
+ return undefined;
147
+ }
148
+ function locator(source) {
149
+ const starts = [0];
150
+ for (let i = 0; i < source.length; i++) {
151
+ if (source[i] === "\n")
152
+ starts.push(i + 1);
153
+ }
154
+ return (offset) => {
155
+ let lo = 0;
156
+ let hi = starts.length - 1;
157
+ while (lo < hi) {
158
+ const mid = (lo + hi + 1) >> 1;
159
+ if ((starts[mid] ?? 0) <= offset)
160
+ lo = mid;
161
+ else
162
+ hi = mid - 1;
163
+ }
164
+ return { column: offset - (starts[lo] ?? 0) + 1, line: lo + 1 };
165
+ };
166
+ }
167
+ /**
168
+ * Analyzes one file. Libraries (no tags, no implied entry points) produce no
169
+ * diagnostics.
170
+ */
171
+ export function analyzeSource(filename, source) {
172
+ const diagnostics = [];
173
+ const at = locator(source);
174
+ const report = (rule, message, offset = 0) => {
175
+ diagnostics.push({ message, rule, ...at(offset) });
176
+ };
177
+ const parsed = parseSync(filename, source);
178
+ const fatal = parsed.errors.find((e) => e.severity === "Error");
179
+ if (fatal && parsed.program.body.length === 0) {
180
+ report("parse", `Could not parse file: ${fatal.message}`);
181
+ return { diagnostics, upgrade: false };
182
+ }
183
+ const apiVersion = findTag(parsed.comments, "@NApiVersion");
184
+ const scriptType = findTag(parsed.comments, "@NScriptType");
185
+ const { isScript, returned } = inspectProgram(parsed.program);
186
+ const inferred = isScript && returned ? inferScriptType(returned) : undefined;
187
+ // Not a script: no tags and nothing that implies an entry point.
188
+ if (!apiVersion && !scriptType && !inferred)
189
+ return { diagnostics, upgrade: false };
190
+ let upgrade = false;
191
+ if (apiVersion) {
192
+ if (!apiVersion.value) {
193
+ report("apiVersion", "No @NApiVersion value provided.", apiVersion.offset);
194
+ }
195
+ else if (apiVersion.value !== API_VERSION) {
196
+ upgrade = UPGRADABLE.has(apiVersion.value);
197
+ report("apiVersion", `@NApiVersion must be ${API_VERSION}, found '${apiVersion.value}'.`, apiVersion.offset);
198
+ }
199
+ }
200
+ else {
201
+ report("apiVersion", `Missing @NApiVersion ${API_VERSION} tag.`);
202
+ }
203
+ let declared;
204
+ if (scriptType) {
205
+ if (!scriptType.value) {
206
+ report("scriptType", "No @NScriptType value provided.", scriptType.offset);
207
+ }
208
+ else if (!typeByName(scriptType.value)) {
209
+ report("scriptType", `Invalid @NScriptType value '${scriptType.value}'.`, scriptType.offset);
210
+ }
211
+ else {
212
+ declared = scriptType.value;
213
+ }
214
+ }
215
+ else if (inferred?.types.length) {
216
+ const names = inferred.types.map((t) => t.name).join(" or ");
217
+ report("scriptType", `Missing @NScriptType tag; the returned entry points imply ${names}.`);
218
+ }
219
+ if (returned) {
220
+ const problems = declared
221
+ ? declaredProblems(declared, returned)
222
+ : inferredProblems(returned);
223
+ for (const message of problems)
224
+ report("entryPoints", message, scriptType?.offset ?? 0);
225
+ }
226
+ return { diagnostics, upgrade };
227
+ }
@@ -0,0 +1,23 @@
1
+ const escapeData = (s) => s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
2
+ const escapeProperty = (s) => escapeData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
3
+ export const UPGRADE_HINT = "Upgrade old scripts with Oracle's netsuite-suitescript-upgrade agent skill (run /netsuite-suitescript-upgrade, starting with `analyze`): https://github.com/oracle/netsuite-suitecloud-sdk/tree/master/packages/agent-skills/netsuite-suitescript-upgrade";
4
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
5
+ /** Returns the text to print for the results of a run. */
6
+ export function format(kind, results, { upgrade, files }) {
7
+ const all = results.flatMap((r) => r.diagnostics.map((d) => ({ file: r.file, ...d })));
8
+ if (kind === "json") {
9
+ return JSON.stringify({ diagnostics: all, files, hints: upgrade ? [UPGRADE_HINT] : [] }, null, 2);
10
+ }
11
+ if (kind === "github") {
12
+ return all
13
+ .map((d) => `::error file=${escapeProperty(d.file)},line=${d.line},col=${d.column},title=${escapeProperty(`suitescript/${d.rule}`)}::${escapeData(d.message)}`)
14
+ .join("\n");
15
+ }
16
+ const lines = all.map((d) => `${d.file}:${d.line}:${d.column} ${d.rule} ${d.message}`);
17
+ lines.push(all.length
18
+ ? `\n${plural(all.length, "problem")} in ${plural(files, "checked file")}.`
19
+ : `Checked ${plural(files, "file")}. No problems found.`);
20
+ if (upgrade)
21
+ lines.push(`\n${UPGRADE_HINT}`);
22
+ return lines.join("\n");
23
+ }