Add block scalar newline warning (#295)
In YAML, block scalars (`|` and `>`) silently add a trailing newline by default
("clip" chomping). This can cause subtle bugs when the newline is unintentional.
This PR adds a warning when clip chomping is used in fields where trailing
newlines commonly cause issues:
- Environment variables (workflow, job, step, container, service levels)
- Action inputs (`with:`)
- Reusable workflow inputs and secrets
- Job outputs
- Matrix values (including `include` and `exclude`)
- Concurrency groups
The warning suggests using `|-` (strip) or `|+` (keep) to be explicit.
Intentionally does NOT warn for:
- `run:` scripts (trailing newlines are normal)
- Fields trimmed server-side (`if:`, `name:`, `runs-on:`, etc.)
The feature is gated behind the `blockScalarChompingWarning` feature flag.
This commit is contained in:
@@ -51,7 +51,7 @@ describe("FeatureFlags", () => {
|
|||||||
|
|
||||||
it("returns all features when all is enabled", () => {
|
it("returns all features when all is enabled", () => {
|
||||||
const flags = new FeatureFlags({all: true});
|
const flags = new FeatureFlags({all: true});
|
||||||
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix"]);
|
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix", "blockScalarChompingWarning"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ export interface ExperimentalFeatures {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
missingInputsQuickfix?: boolean;
|
missingInputsQuickfix?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warn when block scalars (| or >) use implicit clip chomping,
|
||||||
|
* which adds a trailing newline that may be unintentional.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
blockScalarChompingWarning?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,7 +39,7 @@ export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
|
|||||||
* All known experimental feature keys.
|
* All known experimental feature keys.
|
||||||
* This list must be kept in sync with the ExperimentalFeatures interface.
|
* This list must be kept in sync with the ExperimentalFeatures interface.
|
||||||
*/
|
*/
|
||||||
const allFeatureKeys: ExperimentalFeatureKey[] = ["missingInputsQuickfix"];
|
const allFeatureKeys: ExperimentalFeatureKey[] = ["missingInputsQuickfix", "blockScalarChompingWarning"];
|
||||||
|
|
||||||
export class FeatureFlags {
|
export class FeatureFlags {
|
||||||
private readonly features: ExperimentalFeatures;
|
private readonly features: ExperimentalFeatures;
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ initializationOptions: {
|
|||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `missingInputsQuickfix` | Code action to add missing required inputs for actions |
|
| `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 |
|
||||||
|
|
||||||
Individual feature flags take precedence over `all`. For example, `{ all: true, missingInputsQuickfix: false }` enables all experimental features except `missingInputsQuickfix`.
|
Individual feature flags take precedence over `all`. For example, `{ all: true, missingInputsQuickfix: false }` enables all experimental features except `missingInputsQuickfix`.
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ export function initConnection(connection: Connection) {
|
|||||||
actionsMetadataProvider: getActionsMetadataProvider(client, cache),
|
actionsMetadataProvider: getActionsMetadataProvider(client, cache),
|
||||||
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
||||||
return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest);
|
return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest);
|
||||||
})
|
}),
|
||||||
|
featureFlags
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validate(textDocument, config);
|
const result = await validate(textDocument, config);
|
||||||
|
|||||||
@@ -0,0 +1,835 @@
|
|||||||
|
import {FeatureFlags} from "@actions/expressions";
|
||||||
|
import {DiagnosticSeverity} from "vscode-languageserver-types";
|
||||||
|
import {registerLogger} from "./log.js";
|
||||||
|
import {createDocument} from "./test-utils/document.js";
|
||||||
|
import {TestLogger} from "./test-utils/logger.js";
|
||||||
|
import {clearCache} from "./utils/workflow-cache.js";
|
||||||
|
import {validate, ValidationConfig} from "./validate.js";
|
||||||
|
|
||||||
|
registerLogger(new TestLogger());
|
||||||
|
|
||||||
|
const configWithFlag: ValidationConfig = {
|
||||||
|
featureFlags: new FeatureFlags({blockScalarChompingWarning: true})
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("block scalar chomping - warning cases", () => {
|
||||||
|
describe("step-level env values", () => {
|
||||||
|
it("warns with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo $VAR
|
||||||
|
env:
|
||||||
|
VAR: |
|
||||||
|
\${{ matrix.value }}
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn with keep chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo $VAR
|
||||||
|
env:
|
||||||
|
VAR: |+
|
||||||
|
\${{ matrix.value }}
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn with strip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo $VAR
|
||||||
|
env:
|
||||||
|
VAR: |-
|
||||||
|
\${{ matrix.value }}
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses > indicator in warning message for folded scalars", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo $VAR
|
||||||
|
env:
|
||||||
|
VAR: >
|
||||||
|
\${{ matrix.value }}
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '>' implicitly adds a trailing newline that may be unintentional. Use '>-' to remove it, or '>+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns for plain string env value with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo $VAR
|
||||||
|
env:
|
||||||
|
VAR: |
|
||||||
|
hello world
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("job-level env values", () => {
|
||||||
|
it("warns with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
MY_VAR: |
|
||||||
|
some value
|
||||||
|
steps:
|
||||||
|
- run: echo done
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("workflow-level env values", () => {
|
||||||
|
it("warns with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
env:
|
||||||
|
GLOBAL_VAR: |
|
||||||
|
some value
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo done
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("container env values", () => {
|
||||||
|
it("warns with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:18
|
||||||
|
env:
|
||||||
|
CONTAINER_VAR: |
|
||||||
|
some value
|
||||||
|
steps:
|
||||||
|
- run: echo done
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("service container env values", () => {
|
||||||
|
it("warns with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis
|
||||||
|
env:
|
||||||
|
REDIS_PASSWORD: |
|
||||||
|
secret123
|
||||||
|
steps:
|
||||||
|
- run: echo done
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("action input (with)", () => {
|
||||||
|
it("warns with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
\${{ matrix.value }}
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn with keep chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
script: |+
|
||||||
|
\${{ matrix.value }}
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn with strip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
script: |-
|
||||||
|
\${{ matrix.value }}
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reusable workflow inputs (with)", () => {
|
||||||
|
it("warns with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
call-workflow:
|
||||||
|
uses: ./.github/workflows/reusable.yml
|
||||||
|
with:
|
||||||
|
my-input: |
|
||||||
|
some value
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reusable workflow secrets", () => {
|
||||||
|
it("warns with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
call-workflow:
|
||||||
|
uses: ./.github/workflows/reusable.yml
|
||||||
|
secrets:
|
||||||
|
my-secret: |
|
||||||
|
\${{ secrets.TOKEN }}
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("job outputs", () => {
|
||||||
|
it("warns with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
my_output: |
|
||||||
|
\${{ steps.test.outputs.value }}
|
||||||
|
steps:
|
||||||
|
- id: test
|
||||||
|
run: echo "value=test" >> $GITHUB_OUTPUT
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn with strip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
my_output: |-
|
||||||
|
\${{ steps.test.outputs.value }}
|
||||||
|
steps:
|
||||||
|
- id: test
|
||||||
|
run: echo "value=test" >> $GITHUB_OUTPUT
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("matrix values", () => {
|
||||||
|
it("warns for matrix vector value with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
config:
|
||||||
|
- |
|
||||||
|
value1
|
||||||
|
- value2
|
||||||
|
steps:
|
||||||
|
- run: echo \${{ matrix.config }}
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn with strip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
config:
|
||||||
|
- |-
|
||||||
|
value1
|
||||||
|
- value2
|
||||||
|
steps:
|
||||||
|
- run: echo \${{ matrix.config }}
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns for matrix include value with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest]
|
||||||
|
include:
|
||||||
|
- os: |
|
||||||
|
windows-latest
|
||||||
|
special: true
|
||||||
|
steps:
|
||||||
|
- run: echo \${{ matrix.os }}
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns for matrix exclude value with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest]
|
||||||
|
node: [16, 18]
|
||||||
|
exclude:
|
||||||
|
- os: |
|
||||||
|
windows-latest
|
||||||
|
node: 16
|
||||||
|
steps:
|
||||||
|
- run: echo \${{ matrix.os }}
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns for deeply nested matrix value with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
config:
|
||||||
|
- foo:
|
||||||
|
bar: |
|
||||||
|
baz
|
||||||
|
steps:
|
||||||
|
- run: echo \${{ matrix.config }}
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns for deeply nested matrix include value with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest]
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
config:
|
||||||
|
nested: |
|
||||||
|
value
|
||||||
|
steps:
|
||||||
|
- run: echo \${{ matrix.config }}
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns for deeply nested matrix exclude value with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest]
|
||||||
|
exclude:
|
||||||
|
- os: windows-latest
|
||||||
|
config:
|
||||||
|
nested: |
|
||||||
|
value
|
||||||
|
steps:
|
||||||
|
- run: echo \${{ matrix.os }}
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("concurrency", () => {
|
||||||
|
it("warns for concurrency string with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
concurrency: |
|
||||||
|
my-group-\${{ github.ref }}
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo done
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn for concurrency with strip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
concurrency: |-
|
||||||
|
my-group-\${{ github.ref }}
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo done
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns for concurrency.group with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
concurrency:
|
||||||
|
group: |
|
||||||
|
my-group-\${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo done
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns for job-level concurrency with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency: |
|
||||||
|
job-group-\${{ github.ref }}
|
||||||
|
steps:
|
||||||
|
- run: echo done
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message:
|
||||||
|
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||||
|
code: "block-scalar-chomping",
|
||||||
|
severity: DiagnosticSeverity.Warning
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("block scalar chomping - no warning cases", () => {
|
||||||
|
describe("fields trimmed server-side", () => {
|
||||||
|
it("does not warn for job-if with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: |
|
||||||
|
github.ref == 'refs/heads/main'
|
||||||
|
steps:
|
||||||
|
- run: echo done
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn for step-if with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo done
|
||||||
|
if: |
|
||||||
|
github.ref == 'refs/heads/main'
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn for runs-on with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: |
|
||||||
|
ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo done
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn for job name with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: |
|
||||||
|
My Job
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo done
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn for step name with clip chomping", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: |
|
||||||
|
My Step
|
||||||
|
run: echo done
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("run field (intentionally allowed)", () => {
|
||||||
|
it("does not warn for step run field", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: |
|
||||||
|
echo hello
|
||||||
|
echo world
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn for run field with expression", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: |
|
||||||
|
echo \${{ github.ref }}
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("non-block scalars", () => {
|
||||||
|
it("does not warn for quoted strings", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo $VAR
|
||||||
|
env:
|
||||||
|
VAR: "hello world"
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn for flow scalars", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo $VAR
|
||||||
|
env:
|
||||||
|
VAR: hello world
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn for inline expressions", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo $VAR
|
||||||
|
env:
|
||||||
|
VAR: \${{ matrix.value }}
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||||
|
|
||||||
|
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Lexer, Parser, data} from "@actions/expressions";
|
import {FeatureFlags, Lexer, Parser, data} from "@actions/expressions";
|
||||||
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
||||||
import {TemplateParseResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
|
import {TemplateParseResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
|
||||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||||
@@ -39,6 +39,7 @@ export type ValidationConfig = {
|
|||||||
contextProviderConfig?: ContextProviderConfig;
|
contextProviderConfig?: ContextProviderConfig;
|
||||||
actionsMetadataProvider?: ActionsMetadataProvider;
|
actionsMetadataProvider?: ActionsMetadataProvider;
|
||||||
fileProvider?: FileProvider;
|
fileProvider?: FileProvider;
|
||||||
|
featureFlags?: FeatureFlags;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ActionsMetadataProvider = {
|
export type ActionsMetadataProvider = {
|
||||||
@@ -85,7 +86,7 @@ async function validateWorkflow(textDocument: TextDocument, config?: ValidationC
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Validate expressions and value providers
|
// Validate expressions and value providers
|
||||||
await additionalValidations(diagnostics, textDocument.uri, template, result.value, config);
|
await additionalValidations(diagnostics, textDocument.uri, template, result.value, config, config?.featureFlags);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For now map parser errors directly to diagnostics
|
// For now map parser errors directly to diagnostics
|
||||||
@@ -109,9 +110,10 @@ async function additionalValidations(
|
|||||||
documentUri: URI,
|
documentUri: URI,
|
||||||
template: WorkflowTemplate,
|
template: WorkflowTemplate,
|
||||||
root: TemplateToken,
|
root: TemplateToken,
|
||||||
config?: ValidationConfig
|
config?: ValidationConfig,
|
||||||
|
featureFlags?: FeatureFlags
|
||||||
) {
|
) {
|
||||||
for (const [parent, token, key] of TemplateToken.traverse(root)) {
|
for (const [parent, token, key, ancestors] of TemplateToken.traverse(root)) {
|
||||||
// If the token is a value in a pair, use the key definition for validation
|
// If the token is a value in a pair, use the key definition for validation
|
||||||
// If the token has a parent (map, sequence, etc), use this definition for validation
|
// If the token has a parent (map, sequence, etc), use this definition for validation
|
||||||
const validationToken = key || parent || token;
|
const validationToken = key || parent || token;
|
||||||
@@ -129,7 +131,12 @@ async function additionalValidations(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this is a job-if, step-if, or snapshot-if field (which are strings that should be treated as expressions), validate it
|
// Validate block scalar chomping for expressions and strings
|
||||||
|
if (featureFlags?.isEnabled("blockScalarChompingWarning")) {
|
||||||
|
validateBlockScalarChomping(diagnostics, token, parent, key, ancestors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `if` conditions allow omitting ${{ }}, so validate strings in these fields as expressions
|
||||||
const definitionKey = token.definition?.key;
|
const definitionKey = token.definition?.key;
|
||||||
if (
|
if (
|
||||||
isString(token) &&
|
isString(token) &&
|
||||||
@@ -149,7 +156,9 @@ async function additionalValidations(
|
|||||||
finalCondition,
|
finalCondition,
|
||||||
token.definitionInfo,
|
token.definitionInfo,
|
||||||
undefined,
|
undefined,
|
||||||
token.source
|
token.source,
|
||||||
|
undefined,
|
||||||
|
token.blockScalarHeader
|
||||||
);
|
);
|
||||||
|
|
||||||
await validateExpression(
|
await validateExpression(
|
||||||
@@ -845,3 +854,90 @@ function getStaticConcurrencyGroup(token: TemplateToken | undefined): StringToke
|
|||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates YAML block scalar chomping.
|
||||||
|
*
|
||||||
|
* Block scalars (| and >) implicitly add a trailing newline by default ("clip" chomping).
|
||||||
|
* This is often unintended by the workflow author and can cause unexpected behavior.
|
||||||
|
* This function warns on certain fields when clip chomping is used (implicit trailing newline)
|
||||||
|
* and suggests they explicitly use strip (|-) or keep (|+) to clarify intent.
|
||||||
|
*
|
||||||
|
* Only specific fields are validated - those where trailing newlines may cause
|
||||||
|
* issues but aren't automatically trimmed server-side. For example env, inputs, outputs, etc.
|
||||||
|
*
|
||||||
|
* Skipped fields:
|
||||||
|
* - run: Multi-line scripts commonly have trailing newlines
|
||||||
|
* - Fields trimmed server-side: name, uses, shell, if, etc.
|
||||||
|
*/
|
||||||
|
function validateBlockScalarChomping(
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
token: TemplateToken,
|
||||||
|
parent: TemplateToken | undefined,
|
||||||
|
key: TemplateToken | undefined,
|
||||||
|
ancestors: TemplateToken[]
|
||||||
|
): void {
|
||||||
|
// Not an expression or string?
|
||||||
|
if (!isBasicExpression(token) && !isString(token)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a block scalar?
|
||||||
|
const header = token.blockScalarHeader;
|
||||||
|
if (!header) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not "clip" chomp style?
|
||||||
|
if (header.includes("+") || header.includes("-")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we should warn
|
||||||
|
let shouldWarn = false;
|
||||||
|
const parentDefinitionName = parent?.definition?.key;
|
||||||
|
const tokenDefinitionName = token.definition?.key;
|
||||||
|
const keyName = key && isString(key) ? key.value : undefined;
|
||||||
|
if (
|
||||||
|
parentDefinitionName &&
|
||||||
|
[
|
||||||
|
"workflow-env",
|
||||||
|
"job-env",
|
||||||
|
"step-env",
|
||||||
|
"container-env",
|
||||||
|
"step-with",
|
||||||
|
"job-outputs",
|
||||||
|
"workflow-job-with",
|
||||||
|
"workflow-job-secrets"
|
||||||
|
].includes(parentDefinitionName)
|
||||||
|
) {
|
||||||
|
// env, with, outputs, or secrets fields
|
||||||
|
shouldWarn = true;
|
||||||
|
} else if (
|
||||||
|
ancestors.some(ancestor => {
|
||||||
|
const ancestorKey = ancestor.definition?.key;
|
||||||
|
return ancestorKey === "matrix" || ancestorKey === "matrix-filter" || ancestorKey === "matrix-filter-item";
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
// Matrix values (vectors, include, exclude)
|
||||||
|
shouldWarn = true;
|
||||||
|
} else if (tokenDefinitionName && ["workflow-concurrency", "job-concurrency"].includes(tokenDefinitionName)) {
|
||||||
|
// Concurrency shorthand
|
||||||
|
shouldWarn = true;
|
||||||
|
} else if (keyName === "group" && parentDefinitionName === "concurrency-mapping") {
|
||||||
|
// Concurrency group field
|
||||||
|
shouldWarn = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldWarn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockIndicator = header.startsWith("|") ? "|" : ">";
|
||||||
|
diagnostics.push({
|
||||||
|
message: `Block scalar '${blockIndicator}' implicitly adds a trailing newline that may be unintentional. Use '${blockIndicator}-' to remove it, or '${blockIndicator}+' to explicitly keep it.`,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
severity: DiagnosticSeverity.Warning,
|
||||||
|
code: "block-scalar-chomping"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -201,4 +201,355 @@ jobs:
|
|||||||
throw new Error("expected if to be a string (will be converted to expression later)");
|
throw new Error("expected if to be a string (will be converted to expression later)");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Block scalar chomp style preservation", () => {
|
||||||
|
it("preserves clip chomping (|) for literal block scalar", () => {
|
||||||
|
const result = parseWorkflow(
|
||||||
|
{
|
||||||
|
name: "test.yaml",
|
||||||
|
content: `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
TEST: |
|
||||||
|
\${{ github.event_name }}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`
|
||||||
|
},
|
||||||
|
nullTrace
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||||
|
|
||||||
|
const workflowRoot = result.value!.assertMapping("root")!;
|
||||||
|
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||||
|
const build = jobs.get(0).value.assertMapping("job");
|
||||||
|
const env = build.get(1).value.assertMapping("env");
|
||||||
|
const testToken = env.get(0).value;
|
||||||
|
|
||||||
|
if (!isBasicExpression(testToken)) {
|
||||||
|
throw new Error("expected TEST to be a basic expression");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(testToken.blockScalarHeader).toBe("|");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves strip chomping (|-) for literal block scalar", () => {
|
||||||
|
const result = parseWorkflow(
|
||||||
|
{
|
||||||
|
name: "test.yaml",
|
||||||
|
content: `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
TEST: |-
|
||||||
|
\${{ github.event_name }}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`
|
||||||
|
},
|
||||||
|
nullTrace
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||||
|
|
||||||
|
const workflowRoot = result.value!.assertMapping("root")!;
|
||||||
|
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||||
|
const build = jobs.get(0).value.assertMapping("job");
|
||||||
|
const env = build.get(1).value.assertMapping("env");
|
||||||
|
const testToken = env.get(0).value;
|
||||||
|
|
||||||
|
if (!isBasicExpression(testToken)) {
|
||||||
|
throw new Error("expected TEST to be a basic expression");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(testToken.blockScalarHeader).toBe("|-");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves keep chomping (|+) for literal block scalar", () => {
|
||||||
|
const result = parseWorkflow(
|
||||||
|
{
|
||||||
|
name: "test.yaml",
|
||||||
|
content: `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
TEST: |+
|
||||||
|
\${{ github.event_name }}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`
|
||||||
|
},
|
||||||
|
nullTrace
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||||
|
|
||||||
|
const workflowRoot = result.value!.assertMapping("root")!;
|
||||||
|
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||||
|
const build = jobs.get(0).value.assertMapping("job");
|
||||||
|
const env = build.get(1).value.assertMapping("env");
|
||||||
|
const testToken = env.get(0).value;
|
||||||
|
|
||||||
|
if (!isBasicExpression(testToken)) {
|
||||||
|
throw new Error("expected TEST to be a basic expression");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(testToken.blockScalarHeader).toBe("|+");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves folded clip (>) chomping", () => {
|
||||||
|
const result = parseWorkflow(
|
||||||
|
{
|
||||||
|
name: "test.yaml",
|
||||||
|
content: `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
TEST: >
|
||||||
|
\${{ github.event_name }}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`
|
||||||
|
},
|
||||||
|
nullTrace
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||||
|
|
||||||
|
const workflowRoot = result.value!.assertMapping("root")!;
|
||||||
|
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||||
|
const build = jobs.get(0).value.assertMapping("job");
|
||||||
|
const env = build.get(1).value.assertMapping("env");
|
||||||
|
const testToken = env.get(0).value;
|
||||||
|
|
||||||
|
if (!isBasicExpression(testToken)) {
|
||||||
|
throw new Error("expected TEST to be a basic expression");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(testToken.blockScalarHeader).toBe(">");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves folded strip (>-) chomping", () => {
|
||||||
|
const result = parseWorkflow(
|
||||||
|
{
|
||||||
|
name: "test.yaml",
|
||||||
|
content: `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
TEST: >-
|
||||||
|
\${{ github.event_name }}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`
|
||||||
|
},
|
||||||
|
nullTrace
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||||
|
|
||||||
|
const workflowRoot = result.value!.assertMapping("root")!;
|
||||||
|
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||||
|
const build = jobs.get(0).value.assertMapping("job");
|
||||||
|
const env = build.get(1).value.assertMapping("env");
|
||||||
|
const testToken = env.get(0).value;
|
||||||
|
|
||||||
|
if (!isBasicExpression(testToken)) {
|
||||||
|
throw new Error("expected TEST to be a basic expression");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(testToken.blockScalarHeader).toBe(">-");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves with explicit indent (|2)", () => {
|
||||||
|
const result = parseWorkflow(
|
||||||
|
{
|
||||||
|
name: "test.yaml",
|
||||||
|
content: `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
TEST: |2
|
||||||
|
\${{ github.event_name }}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`
|
||||||
|
},
|
||||||
|
nullTrace
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||||
|
|
||||||
|
const workflowRoot = result.value!.assertMapping("root")!;
|
||||||
|
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||||
|
const build = jobs.get(0).value.assertMapping("job");
|
||||||
|
const env = build.get(1).value.assertMapping("env");
|
||||||
|
const testToken = env.get(0).value;
|
||||||
|
|
||||||
|
if (!isBasicExpression(testToken)) {
|
||||||
|
throw new Error("expected TEST to be a basic expression");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(testToken.blockScalarHeader).toBe("|2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves with explicit indent and strip (|-2)", () => {
|
||||||
|
const result = parseWorkflow(
|
||||||
|
{
|
||||||
|
name: "test.yaml",
|
||||||
|
content: `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
TEST: |-2
|
||||||
|
\${{ github.event_name }}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`
|
||||||
|
},
|
||||||
|
nullTrace
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||||
|
|
||||||
|
const workflowRoot = result.value!.assertMapping("root")!;
|
||||||
|
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||||
|
const build = jobs.get(0).value.assertMapping("job");
|
||||||
|
const env = build.get(1).value.assertMapping("env");
|
||||||
|
const testToken = env.get(0).value;
|
||||||
|
|
||||||
|
if (!isBasicExpression(testToken)) {
|
||||||
|
throw new Error("expected TEST to be a basic expression");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(testToken.blockScalarHeader).toBe("|-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles flow scalars (no chomp info for inline)", () => {
|
||||||
|
const result = parseWorkflow(
|
||||||
|
{
|
||||||
|
name: "test.yaml",
|
||||||
|
content: `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
TEST: \${{ github.event_name }}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`
|
||||||
|
},
|
||||||
|
nullTrace
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||||
|
|
||||||
|
const workflowRoot = result.value!.assertMapping("root")!;
|
||||||
|
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||||
|
const build = jobs.get(0).value.assertMapping("job");
|
||||||
|
const env = build.get(1).value.assertMapping("env");
|
||||||
|
const testToken = env.get(0).value;
|
||||||
|
|
||||||
|
if (!isBasicExpression(testToken)) {
|
||||||
|
throw new Error("expected TEST to be a basic expression");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(testToken.blockScalarHeader).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves block scalar info for format expressions with multiple sub-expressions", () => {
|
||||||
|
const result = parseWorkflow(
|
||||||
|
{
|
||||||
|
name: "test.yaml",
|
||||||
|
content: `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
TEST: |
|
||||||
|
Hello \${{ github.event_name }} World \${{ github.ref }}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`
|
||||||
|
},
|
||||||
|
nullTrace
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||||
|
|
||||||
|
const workflowRoot = result.value!.assertMapping("root")!;
|
||||||
|
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||||
|
const build = jobs.get(0).value.assertMapping("job");
|
||||||
|
const env = build.get(1).value.assertMapping("env");
|
||||||
|
const testToken = env.get(0).value;
|
||||||
|
|
||||||
|
if (!isBasicExpression(testToken)) {
|
||||||
|
throw new Error("expected TEST to be a basic expression");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The format expression should preserve the block scalar info
|
||||||
|
expect(testToken.blockScalarHeader).toBe("|");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves block scalar info on StringToken for isExpression fields", () => {
|
||||||
|
const result = parseWorkflow(
|
||||||
|
{
|
||||||
|
name: "test.yaml",
|
||||||
|
content: `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: |
|
||||||
|
github.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- run: echo hi`
|
||||||
|
},
|
||||||
|
nullTrace
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||||
|
|
||||||
|
const workflowRoot = result.value!.assertMapping("root")!;
|
||||||
|
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||||
|
const build = jobs.get(0).value.assertMapping("job");
|
||||||
|
const ifToken = build.get(1).value;
|
||||||
|
|
||||||
|
// For isExpression fields without ${{ }}, the token is a StringToken
|
||||||
|
if (!isString(ifToken)) {
|
||||||
|
throw new Error("expected if to be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(ifToken.blockScalarHeader).toBe("|");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves block scalar info on StringToken for isExpression fields with strip", () => {
|
||||||
|
const result = parseWorkflow(
|
||||||
|
{
|
||||||
|
name: "test.yaml",
|
||||||
|
content: `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: |-
|
||||||
|
github.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- run: echo hi`
|
||||||
|
},
|
||||||
|
nullTrace
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||||
|
|
||||||
|
const workflowRoot = result.value!.assertMapping("root")!;
|
||||||
|
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||||
|
const build = jobs.get(0).value.assertMapping("job");
|
||||||
|
const ifToken = build.get(1).value;
|
||||||
|
|
||||||
|
if (!isString(ifToken)) {
|
||||||
|
throw new Error("expected if to be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(ifToken.blockScalarHeader).toBe("|-");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -613,7 +613,9 @@ class TemplateReader {
|
|||||||
`format('${format.join("")}'${args.join("")})`,
|
`format('${format.join("")}'${args.join("")})`,
|
||||||
definitionInfo,
|
definitionInfo,
|
||||||
expressionTokens,
|
expressionTokens,
|
||||||
raw
|
raw,
|
||||||
|
undefined,
|
||||||
|
token.blockScalarHeader
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,7 +697,8 @@ class TemplateReader {
|
|||||||
definitionInfo,
|
definitionInfo,
|
||||||
undefined,
|
undefined,
|
||||||
token.source,
|
token.source,
|
||||||
expressionRange
|
expressionRange,
|
||||||
|
token.blockScalarHeader
|
||||||
),
|
),
|
||||||
error: undefined
|
error: undefined
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,19 @@ export class BasicExpressionToken extends ExpressionToken {
|
|||||||
public readonly expressionRange: TokenRange | undefined;
|
public readonly expressionRange: TokenRange | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param originalExpressions If the basic expression was transformed from individual expressions, these will be the original ones
|
* The block scalar header (e.g., "|", "|-", "|+", ">", ">-", ">+") if parsed from a YAML block scalar.
|
||||||
|
*/
|
||||||
|
public readonly blockScalarHeader: string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param file The file ID where this token originated
|
||||||
|
* @param range The range of the entire expression including `${{` and `}}`
|
||||||
|
* @param expression The expression string without `${{` and `}}` markers
|
||||||
|
* @param definitionInfo Schema definition info for this token
|
||||||
|
* @param originalExpressions If transformed from individual expressions (e.g., format()), these are the originals
|
||||||
|
* @param source The original source string from the YAML
|
||||||
|
* @param expressionRange The range of just the expression, excluding `${{` and `}}`
|
||||||
|
* @param blockScalarHeader The block scalar header (e.g., "|", "|-") if parsed from a YAML block scalar
|
||||||
*/
|
*/
|
||||||
public constructor(
|
public constructor(
|
||||||
file: number | undefined,
|
file: number | undefined,
|
||||||
@@ -33,13 +45,15 @@ export class BasicExpressionToken extends ExpressionToken {
|
|||||||
definitionInfo: DefinitionInfo | undefined,
|
definitionInfo: DefinitionInfo | undefined,
|
||||||
originalExpressions: BasicExpressionToken[] | undefined,
|
originalExpressions: BasicExpressionToken[] | undefined,
|
||||||
source: string | undefined,
|
source: string | undefined,
|
||||||
expressionRange?: TokenRange | undefined
|
expressionRange?: TokenRange | undefined,
|
||||||
|
blockScalarHeader?: string | undefined
|
||||||
) {
|
) {
|
||||||
super(TokenType.BasicExpression, file, range, undefined, definitionInfo);
|
super(TokenType.BasicExpression, file, range, undefined, definitionInfo);
|
||||||
this.expr = expression;
|
this.expr = expression;
|
||||||
this.source = source;
|
this.source = source;
|
||||||
this.originalExpressions = originalExpressions;
|
this.originalExpressions = originalExpressions;
|
||||||
this.expressionRange = expressionRange;
|
this.expressionRange = expressionRange;
|
||||||
|
this.blockScalarHeader = blockScalarHeader;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get expression(): string {
|
public get expression(): string {
|
||||||
@@ -55,7 +69,8 @@ export class BasicExpressionToken extends ExpressionToken {
|
|||||||
this.definitionInfo,
|
this.definitionInfo,
|
||||||
this.originalExpressions,
|
this.originalExpressions,
|
||||||
this.source,
|
this.source,
|
||||||
this.expressionRange
|
this.expressionRange,
|
||||||
|
this.blockScalarHeader
|
||||||
)
|
)
|
||||||
: new BasicExpressionToken(
|
: new BasicExpressionToken(
|
||||||
this.file,
|
this.file,
|
||||||
@@ -64,7 +79,8 @@ export class BasicExpressionToken extends ExpressionToken {
|
|||||||
this.definitionInfo,
|
this.definitionInfo,
|
||||||
this.originalExpressions,
|
this.originalExpressions,
|
||||||
this.source,
|
this.source,
|
||||||
this.expressionRange
|
this.expressionRange,
|
||||||
|
this.blockScalarHeader
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,23 +6,26 @@ import {TokenType} from "./types.js";
|
|||||||
export class StringToken extends LiteralToken {
|
export class StringToken extends LiteralToken {
|
||||||
public readonly value: string;
|
public readonly value: string;
|
||||||
public readonly source: string | undefined;
|
public readonly source: string | undefined;
|
||||||
|
public readonly blockScalarHeader: string | undefined;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
file: number | undefined,
|
file: number | undefined,
|
||||||
range: TokenRange | undefined,
|
range: TokenRange | undefined,
|
||||||
value: string,
|
value: string,
|
||||||
definitionInfo: DefinitionInfo | undefined,
|
definitionInfo: DefinitionInfo | undefined,
|
||||||
source?: string
|
source?: string,
|
||||||
|
blockScalarHeader?: string
|
||||||
) {
|
) {
|
||||||
super(TokenType.String, file, range, definitionInfo);
|
super(TokenType.String, file, range, definitionInfo);
|
||||||
this.value = value;
|
this.value = value;
|
||||||
this.source = source;
|
this.source = source;
|
||||||
|
this.blockScalarHeader = blockScalarHeader;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override clone(omitSource?: boolean): TemplateToken {
|
public override clone(omitSource?: boolean): TemplateToken {
|
||||||
return omitSource
|
return omitSource
|
||||||
? new StringToken(undefined, undefined, this.value, this.definitionInfo, this.source)
|
? new StringToken(undefined, undefined, this.value, this.definitionInfo, this.source, this.blockScalarHeader)
|
||||||
: new StringToken(this.file, this.range, this.value, this.definitionInfo, this.source);
|
: new StringToken(this.file, this.range, this.value, this.definitionInfo, this.source, this.blockScalarHeader);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override toString(): string {
|
public override toString(): string {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unnecessary-type-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unnecessary-type-assertion */
|
||||||
import {nullTrace} from "../../test-utils/null-trace.js";
|
import {nullTrace} from "../../test-utils/null-trace.js";
|
||||||
import {parseWorkflow} from "../../workflows/workflow-parser.js";
|
import {parseWorkflow} from "../../workflows/workflow-parser.js";
|
||||||
|
import {MappingToken} from "./mapping-token.js";
|
||||||
|
import {SequenceToken} from "./sequence-token.js";
|
||||||
import {StringToken} from "./string-token.js";
|
import {StringToken} from "./string-token.js";
|
||||||
import {TemplateToken} from "./template-token.js";
|
import {TemplateToken} from "./template-token.js";
|
||||||
|
|
||||||
describe("traverse", () => {
|
describe("traverse", () => {
|
||||||
it("returns parent token and key", () => {
|
it("returns parent token, key, and ancestors", () => {
|
||||||
const workflow = parseWorkflow(
|
const workflow = parseWorkflow(
|
||||||
{
|
{
|
||||||
name: "wf.yaml",
|
name: "wf.yaml",
|
||||||
@@ -18,19 +20,118 @@ describe("traverse", () => {
|
|||||||
const traverser = TemplateToken.traverse(root);
|
const traverser = TemplateToken.traverse(root);
|
||||||
|
|
||||||
// Root
|
// Root
|
||||||
expect(traverser.next()!.value).toEqual([undefined, root, undefined]);
|
const rootResult = traverser.next()!.value!;
|
||||||
|
expect(rootResult[0]).toBeUndefined();
|
||||||
|
expect(rootResult[1]).toBe(root);
|
||||||
|
expect(rootResult[2]).toBeUndefined();
|
||||||
|
expect(rootResult[3]).toEqual([]);
|
||||||
|
|
||||||
// On
|
// On
|
||||||
const onResult = traverser.next().value!;
|
const onResult = traverser.next().value!;
|
||||||
expect(onResult[0]).toBe(root);
|
expect(onResult[0]).toBe(root);
|
||||||
expect(getValue(onResult[1])).toEqual("on");
|
expect(getValue(onResult[1])).toEqual("on");
|
||||||
expect(onResult[2]).toBeUndefined();
|
expect(onResult[2]).toBeUndefined();
|
||||||
|
expect(onResult[3]).toEqual([root]);
|
||||||
|
|
||||||
// Push
|
// Push
|
||||||
const pushResult = traverser.next().value!;
|
const pushResult = traverser.next().value!;
|
||||||
expect(pushResult[0]).toBe(root);
|
expect(pushResult[0]).toBe(root);
|
||||||
expect(getValue(pushResult[1])).toEqual("push");
|
expect(getValue(pushResult[1])).toEqual("push");
|
||||||
expect(getValue(pushResult[2])).toEqual("on");
|
expect(getValue(pushResult[2])).toEqual("on");
|
||||||
|
expect(pushResult[3]).toEqual([root]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ancestors for nested mappings", () => {
|
||||||
|
const workflow = parseWorkflow(
|
||||||
|
{
|
||||||
|
name: "wf.yaml",
|
||||||
|
content: `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest`
|
||||||
|
},
|
||||||
|
nullTrace
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = workflow.value!;
|
||||||
|
const results = Array.from(TemplateToken.traverse(root));
|
||||||
|
|
||||||
|
// Find the "ubuntu-latest" token
|
||||||
|
const ubuntuResult = results.find(r => getValue(r[1]) === "ubuntu-latest")!;
|
||||||
|
expect(ubuntuResult).toBeDefined();
|
||||||
|
|
||||||
|
// Ancestors should be: root -> jobs mapping -> build mapping
|
||||||
|
const ancestors = ubuntuResult[3];
|
||||||
|
expect(ancestors.length).toBe(3);
|
||||||
|
expect(ancestors[0]).toBe(root);
|
||||||
|
expect(ancestors[1]).toBeInstanceOf(MappingToken); // jobs mapping
|
||||||
|
expect(ancestors[2]).toBeInstanceOf(MappingToken); // build mapping
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ancestors for sequences", () => {
|
||||||
|
const workflow = parseWorkflow(
|
||||||
|
{
|
||||||
|
name: "wf.yaml",
|
||||||
|
content: `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo hello`
|
||||||
|
},
|
||||||
|
nullTrace
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = workflow.value!;
|
||||||
|
const results = Array.from(TemplateToken.traverse(root));
|
||||||
|
|
||||||
|
// Find the "echo hello" token
|
||||||
|
const echoResult = results.find(r => getValue(r[1]) === "echo hello")!;
|
||||||
|
expect(echoResult).toBeDefined();
|
||||||
|
|
||||||
|
// Ancestors should be: root -> jobs mapping -> build mapping -> steps sequence -> step mapping
|
||||||
|
const ancestors = echoResult[3];
|
||||||
|
expect(ancestors.length).toBe(5);
|
||||||
|
expect(ancestors[0]).toBe(root);
|
||||||
|
expect(ancestors[1]).toBeInstanceOf(MappingToken); // jobs mapping
|
||||||
|
expect(ancestors[2]).toBeInstanceOf(MappingToken); // build mapping
|
||||||
|
expect(ancestors[3]).toBeInstanceOf(SequenceToken); // steps sequence
|
||||||
|
expect(ancestors[4]).toBeInstanceOf(MappingToken); // step mapping
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct ancestors for matrix values", () => {
|
||||||
|
const workflow = parseWorkflow(
|
||||||
|
{
|
||||||
|
name: "wf.yaml",
|
||||||
|
content: `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node: [a, b]
|
||||||
|
steps:
|
||||||
|
- run: echo hi`
|
||||||
|
},
|
||||||
|
nullTrace
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = workflow.value!;
|
||||||
|
const results = Array.from(TemplateToken.traverse(root));
|
||||||
|
|
||||||
|
// Find the "a" token (first matrix value)
|
||||||
|
const nodeValueResult = results.find(r => {
|
||||||
|
const token = r[1];
|
||||||
|
return token instanceof StringToken && token.value === "a";
|
||||||
|
})!;
|
||||||
|
expect(nodeValueResult).toBeDefined();
|
||||||
|
|
||||||
|
// Ancestors: root -> jobs mapping -> build mapping -> strategy mapping -> matrix mapping -> node sequence
|
||||||
|
const ancestors = nodeValueResult[3];
|
||||||
|
expect(ancestors.length).toBeGreaterThanOrEqual(5);
|
||||||
|
expect(ancestors[0]).toBe(root);
|
||||||
|
// Last ancestor should be the sequence containing [a, b]
|
||||||
|
expect(ancestors[ancestors.length - 1]).toBeInstanceOf(SequenceToken);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -185,14 +185,23 @@ export abstract class TemplateToken {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all tokens (depth first)
|
* Returns all tokens (depth first)
|
||||||
* @param value The object to travese
|
* @param value The object to traverse
|
||||||
* @param omitKeys Whether to omit mapping keys
|
* @param omitKeys Whether to omit mapping keys
|
||||||
|
* @yields A tuple of [parent, token, keyToken, ancestors] for each token in the tree
|
||||||
*/
|
*/
|
||||||
public static *traverse(
|
public static *traverse(
|
||||||
value: TemplateToken,
|
value: TemplateToken,
|
||||||
omitKeys?: boolean
|
omitKeys?: boolean
|
||||||
): Generator<[parent: TemplateToken | undefined, token: TemplateToken, keyToken: TemplateToken | undefined], void> {
|
): Generator<
|
||||||
yield [undefined, value, undefined];
|
[
|
||||||
|
parent: TemplateToken | undefined,
|
||||||
|
token: TemplateToken,
|
||||||
|
keyToken: TemplateToken | undefined,
|
||||||
|
ancestors: TemplateToken[]
|
||||||
|
],
|
||||||
|
void
|
||||||
|
> {
|
||||||
|
yield [undefined, value, undefined, []];
|
||||||
|
|
||||||
switch (value.templateTokenType) {
|
switch (value.templateTokenType) {
|
||||||
case TokenType.Sequence:
|
case TokenType.Sequence:
|
||||||
@@ -202,7 +211,7 @@ export abstract class TemplateToken {
|
|||||||
while (state.parent) {
|
while (state.parent) {
|
||||||
if (state.moveNext(omitKeys ?? false)) {
|
if (state.moveNext(omitKeys ?? false)) {
|
||||||
value = state.current as TemplateToken;
|
value = state.current as TemplateToken;
|
||||||
yield [state.parent?.current, value, state.currentKey];
|
yield [state.parent?.current, value, state.currentKey, state.getAncestors()];
|
||||||
|
|
||||||
switch (value.type) {
|
switch (value.type) {
|
||||||
case TokenType.Sequence:
|
case TokenType.Sequence:
|
||||||
|
|||||||
@@ -66,4 +66,19 @@ export class TraversalState {
|
|||||||
throw new Error(`Unexpected token type '${this._token.templateTokenType}' when traversing state`);
|
throw new Error(`Unexpected token type '${this._token.templateTokenType}' when traversing state`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the ancestor tokens from root to the current token's parent container.
|
||||||
|
*/
|
||||||
|
public getAncestors(): TemplateToken[] {
|
||||||
|
const ancestors: TemplateToken[] = [];
|
||||||
|
let state: TraversalState | undefined = this.parent;
|
||||||
|
while (state) {
|
||||||
|
if (state.current) {
|
||||||
|
ancestors.unshift(state.current);
|
||||||
|
}
|
||||||
|
state = state.parent;
|
||||||
|
}
|
||||||
|
return ancestors;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,11 +152,27 @@ export class YamlObjectReader implements ObjectReader {
|
|||||||
return new BooleanToken(fileId, range, value, undefined);
|
return new BooleanToken(fileId, range, value, undefined);
|
||||||
case "string": {
|
case "string": {
|
||||||
let source: string | undefined;
|
let source: string | undefined;
|
||||||
|
let blockScalarHeader: string | undefined;
|
||||||
|
|
||||||
if (token.srcToken && "source" in token.srcToken) {
|
if (token.srcToken && "source" in token.srcToken) {
|
||||||
source = token.srcToken.source;
|
source = token.srcToken.source;
|
||||||
|
|
||||||
|
// Extract block scalar header (e.g., |-, |+, >-)
|
||||||
|
//
|
||||||
|
// CST node interfaces are supported and documented per yaml library maintainer:
|
||||||
|
// https://eemeli.org/yaml/#parser -> "For a complete description of CST node
|
||||||
|
// interfaces, please consult the cst.ts source."
|
||||||
|
// See also: https://github.com/eemeli/yaml/issues/643
|
||||||
|
if (token.srcToken.type === "block-scalar" && "props" in token.srcToken) {
|
||||||
|
const props = token.srcToken.props as Array<{type: string; source?: string}>;
|
||||||
|
const headerProp = props.find(p => p.type === "block-scalar-header");
|
||||||
|
if (headerProp?.source) {
|
||||||
|
blockScalarHeader = headerProp.source;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new StringToken(fileId, range, value, undefined, source);
|
return new StringToken(fileId, range, value, undefined, source, blockScalarHeader);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unexpected value type '${typeof value}' when reading object`);
|
throw new Error(`Unexpected value type '${typeof value}' when reading object`);
|
||||||
|
|||||||
Reference in New Issue
Block a user