diff --git a/src/actions/fixIssue.ts b/src/actions/fixIssue.ts index df64b851..9a0f2b65 100644 --- a/src/actions/fixIssue.ts +++ b/src/actions/fixIssue.ts @@ -17,7 +17,7 @@ type IssueFixQuickPickItem = vscode.QuickPickItem & { }; function getIssueFixQuickPickItems( - issues: FixableIssue[], + issues: readonly FixableIssue[], ): IssueFixQuickPickItem[] { return issues.map((issue) => ({ label: issue.name, @@ -89,7 +89,7 @@ export class FixIssue { private async selectAndFixTargetIssues( target: string, - issues: FixableIssue[], + issues: readonly FixableIssue[], ): Promise { const selectedFixes = await vscode.window.showQuickPick( getIssueFixQuickPickItems(issues), @@ -119,7 +119,7 @@ export class FixIssue { private async executeFix( target: string, - issueNames: string[], + issueNames: readonly string[], command: string, ): Promise { const issueName = issueNames.join(', '); @@ -141,7 +141,7 @@ export class FixIssue { export function createFixIssueTask( target: string, - issueNames: string[], + issueNames: readonly string[], command: string, ): vscode.Task { const issueName = issueNames.join(', '); diff --git a/src/controllers/targetController.ts b/src/controllers/targetController.ts index 9688972e..8ecbdd9c 100644 --- a/src/controllers/targetController.ts +++ b/src/controllers/targetController.ts @@ -29,7 +29,7 @@ const removeTargetQuickPickButton: vscode.QuickInputButton = { }; async function promptForSshTarget( - currentTargets: string[], + currentTargets: readonly string[], selectedTarget: string | undefined, ): Promise { const sshHosts = await getHosts(defaultSshConfigPath); @@ -81,9 +81,9 @@ async function promptForSshTarget( } export function buildQuickPickItems( - discoveredTargets: string[], + discoveredTargets: readonly string[], filter: string, - savedTargets: string[] = [], + savedTargets: readonly string[] = [], selectedTarget?: string, ): TargetQuickPickItem[] { const discoveredTargetsSet = new Set(discoveredTargets); diff --git a/src/errors/wrappedError.ts b/src/errors/wrappedError.ts index 70bd8e88..8177b116 100644 --- a/src/errors/wrappedError.ts +++ b/src/errors/wrappedError.ts @@ -9,15 +9,15 @@ export type WrappedErrorCode = | 'INVALID_SSH_DESTINATION'; export interface WrappedErrorLog { - level: TopoLogLevel; - msg: string; + readonly level: TopoLogLevel; + readonly msg: string; } export class WrappedError extends Error { constructor( public readonly code: WrappedErrorCode, message: string, - public readonly logs: WrappedErrorLog[] = [], + public readonly logs: readonly WrappedErrorLog[] = [], options?: ErrorOptions, ) { super(message, options); @@ -27,7 +27,7 @@ export class WrappedError extends Error { export function isWrappedError( error: unknown, - codes: WrappedErrorCode[] = [], + codes: readonly WrappedErrorCode[] = [], ): error is WrappedError { return ( error instanceof WrappedError && diff --git a/src/services/dockerCommands.ts b/src/services/dockerCommands.ts index f966b27f..f8d90afc 100644 --- a/src/services/dockerCommands.ts +++ b/src/services/dockerCommands.ts @@ -31,7 +31,9 @@ const isDockerError = (err: unknown): err is DockerError => { * @param shouldTreatErrorAsWarning - Optional function to determine if an error should be treated as a warning. * @returns The stdout of the command. */ -export const parseDockerStderr = (stderr: string): WrappedErrorLog[] => { +export const parseDockerStderr = ( + stderr: string, +): readonly WrappedErrorLog[] => { return splitLines(stderr).map((line): WrappedErrorLog => { const trimmed = line.trim(); if (trimmed.startsWith('Warning:')) { diff --git a/src/services/topoCli.ts b/src/services/topoCli.ts index 76623b66..8ea5fa4f 100644 --- a/src/services/topoCli.ts +++ b/src/services/topoCli.ts @@ -28,7 +28,9 @@ interface ExecOptions { cwd?: string; } -export function parseTopoLogEntries(output: string): WrappedErrorLog[] { +export function parseTopoLogEntries( + output: string, +): readonly WrappedErrorLog[] { const entries: WrappedErrorLog[] = []; for (const line of output.split('\n')) { const trimmed = line.trim(); @@ -164,7 +166,7 @@ export class TopoCli { public async listProjects( sshTarget?: string, - ): Promise { + ): Promise { const cmd = ['templates', '-o', 'json']; if (sshTarget) { cmd.push('--target', sshTarget); diff --git a/src/services/topoCliSchema.ts b/src/services/topoCliSchema.ts index 286adf6d..338bc42f 100644 --- a/src/services/topoCliSchema.ts +++ b/src/services/topoCliSchema.ts @@ -11,6 +11,7 @@ import { type, defaulted, } from 'superstruct'; +import type { DeepReadonly } from '../util/types'; const trimmedStringSchema = trimmed(string()); @@ -25,7 +26,7 @@ export const projectSchema = type({ compatibility: optional(projectCompatibilitySchema), }); -export type ProjectDescription = Infer; +export type ProjectDescription = DeepReadonly>; const healthCheckStatusSchema = trimmed( enums(['ok', 'warning', 'error', 'info']), @@ -38,7 +39,7 @@ export const healthCheckFixSchema = type({ command: optional(trimmedStringSchema), }); -export type HealthCheckFix = Infer; +export type HealthCheckFix = DeepReadonly>; export const healthCheckSchema = type({ name: trimmedStringSchema, @@ -47,7 +48,7 @@ export const healthCheckSchema = type({ fix: optional(healthCheckFixSchema), }); -export type HealthCheck = Infer; +export type HealthCheck = DeepReadonly>; const targetHealthReportSchema = type({ destination: trimmedStringSchema, @@ -65,16 +66,20 @@ export const hostHealthReportSchema = type({ host: hostHealthSchema, }); -export type HostHealthReport = Infer; +export type HostHealthReport = DeepReadonly< + Infer +>; -export type TargetHealthReport = Infer; +export type TargetHealthReport = DeepReadonly< + Infer +>; export const healthReportSchema = type({ host: hostHealthSchema, target: targetHealthReportSchema, }); -export type HealthReport = Infer; +export type HealthReport = DeepReadonly>; const describeHostProcessorSchema = type({ model: trimmedStringSchema, @@ -126,10 +131,10 @@ const psEntrySchema = type({ address: trimmedStringSchema, }); -export type PsEntry = Infer; +export type PsEntry = DeepReadonly>; export const psSchema = type({ containers: array(psEntrySchema), }); -export type PsOutput = Infer; +export type PsOutput = DeepReadonly>; diff --git a/src/util/getWorstHealthCheckStatus.ts b/src/util/getWorstHealthCheckStatus.ts index 4ddeaa77..c25443b0 100644 --- a/src/util/getWorstHealthCheckStatus.ts +++ b/src/util/getWorstHealthCheckStatus.ts @@ -1,7 +1,7 @@ import { HealthCheck, HealthCheckStatus } from '../services/topoCliSchema'; export const getWorstHealthCheckStatus = ( - healthChecks: HealthCheck[], + healthChecks: readonly HealthCheck[], ): HealthCheckStatus => { return healthChecks.reduce((acc: HealthCheckStatus, healthCheck) => { if (healthCheck.status === 'error') { diff --git a/src/util/isProjectComposePathDeleted.ts b/src/util/isProjectComposePathDeleted.ts index a9a687f0..675364a6 100644 --- a/src/util/isProjectComposePathDeleted.ts +++ b/src/util/isProjectComposePathDeleted.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import { ProjectMetadata } from './project'; export function isProjectComposePathDeleted( - projects: ProjectMetadata[], + projects: readonly ProjectMetadata[], deletedPath: string, ): boolean { return projects.some((project) => { diff --git a/src/util/issueFixes.ts b/src/util/issueFixes.ts index c1a1f569..9ec981f3 100644 --- a/src/util/issueFixes.ts +++ b/src/util/issueFixes.ts @@ -5,12 +5,12 @@ import { } from '../services/topoCliSchema'; export type IssueFixCommandGroup = { - issueNames: string[]; - command: string; + readonly issueNames: readonly string[]; + readonly command: string; }; export type FixableIssue = HealthCheck & { - fix: HealthCheckFix & { command: string }; + readonly fix: HealthCheckFix & { readonly command: string }; }; export function hasFixCommand( @@ -21,7 +21,7 @@ export function hasFixCommand( export function getTargetIssueFixCommandGroups( health: TargetHealthReport | undefined, -): IssueFixCommandGroup[] { +): readonly IssueFixCommandGroup[] { if (!health) { return []; } @@ -30,9 +30,9 @@ export function getTargetIssueFixCommandGroups( } export function getIssueFixCommandGroups( - healthChecks: HealthCheck[], -): IssueFixCommandGroup[] { - const groups = new Map(); + healthChecks: readonly HealthCheck[], +): readonly IssueFixCommandGroup[] { + const issueNamesByCommand = new Map(); for (const healthCheck of healthChecks) { const command = healthCheck.fix?.command; @@ -40,22 +40,24 @@ export function getIssueFixCommandGroups( continue; } - const group = groups.get(command); - if (group) { - group.issueNames.push(healthCheck.name); + const issueNames = issueNamesByCommand.get(command); + if (issueNames) { + issueNames.push(healthCheck.name); continue; } - groups.set(command, { - issueNames: [healthCheck.name], - command, - }); + issueNamesByCommand.set(command, [healthCheck.name]); } - return [...groups.values()]; + return [...issueNamesByCommand].map(([command, issueNames]) => ({ + issueNames, + command, + })); } -function getTargetHealthChecks(health: TargetHealthReport): HealthCheck[] { +function getTargetHealthChecks( + health: TargetHealthReport, +): readonly HealthCheck[] { return [ health.connectivity, health.processingDomainDriver, diff --git a/src/util/loadable.ts b/src/util/loadable.ts index 31d3170c..39133b51 100644 --- a/src/util/loadable.ts +++ b/src/util/loadable.ts @@ -1,13 +1,29 @@ -export type Loaded = { status: 'loaded'; data: T; loading: boolean }; -export type Errored = { status: 'errored'; error: Error; loading: boolean }; -export type Unloaded = { status: 'unloaded'; loading: boolean }; +import type { DeepReadonly } from './types'; + +export type Loaded = { + readonly status: 'loaded'; + readonly data: DeepReadonly; + readonly loading: boolean; +}; +export type Errored = { + readonly status: 'errored'; + readonly error: Error; + readonly loading: boolean; +}; +export type Unloaded = { + readonly status: 'unloaded'; + readonly loading: boolean; +}; export type Loadable = Loaded | Errored | Unloaded; export function loading>(current: T): T { return { ...current, loading: true }; } -export function loaded(data: T, loading: boolean = false): Loaded { +export function loaded( + data: DeepReadonly, + loading: boolean = false, +): Loaded { return { status: 'loaded', data, loading }; } diff --git a/src/util/project.ts b/src/util/project.ts index e8e682ae..c20441f5 100644 --- a/src/util/project.ts +++ b/src/util/project.ts @@ -6,16 +6,16 @@ const ROOT_COMPOSE_FILE_GLOB = 'compose.{yaml,yml}'; const CHILD_COMPOSE_FILE_GLOB = '*/compose.{yaml,yml}'; export interface ProjectMetadata { - name: string; - uri: vscode.Uri; - composeFileUri: vscode.Uri; - workspaceIndex: number; - workspaceName: string; + readonly name: string; + readonly uri: vscode.Uri; + readonly composeFileUri: vscode.Uri; + readonly workspaceIndex: number; + readonly workspaceName: string; } export async function findTopLevelComposeProjects( workspaceFolders: readonly vscode.WorkspaceFolder[], -): Promise { +): Promise { const projects: ProjectMetadata[] = []; for (const workspaceFolder of workspaceFolders) { diff --git a/src/util/projectClone.ts b/src/util/projectClone.ts index 8881a1ab..2f941fdf 100644 --- a/src/util/projectClone.ts +++ b/src/util/projectClone.ts @@ -241,7 +241,7 @@ const cloneWithSource = async ( const listProjects = async ( topoCli: TopoCli, sshTarget?: string, -): Promise => { +): Promise => { if (!sshTarget) { return topoCli.listProjects(); } @@ -290,7 +290,7 @@ export const promptForRemoteCloneSource = async ( let projectItems: RemoteProjectQuickPickItem[] = []; void (async () => { - let projects: ProjectDescription[] = []; + let projects: readonly ProjectDescription[] = []; try { projects = await listProjects(topoCli, sshTarget); } catch (e) { diff --git a/src/util/showAndLog.ts b/src/util/showAndLog.ts index 772d1207..38f5f32e 100644 --- a/src/util/showAndLog.ts +++ b/src/util/showAndLog.ts @@ -10,7 +10,7 @@ const logMethods = { DEBUG: logger.debug, } as const; -const logEntries = (entries: WrappedErrorLog[]) => { +const logEntries = (entries: readonly WrappedErrorLog[]) => { for (const entry of entries) { logMethods[entry.level](entry.msg); } diff --git a/src/util/types.ts b/src/util/types.ts index 1b4d1388..7b3cd6d0 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -1,23 +1,29 @@ -import { PsEntry } from '../services/topoCliSchema'; +import type { PsEntry } from '../services/topoCliSchema'; export interface HostProcessor { - model: string; - cores: number; - features: string[]; + readonly model: string; + readonly cores: number; + readonly features: readonly string[]; } export interface RemoteProcessor { - name: string; + readonly name: string; } export interface TargetDescription { - hostProcessors: HostProcessor[]; - remoteProcessors: RemoteProcessor[]; - totalMemoryKb: number; + readonly hostProcessors: readonly HostProcessor[]; + readonly remoteProcessors: readonly RemoteProcessor[]; + readonly totalMemoryKb: number; } -export interface ContainerItem extends PsEntry { - target: string; -} +export type DeepReadonly = T extends (...args: never[]) => unknown + ? T + : T extends object + ? { readonly [P in keyof T]: DeepReadonly } + : T; + +export type ContainerItem = PsEntry & { + readonly target: string; +}; export type Mutable = { -readonly [P in keyof T]: T[P] }; diff --git a/src/views/hostTreeView.test.ts b/src/views/hostTreeView.test.ts index 42f384e6..ae2523b4 100644 --- a/src/views/hostTreeView.test.ts +++ b/src/views/hostTreeView.test.ts @@ -34,7 +34,7 @@ describe('HostTreeView', () => { expect(children[0].contextValue).toBe('Health'); }); - it('returns host health checks sorted by name below the Health group', async () => { + it('returns sorted host health checks without mutating the model', async () => { const model = new HostModel(); model.setHealth( loaded({ @@ -79,6 +79,13 @@ describe('HostTreeView', () => { description: 'missing', }), ]); + expect(model.health).toMatchObject({ + data: { + host: { + dependencies: [{ name: 'Zed' }, { name: 'Alpha' }], + }, + }, + }); }); it('returns an error item when host health cannot be loaded', async () => { diff --git a/src/views/hostTreeView.ts b/src/views/hostTreeView.ts index 222e5efe..e6ff715f 100644 --- a/src/views/hostTreeView.ts +++ b/src/views/hostTreeView.ts @@ -8,8 +8,10 @@ import { HostModel } from '../models/hostModel'; import { DisposableCollector } from '../util/disposableCollector'; import { loaded } from '../util/loadable'; -function sortHealthChecksByName(healthChecks: HealthCheck[]): HealthCheck[] { - return healthChecks.sort((a, b) => +function sortHealthChecksByName( + healthChecks: readonly HealthCheck[], +): HealthCheck[] { + return healthChecks.toSorted((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }), ); } diff --git a/src/views/projectsTreeView.ts b/src/views/projectsTreeView.ts index ed7cedb8..63079073 100644 --- a/src/views/projectsTreeView.ts +++ b/src/views/projectsTreeView.ts @@ -35,7 +35,7 @@ function compareContainers(a: ContainerItem, b: ContainerItem): number { } function groupContainersByProcessingDomain( - containers: ContainerItem[], + containers: readonly ContainerItem[], ): ProcessingDomainTreeItem[] { const containersByDomain = new Map(); for (const container of containers) { @@ -48,7 +48,7 @@ function groupContainersByProcessingDomain( return [...containersByDomain.entries()] .map(([domain, containers]) => { - const sortedContainers = [...containers].sort(compareContainers); + const sortedContainers = containers.toSorted(compareContainers); return new ProcessingDomainTreeItem(domain, sortedContainers); }) .sort(compareProcessingDomains); diff --git a/src/views/targetTreeView.ts b/src/views/targetTreeView.ts index d07e0cd4..ed40d300 100644 --- a/src/views/targetTreeView.ts +++ b/src/views/targetTreeView.ts @@ -199,7 +199,7 @@ export class TargetTreeView } if (element instanceof HealthCheckGroupTreeItem) { - const healthChecks = [...element.healthChecks].sort(compareByName); + const healthChecks = element.healthChecks.toSorted(compareByName); return healthChecks.map( (healthCheck) => new HealthCheckTreeItem(loaded(healthCheck)), ); diff --git a/src/views/treeItems/healthCheckGroupTreeItem.ts b/src/views/treeItems/healthCheckGroupTreeItem.ts index 1bb38e7d..3a3e7968 100644 --- a/src/views/treeItems/healthCheckGroupTreeItem.ts +++ b/src/views/treeItems/healthCheckGroupTreeItem.ts @@ -6,7 +6,7 @@ import { Loaded } from '../../util/loadable'; import { hasFixCommand } from '../../util/issueFixes'; export class HealthCheckGroupTreeItem extends vscode.TreeItem { - public readonly healthChecks: HealthCheck[]; + public readonly healthChecks: readonly HealthCheck[]; constructor(healthChecks: Loaded) { super('Health', vscode.TreeItemCollapsibleState.Collapsed); diff --git a/src/views/treeItems/processingDomainTreeItem.ts b/src/views/treeItems/processingDomainTreeItem.ts index 9cb2eedc..2a824173 100644 --- a/src/views/treeItems/processingDomainTreeItem.ts +++ b/src/views/treeItems/processingDomainTreeItem.ts @@ -21,7 +21,7 @@ export function compareProcessingDomains( export class ProcessingDomainTreeItem extends vscode.TreeItem { constructor( public readonly processingDomain: string, - public readonly containers?: ContainerItem[], + public readonly containers?: readonly ContainerItem[], ) { super( processingDomain, diff --git a/src/views/util/getVisibleTargetHealthChecks.ts b/src/views/util/getVisibleTargetHealthChecks.ts index a23421d4..c03bc643 100644 --- a/src/views/util/getVisibleTargetHealthChecks.ts +++ b/src/views/util/getVisibleTargetHealthChecks.ts @@ -4,7 +4,7 @@ import { TargetDescription } from '../../util/types'; export function getVisibleTargetHealthChecks( health: TargetHealthReport, targetDescription: TargetDescription | undefined, -): HealthCheck[] { +): readonly HealthCheck[] { const healthChecks = [...health.dependencies]; if (targetDescription?.remoteProcessors.length) { healthChecks.push(health.processingDomainDriver); diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index b884d3a4..670ba18b 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "lib": ["ES2022", "DOM"], + "lib": ["ES2023", "DOM"], "types": ["node", "vitest/globals"] }, "include": ["**/*.ts"] diff --git a/tsconfig.json b/tsconfig.json index 82ebb443..47afdbd8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2022", + "target": "ES2023", "module": "commonjs", - "lib": ["ES2022"], + "lib": ["ES2023"], "types": ["node", "vitest/globals"], "sourceMap": true, "strict": true,