Skip to content

Commit e42c4d3

Browse files
authored
feat: Add lock jobs and improve job JSON schema validations (#370)
1 parent 35c674a commit e42c4d3

File tree

11 files changed

+4009
-3772
lines changed

11 files changed

+4009
-3772
lines changed

HACKING.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,87 @@ task prepare:vscode
124124
3. Next, open a VSCode instance at the root of the project, open the
125125
`Run and Debug` tab and run it via the `Run Extension` on the dropdown menu
126126
at the top of the tab.
127+
128+
## Understanding the Schema Files
129+
130+
The CircleCI YAML Language Server uses **two different schema files** for different purposes:
131+
132+
| File | Purpose | Used By |
133+
| ------------------- | -------------------------------------------- | ------------------------------------------- |
134+
| `schema.json` | Core validation and language server features | Go language server binary, external tools |
135+
| `publicschema.json` | Rich hover documentation | VSCode extension TypeScript hover providers |
136+
137+
### Architecture
138+
139+
This is a **two-tier schema system**:
140+
141+
### `schema.json`
142+
143+
**Primary Purpose**: Validates the YAML is valid according to our CircleCI rules
144+
145+
**Used By**:
146+
147+
- **Go Language Server Binary**: The main language server reads this schema via the `SCHEMA_LOCATION` environment variable
148+
- Location: `pkg/services/diagnostics.go`, `pkg/services/validate.go`, etc.
149+
150+
- **External Tools**: Used by the Red Hat YAML extension. This extension looks at [schemastore.org](https://www.schemastore.org/api/json/catalog.json), which reads the latest schema.json from this repo.
151+
- URL: `https://raw.githubusercontent.com/CircleCI-Public/circleci-yaml-language-server/refs/heads/main/schema.json`
152+
153+
- **VSCode Extension**: Downloaded from GitHub releases page and bundled with the extension
154+
- Location in our private VSCode extension
155+
156+
- **Go Tests**: Used for validation testing
157+
- Location: `pkg/services/diagnostics_test.go`
158+
159+
**Characteristics**:
160+
161+
- JSON Schema draft-07
162+
163+
### `publicschema.json`
164+
165+
**Primary Purpose**: Documentation for IDE hover features
166+
167+
**Used By**:
168+
169+
- **VSCode Extension Hover Provider**
170+
- Location: `circleci-vscode-extension/packages/vscode-extension/src/lsp/hover.ts:62-67`
171+
172+
**Characteristics**:
173+
174+
- JSON Schema draft-04
175+
- Includes inline CircleCI documentation URLs (e.g., `https://circleci.com/docs/configuration-reference#...`)
176+
- **Never used by the Go language server**
177+
178+
### Why Two Schemas?
179+
180+
The separation exists because:
181+
182+
- The Go language server needs a comprehensive schema for validation that handles all edge cases
183+
- The hover provider needs clean documentation with links to CircleCI docs
184+
185+
### Development Guidelines
186+
187+
#### When to Update `schema.json`
188+
189+
Update this schema when:
190+
191+
- Adding or modifying CircleCI config validation rules
192+
- Changing supported configuration keys or values
193+
- Adding new CircleCI features that affect config structure
194+
- Fixing validation bugs
195+
196+
#### When to Update `publicschema.json`
197+
198+
Update this schema when:
199+
200+
- Improving hover documentation text
201+
- Adding or updating links to CircleCI documentation
202+
- Changing the structure of hover hints
203+
- Making documentation more user-friendly
204+
205+
#### Keeping Schemas in Sync
206+
207+
> ⚠️ [!IMPORTANT]
208+
> Both schemas should represent the same CircleCI configuration format. When you update one schema's structure, you likely need to update the other.
209+
210+
**Best Practice**: Make structural changes to both schemas in the same PR to prevent drift.

pkg/ast/job.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ type Job struct {
5252

5353
Type string
5454
TypeRange protocol.Range
55+
56+
PlanName string
57+
PlanNameRange protocol.Range
58+
59+
Key string
60+
KeyRange protocol.Range
5561
}
5662

5763
func (job *Job) AddCompletionItem(label string, commitCharacters []string) {

pkg/parser/jobs.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,15 @@ func (doc *YamlDocument) parseSingleJob(jobNode *sitter.Node) ast.Job {
125125
case "type":
126126
res.Type = doc.GetNodeText(valueNode)
127127
res.TypeRange = doc.NodeToRange(child)
128+
129+
case "plan_name":
130+
res.PlanName = doc.GetNodeText(valueNode)
131+
res.PlanNameRange = doc.NodeToRange(child)
132+
133+
case "key":
134+
res.Key = doc.GetNodeText(valueNode)
135+
res.KeyRange = doc.NodeToRange(child)
136+
128137
}
129138
}
130139
})
@@ -165,4 +174,10 @@ func (doc *YamlDocument) jobCompletionItem(job ast.Job) {
165174
if job.Type == "" {
166175
job.AddCompletionItem("type", []string{":", " "})
167176
}
177+
if job.PlanName == "" {
178+
job.AddCompletionItem("plan_name", []string{":", " "})
179+
}
180+
if job.Key == "" {
181+
job.AddCompletionItem("key", []string{":", " "})
182+
}
168183
}

