Compare commits
139 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77ed325c44 | |||
| 1372d6dec7 | |||
| a06de82217 | |||
| 36b909a32d | |||
| 9a8a94bd21 | |||
| 8aa246e9d9 | |||
| ffc3778653 | |||
| 38f730cdce | |||
| a810405967 | |||
| 840d04cea8 | |||
| 0446b065b0 | |||
| 763dff2018 | |||
| 0c9d817440 | |||
| cc316ab9de | |||
| d5670c383a | |||
| f62a0e189d | |||
| 9e1662f1d4 | |||
| 5db2e80f32 | |||
| 83de320ba9 | |||
| 74e6638098 | |||
| f8b8b57248 | |||
| aa1e7d8aec | |||
| bd6ce5923b | |||
| 3de9820cd8 | |||
| a7f581bde5 | |||
| 8c0a3a947b | |||
| eb71b18f2b | |||
| 92c5235a00 | |||
| 9f770badd3 | |||
| 9dd856db3d | |||
| 4a881d9ea1 | |||
| 6a0408d237 | |||
| 0c2f39f1d0 | |||
| fb5c6e4f27 | |||
| f29f508cec | |||
| d69c1fa0f3 | |||
| 191a7b6a00 | |||
| 0410ab8302 | |||
| 7ac83f43a6 | |||
| ef457b29fa | |||
| fea8440c1d | |||
| 3c0a5f79fc | |||
| 448180bd7f | |||
| d2f52a9043 | |||
| 46b216a6dc | |||
| 0fe7798548 | |||
| bdd72406c3 | |||
| 33291f0f8d | |||
| 8511ae2e6d | |||
| cd1078fb2f | |||
| 96be7ce46c | |||
| c2bf928e7b | |||
| 74d69b24ab | |||
| 22aa458809 | |||
| f3f11d8658 | |||
| 5359433879 | |||
| a8bfe74256 | |||
| e2c5f1f74a | |||
| 2a203ec742 | |||
| 92960e0093 | |||
| 0fe31c6656 | |||
| 67dd4fbd61 | |||
| 4a7e08774d | |||
| 9ec1c123a8 | |||
| aad3bcd291 | |||
| 248934d513 | |||
| b605cb6582 | |||
| 05debf64b0 | |||
| 1baa74a67e | |||
| fa27dfa563 | |||
| 228acc3cd9 | |||
| 9f30846fde | |||
| 2816233a40 | |||
| 54404aa9ff | |||
| 0ebe1262ee | |||
| 94d7f7b124 | |||
| f439272f69 | |||
| 161574adac | |||
| dbf7752734 | |||
| 78231482f5 | |||
| 2e46c66878 | |||
| 44900feff7 | |||
| 39b9b14e3a | |||
| 71ff7b49c3 | |||
| 1a42526360 | |||
| 1cfe9f9f86 | |||
| 6641228870 | |||
| c1ad4d14df | |||
| 6a47895521 | |||
| c67c353245 | |||
| c6d2036302 | |||
| 56ce46afa6 | |||
| e3b56c2416 | |||
| d2ffb50a92 | |||
| 3734de18ee | |||
| 90e7932e97 | |||
| f84e42c1f1 | |||
| 08c78d2a73 | |||
| 26f3969cde | |||
| 61a6fc54f2 | |||
| 6511be5ab4 | |||
| a06ceee92b | |||
| efd53330a3 | |||
| 86888cf4c8 | |||
| ed4c2ce44c | |||
| 9bb4c76612 | |||
| 8b86b48961 | |||
| c0062e5287 | |||
| 2eb53df976 | |||
| 656a821a94 | |||
| fbdc2a5749 | |||
| 47ec2dc734 | |||
| 1395ae198f | |||
| 589c1e34f4 | |||
| 1f2031c2f3 | |||
| ecebf60561 | |||
| 9922d3983f | |||
| 742b36d6b7 | |||
| 8507419ebf | |||
| 952dc89b78 | |||
| 2934e36944 | |||
| 8d2c24d7f5 | |||
| 4181cb3c90 | |||
| 78ea3ba17f | |||
| 4cf3365c68 | |||
| 1a63ee9de6 | |||
| 108b8c2766 | |||
| e20dbae803 | |||
| 69b383af3d | |||
| 4429c41275 | |||
| 7b9adb106e | |||
| 576402fc01 | |||
| 22c36bc946 | |||
| 4dd678cf30 | |||
| dfb411f71e | |||
| dec597b0db | |||
| bd7e5f0b70 | |||
| 37ba6ab105 | |||
| 216fcbb8c4 |
@@ -1 +1,4 @@
|
||||
* @actions/actions-vscode-reviewers
|
||||
|
||||
# Owners maintaining https://github.com/actions/runner-images
|
||||
/languageservice/src/value-providers/default.ts @actions/runner-images-writers @actions/actions-vscode-reviewers
|
||||
|
||||
@@ -12,18 +12,55 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x, 22.x, 24.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 16.15
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16.15
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
- run: npm ci
|
||||
- run: npm ci --engine-strict
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- run: npm run format-check -ws
|
||||
- run: npm run build -ws
|
||||
- run: npm run lint -ws
|
||||
- run: npm test -ws
|
||||
|
||||
check-generated:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 24.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'npm'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
- run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Regenerate JSON files
|
||||
run: |
|
||||
cd languageservice && npm run update-webhooks && cd ..
|
||||
- name: Check for uncommitted changes
|
||||
run: |
|
||||
if ! git diff --exit-code; then
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "ERROR: Generated files are out of date!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Please run the following commands locally and commit the changes:"
|
||||
echo ""
|
||||
echo " cd languageservice && npm run update-webhooks && cd .."
|
||||
echo " git add -A && git commit -m 'Regenerate JSON files'"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "16"
|
||||
node-version: 24.x
|
||||
|
||||
- name: Bump version and push
|
||||
run: |
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
PKG_VERSION: "" # will be set in the workflow
|
||||
@@ -69,9 +69,8 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16.x
|
||||
node-version: 24.x
|
||||
cache: "npm"
|
||||
scope: '@actions'
|
||||
|
||||
- name: Parse version from lerna.json
|
||||
run: |
|
||||
@@ -97,13 +96,6 @@ jobs:
|
||||
core.summary.addLink(`Release v${{ env.PKG_VERSION }}`, release.data.html_url);
|
||||
await core.summary.write();
|
||||
|
||||
- name: setup authentication
|
||||
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish packages
|
||||
run: |
|
||||
lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
npx lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
|
||||
+12
-1
@@ -2,4 +2,15 @@
|
||||
*/dist
|
||||
lerna-debug.log
|
||||
node_modules
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
# Nx cache (generated by Lerna/Nx)
|
||||
.nx/
|
||||
|
||||
# Minified JSON (generated at build time)
|
||||
*.min.json
|
||||
|
||||
# Intermediate JSON for size comparison (generated by update-webhooks --all)
|
||||
*.all.json
|
||||
*.drop.json
|
||||
*.strip.json
|
||||
+2
-1
@@ -3,4 +3,5 @@ dist
|
||||
*.md
|
||||
*.js
|
||||
*.json
|
||||
*.d.ts
|
||||
*.d.ts
|
||||
/.nx/workspace-data
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agents
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
npx lerna run build
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```
|
||||
npm -w @actions/expressions test
|
||||
npm -w @actions/workflow-parser test
|
||||
npm -w @actions/languageservice test
|
||||
```
|
||||
|
||||
## Format
|
||||
|
||||
Always run formatting before committing:
|
||||
|
||||
```
|
||||
npx prettier --write <changed files>
|
||||
```
|
||||
|
||||
Verify with:
|
||||
|
||||
```
|
||||
npm run format-check -ws
|
||||
```
|
||||
|
||||
## Feature flags
|
||||
|
||||
Feature flags are defined in `expressions/src/features.ts` (`ExperimentalFeatures` interface + `allFeatureKeys` array). They are plumbed through `ConvertOptions`, `CompletionConfig`, `ValidationConfig`, and `initializationOptions`. When a feature graduates to stable, remove its flag and make the behavior unconditional.
|
||||
@@ -8,6 +8,10 @@ This repository contains multiple npm packages for working with GitHub Actions w
|
||||
- [languageserver](./languageserver) - Language Server for GitHub Actions, hosting the language service for LSP-compatible editors
|
||||
- [browser-playground](./browser-playground) - Browser-based playground for the language service
|
||||
|
||||
## Documentation
|
||||
|
||||
- [JSON Data Files](./docs/json-data-files.md) - How the JSON data files are generated and maintained
|
||||
|
||||
### Note
|
||||
|
||||
Thank you for your interest in this GitHub repo, however, right now we are not taking contributions.
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
# ESM Migration Plan: Add File Extensions to Imports
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the plan to migrate from TypeScript's deprecated `"moduleResolution": "node"` (node10) to `"moduleResolution": "node16"` or `"nodenext"`. This change is necessary because the published ESM packages have extensionless imports that don't work correctly in modern ESM environments.
|
||||
|
||||
## TL;DR - Remaining Work
|
||||
|
||||
- [x] expressions - Migrated ✅
|
||||
- [x] workflow-parser - Migrated ✅
|
||||
- [x] languageservice - Migrated ✅
|
||||
- [x] languageserver - Add `.js` extensions to imports ✅
|
||||
- [ ] languageserver - Update `tsconfig.build.json` to `moduleResolution: "node16"` (blocked by vscode-languageserver)
|
||||
- [ ] languageserver - Upgrade `vscode-languageserver` to stable v10+ when released
|
||||
|
||||
**Blocker:** `vscode-languageserver@8.0.2` lacks ESM exports. Stable v10 with `exports` field needed.
|
||||
|
||||
### ⚠️ Important: `skipLibCheck: true` Required
|
||||
|
||||
All migrated packages use `skipLibCheck: true` in their `tsconfig.build.json`. This works around a TS2386 "Overload signatures must all be optional or required" error in `@types/node/module.d.ts`.
|
||||
|
||||
**Why can't we just fix the error?** The error is in `@types/node`, a third-party package maintained by DefinitelyTyped. We can't modify `node_modules`, and upstream fixes take time.
|
||||
|
||||
**Is `skipLibCheck` safe?** Yes. It only skips type checking of `.d.ts` files (declaration files from dependencies). Our own `.ts` source files are still fully type-checked. This is a common and recommended workaround for issues in third-party type definitions.
|
||||
|
||||
---
|
||||
|
||||
## Issues Fixed
|
||||
|
||||
This migration will resolve the following issues:
|
||||
|
||||
- **#154** - Upgrade `moduleResolution` from `node` to `node16` or `nodenext` in tsconfig
|
||||
- **#110** - Published ESM code has imports without file extensions
|
||||
- **#64** - expressions: ERR_MODULE_NOT_FOUND attempting to run example demo script
|
||||
- **#146** - Can not import `@actions/workflow-parser`
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### Current State
|
||||
|
||||
All packages use `"moduleResolution": "node"`:
|
||||
|
||||
| Package | moduleResolution | TypeScript |
|
||||
|---------|------------------|------------|
|
||||
| expressions | `"node"` | ^4.7.4 |
|
||||
| workflow-parser | `"node"` | ^4.8.4 |
|
||||
| languageservice | `"node"` | ^4.8.4 |
|
||||
| languageserver | `"node"` | ^4.8.4 |
|
||||
| browser-playground | `"Node16"` ✅ | ^4.9.4 |
|
||||
|
||||
This causes TypeScript to emit code like:
|
||||
```javascript
|
||||
// Published to npm - INVALID ESM
|
||||
export { Expr } from "./ast"; // Missing .js extension!
|
||||
```
|
||||
|
||||
### Why This Fails
|
||||
|
||||
ESM in Node.js 12+ **requires** explicit file extensions. When users try to import these packages:
|
||||
|
||||
```javascript
|
||||
// User's code
|
||||
import { Expr } from "@actions/expressions";
|
||||
```
|
||||
|
||||
Node.js fails with:
|
||||
```
|
||||
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../node_modules/@actions/expressions/dist/ast'
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Option A: TypeScript 5.7+ with `rewriteRelativeImportExtensions` (Recommended)
|
||||
|
||||
TypeScript 5.7 introduced a new compiler option that automatically rewrites `.ts` extensions to `.js` in output:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node16", // or "nodenext"
|
||||
"rewriteRelativeImportExtensions": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Source code:**
|
||||
```typescript
|
||||
import { Expr } from "./ast.ts";
|
||||
```
|
||||
|
||||
**Compiled output:**
|
||||
```javascript
|
||||
export { Expr } from "./ast.js";
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Source uses `.ts` extensions (matches actual files)
|
||||
- Works with Deno (which requires `.ts` extensions)
|
||||
- TypeScript automatically transforms to `.js`
|
||||
- Modern, forward-looking approach
|
||||
|
||||
**Cons:**
|
||||
- Requires TypeScript 5.7+
|
||||
- Relatively new feature
|
||||
- **BUG:** See "Known Issues" section below
|
||||
|
||||
### Option B: Manual `.js` Extensions
|
||||
|
||||
Use `.js` extensions in source TypeScript files:
|
||||
|
||||
```typescript
|
||||
import { Expr } from "./ast.js"; // Points to .ts file, but use .js extension
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Works with TypeScript 4.7+ (with node16 moduleResolution)
|
||||
- Well-established pattern
|
||||
- No post-processing needed
|
||||
- Works with ts-jest without extra configuration
|
||||
|
||||
**Cons:**
|
||||
- Confusing - `.js` files don't exist at write time
|
||||
- Doesn't work with Deno out of the box
|
||||
|
||||
### Recommendation
|
||||
|
||||
**Use Option B** (manual `.js` extensions). Option A with `rewriteRelativeImportExtensions` has compatibility issues with ts-jest and requires additional workarounds.
|
||||
|
||||
---
|
||||
|
||||
## Known Issues and Workarounds (December 2025)
|
||||
|
||||
### 1. TypeScript Version Conflicts in Monorepo
|
||||
|
||||
**Problem:** The root `node_modules/typescript` was version 4.9.5 (pulled in by `ts-node` and `tsutils` dependencies), while workspace packages specified `^5.8.3`.
|
||||
|
||||
**Symptoms:**
|
||||
- `npx tsc --version` showed 4.9.5
|
||||
- `require('typescript').version` in ts-jest showed 5.8.3
|
||||
- Confusing build failures
|
||||
|
||||
**Solution:** Add npm overrides in root `package.json`:
|
||||
```json
|
||||
{
|
||||
"overrides": {
|
||||
"typescript": "5.8.3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. ts-jest Compatibility with TypeScript 5.9+
|
||||
|
||||
**Problem:** ts-jest 29.4.6 uses `typescript.JSDocParsingMode.ParseAll` which doesn't exist in TypeScript's ES module exports.
|
||||
|
||||
**Error:**
|
||||
```
|
||||
TypeError: Cannot read properties of undefined (reading 'ParseAll')
|
||||
at Object.<anonymous> (node_modules/ts-jest/dist/compiler/ts-compiler.js:43:123)
|
||||
```
|
||||
|
||||
**Root Cause:** ts-jest accesses `typescript_1.default.JSDocParsingMode.ParseAll` but TypeScript has no default export in ESM.
|
||||
|
||||
**Solution:**
|
||||
- Use ts-jest 29.0.3 (older version that doesn't use this API)
|
||||
- OR wait for ts-jest fix
|
||||
- **Stay on TypeScript 5.8.3, not 5.9+**
|
||||
|
||||
### 3. TypeScript `rewriteRelativeImportExtensions` Bug with .d.ts Files
|
||||
|
||||
**Problem:** TypeScript's `rewriteRelativeImportExtensions: true` correctly rewrites `.ts` → `.js` in `.js` output files, but **incorrectly keeps `.ts` extensions in `.d.ts` declaration files**.
|
||||
|
||||
**Example:**
|
||||
- Source: `export { Expr } from "./ast.ts";`
|
||||
- Output `index.js`: `export { Expr } from "./ast.js";` ✅ Correct
|
||||
- Output `index.d.ts`: `export { Expr } from "./ast.ts";` ❌ Wrong (should be `.js`)
|
||||
|
||||
**Upstream Issue:** https://github.com/microsoft/TypeScript/issues/61037 (marked "Help Wanted", in Backlog, NOT FIXED as of Dec 2025)
|
||||
|
||||
**Workaround:** Post-process `.d.ts` files with a script. See `script/fix-dts-extensions.cjs`.
|
||||
|
||||
**Note:** Since we use Option B (manual `.js` extensions), this bug does not affect our migration.
|
||||
|
||||
### 4. yaml Package Internal Types Not Exported
|
||||
|
||||
**Problem:** The `yaml` package does not export internal types like `LinePos` and `NodeBase` that are used in `workflow-parser/src/workflows/yaml-object-reader.ts`.
|
||||
|
||||
**Error:**
|
||||
```
|
||||
error TS2305: Module '"yaml"' has no exported member 'LinePos'.
|
||||
error TS2305: Module '"yaml"' has no exported member 'NodeBase'.
|
||||
```
|
||||
|
||||
**Solution:** Define local type aliases in the file that uses them:
|
||||
```typescript
|
||||
// Local type definitions to replace yaml internal imports
|
||||
type LinePos = { line: number; col: number };
|
||||
type NodeBase = { range?: [number, number, number] };
|
||||
```
|
||||
|
||||
### 5. languageserver Blocked by vscode-languageserver Dependency
|
||||
|
||||
**Problem:** The `vscode-languageserver` package (v8.0.2) does not have proper ESM exports. When using `moduleResolution: "node16"`, TypeScript requires packages to have an `exports` field in `package.json` for subpath imports to work.
|
||||
|
||||
**Error:**
|
||||
```
|
||||
src/index.ts(6,8): error TS2307: Cannot find module 'vscode-languageserver/browser' or its corresponding type declarations.
|
||||
src/connection.ts(1,43): error TS2307: Cannot find module 'vscode-languageserver/node' or its corresponding type declarations.
|
||||
```
|
||||
|
||||
**Root Cause:** The `vscode-languageserver` package.json only has `main` and `browser` fields, but no `exports` field:
|
||||
```json
|
||||
{
|
||||
"main": "./lib/node/main.js",
|
||||
"browser": {
|
||||
"./lib/node/main.js": "./lib/browser/main.js"
|
||||
}
|
||||
// No "exports" field!
|
||||
}
|
||||
```
|
||||
|
||||
With `moduleResolution: "node16"`, TypeScript follows Node.js ESM resolution rules which require explicit `exports` for subpath imports like `vscode-languageserver/browser` and `vscode-languageserver/node`.
|
||||
|
||||
**Status:** Partial - `.js` extensions added, waiting for stable `vscode-languageserver` release with ESM exports to complete migration.
|
||||
|
||||
**Completed:** All relative imports in languageserver source files have been updated to use `.js` extensions. This is compatible with the current `moduleResolution: "node"` and will enable a seamless migration once a stable vscode-languageserver version with ESM exports is available.
|
||||
|
||||
**Options to resolve:**
|
||||
- Wait for stable vscode-languageserver v10+ with ESM exports
|
||||
- Use pre-release `vscode-languageserver@10.0.0-next.16` (has proper exports but is unstable)
|
||||
- Fork or patch the dependency
|
||||
|
||||
---
|
||||
|
||||
## Migration Status
|
||||
|
||||
| Package | Tests | ESM Status |
|
||||
|---------|-------|------------|
|
||||
| expressions | 1068 | ✅ Migrated |
|
||||
| workflow-parser | 292 | ✅ Migrated |
|
||||
| languageservice | 452 | ✅ Migrated |
|
||||
| languageserver | 31 | 🔶 Partial (`.js` extensions added, awaiting stable vscode-languageserver) |
|
||||
|
||||
---
|
||||
|
||||
## Required Configuration Changes
|
||||
|
||||
### tsconfig.build.json (each migrated package)
|
||||
|
||||
**Note:** We use **Option B** (manual `.js` extensions in source files) rather than `rewriteRelativeImportExtensions` because Option A caused ts-jest compatibility issues (tests would hang indefinitely).
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022"],
|
||||
"target": "ES2022"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `skipLibCheck: true` is needed to work around @types/node compatibility issues with TypeScript 5.x (TS2386 overload signature errors).
|
||||
```
|
||||
|
||||
### jest.config.js (each migrated package)
|
||||
|
||||
```javascript
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
export default {
|
||||
preset: "ts-jest/presets/default-esm",
|
||||
moduleNameMapper: {
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1",
|
||||
"^(\\.{1,2}/.*)\\.ts$": "$1",
|
||||
},
|
||||
transform: {
|
||||
"^.+\\.tsx?$": [
|
||||
"ts-jest",
|
||||
{
|
||||
useESM: true,
|
||||
isolatedModules: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
moduleFileExtensions: ["ts", "js"],
|
||||
};
|
||||
```
|
||||
|
||||
### Root package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"overrides": {
|
||||
"typescript": "5.8.3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Each workspace package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3",
|
||||
"ts-jest": "^29.0.3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [TypeScript moduleResolution reference](https://www.typescriptlang.org/docs/handbook/modules/reference.html)
|
||||
- [TypeScript 5.7 rewriteRelativeImportExtensions](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-7.html#path-rewriting-for-relative-paths)
|
||||
- [TypeScript .d.ts extension bug #61037](https://github.com/microsoft/TypeScript/issues/61037)
|
||||
- [Node.js ESM mandatory extensions](https://nodejs.org/api/esm.html#mandatory-file-extensions)
|
||||
- [ts-jest ESM support](https://kulshekhar.github.io/ts-jest/docs/guides/esm-support)
|
||||
- [Community fork that works](https://github.com/boxbuild-io/actions-languageservices/commit/077fb2b58dfd2cca3d6e3df1fdf9e26e75db24ae)
|
||||
@@ -0,0 +1,197 @@
|
||||
# JSON Data Files
|
||||
|
||||
This document describes the JSON data files used by the language service packages and how they are maintained.
|
||||
|
||||
## Overview
|
||||
|
||||
The language service uses several JSON files containing schema definitions, webhook payloads, and other metadata. To reduce bundle size, these files are:
|
||||
|
||||
1. **Optimized at generation time** — unused events are dropped, unused fields are stripped
|
||||
2. **Minified at build time** — whitespace is removed to produce `.min.json` files
|
||||
|
||||
The source `.json` files are human-readable and checked into the repository. The `.min.json` files are generated during build and gitignored.
|
||||
|
||||
## Files
|
||||
|
||||
### languageservice
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `src/context-providers/events/webhooks.json` | Webhook event payload schemas for autocompletion |
|
||||
| `src/context-providers/events/objects.json` | Deduplicated shared object definitions referenced by webhooks |
|
||||
| `src/context-providers/events/schedule.json` | Schedule event context data |
|
||||
| `src/context-providers/events/workflow_call.json` | Reusable workflow call context data |
|
||||
| `src/context-providers/descriptions.json` | Context variable descriptions for hover |
|
||||
|
||||
### workflow-parser
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `src/workflow-v1.0.json` | Workflow YAML schema definition |
|
||||
|
||||
## Generation
|
||||
|
||||
### Webhooks and Objects
|
||||
|
||||
The `webhooks.json` and `objects.json` files are generated from the [GitHub REST API description](https://github.com/github/rest-api-description):
|
||||
|
||||
```bash
|
||||
cd languageservice
|
||||
npm run update-webhooks
|
||||
```
|
||||
|
||||
This script:
|
||||
1. Fetches webhook schemas from the GitHub API description
|
||||
2. **Validates** all events are categorized (fails if new events are found)
|
||||
3. **Drops** events that aren't valid workflow triggers (see [Dropped Events](#dropped-events))
|
||||
4. **Strips** unused fields like `description` and `summary` (see [Stripped Fields](#stripped-fields))
|
||||
5. **Deduplicates** shared object definitions into `objects.json`
|
||||
6. Writes the optimized, pretty-printed JSON files
|
||||
|
||||
### Handling New Webhook Events
|
||||
|
||||
When GitHub adds a new webhook event, the script will fail with an error like:
|
||||
|
||||
```
|
||||
ERROR: New webhook event(s) detected!
|
||||
|
||||
The following events are not categorized:
|
||||
- new_event_name
|
||||
|
||||
Action required:
|
||||
1. Check if the event is a valid workflow trigger
|
||||
2. Add the event to DROPPED_EVENTS or KEPT_EVENTS
|
||||
```
|
||||
|
||||
**To resolve:**
|
||||
|
||||
1. Check [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows)
|
||||
|
||||
2. Edit `languageservice/script/webhooks/index.ts`:
|
||||
- Add to `KEPT_EVENTS` if it's a valid workflow trigger
|
||||
- Add to `DROPPED_EVENTS` if it's GitHub App or API-only
|
||||
|
||||
3. Run `npm run update-webhooks` and commit the changes
|
||||
|
||||
#### Viewing Full Unprocessed Data
|
||||
|
||||
To see all available fields and events before optimization:
|
||||
|
||||
```bash
|
||||
npm run update-webhooks -- --all
|
||||
```
|
||||
|
||||
This generates `webhooks.all.json` and `objects.all.json` (gitignored) containing the complete unprocessed data from the GitHub API.
|
||||
|
||||
### Other Files
|
||||
|
||||
The other JSON files (`schedule.json`, `workflow_call.json`, `descriptions.json`, `workflow-v1.0.json`) are manually maintained.
|
||||
|
||||
## Minification
|
||||
|
||||
At build time, all JSON files are minified (whitespace removed) to produce `.min.json` versions:
|
||||
|
||||
```bash
|
||||
npm run minify-json
|
||||
```
|
||||
|
||||
This runs automatically via `prebuild` and `pretest` hooks, so you don't need to run it manually.
|
||||
|
||||
The code imports the minified versions:
|
||||
|
||||
```ts
|
||||
import webhooks from "./events/webhooks.min.json"
|
||||
```
|
||||
|
||||
## CI Verification
|
||||
|
||||
CI verifies that generated source files are up-to-date:
|
||||
|
||||
1. Runs `npm run update-webhooks` to regenerate webhooks.json and objects.json
|
||||
2. Checks for uncommitted changes with `git diff --exit-code`
|
||||
|
||||
The `.min.json` files are generated at build time and are not committed to the repository.
|
||||
|
||||
If the build fails, run `cd languageservice && npm run update-webhooks` locally and commit the changes.
|
||||
|
||||
## Dropped Events
|
||||
|
||||
Webhook events that aren't valid workflow `on:` triggers are dropped (e.g., `installation`, `ping`, `member`, etc.). These are GitHub App or API-only events.
|
||||
|
||||
See `DROPPED_EVENTS` in `script/webhooks/index.ts` for the full list.
|
||||
|
||||
## Stripped Fields
|
||||
|
||||
Unused fields are stripped to reduce bundle size. For example:
|
||||
|
||||
```json
|
||||
// Before (from webhooks.all.json)
|
||||
{
|
||||
"type": "object",
|
||||
"name": "issue",
|
||||
"in": "body",
|
||||
"description": "The issue itself.",
|
||||
"isRequired": true,
|
||||
"childParamsGroups": [...]
|
||||
}
|
||||
|
||||
// After (webhooks.json)
|
||||
{
|
||||
"name": "issue",
|
||||
"description": "The issue itself.",
|
||||
"childParamsGroups": [...]
|
||||
}
|
||||
```
|
||||
|
||||
Only `name`, `description`, and `childParamsGroups` are kept — these are used for autocompletion and hover docs.
|
||||
|
||||
To compare all fields vs stripped, run `npm run update-webhooks -- --all` and diff the `.all.json` files against the regular ones.
|
||||
|
||||
See `EVENT_ACTION_FIELDS` and `BODY_PARAM_FIELDS` in `script/webhooks/index.ts` to modify what gets stripped.
|
||||
|
||||
## Schema Synchronization
|
||||
|
||||
The `workflow-v1.0.json` schema defines which activity types are valid for each workflow trigger event. A test in `workflow-parser/src/schema-sync.test.ts` verifies these stay in sync with `webhooks.json`.
|
||||
|
||||
### When the Test Fails
|
||||
|
||||
If the schema-sync test fails, you'll see an error like:
|
||||
|
||||
```
|
||||
Event "pull_request" is missing activity type "new_activity" in workflow-v1.0.json
|
||||
```
|
||||
|
||||
**To resolve:**
|
||||
|
||||
1. Check [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows) to verify the activity type is a valid workflow trigger:
|
||||
- Find the event section (e.g., "pull_request")
|
||||
- Look at the "Activity types" table — it lists which types can be used in `on.<event>.types`
|
||||
- If the type is listed there, it's a valid workflow trigger
|
||||
- If the type only appears in webhook docs but NOT in the workflow trigger docs, it's webhook-only
|
||||
|
||||
2. If it IS a valid workflow trigger:
|
||||
- Edit `workflow-parser/src/workflow-v1.0.json`
|
||||
- Find the `<event>-activity-type` definition (e.g., `pull-request-activity-type`)
|
||||
- Add the new activity type to `allowed-values`
|
||||
- Update the `description` in `<event>-activity` to list all types
|
||||
- Run `npm test` to regenerate the minified JSON
|
||||
|
||||
3. If it is NOT a valid workflow trigger (webhook-only):
|
||||
- Edit `workflow-parser/src/schema-sync.test.ts`
|
||||
- Add the type to `WEBHOOK_ONLY` for that event
|
||||
|
||||
### Known Discrepancies
|
||||
|
||||
The test tracks several types of known discrepancies:
|
||||
|
||||
| Category | Purpose | Example |
|
||||
|----------|---------|---------|
|
||||
| `WEBHOOK_ONLY` | Types in webhooks that aren't valid workflow triggers | `check_suite.requested` |
|
||||
| `SCHEMA_ONLY` | Types valid for workflows but missing from webhooks | `registry_package.updated` |
|
||||
| `NAME_MAPPINGS` | Different names for the same concept | `project_column`: webhook uses `edited`, schema uses `updated` |
|
||||
|
||||
### Bidirectional Checking
|
||||
|
||||
The test checks both directions:
|
||||
- **webhooks → schema**: Ensures all webhook activity types are in the schema (or listed in `WEBHOOK_ONLY`)
|
||||
- **schema → webhooks**: Ensures the schema doesn't have types that don't exist in webhooks (or listed in `SCHEMA_ONLY` or `NAME_MAPPINGS`)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.54",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -36,7 +36,7 @@
|
||||
"clean": "rimraf dist",
|
||||
"format": "prettier --write '**/*.ts'",
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
|
||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||
"prepublishOnly": "npm run build && npm run test",
|
||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||
@@ -44,7 +44,7 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 20"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
@@ -60,6 +60,6 @@
|
||||
"prettier": "^2.8.3",
|
||||
"rimraf": "^3.0.2",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.7.4"
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {ExpressionData} from "./data";
|
||||
import {Token} from "./lexer";
|
||||
import {ExpressionData} from "./data/index.js";
|
||||
import {Token} from "./lexer.js";
|
||||
|
||||
export interface ExprVisitor<R> {
|
||||
visitLiteral(literal: Literal): R;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {complete, CompletionItem, trimTokenVector} from "./completion";
|
||||
import {DescriptionDictionary} from "./completion/descriptionDictionary";
|
||||
import {BooleanData} from "./data/boolean";
|
||||
import {Dictionary} from "./data/dictionary";
|
||||
import {StringData} from "./data/string";
|
||||
import {wellKnownFunctions} from "./funcs";
|
||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
|
||||
import {Lexer, TokenType} from "./lexer";
|
||||
import {complete, CompletionItem, trimTokenVector} from "./completion.js";
|
||||
import {DescriptionDictionary} from "./completion/descriptionDictionary.js";
|
||||
import {BooleanData} from "./data/boolean.js";
|
||||
import {Dictionary} from "./data/dictionary.js";
|
||||
import {StringData} from "./data/string.js";
|
||||
import {wellKnownFunctions} from "./funcs.js";
|
||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
|
||||
import {Lexer, TokenType} from "./lexer.js";
|
||||
|
||||
const testContext = new Dictionary(
|
||||
{
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {DescriptionPair} from "./completion/descriptionDictionary";
|
||||
import {Dictionary, isDictionary} from "./data/dictionary";
|
||||
import {ExpressionData} from "./data/expressiondata";
|
||||
import {Evaluator} from "./evaluator";
|
||||
import {wellKnownFunctions} from "./funcs";
|
||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
|
||||
import {Lexer, Token, TokenType} from "./lexer";
|
||||
import {Parser} from "./parser";
|
||||
import {DescriptionPair} from "./completion/descriptionDictionary.js";
|
||||
import {Dictionary, isDictionary} from "./data/dictionary.js";
|
||||
import {ExpressionData} from "./data/expressiondata.js";
|
||||
import {Evaluator} from "./evaluator.js";
|
||||
import {FeatureFlags} from "./features.js";
|
||||
import {wellKnownFunctions} from "./funcs.js";
|
||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
|
||||
import {Lexer, Token, TokenType} from "./lexer.js";
|
||||
import {Parser} from "./parser.js";
|
||||
|
||||
export type CompletionItem = {
|
||||
label: string;
|
||||
@@ -26,13 +27,15 @@ export type CompletionItem = {
|
||||
* @param context Context available for the expression
|
||||
* @param extensionFunctions List of functions available
|
||||
* @param functions Optional map of functions to use during evaluation
|
||||
* @param featureFlags Optional feature flags to control which features are enabled
|
||||
* @returns Array of completion items
|
||||
*/
|
||||
export function complete(
|
||||
input: string,
|
||||
context: Dictionary,
|
||||
extensionFunctions: FunctionInfo[],
|
||||
functions?: Map<string, FunctionDefinition>
|
||||
functions?: Map<string, FunctionDefinition>,
|
||||
featureFlags?: FeatureFlags
|
||||
): CompletionItem[] {
|
||||
// Lex
|
||||
const lexer = new Lexer(input);
|
||||
@@ -63,7 +66,7 @@ export function complete(
|
||||
const result = contextKeys(context);
|
||||
|
||||
// Merge with functions
|
||||
result.push(...functionItems(extensionFunctions));
|
||||
result.push(...functionItems(extensionFunctions, featureFlags));
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -88,10 +91,15 @@ export function complete(
|
||||
return contextKeys(result);
|
||||
}
|
||||
|
||||
function functionItems(extensionFunctions: FunctionInfo[]): CompletionItem[] {
|
||||
function functionItems(extensionFunctions: FunctionInfo[], featureFlags?: FeatureFlags): CompletionItem[] {
|
||||
const result: CompletionItem[] = [];
|
||||
const flags = featureFlags ?? new FeatureFlags();
|
||||
|
||||
for (const fdef of [...Object.values(wellKnownFunctions), ...extensionFunctions]) {
|
||||
// Filter out case function if feature is disabled
|
||||
if (fdef.name === "case" && !flags.isEnabled("allowCaseFunction")) {
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
label: fdef.name,
|
||||
description: fdef.description,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {StringData} from "../data";
|
||||
import {DescriptionDictionary} from "./descriptionDictionary";
|
||||
import {StringData} from "../data/index.js";
|
||||
import {DescriptionDictionary} from "./descriptionDictionary.js";
|
||||
|
||||
describe("description dictionary", () => {
|
||||
it("pairs contains all values", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Dictionary} from "../data/dictionary";
|
||||
import {ExpressionData, Kind, Pair} from "../data/expressiondata";
|
||||
import {Dictionary} from "../data/dictionary.js";
|
||||
import {ExpressionData, Kind, Pair} from "../data/expressiondata.js";
|
||||
|
||||
export type DescriptionPair = Pair & {description?: string};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ExpressionData, ExpressionDataInterface, Kind, kindStr} from "./expressiondata";
|
||||
import {ExpressionData, ExpressionDataInterface, Kind, kindStr} from "./expressiondata.js";
|
||||
|
||||
export class Array implements ExpressionDataInterface {
|
||||
private v: ExpressionData[] = [];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ExpressionDataInterface, Kind} from "./expressiondata";
|
||||
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
|
||||
|
||||
export class BooleanData implements ExpressionDataInterface {
|
||||
constructor(public readonly value: boolean) {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Dictionary} from "./dictionary";
|
||||
import {StringData} from "./string";
|
||||
import {Dictionary} from "./dictionary.js";
|
||||
import {StringData} from "./string.js";
|
||||
|
||||
describe("dictionary", () => {
|
||||
it("pairs contains all values", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ExpressionData, ExpressionDataInterface, Kind, kindStr, Pair} from "./expressiondata";
|
||||
import {ExpressionData, ExpressionDataInterface, Kind, kindStr, Pair} from "./expressiondata.js";
|
||||
|
||||
export class Dictionary implements ExpressionDataInterface {
|
||||
private keys: string[] = [];
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Dictionary} from "./dictionary";
|
||||
import {Null} from "./null";
|
||||
import {Array} from "./array";
|
||||
import {StringData} from "./string";
|
||||
import {NumberData} from "./number";
|
||||
import {BooleanData} from "./boolean";
|
||||
import {Dictionary} from "./dictionary.js";
|
||||
import {Null} from "./null.js";
|
||||
import {Array} from "./array.js";
|
||||
import {StringData} from "./string.js";
|
||||
import {NumberData} from "./number.js";
|
||||
import {BooleanData} from "./boolean.js";
|
||||
|
||||
export enum Kind {
|
||||
String = 0,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export {Array} from "./array";
|
||||
export {BooleanData} from "./boolean";
|
||||
export {Dictionary} from "./dictionary";
|
||||
export {ExpressionData, Kind} from "./expressiondata";
|
||||
export {Null} from "./null";
|
||||
export {NumberData} from "./number";
|
||||
export {replacer} from "./replacer";
|
||||
export {reviver} from "./reviver";
|
||||
export {StringData} from "./string";
|
||||
export {Array} from "./array.js";
|
||||
export {BooleanData} from "./boolean.js";
|
||||
export {Dictionary} from "./dictionary.js";
|
||||
export {ExpressionData, Kind} from "./expressiondata.js";
|
||||
export {Null} from "./null.js";
|
||||
export {NumberData} from "./number.js";
|
||||
export {replacer} from "./replacer.js";
|
||||
export {reviver} from "./reviver.js";
|
||||
export {StringData} from "./string.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ExpressionDataInterface, Kind} from "./expressiondata";
|
||||
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
|
||||
|
||||
export class Null implements ExpressionDataInterface {
|
||||
public readonly kind = Kind.Null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {NumberData} from "./number";
|
||||
import {NumberData} from "./number.js";
|
||||
|
||||
describe("number", () => {
|
||||
it("coerces to string", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ExpressionDataInterface, Kind} from "./expressiondata";
|
||||
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
|
||||
|
||||
export class NumberData implements ExpressionDataInterface {
|
||||
constructor(public readonly value: number) {}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Array} from "./array";
|
||||
import {Dictionary} from "./dictionary";
|
||||
import {Null} from "./null";
|
||||
import {NumberData} from "./number";
|
||||
import {replacer} from "./replacer";
|
||||
import {StringData} from "./string";
|
||||
import {Array} from "./array.js";
|
||||
import {Dictionary} from "./dictionary.js";
|
||||
import {Null} from "./null.js";
|
||||
import {NumberData} from "./number.js";
|
||||
import {replacer} from "./replacer.js";
|
||||
import {StringData} from "./string.js";
|
||||
|
||||
describe("replacer", () => {
|
||||
it("null", () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Array} from "./array";
|
||||
import {BooleanData} from "./boolean";
|
||||
import {Dictionary} from "./dictionary";
|
||||
import {Null} from "./null";
|
||||
import {NumberData} from "./number";
|
||||
import {StringData} from "./string";
|
||||
import {Array} from "./array.js";
|
||||
import {BooleanData} from "./boolean.js";
|
||||
import {Dictionary} from "./dictionary.js";
|
||||
import {Null} from "./null.js";
|
||||
import {NumberData} from "./number.js";
|
||||
import {StringData} from "./string.js";
|
||||
|
||||
/**
|
||||
* Replacer can be passed to JSON.stringify to convert an ExpressionData object into plain JSON
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {Array} from "./array";
|
||||
import {BooleanData} from "./boolean";
|
||||
import {Dictionary} from "./dictionary";
|
||||
import {ExpressionData} from "./expressiondata";
|
||||
import {Null} from "./null";
|
||||
import {NumberData} from "./number";
|
||||
import {reviver} from "./reviver";
|
||||
import {StringData} from "./string";
|
||||
import {Array} from "./array.js";
|
||||
import {BooleanData} from "./boolean.js";
|
||||
import {Dictionary} from "./dictionary.js";
|
||||
import {ExpressionData} from "./expressiondata.js";
|
||||
import {Null} from "./null.js";
|
||||
import {NumberData} from "./number.js";
|
||||
import {reviver} from "./reviver.js";
|
||||
import {StringData} from "./string.js";
|
||||
|
||||
describe("reviver", () => {
|
||||
const tests: {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {Array as dArray} from "./array";
|
||||
import {BooleanData} from "./boolean";
|
||||
import {Dictionary} from "./dictionary";
|
||||
import {ExpressionData} from "./expressiondata";
|
||||
import {Null} from "./null";
|
||||
import {NumberData} from "./number";
|
||||
import {StringData} from "./string";
|
||||
import {Array as dArray} from "./array.js";
|
||||
import {BooleanData} from "./boolean.js";
|
||||
import {Dictionary} from "./dictionary.js";
|
||||
import {ExpressionData} from "./expressiondata.js";
|
||||
import {Null} from "./null.js";
|
||||
import {NumberData} from "./number.js";
|
||||
import {StringData} from "./string.js";
|
||||
|
||||
/**
|
||||
* Reviver can be passed to `JSON.parse` to convert plain JSON into an `ExpressionData` object.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ExpressionDataInterface, Kind} from "./expressiondata";
|
||||
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
|
||||
|
||||
export class StringData implements ExpressionDataInterface {
|
||||
constructor(public readonly value: string) {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Pos, Token, tokenString} from "./lexer";
|
||||
import {Pos, Token, tokenString} from "./lexer.js";
|
||||
|
||||
export const MAX_PARSER_DEPTH = 50;
|
||||
export const MAX_EXPRESSION_LENGTH = 21000;
|
||||
@@ -12,6 +12,7 @@ export enum ErrorType {
|
||||
ErrorExceededMaxLength,
|
||||
ErrorTooFewParameters,
|
||||
ErrorTooManyParameters,
|
||||
ErrorEvenParameters,
|
||||
ErrorUnrecognizedContext,
|
||||
ErrorUnrecognizedFunction
|
||||
}
|
||||
@@ -42,6 +43,8 @@ function errorDescription(typ: ErrorType): string {
|
||||
return "Too few parameters supplied";
|
||||
case ErrorType.ErrorTooManyParameters:
|
||||
return "Too many parameters supplied";
|
||||
case ErrorType.ErrorEvenParameters:
|
||||
return "Even number of parameters supplied, requires an odd number of parameters";
|
||||
case ErrorType.ErrorUnrecognizedContext:
|
||||
return "Unrecognized named-value";
|
||||
case ErrorType.ErrorUnrecognizedFunction:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as data from "./data";
|
||||
import {ExpressionEvaluationError} from "./errors";
|
||||
import {Evaluator} from "./evaluator";
|
||||
import {Lexer} from "./lexer";
|
||||
import {Parser} from "./parser";
|
||||
import * as data from "./data/index.js";
|
||||
import {ExpressionEvaluationError} from "./errors.js";
|
||||
import {Evaluator} from "./evaluator.js";
|
||||
import {Lexer} from "./lexer.js";
|
||||
import {Parser} from "./parser.js";
|
||||
|
||||
describe("evaluator", () => {
|
||||
const lexAndParse = (input: string) => {
|
||||
|
||||
@@ -10,14 +10,14 @@ import {
|
||||
Logical,
|
||||
Star,
|
||||
Unary
|
||||
} from "./ast";
|
||||
import * as data from "./data";
|
||||
import {FilteredArray} from "./filtered_array";
|
||||
import {wellKnownFunctions} from "./funcs";
|
||||
import {FunctionDefinition} from "./funcs/info";
|
||||
import {idxHelper} from "./idxHelper";
|
||||
import {TokenType} from "./lexer";
|
||||
import {equals, falsy, greaterThan, lessThan, truthy} from "./result";
|
||||
} from "./ast.js";
|
||||
import * as data from "./data/index.js";
|
||||
import {FilteredArray} from "./filtered_array.js";
|
||||
import {wellKnownFunctions} from "./funcs.js";
|
||||
import {FunctionDefinition} from "./funcs/info.js";
|
||||
import {idxHelper} from "./idxHelper.js";
|
||||
import {TokenType} from "./lexer.js";
|
||||
import {equals, falsy, greaterThan, lessThan, truthy} from "./result.js";
|
||||
|
||||
export class Evaluator implements ExprVisitor<data.ExpressionData> {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import {FeatureFlags} from "./features.js";
|
||||
|
||||
describe("FeatureFlags", () => {
|
||||
describe("isEnabled", () => {
|
||||
it("returns false by default when no options provided", () => {
|
||||
const flags = new FeatureFlags();
|
||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false by default when empty options provided", () => {
|
||||
const flags = new FeatureFlags({});
|
||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when feature is explicitly enabled", () => {
|
||||
const flags = new FeatureFlags({missingInputsQuickfix: true});
|
||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when feature is explicitly disabled", () => {
|
||||
const flags = new FeatureFlags({missingInputsQuickfix: false});
|
||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when all is enabled", () => {
|
||||
const flags = new FeatureFlags({all: true});
|
||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
|
||||
expect(flags.isEnabled("allowConcurrencyQueue")).toBe(true);
|
||||
});
|
||||
|
||||
it("explicit feature flag takes precedence over all:true", () => {
|
||||
const flags = new FeatureFlags({all: true, missingInputsQuickfix: false});
|
||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
|
||||
});
|
||||
|
||||
it("explicit feature flag takes precedence over all:false", () => {
|
||||
const flags = new FeatureFlags({all: false, missingInputsQuickfix: true});
|
||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnabledFeatures", () => {
|
||||
it("returns empty array when no features enabled", () => {
|
||||
const flags = new FeatureFlags();
|
||||
expect(flags.getEnabledFeatures()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns enabled features", () => {
|
||||
const flags = new FeatureFlags({missingInputsQuickfix: true});
|
||||
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix"]);
|
||||
});
|
||||
|
||||
it("returns all features when all is enabled", () => {
|
||||
const flags = new FeatureFlags({all: true});
|
||||
expect(flags.getEnabledFeatures()).toEqual([
|
||||
"missingInputsQuickfix",
|
||||
"blockScalarChompingWarning",
|
||||
"allowCaseFunction",
|
||||
"allowCopilotRequestsPermission",
|
||||
"allowConcurrencyQueue"
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Experimental feature flags.
|
||||
*
|
||||
* Individual feature flags take precedence over `all`.
|
||||
* Example: { all: true, missingInputsQuickfix: false } enables all
|
||||
* experimental features EXCEPT missingInputsQuickfix.
|
||||
*
|
||||
* When a feature graduates to stable, its flag becomes a no-op
|
||||
* (the feature will be enabled regardless of the configuration value).
|
||||
*/
|
||||
export interface ExperimentalFeatures {
|
||||
/**
|
||||
* Enable all experimental features.
|
||||
* Individual feature flags take precedence over this setting.
|
||||
* @default false
|
||||
*/
|
||||
all?: boolean;
|
||||
|
||||
/**
|
||||
* Enable quickfix code action for missing required action inputs.
|
||||
* @default false
|
||||
*/
|
||||
missingInputsQuickfix?: boolean;
|
||||
|
||||
/**
|
||||
* Warn when block scalars (| or >) use implicit clip chomping,
|
||||
* which adds a trailing newline that may be unintentional.
|
||||
* @default false
|
||||
*/
|
||||
blockScalarChompingWarning?: boolean;
|
||||
|
||||
/**
|
||||
* Enable the case() function in expressions.
|
||||
* @default false
|
||||
*/
|
||||
allowCaseFunction?: boolean;
|
||||
|
||||
/**
|
||||
* Enable the copilot-requests permission in workflow permissions.
|
||||
* @default false
|
||||
*/
|
||||
allowCopilotRequestsPermission?: boolean;
|
||||
|
||||
/**
|
||||
* Enable the queue property in workflow concurrency settings.
|
||||
* @default false
|
||||
*/
|
||||
allowConcurrencyQueue?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys of ExperimentalFeatures that represent actual features (excludes 'all')
|
||||
*/
|
||||
export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
|
||||
|
||||
/**
|
||||
* All known experimental feature keys.
|
||||
* This list must be kept in sync with the ExperimentalFeatures interface.
|
||||
*/
|
||||
const allFeatureKeys: ExperimentalFeatureKey[] = [
|
||||
"missingInputsQuickfix",
|
||||
"blockScalarChompingWarning",
|
||||
"allowCaseFunction",
|
||||
"allowCopilotRequestsPermission",
|
||||
"allowConcurrencyQueue"
|
||||
];
|
||||
|
||||
export class FeatureFlags {
|
||||
private readonly features: ExperimentalFeatures;
|
||||
|
||||
constructor(features?: ExperimentalFeatures) {
|
||||
this.features = features ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an experimental feature is enabled.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Explicit feature flag (if set)
|
||||
* 2. `all` flag (if set)
|
||||
* 3. false (default)
|
||||
*/
|
||||
isEnabled(feature: ExperimentalFeatureKey): boolean {
|
||||
const explicit = this.features[feature];
|
||||
if (explicit !== undefined) {
|
||||
return explicit;
|
||||
}
|
||||
return this.features.all ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns list of all enabled experimental features.
|
||||
*/
|
||||
getEnabledFeatures(): ExperimentalFeatureKey[] {
|
||||
return allFeatureKeys.filter(key => this.isEnabled(key));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
import * as data from "./data";
|
||||
import * as data from "./data/index.js";
|
||||
|
||||
export class FilteredArray extends data.Array {}
|
||||
|
||||
+17
-10
@@ -1,13 +1,14 @@
|
||||
import {ErrorType, ExpressionError} from "./errors";
|
||||
import {contains} from "./funcs/contains";
|
||||
import {endswith} from "./funcs/endswith";
|
||||
import {format} from "./funcs/format";
|
||||
import {fromjson} from "./funcs/fromjson";
|
||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
|
||||
import {join} from "./funcs/join";
|
||||
import {startswith} from "./funcs/startswith";
|
||||
import {tojson} from "./funcs/tojson";
|
||||
import {Token} from "./lexer";
|
||||
import {ErrorType, ExpressionError} from "./errors.js";
|
||||
import {caseFunc} from "./funcs/case.js";
|
||||
import {contains} from "./funcs/contains.js";
|
||||
import {endswith} from "./funcs/endswith.js";
|
||||
import {format} from "./funcs/format.js";
|
||||
import {fromjson} from "./funcs/fromjson.js";
|
||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
|
||||
import {join} from "./funcs/join.js";
|
||||
import {startswith} from "./funcs/startswith.js";
|
||||
import {tojson} from "./funcs/tojson.js";
|
||||
import {Token} from "./lexer.js";
|
||||
|
||||
export type ParseContext = {
|
||||
allowUnknownKeywords: boolean;
|
||||
@@ -16,6 +17,7 @@ export type ParseContext = {
|
||||
};
|
||||
|
||||
export const wellKnownFunctions: {[name: string]: FunctionDefinition} = {
|
||||
case: caseFunc,
|
||||
contains: contains,
|
||||
endswith: endswith,
|
||||
format: format,
|
||||
@@ -53,4 +55,9 @@ export function validateFunction(context: ParseContext, identifier: Token, argCo
|
||||
if (argCount > f.maxArgs) {
|
||||
throw new ExpressionError(ErrorType.ErrorTooManyParameters, identifier);
|
||||
}
|
||||
|
||||
// case function requires an odd number of arguments
|
||||
if (name === "case" && argCount % 2 === 0) {
|
||||
throw new ExpressionError(ErrorType.ErrorEvenParameters, identifier);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import {ExpressionData, Kind} from "../data/index.js";
|
||||
import {FunctionDefinition} from "./info.js";
|
||||
|
||||
export const caseFunc: FunctionDefinition = {
|
||||
name: "case",
|
||||
description:
|
||||
"`case( pred1, val1, pred2, val2, ..., default )`\n\nEvaluates predicates in order and returns the value corresponding to the first predicate that evaluates to `true`. If no predicate matches, it returns the last argument as the default value.",
|
||||
minArgs: 3,
|
||||
maxArgs: Number.MAX_SAFE_INTEGER,
|
||||
call: (...args: ExpressionData[]): ExpressionData => {
|
||||
// Evaluate predicate-result pairs
|
||||
for (let i = 0; i < args.length - 1; i += 2) {
|
||||
const predicate = args[i];
|
||||
|
||||
// Predicate must be a boolean
|
||||
if (predicate.kind !== Kind.Boolean) {
|
||||
throw new Error("case predicate must evaluate to a boolean value");
|
||||
}
|
||||
|
||||
// If predicate is true, return the corresponding result
|
||||
if (predicate.value) {
|
||||
return args[i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
// No predicate matched, return default (last argument)
|
||||
return args[args.length - 1];
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import {BooleanData, ExpressionData, Kind} from "../data";
|
||||
import {equals} from "../result";
|
||||
import {FunctionDefinition} from "./info";
|
||||
import {BooleanData, ExpressionData, Kind} from "../data/index.js";
|
||||
import {equals} from "../result.js";
|
||||
import {FunctionDefinition} from "./info.js";
|
||||
|
||||
export const contains: FunctionDefinition = {
|
||||
name: "contains",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {BooleanData, ExpressionData} from "../data";
|
||||
import {toUpperSpecial} from "../result";
|
||||
import {FunctionDefinition} from "./info";
|
||||
import {BooleanData, ExpressionData} from "../data/index.js";
|
||||
import {toUpperSpecial} from "../result.js";
|
||||
import {FunctionDefinition} from "./info.js";
|
||||
|
||||
export const endswith: FunctionDefinition = {
|
||||
name: "endsWith",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Null, NumberData, StringData} from "../data";
|
||||
import {format} from "./format";
|
||||
import {Null, NumberData, StringData} from "../data/index.js";
|
||||
import {format} from "./format.js";
|
||||
|
||||
describe("format", () => {
|
||||
it("null", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {ExpressionData, StringData} from "../data";
|
||||
import {FunctionDefinition} from "./info";
|
||||
import {ExpressionData, StringData} from "../data/index.js";
|
||||
import {FunctionDefinition} from "./info.js";
|
||||
|
||||
export const format: FunctionDefinition = {
|
||||
name: "format",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {ExpressionData} from "../data";
|
||||
import {reviver} from "../data/reviver";
|
||||
import {ExpressionEvaluationError} from "../errors";
|
||||
import {FunctionDefinition} from "./info";
|
||||
import {ExpressionData} from "../data/index.js";
|
||||
import {reviver} from "../data/reviver.js";
|
||||
import {ExpressionEvaluationError} from "../errors.js";
|
||||
import {FunctionDefinition} from "./info.js";
|
||||
|
||||
export const fromjson: FunctionDefinition = {
|
||||
name: "fromJson",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ExpressionData} from "../data";
|
||||
import {ExpressionData} from "../data/index.js";
|
||||
|
||||
export interface FunctionInfo {
|
||||
name: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {ExpressionData, Kind, StringData} from "../data";
|
||||
import {FunctionDefinition} from "./info";
|
||||
import {ExpressionData, Kind, StringData} from "../data/index.js";
|
||||
import {FunctionDefinition} from "./info.js";
|
||||
|
||||
export const join: FunctionDefinition = {
|
||||
name: "join",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {BooleanData, ExpressionData} from "../data";
|
||||
import {toUpperSpecial} from "../result";
|
||||
import {FunctionDefinition} from "./info";
|
||||
import {BooleanData, ExpressionData} from "../data/index.js";
|
||||
import {toUpperSpecial} from "../result.js";
|
||||
import {FunctionDefinition} from "./info.js";
|
||||
|
||||
export const startswith: FunctionDefinition = {
|
||||
name: "startsWith",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {ExpressionData, StringData} from "../data";
|
||||
import {replacer} from "../data/replacer";
|
||||
import {FunctionDefinition} from "./info";
|
||||
import {ExpressionData, StringData} from "../data/index.js";
|
||||
import {replacer} from "../data/replacer.js";
|
||||
import {FunctionDefinition} from "./info.js";
|
||||
|
||||
export const tojson: FunctionDefinition = {
|
||||
name: "toJson",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ExpressionData} from "./data";
|
||||
import {ExpressionData} from "./data/index.js";
|
||||
|
||||
export class idxHelper {
|
||||
public readonly str: string | undefined;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export {Expr} from "./ast";
|
||||
export {complete, CompletionItem} from "./completion";
|
||||
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary";
|
||||
export * as data from "./data";
|
||||
export {ExpressionError, ExpressionEvaluationError} from "./errors";
|
||||
export {Evaluator} from "./evaluator";
|
||||
export {wellKnownFunctions} from "./funcs";
|
||||
export {Lexer, Result} from "./lexer";
|
||||
export {Parser} from "./parser";
|
||||
export {Expr} from "./ast.js";
|
||||
export {complete, CompletionItem} from "./completion.js";
|
||||
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary.js";
|
||||
export * as data from "./data/index.js";
|
||||
export {ExpressionError, ExpressionEvaluationError} from "./errors.js";
|
||||
export {Evaluator} from "./evaluator.js";
|
||||
export {ExperimentalFeatureKey, ExperimentalFeatures, FeatureFlags} from "./features.js";
|
||||
export {wellKnownFunctions} from "./funcs.js";
|
||||
export {Lexer, Result} from "./lexer.js";
|
||||
export {Parser} from "./parser.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Lexer, Token, TokenType} from "./lexer";
|
||||
import {Lexer, Token, TokenType} from "./lexer.js";
|
||||
|
||||
describe("lexer", () => {
|
||||
const tests: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {StringData} from "./data";
|
||||
import {MAX_EXPRESSION_LENGTH} from "./errors";
|
||||
import {StringData} from "./data/index.js";
|
||||
import {MAX_EXPRESSION_LENGTH} from "./errors.js";
|
||||
|
||||
export enum TokenType {
|
||||
UNKNOWN,
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import {Binary, ContextAccess, Expr, FunctionCall, Grouping, IndexAccess, Literal, Logical, Star, Unary} from "./ast";
|
||||
import * as data from "./data";
|
||||
import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors";
|
||||
import {ParseContext, validateFunction} from "./funcs";
|
||||
import {FunctionInfo} from "./funcs/info";
|
||||
import {Token, TokenType} from "./lexer";
|
||||
import {
|
||||
Binary,
|
||||
ContextAccess,
|
||||
Expr,
|
||||
FunctionCall,
|
||||
Grouping,
|
||||
IndexAccess,
|
||||
Literal,
|
||||
Logical,
|
||||
Star,
|
||||
Unary
|
||||
} from "./ast.js";
|
||||
import * as data from "./data/index.js";
|
||||
import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors.js";
|
||||
import {ParseContext, validateFunction} from "./funcs.js";
|
||||
import {FunctionInfo} from "./funcs/info.js";
|
||||
import {Token, TokenType} from "./lexer.js";
|
||||
|
||||
export class Parser {
|
||||
private extContexts: Map<string, boolean>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {BooleanData, ExpressionData, NumberData, StringData} from "./data";
|
||||
import {coerceTypes, toUpperSpecial} from "./result";
|
||||
import {BooleanData, ExpressionData, NumberData, StringData} from "./data/index.js";
|
||||
import {coerceTypes, toUpperSpecial} from "./result.js";
|
||||
|
||||
describe("coerceTypes", () => {
|
||||
const tests: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as data from "./data";
|
||||
import * as data from "./data/index.js";
|
||||
|
||||
export function falsy(d: data.ExpressionData): boolean {
|
||||
switch (d.kind) {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import {Expr} from "./ast";
|
||||
import * as data from "./data";
|
||||
import {kindStr} from "./data/expressiondata";
|
||||
import {replacer} from "./data/replacer";
|
||||
import {reviver} from "./data/reviver";
|
||||
import {ExpressionError} from "./errors";
|
||||
import {Evaluator} from "./evaluator";
|
||||
import {Lexer, Result} from "./lexer";
|
||||
import {Parser} from "./parser";
|
||||
import {Expr} from "./ast.js";
|
||||
import * as data from "./data/index.js";
|
||||
import {kindStr} from "./data/expressiondata.js";
|
||||
import {replacer} from "./data/replacer.js";
|
||||
import {reviver} from "./data/reviver.js";
|
||||
import {ExpressionError} from "./errors.js";
|
||||
import {Evaluator} from "./evaluator.js";
|
||||
import {Lexer, Result} from "./lexer.js";
|
||||
import {Parser} from "./parser.js";
|
||||
|
||||
interface TestResult {
|
||||
value: data.ExpressionData;
|
||||
|
||||
Vendored
+157
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"case": [
|
||||
{
|
||||
"expr": "case(true, 'first', 'default')",
|
||||
"result": { "kind": "String", "value": "first" }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', 'default')",
|
||||
"result": { "kind": "String", "value": "default" }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, 'first', false, 'second', 'default')",
|
||||
"result": { "kind": "String", "value": "first" }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', true, 'second', 'default')",
|
||||
"result": { "kind": "String", "value": "second" }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', false, 'second', 'default')",
|
||||
"result": { "kind": "String", "value": "default" }
|
||||
},
|
||||
{
|
||||
"expr": "case(1 == 1, 'equal', 'not equal')",
|
||||
"result": { "kind": "String", "value": "equal" }
|
||||
},
|
||||
{
|
||||
"expr": "case(1 == 2, 'equal', 'not equal')",
|
||||
"result": { "kind": "String", "value": "not equal" }
|
||||
},
|
||||
{
|
||||
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
|
||||
"contexts": {
|
||||
"github": {
|
||||
"ref": "refs/heads/main",
|
||||
"event_name": "push"
|
||||
}
|
||||
},
|
||||
"result": { "kind": "String", "value": "main" }
|
||||
},
|
||||
{
|
||||
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
|
||||
"contexts": {
|
||||
"github": {
|
||||
"ref": "refs/heads/develop",
|
||||
"event_name": "pull_request"
|
||||
}
|
||||
},
|
||||
"result": { "kind": "String", "value": "pr" }
|
||||
},
|
||||
{
|
||||
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
|
||||
"contexts": {
|
||||
"github": {
|
||||
"ref": "refs/heads/develop",
|
||||
"event_name": "push"
|
||||
}
|
||||
},
|
||||
"result": { "kind": "String", "value": "other" }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, 123, 456)",
|
||||
"result": { "kind": "Number", "value": 123 }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 123, 456)",
|
||||
"result": { "kind": "Number", "value": 456 }
|
||||
},
|
||||
{
|
||||
"expr": "case(github.event == 'pull_request', 0, 1)",
|
||||
"contexts": {
|
||||
"github": {
|
||||
"event": "pull_request"
|
||||
}
|
||||
},
|
||||
"result": { "kind": "Number", "value": 0 }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 0, 1)",
|
||||
"result": { "kind": "Number", "value": 1 }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, false, true)",
|
||||
"result": { "kind": "Boolean", "value": false }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, false, true)",
|
||||
"result": { "kind": "Boolean", "value": true }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, '', 'default')",
|
||||
"result": { "kind": "String", "value": "" }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', '')",
|
||||
"result": { "kind": "String", "value": "" }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, fromJSON('[1,2,3]'), 'default')",
|
||||
"result": { "kind": "Array", "value": [1, 2, 3] }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, fromJSON('{\"key\":\"value\"}'), 'default')",
|
||||
"result": { "kind": "Object", "value": { "key": "value" } }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', false, 'second', false, 'third', false, 'fourth', 'default')",
|
||||
"result": { "kind": "String", "value": "default" }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', false, 'second', true, 'third', false, 'fourth', 'default')",
|
||||
"result": { "kind": "String", "value": "third" }
|
||||
},
|
||||
{
|
||||
"expr": "case('not a boolean', 'first', 'default')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "case predicate must evaluate to a boolean value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "case(1, 'first', 'default')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "case predicate must evaluate to a boolean value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "case(null, 'first', 'default')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "case predicate must evaluate to a boolean value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "case(fromJSON('[]'), 'first', 'default')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "case predicate must evaluate to a boolean value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "case(fromJSON('{}'), 'first', 'default')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "case predicate must evaluate to a boolean value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "case(true, 'first', false, 'second')",
|
||||
"err": {
|
||||
"kind": "parsing",
|
||||
"value": "Even number of parameters supplied, requires an odd number of parameters: 'case'. Located at position 1 within expression: case(true, 'first', false, 'second')"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,9 +2,12 @@
|
||||
"exclude": ["./src/**/*.test.ts"],
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"noEmit": false,
|
||||
"outDir": "./dist"
|
||||
"outDir": "./dist",
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@ The [package](https://www.npmjs.com/package/@actions/languageserver) contains Ty
|
||||
npm install @actions/languageserver
|
||||
```
|
||||
|
||||
To install the language server as a standalone CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @actions/languageserver
|
||||
```
|
||||
|
||||
This makes the `actions-languageserver` command available globally.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic usage using `vscode-languageserver-node`
|
||||
@@ -76,6 +84,11 @@ export interface InitializationOptions {
|
||||
* Desired log level
|
||||
*/
|
||||
logLevel?: LogLevel;
|
||||
|
||||
/**
|
||||
* Experimental features that are opt-in
|
||||
*/
|
||||
experimentalFeatures?: ExperimentalFeatures;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -92,6 +105,178 @@ const clientOptions: LanguageClientOptions = {
|
||||
const client = new LanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions);
|
||||
```
|
||||
|
||||
### Experimental Features
|
||||
|
||||
The language server supports opt-in experimental features via the `experimentalFeatures` initialization option. These features may change or be removed in between releases.
|
||||
|
||||
```typescript
|
||||
initializationOptions: {
|
||||
experimentalFeatures: {
|
||||
// Enable all experimental features
|
||||
all: true,
|
||||
|
||||
// Or enable specific features
|
||||
missingInputsQuickfix: true,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Available experimental features:**
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| `missingInputsQuickfix` | Code action to add missing required inputs for actions |
|
||||
| `blockScalarChompingWarning` | Warn when block scalars (`\|` or `>`) use implicit clip chomping, which adds a trailing newline that may be unintentional |
|
||||
| `allowConcurrencyQueue` | Enable the `concurrency.queue` workflow property |
|
||||
|
||||
Individual feature flags take precedence over `all`. For example, `{ all: true, missingInputsQuickfix: false }` enables all experimental features except `missingInputsQuickfix`.
|
||||
|
||||
When a feature graduates to stable, its flag becomes a no-op and the feature will be enabled regardless of the configuration value.
|
||||
|
||||
### Standalone CLI
|
||||
|
||||
After installing globally, you can run the language server directly:
|
||||
|
||||
```bash
|
||||
actions-languageserver --stdio
|
||||
```
|
||||
|
||||
This starts the language server using stdio transport, which is the standard way for editors to communicate with language servers.
|
||||
|
||||
### In Neovim
|
||||
|
||||
#### 1. Install the language server
|
||||
|
||||
```bash
|
||||
npm install -g @actions/languageserver
|
||||
```
|
||||
|
||||
#### 2. Set up filetype detection
|
||||
|
||||
Add this to your `init.lua` to detect GitHub Actions workflow files:
|
||||
|
||||
```lua
|
||||
vim.filetype.add({
|
||||
pattern = {
|
||||
[".*/%.github/workflows/.*%.ya?ml"] = "yaml.ghactions",
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
This sets the filetype to `yaml.ghactions` for YAML files in `.github/workflows/`, allowing you to keep separate YAML LSP configurations if needed.
|
||||
|
||||
#### 3. Create the LSP configuration
|
||||
|
||||
As of Neovim 0.11+ you can add this configuration in `~/.config/nvim/lsp/actionsls.lua`:
|
||||
|
||||
```lua
|
||||
local function get_github_token()
|
||||
local handle = io.popen("gh auth token 2>/dev/null")
|
||||
if not handle then return nil end
|
||||
local token = handle: read("*a"):gsub("%s+", "")
|
||||
handle:close()
|
||||
return token ~= "" and token or nil
|
||||
end
|
||||
|
||||
local function parse_github_remote(url)
|
||||
if not url or url == "" then return nil end
|
||||
|
||||
-- SSH format: git@github.com:owner/repo.git
|
||||
local owner, repo = url:match("git@github%.com:([^/]+)/([^/%.]+)")
|
||||
if owner and repo then
|
||||
return owner, repo: gsub("%.git$", "")
|
||||
end
|
||||
|
||||
-- HTTPS format: https://github.com/owner/repo.git
|
||||
owner, repo = url:match("github%.com/([^/]+)/([^/%.]+)")
|
||||
if owner and repo then
|
||||
return owner, repo:gsub("%.git$", "")
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
local function get_repo_info(owner, repo)
|
||||
local cmd = string.format(
|
||||
"gh repo view %s/%s --json id,owner --template '{{.id}}\t{{.owner.type}}' 2>/dev/null",
|
||||
owner,
|
||||
repo
|
||||
)
|
||||
local handle = io.popen(cmd)
|
||||
if not handle then return nil end
|
||||
local result = handle: read("*a"):gsub("%s+$", "")
|
||||
handle:close()
|
||||
|
||||
local id, owner_type = result:match("^(%d+)\t(.+)$")
|
||||
if id then
|
||||
return {
|
||||
id = tonumber(id),
|
||||
organizationOwned = owner_type == "Organization",
|
||||
}
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function get_repos_config()
|
||||
local handle = io.popen("git rev-parse --show-toplevel 2>/dev/null")
|
||||
if not handle then return nil end
|
||||
local git_root = handle: read("*a"):gsub("%s+", "")
|
||||
handle:close()
|
||||
|
||||
if git_root == "" then return nil end
|
||||
|
||||
handle = io.popen("git remote get-url origin 2>/dev/null")
|
||||
if not handle then return nil end
|
||||
local remote_url = handle:read("*a"):gsub("%s+", "")
|
||||
handle:close()
|
||||
|
||||
local owner, name = parse_github_remote(remote_url)
|
||||
if not owner or not name then return nil end
|
||||
|
||||
local info = get_repo_info(owner, name)
|
||||
|
||||
return {
|
||||
{
|
||||
id = info and info.id or 0,
|
||||
owner = owner,
|
||||
name = name,
|
||||
organizationOwned = info and info.organizationOwned or false,
|
||||
workspaceUri = "file://" .. git_root,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
return {
|
||||
cmd = { "actions-languageserver", "--stdio" },
|
||||
filetypes = { "yaml.ghactions" },
|
||||
root_markers = { ".git" },
|
||||
init_options = {
|
||||
-- Optional: provide a GitHub token and repo context for added functionality
|
||||
-- (e.g., repository-specific completions)
|
||||
sessionToken = get_github_token(),
|
||||
repos = get_repos_config(),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Enable the LSP
|
||||
|
||||
Add to your `init.lua`:
|
||||
|
||||
```lua
|
||||
vim.lsp.enable('actionsls')
|
||||
```
|
||||
|
||||
#### 5. Verify it's working
|
||||
|
||||
Open any `.github/workflows/*.yml` file and run:
|
||||
|
||||
```vim
|
||||
:checkhealth vim.lsp
|
||||
```
|
||||
|
||||
You should see `actionsls` in the list of attached clients.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](../CONTRIBUTING.md) at the root of the repository for general guidelines and recommendations.
|
||||
@@ -110,6 +295,27 @@ or to watch for changes
|
||||
npm run watch
|
||||
```
|
||||
|
||||
### Running the language server locally
|
||||
|
||||
After running
|
||||
|
||||
```bash
|
||||
npm run build:cli
|
||||
npm link
|
||||
```
|
||||
|
||||
`actions-languageserver` will be available globally. You can start it with:
|
||||
|
||||
```bash
|
||||
actions-languageserver --stdio
|
||||
```
|
||||
|
||||
Once linked you can also watch for changes and rebuild automatically:
|
||||
|
||||
```bash
|
||||
npm run watch:cli
|
||||
```
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
import "../dist/cli.bundle.cjs";
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.54",
|
||||
"description": "Language server for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -31,20 +31,25 @@
|
||||
"url": "https://github.com/actions/languageservices"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --build tsconfig.build.json",
|
||||
"build": "tsc --build tsconfig.build.json && npm run build:cli",
|
||||
"build:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs",
|
||||
"clean": "rimraf dist",
|
||||
"format": "prettier --write '**/*.ts'",
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
|
||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||
"prepublishOnly": "npm run build && npm run test",
|
||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
"watch": "tsc --build tsconfig.build.json --watch",
|
||||
"watch:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs --watch"
|
||||
},
|
||||
"bin": {
|
||||
"actions-languageserver": "./bin/actions-languageserver"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.20",
|
||||
"@actions/workflow-parser": "^0.3.20",
|
||||
"@actions/languageservice": "^0.3.54",
|
||||
"@actions/workflow-parser": "^0.3.54",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
@@ -52,23 +57,26 @@
|
||||
"yaml": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 20"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
"dist/**/*",
|
||||
"bin/**/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.56.0",
|
||||
"@typescript-eslint/parser": "^5.56.0",
|
||||
"esbuild": "^0.27.1",
|
||||
"eslint": "^8.36.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"fetch-mock": "^9.11.0",
|
||||
"jest": "^29.0.3",
|
||||
"node-fetch": "^2.6.7",
|
||||
"prettier": "^2.8.3",
|
||||
"rimraf": "^3.0.2",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.8.4"
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
|
||||
import {
|
||||
documentLinks,
|
||||
getCodeActions,
|
||||
getInlayHints,
|
||||
hover,
|
||||
validate,
|
||||
ValidationConfig
|
||||
} from "@actions/languageservice";
|
||||
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
|
||||
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {
|
||||
CodeAction,
|
||||
CodeActionKind,
|
||||
CodeActionParams,
|
||||
CompletionItem,
|
||||
Connection,
|
||||
DocumentLink,
|
||||
@@ -12,24 +22,27 @@ import {
|
||||
HoverParams,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
InlayHint,
|
||||
InlayHintParams,
|
||||
TextDocumentIdentifier,
|
||||
TextDocumentPositionParams,
|
||||
TextDocuments,
|
||||
TextDocumentSyncKind
|
||||
} from "vscode-languageserver";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {getClient} from "./client";
|
||||
import {Commands} from "./commands";
|
||||
import {contextProviders} from "./context-providers";
|
||||
import {descriptionProvider} from "./description-provider";
|
||||
import {getFileProvider} from "./file-provider";
|
||||
import {InitializationOptions, RepositoryContext} from "./initializationOptions";
|
||||
import {onCompletion} from "./on-completion";
|
||||
import {ReadFileRequest, Requests} from "./request";
|
||||
import {getActionsMetadataProvider} from "./utils/action-metadata";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {timeOperation} from "./utils/timer";
|
||||
import {valueProviders} from "./value-providers";
|
||||
import {getClient} from "./client.js";
|
||||
import {Commands} from "./commands.js";
|
||||
import {contextProviders} from "./context-providers.js";
|
||||
import {descriptionProvider} from "./description-provider.js";
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {getFileProvider} from "./file-provider.js";
|
||||
import {InitializationOptions, RepositoryContext} from "./initializationOptions.js";
|
||||
import {onCompletion} from "./on-completion.js";
|
||||
import {ReadFileRequest, Requests} from "./request.js";
|
||||
import {getActionsMetadataProvider} from "./utils/action-metadata.js";
|
||||
import {TTLCache} from "./utils/cache.js";
|
||||
import {timeOperation} from "./utils/timer.js";
|
||||
import {valueProviders} from "./value-providers.js";
|
||||
|
||||
export function initConnection(connection: Connection) {
|
||||
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
|
||||
@@ -39,6 +52,7 @@ export function initConnection(connection: Connection) {
|
||||
const cache = new TTLCache();
|
||||
|
||||
let hasWorkspaceFolderCapability = false;
|
||||
let featureFlags = new FeatureFlags();
|
||||
|
||||
// Register remote console logger with language service
|
||||
registerLogger(connection.console);
|
||||
@@ -62,6 +76,8 @@ export function initConnection(connection: Connection) {
|
||||
setLogLevel(options.logLevel);
|
||||
}
|
||||
|
||||
featureFlags = new FeatureFlags(options.experimentalFeatures);
|
||||
|
||||
const result: InitializeResult = {
|
||||
capabilities: {
|
||||
textDocumentSync: TextDocumentSyncKind.Full,
|
||||
@@ -72,6 +88,10 @@ export function initConnection(connection: Connection) {
|
||||
hoverProvider: true,
|
||||
documentLinkProvider: {
|
||||
resolveProvider: false
|
||||
},
|
||||
inlayHintProvider: true,
|
||||
codeActionProvider: {
|
||||
codeActionKinds: [CodeActionKind.QuickFix]
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -88,6 +108,11 @@ export function initConnection(connection: Connection) {
|
||||
});
|
||||
|
||||
connection.onInitialized(() => {
|
||||
const enabledFeatures = featureFlags.getEnabledFeatures();
|
||||
if (enabledFeatures.length > 0) {
|
||||
connection.console.info(`Experimental features enabled: ${enabledFeatures.join(", ")}`);
|
||||
}
|
||||
|
||||
if (hasWorkspaceFolderCapability) {
|
||||
connection.workspace.onDidChangeWorkspaceFolders(() => {
|
||||
clearCache();
|
||||
@@ -111,7 +136,8 @@ export function initConnection(connection: Connection) {
|
||||
actionsMetadataProvider: getActionsMetadataProvider(client, cache),
|
||||
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
||||
return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest);
|
||||
})
|
||||
}),
|
||||
featureFlags
|
||||
};
|
||||
|
||||
const result = await validate(textDocument, config);
|
||||
@@ -128,7 +154,8 @@ export function initConnection(connection: Connection) {
|
||||
getDocument(documents, textDocument),
|
||||
client,
|
||||
repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)),
|
||||
cache
|
||||
cache,
|
||||
featureFlags
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -158,6 +185,23 @@ export function initConnection(connection: Connection) {
|
||||
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
|
||||
});
|
||||
|
||||
connection.languages.inlayHint.on(async ({textDocument}: InlayHintParams): Promise<InlayHint[] | null> => {
|
||||
return timeOperation("inlayHints", () => {
|
||||
return getInlayHints(getDocument(documents, textDocument));
|
||||
});
|
||||
});
|
||||
|
||||
connection.onCodeAction((params: CodeActionParams): CodeAction[] => {
|
||||
const document = getDocument(documents, params.textDocument);
|
||||
return getCodeActions({
|
||||
uri: params.textDocument.uri,
|
||||
documentContent: document.getText(),
|
||||
diagnostics: params.context.diagnostics,
|
||||
only: params.context.only,
|
||||
featureFlags
|
||||
});
|
||||
});
|
||||
|
||||
// Make the text document manager listen on the connection
|
||||
// for open, change and close text document events
|
||||
documents.listen(connection);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||
import {Mode} from "@actions/languageservice/context-providers/default";
|
||||
import {contextProviders} from "./context-providers.js";
|
||||
import {RepositoryContext} from "./initializationOptions.js";
|
||||
import {TTLCache} from "./utils/cache.js";
|
||||
|
||||
describe("contextProviders", () => {
|
||||
const mockCache = new TTLCache();
|
||||
const mockRepo: RepositoryContext = {
|
||||
id: 123,
|
||||
owner: "test-owner",
|
||||
name: "test-repo",
|
||||
organizationOwned: true,
|
||||
workspaceUri: "file:///workspace"
|
||||
};
|
||||
const mockWorkflowContext: WorkflowContext = {
|
||||
uri: "test.yaml",
|
||||
template: undefined
|
||||
};
|
||||
|
||||
describe("when client is undefined", () => {
|
||||
it("should return incomplete context for secrets", async () => {
|
||||
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should return incomplete context for vars", async () => {
|
||||
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should preserve defaultContext and mark as incomplete for secrets", async () => {
|
||||
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||
const defaultContext = new DescriptionDictionary();
|
||||
defaultContext.add("EXISTING_SECRET", new data.StringData("test"));
|
||||
|
||||
const result = await config.getContext("secrets", defaultContext, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBe(defaultContext);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
expect((result as DescriptionDictionary).get("EXISTING_SECRET")).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return undefined for other contexts like steps", async () => {
|
||||
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||
const result = await config.getContext("steps", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when both client and repo are undefined", () => {
|
||||
it("should return incomplete context for secrets", async () => {
|
||||
const config = contextProviders(undefined, undefined, mockCache);
|
||||
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should return incomplete context for vars", async () => {
|
||||
const config = contextProviders(undefined, undefined, mockCache);
|
||||
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,11 @@ import {ContextProviderConfig} from "@actions/languageservice";
|
||||
import {Mode} from "@actions/languageservice/context-providers/default";
|
||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {getSecrets} from "./context-providers/secrets";
|
||||
import {getStepsContext} from "./context-providers/steps";
|
||||
import {getVariables} from "./context-providers/variables";
|
||||
import {RepositoryContext} from "./initializationOptions";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {getSecrets} from "./context-providers/secrets.js";
|
||||
import {getStepsContext} from "./context-providers/steps.js";
|
||||
import {getVariables} from "./context-providers/variables.js";
|
||||
import {RepositoryContext} from "./initializationOptions.js";
|
||||
import {TTLCache} from "./utils/cache.js";
|
||||
|
||||
export function contextProviders(
|
||||
client: Octokit | undefined,
|
||||
@@ -15,7 +15,18 @@ export function contextProviders(
|
||||
cache: TTLCache
|
||||
): ContextProviderConfig {
|
||||
if (!repo || !client) {
|
||||
return {getContext: () => Promise.resolve(undefined)};
|
||||
// When GitHub client/repo is unavailable, return an incomplete dictionary
|
||||
// to avoid false "Context access might be invalid" warnings
|
||||
return {
|
||||
getContext: (name: string, defaultContext: DescriptionDictionary | undefined) => {
|
||||
if (name === "secrets" || name === "vars") {
|
||||
const context = defaultContext || new DescriptionDictionary();
|
||||
context.complete = false;
|
||||
return Promise.resolve(context);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const getContext = async (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {ActionOutputs, ActionReference} from "@actions/languageservice/action";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
|
||||
export async function getActionOutputs(
|
||||
octokit: Octokit,
|
||||
|
||||
@@ -6,10 +6,10 @@ import {warn} from "@actions/languageservice/log";
|
||||
import {isMapping, isString} from "@actions/workflow-parser";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
|
||||
import {RepositoryContext} from "../initializationOptions";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {errorStatus} from "../utils/error";
|
||||
import {getRepoPermission} from "../utils/repo-permission";
|
||||
import {RepositoryContext} from "../initializationOptions.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
import {errorStatus} from "../utils/error.js";
|
||||
import {getRepoPermission} from "../utils/repo-permission.js";
|
||||
|
||||
export async function getSecrets(
|
||||
workflowContext: WorkflowContext,
|
||||
@@ -49,7 +49,7 @@ export async function getSecrets(
|
||||
if (isString(x.value)) {
|
||||
environmentName = x.value.value;
|
||||
} else {
|
||||
// this means we have a dynamic enviornment, in those situations we
|
||||
// this means we have a dynamic environment, in those situations we
|
||||
// want to make sure we skip doing secret validation
|
||||
secretsContext.complete = false;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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";
|
||||
|
||||
import {createWorkflowContext} from "../test-utils/workflow-context";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {getStepsContext} from "./steps";
|
||||
import {createWorkflowContext} from "../test-utils/workflow-context.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
import {getStepsContext} from "./steps.js";
|
||||
|
||||
const workflow = `
|
||||
name: Caching Primes
|
||||
@@ -63,6 +63,47 @@ 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");
|
||||
if (!stepContext) {
|
||||
throw new Error("Expected stepContext to be defined");
|
||||
}
|
||||
expect(isDescriptionDictionary(stepContext)).toBe(true);
|
||||
|
||||
// Get the outputs - should be a dictionary, not null
|
||||
const outputs = (stepContext as DescriptionDictionary).get("outputs");
|
||||
if (!outputs) {
|
||||
throw new Error("Expected outputs to be defined");
|
||||
}
|
||||
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 +124,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",
|
||||
|
||||
@@ -3,8 +3,8 @@ import {parseActionReference} from "@actions/languageservice/action";
|
||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {getActionOutputs} from "./action-outputs";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
import {getActionOutputs} from "./action-outputs.js";
|
||||
|
||||
export async function getStepsContext(
|
||||
octokit: Octokit,
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import {isMapping, isString} from "@actions/workflow-parser";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {RequestError} from "@octokit/request-error";
|
||||
|
||||
import {RepositoryContext} from "../initializationOptions";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {errorStatus} from "../utils/error";
|
||||
import {getRepoPermission} from "../utils/repo-permission";
|
||||
import {RepositoryContext} from "../initializationOptions.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
import {errorStatus} from "../utils/error.js";
|
||||
import {getRepoPermission} from "../utils/repo-permission.js";
|
||||
|
||||
export async function getVariables(
|
||||
workflowContext: WorkflowContext,
|
||||
@@ -26,6 +26,8 @@ export async function getVariables(
|
||||
return secretsContext;
|
||||
}
|
||||
|
||||
const variablesContext = defaultContext || new DescriptionDictionary();
|
||||
|
||||
let environmentName: string | undefined;
|
||||
if (workflowContext?.job?.environment) {
|
||||
if (isString(workflowContext.job.environment)) {
|
||||
@@ -35,14 +37,19 @@ export async function getVariables(
|
||||
if (isString(x.key) && x.key.value === "name") {
|
||||
if (isString(x.value)) {
|
||||
environmentName = x.value.value;
|
||||
} else {
|
||||
// this means we have a dynamic environment, in those situations we want to skip validation
|
||||
variablesContext.complete = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// if the expression is something like environment: ${{ ... }} then we want to skip validation
|
||||
variablesContext.complete = false;
|
||||
}
|
||||
}
|
||||
|
||||
const variablesContext = defaultContext || new DescriptionDictionary();
|
||||
try {
|
||||
const variables = await getRemoteVariables(octokit, cache, repo, environmentName);
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {DescriptionProvider} from "@actions/languageservice/hover";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {getActionDescription} from "./description-providers/action-description";
|
||||
import {getActionInputDescription} from "./description-providers/action-input";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {getActionDescription} from "./description-providers/action-description.js";
|
||||
import {getActionInputDescription} from "./description-providers/action-input.js";
|
||||
import {TTLCache} from "./utils/cache.js";
|
||||
|
||||
export function descriptionProvider(client: Octokit | undefined, cache: TTLCache): DescriptionProvider {
|
||||
const getDescription: DescriptionProvider["getDescription"] = async (context, token, path) => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import fetchMock from "fetch-mock";
|
||||
import {createWorkflowContext} from "../test-utils/workflow-context";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {getActionDescription} from "./action-description";
|
||||
import {actionsCheckoutMetadata} from "../test-utils/action-metadata";
|
||||
import {createWorkflowContext} from "../test-utils/workflow-context.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
import {getActionDescription} from "./action-description.js";
|
||||
import {actionsCheckoutMetadata} from "../test-utils/action-metadata.js";
|
||||
|
||||
const workflow = `
|
||||
name: Hello World
|
||||
|
||||
@@ -2,8 +2,8 @@ import {actionUrl, parseActionReference} from "@actions/languageservice/action";
|
||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
|
||||
export async function getActionDescription(client: Octokit, cache: TTLCache, step: Step): Promise<string | undefined> {
|
||||
if (!isActionStep(step)) {
|
||||
|
||||
@@ -2,10 +2,10 @@ import {StringToken} from "@actions/workflow-parser/templates/tokens/string-toke
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import {actionsCheckoutMetadata} from "../test-utils/action-metadata";
|
||||
import {createWorkflowContext} from "../test-utils/workflow-context";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {getActionInputDescription} from "./action-input";
|
||||
import {actionsCheckoutMetadata} from "../test-utils/action-metadata.js";
|
||||
import {createWorkflowContext} from "../test-utils/workflow-context.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
import {getActionInputDescription} from "./action-input.js";
|
||||
|
||||
const workflow = `
|
||||
name: Hello World
|
||||
|
||||
@@ -4,8 +4,8 @@ import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
|
||||
export async function getActionInputDescription(
|
||||
client: Octokit,
|
||||
|
||||
@@ -2,7 +2,7 @@ import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||
import {fileIdentifier} from "@actions/workflow-parser/workflows/file-reference";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {TTLCache} from "./utils/cache.js";
|
||||
import * as vscodeURI from "vscode-uri";
|
||||
|
||||
export function getFileProvider(
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "vscode-languageserver/browser";
|
||||
import {createConnection as createNodeConnection} from "vscode-languageserver/node";
|
||||
|
||||
import {initConnection} from "./connection";
|
||||
import {initConnection} from "./connection.js";
|
||||
|
||||
/** Helper function determining whether we are executing with node runtime */
|
||||
function isNode(): boolean {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {ExperimentalFeatures} from "@actions/expressions";
|
||||
import {LogLevel} from "@actions/languageservice/log";
|
||||
export {LogLevel} from "@actions/languageservice/log";
|
||||
|
||||
@@ -28,6 +29,12 @@ export interface InitializationOptions {
|
||||
* If a GitHub Enterprise Server should be used, the URL of the API endpoint, eg "https://ghe.my-company.com/api/v3"
|
||||
*/
|
||||
gitHubApiUrl?: string;
|
||||
|
||||
/**
|
||||
* Experimental features that are opt-in.
|
||||
* Features listed here may change or be removed without notice.
|
||||
*/
|
||||
experimentalFeatures?: ExperimentalFeatures;
|
||||
}
|
||||
|
||||
export interface RepositoryContext {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import {complete} from "@actions/languageservice/complete";
|
||||
import type {FeatureFlags} from "@actions/expressions";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {CompletionItem, Connection, Position} from "vscode-languageserver";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {contextProviders} from "./context-providers";
|
||||
import {getFileProvider} from "./file-provider";
|
||||
import {RepositoryContext} from "./initializationOptions";
|
||||
import {Requests} from "./request";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {valueProviders} from "./value-providers";
|
||||
import {contextProviders} from "./context-providers.js";
|
||||
import {getFileProvider} from "./file-provider.js";
|
||||
import {RepositoryContext} from "./initializationOptions.js";
|
||||
import {Requests} from "./request.js";
|
||||
import {TTLCache} from "./utils/cache.js";
|
||||
import {valueProviders} from "./value-providers.js";
|
||||
|
||||
export async function onCompletion(
|
||||
connection: Connection,
|
||||
@@ -15,11 +16,13 @@ export async function onCompletion(
|
||||
document: TextDocument,
|
||||
client: Octokit | undefined,
|
||||
repoContext: RepositoryContext | undefined,
|
||||
cache: TTLCache
|
||||
cache: TTLCache,
|
||||
featureFlags?: FeatureFlags
|
||||
): Promise<CompletionItem[]> {
|
||||
return await complete(document, position, {
|
||||
valueProviderConfig: repoContext && valueProviders(client, repoContext, cache),
|
||||
contextProviderConfig: repoContext && contextProviders(client, repoContext, cache),
|
||||
featureFlags,
|
||||
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
||||
return await connection.sendRequest(Requests.ReadFile, {path});
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import fetchMock from "fetch-mock";
|
||||
import {fetchActionMetadata} from "./action-metadata";
|
||||
import {TTLCache} from "./cache";
|
||||
import {fetchActionMetadata} from "./action-metadata.js";
|
||||
import {TTLCache} from "./cache.js";
|
||||
|
||||
// A simplified version of the action.yml file from actions/checkout
|
||||
const actionMetadataContent = `
|
||||
|
||||
@@ -3,8 +3,8 @@ import {ActionsMetadataProvider} from "@actions/languageservice";
|
||||
import {error} from "@actions/languageservice/log";
|
||||
import {Octokit, RestEndpointMethodTypes} from "@octokit/rest";
|
||||
import {parse} from "yaml";
|
||||
import {TTLCache} from "./cache";
|
||||
import {errorMessage, errorStatus} from "./error";
|
||||
import {TTLCache} from "./cache.js";
|
||||
import {errorMessage, errorStatus} from "./error.js";
|
||||
|
||||
export function getActionsMetadataProvider(
|
||||
client: Octokit | undefined,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {error} from "@actions/languageservice/log";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {RepositoryContext} from "../initializationOptions";
|
||||
import {TTLCache} from "./cache";
|
||||
import {errorStatus} from "./error";
|
||||
import {getUsername} from "./username";
|
||||
import {RepositoryContext} from "../initializationOptions.js";
|
||||
import {TTLCache} from "./cache.js";
|
||||
import {errorStatus} from "./error.js";
|
||||
import {getUsername} from "./username.js";
|
||||
|
||||
export type RepoPermission = "admin" | "write" | "read" | "none";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {TTLCache} from "./cache";
|
||||
import {TTLCache} from "./cache.js";
|
||||
|
||||
export async function getUsername(octokit: Octokit, cache: TTLCache): Promise<string> {
|
||||
return await cache.get(`/username`, undefined, () => fetchUsername(octokit));
|
||||
|
||||
@@ -2,11 +2,11 @@ import {ValueProviderConfig} from "@actions/languageservice";
|
||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||
import {ValueProviderKind} from "@actions/languageservice/value-providers/config";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {RepositoryContext} from "./initializationOptions";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {getActionInputValues} from "./value-providers/action-inputs";
|
||||
import {getEnvironments} from "./value-providers/job-environment";
|
||||
import {getRunnerLabels} from "./value-providers/runs-on";
|
||||
import {RepositoryContext} from "./initializationOptions.js";
|
||||
import {TTLCache} from "./utils/cache.js";
|
||||
import {getActionInputValues} from "./value-providers/action-inputs.js";
|
||||
import {getEnvironments} from "./value-providers/job-environment.js";
|
||||
import {getRunnerLabels} from "./value-providers/runs-on.js";
|
||||
|
||||
export function valueProviders(
|
||||
client: Octokit | undefined,
|
||||
|
||||
@@ -3,8 +3,8 @@ import {WorkflowContext} from "@actions/languageservice/context/workflow-context
|
||||
import {Value} from "@actions/languageservice/value-providers/config";
|
||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
|
||||
export async function getActionInputs(
|
||||
client: Octokit,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Value} from "@actions/languageservice/value-providers/config";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
|
||||
export async function getEnvironments(client: Octokit, cache: TTLCache, owner: string, name: string): Promise<Value[]> {
|
||||
const environments = await cache.get(`${owner}/${name}/environments`, undefined, () =>
|
||||
|
||||
@@ -2,8 +2,8 @@ import {log} from "@actions/languageservice/log";
|
||||
import {Value} from "@actions/languageservice/value-providers/config";
|
||||
import {DEFAULT_RUNNER_LABELS} from "@actions/languageservice/value-providers/default";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {errorMessage} from "../utils/error";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
import {errorMessage} from "../utils/error.js";
|
||||
|
||||
// Limitation: getRunnerLabels returns default hosted labels and labels for repository self-hosted runners.
|
||||
// It doesn't return labels for organization runners visible to the repository.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"noEmit": false,
|
||||
"outDir": "./dist"
|
||||
"outDir": "./dist",
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.54",
|
||||
"description": "Language service for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -35,24 +35,27 @@
|
||||
"clean": "rimraf dist",
|
||||
"format": "prettier --write '**/*.ts'",
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
|
||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||
"minify-json": "node ../script/minify-json.js src/context-providers/descriptions.json src/context-providers/events/webhooks.json src/context-providers/events/objects.json src/context-providers/events/schedule.json src/context-providers/events/workflow_call.json",
|
||||
"prebuild": "npm run minify-json",
|
||||
"prepublishOnly": "npm run build && npm run test",
|
||||
"pretest": "npm run minify-json",
|
||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
||||
"update-webhooks": "ts-node-esm script/webhooks/index.ts",
|
||||
"update-webhooks": "npx tsx script/webhooks/index.ts",
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.20",
|
||||
"@actions/workflow-parser": "^0.3.20",
|
||||
"@actions/expressions": "^0.3.54",
|
||||
"@actions/workflow-parser": "^0.3.54",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
"yaml": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 20"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
@@ -71,6 +74,6 @@
|
||||
"rimraf": "^3.0.2",
|
||||
"ts-jest": "^29.0.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^4.8.4"
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,185 @@ const schema = schemaImport as any;
|
||||
|
||||
const OUTPUT_PATH = "./src/context-providers/events/webhooks.json";
|
||||
const OBJECTS_PATH = "./src/context-providers/events/objects.json";
|
||||
const ALL_OUTPUT_PATH = "./src/context-providers/events/webhooks.all.json";
|
||||
const ALL_OBJECTS_PATH = "./src/context-providers/events/objects.all.json";
|
||||
const DROP_OUTPUT_PATH = "./src/context-providers/events/webhooks.drop.json";
|
||||
const DROP_OBJECTS_PATH = "./src/context-providers/events/objects.drop.json";
|
||||
const STRIP_OUTPUT_PATH = "./src/context-providers/events/webhooks.strip.json";
|
||||
const STRIP_OBJECTS_PATH = "./src/context-providers/events/objects.strip.json";
|
||||
|
||||
// Parse --all flag
|
||||
const generateAll = process.argv.includes("--all");
|
||||
|
||||
// Events to drop - not valid workflow triggers (GitHub App or API-only events)
|
||||
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
|
||||
const DROPPED_EVENTS = new Set([
|
||||
"branch_protection_configuration",
|
||||
"code_scanning_alert",
|
||||
"commit_comment",
|
||||
"custom_property",
|
||||
"custom_property_values",
|
||||
"dependabot_alert",
|
||||
"deploy_key",
|
||||
"github_app_authorization",
|
||||
"installation",
|
||||
"installation_repositories",
|
||||
"installation_target",
|
||||
"marketplace_purchase",
|
||||
"member",
|
||||
"membership",
|
||||
"merge_group",
|
||||
"meta",
|
||||
"org_block",
|
||||
"organization",
|
||||
"package",
|
||||
"personal_access_token_request",
|
||||
"ping",
|
||||
"repository",
|
||||
"repository_advisory",
|
||||
"repository_ruleset",
|
||||
"secret_scanning_alert",
|
||||
"secret_scanning_alert_location",
|
||||
"security_advisory",
|
||||
"security_and_analysis",
|
||||
"sponsorship",
|
||||
"star",
|
||||
"team",
|
||||
"team_add"
|
||||
]);
|
||||
|
||||
// Events to keep - valid workflow triggers
|
||||
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
|
||||
const KEPT_EVENTS = new Set([
|
||||
"branch_protection_rule",
|
||||
"check_run",
|
||||
"check_suite",
|
||||
"create",
|
||||
"delete",
|
||||
"deployment",
|
||||
"deployment_status",
|
||||
"discussion",
|
||||
"discussion_comment",
|
||||
"fork",
|
||||
"gollum",
|
||||
"issue_comment",
|
||||
"issues",
|
||||
"label",
|
||||
"milestone",
|
||||
"page_build",
|
||||
"project",
|
||||
"project_card",
|
||||
"project_column",
|
||||
"projects_v2",
|
||||
"projects_v2_item",
|
||||
"public",
|
||||
"pull_request",
|
||||
"pull_request_review",
|
||||
"pull_request_review_comment",
|
||||
"pull_request_review_thread",
|
||||
"push",
|
||||
"registry_package",
|
||||
"release",
|
||||
"repository_dispatch",
|
||||
"repository_import",
|
||||
"repository_vulnerability_alert",
|
||||
"status",
|
||||
"watch",
|
||||
"workflow_dispatch",
|
||||
"workflow_job",
|
||||
"workflow_run"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Fields to strip from the JSON data.
|
||||
*
|
||||
* EVENT_ACTION_FIELDS: stripped from each event action object (top level only)
|
||||
* Example event action object before stripping:
|
||||
* {
|
||||
* "description": "This event is triggered when...", // <-- stripped
|
||||
* "summary": "A brief summary", // <-- stripped
|
||||
* "availability": ["repository"], // <-- stripped
|
||||
* "category": "issues", // <-- stripped
|
||||
* "action": "opened", // kept
|
||||
* "bodyParameters": [...] // kept
|
||||
* }
|
||||
*
|
||||
* BODY_PARAM_FIELDS: stripped from every bodyParameters object, recursively through childParamsGroups
|
||||
* Example bodyParameter object before stripping:
|
||||
* {
|
||||
* "type": "object", // <-- stripped
|
||||
* "name": "changes", // kept (used for property names)
|
||||
* "in": "body", // <-- stripped
|
||||
* "description": "The changes that were made.", // kept (used for hover docs)
|
||||
* "isRequired": true, // <-- stripped
|
||||
* "enum": ["a", "b"], // <-- stripped
|
||||
* "default": "a", // <-- stripped
|
||||
* "childParamsGroups": [ // kept (used for nested properties)
|
||||
* {
|
||||
* "type": "string", // <-- stripped (recursive)
|
||||
* "name": "from", // kept
|
||||
* "isRequired": true // <-- stripped (recursive)
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
const EVENT_ACTION_FIELDS = ["description", "summary", "availability", "category"];
|
||||
const BODY_PARAM_FIELDS = ["type", "in", "isRequired", "enum", "default"];
|
||||
|
||||
/**
|
||||
* Strip fields from a bodyParameter object and recursively from childParamsGroups.
|
||||
*/
|
||||
function stripBodyParam(param: any): any {
|
||||
if (typeof param !== "object" || param === null) {
|
||||
return param;
|
||||
}
|
||||
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(param)) {
|
||||
if (BODY_PARAM_FIELDS.includes(key)) {
|
||||
continue; // Strip this field
|
||||
}
|
||||
if (key === "childParamsGroups" && Array.isArray(value)) {
|
||||
result[key] = value.map(stripBodyParam);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip unused fields from event action data.
|
||||
*/
|
||||
function stripEventActionFields(action: any): any {
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(action)) {
|
||||
if (EVENT_ACTION_FIELDS.includes(key)) {
|
||||
continue; // Strip this field
|
||||
}
|
||||
if (key === "bodyParameters" && Array.isArray(value)) {
|
||||
result[key] = value.map((p: any) => (typeof p === "number" ? p : stripBodyParam(p)));
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip unused fields from all webhooks.
|
||||
* Structure: { eventName: { actionName: { ...fields } } }
|
||||
*/
|
||||
function stripFields(webhooks: Record<string, Record<string, any>>): Record<string, Record<string, any>> {
|
||||
const result: Record<string, Record<string, any>> = {};
|
||||
for (const [eventName, actions] of Object.entries(webhooks)) {
|
||||
result[eventName] = {};
|
||||
for (const [actionName, actionData] of Object.entries(actions)) {
|
||||
result[eventName][actionName] = stripEventActionFields(actionData);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const rawWebhooks = Object.values(schema.webhooks || schema["x-webhooks"]) as any[];
|
||||
if (!rawWebhooks) {
|
||||
@@ -20,11 +199,51 @@ for (const webhook of Object.values(rawWebhooks)) {
|
||||
|
||||
await Promise.all(webhooks.map(webhook => webhook.process()));
|
||||
|
||||
// Check for unknown events (not in DROPPED_EVENTS or KEPT_EVENTS)
|
||||
const unknownEvents: string[] = [];
|
||||
for (const webhook of webhooks) {
|
||||
if (!DROPPED_EVENTS.has(webhook.category) && !KEPT_EVENTS.has(webhook.category)) {
|
||||
if (!unknownEvents.includes(webhook.category)) {
|
||||
unknownEvents.push(webhook.category);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unknownEvents.length > 0) {
|
||||
console.error("");
|
||||
console.error("══════════════════════════════════════════════════════════════════");
|
||||
console.error("ERROR: New webhook event(s) detected!");
|
||||
console.error("══════════════════════════════════════════════════════════════════");
|
||||
console.error("");
|
||||
console.error("The following events are not categorized:");
|
||||
for (const event of unknownEvents.sort()) {
|
||||
console.error(` - ${event}`);
|
||||
}
|
||||
console.error("");
|
||||
console.error("Action required:");
|
||||
console.error(" 1. Check if the event is a valid workflow trigger:");
|
||||
console.error(
|
||||
" https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows"
|
||||
);
|
||||
console.error("");
|
||||
console.error(" 2. Add the event to DROPPED_EVENTS or KEPT_EVENTS in:");
|
||||
console.error(" languageservice/script/webhooks/index.ts");
|
||||
console.error("");
|
||||
console.error(" 3. See docs/json-data-files.md for more details.");
|
||||
console.error("");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// The category is the name of the webhook
|
||||
const categorizedWebhooks: Record<string, Record<string, Webhook>> = {};
|
||||
for (const webhook of webhooks) {
|
||||
if (!webhook.action) webhook.action = "default";
|
||||
|
||||
// Drop unused events
|
||||
if (DROPPED_EVENTS.has(webhook.category)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (categorizedWebhooks[webhook.category]) {
|
||||
categorizedWebhooks[webhook.category][webhook.action] = webhook;
|
||||
} else {
|
||||
@@ -33,7 +252,59 @@ for (const webhook of webhooks) {
|
||||
}
|
||||
}
|
||||
|
||||
const objectsArray = deduplicateWebhooks(categorizedWebhooks);
|
||||
// Strip fields before deduplication
|
||||
const strippedWebhooks = stripFields(categorizedWebhooks);
|
||||
|
||||
// Deduplicate after dropping and stripping
|
||||
const objectsArray = deduplicateWebhooks(strippedWebhooks);
|
||||
|
||||
// Write optimized output
|
||||
await fs.writeFile(OBJECTS_PATH, JSON.stringify(objectsArray, null, 2));
|
||||
await fs.writeFile(OUTPUT_PATH, JSON.stringify(categorizedWebhooks, null, 2));
|
||||
await fs.writeFile(OUTPUT_PATH, JSON.stringify(strippedWebhooks, null, 2));
|
||||
|
||||
console.log(`Wrote ${OUTPUT_PATH} (${Object.keys(strippedWebhooks).length} events)`);
|
||||
console.log(`Wrote ${OBJECTS_PATH} (${objectsArray.length} objects)`);
|
||||
|
||||
// Optionally generate intermediate versions for size comparison
|
||||
if (generateAll) {
|
||||
// Helper to deep clone
|
||||
function clone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
// Build full webhooks (no drop, no strip) from fresh data
|
||||
const fullWebhooks: Record<string, Record<string, any>> = {};
|
||||
for (const webhook of webhooks) {
|
||||
const w = clone(webhook);
|
||||
if (!w.action) w.action = "default";
|
||||
fullWebhooks[w.category] ||= {};
|
||||
fullWebhooks[w.category][w.action] = w;
|
||||
}
|
||||
|
||||
// Generate all version (no drop, no strip)
|
||||
const allWebhooks = clone(fullWebhooks);
|
||||
const allObjects = deduplicateWebhooks(allWebhooks);
|
||||
await fs.writeFile(ALL_OUTPUT_PATH, JSON.stringify(allWebhooks, null, 2));
|
||||
await fs.writeFile(ALL_OBJECTS_PATH, JSON.stringify(allObjects, null, 2));
|
||||
console.log(`Wrote ${ALL_OUTPUT_PATH} (${Object.keys(allWebhooks).length} events)`);
|
||||
console.log(`Wrote ${ALL_OBJECTS_PATH} (${allObjects.length} objects)`);
|
||||
|
||||
// Generate drop-only version (drop events, no strip)
|
||||
const dropWebhooks = clone(fullWebhooks);
|
||||
for (const event of DROPPED_EVENTS) {
|
||||
delete dropWebhooks[event];
|
||||
}
|
||||
const dropObjects = deduplicateWebhooks(dropWebhooks);
|
||||
await fs.writeFile(DROP_OUTPUT_PATH, JSON.stringify(dropWebhooks, null, 2));
|
||||
await fs.writeFile(DROP_OBJECTS_PATH, JSON.stringify(dropObjects, null, 2));
|
||||
console.log(`Wrote ${DROP_OUTPUT_PATH} (${Object.keys(dropWebhooks).length} events)`);
|
||||
console.log(`Wrote ${DROP_OBJECTS_PATH} (${dropObjects.length} objects)`);
|
||||
|
||||
// Generate strip-only version (strip fields, no drop)
|
||||
const stripWebhooks = stripFields(clone(fullWebhooks));
|
||||
const stripObjects = deduplicateWebhooks(stripWebhooks);
|
||||
await fs.writeFile(STRIP_OUTPUT_PATH, JSON.stringify(stripWebhooks, null, 2));
|
||||
await fs.writeFile(STRIP_OBJECTS_PATH, JSON.stringify(stripObjects, null, 2));
|
||||
console.log(`Wrote ${STRIP_OUTPUT_PATH} (${Object.keys(stripWebhooks).length} events)`);
|
||||
console.log(`Wrote ${STRIP_OBJECTS_PATH} (${stripObjects.length} objects)`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {actionIdentifier, parseActionReference as parse} from "./action";
|
||||
import {actionIdentifier, parseActionReference as parse} from "./action.js";
|
||||
|
||||
describe("parseActionReference", () => {
|
||||
it("basic action", () => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types";
|
||||
import {CodeActionContext, CodeActionProvider} from "./types.js";
|
||||
import {getQuickfixProviders} from "./quickfix/quickfix-providers.js";
|
||||
|
||||
export interface CodeActionParams {
|
||||
uri: string;
|
||||
documentContent: string;
|
||||
diagnostics: Diagnostic[];
|
||||
only?: string[];
|
||||
featureFlags?: FeatureFlags;
|
||||
}
|
||||
|
||||
export function getCodeActions(params: CodeActionParams): CodeAction[] {
|
||||
const actions: CodeAction[] = [];
|
||||
const context: CodeActionContext = {
|
||||
uri: params.uri,
|
||||
documentContent: params.documentContent,
|
||||
featureFlags: params.featureFlags
|
||||
};
|
||||
|
||||
// Build providers map based on feature flags
|
||||
const providersByKind: Map<string, CodeActionProvider[]> = new Map([
|
||||
[CodeActionKind.QuickFix, getQuickfixProviders(params.featureFlags)]
|
||||
// [CodeActionKind.Refactor, getRefactorProviders(params.featureFlags)],
|
||||
// [CodeActionKind.Source, getSourceProviders(params.featureFlags)],
|
||||
// etc
|
||||
]);
|
||||
|
||||
// Filter to requested kinds, or use all if none specified
|
||||
const requestedKinds = params.only;
|
||||
const kindsToCheck = requestedKinds
|
||||
? [...providersByKind.keys()].filter(kind => requestedKinds.some(requested => kind.startsWith(requested)))
|
||||
: [...providersByKind.keys()];
|
||||
|
||||
for (const diagnostic of params.diagnostics) {
|
||||
for (const kind of kindsToCheck) {
|
||||
const providers = providersByKind.get(kind) ?? [];
|
||||
for (const provider of providers) {
|
||||
if (provider.diagnosticCodes.includes(diagnostic.code)) {
|
||||
const action = provider.createCodeAction(context, diagnostic);
|
||||
if (action) {
|
||||
action.kind = kind;
|
||||
action.diagnostics = [diagnostic];
|
||||
actions.push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export type {CodeActionContext, CodeActionProvider} from "./types.js";
|
||||
@@ -0,0 +1,245 @@
|
||||
import {isMapping} from "@actions/workflow-parser";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {CodeAction, Position, TextEdit} from "vscode-languageserver-types";
|
||||
import {error} from "../../log.js";
|
||||
import {findToken} from "../../utils/find-token.js";
|
||||
import {getOrParseWorkflow} from "../../utils/workflow-cache.js";
|
||||
import {DiagnosticCode, MissingInputsDiagnosticData} from "../../validate-action-reference.js";
|
||||
import {CodeActionContext, CodeActionProvider} from "../types.js";
|
||||
|
||||
/**
|
||||
* Information extracted from a step token needed to generate edits
|
||||
*/
|
||||
interface StepInfo {
|
||||
/** Column where step keys start (1-indexed), e.g., the column of "uses:" */
|
||||
stepKeyColumn: number;
|
||||
/** End line of the step (1-indexed) */
|
||||
stepEndLine: number;
|
||||
/** Detected indent size (spaces per level) */
|
||||
indentSize: number;
|
||||
/** Information about existing with: block, if present */
|
||||
withInfo?: {
|
||||
keyColumn: number;
|
||||
keyEndLine: number;
|
||||
valueEndLine: number;
|
||||
hasChildren: boolean;
|
||||
/** Column of first child input (1-indexed), for indentation detection */
|
||||
firstChildColumn?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const addMissingInputsProvider: CodeActionProvider = {
|
||||
diagnosticCodes: [DiagnosticCode.MissingRequiredInputs],
|
||||
|
||||
createCodeAction(context: CodeActionContext, diagnostic): CodeAction | undefined {
|
||||
const data = diagnostic.data as MissingInputsDiagnosticData | undefined;
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Parse the document to get the step token
|
||||
const stepInfo = getStepInfo(context, diagnostic.range.start);
|
||||
if (!stepInfo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const edits = createInputEdits(data.missingInputs, stepInfo);
|
||||
if (!edits || edits.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const inputNames = data.missingInputs.map(i => i.name).join(", ");
|
||||
|
||||
return {
|
||||
title: `Add missing input${data.missingInputs.length > 1 ? "s" : ""}: ${inputNames}`,
|
||||
edit: {
|
||||
changes: {
|
||||
[context.uri]: edits
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse the document and extract step information needed for generating edits.
|
||||
* Returns undefined if parsing fails or the step token cannot be found.
|
||||
*/
|
||||
function getStepInfo(context: CodeActionContext, diagnosticPosition: Position): StepInfo | undefined {
|
||||
// Parse the document (uses cache if available from validation)
|
||||
const file = {name: context.uri, content: context.documentContent};
|
||||
const parseResult = getOrParseWorkflow(file, context.uri);
|
||||
|
||||
if (!parseResult.value) {
|
||||
error("Failed to parse workflow for missing inputs quickfix");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Find the token at the diagnostic position
|
||||
const {path} = findToken(diagnosticPosition, parseResult.value);
|
||||
|
||||
// Walk up the path to find the step token (regular-step)
|
||||
const stepToken = findStepInPath(path);
|
||||
if (!stepToken) {
|
||||
error("Could not find step token for missing inputs quickfix");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return extractStepInfo(stepToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the step token (regular-step) in the token path
|
||||
*/
|
||||
function findStepInPath(path: TemplateToken[]): MappingToken | undefined {
|
||||
// Walk backwards through path to find the step
|
||||
for (let i = path.length - 1; i >= 0; i--) {
|
||||
if (path[i].definition?.key === "regular-step" && isMapping(path[i])) {
|
||||
return path[i] as MappingToken;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract position and indentation info from a step token
|
||||
*/
|
||||
function extractStepInfo(stepToken: MappingToken): StepInfo | undefined {
|
||||
if (!stepToken.range) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get the column of the first key in the step
|
||||
let stepKeyColumn = stepToken.range.start.column;
|
||||
if (stepToken.count > 0) {
|
||||
const firstEntry = stepToken.get(0);
|
||||
if (firstEntry?.key.range) {
|
||||
stepKeyColumn = firstEntry.key.range.start.column;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the with: block if present
|
||||
let withKey: ScalarToken | undefined;
|
||||
let withToken: TemplateToken | undefined;
|
||||
for (const {key, value} of stepToken) {
|
||||
if (key.toString() === "with") {
|
||||
withKey = key;
|
||||
withToken = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate indent size
|
||||
let indentSize = 2; // Default
|
||||
let withInfo: StepInfo["withInfo"];
|
||||
|
||||
if (withKey?.range && withToken?.range) {
|
||||
// Has with: block - extract its info
|
||||
const hasChildren = isMapping(withToken) && withToken.count > 0;
|
||||
let firstChildColumn: number | undefined;
|
||||
|
||||
if (hasChildren) {
|
||||
const firstChild = (withToken as MappingToken).get(0);
|
||||
if (firstChild?.key.range) {
|
||||
firstChildColumn = firstChild.key.range.start.column;
|
||||
// Detect indent size from with: children
|
||||
indentSize = firstChildColumn - withKey.range.start.column;
|
||||
}
|
||||
}
|
||||
|
||||
withInfo = {
|
||||
keyColumn: withKey.range.start.column,
|
||||
keyEndLine: withKey.range.end.line,
|
||||
valueEndLine: withToken.range.end.line,
|
||||
hasChildren,
|
||||
firstChildColumn
|
||||
};
|
||||
} else {
|
||||
// No with: block - detect indent size using heuristics
|
||||
// Based on the step key column position, estimate indent size
|
||||
// 2-space indent files typically have step keys at column 7
|
||||
// 4-space indent files typically have step keys at column 15
|
||||
const zeroIndexedCol = stepKeyColumn - 1;
|
||||
if (zeroIndexedCol >= 10) {
|
||||
indentSize = 4;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stepKeyColumn,
|
||||
stepEndLine: stepToken.range.end.line,
|
||||
indentSize,
|
||||
withInfo
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate text edits to add missing inputs
|
||||
*/
|
||||
function createInputEdits(missingInputs: MissingInputsDiagnosticData["missingInputs"], stepInfo: StepInfo): TextEdit[] {
|
||||
const formatInputLines = (indent: string) =>
|
||||
missingInputs.map(input => {
|
||||
const value = input.default ?? '""';
|
||||
return `${indent}${input.name}: ${value}`;
|
||||
});
|
||||
|
||||
if (stepInfo.withInfo) {
|
||||
// `with:` exists - add inputs to existing block
|
||||
const withIndent = stepInfo.withInfo.keyColumn - 1; // 0-indexed
|
||||
const inputIndentSize = stepInfo.withInfo.firstChildColumn
|
||||
? stepInfo.withInfo.firstChildColumn - stepInfo.withInfo.keyColumn
|
||||
: stepInfo.indentSize;
|
||||
|
||||
const inputIndent = " ".repeat(withIndent + inputIndentSize);
|
||||
const inputLines = formatInputLines(inputIndent);
|
||||
|
||||
// Calculate insert position
|
||||
let insertLine: number;
|
||||
if (stepInfo.withInfo.hasChildren) {
|
||||
// Insert after the last child (at end of with: block)
|
||||
// valueEndLine is 1-indexed, we want 0-indexed for Position
|
||||
insertLine = stepInfo.withInfo.valueEndLine - 1;
|
||||
} else {
|
||||
// Empty with: block - insert on the next line after with:
|
||||
// keyEndLine is 1-indexed, convert to 0-indexed and go to next line
|
||||
insertLine = stepInfo.withInfo.keyEndLine;
|
||||
}
|
||||
|
||||
const insertPosition: Position = {
|
||||
line: insertLine,
|
||||
character: 0
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
range: {start: insertPosition, end: insertPosition},
|
||||
newText: inputLines.map(line => line + "\n").join("")
|
||||
}
|
||||
];
|
||||
} else {
|
||||
// No `with:` key - add `with:` at the same level as other step keys
|
||||
const withKeyIndent = stepInfo.stepKeyColumn - 1; // 0-indexed (columns are 1-based)
|
||||
|
||||
const withIndent = " ".repeat(withKeyIndent);
|
||||
const inputIndent = " ".repeat(withKeyIndent + stepInfo.indentSize);
|
||||
const inputLines = formatInputLines(inputIndent);
|
||||
|
||||
const newText = `${withIndent}with:\n` + inputLines.map(line => `${line}\n`).join("");
|
||||
|
||||
// Insert at end of step
|
||||
// stepEndLine is 1-indexed, we want 0-indexed and insert before the line after
|
||||
const insertPosition: Position = {
|
||||
line: stepInfo.stepEndLine - 1,
|
||||
character: 0
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
range: {start: insertPosition, end: insertPosition},
|
||||
newText
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {CodeActionProvider} from "../types.js";
|
||||
import {addMissingInputsProvider} from "./add-missing-inputs.js";
|
||||
|
||||
export function getQuickfixProviders(featureFlags?: FeatureFlags): CodeActionProvider[] {
|
||||
const providers: CodeActionProvider[] = [];
|
||||
|
||||
if (featureFlags?.isEnabled("missingInputsQuickfix")) {
|
||||
providers.push(addMissingInputsProvider);
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as path from "path";
|
||||
import {fileURLToPath} from "url";
|
||||
import {loadTestCases, runTestCase} from "./runner.js";
|
||||
import {ValidationConfig} from "../../validate.js";
|
||||
import {ActionMetadata, ActionReference} from "../../action.js";
|
||||
import {clearCache} from "../../utils/workflow-cache.js";
|
||||
|
||||
// ESM-compatible __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Mock action metadata provider for tests
|
||||
const validationConfig: ValidationConfig = {
|
||||
actionsMetadataProvider: {
|
||||
fetchActionMetadata: (ref: ActionReference): Promise<ActionMetadata | undefined> => {
|
||||
const key = `${ref.owner}/${ref.name}@${ref.ref}`;
|
||||
|
||||
const metadata: Record<string, ActionMetadata> = {
|
||||
"actions/cache@v1": {
|
||||
name: "Cache",
|
||||
description: "Cache dependencies",
|
||||
inputs: {
|
||||
path: {
|
||||
description: "A list of files to cache",
|
||||
required: true
|
||||
},
|
||||
key: {
|
||||
description: "Cache key",
|
||||
required: true
|
||||
},
|
||||
"restore-keys": {
|
||||
description: "Restore keys",
|
||||
required: false
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions/setup-node@v3": {
|
||||
name: "Setup Node",
|
||||
description: "Setup Node.js",
|
||||
inputs: {
|
||||
"node-version": {
|
||||
description: "Node version",
|
||||
required: true,
|
||||
default: "16"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return Promise.resolve(metadata[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Point to the source testdata directory
|
||||
const testdataDir = path.join(__dirname, "testdata");
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("code action golden tests", () => {
|
||||
const testCases = loadTestCases(testdataDir);
|
||||
|
||||
if (testCases.length === 0) {
|
||||
it.todo("no test cases found - add .yml files to testdata/");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, async () => {
|
||||
const result = await runTestCase(testCase, validationConfig);
|
||||
|
||||
if (!result.passed) {
|
||||
let errorMessage = result.error || "Test failed";
|
||||
|
||||
if (result.expected !== undefined && result.actual !== undefined) {
|
||||
errorMessage += "\n\n";
|
||||
errorMessage += "=== EXPECTED (golden file) ===\n";
|
||||
errorMessage += result.expected;
|
||||
errorMessage += "\n\n";
|
||||
errorMessage += "=== ACTUAL ===\n";
|
||||
errorMessage += result.actual;
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import {TextEdit} from "vscode-languageserver-types";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {validate, ValidationConfig} from "../../validate.js";
|
||||
import {getCodeActions, CodeActionParams} from "../code-actions.js";
|
||||
|
||||
// Marker pattern: # want "diagnostic message" fix="code-action-name"
|
||||
const MARKER_PATTERN = /#\s*want\s+"([^"]+)"(?:\s+fix="([^"]+)")?/;
|
||||
|
||||
export interface TestCase {
|
||||
name: string;
|
||||
inputPath: string;
|
||||
goldenPath: string;
|
||||
input: string;
|
||||
golden: string;
|
||||
markers: Marker[];
|
||||
}
|
||||
|
||||
export interface Marker {
|
||||
line: number;
|
||||
message: string;
|
||||
fix?: string;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse markers from input file content
|
||||
*/
|
||||
export function parseMarkers(content: string): Marker[] {
|
||||
const lines = content.split("\n");
|
||||
const markers: Marker[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(MARKER_PATTERN);
|
||||
if (match) {
|
||||
markers.push({
|
||||
line: i,
|
||||
message: match[1],
|
||||
fix: match[2]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip markers from content (for processing)
|
||||
*/
|
||||
export function stripMarkers(content: string): string {
|
||||
return content
|
||||
.split("\n")
|
||||
.map(line => line.replace(MARKER_PATTERN, "").trimEnd())
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all test cases from a testdata directory
|
||||
*/
|
||||
export function loadTestCases(testdataDir: string): TestCase[] {
|
||||
const testCases: TestCase[] = [];
|
||||
|
||||
function walkDir(dir: string) {
|
||||
const entries = fs.readdirSync(dir, {withFileTypes: true});
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walkDir(fullPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".yml") && !entry.name.endsWith(".golden.yml")) {
|
||||
const goldenPath = fullPath.replace(".yml", ".golden.yml");
|
||||
|
||||
if (fs.existsSync(goldenPath)) {
|
||||
const input = fs.readFileSync(fullPath, "utf-8");
|
||||
const golden = fs.readFileSync(goldenPath, "utf-8");
|
||||
|
||||
testCases.push({
|
||||
name: path.relative(testdataDir, fullPath),
|
||||
inputPath: fullPath,
|
||||
goldenPath,
|
||||
input,
|
||||
golden,
|
||||
markers: parseMarkers(input)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkDir(testdataDir);
|
||||
return testCases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply text edits to a document
|
||||
*/
|
||||
export function applyEdits(content: string, edits: TextEdit[]): string {
|
||||
// Sort edits in reverse order by position to apply from bottom to top
|
||||
const sortedEdits = [...edits].sort((a, b) => {
|
||||
if (b.range.start.line !== a.range.start.line) {
|
||||
return b.range.start.line - a.range.start.line;
|
||||
}
|
||||
return b.range.start.character - a.range.start.character;
|
||||
});
|
||||
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (const edit of sortedEdits) {
|
||||
const startLine = edit.range.start.line;
|
||||
const startChar = edit.range.start.character;
|
||||
const endLine = edit.range.end.line;
|
||||
const endChar = edit.range.end.character;
|
||||
|
||||
const before = lines[startLine].slice(0, startChar);
|
||||
const after = lines[endLine].slice(endChar);
|
||||
|
||||
const newLines = edit.newText.split("\n");
|
||||
newLines[0] = before + newLines[0];
|
||||
newLines[newLines.length - 1] = newLines[newLines.length - 1] + after;
|
||||
|
||||
lines.splice(startLine, endLine - startLine + 1, ...newLines);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single test case
|
||||
*/
|
||||
export async function runTestCase(testCase: TestCase, validationConfig: ValidationConfig): Promise<TestResult> {
|
||||
const strippedInput = stripMarkers(testCase.input);
|
||||
const document = TextDocument.create("file:///test.yml", "yaml", 1, strippedInput);
|
||||
|
||||
// 1. Validate and get diagnostics
|
||||
const diagnostics = await validate(document, validationConfig);
|
||||
|
||||
// 2. Verify all expected diagnostics are present
|
||||
const missingDiagnostics: string[] = [];
|
||||
for (const marker of testCase.markers) {
|
||||
const found = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
|
||||
if (!found) {
|
||||
missingDiagnostics.push(`line ${marker.line}: "${marker.message}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingDiagnostics.length > 0) {
|
||||
return {
|
||||
name: testCase.name,
|
||||
passed: false,
|
||||
error: `Missing expected diagnostics:\n ${missingDiagnostics.join(
|
||||
"\n "
|
||||
)}\n\nActual diagnostics:\n ${diagnostics.map(d => `line ${d.range.start.line}: "${d.message}"`).join("\n ")}`
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Collect all edits from all matching code actions
|
||||
const allEdits: TextEdit[] = [];
|
||||
|
||||
for (const marker of testCase.markers) {
|
||||
if (!marker.fix) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const diagnostic = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
|
||||
|
||||
if (!diagnostic) {
|
||||
continue; // Already reported above
|
||||
}
|
||||
|
||||
const params: CodeActionParams = {
|
||||
uri: document.uri,
|
||||
documentContent: strippedInput,
|
||||
diagnostics: [diagnostic],
|
||||
featureFlags: new FeatureFlags({all: true})
|
||||
};
|
||||
|
||||
const actions = getCodeActions(params);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- marker.fix is checked at the start of the loop
|
||||
const matchingAction = actions.find(a => a.title.toLowerCase().includes(marker.fix!.toLowerCase()));
|
||||
|
||||
if (!matchingAction) {
|
||||
return {
|
||||
name: testCase.name,
|
||||
passed: false,
|
||||
error: `Code action "${marker.fix}" not found for diagnostic on line ${marker.line}.\nAvailable actions: ${
|
||||
actions.map(a => a.title).join(", ") || "(none)"
|
||||
}`
|
||||
};
|
||||
}
|
||||
|
||||
if (!matchingAction.edit?.changes) {
|
||||
return {
|
||||
name: testCase.name,
|
||||
passed: false,
|
||||
error: `Code action "${marker.fix}" has no edits`
|
||||
};
|
||||
}
|
||||
|
||||
const edits = matchingAction.edit.changes[document.uri] || [];
|
||||
allEdits.push(...edits);
|
||||
}
|
||||
|
||||
// 4. Apply all edits and compare to golden file
|
||||
const actualOutput = applyEdits(strippedInput, allEdits);
|
||||
const expectedOutput = testCase.golden;
|
||||
|
||||
if (actualOutput.trim() !== expectedOutput.trim()) {
|
||||
return {
|
||||
name: testCase.name,
|
||||
passed: false,
|
||||
error: "Output does not match golden file",
|
||||
expected: expectedOutput,
|
||||
actual: actualOutput
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: testCase.name,
|
||||
passed: true
|
||||
};
|
||||
}
|
||||
languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key-without-inputs.golden.yml
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ""
|
||||
key: ""
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
restore-keys: ${{ runner.os }}-
|
||||
path: ""
|
||||
key: ""
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
|
||||
restore-keys: ${{ runner.os }}-
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ""
|
||||
key: ""
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user