Repository: stoplightio/spectral (ts)

Issue:
Improve logging output from Nimma when a custom function errors
**User story.**

As a user of Spectral
When I write a custom function that is erroneous 
I want to see more verbose logging than "Error running Nimma"
That helps me diagnose the problem with my code

**Is your feature request related to a problem?**

I've across to v6 of Spectral and gone about rewriting my custom functions in the revised style. In doing so I (naturally) wrote some poor code that was incorrect.

However, Spectral told me virtually nothing about my mistake other than telling me "Error running Nimma". After poking around in the Nimma package I found that the errors are correctly collected, but not echoed out to the console in any useful way. I added ```console.error(__filename, this.errors)``` in a couple of places and Bob's your uncle and Fanny is your mother's sister I had the logging I need.

**Describe the solution you'd like**

I'm sure these logs are available to be echoed out so some means to see them when they happen would be very useful

**Additional context**

Given this clearly broken function:

```javascript
import { createRulesetFunction } from '@stoplight/spectral-core';

export default createRulesetFunction({
  input: null,
  options: {
    type: 'object',
    additionalProperties: false,
    properties: {
      value: true,
    },
    required: ['value'],
  },
}, (input, options) => {
  const { value } = options;

  const results = input.filter((server) => server.url.brokenFunction);

  if (results.length !== 0) {
    return [
      {
        message: `This is just for debug`,
      },
    ];
  }
});
```

And this here ruleset:

```yaml
extends: spectral:oas
functions: [empty]
rules:
  operation-operationId: off
  oas3-api-servers: off
  info-contact: off
  test-empty:
    message: This is broken
    severity: error
    given: "$.servers"
    then:
      function: empty
      functionOptions:
        value: "abc"
```

I get this result even with the `--verbose` flag on:

```bash
➜  spectral-ruleset git:(develop) ✗ ./node_modules/.bin/spectral lint --ruleset rulesets/debug.yaml test/broken-build.yaml.txt --verbose
Found 51 rules (37 enabled)
Linting /Users/chris/Documents/git/github/spectral-ruleset/test/broken-build.yaml.txt
Error running Nimma
➜  spectral-ruleset git:(develop) ✗
```

Pull request description:
feat(cli): improve error logging
Closes #2053

**Checklist**

- [x] Tests added / updated
- [ ] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No




Patch:
diff --git a/.eslintrc b/.eslintrc
index d0c1760d..95efccda 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -84,10 +84,12 @@
 
     {
       "files": [
-        "src/**/__tests__/**/*.jest.test.{ts,js}"
+        "src/**/__tests__/**/*.jest.test.{ts,js}",
+        "__mocks__/**/*.{ts,js}"
       ],
       "env": {
-        "jest": true
+        "jest": true,
+        "node": true
       }
     },
 
diff --git a/__karma__/process.js b/__karma__/process.js
deleted file mode 100644
index 98594c82..00000000
--- a/__karma__/process.js
+++ /dev/null
@@ -1,1 +0,0 @@
-export const on = Function();
diff --git a/__mocks__/nanoid/non-secure.ts b/__mocks__/nanoid/non-secure.ts
deleted file mode 100644
index 3c280b83..00000000
--- a/__mocks__/nanoid/non-secure.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-let seed = 0;
-
-beforeEach(() => {
-  seed = 0;
-});
-
-module.exports = jest.fn(() => `random-id-${seed++}`);
diff --git a/__mocks__/process.js b/__mocks__/process.js
new file mode 100644
index 00000000..85548b6b
--- /dev/null
+++ b/__mocks__/process.js
@@ -0,0 +1,15 @@
+module.exports.exit = jest.fn();
+module.exports.cwd = jest.fn();
+module.exports.on = jest.fn();
+
+module.exports.stdin = {
+  fd: 0,
+  isTTY: true,
+};
+module.exports.stdout = {
+  write: jest.fn(),
+};
+
+module.exports.stderr = {
+  write: jest.fn(),
+};
diff --git a/karma.conf.ts b/karma.conf.ts
index 2581b4bb..bc18c36e 100644
--- a/karma.conf.ts
+++ b/karma.conf.ts
@@ -40,7 +40,7 @@ module.exports = (config: Config): void => {
             'nimma/legacy': require.resolve('./node_modules/nimma/dist/legacy/cjs/index.js'),
             'node-fetch': require.resolve('./__karma__/fetch'),
             fs: require.resolve('./__karma__/fs'),
-            process: require.resolve('./__karma__/process'),
+            process: require.resolve('./__mocks__/process'),
             perf_hooks: require.resolve('./__karma__/perf_hooks'),
             fsevents: require.resolve('./__karma__/fsevents'),
           },
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 5ea1046e..7e3a7d92 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -51,15 +51,18 @@
     "lodash": "~4.17.21",
     "pony-cause": "^1.0.0",
     "proxy-agent": "5.0.0",
+    "stacktracey": "^2.1.7",
     "strip-ansi": "6.0",
     "text-table": "0.2",
     "tslib": "^2.3.0",
     "yargs": "17.3.1"
   },
   "devDependencies": {
+    "@types/es-aggregate-error": "^1.0.2",
     "@types/xml2js": "^0.4.9",
     "@types/yargs": "^17.0.8",
     "copyfiles": "^2.4.1",
+    "es-aggregate-error": "^1.0.7",
     "jest-when": "^3.4.2",
     "nock": "^13.1.3",
     "node-html-parser": "^4.1.5",
@@ -73,7 +76,9 @@
     ],
     "assets": [
       "./dist/**/*.json",
-      "./dist/**/*.html"
+      "./dist/**/*.html",
+      "../*/dist/**/*.js.map",
+      "../*/src/**/*.ts"
     ]
   }
 }
diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts
index 33f2cb53..094991c1 100644
--- a/packages/cli/src/commands/lint.ts
+++ b/packages/cli/src/commands/lint.ts
@@ -1,14 +1,20 @@
 import { Dictionary } from '@stoplight/types';
 import { isPlainObject } from '@stoplight/json';
-import { difference, noop, pick } from 'lodash';
-import { ReadStream } from 'tty';
-import type { CommandModule } from 'yargs';
 import { getDiagnosticSeverity, IRuleResult } from '@stoplight/spectral-core';
+import { isError, difference, pick } from 'lodash';
+import type { ReadStream } from 'tty';
+import type { CommandModule } from 'yargs';
+import * as process from 'process';
+import chalk from 'chalk';
+import type { ErrorWithCause } from 'pony-cause';
+import StackTracey from 'stacktracey';
 
 import { lint } from '../services/linter';
 import { formatOutput, writeOutput } from '../services/output';
 import { FailSeverity, ILintConfig, OutputFormat } from '../services/config';
 
+import { CLIError } from '../errors';
+
 const formatOptions = Object.values(OutputFormat);
 
 const lintCommand: CommandModule = {
@@ -55,7 +61,7 @@ const lintCommand: CommandModule = {
       })
       .check((argv: Dictionary<unknown>) => {
         if (!Array.isArray(argv.documents) || argv.documents.length === 0) {
-          throw new TypeError('No documents provided.');
+          throw new CLIError('No documents provided.');
         }
 
         const format = argv.format as string[] & { 0: string };
@@ -66,21 +72,21 @@ const lintCommand: CommandModule = {
             return true;
           }
 
-          throw new TypeError('Output must be either string or unspecified when a single format is specified');
+          throw new CLIError('Output must be either string or unspecified when a single format is specified');
         }
 
         if (!isPlainObject(output)) {
-          throw new TypeError('Multiple outputs have to be provided when more than a single format is specified');
+          throw new CLIError('Multiple outputs have to be provided when more than a single format is specified');
         }
 
         const keys = Object.keys(output);
         if (format.length !== keys.length) {
-          throw new TypeError('The number of outputs must match the number of formats');
+          throw new CLIError('The number of outputs must match the number of formats');
         }
 
         const diff = difference(format, keys);
         if (diff.length !== 0) {
-          throw new TypeError(`Missing outputs for the following formats: ${diff.join(', ')}`);
+          throw new CLIError(`Missing outputs for the following formats: ${diff.join(', ')}`);
         }
 
         return true;
@@ -156,7 +162,7 @@ const lintCommand: CommandModule = {
         },
       }),
 