pkg/parser/jsonschema_test.go

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package parser
22

33
import (
4+
"strings"
45
"testing"
56

67
"github.com/CircleCI-Public/circleci-yaml-language-server/pkg/expect"
@@ -72,3 +73,261 @@ test:
7273

7374
expect.DiagnosticList(t, diagnostics).To.Include(expected)
7475
}
76+
77+
func Test_JobDefinitionTypes(t *testing.T) {
78+
testCases := []struct {
79+
name string
80+
yaml string
81+
expectError bool
82+
expectErrorContains string
83+
}{
84+
// Build type tests
85+
{
86+
name: "build type - valid with docker and steps",
87+
yaml: `
88+
version: 2.1
89+
jobs:
90+
my-build-job:
91+
type: build
92+
docker:
93+
- image: cimg/base:2023.01
94+
steps:
95+
- checkout
96+
`,
97+
expectError: false,
98+
},
99+
{
100+
name: "build type - valid with explicit type",
101+
yaml: `
102+
version: 2.1
103+
jobs:
104+
my-build-job:
105+
type: build
106+
docker:
107+
- image: cimg/base:2023.01
108+
steps:
109+
- checkout
110+
- run: echo "build job with explicit type"
111+
`,
112+
expectError: false,
113+
},
114+
{
115+
name: "build type - missing steps (should error)",
116+
yaml: `
117+
version: 2.1
118+
jobs:
119+
my-build-job:
120+
type: build
121+
docker:
122+
- image: cimg/base:2023.01
123+
`,
124+
expectError: true,
125+
expectErrorContains: "steps",
126+
},
127+
128+
// Release type tests
129+
{
130+
name: "release type - valid with plan_name",
131+
yaml: `
132+
version: 2.1
133+
jobs:
134+
my-release-job:
135+
type: release
136+
plan_name: my-release-plan
137+
`,
138+
expectError: false,
139+
},
140+
{
141+
name: "release type - valid with additional properties",
142+
yaml: `
143+
version: 2.1
144+
jobs:
145+
my-release-job:
146+
type: release
147+
plan_name: my-plan
148+
some_other_property: allowed
149+
`,
150+
expectError: false,
151+
},
152+
{
153+
name: "release type - missing plan_name (should error)",
154+
yaml: `
155+
version: 2.1
156+
jobs:
157+
my-release-job:
158+
type: release
159+
`,
160+
expectError: true,
161+
expectErrorContains: "plan_name",
162+
},
163+
164+
// Lock type tests
165+
{
166+
name: "lock type - valid with key",
167+
yaml: `
168+
version: 2.1
169+
jobs:
170+
my-lock-job:
171+
type: lock
172+
key: my-lock-key
173+
`,
174+
expectError: false,
175+
},
176+
{
177+
name: "lock type - valid with additional properties",
178+
yaml: `
179+
version: 2.1
180+
jobs:
181+
my-lock-job:
182+
type: lock
183+
key: my-key
184+
some_other_property: allowed
185+
`,
186+
expectError: false,
187+
},
188+
{
189+
name: "lock type - missing key (should error)",
190+
yaml: `
191+
version: 2.1
192+
jobs:
193+
my-lock-job:
194+
type: lock
195+
`,
196+
expectError: true,
197+
expectErrorContains: "key",
198+
},
199+
200+
// Unlock type tests
201+
{
202+
name: "unlock type - valid with key",
203+
yaml: `
204+
version: 2.1
205+
jobs:
206+
my-unlock-job:
207+
type: unlock
208+
key: my-lock-key
209+
`,
210+
expectError: false,
211+
},
212+
{
213+
name: "unlock type - valid with additional properties",
214+
yaml: `
215+
version: 2.1
216+
jobs:
217+
my-unlock-job:
218+
type: unlock
219+
key: my-key
220+
some_other_property: allowed
221+
`,
222+
expectError: false,
223+
},
224+
{
225+
name: "unlock type - missing key (should error)",
226+
yaml: `
227+
version: 2.1
228+
jobs:
229+
my-unlock-job:
230+
type: unlock
231+
`,
232+
expectError: true,
233+
expectErrorContains: "key",
234+
},
235+
236+
// Approval type tests
237+
{
238+
name: "approval type - valid minimal",
239+
yaml: `
240+
version: 2.1
241+
jobs:
242+
my-approval-job:
243+
type: approval
244+
`,
245+
expectError: false,
246+
},
247+
{
248+
name: "approval type - valid with steps (ignored)",
249+
yaml: `
250+
version: 2.1
251+
jobs:
252+
my-approval-job:
253+
type: approval
254+
steps:
255+
- run: echo "This will be ignored"
256+
`,
257+
expectError: false,
258+
},
259+
260+
// No-op type tests
261+
{
262+
name: "no-op type - valid minimal",
263+
yaml: `
264+
version: 2.1
265+
jobs:
266+
my-noop-job:
267+
type: no-op
268+
`,
269+
expectError: false,
270+
},
271+
{
272+
name: "no-op type - valid with steps (ignored)",
273+
yaml: `
274+
version: 2.1
275+
jobs:
276+
my-noop-job:
277+
type: no-op
278+
steps:
279+
- run: echo "This will be ignored"
280+
`,
281+
expectError: false,
282+
},
283+
}
284+
285+
for _, tc := range testCases {
286+
t.Run(tc.name, func(t *testing.T) {
287+
context := testHelpers.GetDefaultLsContext()
288+
yamlDocument, _ := ParseFromContent([]byte(tc.yaml), context, uri.File(""), protocol.Position{})
289+
290+
if tc.expectError {
291+
// For error cases, also run JSON schema validation
292+
validator := JSONSchemaValidator{
293+
Doc: yamlDocument,
294+
}
295+
296+
schemaPath := "../../schema.json"
297+
err := validator.LoadJsonSchema(schemaPath)
298+
if err != nil {
299+
t.Logf("Warning: Could not load schema: %v", err)
300+
t.SkipNow()
301+
}
302+
303+
diagnostics := validator.ValidateWithJSONSchema(yamlDocument.RootNode, yamlDocument.Content)
304+
305+
// Log all diagnostics for debugging
306+
if len(diagnostics) > 0 {
307+
t.Logf("Found %d diagnostic(s):", len(diagnostics))
308+
for _, d := range diagnostics {
309+
t.Logf(" - %s", d.Message)
310+
}
311+
} else {
312+
t.Logf("No diagnostics found")
313+
}
314+
315+
assert.NotEmpty(t, diagnostics, "Expected validation errors but got none")
316+
if tc.expectErrorContains != "" {
317+
found := false
318+
for _, d := range diagnostics {
319+
if strings.Contains(strings.ToLower(d.Message), strings.ToLower(tc.expectErrorContains)) {
320+
found = true
321+
break
322+
}
323+
}
324+
assert.True(t, found, "Expected error message to contain '%s'", tc.expectErrorContains)
325+
}
326+
} else {
327+
// For non-error cases, just check that parsing succeeded
328+
diagnostics := yamlDocument.Diagnostics
329+
assert.Empty(t, diagnostics, "Expected no errors but got: %v", diagnostics)
330+
}
331+
})
332+
}
333+
}

0 commit comments

Comments
 (0)