Repository: stoplightio/spectral (ts)

Issue:
Path is undefined when using async functions within a custom function
**Describe the bug**
Path is undefined when using async functions within a custom function. This results in errors being shown on line 1 only. 

**To Reproduce**

1. Given this API file
[my-sample-api.zip](https://github.com/stoplightio/spectral/files/9698432/my-sample-api.zip)

2. Run this CLI command `spectral lint -r .spectral.json reference/todo-api.yaml`
3. See errors are all on line 1
 <img width="568" alt="Screenshot 2022-10-03 at 7 41 29 PM" src="https://user-images.githubusercontent.com/17899454/193605594-58c5124d-83b4-487d-aca9-1520ae17740a.png">

**Expected behavior**
Correct line number should be shown with the error.


Pull request:
fix(core): async functions have undefined paths
Fixes #2303 

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No



Patch:
diff --git a/packages/core/src/runner/lintNode.ts b/packages/core/src/runner/lintNode.ts
index 6059f597..5e232cff 100644
--- a/packages/core/src/runner/lintNode.ts
+++ b/packages/core/src/runner/lintNode.ts
@@ -1,36 +1,34 @@
-import { JsonPath } from '@stoplight/types';
 import { decodeSegmentFragment, getClosestJsonPath, printPath, PrintStyle } from '@stoplight/spectral-runtime';
 import { get, isError } from 'lodash';
 import { ErrorWithCause } from 'pony-cause';
 
 import { Document } from '../document';
-import { IFunctionResult, IGivenNode, RulesetFunctionContext } from '../types';
-import { IRunnerInternalContext } from './types';
+import type { IFunctionResult, IGivenNode, RulesetFunctionContext } from '../types';
+import type { IRunnerInternalContext } from './types';
 import { getLintTargets, MessageVars, message } from './utils';
-import { Rule } from '../ruleset/rule';
+import type { Rule } from '../ruleset/rule';
 
 export const lintNode = (context: IRunnerInternalContext, node: IGivenNode, rule: Rule): void => {
-  const fnContext: RulesetFunctionContext = {
+  const givenPath = node.path.length > 0 && node.path[0] === '$' ? node.path.slice(1) : node.path.slice();
+
+  const fnContext: RulesetFunctionContext & { rule: Rule } = {
     document: context.documentInventory.document,
     documentInventory: context.documentInventory,
     rule,
-    path: [],
+    path: givenPath,
   };
 
-  const givenPath = node.path.length > 0 && node.path[0] === '$' ? node.path.slice(1) : node.path;
-
   for (const then of rule.then) {
     const targets = getLintTargets(node.value, then.field);
 
     for (const target of targets) {
-      const path = target.path.length > 0 ? [...givenPath, ...target.path] : givenPath;
+      if (target.path.length > 0) {
+        fnContext.path = [...givenPath, ...target.path];
+      }
 
       let targetResults;
       try {
-        targetResults = then.function(target.value, then.functionOptions ?? null, {
-          ...fnContext,
-          path,
-        });
+        targetResults = then.function(target.value, then.functionOptions ?? null, fnContext);
       } catch (e) {
         throw new ErrorWithCause(
           `Function "${then.function.name}" threw an exception${isError(e) ? `: ${e.message}` : ''}`,
@@ -43,25 +41,14 @@ export const lintNode = (context: IRunnerInternalContext, node: IGivenNode, rule
       if (targetResults === void 0) continue;
 
       if ('then' in targetResults) {
+        const _fnContext = { ...fnContext };
         context.promises.push(
           targetResults.then(results =>
-            results === void 0
-              ? void 0
-              : void processTargetResults(
-                  context,
-                  results,
-                  rule,
-                  path, // todo: get rid of it somehow.
-                ),
+            results === void 0 ? void 0 : processTargetResults(context, _fnContext, results),
           ),
         );
       } else {
-        processTargetResults(
-          context,
-          targetResults,
-          rule,
-          path, // todo: get rid of it somehow.
-        );
+        processTargetResults(context, fnContext, targetResults);
       }
     }
   }
@@ -69,10 +56,10 @@ export const lintNode = (context: IRunnerInternalContext, node: IGivenNode, rule
 
 function processTargetResults(
   context: IRunnerInternalContext,
+  fnContext: RulesetFunctionContext & { rule: Rule },
   results: IFunctionResult[],
-  rule: Rule,
-  targetPath: JsonPath,
 ): void {
+  const { rule, path: targetPath } = fnContext;
   for (const result of results) {
     const escapedJsonPath = (result.path ?? targetPath).map(decodeSegmentFragment);
     const associatedItem = context.documentInventory.findAssociatedItemForPath(escapedJsonPath, rule.resolved);


Test patch:
diff --git a/packages/core/src/__tests__/linter.test.ts b/packages/core/src/__tests__/linter.test.ts
index ba4dc148..a008d423 100644
--- a/packages/core/src/__tests__/linter.test.ts
+++ b/packages/core/src/__tests__/linter.test.ts
@@ -1540,4 +1540,55 @@ responses:: !!foo
       ]);
     });
   });
+
+  test.concurrent('should retain path in async functions', async () => {
+    const spectral = new Spectral();
+    const documentUri = path.join(__dirname, './__fixtures__/test.json');
+    spectral.setRuleset({
+      rules: {
+        'valid-type': {
+          given: '$..type',
+          then: {
+            async function() {
+              return [
+                {
+                  message: 'Restricted type',
+                },
+              ];
+            },
+          },
+        },
+      },
+    });
+
+    const document = new Document(
+      JSON.stringify({
+        oneOf: [
+          {
+            type: ['number'],
+          },
+          {
+            type: ['string'],
+          },
+        ],
+      }),
+      Parsers.Json,
+      documentUri,
+    );
+
+    const results = spectral.run(document);
+
+    await expect(results).resolves.toEqual([
+      expect.objectContaining({
+        code: 'valid-type',
+        path: ['oneOf', '0', 'type'],
+        severity: DiagnosticSeverity.Warning,
+      }),
+      expect.objectContaining({
+        code: 'valid-type',
+        path: ['oneOf', '1', 'type'],
+        severity: DiagnosticSeverity.Warning,
+      }),
+    ]);
+  });
 });