diff --git a/.eslintrc.js b/.eslintrc.js index 54203c4ecb..a4870dd3da 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -42,4 +42,18 @@ module.exports = { version: "detect", }, }, + overrides: [ + /** + * Some enums are subsets of other enums. For example, UniversityLocationName contains locations of 3 universities. + * With each member, we refer to the respective LocationName's member instead of using a literal string. This usage + * is okay, but it triggers the "prefer-literal-enum-member" rule. This rule is not useful in this case, so we + * suppress it in NetscriptDefinitions.d.ts. + */ + { + files: ["src/ScriptEditor/NetscriptDefinitions.d.ts"], + rules: { + "@typescript-eslint/prefer-literal-enum-member": ["off"], + }, + }, + ], }; diff --git a/package-lock.json b/package-lock.json index 0b365e1923..ea667f06c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,7 @@ "@types/react-beautiful-dnd": "^13.1.5", "@types/react-dom": "^17.0.21", "@types/react-resizable": "^3.0.5", + "@types/sprintf-js": "^1.1.4", "@typescript-eslint/eslint-plugin": "^6.9.1", "@typescript-eslint/parser": "^6.9.1", "babel-jest": "^29.7.0", @@ -5124,6 +5125,13 @@ "@types/node": "*" } }, + "node_modules/@types/sprintf-js": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/sprintf-js/-/sprintf-js-1.1.4.tgz", + "integrity": "sha512-aWK1reDYWxcjgcIIPmQi3u+OQDuYa9b+lr6eIsGWrekJ9vr1NSjr4Eab8oQ1iKuH1ltFHpXGyerAv1a3FMKxzQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", diff --git a/package.json b/package.json index c0726b6e65..848c488664 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@types/react-beautiful-dnd": "^13.1.5", "@types/react-dom": "^17.0.21", "@types/react-resizable": "^3.0.5", + "@types/sprintf-js": "^1.1.4", "@typescript-eslint/eslint-plugin": "^6.9.1", "@typescript-eslint/parser": "^6.9.1", "babel-jest": "^29.7.0", diff --git a/src/Bladeburner/Bladeburner.ts b/src/Bladeburner/Bladeburner.ts index 6d635f7150..f2bb03d841 100644 --- a/src/Bladeburner/Bladeburner.ts +++ b/src/Bladeburner/Bladeburner.ts @@ -51,6 +51,7 @@ import { Sleeve } from "../PersonObjects/Sleeve/Sleeve"; import { autoCompleteTypeShorthand } from "./utils/terminalShorthands"; import { resolveTeamCasualties, type OperationTeam } from "./Actions/TeamCasualties"; import { shuffleArray } from "../Infiltration/ui/BribeGame"; +import { objectAssert } from "../utils/helpers/typeAssertion"; export const BladeburnerPromise: PromisePair = { promise: null, resolve: null }; @@ -1403,9 +1404,10 @@ export class Bladeburner implements OperationTeam { /** Initializes a Bladeburner object from a JSON save state. */ static fromJSON(value: IReviverValue): Bladeburner { + objectAssert(value.data); // operations and contracts are not loaded directly from the save, we load them in using a different method - const contractsData = value.data?.contracts; - const operationsData = value.data?.operations; + const contractsData = value.data.contracts; + const operationsData = value.data.operations; const bladeburner = Generic_fromJSON(Bladeburner, value.data, Bladeburner.keysToLoad); // Loading this way allows better typesafety and also allows faithfully reconstructing contracts/operations // even from save data that is missing a lot of static info about the objects. diff --git a/src/CodingContracts.ts b/src/CodingContracts.ts index 6fe9c2bc6f..c0238be751 100644 --- a/src/CodingContracts.ts +++ b/src/CodingContracts.ts @@ -4,6 +4,7 @@ import { codingContractTypesMetadata } from "./data/codingcontracttypes"; import { Generic_fromJSON, Generic_toJSON, IReviverValue, constructorsForReviver } from "./utils/JSONReviver"; import { CodingContractEvent } from "./ui/React/CodingContractModal"; import { ContractFilePath, resolveContractFilePath } from "./Paths/ContractFilePath"; +import { objectAssert } from "./utils/helpers/typeAssertion"; /* Contract Types */ export const CodingContractTypes = Object.fromEntries(codingContractTypesMetadata.map((x) => [x.name, x])); @@ -127,6 +128,7 @@ export class CodingContract { /** Initializes a CodingContract from a JSON save state. */ static fromJSON(value: IReviverValue): CodingContract { + objectAssert(value.data); // In previous versions, there was a data field instead of a state field. if ("data" in value.data) { value.data.state = value.data.data; diff --git a/src/Corporation/CorporationState.ts b/src/Corporation/CorporationState.ts index ae1705b8a3..8faf02ff47 100644 --- a/src/Corporation/CorporationState.ts +++ b/src/Corporation/CorporationState.ts @@ -1,6 +1,7 @@ import { CorpStateName } from "@nsdefs"; import { Generic_fromJSON, Generic_toJSON, IReviverValue, constructorsForReviver } from "../utils/JSONReviver"; import { stateNames } from "./data/Constants"; + export class CorporationState { // Number representing what state the Corporation is in. The number // is an index for the array that holds all Corporation States diff --git a/src/Gang/GangMember.ts b/src/Gang/GangMember.ts index 80b6553462..1879f40b85 100644 --- a/src/Gang/GangMember.ts +++ b/src/Gang/GangMember.ts @@ -100,12 +100,6 @@ export class GangMember { } getTask(): GangMemberTask { - // TODO unplanned: transfer that to a save file migration function - // Backwards compatibility - if ((this.task as any) instanceof GangMemberTask) { - this.task = (this.task as any).name; - } - if (Object.hasOwn(GangMemberTasks, this.task)) { return GangMemberTasks[this.task]; } diff --git a/src/Infiltration/ui/MinesweeperGame.tsx b/src/Infiltration/ui/MinesweeperGame.tsx index 81df2942ee..be73738a46 100644 --- a/src/Infiltration/ui/MinesweeperGame.tsx +++ b/src/Infiltration/ui/MinesweeperGame.tsx @@ -165,9 +165,9 @@ function fieldEquals(a: boolean[][], b: boolean[][]): boolean { } function generateEmptyField(difficulty: Difficulty): boolean[][] { - const field = []; + const field: boolean[][] = []; for (let i = 0; i < Math.round(difficulty.height); i++) { - field.push(new Array(Math.round(difficulty.width)).fill(false)); + field.push(new Array(Math.round(difficulty.width)).fill(false)); } return field; } diff --git a/src/NetscriptFunctions.ts b/src/NetscriptFunctions.ts index ecc4593bef..c1e55f4719 100644 --- a/src/NetscriptFunctions.ts +++ b/src/NetscriptFunctions.ts @@ -20,11 +20,12 @@ import { GymType, JobName, JobField, - LiteratureName, + type LiteratureName, LocationName, ToastVariant, UniversityClassType, CompanyName, + type MessageFilename, } from "@enums"; import { PromptEvent } from "./ui/React/PromptManager"; import { GetServer, DeleteServer, AddToAllServers, createUniqueRandomIp } from "./Server/AllServers"; @@ -145,8 +146,19 @@ export const ns: InternalAPI = { stock: NetscriptStockMarket(), grafting: NetscriptGrafting(), hacknet: NetscriptHacknet(), - sprintf: () => sprintf, - vsprintf: () => vsprintf, + sprintf: + (ctx) => + (_format, ...args) => { + const format = helpers.string(ctx, "format", _format); + return sprintf(format, ...(args as unknown[])); + }, + vsprintf: (ctx) => (_format, _args) => { + const format = helpers.string(ctx, "format", _format); + if (!Array.isArray(_args)) { + throw helpers.errorMessage(ctx, `args must be an array.`); + } + return vsprintf(format, _args); + }, scan: (ctx) => (_hostname) => { const hostname = _hostname ? helpers.string(ctx, "hostname", _hostname) : ctx.workerScript.hostname; const server = helpers.getServer(ctx, hostname); @@ -1163,7 +1175,8 @@ export const ns: InternalAPI = { if (!path) return false; if (hasScriptExtension(path)) return server.scripts.has(path); if (hasTextExtension(path)) return server.textFiles.has(path); - if (path.endsWith(".lit") || path.endsWith(".msg")) return server.messages.includes(path as any); + if (path.endsWith(".lit") || path.endsWith(".msg")) + return server.messages.includes(path as LiteratureName | MessageFilename); if (hasContractExtension(path)) return !!server.contracts.find(({ fn }) => fn === path); const lowerPath = path.toLowerCase(); return server.programs.map((programName) => programName.toLowerCase()).includes(lowerPath); diff --git a/src/NetscriptFunctions/Flags.ts b/src/NetscriptFunctions/Flags.ts index 0e8a8075ba..15baa2aa64 100644 --- a/src/NetscriptFunctions/Flags.ts +++ b/src/NetscriptFunctions/Flags.ts @@ -3,6 +3,7 @@ import { toNative } from "./toNative"; import libarg from "arg"; import { NetscriptContext } from "../Netscript/APIWrapper"; +export type Schema = [string, string | number | boolean | string[]][]; type FlagType = StringConstructor | NumberConstructor | BooleanConstructor | StringConstructor[]; type FlagsRet = Record; export function Flags(ctx: NetscriptContext | string[]): (data: unknown) => FlagsRet { @@ -12,7 +13,7 @@ export function Flags(ctx: NetscriptContext | string[]): (data: unknown) => Flag if (!Array.isArray(schema)) throw new Error("flags schema passed in is invalid."); const args: Record = {}; - for (const d of schema) { + for (const d of schema as Schema) { let t: FlagType = String; if (typeof d[1] === "number") { t = Number; @@ -24,13 +25,15 @@ export function Flags(ctx: NetscriptContext | string[]): (data: unknown) => Flag const numDashes = d[0].length > 1 ? 2 : 1; args["-".repeat(numDashes) + d[0]] = t; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment const ret: FlagsRet = libarg(args, { argv: vargs }); - for (const d of schema) { + for (const d of schema as Schema) { if (!Object.hasOwn(ret, "--" + d[0]) || !Object.hasOwn(ret, "-" + d[0])) ret[d[0]] = d[1]; } for (const key of Object.keys(ret)) { if (!key.startsWith("-")) continue; const value = ret[key]; + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete ret[key]; const numDashes = key.length === 2 ? 1 : 2; ret[key.slice(numDashes)] = value; diff --git a/src/NetscriptFunctions/UserInterface.ts b/src/NetscriptFunctions/UserInterface.ts index 3d0577afa8..c387d5bd4f 100644 --- a/src/NetscriptFunctions/UserInterface.ts +++ b/src/NetscriptFunctions/UserInterface.ts @@ -82,14 +82,14 @@ export function NetscriptUserInterface(): InternalAPI { setStyles: (ctx) => (newStyles) => { const styleValidator: Record = {}; assertObjectType(ctx, "newStyles", newStyles, styleValidator); - const currentStyles = { ...Settings.styles }; + const currentStyles: Record = { ...Settings.styles }; const errors: string[] = []; for (const key of Object.keys(newStyles)) { - if (!(currentStyles as any)[key]) { + if (!currentStyles[key]) { // Invalid key errors.push(`Invalid key "${key}"`); } else { - (currentStyles as any)[key] = newStyles[key]; + currentStyles[key] = newStyles[key]; } } diff --git a/src/PersonObjects/Player/PlayerObject.ts b/src/PersonObjects/Player/PlayerObject.ts index 818b714c0a..65ece9e95a 100644 --- a/src/PersonObjects/Player/PlayerObject.ts +++ b/src/PersonObjects/Player/PlayerObject.ts @@ -203,6 +203,7 @@ export class PlayerObject extends Person implements IPlayer { // Remove any invalid jobs for (const [loadedCompanyName, loadedJobName] of Object.entries(player.jobs)) { if (!isMember("CompanyName", loadedCompanyName) || !isMember("JobName", loadedJobName)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete player.jobs[loadedCompanyName as CompanyName]; } } diff --git a/src/PersonObjects/Player/PlayerObjectGeneralMethods.ts b/src/PersonObjects/Player/PlayerObjectGeneralMethods.ts index 50fd5ee717..3294efeaed 100644 --- a/src/PersonObjects/Player/PlayerObjectGeneralMethods.ts +++ b/src/PersonObjects/Player/PlayerObjectGeneralMethods.ts @@ -377,6 +377,7 @@ export function quitJob(this: PlayerObject, company: CompanyName, suppressDialog } } } + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete this.jobs[company]; } diff --git a/src/PersonObjects/Sleeve/Work/SleeveBladeburnerWork.ts b/src/PersonObjects/Sleeve/Work/SleeveBladeburnerWork.ts index df86ba2409..355f5a6d47 100644 --- a/src/PersonObjects/Sleeve/Work/SleeveBladeburnerWork.ts +++ b/src/PersonObjects/Sleeve/Work/SleeveBladeburnerWork.ts @@ -10,6 +10,7 @@ import { scaleWorkStats } from "../../../Work/WorkStats"; import { getKeyList } from "../../../utils/helpers/getKeyList"; import { loadActionIdentifier } from "../../../Bladeburner/utils/loadActionIdentifier"; import { invalidWork } from "../../../Work/InvalidWork"; +import { objectAssert } from "../../../utils/helpers/typeAssertion"; interface SleeveBladeburnerWorkParams { actionId: ActionIdentifier & { type: BladeburnerActionType.General | BladeburnerActionType.Contract }; @@ -98,6 +99,7 @@ export class SleeveBladeburnerWork extends SleeveWorkClass { /** Initializes a BladeburnerWork object from a JSON save state. */ static fromJSON(value: IReviverValue): SleeveBladeburnerWork { + objectAssert(value.data); const actionId = loadActionIdentifier(value.data?.actionId); if (!actionId) return invalidWork(); value.data.actionId = actionId; diff --git a/src/PersonObjects/Sleeve/Work/SleeveClassWork.ts b/src/PersonObjects/Sleeve/Work/SleeveClassWork.ts index 91c4a9c780..896a9a6dd2 100644 --- a/src/PersonObjects/Sleeve/Work/SleeveClassWork.ts +++ b/src/PersonObjects/Sleeve/Work/SleeveClassWork.ts @@ -7,6 +7,7 @@ import { Sleeve } from "../Sleeve"; import { scaleWorkStats, WorkStats } from "../../../Work/WorkStats"; import { Locations } from "../../../Locations/Locations"; import { isMember } from "../../../utils/EnumHelper"; +import { objectAssert } from "../../../utils/helpers/typeAssertion"; export const isSleeveClassWork = (w: SleeveWorkClass | null): w is SleeveClassWork => w !== null && w.type === SleeveWorkType.CLASS; @@ -54,8 +55,13 @@ export class SleeveClassWork extends SleeveWorkClass { /** Initializes a ClassWork object from a JSON save state. */ static fromJSON(value: IReviverValue): SleeveClassWork { - if (!(value.data.classType in Classes)) value.data.classType = "Computer Science"; - if (!(value.data.location in Locations)) value.data.location = LocationName.Sector12RothmanUniversity; + objectAssert(value.data); + if (typeof value.data.classType !== "string" || !(value.data.classType in Classes)) { + value.data.classType = "Computer Science"; + } + if (typeof value.data.location !== "string" || !(value.data.location in Locations)) { + value.data.location = LocationName.Sector12RothmanUniversity; + } return Generic_fromJSON(SleeveClassWork, value.data); } } diff --git a/src/SaveObject.ts b/src/SaveObject.ts index 98cb42b941..d5778cde81 100644 --- a/src/SaveObject.ts +++ b/src/SaveObject.ts @@ -225,7 +225,12 @@ class BitburnerSaveObject { let parsedSaveData; try { - parsedSaveData = JSON.parse(decodedSaveData); + parsedSaveData = JSON.parse(decodedSaveData) as { + ctor: string; + data: { + PlayerSave: string; + }; + }; } catch (error) { console.error(error); // We'll handle below } @@ -632,6 +637,7 @@ function evaluateVersionCompatibility(ver: string | number): void { if (isNaN(intExp)) intExp = 0; anyPlayer.exp.intelligence += intExp; for (const field of removePlayerFields) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete anyPlayer[field]; } for (const sleeve of anyPlayer.sleeves) { @@ -640,6 +646,7 @@ function evaluateVersionCompatibility(ver: string | number): void { if (isNaN(intExp)) intExp = 0; anySleeve.exp.intelligence += intExp; for (const field of removeSleeveFields) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete sleeve[field]; } } diff --git a/src/ScriptEditor/ui/themes.ts b/src/ScriptEditor/ui/themes.ts index 23152c2c8f..41fbebe816 100644 --- a/src/ScriptEditor/ui/themes.ts +++ b/src/ScriptEditor/ui/themes.ts @@ -79,7 +79,10 @@ export const sanitizeTheme = (theme: IScriptEditorTheme): void => { return; } for (const themeKey of getRecordKeys(theme)) { - if (typeof theme[themeKey] !== "object") delete theme[themeKey]; + if (typeof theme[themeKey] !== "object") { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete theme[themeKey]; + } switch (themeKey) { case "base": if (!["vs-dark", "vs"].includes(theme.base)) theme.base = "vs-dark"; diff --git a/src/Server/AllServers.ts b/src/Server/AllServers.ts index 68bb36af10..2d3fd26d3f 100644 --- a/src/Server/AllServers.ts +++ b/src/Server/AllServers.ts @@ -13,6 +13,7 @@ import { currentNodeMults } from "../BitNode/BitNodeMultipliers"; import { IPAddress, isIPAddress } from "../Types/strings"; import "../Script/RunningScript"; // For reviver side-effect +import { objectAssert } from "../utils/helpers/typeAssertion"; /** * Map of all Servers that exist in the game @@ -63,6 +64,7 @@ export function DeleteServer(serverkey: string): void { for (const key of Object.keys(AllServers)) { const server = AllServers[key]; if (server.ip !== serverkey && server.hostname !== serverkey) continue; + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete AllServers[key]; break; } @@ -100,6 +102,7 @@ export function AddToAllServers(server: Server | HacknetServer): void { export const renameServer = (hostname: string, newName: string): void => { AllServers[newName] = AllServers[hostname]; + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete AllServers[hostname]; }; @@ -188,13 +191,22 @@ export function initForeignServers(homeComputer: Server): void { export function prestigeAllServers(): void { for (const member of Object.keys(AllServers)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete AllServers[member]; } AllServers = {}; } export function loadAllServers(saveString: string): void { - AllServers = JSON.parse(saveString, Reviver); + const allServersData: unknown = JSON.parse(saveString, Reviver); + objectAssert(allServersData); + for (const [serverName, server] of Object.entries(allServersData)) { + if (!(server instanceof Server) && !(server instanceof HacknetServer)) { + throw new Error(`Server ${serverName} is not an instance of Server or HacknetServer.`); + } + } + // We validated the data above, so it's safe to typecast here. + AllServers = allServersData as typeof AllServers; } export function saveAllServers(): string { diff --git a/src/Server/BaseServer.ts b/src/Server/BaseServer.ts index 5c4564120a..1edb7b8577 100644 --- a/src/Server/BaseServer.ts +++ b/src/Server/BaseServer.ts @@ -24,6 +24,7 @@ import lodash from "lodash"; import { Settings } from "../Settings/Settings"; import type { ScriptKey } from "../utils/helpers/scriptKey"; +import { objectAssert } from "../utils/helpers/typeAssertion"; interface IConstructorParams { adminRights?: boolean; @@ -293,6 +294,7 @@ export abstract class BaseServer implements IServer { // RunningScripts are stored as a simple array, both for backward compatibility, // compactness, and ease of filtering them here. const result = Generic_toJSON(ctorName, this, keys); + objectAssert(result.data); if (Settings.ExcludeRunningScriptsFromSave) { result.data.runningScripts = []; return result; @@ -313,8 +315,11 @@ export abstract class BaseServer implements IServer { // Initializes a Server Object from a JSON save state // Called by subclasses, not Reviver. static fromJSONBase(value: IReviverValue, ctor: new () => T, keys: readonly (keyof T)[]): T { + objectAssert(value.data); const server = Generic_fromJSON(ctor, value.data, keys); - server.savedScripts = value.data.runningScripts; + if (value.data.runningScripts != null && Array.isArray(value.data.runningScripts)) { + server.savedScripts = value.data.runningScripts; + } // If textFiles is not an array, we've already done the 2.3 migration to textFiles and scripts as maps + path changes. if (!Array.isArray(server.textFiles)) return server; @@ -333,7 +338,7 @@ export abstract class BaseServer implements IServer { // In case somehow there are previously valid filenames that can't be sanitized, they will go in a new directory with a note. for (const script of oldScripts) { // We're about to do type validation on the filename anyway. - if (script.filename.endsWith(".ns")) script.filename = (script.filename + ".js") as any; + if (script.filename.endsWith(".ns")) script.filename = (script.filename + ".js") as ScriptFilePath; let newFilePath = resolveScriptFilePath(script.filename); if (!newFilePath) { newFilePath = `${newDirectory}script${++invalidScriptCount}.js` as ScriptFilePath; diff --git a/src/StockMarket/StockMarket.tsx b/src/StockMarket/StockMarket.tsx index f9ec466797..97018bb8d7 100644 --- a/src/StockMarket/StockMarket.tsx +++ b/src/StockMarket/StockMarket.tsx @@ -151,6 +151,7 @@ export function deleteStockMarket(): void { export function initStockMarket(): void { for (const stockName of Object.getOwnPropertyNames(StockMarket)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete StockMarket[stockName]; } diff --git a/src/Terminal/commands/download.ts b/src/Terminal/commands/download.ts index 946936c703..0258573f45 100644 --- a/src/Terminal/commands/download.ts +++ b/src/Terminal/commands/download.ts @@ -18,7 +18,7 @@ export function exportScripts(pattern: string, server: BaseServer, currDir = roo // Return an error if no files matched, rather than an empty zip folder if (Object.keys(zip.files).length == 0) throw new Error(`No files match the pattern ${pattern}`); const filename = `bitburner${ - hasScriptExtension(pattern) ? "Scripts" : pattern.endsWith(".txt") ? "Texts" : "Files" + hasScriptExtension(pattern) ? "Scripts" : hasTextExtension(pattern) ? "Texts" : "Files" }.zip`; zip .generateAsync({ type: "blob" }) diff --git a/src/Terminal/commands/ls.tsx b/src/Terminal/commands/ls.tsx index 7b2d9638fe..3159491ca2 100644 --- a/src/Terminal/commands/ls.tsx +++ b/src/Terminal/commands/ls.tsx @@ -33,6 +33,7 @@ export function ls(args: (string | number | boolean)[], server: BaseServer): voi } let flags: LSFlags; try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment flags = libarg( { "-l": Boolean, diff --git a/src/Terminal/commands/ps.ts b/src/Terminal/commands/ps.ts index 184a911e29..2606219184 100644 --- a/src/Terminal/commands/ps.ts +++ b/src/Terminal/commands/ps.ts @@ -4,8 +4,11 @@ import { matchScriptPathUnanchored } from "../../utils/helpers/scriptKey"; import libarg from "arg"; export function ps(args: (string | number | boolean)[], server: BaseServer): void { - let flags; + let flags: { + "--grep": string; + }; try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment flags = libarg( { "--grep": String, diff --git a/src/Terminal/commands/runScript.ts b/src/Terminal/commands/runScript.ts index 5a24357b89..1f85f92f5b 100644 --- a/src/Terminal/commands/runScript.ts +++ b/src/Terminal/commands/runScript.ts @@ -18,8 +18,14 @@ export function runScript(path: ScriptFilePath, commandArgs: (string | number | if (!script) return Terminal.error(`Script ${path} does not exist on this server.`); const runArgs = { "--tail": Boolean, "-t": Number, "--ram-override": Number }; - let flags; + let flags: { + _: ScriptArg[]; + "--tail": boolean; + "-t": string; + "--ram-override": string; + }; try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment flags = libarg(runArgs, { permissive: true, argv: commandArgs, @@ -42,7 +48,7 @@ export function runScript(path: ScriptFilePath, commandArgs: (string | number | if (!server.hasAdminRights) return Terminal.error("Need root access to run script"); // Todo: Switch out arg for something with typescript support - const args = flags._ as ScriptArg[]; + const args = flags._; const singleRamUsage = ramOverride ?? script.getRamUsage(server.scripts); if (!singleRamUsage) { diff --git a/src/Terminal/commands/tail.ts b/src/Terminal/commands/tail.ts index b9e05c2938..9c68a54ec6 100644 --- a/src/Terminal/commands/tail.ts +++ b/src/Terminal/commands/tail.ts @@ -23,7 +23,10 @@ export function tail(commandArray: (string | number | boolean)[], server: BaseSe } // Just use the first one (if there are multiple with the same // arguments, they can't be distinguished except by pid). - LogBoxEvents.emit(candidates.values().next().value); + const next = candidates.values().next(); + if (!next.done) { + LogBoxEvents.emit(next.value); + } } else if (typeof commandArray[0] === "number") { const runningScript = findRunningScriptByPid(commandArray[0]); if (runningScript == null) { diff --git a/src/Terminal/getTabCompletionPossibilities.ts b/src/Terminal/getTabCompletionPossibilities.ts index 6356ea036e..7c80f39f24 100644 --- a/src/Terminal/getTabCompletionPossibilities.ts +++ b/src/Terminal/getTabCompletionPossibilities.ts @@ -5,7 +5,7 @@ import { GetAllServers } from "../Server/AllServers"; import { parseCommand, parseCommands } from "./Parser"; import { HelpTexts } from "./HelpText"; import { compile } from "../NetscriptJSEvaluator"; -import { Flags } from "../NetscriptFunctions/Flags"; +import { Flags, type Schema } from "../NetscriptFunctions/Flags"; import { AutocompleteData } from "@nsdefs"; import libarg from "arg"; import { getAllDirectories, resolveDirectory, root } from "../Paths/Directory"; @@ -280,6 +280,7 @@ export async function getTabCompletionPossibilities(terminalText: string, baseDi _: [], }; try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment flags = libarg(runArgs, { permissive: true, argv: command.slice(2), @@ -299,9 +300,9 @@ export async function getTabCompletionPossibilities(terminalText: string, baseDi scripts: [...currServ.scripts.keys()], txts: [...currServ.textFiles.keys()], enums: enums, - flags: (schema: unknown) => { + flags: (schema: Schema) => { if (!Array.isArray(schema)) throw new Error("flags require an array of array"); - pos2 = schema.map((f: unknown) => { + pos2 = schema.map((f) => { if (!Array.isArray(f)) throw new Error("flags require an array of array"); if (f[0].length === 1) return "-" + f[0]; return "--" + f[0]; diff --git a/src/ThirdParty/acorn-jsx-walk.d.ts b/src/ThirdParty/acorn-jsx-walk.d.ts index 41b4d7b50d..30e095ac42 100644 --- a/src/ThirdParty/acorn-jsx-walk.d.ts +++ b/src/ThirdParty/acorn-jsx-walk.d.ts @@ -1 +1,3 @@ -declare module "acorn-jsx-walk"; +declare module "acorn-jsx-walk" { + export function extend(base: any): void; +} diff --git a/src/ThirdParty/sprintf-js.d.ts b/src/ThirdParty/sprintf-js.d.ts deleted file mode 100644 index 6da6a8676a..0000000000 --- a/src/ThirdParty/sprintf-js.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module "sprintf-js"; diff --git a/src/Types/Jsonable.ts b/src/Types/Jsonable.ts index 42d5cbae1c..db0b311fe5 100644 --- a/src/Types/Jsonable.ts +++ b/src/Types/Jsonable.ts @@ -1,3 +1,4 @@ +import { arrayAssert } from "../utils/helpers/typeAssertion"; import type { IReviverValue } from "../utils/JSONReviver"; // Versions of js builtin classes that can be converted to and from JSON for use in save files @@ -6,6 +7,7 @@ export class JSONSet extends Set { return { ctor: "JSONSet", data: Array.from(this) }; } static fromJSON(value: IReviverValue): JSONSet { + arrayAssert(value.data); return new JSONSet(value.data); } } @@ -16,6 +18,15 @@ export class JSONMap extends Map { } static fromJSON(value: IReviverValue): JSONMap { - return new JSONMap(value.data); + arrayAssert(value.data); + for (const item of value.data) { + arrayAssert(item); + if (item.length !== 2) { + console.error("Invalid data passed to JSONMap.fromJSON(). Value:", value); + throw new Error(`An item is not an array with exactly 2 items. Its length is ${item.length}.`); + } + } + // We validated the data above, so it's safe to typecast here. + return new JSONMap(value.data as [unknown, unknown][]); } } diff --git a/src/utils/JSONReviver.ts b/src/utils/JSONReviver.ts index f9849d4da2..b78fe2d54e 100644 --- a/src/utils/JSONReviver.ts +++ b/src/utils/JSONReviver.ts @@ -2,13 +2,14 @@ import { ObjectValidator, validateObject } from "./Validator"; import { JSONMap, JSONSet } from "../Types/Jsonable"; import { loadActionIdentifier } from "../Bladeburner/utils/loadActionIdentifier"; +import { objectAssert } from "./helpers/typeAssertion"; type JsonableClass = (new () => { toJSON: () => IReviverValue }) & { - fromJSON: (value: IReviverValue) => any; + fromJSON: (value: IReviverValue) => unknown; validationData?: ObjectValidator; }; -export interface IReviverValue { +export interface IReviverValue { ctor: string; data: T; } @@ -87,16 +88,23 @@ export function Generic_toJSON>( * @returns The object */ export function Generic_fromJSON>( ctor: new () => T, - // data can actually be anything. We're just pretending it has the right keys for T. Save data is not type validated. - data: Record, + data: unknown, keys?: readonly (keyof T)[], ): T { + objectAssert(data); const obj = new ctor(); // If keys were provided, just load the provided keys (if they are in the data) if (keys) { - for (const key of keys) { + /** + * The type of key is "keyof T", but the type of data is Record. TypeScript won't allow us to use + * key as the index of data, so we need to typecast here. + */ + for (const key of keys as string[]) { const val = data[key]; - if (val !== undefined) obj[key] = val; + if (val !== undefined) { + // @ts-expect-error -- TypeScript won't allow this action: Type 'T' is generic and can only be indexed for reading. + obj[key] = val; + } } return obj; } diff --git a/src/utils/helpers/typeAssertion.ts b/src/utils/helpers/typeAssertion.ts index 30eba63b81..71de528125 100644 --- a/src/utils/helpers/typeAssertion.ts +++ b/src/utils/helpers/typeAssertion.ts @@ -1,5 +1,15 @@ // Various functions for asserting types. +export class TypeAssertionError extends Error { + friendlyType: string; + + constructor(message: string, friendlyType: string, options?: ErrorOptions) { + super(message, options); + this.name = this.constructor.name; + this.friendlyType = friendlyType; + } +} + /** Function for providing custom error message to throw for a type assertion. * @param v: Value to assert type of * @param assertFn: Typechecking function to use for asserting type of v. @@ -12,31 +22,47 @@ export function assert( try { assertFn(v); } catch (e) { + if (e instanceof TypeAssertionError) { + throw msgFn(e.friendlyType); + } const type = typeof e === "string" ? e : "unknown"; throw msgFn(type); } } /** Returns the friendlyType of v. arrays are "array" and null is "null". */ -export function getFriendlyType(v: unknown): string { +function getFriendlyType(v: unknown): string { return v === null ? "null" : Array.isArray(v) ? "array" : typeof v; } //All assertion functions used here should return the friendlyType of the input. -/** For non-objects, and for array/null, throws the friendlyType of v. */ -export function objectAssert(v: unknown): asserts v is Partial> { +/** For non-objects, and for array/null, throws an error with the friendlyType of v. */ +export function objectAssert(v: unknown): asserts v is Record { const type = getFriendlyType(v); - if (type !== "object") throw type; + if (type !== "object") { + console.error("The value is not an object. Value:", v); + throw new TypeAssertionError( + `The value is not an object. Its type is ${type}. Its string value is ${String(v)}.`, + type, + ); + } } -/** For non-string, throws the friendlyType of v. */ +/** For non-string, throws an error with the friendlyType of v. */ export function stringAssert(v: unknown): asserts v is string { const type = getFriendlyType(v); - if (type !== "string") throw type; + if (type !== "string") { + console.error("The value is not a string. Value:", v); + throw new TypeAssertionError(`The value is not an string. Its type is ${type}.`, type); + } } -/** For non-array, throws the friendlyType of v. */ +/** For non-array, throws an error with the friendlyType of v. */ export function arrayAssert(v: unknown): asserts v is unknown[] { - if (!Array.isArray(v)) throw getFriendlyType(v); + if (!Array.isArray(v)) { + console.error("The value is not an array. Value:", v); + const type = getFriendlyType(v); + throw new TypeAssertionError(`The value is not an array. Its type is ${type}.`, type); + } }