-  handler: args => {
+  async handler(args) {
     const {
       documents,
       failSeverity,
@@ -175,45 +181,90 @@ const lintCommand: CommandModule = {
       displayOnlyFailures: boolean;
     };
 
-    return lint(documents, {
-      format,
-      output,
-      encoding,
-      ignoreUnknownFormat,
-      failOnUnmatchedGlobs,
-      ruleset,
-      stdinFilepath,
-      ...pick<Partial<ILintConfig>, keyof ILintConfig>(config, ['verbose', 'quiet', 'resolver']),
-    })
-      .then(results => {
-        if (displayOnlyFailures) {
-          return filterResultsBySeverity(results, failSeverity);
-        }
-        return results;
-      })
-      .then(results => {
-        if (results.length > 0) {
-          process.exitCode = severeEnoughToFail(results, failSeverity) ? 1 : 0;
-        } else if (config.quiet !== true) {
-          console.log(`No results with a severity of '${failSeverity}' or higher found!`);
-        }
+    try {
+      let results = await lint(documents, {
+        format,
+        output,
+        encoding,
+        ignoreUnknownFormat,
+        failOnUnmatchedGlobs,
+        ruleset,
+        stdinFilepath,
+        ...pick<Partial<ILintConfig>, keyof ILintConfig>(config, ['verbose', 'quiet', 'resolver']),
+      });
 
-        return Promise.all(
-          format.map(f => {
-            const formattedOutput = formatOutput(results, f, { failSeverity: getDiagnosticSeverity(failSeverity) });
-            return writeOutput(formattedOutput, output?.[f] ?? '<stdout>');
-          }),
-        ).then(noop);
-      })
-      .catch(fail);
+      if (displayOnlyFailures) {
+        results = filterResultsBySeverity(results, failSeverity);
+      }
+
+      await Promise.all(
+        format.map(f => {
+          const formattedOutput = formatOutput(results, f, { failSeverity: getDiagnosticSeverity(failSeverity) });
+          return writeOutput(formattedOutput, output?.[f] ?? '<stdout>');
+        }),
+      );
+
+      if (results.length > 0) {
+        process.exit(severeEnoughToFail(results, failSeverity) ? 1 : 0);
+      } else if (config.quiet !== true) {
+        process.stdout.write(`No results with a severity of '${failSeverity}' or higher found!`);
+      }
+    } catch (ex) {
+      fail(isError(ex) ? ex : new Error(String(ex)), config.verbose === true);
+    }
   },
 };
 
-const fail = ({ message }: Error): void => {
-  console.error(message);
-  process.exitCode = 2;
+const fail = (error: Error | ErrorWithCause<unknown> | AggregateError, verbose: boolean): void => {
+  if (error instanceof CLIError) {
+    process.stderr.write(chalk.red(error.message));
+    process.exit(2);
+  }
+
+  const errors: unknown[] = 'errors' in error ? error.errors : [error];
+
+  process.stderr.write(chalk.red('Error running Spectral!\n'));
+
+  if (!verbose) {
+    process.stderr.write(chalk.red('Use --verbose flag to print the error stack.\n'));
+  }
+
+  for (const [i, error] of errors.entries()) {
+    const actualError: unknown = isError(error) && 'cause' in error ? (error as ErrorWithCause<unknown>).cause : error;
+    const message = isError(actualError) ? actualError.message : String(actualError);
+
+    const info = `Error #${i + 1}: `;
+
+    process.stderr.write(`${info}${chalk.red(message)}\n`);
+
+    if (verbose && isError(actualError)) {
+      process.stderr.write(`${chalk.red(printErrorStacks(actualError, info.length))}\n`);
+    }
+  }
+
+  process.exit(2);
 };
 
+function getWidth(ratio: number): number {
+  return Math.min(20, Math.floor(ratio * process.stderr.columns));
+}
+
+function printErrorStacks(error: Error, padding: number): string {
+  return new StackTracey(error)
+    .slice(0, 5)
+    .withSources()
+    .asTable({
+      maxColumnWidths: {
+        callee: getWidth(0.2),
+        file: getWidth(0.4),
+        sourceLine: getWidth(0.4),
+      },
+    })
+    .split('\n')
+    .map(error => `${' '.repeat(padding)}${error}`)
+    .join('\n');
+}
+
 const filterResultsBySeverity = (results: IRuleResult[], failSeverity: FailSeverity): IRuleResult[] => {
   const diagnosticSeverity = getDiagnosticSeverity(failSeverity);
   return results.filter(r => r.severity <= diagnosticSeverity);
diff --git a/packages/cli/src/errors/index.ts b/packages/cli/src/errors/index.ts
new file mode 100644
index 00000000..a993194e
--- /dev/null
+++ b/packages/cli/src/errors/index.ts
@@ -0,0 +1,1 @@
+export class CLIError extends Error {}
diff --git a/packages/cli/src/services/linter/linter.ts b/packages/cli/src/services/linter/linter.ts
index c02e4ce4..7796b81b 100644
--- a/packages/cli/src/services/linter/linter.ts
+++ b/packages/cli/src/services/linter/linter.ts
@@ -5,6 +5,7 @@ import * as Parsers from '@stoplight/spectral-parsers';
 import { getRuleset, listFiles, segregateEntriesPerKind, readFileDescriptor } from './utils';
 import { getResolver } from './utils/getResolver';
 import { ILintConfig } from '../config';
+import { CLIError } from '../../errors';
 
 export async function lint(documents: Array<number | string>, flags: ILintConfig): Promise<IRuleResult[]> {
   const spectral = new Spectral({
@@ -25,7 +26,7 @@ export async function lint(documents: Array<number | string>, flags: ILintConfig
 
   if (unmatchedPatterns.length > 0) {
     if (flags.failOnUnmatchedGlobs) {
-      throw new Error(`Unmatched glob patterns: \`${unmatchedPatterns.join(',')}\``);
+      throw new CLIError(`Unmatched glob patterns: \`${unmatchedPatterns.join(',')}\``);
     }
 
     for (const unmatchedPattern of unmatchedPatterns) {
diff --git a/packages/cli/src/services/linter/utils/getResolver.ts b/packages/cli/src/services/linter/utils/getResolver.ts
index dee98fd3..db63d5a9 100644
--- a/packages/cli/src/services/linter/utils/getResolver.ts
+++ b/packages/cli/src/services/linter/utils/getResolver.ts
@@ -2,6 +2,7 @@ import { isAbsolute, join } from '@stoplight/path';
 import { Optional } from '@stoplight/types';
 import { createHttpAndFileResolver, Resolver } from '@stoplight/spectral-ref-resolver';
 import { isError } from 'lodash';
+import { CLIError } from '../../../errors';
 
 export const getResolver = (resolver: Optional<string>, proxy: Optional<string>): Resolver => {
   if (resolver !== void 0) {
@@ -9,7 +10,7 @@ export const getResolver = (resolver: Optional<string>, proxy: Optional<string>)
       // eslint-disable-next-line @typescript-eslint/no-unsafe-return
       return require(isAbsolute(resolver) ? resolver : join(process.cwd(), resolver));
     } catch (ex) {
-      throw new Error(isError(ex) ? formatMessage(ex.message) : String(ex));
+      throw new CLIError(isError(ex) ? formatMessage(ex.message) : String(ex));
     }
   }
 
diff --git a/packages/cli/src/services/linter/utils/getRuleset.ts b/packages/cli/src/services/linter/utils/getRuleset.ts
index c16ea733..f2af0b7c 100644
--- a/packages/cli/src/services/linter/utils/getRuleset.ts
+++ b/packages/cli/src/services/linter/utils/getRuleset.ts
@@ -12,7 +12,7 @@ import { stdin } from '@stoplight/spectral-ruleset-bundler/plugins/stdin';
 import { builtins } from '@stoplight/spectral-ruleset-bundler/plugins/builtins';
 import { isError, isObject } from 'lodash';
 import commonjs from '@rollup/plugin-commonjs';
-import { ErrorWithCause } from 'pony-cause';
+import { CLIError } from '../../../errors';
 
 async function getDefaultRulesetFile(): Promise<Optional<string>> {
   const cwd = process.cwd();
@@ -41,7 +41,7 @@ export async function getRuleset(rulesetFile: Optional<string>): Promise<Ruleset
   }
 
   if (rulesetFile === void 0) {
-    throw new Error(
+    throw new CLIError(
       'No ruleset has been found. Please provide a ruleset using the --ruleset CLI argument, or make sure your ruleset file matches .?spectral.(js|ya?ml|json)',
     );
   }
@@ -74,7 +74,7 @@ export async function getRuleset(rulesetFile: Optional<string>): Promise<Ruleset
       throw e;
     }
 
-    throw new ErrorWithCause(`Could not read ruleset at ${rulesetFile}.`, { cause: e });
+    throw new CLIError(`Could not read ruleset at ${rulesetFile}.`);
   }
 
   return new Ruleset(load(ruleset, rulesetFile), {
@@ -99,7 +99,7 @@ function load(source: string, uri: string): RulesetDefinition {
   Function('module, require', source)(m, _require);
 
   if (!isObject(m.exports)) {
-    throw Error('No valid export found');
+    throw new CLIError('No valid export found');
   }
 
   return m.exports;
diff --git a/packages/core/package.json b/packages/core/package.json
index 2677adc3..50e1195e 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -40,6 +40,7 @@
     "lodash.topath": "^4.5.2",
     "minimatch": "3.0.4",
     "nimma": "0.1.8",
+    "pony-cause": "^1.0.0",
     "simple-eval": "1.0.0",
     "tslib": "^2.3.0"
   },
diff --git a/packages/core/src/runner/lintNode.ts b/packages/core/src/runner/lintNode.ts
index f8cd3806..6059f597 100644
--- a/packages/core/src/runner/lintNode.ts
+++ b/packages/core/src/runner/lintNode.ts
@@ -1,6 +1,7 @@
 import { JsonPath } from '@stoplight/types';
 import { decodeSegmentFragment, getClosestJsonPath, printPath, PrintStyle } from '@stoplight/spectral-runtime';
-import { get } from 'lodash';
+import { get, isError } from 'lodash';
+import { ErrorWithCause } from 'pony-cause';
 
 import { Document } from '../document';
 import { IFunctionResult, IGivenNode, RulesetFunctionContext } from '../types';
@@ -24,10 +25,20 @@ export const lintNode = (context: IRunnerInternalContext, node: IGivenNode, rule
     for (const target of targets) {
       const path = target.path.length > 0 ? [...givenPath, ...target.path] : givenPath;
 
-      const targetResults = then.function(target.value, then.functionOptions ?? null, {
-        ...fnContext,
-        path,
-      });
+      let targetResults;
+      try {
+        targetResults = then.function(target.value, then.functionOptions ?? null, {
+          ...fnContext,
+          path,
+        });
+      } catch (e) {
+        throw new ErrorWithCause(
+          `Function "${then.function.name}" threw an exception${isError(e) ? `: ${e.message}` : ''}`,
+          {
+            cause: e,
+          },
+        );
+      }
 
       if (targetResults === void 0) continue;
 
diff --git a/tsconfig.json b/tsconfig.json
index f57c5fa8..b260097c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -22,7 +22,7 @@
     "target": "ES2019",
     "module": "CommonJS",
     "esModuleInterop": true,
-    "lib": ["es2020"],
+    "lib": ["es2021"],
     "strict": true,
     "pretty": true,
     "experimentalDecorators": true,
diff --git a/yarn.lock b/yarn.lock
index 815b0186..c809499b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2349,12 +2349,14 @@ __metadata:
     "@stoplight/spectral-rulesets": ">=1"
     "@stoplight/spectral-runtime": ^1.1.0
     "@stoplight/types": 12.3.0
+    "@types/es-aggregate-error": ^1.0.2
     "@types/xml2js": ^0.4.9
     "@types/yargs": ^17.0.8
     chalk: 4.1.2
     cliui: 7.0.4
     copyfiles: ^2.4.1
     eol: 0.9.1
+    es-aggregate-error: ^1.0.7
     fast-glob: 3.2.7
     jest-when: ^3.4.2
     lodash: ~4.17.21
@@ -2363,6 +2365,7 @@ __metadata:
     pkg: ^5.4.1
     pony-cause: ^1.0.0
     proxy-agent: 5.0.0
+    stacktracey: ^2.1.7
     strip-ansi: 6.0
     text-table: 0.2
     tslib: ^2.3.0
@@ -2402,6 +2405,7 @@ __metadata:
     minimatch: 3.0.4
     nimma: 0.1.8
     nock: ^13.1.0
+    pony-cause: ^1.0.0
     simple-eval: 1.0.0
     treeify: ^1.1.0
     tslib: ^2.3.0
@@ -2688,6 +2692,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/es-aggregate-error@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "@types/es-aggregate-error@npm:1.0.2"
+  dependencies:
+    "@types/node": "*"
+  checksum: 076fd59b595f33c8c7e7eb68ec55bd43cf8b2cf7bbc6778e25d7ae1a5fa0538a0a56f149015f403d7bbcefe59f1d8182351685b59c1fe719fd46d0dd8a9737fa
+  languageName: node
+  linkType: hard
+
 "@types/estree@npm:*":
   version: 0.0.50
   resolution: "@types/estree@npm:0.0.50"
@@ -3118,7 +3131,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1":
+"acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1, acorn-walk@npm:^8.2.0":
   version: 8.2.0
   resolution: "acorn-walk@npm:8.2.0"
   checksum: 1715e76c01dd7b2d4ca472f9c58968516a4899378a63ad5b6c2d668bba8da21a71976c14ec5f5b75f887b6317c4ae0b897ab141c831d741dc76024d8745f1ad1
@@ -3143,6 +3156,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"acorn@npm:^8.7.0":
+  version: 8.7.0
+  resolution: "acorn@npm:8.7.0"
+  bin:
+    acorn: bin/acorn
+  checksum: e0f79409d68923fbf1aa6d4166f3eedc47955320d25c89a20cc822e6ba7c48c5963d5bc657bc242d68f7a4ac9faf96eef033e8f73656da6c640d4219935fdfd0
+  languageName: node
+  linkType: hard
+
 "agent-base@npm:6, agent-base@npm:^6.0.0, agent-base@npm:^6.0.2":
   version: 6.0.2
   resolution: "agent-base@npm:6.0.2"
@@ -3434,6 +3456,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"as-table@npm:^1.0.36":
+  version: 1.0.55
+  resolution: "as-table@npm:1.0.55"
+  dependencies:
+    printable-characters: ^1.0.42
+  checksum: 341c99d9e99a702c315b3f0744d49b4764b26ef7ddd32bafb9e1706626560c0e599100521fc1b17f640e804bd0503ce70b2ba519c023da6edf06bdd9086dccd9
+  languageName: node
+  linkType: hard
+
 "asap@npm:^2.0.0":
   version: 2.0.6
   resolution: "asap@npm:2.0.6"
@@ -4726,6 +4757,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"data-uri-to-buffer@npm:^2.0.0":
+  version: 2.0.2
+  resolution: "data-uri-to-buffer@npm:2.0.2"
+  checksum: 152bec5e77513ee253a7c686700a1723246f582dad8b614e8eaaaba7fa45a15c8671ae4b8f4843f4f3a002dae1d3e7a20f852f7d7bdc8b4c15cfe7adfdfb07f8
+  languageName: node
+  linkType: hard
+
 "data-urls@npm:^2.0.0":
   version: 2.0.0
   resolution: "data-urls@npm:2.0.0"
@@ -4875,14 +4913,14 @@ __metadata:
   linkType: hard
 
 "degenerator@npm:^3.0.1":
-  version: 3.0.1
-  resolution: "degenerator@npm:3.0.1"
+  version: 3.0.2
+  resolution: "degenerator@npm:3.0.2"
   dependencies:
     ast-types: ^0.13.2
     escodegen: ^1.8.1
     esprima: ^4.0.0
-    vm2: ^3.9.3
-  checksum: 110d5fa772933d21484995e518feeb2ea54e5804421edf8546900973a227dcdf621a0cbac0a5d0a13273424ea3763aba815246dfffa386483f5480d60f50bed1
+    vm2: ^3.9.8
+  checksum: 6a8fffe1ddde692931a1d74c0636d9e6963f2aa16748d4b95f4833cdcbe8df571e5c127e4f1d625a4c340cc60f5a969ac9e5aa14baecfb6f69b85638e180cd97
   languageName: node
   linkType: hard
 
@@ -5307,6 +5345,20 @@ __metadata:
   languageName: node
   linkType: hard
 
+"es-aggregate-error@npm:^1.0.7":
+  version: 1.0.7
+  resolution: "es-aggregate-error@npm:1.0.7"
+  dependencies:
+    define-properties: ^1.1.3
+    es-abstract: ^1.19.0
+    function-bind: ^1.1.1
+    functions-have-names: ^1.2.2
+    get-intrinsic: ^1.1.1
+    globalthis: ^1.0.2
+  checksum: 16b89fefdf56c0478cd21577249156cf83e44c2220c057cbfddd99c01e15e03d6d90a85ce73dece4728a5bfcb022dc160e04a66b1f83a620f140842c6f8325f9
+  languageName: node
+  linkType: hard
+
 "es-to-primitive@npm:^1.2.1":
   version: 1.2.1
   resolution: "es-to-primitive@npm:1.2.1"
@@ -6211,6 +6263,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"functions-have-names@npm:^1.2.2":
+  version: 1.2.2
+  resolution: "functions-have-names@npm:1.2.2"
+  checksum: 25f44b6d1c41ac86ffdf41f25d1de81c0a5b4a3fcf4307a33cdfb23b9d4bd5d0d8bf312eaef5ad368c6500c8a9e19f692b8ce9f96aaab99db9dd936554165558
+  languageName: node
+  linkType: hard
+
 "gauge@npm:^4.0.0":
   version: 4.0.0
   resolution: "gauge@npm:4.0.0"
@@ -6276,6 +6335,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"get-source@npm:^2.0.12":
+  version: 2.0.12
+  resolution: "get-source@npm:2.0.12"
+  dependencies:
+    data-uri-to-buffer: ^2.0.0
+    source-map: ^0.6.1
+  checksum: c73368fee709594ba38682ec1a96872aac6f7d766396019611d3d2358b68186a7847765a773ea0db088c42781126cc6bc09e4b87f263951c74dae5dcea50ad42
+  languageName: node
+  linkType: hard
+
 "get-stdin@npm:^8.0.0":
   version: 8.0.0
   resolution: "get-stdin@npm:8.0.0"
@@ -6423,6 +6492,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"globalthis@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "globalthis@npm:1.0.2"
+  dependencies:
+    define-properties: ^1.1.3
+  checksum: 5a5f3c7ab94708260a98106b35946b74bb57f6b2013e39668dc9e8770b80a3418103b63a2b4aa01c31af15fdf6a2940398ffc0a408573c34c2304f928895adff
+  languageName: node
+  linkType: hard
+
 "globby@npm:^11.0.0, globby@npm:^11.0.1, globby@npm:^11.0.3":
   version: 11.1.0
   resolution: "globby@npm:11.1.0"
@@ -10420,6 +10498,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"printable-characters@npm:^1.0.42":
+  version: 1.0.42
+  resolution: "printable-characters@npm:1.0.42"
+  checksum: 2724aa02919d7085933af0f8f904bd0de67a6b53834f2e5b98fc7aa3650e20755c805e8c85bcf96c09f678cb16a58b55640dd3a2da843195fce06b1ccb0c8ce4
+  languageName: node
+  linkType: hard
+
 "proc-log@npm:^1.0.0":
   version: 1.0.0
   resolution: "proc-log@npm:1.0.0"
@@ -11712,6 +11797,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"stacktracey@npm:^2.1.7":
+  version: 2.1.8
+  resolution: "stacktracey@npm:2.1.8"
+  dependencies:
+    as-table: ^1.0.36
+    get-source: ^2.0.12
+  checksum: abd8316b4e120884108f5a47b2f61abdcaeaa118afd95f3c48317cb057fff43d697450ba00de3f9fe7fee61ee72644ccda4db990a8e4553706644f7c17522eab
+  languageName: node
+  linkType: hard
+
 "statuses@npm:>= 1.5.0 < 2, statuses@npm:~1.5.0":
   version: 1.5.0
   resolution: "statuses@npm:1.5.0"
@@ -12796,12 +12891,15 @@ __metadata:
   languageName: node
   linkType: hard
 
-"vm2@npm:^3.9.3":
-  version: 3.9.5
-  resolution: "vm2@npm:3.9.5"
+"vm2@npm:^3.9.8":
+  version: 3.9.8
+  resolution: "vm2@npm:3.9.8"
+  dependencies:
+    acorn: ^8.7.0
+    acorn-walk: ^8.2.0
   bin:
     vm2: bin/vm2
-  checksum: d83dbe929ca4d1c9fca71cda34a5aee9a6b4bdc1de1ddb11777c4f6e1e48a471764195258dbf608f962df1a1c3d6ae917c9755f11a8f37b9e0bbf691313a725c
+  checksum: 1e665a45ce76612922368462a8b98876698e866c1f201393b0b646f07a00449dc4170e987152cf1443af664ca8f2b82bb52a0760456a71912fa63f22980de7b4
   languageName: node
   linkType: hard
 


Test patch:
diff --git a/packages/cli/src/commands/__tests__/lint.test.ts b/packages/cli/src/commands/__tests__/lint.test.ts
index 40f1aac6..eb98e705 100644
--- a/packages/cli/src/commands/__tests__/lint.test.ts
+++ b/packages/cli/src/commands/__tests__/lint.test.ts
@@ -1,12 +1,16 @@
 import * as yargs from 'yargs';
-import { noop } from 'lodash';
 import { DiagnosticSeverity } from '@stoplight/types';
 import { IRuleResult } from '@stoplight/spectral-core';
+import * as process from 'process';
+import { ErrorWithCause } from 'pony-cause';
+import AggregateError from 'es-aggregate-error';
 
 import { lint } from '../../services/linter';
 import { formatOutput, writeOutput } from '../../services/output';
 import lintCommand from '../lint';
+import chalk from 'chalk';
 
+jest.mock('process');
 jest.mock('../../services/output');
 jest.mock('../../services/linter');
 
@@ -24,9 +28,6 @@ function run(command: string) {
 }
 
 describe('lint', () => {
-  let errorSpy: jest.SpyInstance;
-  const { isTTY } = process.stdin;
-
   const results: IRuleResult[] = [
     {
       code: 'parser',
@@ -47,29 +48,26 @@ describe('lint', () => {
   ];
 
   beforeEach(() => {
-    (lint as jest.Mock).mockClear();
     (lint as jest.Mock).mockResolvedValueOnce(results);
-
-    (formatOutput as jest.Mock).mockClear();
     (formatOutput as jest.Mock).mockReturnValueOnce('<formatted output>');
-
-    (writeOutput as jest.Mock).mockClear();
     (writeOutput as jest.Mock).mockResolvedValueOnce(undefined);
-
-    errorSpy = jest.spyOn(console, 'error').mockImplementation(noop);
   });
 
   afterEach(() => {
-    errorSpy.mockRestore();
-    process.stdin.isTTY = isTTY;
+    process.stdin.isTTY = true;
+    jest.clearAllMocks();
+    jest.resetAllMocks();
   });
 
   it('shows help when no document and no STDIN are present', () => {
-    process.stdin.isTTY = true;
     return expect(run('lint')).rejects.toContain('documents  Location of JSON/YAML documents');
   });
 
   describe('when STDIN is present', () => {
+    beforeEach(() => {
+      process.stdin.isTTY = false;
+    });
+
     it('does not show help when documents are missing', async () => {
       const output = await run('lint');
       expect(output).not.toContain('documents  Location of JSON/YAML documents');
@@ -150,35 +148,29 @@ describe('lint', () => {
 
   it.each(['json', 'stylish'])('calls formatOutput with %s format', async format => {
     await run(`lint -f ${format} ./__fixtures__/empty-oas2-document.json`);
-    await new Promise(resolve => void process.nextTick(resolve));
     expect(formatOutput).toBeCalledWith(results, format, { failSeverity: DiagnosticSeverity.Error });
   });
 
   it('writes formatted output to a file', async () => {
     await run(`lint -o foo.json ./__fixtures__/empty-oas2-document.json`);
-    await new Promise(resolve => void process.nextTick(resolve));
     expect(writeOutput).toBeCalledWith('<formatted output>', 'foo.json');
   });
 
   it('writes formatted output to multiple files when using format and output flags', async () => {
-    (formatOutput as jest.Mock).mockClear();
     (formatOutput as jest.Mock).mockReturnValue('<formatted output>');
 
     await run(
       `lint --format html --format json --output.json foo.json --output.html foo.html ./__fixtures__/empty-oas2-document.json`,
     );
-    await new Promise(resolve => void process.nextTick(resolve));
     expect(writeOutput).toBeCalledTimes(2);
     expect(writeOutput).nthCalledWith(1, '<formatted output>', 'foo.html');
     expect(writeOutput).nthCalledWith(2, '<formatted output>', 'foo.json');
   });
 
   it('writes formatted output to multiple files and stdout when using format and output flags', async () => {
-    (formatOutput as jest.Mock).mockClear();
     (formatOutput as jest.Mock).mockReturnValue('<formatted output>');
 
     await run(`lint --format html --format json --output.json foo.json ./__fixtures__/empty-oas2-document.json`);
-    await new Promise(resolve => void process.nextTick(resolve));
     expect(writeOutput).toBeCalledTimes(2);
     expect(writeOutput).nthCalledWith(1, '<formatted output>', '<stdout>');
     expect(writeOutput).nthCalledWith(2, '<formatted output>', 'foo.json');
@@ -216,8 +208,51 @@ describe('lint', () => {
     const error = new Error('Failure');
     (lint as jest.Mock).mockReset();
     (lint as jest.Mock).mockRejectedValueOnce(error);
-    await run(`lint -o foo.json ./__fixtures__/empty-oas2-document.json`);
-    await new Promise(resolve => void process.nextTick(resolve));
-    expect(errorSpy).toBeCalledWith('Failure');
+    await run(`lint ./__fixtures__/empty-oas2-document.json`);
+    expect(process.stderr.write).nthCalledWith(1, chalk.red('Error running Spectral!\n'));
+    expect(process.stderr.write).nthCalledWith(2, chalk.red('Use --verbose flag to print the error stack.\n'));
+    expect(process.stderr.write).nthCalledWith(3, `Error #1: ${chalk.red('Failure')}\n`);
+  });
+
+  it('prints each error separately', async () => {
+    (lint as jest.Mock).mockReset();
+    (lint as jest.Mock).mockRejectedValueOnce(
+      new AggregateError([
+        new Error('some unhandled exception'),
+        new TypeError('another one'),
+        new ErrorWithCause('some error with cause', { cause: 'original exception' }),
+      ]),
+    );
+    await run(`lint ./__fixtures__/empty-oas2-document.json`);
+    expect(process.stderr.write).nthCalledWith(3, `Error #1: ${chalk.red('some unhandled exception')}\n`);
+    expect(process.stderr.write).nthCalledWith(4, `Error #2: ${chalk.red('another one')}\n`);
+    expect(process.stderr.write).nthCalledWith(5, `Error #3: ${chalk.red('original exception')}\n`);
+  });
+
+  it('given verbose flag, prints each error together with their stacks', async () => {
+    (lint as jest.Mock).mockReset();
+    (lint as jest.Mock).mockRejectedValueOnce(
+      new AggregateError([
+        new Error('some unhandled exception'),
+        new TypeError('another one'),
+        new ErrorWithCause('some error with cause', { cause: 'original exception' }),
+      ]),
+    );
+
+    await run(`lint --verbose ./__fixtures__/empty-oas2-document.json`);
+
+    expect(process.stderr.write).nthCalledWith(2, `Error #1: ${chalk.red('some unhandled exception')}\n`);
+    expect(process.stderr.write).nthCalledWith(
+      3,
+      expect.stringContaining(`packages/cli/src/commands/__tests__/lint.test.ts:236`),
+    );
+
+    expect(process.stderr.write).nthCalledWith(4, `Error #2: ${chalk.red('another one')}\n`);
+    expect(process.stderr.write).nthCalledWith(
+      5,
+      expect.stringContaining(`packages/cli/src/commands/__tests__/lint.test.ts:237`),
+    );
+
+    expect(process.stderr.write).nthCalledWith(6, `Error #3: ${chalk.red('original exception')}\n`);
   });
 });
diff --git a/packages/cli/src/services/__tests__/linter.test.ts b/packages/cli/src/services/__tests__/linter.test.ts
index 4ab846de..84ed4452 100644
--- a/packages/cli/src/services/__tests__/linter.test.ts
+++ b/packages/cli/src/services/__tests__/linter.test.ts
@@ -1,11 +1,14 @@
 import { join, resolve } from '@stoplight/path';
 import nock from 'nock';
 import * as yargs from 'yargs';
-import lintCommand from '../../commands/lint';
-import { lint } from '../linter';
 import { DiagnosticSeverity } from '@stoplight/types';
 import { RulesetValidationError } from '@stoplight/spectral-core';
+import * as process from 'process';
 
+import lintCommand from '../../commands/lint';
+import { lint } from '../linter';
+
+jest.mock('process');
 jest.mock('../output');
 
 const validCustomOas3SpecPath = resolve(__dirname, '__fixtures__/openapi-3.0-valid-custom.yaml');
@@ -29,25 +32,16 @@ async function run(command: string) {
 }
 
 describe('Linter service', () => {
-  let consoleLogSpy: jest.SpyInstance;
-  let consoleErrorSpy: jest.SpyInstance;
   let processCwdSpy: jest.SpyInstance;
 
   beforeEach(() => {
-    const noop = () => {
-      /* no-op */
-    };
-    consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(noop);
-    consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(noop);
-
-    processCwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(join(__dirname, '__fixtures__'));
+    (process.cwd as jest.Mock).mockReturnValue(join(__dirname, '__fixtures__'));
+    processCwdSpy = jest.spyOn(globalThis.process, 'cwd').mockImplementation(process.cwd);
   });
 
   afterEach(() => {
-    consoleLogSpy.mockRestore();
-    consoleErrorSpy.mockRestore();
     processCwdSpy.mockRestore();
-
+    jest.clearAllMocks();
     nock.cleanAll();
   });
 
@@ -93,7 +87,7 @@ describe('Linter service', () => {
   });
 
   it('demands some ruleset to be present', () => {
-    processCwdSpy.mockReturnValue(join(__dirname, '__fixtures__/resolver'));
+    (process.cwd as jest.Mock).mockReturnValue(join(__dirname, '__fixtures__/resolver'));
     return expect(run(`lint stoplight-info-document.json`)).rejects.toThrow(
       'No ruleset has been found. Please provide a ruleset using the --ruleset CLI argument, or make sure your ruleset file matches .?spectral.(js|ya?ml|json)',
     );
diff --git a/packages/cli/src/services/__tests__/output.test.ts b/packages/cli/src/services/__tests__/output.test.ts
index d04e6a5c..c9f08e30 100644
--- a/packages/cli/src/services/__tests__/output.test.ts
+++ b/packages/cli/src/services/__tests__/output.test.ts
@@ -12,11 +12,7 @@ jest.mock('fs', () => ({
     writeFile: jest.fn().mockResolvedValue(void 0),
   },
 }));
-jest.mock('process', () => ({
-  stdout: {
-    write: jest.fn(),
-  },
-}));
+jest.mock('process');
 
 describe('Output service', () => {
   describe('formatOutput', () => {
diff --git a/packages/functions/src/__tests__/__helpers__/tester.ts b/packages/functions/src/__tests__/__helpers__/tester.ts
index 0dc0c0a9..21a1c03e 100644
--- a/packages/functions/src/__tests__/__helpers__/tester.ts
+++ b/packages/functions/src/__tests__/__helpers__/tester.ts
@@ -38,18 +38,27 @@ export default async function <O = unknown>(
         path: error.path,
         message: error.message,
       }));
-  } catch (e: unknown) {
-    if (
-      e instanceof Error &&
-      Array.isArray((e as Error & { errors?: unknown }).errors) &&
-      (e as Error & { errors: unknown[] }).errors.length === 1
-    ) {
-      const actualError = (e as Error & { errors: [unknown] }).errors[0];
-      throw actualError instanceof Error && 'cause' in (actualError as Error & { cause?: unknown })
-        ? (actualError as Error & { cause: unknown }).cause
-        : actualError;
+  } catch (error: unknown) {
+    if (!(error instanceof Error)) {
+      throw error;
+    }
+
+    const errors = Array.isArray((error as Error & { errors?: unknown }).errors)
+      ? (error as Error & { errors: unknown[] }).errors
+      : [error];
+
+    if (errors.length === 1) {
+      throw getCause(errors[0]);
     } else {
-      throw e;
+      throw error;
     }
   }
 }
+
+function getCause(error: unknown): unknown {
+  if (error instanceof Error && 'cause' in (error as Error & { cause?: unknown })) {
+    return getCause((error as Error & { cause?: unknown }).cause);
+  }
+
+  return error;
+}
diff --git a/test-harness/scenarios/runtime-errors.scenario b/test-harness/scenarios/runtime-errors.scenario
new file mode 100644
index 00000000..c675c986
--- /dev/null
+++ b/test-harness/scenarios/runtime-errors.scenario
@@ -0,0 +1,68 @@
+====test====
+Logs all runtime errors thrown during the linting process
+====document====
+schemas:
+  user:
+    type: object
+    properties:
+      name:
+        type: number
+      age:
+        type: number
+      occupation:
+        type: boolean
+  addresses:
+    -
+====command====
+{bin} lint {document} -r "{asset:ruleset.js}"
+====asset:ruleset.js====
+const { truthy } = require("@stoplight/spectral-functions");
+
+function validAddress(input) {
+  throw new TypeError(`Cannot read properties of null (reading 'test')`);
+}
+
+function upperCase() {
+  return String(input).toLowerCase() === String(input).toUpperCase();
+}
+
+module.exports = {
+   "rules": {
+      "valid-user-properties": {
+         "severity": "error",
+         "given": [
+            "$.schemas.user.properties.name",
+            "$.schemas.user.properties.occupation"
+         ],
+         "then": {
+            "field": "type",
+            "function": upperCase,
+         }
+      },
+      "valid-address": {
+        "given": [
+          "$..addresses[*]",
+        ],
+        "then": {
+          "function": validAddress
+        }
+      },
+      "require-user-and-address": {
+         "severity": "error",
+         "given": [
+            "$.schemas.user",
+         ],
+         "then": {
+            "function": truthy
+         }
+      }
+   }
+}
+====status====
+2
+====stderr====
+Error running Spectral!
+Use --verbose flag to print the error stack.
+Error #1: Function "upperCase" threw an exception: input is not defined
+Error #2: Function "upperCase" threw an exception: input is not defined
+Error #3: Function "validAddress" threw an exception: Cannot read properties of null (reading 'test')