Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 27 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,26 +164,40 @@ Follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). User-facing onl

### Error Messages

Errors are a UX surface. Every message must let the reader fix the problem without reading the source. Four ingredients, in order:
An error message is UI. The reader should be able to fix the problem from the message alone, without opening your source.

1. **What**: the rule that was violated (the contract, not the symptom)
2. **Where**: the exact flag, file, key, line, or record — never "somewhere in config"
3. **Saw vs. wanted**: the offending value and the allowed shape/set
4. **Fix**: one concrete action to resolve it
Every message needs four ingredients, in order:

- Imperative voice (`pass --limit=50`), not passive (`--limit was wrong`)
- Never say "invalid" without what made it invalid. `Invalid ecosystem: "foo"` is a symptom; `--reach-ecosystems must be one of: npm, pypi, maven (saw: "foo")` is a rule
- If two records collide, name both — not just the second one found
- Suggest, don't auto-correct. An error that silently repairs state hides the bug in the next run
1. **What** — the rule that was broken (e.g. "must be a non-negative integer"), not the fallout ("invalid").
2. **Where** — the exact flag, file, line, key, or record. Not "somewhere in config".
3. **Saw vs. wanted** — the bad value and the allowed shape or set.
4. **Fix** — one concrete action, in imperative voice (`pass --limit=50`, not `--limit was wrong`).

**Examples:**
Length depends on the audience:

- **Library API errors** (thrown from a published package): terse. Callers may match on the message text, so every word counts. All four ingredients often fit in one sentence.
- **CLI / validator / config errors** (developer reading a terminal): verbose. Give each ingredient its own words so the reader can fix it without re-running the tool. Most `InputError`/`AuthError` cases land here.
- **Programmatic errors** (internal assertions, invariant checks): terse, rule only. No end user will see it; short keeps the check readable.

Rules for every message:

- Imperative voice for the fix — `pass --limit=50`, not `--limit was wrong`.
- Never "invalid" on its own. `Invalid ecosystem: "foo"` is fallout; `--reach-ecosystems must be one of: npm, pypi, maven (saw: "foo")` is a rule.
- On a collision, name **both** sides, not just the second one found.
- Suggest, don't auto-correct. Silently fixing state hides the bug next time.
- Bloat check: if removing a word keeps the information, drop it.
- For allowed-set / conflict lists, use `joinAnd` / `joinOr` from `@socketsecurity/lib/arrays` — `must be one of: ${joinOr(allowed)}` reads better than a hand-formatted list.

**CLI examples:**

