Skip to content

Commit 5a58188

Browse files
sorccuclaude
andauthored
feat(cli): add early diagnostics to Construct [RED-641] (#1355)
* feat(cli): add early diagnostics to Construct [RED-641] Constructs validate via async validate(), but are built through a sync constructor that cannot do async work and by convention does not throw for user-facing validation. Add an earlyDiagnostics collector on the base Construct that a constructor can record issues into; base validate() merges it into the caller's Diagnostics, so the issues flow through the existing per-construct collection and reporting path with no changes to subclasses or commands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBbrKMRZw7DjdqjSgnQX41 * feat(cli): report invalid and duplicate logicalId as diagnostics [RED-641] Turn two construction-time hard errors into soft validation diagnostics via the new earlyDiagnostics mechanism, so they're collected and reported alongside other validation issues instead of aborting with an exception: - A non-string logicalId is recorded as a diagnostic in the Construct constructor and the value is coerced to a string; the matching throw in Session.validateCreateConstruct is removed. - A duplicate logicalId is recorded as a diagnostic on the Project in addResource instead of throwing. The duplicate resource is intentionally not stored, so the diagnostic is attributed to the project to ensure it surfaces during validation. Removes the now-unused ValidationError class. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBbrKMRZw7DjdqjSgnQX41 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 22d5fd9 commit 5a58188

11 files changed

Lines changed: 228 additions & 56 deletions

packages/cli/e2e/__tests__/test.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,10 @@ describe('test', { timeout: 45000 }, () => {
174174
await runTest(fixt, [])
175175
} catch (err) {
176176
if (err instanceof ExecaError) {
177-
expect((err.stderr as unknown as string).replace(/(\n {4})/gm, ''))
178-
.toContain('Error: Resource of type \'check-group\' with logical id \'my-check-group\' already exists.')
177+
// A duplicate logicalId is now reported as a fatal validation
178+
// diagnostic (printed to stdout) rather than a thrown error.
179+
expect((err.stdout as unknown as string).replace(/(\n {4})/gm, ''))
180+
.toContain('A check-group with logicalId "my-check-group" already exists.')
179181
expect(err.exitCode).toBe(1)
180182
} else {
181183
throw err

packages/cli/src/constructs/__tests__/alert-channel.spec.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,24 @@ class TestAlertChannel extends AlertChannel {
1919
}
2020

2121
describe('AlertChannel', () => {
22-
it('should throw if the same logicalId is used twice', () => {
23-
Session.project = new Project('project-id', {
22+
it('should produce a diagnostic if the same logicalId is used twice', async () => {
23+
const project = new Project('project-id', {
2424
name: 'Test Project',
2525
repoUrl: 'https://github.com/checkly/checkly-cli',
2626
})
27+
Session.project = project
2728

28-
const add = () => {
29-
new TestAlertChannel('foo', {
30-
})
31-
}
29+
new TestAlertChannel('foo', {})
30+
new TestAlertChannel('foo', {})
3231

33-
expect(add).not.toThrow()
34-
expect(add).toThrow('already exists')
32+
const diagnostics = new Diagnostics()
33+
await project.validate(diagnostics)
34+
expect(diagnostics.isFatal()).toBe(true)
35+
expect(diagnostics.observations).toEqual(expect.arrayContaining([
36+
expect.objectContaining({
37+
message: expect.stringContaining('already exists'),
38+
}),
39+
]))
3540
})
3641

3742
it('should not throw if the same fromId() is used twice', () => {

packages/cli/src/constructs/__tests__/check-group.spec.ts

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,28 @@ describe('CheckGroup', () => {
1111
expect(CheckGroupV1).toBe(CheckGroup)
1212
})
1313

14-
it('should throw if the same logicalId is used twice', () => {
15-
Session.project = new Project('project-id', {
14+
it('should produce a diagnostic if the same logicalId is used twice', async () => {
15+
const project = new Project('project-id', {
1616
name: 'Test Project',
1717
repoUrl: 'https://github.com/checkly/checkly-cli',
1818
})
19+
Session.project = project
1920

20-
const add = () => {
21-
new CheckGroupV1('foo', {
22-
name: 'Test',
23-
})
24-
}
21+
new CheckGroupV1('foo', {
22+
name: 'Test',
23+
})
24+
new CheckGroupV1('foo', {
25+
name: 'Test',
26+
})
2527

26-
expect(add).not.toThrow()
27-
expect(add).toThrow('already exists')
28+
const diagnostics = new Diagnostics()
29+
await project.validate(diagnostics)
30+
expect(diagnostics.isFatal()).toBe(true)
31+
expect(diagnostics.observations).toEqual(expect.arrayContaining([
32+
expect.objectContaining({
33+
message: expect.stringContaining('already exists'),
34+
}),
35+
]))
2836
})
2937

3038
it('should not throw if the same fromId() is used twice', () => {
@@ -84,20 +92,28 @@ describe('CheckGroup', () => {
8492
})
8593

8694
describe('v2', () => {
87-
it('should throw if the same logicalId is used twice', () => {
88-
Session.project = new Project('project-id', {
95+
it('should produce a diagnostic if the same logicalId is used twice', async () => {
96+
const project = new Project('project-id', {
8997
name: 'Test Project',
9098
repoUrl: 'https://github.com/checkly/checkly-cli',
9199
})
100+
Session.project = project
92101

93-
const add = () => {
94-
new CheckGroupV2('foo', {
95-
name: 'Test',
96-
})
97-
}
102+
new CheckGroupV2('foo', {
103+
name: 'Test',
104+
})
105+
new CheckGroupV2('foo', {
106+
name: 'Test',
107+
})
98108

99-
expect(add).not.toThrow()
100-
expect(add).toThrow('already exists')
109+
const diagnostics = new Diagnostics()
110+
await project.validate(diagnostics)
111+
expect(diagnostics.isFatal()).toBe(true)
112+
expect(diagnostics.observations).toEqual(expect.arrayContaining([
113+
expect.objectContaining({
114+
message: expect.stringContaining('already exists'),
115+
}),
116+
]))
101117
})
102118

103119
it('should not throw if the same fromId() is used twice', () => {
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, it, expect, beforeEach } from 'vitest'
2+
3+
import { Session } from '../session.js'
4+
import { Construct } from '../construct.js'
5+
import { Diagnostics, ErrorDiagnostic } from '../diagnostics.js'
6+
import { ConstructDiagnostics } from '../construct-diagnostics.js'
7+
8+
class EarlyConstruct extends Construct {
9+
constructor (logicalId: string) {
10+
super('test', logicalId)
11+
this.earlyDiagnostics.add(new ErrorDiagnostic({
12+
title: 'Early problem',
13+
message: 'Something the constructor noticed.',
14+
error: new Error('Something the constructor noticed.'),
15+
}))
16+
}
17+
18+
describe (): string {
19+
return `Test:${this.logicalId}`
20+
}
21+
22+
synthesize () {
23+
return null
24+
}
25+
}
26+
27+
describe('Construct early diagnostics', () => {
28+
beforeEach(() => {
29+
Session.reset()
30+
Session.project = { addResource: () => {} } as any
31+
})
32+
33+
it('surfaces diagnostics recorded in the constructor via validate()', async () => {
34+
const construct = new EarlyConstruct('my-check')
35+
const diagnostics = new Diagnostics()
36+
await construct.validate(diagnostics)
37+
expect(diagnostics.isFatal()).toBe(true)
38+
expect(diagnostics.observations).toEqual(expect.arrayContaining([
39+
expect.objectContaining({
40+
message: expect.stringContaining('Something the constructor noticed.'),
41+
}),
42+
]))
43+
})
44+
45+
it('attributes early diagnostics to the construct when validated via ConstructDiagnostics', async () => {
46+
const construct = new EarlyConstruct('my-check')
47+
const diagnostics = new ConstructDiagnostics(construct)
48+
await construct.validate(diagnostics)
49+
expect(diagnostics.observations).toEqual(expect.arrayContaining([
50+
expect.objectContaining({
51+
title: expect.stringContaining('[Test:my-check]'),
52+
}),
53+
]))
54+
})
55+
56+
it('does not record any diagnostics when the constructor adds none', async () => {
57+
class QuietConstruct extends Construct {
58+
constructor (logicalId: string) {
59+
super('test', logicalId)
60+
}
61+
62+
describe (): string {
63+
return `Quiet:${this.logicalId}`
64+
}
65+
66+
synthesize () {
67+
return null
68+
}
69+
}
70+
71+
const construct = new QuietConstruct('my-check')
72+
const diagnostics = new Diagnostics()
73+
await construct.validate(diagnostics)
74+
expect(diagnostics.isFatal()).toBe(false)
75+
expect(diagnostics.observations).toHaveLength(0)
76+
})
77+
})

packages/cli/src/constructs/__tests__/private-location.spec.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,30 @@ import { Project } from '../project.js'
55
import { Session } from '../session.js'
66

77
describe('PrivateLocation', () => {
8-
it('should throw if the same logicalId is used twice', () => {
9-
Session.project = new Project('project-id', {
8+
it('should produce a diagnostic if the same logicalId is used twice', async () => {
9+
const project = new Project('project-id', {
1010
name: 'Test Project',
1111
repoUrl: 'https://github.com/checkly/checkly-cli',
1212
})
13+
Session.project = project
1314

14-
const add = () => {
15-
new PrivateLocation('foo', {
16-
name: 'Test',
17-
slugName: 'test',
18-
})
19-
}
15+
new PrivateLocation('foo', {
16+
name: 'Test',
17+
slugName: 'test',
18+
})
19+
new PrivateLocation('foo', {
20+
name: 'Test',
21+
slugName: 'test',
22+
})
2023

21-
expect(add).not.toThrow()
22-
expect(add).toThrow('already exists')
24+
const diagnostics = new Diagnostics()
25+
await project.validate(diagnostics)
26+
expect(diagnostics.isFatal()).toBe(true)
27+
expect(diagnostics.observations).toEqual(expect.arrayContaining([
28+
expect.objectContaining({
29+
message: expect.stringContaining('already exists'),
30+
}),
31+
]))
2332
})
2433

2534
it('should not throw if the same fromId() is used twice', () => {

packages/cli/src/constructs/__tests__/project.spec.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { describe, it, expect, beforeEach } from 'vitest'
22

3+
import { Project } from '../project.js'
34
import { Session } from '../session.js'
45
import { Construct } from '../construct.js'
56
import { Diagnostics } from '../diagnostics.js'
67

78
class TestConstruct extends Construct {
8-
constructor (logicalId: string) {
9+
constructor (logicalId: any) {
910
super('test', logicalId)
1011
}
1112

@@ -74,4 +75,36 @@ describe('Construct logicalId validation', () => {
7475
}),
7576
]))
7677
})
78+
79+
it('should produce a diagnostic for a non-string logicalId instead of throwing', async () => {
80+
const construct = new TestConstruct(123)
81+
expect(construct.logicalId).toBe('123')
82+
const diagnostics = new Diagnostics()
83+
await construct.validate(diagnostics)
84+
expect(diagnostics.isFatal()).toBe(true)
85+
expect(diagnostics.observations).toEqual(expect.arrayContaining([
86+
expect.objectContaining({
87+
message: expect.stringContaining('Expected a string but received type "number"'),
88+
}),
89+
]))
90+
})
91+
92+
it('should produce a diagnostic for a duplicate logicalId instead of throwing', async () => {
93+
Session.reset()
94+
const project = new Project('test-project', { name: 'Test' })
95+
Session.project = project
96+
97+
project.addResource('check', 'duplicate-id', new TestConstruct('duplicate-id'))
98+
project.addResource('check', 'duplicate-id', new TestConstruct('duplicate-id'))
99+
100+
const diagnostics = new Diagnostics()
101+
await project.validate(diagnostics)
102+
expect(diagnostics.isFatal()).toBe(true)
103+
expect(diagnostics.observations).toEqual(expect.arrayContaining([
104+
expect.objectContaining({
105+
title: expect.stringContaining('[Test:duplicate-id]'),
106+
message: expect.stringContaining('A check with logicalId "duplicate-id" already exists'),
107+
}),
108+
]))
109+
})
77110
})

packages/cli/src/constructs/__tests__/status-page-service.spec.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,28 @@ import { Project } from '../project.js'
55
import { Session } from '../session.js'
66

77
describe('StatusPageService', () => {
8-
it('should throw if the same logicalId is used twice', () => {
9-
Session.project = new Project('project-id', {
8+
it('should produce a diagnostic if the same logicalId is used twice', async () => {
9+
const project = new Project('project-id', {
1010
name: 'Test Project',
1111
repoUrl: 'https://github.com/checkly/checkly-cli',
1212
})
13+
Session.project = project
1314

14-
const add = () => {
15-
new StatusPageService('foo', {
16-
name: 'foo',
17-
})
18-
}
15+
new StatusPageService('foo', {
16+
name: 'foo',
17+
})
18+
new StatusPageService('foo', {
19+
name: 'foo',
20+
})
1921

20-
expect(add).not.toThrow()
21-
expect(add).toThrow('already exists')
22+
const diagnostics = new Diagnostics()
23+
await project.validate(diagnostics)
24+
expect(diagnostics.isFatal()).toBe(true)
25+
expect(diagnostics.observations).toEqual(expect.arrayContaining([
26+
expect.objectContaining({
27+
message: expect.stringContaining('already exists'),
28+
}),
29+
]))
2230
})
2331

2432
it('should not throw if the same fromId() is used twice', () => {

packages/cli/src/constructs/construct.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ export abstract class Construct implements Validate, Bundle {
5757
member: boolean
5858
/** Absolute path to the check file that created this construct */
5959
checkFileAbsolutePath?: string
60+
/**
61+
* Diagnostics recorded during construction. A constructor cannot perform the
62+
* async work that validate() can, so any issue it notices (e.g. an argument
63+
* of the wrong type) can be added here and is merged into the caller's
64+
* Diagnostics by validate().
65+
*/
66+
readonly earlyDiagnostics = new Diagnostics()
6067

6168
/**
6269
* Creates a new construct instance.
@@ -67,6 +74,13 @@ export abstract class Construct implements Validate, Bundle {
6774
* @param member Whether this construct is a member of the project
6875
*/
6976
constructor (type: string, logicalId: string, physicalId?: string | number, member?: boolean) {
77+
if (typeof logicalId !== 'string') {
78+
this.earlyDiagnostics.add(new InvalidPropertyValueDiagnostic(
79+
'logicalId',
80+
new Error(`Expected a string but received type "${typeof logicalId}".`),
81+
))
82+
logicalId = String(logicalId)
83+
}
7084
this.logicalId = logicalId
7185
this.type = type
7286
this.physicalId = physicalId
@@ -128,6 +142,8 @@ export abstract class Construct implements Validate, Bundle {
128142
*/
129143
// eslint-disable-next-line require-await
130144
async validate (diagnostics: Diagnostics): Promise<void> {
145+
diagnostics.extend(this.earlyDiagnostics)
146+
131147
if (!LOGICAL_ID_PATTERN.test(this.logicalId)) {
132148
diagnostics.add(new InvalidPropertyValueDiagnostic(
133149
'logicalId',

packages/cli/src/constructs/project.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
StatusPage, StatusPageService,
1010
} from './/index.js'
1111
import { Diagnostics } from './diagnostics.js'
12-
import { ConstructDiagnostics, InvalidPropertyValueDiagnostic } from './construct-diagnostics.js'
12+
import { ConstructDiagnostic, ConstructDiagnostics, InvalidPropertyValueDiagnostic } from './construct-diagnostics.js'
1313
import { ProjectBundle, ProjectDataBundle } from './project-bundle.js'
1414
import { Bundler } from '../services/check-parser/bundler.js'
1515
import { Session } from './session.js'
@@ -126,7 +126,19 @@ export class Project extends Construct {
126126
return
127127
}
128128

129-
throw new Error(`Resource of type '${type}' with logical id '${logicalId}' already exists.`)
129+
// The duplicate resource is intentionally not stored, so it is never
130+
// visited by the validate() fan-out over project data. Record the
131+
// diagnostic on the project itself so it surfaces during validation,
132+
// wrapping it in a ConstructDiagnostic so the output names the
133+
// offending construct.
134+
this.earlyDiagnostics.add(new ConstructDiagnostic(
135+
resource,
136+
new InvalidPropertyValueDiagnostic(
137+
'logicalId',
138+
new Error(`A ${type} with logicalId "${logicalId}" already exists.`),
139+
),
140+
))
141+
return
130142
}
131143

132144
this.data[type as keyof ProjectData][logicalId] = resource

0 commit comments

Comments
 (0)