From b232f013979fb20295e25477a391158a28b0841a Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:59:15 +0100 Subject: [PATCH] chore: enable strict index checks This catches a couple of bugs where we were assuming arrays were non empty, for example. By forcing us to check the result of index access, we'll generally catch these bugs. --- .changeset/dirty-rats-change.md | 6 +++ packages/core/src/prompts/autocomplete.ts | 9 ++-- packages/core/src/prompts/date.ts | 3 +- .../core/src/prompts/group-multiselect.ts | 3 ++ packages/core/src/prompts/multi-line.ts | 10 ++--- packages/core/src/prompts/multi-select.ts | 4 +- packages/core/src/prompts/password.ts | 7 +-- packages/core/src/prompts/select.ts | 5 ++- packages/core/src/prompts/text.ts | 7 +-- packages/core/src/utils/cursor.ts | 16 ++++--- packages/core/src/utils/settings.ts | 2 +- packages/core/test/prompts/date.test.ts | 10 ++--- .../test/prompts/group-multiselect.test.ts | 43 +++++++++++++++++++ .../core/test/prompts/multi-select.test.ts | 12 ++++++ packages/core/test/prompts/select.test.ts | 10 +++++ packages/prompts/src/group-multi-select.ts | 2 +- packages/prompts/src/limit-options.ts | 14 ++++-- packages/prompts/src/log.ts | 2 +- packages/prompts/src/multi-line.ts | 8 ++-- packages/prompts/src/select-key.ts | 5 ++- packages/prompts/src/select.ts | 5 ++- packages/prompts/src/spinner.ts | 3 +- packages/prompts/src/task-log.ts | 2 +- packages/prompts/src/text.ts | 8 ++-- packages/prompts/test/date.test.ts | 2 +- tsconfig.json | 1 + 26 files changed, 150 insertions(+), 49 deletions(-) create mode 100644 .changeset/dirty-rats-change.md create mode 100644 packages/core/test/prompts/group-multiselect.test.ts diff --git a/.changeset/dirty-rats-change.md b/.changeset/dirty-rats-change.md new file mode 100644 index 00000000..b529f3f0 --- /dev/null +++ b/.changeset/dirty-rats-change.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": patch +"@clack/core": patch +--- + +Handle empty arrays in various prompts and utilities. diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts index e023462b..fa9a43dd 100644 --- a/packages/core/src/prompts/autocomplete.ts +++ b/packages/core/src/prompts/autocomplete.ts @@ -86,9 +86,10 @@ export default class AutocompletePrompt extends Prompt< if (this._cursor >= this.userInput.length) { return `${this.userInput}█`; } - const s1 = this.userInput.slice(0, this._cursor); - const [s2, ...s3] = this.userInput.slice(this._cursor); - return `${s1}${styleText('inverse', s2)}${s3.join('')}`; + const preCursor = this.userInput.slice(0, this.cursor); + const cursorChar = this.userInput.slice(this.cursor, this.cursor + 1); + const rest = this.userInput.slice(this.cursor + 1); + return `${preCursor}${styleText('inverse', cursorChar)}${rest}`; } get options(): T[] { @@ -117,7 +118,7 @@ export default class AutocompletePrompt extends Prompt< } } else { if (!this.multiple && this.options.length > 0) { - initialValues = [this.options[0].value]; + initialValues = [this.options[0]?.value]; } } diff --git a/packages/core/src/prompts/date.ts b/packages/core/src/prompts/date.ts index b04c2b50..d782a519 100644 --- a/packages/core/src/prompts/date.ts +++ b/packages/core/src/prompts/date.ts @@ -22,7 +22,8 @@ const SEGMENTS: Record = { } as const; function segmentsFor(fmt: DateFormat): SegmentConfig[] { - return [...fmt].map((c) => SEGMENTS[c as keyof typeof SEGMENTS]); + // biome-ignore lint/style/noNonNullAssertion: DateFormat only contains Y/M/D keys present in SEGMENTS + return [...fmt].map((c) => SEGMENTS[c as keyof typeof SEGMENTS]!); } function detectLocaleFormat(locale?: string): { segments: SegmentConfig[]; separator: string } { diff --git a/packages/core/src/prompts/group-multiselect.ts b/packages/core/src/prompts/group-multiselect.ts index c189a919..e5496728 100644 --- a/packages/core/src/prompts/group-multiselect.ts +++ b/packages/core/src/prompts/group-multiselect.ts @@ -28,6 +28,9 @@ export default class GroupMultiSelectPrompt extends Pr private toggleValue() { const item = this.options[this.cursor]; + if (item === undefined) { + return; + } if (this.value === undefined) { this.value = []; } diff --git a/packages/core/src/prompts/multi-line.ts b/packages/core/src/prompts/multi-line.ts index e8c881f7..f6b6139c 100644 --- a/packages/core/src/prompts/multi-line.ts +++ b/packages/core/src/prompts/multi-line.ts @@ -25,11 +25,11 @@ export default class MultiLinePrompt extends Prompt { if (this.cursor >= userInput.length) { return `${userInput}█`; } - const s1 = userInput.slice(0, this.cursor); - const s2 = userInput[this.cursor]; - const s3 = userInput.slice(this.cursor + 1); - if (s2 === '\n') return `${s1}█\n${s3}`; - return `${s1}${styleText('inverse', s2)}${s3}`; + const preCursor = userInput.slice(0, this.cursor); + const cursorChar = userInput.slice(this.cursor, this.cursor + 1); + const rest = userInput.slice(this.cursor + 1); + if (cursorChar === '\n') return `${preCursor}█\n${rest}`; + return `${preCursor}${styleText('inverse', cursorChar)}${rest}`; } get cursor() { return this._cursor; diff --git a/packages/core/src/prompts/multi-select.ts b/packages/core/src/prompts/multi-select.ts index f5b04254..88d76a8e 100644 --- a/packages/core/src/prompts/multi-select.ts +++ b/packages/core/src/prompts/multi-select.ts @@ -18,7 +18,7 @@ export default class MultiSelectPrompt extends Prompt extends Prompt value === opts.cursorAt), 0 ); - this.cursor = this.options[cursor].disabled ? findCursor(cursor, 1, this.options) : cursor; + this.cursor = this.options[cursor]?.disabled ? findCursor(cursor, 1, this.options) : cursor; this.on('key', (_char, key) => { if (key.name === 'a') { this.toggleAll(); diff --git a/packages/core/src/prompts/password.ts b/packages/core/src/prompts/password.ts index 738d973b..e28b8abc 100644 --- a/packages/core/src/prompts/password.ts +++ b/packages/core/src/prompts/password.ts @@ -21,9 +21,10 @@ export default class PasswordPrompt extends Prompt { return `${this.masked}${styleText(['inverse', 'hidden'], '_')}`; } const masked = this.masked; - const s1 = masked.slice(0, this.cursor); - const s2 = masked.slice(this.cursor); - return `${s1}${styleText('inverse', s2[0])}${s2.slice(1)}`; + const preCursor = masked.slice(0, this.cursor); + const cursorChar = masked.slice(this.cursor, this.cursor + 1); + const rest = masked.slice(this.cursor + 1); + return `${preCursor}${styleText('inverse', cursorChar)}${rest}`; } clear() { this._clearUserInput(); diff --git a/packages/core/src/prompts/select.ts b/packages/core/src/prompts/select.ts index 99ed5df9..2aec9b4c 100644 --- a/packages/core/src/prompts/select.ts +++ b/packages/core/src/prompts/select.ts @@ -17,7 +17,8 @@ export default class SelectPrompt } private changeValue() { - this.value = this._selectedValue.value; + const selectedValue = this._selectedValue; + this.value = selectedValue === undefined ? undefined : selectedValue.value; } constructor(opts: SelectOptions) { @@ -27,7 +28,7 @@ export default class SelectPrompt const initialCursor = this.options.findIndex(({ value }) => value === opts.initialValue); const cursor = initialCursor === -1 ? 0 : initialCursor; - this.cursor = this.options[cursor].disabled ? findCursor(cursor, 1, this.options) : cursor; + this.cursor = this.options[cursor]?.disabled ? findCursor(cursor, 1, this.options) : cursor; this.changeValue(); this.on('cursor', (key) => { diff --git a/packages/core/src/prompts/text.ts b/packages/core/src/prompts/text.ts index 1aacc12f..27b124ff 100644 --- a/packages/core/src/prompts/text.ts +++ b/packages/core/src/prompts/text.ts @@ -15,9 +15,10 @@ export default class TextPrompt extends Prompt { if (this.cursor >= userInput.length) { return `${this.userInput}█`; } - const s1 = userInput.slice(0, this.cursor); - const [s2, ...s3] = userInput.slice(this.cursor); - return `${s1}${styleText('inverse', s2)}${s3.join('')}`; + const preCursor = userInput.slice(0, this.cursor); + const cursorChar = userInput.slice(this.cursor, this.cursor + 1); + const rest = userInput.slice(this.cursor + 1); + return `${preCursor}${styleText('inverse', cursorChar)}${rest}`; } get cursor() { return this._cursor; diff --git a/packages/core/src/utils/cursor.ts b/packages/core/src/utils/cursor.ts index 75df1ac3..e078c986 100644 --- a/packages/core/src/utils/cursor.ts +++ b/packages/core/src/utils/cursor.ts @@ -11,7 +11,7 @@ export function findCursor( const maxCursor = Math.max(options.length - 1, 0); const clampedCursor = newCursor < 0 ? maxCursor : newCursor > maxCursor ? 0 : newCursor; const newOption = options[clampedCursor]; - if (newOption.disabled) { + if (newOption?.disabled) { return findCursor(clampedCursor, delta < 0 ? -1 : 1, options); } return clampedCursor; @@ -37,20 +37,22 @@ export function findTextCursor( cursorY = Math.max(0, Math.min(lines.length - 1, cursorY + deltaY)); - cursorX = Math.min(cursorX, lines[cursorY].length) + deltaX; + // biome-ignore-start lint/style/noNonNullAssertion: cursorY/i are always kept within lines bounds + cursorX = Math.min(cursorX, lines[cursorY]!.length) + deltaX; while (cursorX < 0 && cursorY > 0) { cursorY--; - cursorX += lines[cursorY].length + 1; + cursorX += lines[cursorY]!.length + 1; } - while (cursorX > lines[cursorY].length && cursorY < lines.length - 1) { - cursorX -= lines[cursorY].length + 1; + while (cursorX > lines[cursorY]!.length && cursorY < lines.length - 1) { + cursorX -= lines[cursorY]!.length + 1; cursorY++; } - cursorX = Math.max(0, Math.min(lines[cursorY].length, cursorX)); + cursorX = Math.max(0, Math.min(lines[cursorY]!.length, cursorX)); let newCursor = 0; for (let i = 0; i < cursorY; i++) { - newCursor += lines[i].length + 1; + newCursor += lines[i]!.length + 1; } + // biome-ignore-end lint/style/noNonNullAssertion: end of clamped cursor block return newCursor + cursorX; } diff --git a/packages/core/src/utils/settings.ts b/packages/core/src/utils/settings.ts index b8b3222d..7b35959e 100644 --- a/packages/core/src/utils/settings.ts +++ b/packages/core/src/utils/settings.ts @@ -123,7 +123,7 @@ export function updateSettings(updates: ClackSettings) { if (!Object.hasOwn(aliases, alias)) continue; const action = aliases[alias]; - if (!settings.actions.has(action)) continue; + if (action === undefined || !settings.actions.has(action)) continue; if (!settings.aliases.has(alias)) { settings.aliases.set(alias, action); diff --git a/packages/core/test/prompts/date.test.ts b/packages/core/test/prompts/date.test.ts index 8f3ce7e9..1d818579 100644 --- a/packages/core/test/prompts/date.test.ts +++ b/packages/core/test/prompts/date.test.ts @@ -6,7 +6,7 @@ import { MockReadable } from '../mock-readable.js'; import { MockWritable } from '../mock-writable.js'; const d = (iso: string) => { - const [y, m, day] = iso.slice(0, 10).split('-').map(Number); + const [y, m, day] = iso.slice(0, 10).split('-').map(Number) as [number, number, number]; return new Date(Date.UTC(y, m - 1, day)); }; @@ -407,9 +407,9 @@ describe('DatePrompt', () => { }); instance.prompt(); // en-US is MDY - expect(instance.segments[0].type).to.equal('month'); - expect(instance.segments[1].type).to.equal('day'); - expect(instance.segments[2].type).to.equal('year'); + expect(instance.segments[0]?.type).to.equal('month'); + expect(instance.segments[1]?.type).to.equal('day'); + expect(instance.segments[2]?.type).to.equal('year'); }); test('explicit format overrides locale', () => { @@ -422,7 +422,7 @@ describe('DatePrompt', () => { initialValue: d('2025-03-15'), }); instance.prompt(); - expect(instance.segments[0].type).to.equal('year'); + expect(instance.segments[0]?.type).to.equal('year'); }); }); }); diff --git a/packages/core/test/prompts/group-multiselect.test.ts b/packages/core/test/prompts/group-multiselect.test.ts new file mode 100644 index 00000000..fa370f7f --- /dev/null +++ b/packages/core/test/prompts/group-multiselect.test.ts @@ -0,0 +1,43 @@ +import { cursor } from 'sisteransi'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { default as GroupMultiSelectPrompt } from '../../src/prompts/group-multiselect.js'; +import { MockReadable } from '../mock-readable.js'; +import { MockWritable } from '../mock-writable.js'; + +describe('GroupMultiSelectPrompt', () => { + let input: MockReadable; + let output: MockWritable; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('renders render() result', () => { + const instance = new GroupMultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: { + group: [{ value: 'foo' }, { value: 'bar' }], + }, + }); + instance.prompt(); + expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); + }); + + test('does not throw if empty options are provided', () => { + const instance = new GroupMultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: {}, + }); + instance.prompt(); + expect(() => input.emit('keypress', ' ', { name: 'space' })).not.toThrow(); + }); +}); diff --git a/packages/core/test/prompts/multi-select.test.ts b/packages/core/test/prompts/multi-select.test.ts index 99695793..e8af630d 100644 --- a/packages/core/test/prompts/multi-select.test.ts +++ b/packages/core/test/prompts/multi-select.test.ts @@ -28,6 +28,18 @@ describe('MultiSelectPrompt', () => { expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); }); + test('does not throw if empty options are provided', () => { + expect( + () => + new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [], + }) + ).not.toThrow(); + }); + describe('cursor', () => { test('cursor is index of selected item', () => { const instance = new MultiSelectPrompt({ diff --git a/packages/core/test/prompts/select.test.ts b/packages/core/test/prompts/select.test.ts index a3583061..f27b306e 100644 --- a/packages/core/test/prompts/select.test.ts +++ b/packages/core/test/prompts/select.test.ts @@ -28,6 +28,16 @@ describe('SelectPrompt', () => { expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); }); + test('does not throw if empty options are provided', () => { + const instance = new SelectPrompt({ + input, + output, + render: () => 'foo', + options: [], + }); + expect(() => instance.prompt()).not.toThrow(); + }); + describe('cursor', () => { test('cursor is index of selected item', () => { const instance = new SelectPrompt({ diff --git a/packages/prompts/src/group-multi-select.ts b/packages/prompts/src/group-multi-select.ts index 819d81e5..476689ef 100644 --- a/packages/prompts/src/group-multi-select.ts +++ b/packages/prompts/src/group-multi-select.ts @@ -237,7 +237,7 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => const groupActive = !active && typeof option.group === 'string' && - this.options[this.cursor].value === option.group; + this.options[this.cursor]?.value === option.group; if (groupActive) { return opt(option, selected ? 'group-active-selected' : 'group-active', options); } diff --git a/packages/prompts/src/limit-options.ts b/packages/prompts/src/limit-options.ts index 6e1553a7..8805afbd 100644 --- a/packages/prompts/src/limit-options.ts +++ b/packages/prompts/src/limit-options.ts @@ -56,13 +56,19 @@ const trimLines = ( let removals = 0; if (fromEnd) { for (let i = endIndex - 1; i >= startIndex; i--) { - lineCount -= groups[i].length; + const group = groups[i]; + if (group) { + lineCount -= group.length; + } removals++; if (lineCount <= maxLines) break; } } else { for (let i = startIndex; i < endIndex; i++) { - lineCount -= groups[i].length; + const group = groups[i]; + if (group) { + lineCount -= group.length; + } removals++; if (lineCount <= maxLines) break; } @@ -142,7 +148,9 @@ export const limitOptions = ({ slidingWindowLocationEnd - (shouldRenderBottomEllipsis ? 1 : 0); for (let i = slidingWindowLocationWithEllipsis; i < slidingWindowLocationEndWithEllipsis; i++) { - const wrappedLines = wrapAnsi(style(options[i], i === cursor), maxWidth, { + const option = options[i]; + const styledOption = option ? style(option, i === cursor) : ''; + const wrappedLines = wrapAnsi(styledOption, maxWidth, { hard: true, trim: false, }).split('\n'); diff --git a/packages/prompts/src/log.ts b/packages/prompts/src/log.ts index 2728d385..4ed38989 100644 --- a/packages/prompts/src/log.ts +++ b/packages/prompts/src/log.ts @@ -39,7 +39,7 @@ export const log = { const messageParts = Array.isArray(message) ? message : message.split('\n'); if (messageParts.length > 0) { - const [firstLine, ...lines] = messageParts; + const [firstLine, ...lines] = messageParts as [string, ...string[]]; if (firstLine.length > 0) { parts.push(`${prefix}${firstLine}`); } else { diff --git a/packages/prompts/src/multi-line.ts b/packages/prompts/src/multi-line.ts index d1017b02..b8e4d4ba 100644 --- a/packages/prompts/src/multi-line.ts +++ b/packages/prompts/src/multi-line.ts @@ -47,9 +47,11 @@ export const multiline = (opts: MultiLineOptions) => { const hasGuide = opts?.withGuide ?? settings.withGuide; const titlePrefix = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(this.state)} `; const title = `${titlePrefix}${opts.message}\n`; - const placeholder = opts.placeholder - ? styleText('inverse', opts.placeholder[0]) + styleText('dim', opts.placeholder.slice(1)) - : styleText(['inverse', 'hidden'], '_'); + const placeholder = + opts.placeholder && opts.placeholder.length > 0 + ? // biome-ignore lint/style/noNonNullAssertion: guarded by placeholder.length > 0 + styleText('inverse', opts.placeholder[0]!) + styleText('dim', opts.placeholder.slice(1)) + : styleText(['inverse', 'hidden'], '_'); const userInput = !this.userInput ? placeholder : this.userInputWithCursor; const value = this.value ?? ''; const submitButton = opts.showSubmit diff --git a/packages/prompts/src/select-key.ts b/packages/prompts/src/select-key.ts index cceef81e..6cb0c589 100644 --- a/packages/prompts/src/select-key.ts +++ b/packages/prompts/src/select-key.ts @@ -12,9 +12,12 @@ export interface SelectKeyOptions extends CommonOptions { export const selectKey = (opts: SelectKeyOptions) => { const opt = ( - option: Option, + option: Option | undefined, state: 'inactive' | 'active' | 'selected' | 'cancelled' = 'inactive' ) => { + if (option === undefined) { + return ''; + } const label = option.label ?? String(option.value); if (state === 'selected') { return `${styleText('dim', label)}`; diff --git a/packages/prompts/src/select.ts b/packages/prompts/src/select.ts index d6b5dae1..13f57d2f 100644 --- a/packages/prompts/src/select.ts +++ b/packages/prompts/src/select.ts @@ -95,9 +95,12 @@ const computeLabel = (label: string, format: (text: string) => string) => { export const select = (opts: SelectOptions) => { const opt = ( - option: Option, + option: Option | undefined, state: 'inactive' | 'active' | 'selected' | 'cancelled' | 'disabled' ) => { + if (option === undefined) { + return ''; + } const label = option.label ?? String(option.value); switch (state) { case 'disabled': diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 9b427e3a..618ae427 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -146,7 +146,8 @@ export const spinner = ({ } clearPrevMessage(); _prevMessage = _message; - const frame = styleFn(frames[frameIndex]); + // biome-ignore lint/style/noNonNullAssertion: frameIndex is always kept within frames bounds + const frame = styleFn(frames[frameIndex]!); let outputMessage: string; if (isCI) { diff --git a/packages/prompts/src/task-log.ts b/packages/prompts/src/task-log.ts index e69f2206..0c71bf14 100644 --- a/packages/prompts/src/task-log.ts +++ b/packages/prompts/src/task-log.ts @@ -59,7 +59,7 @@ export const taskLog = (opts: TaskLogOptions) => { output.write(`${secondarySymbol}\n`); } - const buffers: BufferEntry[] = [ + const buffers: [BufferEntry, ...BufferEntry[]] = [ { value: '', full: '', diff --git a/packages/prompts/src/text.ts b/packages/prompts/src/text.ts index ff72b1d5..efcbfffe 100644 --- a/packages/prompts/src/text.ts +++ b/packages/prompts/src/text.ts @@ -68,9 +68,11 @@ export const text = (opts: TextOptions) => { const hasGuide = opts?.withGuide ?? settings.withGuide; const titlePrefix = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(this.state)} `; const title = `${titlePrefix}${opts.message}\n`; - const placeholder = opts.placeholder - ? styleText('inverse', opts.placeholder[0]) + styleText('dim', opts.placeholder.slice(1)) - : styleText(['inverse', 'hidden'], '_'); + const placeholder = + opts.placeholder && opts.placeholder.length > 0 + ? // biome-ignore lint/style/noNonNullAssertion: guarded by placeholder.length > 0 + styleText('inverse', opts.placeholder[0]!) + styleText('dim', opts.placeholder.slice(1)) + : styleText(['inverse', 'hidden'], '_'); const userInput = !this.userInput ? placeholder : this.userInputWithCursor; const value = this.value ?? ''; diff --git a/packages/prompts/test/date.test.ts b/packages/prompts/test/date.test.ts index 59490052..5eaec4ca 100644 --- a/packages/prompts/test/date.test.ts +++ b/packages/prompts/test/date.test.ts @@ -4,7 +4,7 @@ import * as prompts from '../src/index.js'; import { MockReadable, MockWritable } from './test-utils.js'; const d = (iso: string) => { - const [y, m, day] = iso.slice(0, 10).split('-').map(Number); + const [y, m, day] = iso.slice(0, 10).split('-').map(Number) as [number, number, number]; return new Date(Date.UTC(y, m - 1, day)); }; diff --git a/tsconfig.json b/tsconfig.json index 594452b0..f7710dee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "verbatimModuleSyntax": true, "noUnusedParameters": true, "noUnusedLocals": true, + "noUncheckedIndexedAccess": true, "lib": ["ES2022"], "paths": { "@clack/core": ["./packages/core/src/index.ts"],