- ✅ `throw new InputError('--pull-request must be a non-negative integer (saw: "abc"); pass a number like --pull-request=42')`
- ✅ `` throw new InputError(`No .socket directory found in ${cwd}; run \`socket init\` to create one`) ``
- ✅ `throw new AuthError('Socket API rejected the token (401); run `socket login` or set SOCKET_CLI_API_TOKEN')`
- ❌ `throw new InputError('Invalid value for --limit: ${limit}')` (symptom, no rule, no fix)
- ❌ `throw new Error('Authentication failed')` (no where, no fix, wrong error type)
- ❌ `logger.error('Error occurred'); return` (doesn't set exit code)
- ❌ `throw new InputError('Invalid value for --limit: ${limit}')` — fallout, no rule, no fix
- ❌ `throw new Error('Authentication failed')` — no where, no fix, wrong error type
- ❌ `logger.error('Error occurred'); return` — doesn't set exit code

See `docs/references/error-messages.md` for cross-fleet worked examples and anti-patterns.

### Command Pattern

Expand Down
166 changes: 166 additions & 0 deletions docs/references/error-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Error Messages — Worked Examples

Companion to the `## Error Messages` section of `CLAUDE.md`. That section
holds the rules; this file holds longer examples and anti-patterns that
would bloat CLAUDE.md if inlined.

## The four ingredients

Every message needs, in order:

1. **What** — the rule that was broken.
2. **Where** — the exact file, line, key, field, or CLI flag.
3. **Saw vs. wanted** — the bad value and the allowed shape or set.
4. **Fix** — one concrete action, in imperative voice.

## Library API errors (terse)

Callers may match on the message text, so stability matters. Aim for one
sentence.

| ✗ / ✓ | Message | Notes |
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| ✗ | `Error: invalid component` | No rule, no saw, no where. |
| ✗ | `The "name" component of type "npm" failed validation because the provided value "" is empty, which is not allowed because names are required; please provide a non-empty name.` | Restates the rule three times. |
| ✓ | `npm "name" component is required` | Rule + where + implied saw (missing). Six words. |
| ✗ | `Error: bad name` | No rule. |
| ✓ | `name "__proto__" cannot start with an underscore` | Rule, where (`name`), saw (`__proto__`), fix implied. |

## Validator / config / build-tool errors (verbose)

The reader is looking at a file and wants to fix the record without
re-running the tool. Give each ingredient its own words.

✗ `Error: invalid tour config`

✓ `tour.json: part 3 ("Parsing & Normalization") is missing "filename". Add a single-word lowercase filename (e.g. "parsing") to this part — one per part is required to route /<slug>/part/3 at publish time.`

Breakdown:

- **What**: `is missing "filename"` — the rule is "each part has a filename".
- **Where**: `tour.json: part 3 ("Parsing & Normalization")` — file + record + human label.
- **Saw vs. wanted**: saw = missing; wanted = a single-word lowercase filename, with `"parsing"` as a concrete model.
- **Fix**: `Add … to this part` — imperative, specific.

The trailing `to route /<slug>/part/3 at publish time` is optional. Include a _why_ clause only when the rule is non-obvious; skip it for rules the reader already knows (e.g. "names can't start with an underscore").

## Programmatic errors (terse, rule only)

Internal assertions and invariant checks. No end user will read them;
terse keeps the assertion readable when you skim the code.

- ✓ `assert(queue.length > 0)` with message `queue drained before worker exit`
- ✓ `pool size must be positive`
- ✗ `An unexpected error occurred while trying to acquire a connection from the pool because the pool size was not positive.` — nothing a maintainer can act on that the rule itself doesn't already say.

## Common anti-patterns

**"Invalid X" with no rule.**

- ✗ `Invalid filename 'My Part'`
- ✓ `filename 'My Part' must be [a-z]+ (lowercase, no spaces)`

**Passive voice on the fix.**

- ✗ `"filename" was missing`
- ✓ `add "filename" to part 3`

**Naming only one side of a collision.**

- ✗ `duplicate key "foo"` (which record won, which lost?)
- ✓ `duplicate key "foo" in config.json (lines 12 and 47) — rename one`

**Silently auto-correcting.**

- ✗ Stripping a trailing slash from a URL and continuing. The next run will hit the same bug; nothing learned.
- ✓ `url "https://api/" has a trailing slash — remove it`.

**Bloat that restates the rule.**

- ✗ `The value provided for "timeout" is invalid because timeouts must be positive numbers and the value you provided was not a positive number.`
- ✓ `timeout must be a positive number (saw: -5)`

## Formatting lists of values

When the error needs to show an allowed set, a list of conflicting
records, or multiple missing fields, use the list formatters from
`@socketsecurity/lib/arrays` rather than hand-joining with commas:

- `joinAnd(['a', 'b', 'c'])` → `"a, b, and c"` — for conjunctions ("missing foo, bar, and baz")
- `joinOr(['npm', 'pypi', 'maven'])` → `"npm, pypi, or maven"` — for disjunctions ("must be one of: …")

Both wrap `Intl.ListFormat`, so the Oxford comma and one-/two-item cases come out right for free (`joinOr(['a'])` → `"a"`; `joinOr(['a', 'b'])` → `"a or b"`).

- ✗ `--reach-ecosystems must be one of: npm, pypi, maven (saw: "foo")` — hand-joined, breaks if the list has one or two entries.
- ✓ `` `--reach-ecosystems must be one of: ${joinOr(ALLOWED)} (saw: "foo")` ``
- ✗ `missing keys: filename slug title` — no separators, no grammar.
- ✓ `` `missing keys: ${joinAnd(missing)}` `` → `"missing keys: filename, slug, and title"`

Use `joinOr` whenever the error is "must be one of X", `joinAnd` whenever it's "all of X are required / missing / in conflict".

## Working with caught values

`catch (e)` binds `unknown`. The helpers in `@socketsecurity/lib/errors` cover the four patterns that recur everywhere:

```ts
import {
errorMessage,
errorStack,
isError,
isErrnoException,
} from '@socketsecurity/lib/errors'
```

### `isError(value)` — replaces `value instanceof Error`

Cross-realm-safe. Uses the native ES2025 `Error.isError` when the engine ships it, falls back to a spec-compliant shim otherwise. Catches Errors from worker threads, `vm` contexts, and iframes that same-realm `instanceof Error` silently misses.

- ✗ `if (e instanceof Error) { … }`
- ✓ `if (isError(e)) { … }`

### `isErrnoException(value)` — replaces `'code' in err` guards

Narrows to `NodeJS.ErrnoException` (an Error with a string `code` set by libuv/syscalls like `ENOENT`, `EACCES`, `EBUSY`, `EPERM`). Builds on `isError`, so it's also cross-realm-safe, and it checks that `code` is a string — a merely branded Error without a real errno code returns `false`.

- ✗ `if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT') { … }`
- ✓ `if (isErrnoException(e) && e.code === 'ENOENT') { … }`

### `errorMessage(value)` — replaces the `instanceof Error ? e.message : String(e)` pattern

Walks the `cause` chain via `messageWithCauses`, coerces primitives and objects to string, and returns the shared `UNKNOWN_ERROR` sentinel (the string `'Unknown error'`) for `null`, `undefined`, empty strings, `[object Object]`, or Errors with no message.

That last bullet is the important one: **every `|| 'Unknown error'` fallback in the fleet should collapse into a single `errorMessage(e)` call.**

- ✗ `` `Failed: ${e instanceof Error ? e.message : String(e)}` ``
- ✗ `` `Failed: ${(e as Error)?.message ?? 'Unknown error'}` ``
- ✗ `` `Failed: ${e instanceof Error ? e.message : 'Unknown error'}` ``
- ✓ `` `Failed: ${errorMessage(e)}` ``

When you want to preserve the cause chain upstream (recommended), pair it with `{ cause }`:

```ts
try {
await readConfig(path)
} catch (e) {
throw new Error(`Failed to read ${path}: ${errorMessage(e)}`, { cause: e })
}
```

### `errorStack(value)` — cause-aware stack, or `undefined`

Returns the cause-walking stack for Errors; returns `undefined` for non-Errors so logger calls stay safe:

```ts
logger.error(`rebuild failed: ${errorMessage(e)}`, { stack: errorStack(e) })
```

## Voice & tone

- Imperative for the fix: `rename`, `add`, `remove`, `set`.
- Present tense for the rule: `must be`, `cannot`, `is required`.
- No apology ("Sorry, …"), no blame ("You provided …"). State the rule and the fix.
- Don't end with "please"; it doesn't add information and it makes the message feel longer than it is.

## Bloat check

Before shipping a message, cross out any word that, if removed, leaves the information intact. If only rhythm or politeness disappears, drop it.
3 changes: 2 additions & 1 deletion packages/cli/src/cli-entry.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node

// Set global Socket theme for consistent CLI branding.
import { isError } from '@socketsecurity/lib/errors'
import { setTheme } from '@socketsecurity/lib/themes'
setTheme('socket')

Expand Down Expand Up @@ -266,7 +267,7 @@ process.on('unhandledRejection', async (reason, promise) => {
}

// Track CLI error for unhandled rejection.
const error = reason instanceof Error ? reason : new Error(String(reason))
const error = isError(reason) ? reason : new Error(String(reason))
await trackCliError(process.argv, cliStartTime, error, 1)

// Finalize telemetry before exit.
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/analytics/AnalyticsRenderer.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* This is a proof-of-concept implementation for the hybrid Ink/iocraft approach.
*/

import { errorMessage } from '@socketsecurity/lib/errors'
import { getDefaultLogger } from '@socketsecurity/lib/logger'

import { Box, Text, print } from '../../utils/terminal/iocraft.mts'
Expand Down Expand Up @@ -118,7 +119,7 @@ export function displayAnalyticsWithIocraft(data: FormattedData): void {
print(tree)
} catch (e) {
process.exitCode = 1
logger.error('Error rendering analytics:', e instanceof Error ? e.message : String(e))
logger.error('Error rendering analytics:', errorMessage(e))
logger.warn('Falling back to plain text output')
logger.log(`Top 5 Alert Types: ${Object.keys(data.top_five_alert_types).length} types`)
logger.log(`Critical Alerts: ${Object.keys(data.total_critical_alerts).length} dates`)
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/audit-log/AuditLogRenderer.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* Non-interactive renderer for audit log data using iocraft native bindings.
*/

import { errorMessage } from '@socketsecurity/lib/errors'
import { getDefaultLogger } from '@socketsecurity/lib/logger'

import { Box, Text, print } from '../../utils/terminal/iocraft.mts'
Expand Down Expand Up @@ -146,7 +147,7 @@ export function displayAuditLogWithIocraft({
print(tree)
} catch (e) {
process.exitCode = 1
logger.error('Error rendering audit log:', e instanceof Error ? e.message : String(e))
logger.error('Error rendering audit log:', errorMessage(e))
logger.warn('Falling back to plain text output')
logger.log(`Organization: ${orgSlug}`)
logger.log(`Entries: ${results.length}`)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from 'node:fs'
import path from 'node:path'

import { errorMessage } from '@socketsecurity/lib/errors'
import { getDefaultLogger } from '@socketsecurity/lib/logger'
import { spawn } from '@socketsecurity/lib/spawn'
import { getDefaultSpinner } from '@socketsecurity/lib/spinner'
Expand Down Expand Up @@ -128,13 +129,13 @@ export async function convertGradleToMaven({
},
}
} catch (e) {
const errorMessage =
const summary =
'There was an unexpected error while generating manifests' +
(verbose ? '' : ' (use --verbose for details)')

if (isTextMode) {
process.exitCode = 1
logger.fail(errorMessage)
logger.fail(summary)
if (verbose) {
logger.group('[VERBOSE] error:')
logger.log(e)
Expand All @@ -144,8 +145,8 @@ export async function convertGradleToMaven({

return {
ok: false,
message: errorMessage,
cause: e instanceof Error ? e.message : String(e),
message: summary,
cause: errorMessage(e),
}
}
}
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/commands/manifest/convert-sbt-to-maven.mts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { errorMessage } from '@socketsecurity/lib/errors'
import { safeReadFile } from '@socketsecurity/lib/fs'
import { getDefaultLogger } from '@socketsecurity/lib/logger'
import { spawn } from '@socketsecurity/lib/spawn'
Expand Down Expand Up @@ -134,14 +135,14 @@ export async function convertSbtToMaven({
},
}
} catch (e) {
const errorMessage =
const summary =
'There was an unexpected error while running this' +
(verbose ? '' : ' (use --verbose for details)')

if (isTextMode) {
process.exitCode = 1
spinner?.stop()
logger.fail(errorMessage)
logger.fail(summary)
if (verbose) {
logger.group('[VERBOSE] error:')
logger.log(e)
Expand All @@ -151,8 +152,8 @@ export async function convertSbtToMaven({

return {
ok: false,
message: errorMessage,
cause: e instanceof Error ? e.message : String(e),
message: summary,
cause: errorMessage(e),
}
}
}
3 changes: 2 additions & 1 deletion packages/cli/src/commands/threat-feed/ThreatFeedRenderer.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* Non-interactive renderer for threat feed data using iocraft native bindings.
*/

import { errorMessage } from '@socketsecurity/lib/errors'
import { getDefaultLogger } from '@socketsecurity/lib/logger'

import { Box, Text, print } from '../../utils/terminal/iocraft.mts'
Expand Down Expand Up @@ -225,7 +226,7 @@ export function displayThreatFeedWithIocraft({
print(tree)
} catch (e) {
process.exitCode = 1
logger.error('Error rendering threat feed:', e instanceof Error ? e.message : String(e))
logger.error('Error rendering threat feed:', errorMessage(e))
logger.warn('Falling back to plain text output')
logger.log(`Total threats: ${results.length}`)
results.slice(0, 10).forEach((threat, i) => {
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/utils/basics/spawn.mts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import path from 'node:path'

import { debug } from '@socketsecurity/lib/debug'
import { normalizePath } from '@socketsecurity/lib/paths/normalize'
import { errorMessage } from '@socketsecurity/lib/errors'
import { spawn } from '@socketsecurity/lib/spawn'

import { WIN32 } from '@socketsecurity/lib/constants/platform'
Expand Down Expand Up @@ -433,7 +434,7 @@ async function parseSocketFacts(factsPath: string): Promise<{
} catch (parseError) {
debug('error', 'Failed to parse socket facts JSON:', parseError)
return {
error: `Invalid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
error: `Invalid JSON: ${errorMessage(parseError)}`,
}
}

Expand All @@ -447,7 +448,7 @@ async function parseSocketFacts(factsPath: string): Promise<{
} catch (e) {
debug('error', 'Failed to read socket facts file:', e)
return {
error: `File read error: ${e instanceof Error ? e.message : String(e)}`,
error: `File read error: ${errorMessage(e)}`,
}
}
}
Loading