Support YAML anchors and aliases (#234)
Fixes https://github.com/github/vscode-github-actions/issues/405 YAML anchors (&name) and aliases (*name) are now properly supported. When an alias is encountered during parsing, it is resolved to its anchored value, making aliases transparent to the rest of the system. Changes: - workflow-parser: Handle isAlias nodes in YamlObjectReader.getNodes() - languageservice: Add tests for various anchor/alias patterns Test cases: - Anchors in env mappings - Multiple aliases to same anchor - Anchors in matrix strategy - Anchors in steps - Scalar anchors (e.g., runs-on)
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import {validate} from "./validate";
|
||||
import {createDocument} from "./test-utils/document";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("YAML anchors and aliases", () => {
|
||||
it("should handle anchors and aliases in env", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
env: &env
|
||||
ENV1: env1
|
||||
ENV2: env2
|
||||
steps:
|
||||
- run: exit 0
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
env: *env
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle multiple aliases to the same anchor", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
env: &shared
|
||||
SHARED: true
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
env: *shared
|
||||
steps:
|
||||
- run: exit 0
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
env: *shared
|
||||
steps:
|
||||
- run: exit 0
|
||||
job3:
|
||||
runs-on: ubuntu-latest
|
||||
env: *shared
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle anchors in matrix strategy", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include: &matrix-include
|
||||
- os: ubuntu-latest
|
||||
node: 18
|
||||
- os: windows-latest
|
||||
node: 20
|
||||
steps:
|
||||
- run: exit 0
|
||||
test2:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include: *matrix-include
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle anchors in steps", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- &checkout
|
||||
uses: actions/checkout@v4
|
||||
- run: npm test
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- *checkout
|
||||
- run: npm run deploy
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle scalar anchors", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: &runner ubuntu-latest
|
||||
steps:
|
||||
- run: exit 0
|
||||
test:
|
||||
runs-on: *runner
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should work without anchors (control test)", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ENV1: env1
|
||||
ENV2: env2
|
||||
steps:
|
||||
- run: exit 0
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ENV1: env1
|
||||
ENV2: env2
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle circular aliases without hanging", async () => {
|
||||
// This is an invalid use case (alias referencing parent) but should not hang
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env: &myenv
|
||||
FOO: bar
|
||||
nested: *myenv
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
// Should complete without hanging - circular portion is silently ignored
|
||||
// which may cause downstream validation errors, but that's acceptable
|
||||
const result = await validate(doc);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle undefined alias references", async () => {
|
||||
// Reference to non-existent anchor - yaml library should report error
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env: *nonexistent
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,16 @@
|
||||
import {isCollection, isDocument, isMap, isPair, isScalar, isSeq, LineCounter, parseDocument, Scalar} from "yaml";
|
||||
import {
|
||||
isAlias,
|
||||
isCollection,
|
||||
isDocument,
|
||||
isMap,
|
||||
isPair,
|
||||
isScalar,
|
||||
isSeq,
|
||||
LineCounter,
|
||||
parseDocument,
|
||||
Scalar
|
||||
} from "yaml";
|
||||
import type {Document} from "yaml";
|
||||
import type {LinePos} from "yaml/dist/errors";
|
||||
import type {NodeBase} from "yaml/dist/nodes/Node";
|
||||
import {ObjectReader} from "../templates/object-reader";
|
||||
@@ -22,30 +34,31 @@ export type YamlError = {
|
||||
export class YamlObjectReader implements ObjectReader {
|
||||
private readonly _generator: Generator<ParseEvent>;
|
||||
private _current!: IteratorResult<ParseEvent>;
|
||||
private readonly doc: Document;
|
||||
private fileId?: number;
|
||||
private lineCounter = new LineCounter();
|
||||
|
||||
public errors: YamlError[] = [];
|
||||
|
||||
constructor(fileId: number | undefined, content: string) {
|
||||
const doc = parseDocument(content, {
|
||||
this.doc = parseDocument(content, {
|
||||
lineCounter: this.lineCounter,
|
||||
keepSourceTokens: true,
|
||||
uniqueKeys: false // Uniqueness is validated by the template reader
|
||||
});
|
||||
for (const err of doc.errors) {
|
||||
for (const err of this.doc.errors) {
|
||||
this.errors.push({message: err.message, range: rangeFromLinePos(err.linePos)});
|
||||
}
|
||||
this._generator = this.getNodes(doc);
|
||||
this._generator = this.getNodes(this.doc, new Set());
|
||||
this.fileId = fileId;
|
||||
}
|
||||
|
||||
private *getNodes(node: unknown): Generator<ParseEvent, void> {
|
||||
private *getNodes(node: unknown, aliasResolutionStack: Set<unknown>): Generator<ParseEvent, void> {
|
||||
let range = this.getRange(node as NodeBase | undefined);
|
||||
|
||||
if (isDocument(node)) {
|
||||
yield new ParseEvent(EventType.DocumentStart);
|
||||
for (const item of this.getNodes(node.contents)) {
|
||||
for (const item of this.getNodes(node.contents, new Set())) {
|
||||
yield item;
|
||||
}
|
||||
yield new ParseEvent(EventType.DocumentEnd);
|
||||
@@ -59,7 +72,7 @@ export class YamlObjectReader implements ObjectReader {
|
||||
}
|
||||
|
||||
for (const item of node.items) {
|
||||
for (const child of this.getNodes(item)) {
|
||||
for (const child of this.getNodes(item, aliasResolutionStack)) {
|
||||
yield child;
|
||||
}
|
||||
}
|
||||
@@ -74,12 +87,32 @@ export class YamlObjectReader implements ObjectReader {
|
||||
yield new ParseEvent(EventType.Literal, YamlObjectReader.getLiteralToken(this.fileId, range, node));
|
||||
}
|
||||
|
||||
// Handle YAML aliases - resolve to the anchored value
|
||||
if (isAlias(node)) {
|
||||
const resolved = node.resolve(this.doc);
|
||||
if (resolved) {
|
||||
// Prevent infinite recursion from circular aliases
|
||||
if (aliasResolutionStack.has(resolved)) {
|
||||
// Silently ignore circular reference - the missing content will cause
|
||||
// downstream validation errors which is acceptable for this edge case
|
||||
return;
|
||||
}
|
||||
// Track this node in the alias resolution stack
|
||||
const newStack = new Set(aliasResolutionStack);
|
||||
newStack.add(resolved);
|
||||
// Yield the resolved node's contents
|
||||
yield* this.getNodes(resolved, newStack);
|
||||
}
|
||||
// If unresolved, the yaml library already reports an error
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPair(node)) {
|
||||
const scalarKey = node.key as Scalar;
|
||||
range = this.getRange(scalarKey);
|
||||
const key = scalarKey.value as string;
|
||||
yield new ParseEvent(EventType.Literal, new StringToken(this.fileId, range, key, undefined));
|
||||
for (const child of this.getNodes(node.value)) {
|
||||
for (const child of this.getNodes(node.value, aliasResolutionStack)) {
|
||||
yield child;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user