From 4cf3365c68a3e73535b91593e5e70e81cd775694 Mon Sep 17 00:00:00 2001 From: eric sciple Date: Mon, 8 Dec 2025 09:20:02 -0600 Subject: [PATCH] Suppress warnings for step output property access (#236) Fixes https://github.com/github/vscode-github-actions/issues/305 Step outputs are dynamic - actions can generate outputs based on their inputs, so validating output property names is not feasible. This marks step output dictionaries as incomplete so that accessing any output property won't produce a warning. Known outputs from action.yml will still be suggested for autocomplete. --- .../src/context-providers/steps.test.ts | 54 +++++++++++-- languageserver/src/context-providers/steps.ts | 2 + .../src/context-providers/steps.test.ts | 78 +++++++++++++++++++ .../src/context-providers/steps.ts | 5 +- 4 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 languageservice/src/context-providers/steps.test.ts diff --git a/languageserver/src/context-providers/steps.test.ts b/languageserver/src/context-providers/steps.test.ts index d5e1818..42f0c47 100644 --- a/languageserver/src/context-providers/steps.test.ts +++ b/languageserver/src/context-providers/steps.test.ts @@ -1,4 +1,4 @@ -import {data, DescriptionDictionary} from "@actions/expressions"; +import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions"; import {getStepsContext as getDefaultStepsContext} from "@actions/languageservice/context-providers/steps"; import {Octokit} from "@octokit/rest"; import fetchMock from "fetch-mock"; @@ -63,6 +63,43 @@ it("returns default context when job is undefined", async () => { expect(stepsContext).toEqual(defaultContext); }); +it("outputs is an incomplete dictionary to allow dynamic outputs", async () => { + const mock = fetchMock + .sandbox() + .getOnce("https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3", actionMetadata); + + const workflowContext = await createWorkflowContext(workflow, "build"); + const defaultContext = getDefaultStepsContext(workflowContext); + + const stepsContext = await getStepsContext( + new Octokit({ + request: { + fetch: mock + } + }), + new TTLCache(), + defaultContext, + workflowContext + ); + + // Get the step context + const stepContext = stepsContext?.get("cache-primes"); + expect(stepContext).toBeDefined(); + expect(isDescriptionDictionary(stepContext!)).toBe(true); + + // Get the outputs - should be a dictionary, not null + const outputs = (stepContext as DescriptionDictionary).get("outputs"); + expect(outputs).toBeDefined(); + expect(isDescriptionDictionary(outputs!)).toBe(true); + + // Outputs should be marked incomplete to allow dynamic outputs + const outputsDict = outputs as DescriptionDictionary; + expect(outputsDict.complete).toBe(false); + + // Known outputs from action.yml should be present + expect(outputsDict.get("cache-hit")).toBeDefined(); +}); + it("adds action outputs", async () => { const mock = fetchMock .sandbox() @@ -83,17 +120,22 @@ it("adds action outputs", async () => { ); expect(stepsContext).toBeDefined(); + // Create expected outputs dict with complete = false + // (actions can have dynamic outputs beyond what's declared in action.yml) + const expectedOutputs = new DescriptionDictionary({ + key: "cache-hit", + value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"), + description: "A boolean value to indicate an exact match was found for the primary key" + }); + expectedOutputs.complete = false; + expect(stepsContext).toEqual( new DescriptionDictionary({ key: "cache-primes", value: new DescriptionDictionary( { key: "outputs", - value: new DescriptionDictionary({ - key: "cache-hit", - value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"), - description: "A boolean value to indicate an exact match was found for the primary key" - }) + value: expectedOutputs }, { key: "conclusion", diff --git a/languageserver/src/context-providers/steps.ts b/languageserver/src/context-providers/steps.ts index 5aa8731..a78cd9e 100644 --- a/languageserver/src/context-providers/steps.ts +++ b/languageserver/src/context-providers/steps.ts @@ -58,6 +58,8 @@ export async function getStepsContext( continue; } const outputsDict = new DescriptionDictionary(); + // Actions can have dynamic outputs beyond what's declared in action.yml + outputsDict.complete = false; for (const [key, value] of Object.entries(outputs)) { outputsDict.add(key, new data.StringData(value.description), value.description); } diff --git a/languageservice/src/context-providers/steps.test.ts b/languageservice/src/context-providers/steps.test.ts new file mode 100644 index 0000000..ad0795f --- /dev/null +++ b/languageservice/src/context-providers/steps.test.ts @@ -0,0 +1,78 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions"; +import {WorkflowContext} from "../context/workflow-context"; +import {getStepsContext} from "./steps"; + +function createWorkflowContext(stepIds: string[], currentStepId?: string): WorkflowContext { + return { + job: { + steps: stepIds.map(id => ({id})) + }, + step: currentStepId ? {id: currentStepId} : undefined + } as WorkflowContext; +} + +describe("steps context", () => { + it("returns empty dictionary when no job", () => { + const workflowContext = {} as WorkflowContext; + const context = getStepsContext(workflowContext); + expect(context.pairs().length).toBe(0); + }); + + it("returns empty dictionary when no steps", () => { + const workflowContext = {job: {}} as WorkflowContext; + const context = getStepsContext(workflowContext); + expect(context.pairs().length).toBe(0); + }); + + it("includes steps with user-defined ids", () => { + const workflowContext = createWorkflowContext(["step-a", "step-b"]); + const context = getStepsContext(workflowContext); + + expect(context.get("step-a")).toBeDefined(); + expect(context.get("step-b")).toBeDefined(); + }); + + it("excludes generated step ids (starting with __)", () => { + const workflowContext = createWorkflowContext(["step-a", "__generated"]); + const context = getStepsContext(workflowContext); + + expect(context.get("step-a")).toBeDefined(); + expect(context.get("__generated")).toBeUndefined(); + }); + + it("excludes current step and later steps", () => { + const workflowContext = createWorkflowContext(["step-a", "step-b", "step-c"], "step-b"); + const context = getStepsContext(workflowContext); + + expect(context.get("step-a")).toBeDefined(); + expect(context.get("step-b")).toBeUndefined(); + expect(context.get("step-c")).toBeUndefined(); + }); + + describe("step outputs", () => { + it("outputs is a dictionary, not null", () => { + const workflowContext = createWorkflowContext(["step-a"]); + const context = getStepsContext(workflowContext); + + const stepContext = context.get("step-a"); + expect(stepContext).toBeDefined(); + expect(isDescriptionDictionary(stepContext!)).toBe(true); + + const outputs = (stepContext as DescriptionDictionary).get("outputs"); + expect(outputs).toBeDefined(); + expect(isDescriptionDictionary(outputs!)).toBe(true); + }); + + it("outputs is marked incomplete to allow dynamic outputs", () => { + const workflowContext = createWorkflowContext(["step-a"]); + const context = getStepsContext(workflowContext); + + const stepContext = context.get("step-a") as DescriptionDictionary; + const outputs = stepContext.get("outputs") as DescriptionDictionary; + + // Outputs should be incomplete since we can't know what outputs a step will produce + expect(outputs.complete).toBe(false); + }); + }); +}); diff --git a/languageservice/src/context-providers/steps.ts b/languageservice/src/context-providers/steps.ts index 7f3c657..8b0fe54 100644 --- a/languageservice/src/context-providers/steps.ts +++ b/languageservice/src/context-providers/steps.ts @@ -31,7 +31,10 @@ function stepContext(): DescriptionDictionary { // https://docs.github.com/en/actions/learn-github-actions/contexts#steps-context const d = new DescriptionDictionary(); - d.add("outputs", new data.Null(), getDescription("steps", "outputs")); + // Step outputs are dynamic - actions can generate outputs based on their inputs + const outputs = new DescriptionDictionary(); + outputs.complete = false; + d.add("outputs", outputs, getDescription("steps", "outputs")); // Can be "success", "failure", "cancelled", or "skipped" d.add("conclusion", new data.Null(), getDescription("steps", "conclusion"));