From 9ed54d797df969826b458aba9185146e47edd253 Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Tue, 4 Aug 2026 19:46:06 +0200 Subject: [PATCH 1/5] feat(constructs): add structured check intent --- .../__tests__/agentic-check-codegen.spec.ts | 10 + .../constructs/__tests__/api-check.spec.ts | 30 ++ .../constructs/__tests__/check-intent.spec.ts | 277 ++++++++++++++++++ .../test-cases/test-intent/checkly.config.js | 9 + .../test-cases/test-intent/test.check.js | 12 + .../__tests__/uptime-monitor-codegen.spec.ts | 46 +++ .../src/constructs/agentic-check-codegen.ts | 1 + packages/cli/src/constructs/check-codegen.ts | 32 ++ packages/cli/src/constructs/check.ts | 198 ++++++++++++- packages/cli/src/constructs/dns-monitor.ts | 6 +- packages/cli/src/constructs/grpc-monitor.ts | 6 +- .../constructs/heartbeat-monitor-codegen.ts | 4 +- packages/cli/src/constructs/icmp-monitor.ts | 6 +- .../cli/src/constructs/ssl-monitor-codegen.ts | 4 +- packages/cli/src/constructs/tcp-monitor.ts | 6 +- .../constructs/traceroute-monitor-codegen.ts | 4 +- packages/cli/src/constructs/url-monitor.ts | 6 +- 17 files changed, 648 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/constructs/__tests__/check-intent.spec.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/api-check/test-cases/test-intent/checkly.config.js create mode 100644 packages/cli/src/constructs/__tests__/fixtures/api-check/test-cases/test-intent/test.check.js diff --git a/packages/cli/src/constructs/__tests__/agentic-check-codegen.spec.ts b/packages/cli/src/constructs/__tests__/agentic-check-codegen.spec.ts index 8e6875b2d..64bac47c1 100644 --- a/packages/cli/src/constructs/__tests__/agentic-check-codegen.spec.ts +++ b/packages/cli/src/constructs/__tests__/agentic-check-codegen.spec.ts @@ -116,6 +116,16 @@ describe('AgenticCheckCodegen', () => { expect(source).not.toContain('RetryStrategyBuilder') }) + it('should not emit intent because AgenticCheck does not support it', async () => { + const source = await renderResource(env, baseResource({ + intent: { + goal: 'Backend data that must not be exposed on this construct.', + }, + })) + + expect(source).not.toContain('intent:') + }) + it('should not emit `agentRuntime` when `agenticCheckData` is missing', async () => { const source = await renderResource(env, baseResource()) expect(source).not.toContain('agentRuntime') diff --git a/packages/cli/src/constructs/__tests__/api-check.spec.ts b/packages/cli/src/constructs/__tests__/api-check.spec.ts index b3588af34..51a4165a7 100644 --- a/packages/cli/src/constructs/__tests__/api-check.spec.ts +++ b/packages/cli/src/constructs/__tests__/api-check.spec.ts @@ -141,6 +141,36 @@ describe('ApiCheck', () => { })) }, DEFAULT_TEST_TIMEOUT) + it('should synthesize normalized intent from a packed fixture project', async () => { + const output = await parseProject( + fixt, + '--config', + fixt.abspath('test-cases/test-intent/checkly.config.js'), + ) + + expect(output).toEqual(expect.objectContaining({ + diagnostics: expect.objectContaining({ + fatal: false, + }), + payload: expect.objectContaining({ + resources: expect.arrayContaining([ + expect.objectContaining({ + logicalId: 'dashboard-intent', + type: 'check', + member: true, + payload: expect.objectContaining({ + intent: { + goal: 'Verify that authenticated users can open the dashboard.', + requiredOutcomes: [], + mustPreserve: [], + }, + }), + }), + ]), + }), + })) + }, DEFAULT_TEST_TIMEOUT) + it('should not synthesize default runtime', async () => { const output = await parseProject( fixt, diff --git a/packages/cli/src/constructs/__tests__/check-intent.spec.ts b/packages/cli/src/constructs/__tests__/check-intent.spec.ts new file mode 100644 index 000000000..fd429cd00 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/check-intent.spec.ts @@ -0,0 +1,277 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { AgenticCheckProps } from '../agentic-check.js' +import { ApiCheck, ApiCheckProps } from '../api-check.js' +import { BrowserCheckProps } from '../browser-check.js' +import { CheckIntent } from '../check.js' +import { Diagnostics } from '../diagnostics.js' +import { DnsMonitorProps } from '../dns-monitor.js' +import { GrpcMonitorProps } from '../grpc-monitor.js' +import { HeartbeatMonitorProps } from '../heartbeat-monitor.js' +import { IcmpMonitorProps } from '../icmp-monitor.js' +import { MultiStepCheckProps } from '../multi-step-check.js' +import { PlaywrightCheck, PlaywrightCheckProps } from '../playwright-check.js' +import { Project } from '../project.js' +import { Session } from '../session.js' +import { SslMonitorProps } from '../ssl-monitor.js' +import { TcpMonitorProps } from '../tcp-monitor.js' +import { TracerouteMonitorProps } from '../traceroute-monitor.js' +import { UrlMonitor, UrlMonitorProps } from '../url-monitor.js' + +const completeIntent: CheckIntent = { + goal: 'Verify that authenticated users can open the dashboard.', + requiredOutcomes: [ + 'Authentication succeeds for a valid user.', + 'The dashboard displays the account overview.', + ], + mustPreserve: [ + 'Do not remove or weaken the authentication assertion.', + 'Do not replace the dashboard assertion with a generic page-load assertion.', + ], +} + +let nextLogicalId = 0 + +function apiCheck (intent?: CheckIntent | null): ApiCheck { + return new ApiCheck(`api-intent-${nextLogicalId++}`, { + name: 'Dashboard API', + intent, + request: { + method: 'GET', + url: 'https://example.com/api/dashboard', + }, + }) +} + +async function validateIntent (intent: unknown): Promise { + const diagnostics = new Diagnostics() + await apiCheck(intent as CheckIntent).validate(diagnostics) + return diagnostics +} + +function messages (diagnostics: Diagnostics): string[] { + return diagnostics.observations.map(observation => observation.message) +} + +describe('check intent', () => { + beforeEach(() => { + nextLogicalId = 0 + Session.project = new Project('intent-project', { + name: 'Intent Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + }) + + afterEach(() => { + Session.reset() + }) + + describe('synthesis', () => { + it('normalizes omitted intent sections to empty arrays', () => { + const synthesized = apiCheck({ + goal: ' Verify that authenticated users can open the dashboard. ', + }).synthesize() + + expect(synthesized.intent).toEqual({ + goal: 'Verify that authenticated users can open the dashboard.', + requiredOutcomes: [], + mustPreserve: [], + }) + }) + + it('synthesizes complete structured intent', () => { + const check = apiCheck(completeIntent) + + expect(check.intent).toBe(completeIntent) + expect(check.synthesize()).toMatchObject({ + intent: completeIntent, + }) + }) + + it('synthesizes intent on runtime checks, monitors, and Playwright checks', () => { + const monitor = new UrlMonitor('url-intent', { + name: 'Dashboard URL', + intent: completeIntent, + request: { + url: 'https://example.com/dashboard', + }, + }) + const playwright = new PlaywrightCheck('playwright-intent', { + name: 'Dashboard browser flow', + intent: completeIntent, + playwrightConfigPath: '/tmp/playwright.config.ts', + }) + + expect(apiCheck(completeIntent).synthesize()).toHaveProperty('intent', completeIntent) + expect(monitor.intent).toBe(completeIntent) + expect(monitor.synthesize()).toHaveProperty('intent', completeIntent) + expect(playwright.intent).toBe(completeIntent) + expect(playwright.synthesize()).toHaveProperty('intent', completeIntent) + }) + + it('omits undefined intent so deployments do not take ownership of existing intent', () => { + expect(apiCheck().synthesize()).not.toHaveProperty('intent') + }) + + it('synthesizes null to explicitly clear intent', () => { + expect(apiCheck(null).synthesize()).toHaveProperty('intent', null) + }) + }) + + describe('validation', () => { + it('accepts a one-character goal and rejects a blank goal', async () => { + expect((await validateIntent({ goal: 'x' })).isFatal()).toBe(false) + + const diagnostics = await validateIntent({ goal: ' \n\t ' }) + expect(diagnostics.isFatal()).toBe(true) + expect(messages(diagnostics)).toEqual(expect.arrayContaining([ + expect.stringContaining('The intent goal must not be blank.'), + ])) + }) + + it('accepts a 2,000-character goal and rejects a 2,001-character goal', async () => { + expect((await validateIntent({ goal: 'g'.repeat(2_000) })).isFatal()).toBe(false) + + const diagnostics = await validateIntent({ goal: 'g'.repeat(2_001) }) + expect(diagnostics.isFatal()).toBe(true) + expect(messages(diagnostics)).toEqual(expect.arrayContaining([ + expect.stringContaining('The intent goal must be at most 2000 characters after trimming, got 2001.'), + ])) + }) + + it('accepts 20 required outcomes and rejects 21', async () => { + expect((await validateIntent({ + goal: 'Verify the dashboard.', + requiredOutcomes: Array.from({ length: 20 }, (_, index) => `Outcome ${index}`), + })).isFatal()).toBe(false) + + const diagnostics = await validateIntent({ + goal: 'Verify the dashboard.', + requiredOutcomes: Array.from({ length: 21 }, (_, index) => `Outcome ${index}`), + }) + expect(messages(diagnostics)).toEqual(expect.arrayContaining([ + expect.stringContaining('may contain at most 20 required outcomes, got 21.'), + ])) + }) + + it('accepts 20 must-preserve guardrails and rejects 21', async () => { + expect((await validateIntent({ + goal: 'Verify the dashboard.', + mustPreserve: Array.from({ length: 20 }, (_, index) => `Guardrail ${index}`), + })).isFatal()).toBe(false) + + const diagnostics = await validateIntent({ + goal: 'Verify the dashboard.', + mustPreserve: Array.from({ length: 21 }, (_, index) => `Guardrail ${index}`), + }) + expect(messages(diagnostics)).toEqual(expect.arrayContaining([ + expect.stringContaining('may contain at most 20 must-preserve guardrails, got 21.'), + ])) + }) + + it.each([ + ['required outcome', 'requiredOutcomes'], + ['must-preserve guardrail', 'mustPreserve'], + ] as const)('rejects a blank %s statement', async (label, property) => { + const diagnostics = await validateIntent({ + goal: 'Verify the dashboard.', + [property]: [' '], + }) + expect(messages(diagnostics)).toEqual(expect.arrayContaining([ + expect.stringContaining(`The intent ${label} must not be blank.`), + ])) + }) + + it.each([ + ['required outcome', 'requiredOutcomes'], + ['must-preserve guardrail', 'mustPreserve'], + ] as const)('accepts a 1,000-character %s and rejects 1,001 characters', async (label, property) => { + expect((await validateIntent({ + goal: 'Verify the dashboard.', + [property]: ['s'.repeat(1_000)], + })).isFatal()).toBe(false) + + const diagnostics = await validateIntent({ + goal: 'Verify the dashboard.', + [property]: ['s'.repeat(1_001)], + }) + expect(messages(diagnostics)).toEqual(expect.arrayContaining([ + expect.stringContaining(`The intent ${label} must be at most 1000 characters after trimming, got 1001.`), + ])) + }) + + it('rejects unknown fields instead of silently discarding them', async () => { + const diagnostics = await validateIntent({ + goal: 'Verify the dashboard.', + assertion: 'The dashboard is visible.', + }) + expect(messages(diagnostics)).toEqual(expect.arrayContaining([ + expect.stringContaining('"intent" contains unknown field "assertion".'), + ])) + }) + + it('rejects non-string statements and non-array statement sections', async () => { + const diagnostics = await validateIntent({ + goal: 'Verify the dashboard.', + requiredOutcomes: 'The dashboard loads.', + mustPreserve: [42], + }) + + expect(messages(diagnostics)).toEqual(expect.arrayContaining([ + expect.stringContaining('"intent.requiredOutcomes" must be an array of strings.'), + expect.stringContaining('The intent must-preserve guardrail must be a string.'), + ])) + }) + }) + + it('exposes intent only on supported construct prop types', () => { + type HasIntent = 'intent' extends keyof Props ? true : false + type IntentExposure = { + api: HasIntent + browser: HasIntent + multiStep: HasIntent + url: HasIntent + dns: HasIntent + icmp: HasIntent + tcp: HasIntent + grpc: HasIntent + playwright: HasIntent + agentic: HasIntent + heartbeat: HasIntent + ssl: HasIntent + traceroute: HasIntent + } + + const exposure: IntentExposure = { + api: true, + browser: true, + multiStep: true, + url: true, + dns: true, + icmp: true, + tcp: true, + grpc: true, + playwright: true, + agentic: false, + heartbeat: false, + ssl: false, + traceroute: false, + } + + expect(exposure).toEqual({ + api: true, + browser: true, + multiStep: true, + url: true, + dns: true, + icmp: true, + tcp: true, + grpc: true, + playwright: true, + agentic: false, + heartbeat: false, + ssl: false, + traceroute: false, + }) + }) +}) diff --git a/packages/cli/src/constructs/__tests__/fixtures/api-check/test-cases/test-intent/checkly.config.js b/packages/cli/src/constructs/__tests__/fixtures/api-check/test-cases/test-intent/checkly.config.js new file mode 100644 index 000000000..7c3fd1553 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/api-check/test-cases/test-intent/checkly.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from 'checkly' + +export default defineConfig({ + projectName: 'Check Intent Fixture', + logicalId: 'check-intent-fixture', + checks: { + checkMatch: '**/*.check.js', + }, +}) diff --git a/packages/cli/src/constructs/__tests__/fixtures/api-check/test-cases/test-intent/test.check.js b/packages/cli/src/constructs/__tests__/fixtures/api-check/test-cases/test-intent/test.check.js new file mode 100644 index 000000000..dbccac0b8 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/api-check/test-cases/test-intent/test.check.js @@ -0,0 +1,12 @@ +import { ApiCheck } from 'checkly/constructs' + +new ApiCheck('dashboard-intent', { + name: 'Dashboard intent', + intent: { + goal: 'Verify that authenticated users can open the dashboard.', + }, + request: { + method: 'GET', + url: 'https://example.com/api/dashboard', + }, +}) diff --git a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts index 74b39baf9..96ce69795 100644 --- a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts +++ b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts @@ -109,6 +109,42 @@ describe('GrpcMonitorCodegen', () => { expect(source).toContain('responseTime()') }) + it('emits complete structured intent in stable property order', async () => { + const source = await renderResource(env, p => new GrpcMonitorCodegen(p), resource({ + intent: { + goal: 'Verify that the gRPC health service is available.', + requiredOutcomes: [ + 'The health RPC returns a serving response.', + ], + mustPreserve: [ + 'Do not weaken the serving-status assertion.', + ], + }, + })) + + expect(source).toContain(`intent: { + goal: 'Verify that the gRPC health service is available.', + requiredOutcomes: [ + 'The health RPC returns a serving response.', + ], + mustPreserve: [ + 'Do not weaken the serving-status assertion.', + ], + }`) + expect(source.indexOf('name:')).toBeLessThan(source.indexOf('intent:')) + expect(source.indexOf('intent:')).toBeLessThan(source.indexOf('request:')) + }) + + it('omits intent when the backend resource has no intent', async () => { + const source = await renderResource(env, p => new GrpcMonitorCodegen(p), resource()) + expect(source).not.toContain('intent:') + }) + + it('omits intent when the backend resource returns null for absent intent', async () => { + const source = await renderResource(env, p => new GrpcMonitorCodegen(p), resource({ intent: null })) + expect(source).not.toContain('intent:') + }) + it('describes the resource by name', () => { const program = new Program({ rootDirectory: env.rootDirectory, @@ -166,6 +202,16 @@ describe('SslMonitorCodegen', () => { expect(source).not.toContain('maxResponseTimeMs:') }) + it('does not emit intent because SslMonitor does not support it', async () => { + const source = await renderResource(env, p => new SslMonitorCodegen(p), resource({ + intent: { + goal: 'Backend data that must not be exposed on this construct.', + }, + })) + + expect(source).not.toContain('intent:') + }) + it('emits assertions through SslAssertionBuilder', async () => { const source = await renderResource(env, p => new SslMonitorCodegen(p), resource({ request: { diff --git a/packages/cli/src/constructs/agentic-check-codegen.ts b/packages/cli/src/constructs/agentic-check-codegen.ts index 80a630aa4..6b335ca87 100644 --- a/packages/cli/src/constructs/agentic-check-codegen.ts +++ b/packages/cli/src/constructs/agentic-check-codegen.ts @@ -52,6 +52,7 @@ export class AgenticCheckCodegen extends Codegen { buildCheckProps(this.program, file, builder, resource, context, { skipRetryStrategy: true, + skipIntent: true, }) }) }) diff --git a/packages/cli/src/constructs/check-codegen.ts b/packages/cli/src/constructs/check-codegen.ts index 2204b2857..9e0d8d058 100644 --- a/packages/cli/src/constructs/check-codegen.ts +++ b/packages/cli/src/constructs/check-codegen.ts @@ -20,12 +20,14 @@ import { IcmpMonitorCodegen, IcmpMonitorResource } from './icmp-monitor-codegen. import { GrpcMonitorCodegen, GrpcMonitorResource } from './grpc-monitor-codegen.js' import { SslMonitorCodegen, SslMonitorResource } from './ssl-monitor-codegen.js' import { TracerouteMonitorCodegen, TracerouteMonitorResource } from './traceroute-monitor-codegen.js' +import { CheckIntent } from './check.js' export interface CheckResource { id: string checkType: string name: string description?: string | null + intent?: CheckIntent | null activated?: boolean muted?: boolean // Handled by the backend which creates the appropriate retryStrategy. @@ -60,6 +62,11 @@ export interface BuildCheckPropsOptions { * an explicit flag. */ skipRetryStrategy?: boolean + + /** + * Skip emitting the `intent` property for constructs that do not support it. + */ + skipIntent?: boolean } export function buildCheckProps ( @@ -76,6 +83,31 @@ export function buildCheckProps ( builder.string('description', resource.description) } + if (!options.skipIntent && resource.intent != null) { + const intent = resource.intent + builder.object('intent', builder => { + builder.string('goal', intent.goal) + + const requiredOutcomes = intent.requiredOutcomes ?? [] + if (requiredOutcomes.length > 0) { + builder.array('requiredOutcomes', builder => { + for (const statement of requiredOutcomes) { + builder.string(statement) + } + }) + } + + const mustPreserve = intent.mustPreserve ?? [] + if (mustPreserve.length > 0) { + builder.array('mustPreserve', builder => { + for (const statement of mustPreserve) { + builder.string(statement) + } + }) + } + }) + } + if (resource.activated !== undefined) { builder.boolean('activated', resource.activated) } diff --git a/packages/cli/src/constructs/check.ts b/packages/cli/src/constructs/check.ts index 13e31cc92..fd2abfc25 100644 --- a/packages/cli/src/constructs/check.ts +++ b/packages/cli/src/constructs/check.ts @@ -50,6 +50,70 @@ export type CheckRetryStrategy = | SingleRetryRetryStrategy | NoRetriesRetryStrategy +/** + * Durable guidance describing what a check is intended to verify. + * + * Intent is used by Checkly's root cause analysis and check repair features. + * It is separate from the check description and from executable assertions. + */ +export interface CheckIntent { + /** + * The user-visible outcome the check should verify. + * Leading and trailing whitespace is removed during synthesis. + * + * @minLength 1 + * @maxLength 2000 + */ + goal: string + + /** + * Specific outcomes that must hold for the check to satisfy its goal. + * Omitted values are synthesized as an empty array. + * Each statement is trimmed and must contain between 1 and 1,000 characters. + * + * @maxItems 20 + */ + requiredOutcomes?: string[] + + /** + * Guardrails that a repair must not weaken or remove. + * Omitted values are synthesized as an empty array. + * Each statement is trimmed and must contain between 1 and 1,000 characters. + * + * @maxItems 20 + */ + mustPreserve?: string[] +} + +/** + * Intent authoring properties shared by checks and monitors that support RCA + * and check repair. + */ +export interface CheckIntentProps { + /** + * Durable guidance for root cause analysis and check repair. + * + * - Omit this property to leave an existing backend-authored intent unchanged. + * - Provide an object to set or update intent. + * - Set it to `null` to explicitly clear intent. + * + * @example + * ```typescript + * intent: { + * goal: 'Verify that authenticated users can open the dashboard.', + * requiredOutcomes: [ + * 'Authentication succeeds for a valid user.', + * 'The dashboard displays the account overview.', + * ], + * mustPreserve: [ + * 'Do not remove or weaken the authentication assertion.', + * ], + * } + * ``` + */ + intent?: CheckIntent | null +} + /** * Base configuration properties for all check types. * These properties are inherited by ApiCheck, BrowserCheck, and other check types. @@ -276,6 +340,7 @@ export abstract class Check extends Construct { runParallel?: boolean triggerIncident?: IncidentTrigger __checkFilePath?: string // internal variable to filter by check file name from the CLI + #intent?: CheckIntent | null static readonly __checklyType = 'check' @@ -349,10 +414,125 @@ export abstract class Check extends Construct { return false } + protected setIntent (intent: CheckIntent | null | undefined): void { + this.#intent = intent + } + + protected validateIntent (diagnostics: Diagnostics): void { + if (this.#intent === undefined || this.#intent === null) { + return + } + + if (typeof this.#intent !== 'object' || Array.isArray(this.#intent)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'intent', + new Error('"intent" must be an object or null.'), + )) + return + } + + const intent = this.#intent as unknown as Record + const supportedFields = new Set(['goal', 'requiredOutcomes', 'mustPreserve']) + for (const field of Object.keys(intent)) { + if (!supportedFields.has(field)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'intent', + new Error( + `"intent" contains unknown field "${field}". ` + + 'Supported fields are "goal", "requiredOutcomes", and "mustPreserve".', + ), + )) + } + } + + this.validateIntentStatement(diagnostics, 'intent.goal', 'goal', intent.goal, 2_000) + this.validateIntentStatements( + diagnostics, + 'intent.requiredOutcomes', + 'required outcome', + intent.requiredOutcomes, + ) + this.validateIntentStatements( + diagnostics, + 'intent.mustPreserve', + 'must-preserve guardrail', + intent.mustPreserve, + ) + } + + private validateIntentStatements ( + diagnostics: Diagnostics, + property: 'intent.requiredOutcomes' | 'intent.mustPreserve', + label: 'required outcome' | 'must-preserve guardrail', + value: unknown, + ): void { + if (value === undefined) { + return + } + + if (!Array.isArray(value)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + property, + new Error(`"${property}" must be an array of strings.`), + )) + return + } + + if (value.length > 20) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + property, + new Error(`"${property}" may contain at most 20 ${label}s, got ${value.length}.`), + )) + } + + for (const [index, statement] of value.entries()) { + this.validateIntentStatement( + diagnostics, + `${property}[${index}]`, + label, + statement, + 1_000, + ) + } + } + + private validateIntentStatement ( + diagnostics: Diagnostics, + property: string, + label: string, + value: unknown, + maximumLength: number, + ): void { + if (typeof value !== 'string') { + diagnostics.add(new InvalidPropertyValueDiagnostic( + property, + new Error(`The intent ${label} must be a string.`), + )) + return + } + + const trimmed = value.trim() + if (trimmed.length === 0) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + property, + new Error(`The intent ${label} must not be blank.`), + )) + } else if (trimmed.length > maximumLength) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + property, + new Error( + `The intent ${label} must be at most ${maximumLength} characters after trimming, ` + + `got ${trimmed.length}.`, + ), + )) + } + } + async validate (diagnostics: Diagnostics): Promise { await super.validate(diagnostics) await this.validateDoubleCheck(diagnostics) await this.validateRetryStrategyOnlyOn(diagnostics) + this.validateIntent(diagnostics) } protected configDefaultsGetter (props: CheckProps): ConfigDefaultsGetter { @@ -445,9 +625,22 @@ export abstract class Check extends Construct { } })() + const intent = this.#intent === undefined + ? {} + : { + intent: this.#intent === null + ? null + : { + goal: this.#intent.goal.trim(), + requiredOutcomes: (this.#intent.requiredOutcomes ?? []).map(statement => statement.trim()), + mustPreserve: (this.#intent.mustPreserve ?? []).map(statement => statement.trim()), + }, + } + return { name: this.name, ...(this.description != null && { description: this.description }), + ...intent, activated: this.activated, muted: this.muted, shouldFail: this.shouldFail, @@ -480,7 +673,7 @@ export abstract class Check extends Construct { } } -export interface RuntimeCheckProps extends CheckProps { +export interface RuntimeCheckProps extends CheckProps, CheckIntentProps { /** * The runtime version, i.e. fixed set of runtime dependencies, used to execute this check. * @@ -506,12 +699,15 @@ export interface RuntimeCheckProps extends CheckProps { } export abstract class RuntimeCheck extends Check { + intent?: CheckIntent | null runtimeId?: string environmentVariables?: EnvironmentVariable[] protected constructor (logicalId: string, props: RuntimeCheckProps) { super(logicalId, props) const config = this.applyConfigDefaults(props) + this.intent = props.intent + this.setIntent(props.intent) this.runtimeId = config.runtimeId this.environmentVariables = config.environmentVariables ?? [] } diff --git a/packages/cli/src/constructs/dns-monitor.ts b/packages/cli/src/constructs/dns-monitor.ts index 2fcd43e06..e69769f22 100644 --- a/packages/cli/src/constructs/dns-monitor.ts +++ b/packages/cli/src/constructs/dns-monitor.ts @@ -5,8 +5,9 @@ import { validateResponseTimes } from './internal/common-diagnostics.js' import { DnsRequest } from './dns-request.js' import { RequiredPropertyDiagnostic } from './construct-diagnostics.js' import { responseTimeLimits } from './internal/account-features.js' +import { CheckIntent, CheckIntentProps } from './check.js' -export interface DnsMonitorProps extends MonitorProps { +export interface DnsMonitorProps extends MonitorProps, CheckIntentProps { /** * Determines the request that the monitor is going to run. */ @@ -49,6 +50,7 @@ export interface DnsMonitorProps extends MonitorProps { * Creates a DNS Monitor */ export class DnsMonitor extends Monitor { + intent?: CheckIntent | null request: DnsRequest degradedResponseTime?: number maxResponseTime?: number @@ -65,6 +67,8 @@ export class DnsMonitor extends Monitor { constructor (logicalId: string, props: DnsMonitorProps) { super(logicalId, props) + this.intent = props.intent + this.setIntent(props.intent) this.request = props.request this.degradedResponseTime = props.degradedResponseTime this.maxResponseTime = props.maxResponseTime diff --git a/packages/cli/src/constructs/grpc-monitor.ts b/packages/cli/src/constructs/grpc-monitor.ts index a197fb7df..bca53ac36 100644 --- a/packages/cli/src/constructs/grpc-monitor.ts +++ b/packages/cli/src/constructs/grpc-monitor.ts @@ -5,8 +5,9 @@ import { validateResponseTimes } from './internal/common-diagnostics.js' import { validateGrpcAssertion } from './grpc-assertion-validation.js' import { GrpcRequest } from './grpc-request.js' import { responseTimeLimits } from './internal/account-features.js' +import { CheckIntent, CheckIntentProps } from './check.js' -export interface GrpcMonitorProps extends MonitorProps { +export interface GrpcMonitorProps extends MonitorProps, CheckIntentProps { /** * Determines the request that the monitor is going to run. */ @@ -45,6 +46,7 @@ export interface GrpcMonitorProps extends MonitorProps { * Creates a gRPC Monitor */ export class GrpcMonitor extends Monitor { + intent?: CheckIntent | null request: GrpcRequest degradedResponseTime?: number maxResponseTime?: number @@ -61,6 +63,8 @@ export class GrpcMonitor extends Monitor { constructor (logicalId: string, props: GrpcMonitorProps) { super(logicalId, props) + this.intent = props.intent + this.setIntent(props.intent) this.request = props.request this.degradedResponseTime = props.degradedResponseTime this.maxResponseTime = props.maxResponseTime diff --git a/packages/cli/src/constructs/heartbeat-monitor-codegen.ts b/packages/cli/src/constructs/heartbeat-monitor-codegen.ts index 1ec6ee29a..41820f59a 100644 --- a/packages/cli/src/constructs/heartbeat-monitor-codegen.ts +++ b/packages/cli/src/constructs/heartbeat-monitor-codegen.ts @@ -32,7 +32,9 @@ export class HeartbeatMonitorCodegen extends Codegen { builder.number('grace', resource.heartbeat.grace) builder.string('graceUnit', resource.heartbeat.graceUnit) - buildMonitorProps(this.program, file, builder, resource, context) + buildMonitorProps(this.program, file, builder, resource, context, { + skipIntent: true, + }) }) }) })) diff --git a/packages/cli/src/constructs/icmp-monitor.ts b/packages/cli/src/constructs/icmp-monitor.ts index b1ebe595c..07cdb69cd 100644 --- a/packages/cli/src/constructs/icmp-monitor.ts +++ b/packages/cli/src/constructs/icmp-monitor.ts @@ -2,8 +2,9 @@ import { Monitor, MonitorProps } from './monitor.js' import { Session } from './session.js' import { Diagnostics } from './diagnostics.js' import { IcmpRequest } from './icmp-request.js' +import { CheckIntent, CheckIntentProps } from './check.js' -export interface IcmpMonitorProps extends MonitorProps { +export interface IcmpMonitorProps extends MonitorProps, CheckIntentProps { /** * Determines the request that the monitor is going to run. */ @@ -42,6 +43,7 @@ export interface IcmpMonitorProps extends MonitorProps { * Creates an ICMP Monitor */ export class IcmpMonitor extends Monitor { + intent?: CheckIntent | null request: IcmpRequest degradedPacketLossThreshold?: number maxPacketLossThreshold?: number @@ -58,6 +60,8 @@ export class IcmpMonitor extends Monitor { constructor (logicalId: string, props: IcmpMonitorProps) { super(logicalId, props) + this.intent = props.intent + this.setIntent(props.intent) this.request = props.request this.degradedPacketLossThreshold = props.degradedPacketLossThreshold this.maxPacketLossThreshold = props.maxPacketLossThreshold diff --git a/packages/cli/src/constructs/ssl-monitor-codegen.ts b/packages/cli/src/constructs/ssl-monitor-codegen.ts index 876aae0ff..65ab1ba6f 100644 --- a/packages/cli/src/constructs/ssl-monitor-codegen.ts +++ b/packages/cli/src/constructs/ssl-monitor-codegen.ts @@ -83,7 +83,9 @@ export class SslMonitorCodegen extends Codegen { builder.number('maxResponseTime', resource.maxResponseTime) } - buildMonitorProps(this.program, file, builder, resource, context) + buildMonitorProps(this.program, file, builder, resource, context, { + skipIntent: true, + }) builder.value('request', valueForSslRequest(this.program, file, context, constructRequest)) }) diff --git a/packages/cli/src/constructs/tcp-monitor.ts b/packages/cli/src/constructs/tcp-monitor.ts index c821eed02..8da92ca41 100644 --- a/packages/cli/src/constructs/tcp-monitor.ts +++ b/packages/cli/src/constructs/tcp-monitor.ts @@ -4,6 +4,7 @@ import { Session } from './session.js' import { Assertion as CoreAssertion, NumericAssertionBuilder, GeneralAssertionBuilder } from './internal/assertion.js' import { Diagnostics } from './diagnostics.js' import { responseTimeLimits } from './internal/account-features.js' +import { CheckIntent, CheckIntentProps } from './check.js' import { validateResponseTimes } from './internal/common-diagnostics.js' type TcpAssertionSource = 'RESPONSE_DATA' | 'RESPONSE_TIME' @@ -89,7 +90,7 @@ export interface TcpRequest { data?: string } -export interface TcpMonitorProps extends MonitorProps { +export interface TcpMonitorProps extends MonitorProps, CheckIntentProps { /** * Determines the request that the check is going to run. */ @@ -127,6 +128,7 @@ export interface TcpMonitorProps extends MonitorProps { * Creates a TCP Monitor */ export class TcpMonitor extends Monitor { + intent?: CheckIntent | null request: TcpRequest degradedResponseTime?: number maxResponseTime?: number @@ -143,6 +145,8 @@ export class TcpMonitor extends Monitor { constructor (logicalId: string, props: TcpMonitorProps) { super(logicalId, props) + this.intent = props.intent + this.setIntent(props.intent) this.request = props.request this.degradedResponseTime = props.degradedResponseTime this.maxResponseTime = props.maxResponseTime diff --git a/packages/cli/src/constructs/traceroute-monitor-codegen.ts b/packages/cli/src/constructs/traceroute-monitor-codegen.ts index 1c704c860..e9178035c 100644 --- a/packages/cli/src/constructs/traceroute-monitor-codegen.ts +++ b/packages/cli/src/constructs/traceroute-monitor-codegen.ts @@ -40,7 +40,9 @@ export class TracerouteMonitorCodegen extends Codegen builder.number('maxResponseTime', resource.maxResponseTime) } - buildMonitorProps(this.program, file, builder, resource, context) + buildMonitorProps(this.program, file, builder, resource, context, { + skipIntent: true, + }) builder.value('request', valueForTracerouteRequest(this.program, file, context, resource.request)) }) diff --git a/packages/cli/src/constructs/url-monitor.ts b/packages/cli/src/constructs/url-monitor.ts index 6e64d3624..b227b14f6 100644 --- a/packages/cli/src/constructs/url-monitor.ts +++ b/packages/cli/src/constructs/url-monitor.ts @@ -2,6 +2,7 @@ import { Diagnostics } from './diagnostics.js' import { responseTimeLimits } from './internal/account-features.js' import { validateResponseTimes } from './internal/common-diagnostics.js' import { Monitor, MonitorProps } from './monitor.js' +import { CheckIntent, CheckIntentProps } from './check.js' import { Session } from './session.js' import { UrlRequest } from './url-request.js' @@ -9,7 +10,7 @@ import { UrlRequest } from './url-request.js' * Configuration properties for UrlMonitor. * Extends MonitorProps with URL-specific settings. */ -export interface UrlMonitorProps extends MonitorProps { +export interface UrlMonitorProps extends MonitorProps, CheckIntentProps { /** * Determines the request that the monitor is going to run. * Defines the URL and validation rules for the HTTP check. @@ -104,6 +105,7 @@ export interface UrlMonitorProps extends MonitorProps { * @see {@link https://www.checklyhq.com/docs/detect/uptime-monitoring/url-monitors/overview/ | URL Monitors Documentation} */ export class UrlMonitor extends Monitor { + readonly intent?: CheckIntent | null readonly request: UrlRequest readonly degradedResponseTime?: number readonly maxResponseTime?: number @@ -120,6 +122,8 @@ export class UrlMonitor extends Monitor { constructor (logicalId: string, props: UrlMonitorProps) { super(logicalId, props) + this.intent = props.intent + this.setIntent(props.intent) this.request = props.request this.degradedResponseTime = props.degradedResponseTime this.maxResponseTime = props.maxResponseTime From ad5bb609dcde963fd3cf2215c5ecb0b64941991e Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Wed, 5 Aug 2026 12:27:17 +0200 Subject: [PATCH 2/5] fix(constructs): keep check intent state in sync --- packages/cli/package.json | 3 +- .../constructs/__tests__/check-intent.spec.ts | 105 +++++++----------- packages/cli/src/constructs/check.ts | 16 ++- packages/cli/src/constructs/dns-monitor.ts | 10 +- packages/cli/src/constructs/grpc-monitor.ts | 10 +- packages/cli/src/constructs/icmp-monitor.ts | 10 +- packages/cli/src/constructs/tcp-monitor.ts | 10 +- packages/cli/src/constructs/url-monitor.ts | 8 +- packages/cli/tsconfig.type-tests.json | 15 +++ packages/cli/type-tests/check-intent.ts | 54 +++++++++ 10 files changed, 162 insertions(+), 79 deletions(-) create mode 100644 packages/cli/tsconfig.type-tests.json create mode 100644 packages/cli/type-tests/check-intent.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 22cb96ea4..2b68945ee 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,7 +22,8 @@ "prepare:ai-context": "cross-env CHECKLY_SKIP_AUTH=1 CHECKLY_CLI_VERSION=99.0.0 ./bin/run import plan --root gen --debug-import-plan-input-file ./src/ai-context/context.fixtures.json && jiti ./scripts/prepare-ai-context.ts", "prepare:dist": "tsc --build", "prepare": "pnpm run clean && pnpm run prepare:dist && pnpm run prepare:ai-context", - "test": "pnpm pack && vitest --run", + "test": "pnpm pack && pnpm run test:types && vitest --run", + "test:types": "tsc -p tsconfig.type-tests.json --noEmit", "test:e2e": "pnpm pack && cross-env NODE_CONFIG_DIR=./e2e/config vitest --run -c ./vitest.config.e2e.mts", "test:e2e:local": "cross-env CHECKLY_BASE_URL=http://localhost:3000 CHECKLY_ENV=local pnpm run test:e2e", "watch": "tsc --watch" diff --git a/packages/cli/src/constructs/__tests__/check-intent.spec.ts b/packages/cli/src/constructs/__tests__/check-intent.spec.ts index fd429cd00..a32f18256 100644 --- a/packages/cli/src/constructs/__tests__/check-intent.spec.ts +++ b/packages/cli/src/constructs/__tests__/check-intent.spec.ts @@ -1,22 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { AgenticCheckProps } from '../agentic-check.js' -import { ApiCheck, ApiCheckProps } from '../api-check.js' -import { BrowserCheckProps } from '../browser-check.js' +import { ApiCheck } from '../api-check.js' import { CheckIntent } from '../check.js' import { Diagnostics } from '../diagnostics.js' -import { DnsMonitorProps } from '../dns-monitor.js' -import { GrpcMonitorProps } from '../grpc-monitor.js' -import { HeartbeatMonitorProps } from '../heartbeat-monitor.js' -import { IcmpMonitorProps } from '../icmp-monitor.js' -import { MultiStepCheckProps } from '../multi-step-check.js' -import { PlaywrightCheck, PlaywrightCheckProps } from '../playwright-check.js' +import { DnsMonitor } from '../dns-monitor.js' +import { PlaywrightCheck } from '../playwright-check.js' import { Project } from '../project.js' import { Session } from '../session.js' -import { SslMonitorProps } from '../ssl-monitor.js' -import { TcpMonitorProps } from '../tcp-monitor.js' -import { TracerouteMonitorProps } from '../traceroute-monitor.js' -import { UrlMonitor, UrlMonitorProps } from '../url-monitor.js' +import { UrlMonitor } from '../url-monitor.js' const completeIntent: CheckIntent = { goal: 'Verify that authenticated users can open the dashboard.', @@ -116,6 +107,43 @@ describe('check intent', () => { it('synthesizes null to explicitly clear intent', () => { expect(apiCheck(null).synthesize()).toHaveProperty('intent', null) }) + + it('uses reassigned runtime-check intent for validation and synthesis', async () => { + const check = apiCheck(completeIntent) + check.intent = { goal: ' ' } + + const diagnostics = new Diagnostics() + await check.validate(diagnostics) + + expect(messages(diagnostics)).toEqual(expect.arrayContaining([ + expect.stringContaining('The intent goal must not be blank.'), + ])) + + check.intent = { goal: ' Verify the replacement dashboard flow. ' } + expect(check.synthesize()).toHaveProperty('intent', { + goal: 'Verify the replacement dashboard flow.', + requiredOutcomes: [], + mustPreserve: [], + }) + + check.intent = null + expect(check.synthesize()).toHaveProperty('intent', null) + }) + + it('uses reassigned monitor intent when synthesizing an explicit clear', () => { + const monitor = new DnsMonitor('dns-intent-reassignment', { + name: 'Dashboard DNS', + intent: completeIntent, + request: { + recordType: 'A', + query: 'example.com', + }, + }) + + monitor.intent = null + + expect(monitor.synthesize()).toHaveProperty('intent', null) + }) }) describe('validation', () => { @@ -223,55 +251,4 @@ describe('check intent', () => { ])) }) }) - - it('exposes intent only on supported construct prop types', () => { - type HasIntent = 'intent' extends keyof Props ? true : false - type IntentExposure = { - api: HasIntent - browser: HasIntent - multiStep: HasIntent - url: HasIntent - dns: HasIntent - icmp: HasIntent - tcp: HasIntent - grpc: HasIntent - playwright: HasIntent - agentic: HasIntent - heartbeat: HasIntent - ssl: HasIntent - traceroute: HasIntent - } - - const exposure: IntentExposure = { - api: true, - browser: true, - multiStep: true, - url: true, - dns: true, - icmp: true, - tcp: true, - grpc: true, - playwright: true, - agentic: false, - heartbeat: false, - ssl: false, - traceroute: false, - } - - expect(exposure).toEqual({ - api: true, - browser: true, - multiStep: true, - url: true, - dns: true, - icmp: true, - tcp: true, - grpc: true, - playwright: true, - agentic: false, - heartbeat: false, - ssl: false, - traceroute: false, - }) - }) }) diff --git a/packages/cli/src/constructs/check.ts b/packages/cli/src/constructs/check.ts index fd2abfc25..43a6b0f85 100644 --- a/packages/cli/src/constructs/check.ts +++ b/packages/cli/src/constructs/check.ts @@ -414,7 +414,11 @@ export abstract class Check extends Construct { return false } - protected setIntent (intent: CheckIntent | null | undefined): void { + protected get checkIntent (): CheckIntent | null | undefined { + return this.#intent + } + + protected set checkIntent (intent: CheckIntent | null | undefined) { this.#intent = intent } @@ -699,15 +703,21 @@ export interface RuntimeCheckProps extends CheckProps, CheckIntentProps { } export abstract class RuntimeCheck extends Check { - intent?: CheckIntent | null runtimeId?: string environmentVariables?: EnvironmentVariable[] + get intent (): CheckIntent | null | undefined { + return this.checkIntent + } + + set intent (intent: CheckIntent | null | undefined) { + this.checkIntent = intent + } + protected constructor (logicalId: string, props: RuntimeCheckProps) { super(logicalId, props) const config = this.applyConfigDefaults(props) this.intent = props.intent - this.setIntent(props.intent) this.runtimeId = config.runtimeId this.environmentVariables = config.environmentVariables ?? [] } diff --git a/packages/cli/src/constructs/dns-monitor.ts b/packages/cli/src/constructs/dns-monitor.ts index e69769f22..55a5144b2 100644 --- a/packages/cli/src/constructs/dns-monitor.ts +++ b/packages/cli/src/constructs/dns-monitor.ts @@ -50,11 +50,18 @@ export interface DnsMonitorProps extends MonitorProps, CheckIntentProps { * Creates a DNS Monitor */ export class DnsMonitor extends Monitor { - intent?: CheckIntent | null request: DnsRequest degradedResponseTime?: number maxResponseTime?: number + get intent (): CheckIntent | null | undefined { + return this.checkIntent + } + + set intent (intent: CheckIntent | null | undefined) { + this.checkIntent = intent + } + /** * Constructs the DNS Monitor instance * @@ -68,7 +75,6 @@ export class DnsMonitor extends Monitor { super(logicalId, props) this.intent = props.intent - this.setIntent(props.intent) this.request = props.request this.degradedResponseTime = props.degradedResponseTime this.maxResponseTime = props.maxResponseTime diff --git a/packages/cli/src/constructs/grpc-monitor.ts b/packages/cli/src/constructs/grpc-monitor.ts index bca53ac36..78fcb0262 100644 --- a/packages/cli/src/constructs/grpc-monitor.ts +++ b/packages/cli/src/constructs/grpc-monitor.ts @@ -46,11 +46,18 @@ export interface GrpcMonitorProps extends MonitorProps, CheckIntentProps { * Creates a gRPC Monitor */ export class GrpcMonitor extends Monitor { - intent?: CheckIntent | null request: GrpcRequest degradedResponseTime?: number maxResponseTime?: number + get intent (): CheckIntent | null | undefined { + return this.checkIntent + } + + set intent (intent: CheckIntent | null | undefined) { + this.checkIntent = intent + } + /** * Constructs the gRPC Monitor instance * @@ -64,7 +71,6 @@ export class GrpcMonitor extends Monitor { super(logicalId, props) this.intent = props.intent - this.setIntent(props.intent) this.request = props.request this.degradedResponseTime = props.degradedResponseTime this.maxResponseTime = props.maxResponseTime diff --git a/packages/cli/src/constructs/icmp-monitor.ts b/packages/cli/src/constructs/icmp-monitor.ts index 07cdb69cd..3ed85210d 100644 --- a/packages/cli/src/constructs/icmp-monitor.ts +++ b/packages/cli/src/constructs/icmp-monitor.ts @@ -43,11 +43,18 @@ export interface IcmpMonitorProps extends MonitorProps, CheckIntentProps { * Creates an ICMP Monitor */ export class IcmpMonitor extends Monitor { - intent?: CheckIntent | null request: IcmpRequest degradedPacketLossThreshold?: number maxPacketLossThreshold?: number + get intent (): CheckIntent | null | undefined { + return this.checkIntent + } + + set intent (intent: CheckIntent | null | undefined) { + this.checkIntent = intent + } + /** * Constructs the ICMP Monitor instance * @@ -61,7 +68,6 @@ export class IcmpMonitor extends Monitor { super(logicalId, props) this.intent = props.intent - this.setIntent(props.intent) this.request = props.request this.degradedPacketLossThreshold = props.degradedPacketLossThreshold this.maxPacketLossThreshold = props.maxPacketLossThreshold diff --git a/packages/cli/src/constructs/tcp-monitor.ts b/packages/cli/src/constructs/tcp-monitor.ts index 8da92ca41..09ead1f50 100644 --- a/packages/cli/src/constructs/tcp-monitor.ts +++ b/packages/cli/src/constructs/tcp-monitor.ts @@ -128,11 +128,18 @@ export interface TcpMonitorProps extends MonitorProps, CheckIntentProps { * Creates a TCP Monitor */ export class TcpMonitor extends Monitor { - intent?: CheckIntent | null request: TcpRequest degradedResponseTime?: number maxResponseTime?: number + get intent (): CheckIntent | null | undefined { + return this.checkIntent + } + + set intent (intent: CheckIntent | null | undefined) { + this.checkIntent = intent + } + /** * Constructs the TCP Monitor instance * @@ -146,7 +153,6 @@ export class TcpMonitor extends Monitor { super(logicalId, props) this.intent = props.intent - this.setIntent(props.intent) this.request = props.request this.degradedResponseTime = props.degradedResponseTime this.maxResponseTime = props.maxResponseTime diff --git a/packages/cli/src/constructs/url-monitor.ts b/packages/cli/src/constructs/url-monitor.ts index b227b14f6..549e20162 100644 --- a/packages/cli/src/constructs/url-monitor.ts +++ b/packages/cli/src/constructs/url-monitor.ts @@ -105,11 +105,14 @@ export interface UrlMonitorProps extends MonitorProps, CheckIntentProps { * @see {@link https://www.checklyhq.com/docs/detect/uptime-monitoring/url-monitors/overview/ | URL Monitors Documentation} */ export class UrlMonitor extends Monitor { - readonly intent?: CheckIntent | null readonly request: UrlRequest readonly degradedResponseTime?: number readonly maxResponseTime?: number + get intent (): CheckIntent | null | undefined { + return this.checkIntent + } + /** * Constructs the URL Monitor instance * @@ -122,8 +125,7 @@ export class UrlMonitor extends Monitor { constructor (logicalId: string, props: UrlMonitorProps) { super(logicalId, props) - this.intent = props.intent - this.setIntent(props.intent) + this.checkIntent = props.intent this.request = props.request this.degradedResponseTime = props.degradedResponseTime this.maxResponseTime = props.maxResponseTime diff --git a/packages/cli/tsconfig.type-tests.json b/packages/cli/tsconfig.type-tests.json new file mode 100644 index 000000000..dfbff71ca --- /dev/null +++ b/packages/cli/tsconfig.type-tests.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "incremental": false, + "noEmit": true, + "outDir": "./dist-type-tests", + "rootDir": ".", + "sourceMap": false + }, + "include": [ + "type-tests/**/*" + ], + "exclude": [] +} diff --git a/packages/cli/type-tests/check-intent.ts b/packages/cli/type-tests/check-intent.ts new file mode 100644 index 000000000..6fdb576b9 --- /dev/null +++ b/packages/cli/type-tests/check-intent.ts @@ -0,0 +1,54 @@ +import type { + AgenticCheckProps, + ApiCheckProps, + BrowserCheckProps, + CheckIntent, + DnsMonitorProps, + GrpcMonitorProps, + HeartbeatMonitorProps, + IcmpMonitorProps, + MultiStepCheckProps, + PlaywrightCheckProps, + SslMonitorProps, + TcpMonitorProps, + TracerouteMonitorProps, + UrlMonitorProps, +} from '../src/constructs/index.js' + +type HasIntent = 'intent' extends keyof Props ? true : false + +type IntentExposure = { + api: HasIntent + browser: HasIntent + multiStep: HasIntent + url: HasIntent + dns: HasIntent + icmp: HasIntent + tcp: HasIntent + grpc: HasIntent + playwright: HasIntent + agentic: HasIntent + heartbeat: HasIntent + ssl: HasIntent + traceroute: HasIntent +} + +export const intentExposure: IntentExposure = { + api: true, + browser: true, + multiStep: true, + url: true, + dns: true, + icmp: true, + tcp: true, + grpc: true, + playwright: true, + agentic: false, + heartbeat: false, + ssl: false, + traceroute: false, +} + +export const goalOnlyIntent: CheckIntent = { + goal: 'Verify that authenticated users can open the dashboard.', +} From 4121b6bb1b03239f28a91a3206d3379616954cbb Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Wed, 12 Aug 2026 19:08:02 +0200 Subject: [PATCH 3/5] refactor(constructs): model check intent as constraints --- .../constructs/__tests__/api-check.spec.ts | 8 +- .../constructs/__tests__/check-intent.spec.ts | 139 ++++++++++++--- .../test-cases/test-intent/test.check.js | 10 ++ .../__tests__/uptime-monitor-codegen.spec.ts | 14 +- packages/cli/src/constructs/check-codegen.ts | 36 ++-- packages/cli/src/constructs/check.ts | 164 ++++++++++++------ packages/cli/type-tests/check-intent.ts | 29 ++++ 7 files changed, 302 insertions(+), 98 deletions(-) diff --git a/packages/cli/src/constructs/__tests__/api-check.spec.ts b/packages/cli/src/constructs/__tests__/api-check.spec.ts index 51a4165a7..18dd2420a 100644 --- a/packages/cli/src/constructs/__tests__/api-check.spec.ts +++ b/packages/cli/src/constructs/__tests__/api-check.spec.ts @@ -161,8 +161,12 @@ describe('ApiCheck', () => { payload: expect.objectContaining({ intent: { goal: 'Verify that authenticated users can open the dashboard.', - requiredOutcomes: [], - mustPreserve: [], + requiredOutcomes: [ + 'The dashboard displays the account overview.', + ], + mustPreserve: [ + 'Do not weaken the authentication assertion.', + ], }, }), }), diff --git a/packages/cli/src/constructs/__tests__/check-intent.spec.ts b/packages/cli/src/constructs/__tests__/check-intent.spec.ts index a32f18256..9b3cf2ee5 100644 --- a/packages/cli/src/constructs/__tests__/check-intent.spec.ts +++ b/packages/cli/src/constructs/__tests__/check-intent.spec.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { ApiCheck } from '../api-check.js' -import { CheckIntent } from '../check.js' +import { CheckIntent, CheckIntentConstraintType } from '../check.js' import { Diagnostics } from '../diagnostics.js' import { DnsMonitor } from '../dns-monitor.js' import { PlaywrightCheck } from '../playwright-check.js' @@ -11,6 +11,28 @@ import { UrlMonitor } from '../url-monitor.js' const completeIntent: CheckIntent = { goal: 'Verify that authenticated users can open the dashboard.', + constraints: [ + { + type: 'required_outcome', + statement: 'Authentication succeeds for a valid user.', + }, + { + type: 'required_outcome', + statement: 'The dashboard displays the account overview.', + }, + { + type: 'must_preserve', + statement: 'Do not remove or weaken the authentication assertion.', + }, + { + type: 'must_preserve', + statement: 'Do not replace the dashboard assertion with a generic page-load assertion.', + }, + ], +} + +const synthesizedCompleteIntent = { + goal: completeIntent.goal, requiredOutcomes: [ 'Authentication succeeds for a valid user.', 'The dashboard displays the account overview.', @@ -44,6 +66,10 @@ function messages (diagnostics: Diagnostics): string[] { return diagnostics.observations.map(observation => observation.message) } +function constraint (type: CheckIntentConstraintType, statement: string) { + return { type, statement } +} + describe('check intent', () => { beforeEach(() => { nextLogicalId = 0 @@ -75,7 +101,7 @@ describe('check intent', () => { expect(check.intent).toBe(completeIntent) expect(check.synthesize()).toMatchObject({ - intent: completeIntent, + intent: synthesizedCompleteIntent, }) }) @@ -93,11 +119,11 @@ describe('check intent', () => { playwrightConfigPath: '/tmp/playwright.config.ts', }) - expect(apiCheck(completeIntent).synthesize()).toHaveProperty('intent', completeIntent) + expect(apiCheck(completeIntent).synthesize()).toHaveProperty('intent', synthesizedCompleteIntent) expect(monitor.intent).toBe(completeIntent) - expect(monitor.synthesize()).toHaveProperty('intent', completeIntent) + expect(monitor.synthesize()).toHaveProperty('intent', synthesizedCompleteIntent) expect(playwright.intent).toBe(completeIntent) - expect(playwright.synthesize()).toHaveProperty('intent', completeIntent) + expect(playwright.synthesize()).toHaveProperty('intent', synthesizedCompleteIntent) }) it('omits undefined intent so deployments do not take ownership of existing intent', () => { @@ -167,43 +193,55 @@ describe('check intent', () => { ])) }) - it('accepts 20 required outcomes and rejects 21', async () => { + it('accepts 20 required-outcome constraints and rejects 21', async () => { expect((await validateIntent({ goal: 'Verify the dashboard.', - requiredOutcomes: Array.from({ length: 20 }, (_, index) => `Outcome ${index}`), + constraints: Array.from( + { length: 20 }, + (_, index) => constraint('required_outcome', `Outcome ${index}`), + ), })).isFatal()).toBe(false) const diagnostics = await validateIntent({ goal: 'Verify the dashboard.', - requiredOutcomes: Array.from({ length: 21 }, (_, index) => `Outcome ${index}`), + constraints: Array.from( + { length: 21 }, + (_, index) => constraint('required_outcome', `Outcome ${index}`), + ), }) expect(messages(diagnostics)).toEqual(expect.arrayContaining([ - expect.stringContaining('may contain at most 20 required outcomes, got 21.'), + expect.stringContaining('may contain at most 20 required-outcome constraints, got 21.'), ])) }) - it('accepts 20 must-preserve guardrails and rejects 21', async () => { + it('accepts 20 must-preserve constraints and rejects 21', async () => { expect((await validateIntent({ goal: 'Verify the dashboard.', - mustPreserve: Array.from({ length: 20 }, (_, index) => `Guardrail ${index}`), + constraints: Array.from( + { length: 20 }, + (_, index) => constraint('must_preserve', `Guardrail ${index}`), + ), })).isFatal()).toBe(false) const diagnostics = await validateIntent({ goal: 'Verify the dashboard.', - mustPreserve: Array.from({ length: 21 }, (_, index) => `Guardrail ${index}`), + constraints: Array.from( + { length: 21 }, + (_, index) => constraint('must_preserve', `Guardrail ${index}`), + ), }) expect(messages(diagnostics)).toEqual(expect.arrayContaining([ - expect.stringContaining('may contain at most 20 must-preserve guardrails, got 21.'), + expect.stringContaining('may contain at most 20 must-preserve constraints, got 21.'), ])) }) it.each([ - ['required outcome', 'requiredOutcomes'], - ['must-preserve guardrail', 'mustPreserve'], - ] as const)('rejects a blank %s statement', async (label, property) => { + ['required-outcome constraint statement', 'required_outcome'], + ['must-preserve constraint statement', 'must_preserve'], + ] as const)('rejects a blank %s', async (label, type) => { const diagnostics = await validateIntent({ goal: 'Verify the dashboard.', - [property]: [' '], + constraints: [constraint(type, ' ')], }) expect(messages(diagnostics)).toEqual(expect.arrayContaining([ expect.stringContaining(`The intent ${label} must not be blank.`), @@ -211,43 +249,88 @@ describe('check intent', () => { }) it.each([ - ['required outcome', 'requiredOutcomes'], - ['must-preserve guardrail', 'mustPreserve'], - ] as const)('accepts a 1,000-character %s and rejects 1,001 characters', async (label, property) => { + ['required-outcome constraint statement', 'required_outcome'], + ['must-preserve constraint statement', 'must_preserve'], + ] as const)('accepts a 1,000-character %s and rejects 1,001 characters', async (label, type) => { expect((await validateIntent({ goal: 'Verify the dashboard.', - [property]: ['s'.repeat(1_000)], + constraints: [constraint(type, 's'.repeat(1_000))], })).isFatal()).toBe(false) const diagnostics = await validateIntent({ goal: 'Verify the dashboard.', - [property]: ['s'.repeat(1_001)], + constraints: [constraint(type, 's'.repeat(1_001))], }) expect(messages(diagnostics)).toEqual(expect.arrayContaining([ expect.stringContaining(`The intent ${label} must be at most 1000 characters after trimming, got 1001.`), ])) }) - it('rejects unknown fields instead of silently discarding them', async () => { + it('rejects unknown intent and constraint fields instead of silently discarding them', async () => { const diagnostics = await validateIntent({ goal: 'Verify the dashboard.', assertion: 'The dashboard is visible.', + constraints: [{ + type: 'required_outcome', + statement: 'The dashboard loads.', + priority: 'high', + }], }) expect(messages(diagnostics)).toEqual(expect.arrayContaining([ expect.stringContaining('"intent" contains unknown field "assertion".'), + expect.stringContaining('"intent.constraints[0]" contains unknown field "priority".'), + ])) + }) + + it('rejects non-array constraints and non-object constraint entries', async () => { + const nonArrayDiagnostics = await validateIntent({ + goal: 'Verify the dashboard.', + constraints: 'The dashboard loads.', + }) + const nonObjectDiagnostics = await validateIntent({ + goal: 'Verify the dashboard.', + constraints: ['The dashboard loads.'], + }) + + expect(messages(nonArrayDiagnostics)).toEqual(expect.arrayContaining([ + expect.stringContaining('"intent.constraints" must be an array of constraint objects.'), + ])) + expect(messages(nonObjectDiagnostics)).toEqual(expect.arrayContaining([ + expect.stringContaining('"intent.constraints[0]" must be a constraint object.'), + ])) + }) + + it('rejects missing or unsupported constraint types', async () => { + const diagnostics = await validateIntent({ + goal: 'Verify the dashboard.', + constraints: [ + { statement: 'The dashboard loads.' }, + { type: 'nice_to_have', statement: 'The dashboard loads quickly.' }, + ], + }) + + expect(messages(diagnostics)).toEqual(expect.arrayContaining([ + expect.stringContaining( + 'The intent constraint type must be "required_outcome" or "must_preserve", got undefined.', + ), + expect.stringContaining( + 'The intent constraint type must be "required_outcome" or "must_preserve", got "nice_to_have".', + ), ])) }) - it('rejects non-string statements and non-array statement sections', async () => { + it('rejects missing or non-string constraint statements', async () => { const diagnostics = await validateIntent({ goal: 'Verify the dashboard.', - requiredOutcomes: 'The dashboard loads.', - mustPreserve: [42], + constraints: [ + { type: 'required_outcome' }, + { type: 'must_preserve', statement: 42 }, + ], }) expect(messages(diagnostics)).toEqual(expect.arrayContaining([ - expect.stringContaining('"intent.requiredOutcomes" must be an array of strings.'), - expect.stringContaining('The intent must-preserve guardrail must be a string.'), + expect.stringContaining('The intent required-outcome constraint statement must be a string.'), + expect.stringContaining('The intent must-preserve constraint statement must be a string.'), ])) }) }) diff --git a/packages/cli/src/constructs/__tests__/fixtures/api-check/test-cases/test-intent/test.check.js b/packages/cli/src/constructs/__tests__/fixtures/api-check/test-cases/test-intent/test.check.js index dbccac0b8..5593c0cd3 100644 --- a/packages/cli/src/constructs/__tests__/fixtures/api-check/test-cases/test-intent/test.check.js +++ b/packages/cli/src/constructs/__tests__/fixtures/api-check/test-cases/test-intent/test.check.js @@ -4,6 +4,16 @@ new ApiCheck('dashboard-intent', { name: 'Dashboard intent', intent: { goal: 'Verify that authenticated users can open the dashboard.', + constraints: [ + { + type: 'required_outcome', + statement: 'The dashboard displays the account overview.', + }, + { + type: 'must_preserve', + statement: 'Do not weaken the authentication assertion.', + }, + ], }, request: { method: 'GET', diff --git a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts index 96ce69795..a103747a3 100644 --- a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts +++ b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts @@ -124,11 +124,15 @@ describe('GrpcMonitorCodegen', () => { expect(source).toContain(`intent: { goal: 'Verify that the gRPC health service is available.', - requiredOutcomes: [ - 'The health RPC returns a serving response.', - ], - mustPreserve: [ - 'Do not weaken the serving-status assertion.', + constraints: [ + { + type: 'required_outcome', + statement: 'The health RPC returns a serving response.', + }, + { + type: 'must_preserve', + statement: 'Do not weaken the serving-status assertion.', + }, ], }`) expect(source.indexOf('name:')).toBeLessThan(source.indexOf('intent:')) diff --git a/packages/cli/src/constructs/check-codegen.ts b/packages/cli/src/constructs/check-codegen.ts index 9e0d8d058..56e7cec9c 100644 --- a/packages/cli/src/constructs/check-codegen.ts +++ b/packages/cli/src/constructs/check-codegen.ts @@ -20,14 +20,25 @@ import { IcmpMonitorCodegen, IcmpMonitorResource } from './icmp-monitor-codegen. import { GrpcMonitorCodegen, GrpcMonitorResource } from './grpc-monitor-codegen.js' import { SslMonitorCodegen, SslMonitorResource } from './ssl-monitor-codegen.js' import { TracerouteMonitorCodegen, TracerouteMonitorResource } from './traceroute-monitor-codegen.js' -import { CheckIntent } from './check.js' + +/** + * Intent shape returned by the checks resource API. + * + * Construct code generation adapts this wire representation to typed intent + * constraints so exported resources round-trip through Monitoring as Code. + */ +export interface CheckIntentResource { + goal: string + requiredOutcomes?: string[] + mustPreserve?: string[] +} export interface CheckResource { id: string checkType: string name: string description?: string | null - intent?: CheckIntent | null + intent?: CheckIntentResource | null activated?: boolean muted?: boolean // Handled by the backend which creates the appropriate retryStrategy. @@ -89,19 +100,20 @@ export function buildCheckProps ( builder.string('goal', intent.goal) const requiredOutcomes = intent.requiredOutcomes ?? [] - if (requiredOutcomes.length > 0) { - builder.array('requiredOutcomes', builder => { + const mustPreserve = intent.mustPreserve ?? [] + if (requiredOutcomes.length > 0 || mustPreserve.length > 0) { + builder.array('constraints', builder => { for (const statement of requiredOutcomes) { - builder.string(statement) + builder.object(builder => { + builder.string('type', 'required_outcome') + builder.string('statement', statement) + }) } - }) - } - - const mustPreserve = intent.mustPreserve ?? [] - if (mustPreserve.length > 0) { - builder.array('mustPreserve', builder => { for (const statement of mustPreserve) { - builder.string(statement) + builder.object(builder => { + builder.string('type', 'must_preserve') + builder.string('statement', statement) + }) } }) } diff --git a/packages/cli/src/constructs/check.ts b/packages/cli/src/constructs/check.ts index 43a6b0f85..97dade31e 100644 --- a/packages/cli/src/constructs/check.ts +++ b/packages/cli/src/constructs/check.ts @@ -50,6 +50,32 @@ export type CheckRetryStrategy = | SingleRetryRetryStrategy | NoRetriesRetryStrategy +/** + * The kind of durable guidance expressed by a check intent constraint. + */ +export type CheckIntentConstraintType = 'required_outcome' | 'must_preserve' + +/** + * A typed statement that refines a check's goal. + * + * New constraint types can be added without changing the overall intent shape. + */ +export interface CheckIntentConstraint { + /** + * How root cause analysis and check repair should interpret the statement. + */ + type: CheckIntentConstraintType + + /** + * The durable guidance for this constraint. + * Leading and trailing whitespace is removed during synthesis. + * + * @minLength 1 + * @maxLength 1000 + */ + statement: string +} + /** * Durable guidance describing what a check is intended to verify. * @@ -67,22 +93,11 @@ export interface CheckIntent { goal: string /** - * Specific outcomes that must hold for the check to satisfy its goal. - * Omitted values are synthesized as an empty array. - * Each statement is trimmed and must contain between 1 and 1,000 characters. - * - * @maxItems 20 - */ - requiredOutcomes?: string[] - - /** - * Guardrails that a repair must not weaken or remove. - * Omitted values are synthesized as an empty array. - * Each statement is trimmed and must contain between 1 and 1,000 characters. - * - * @maxItems 20 + * Typed outcomes and guardrails that refine the goal. + * Omitted values are synthesized as empty backend constraint sections. + * At most 20 constraints of each supported type may be provided. */ - mustPreserve?: string[] + constraints?: CheckIntentConstraint[] } /** @@ -101,12 +116,15 @@ export interface CheckIntentProps { * ```typescript * intent: { * goal: 'Verify that authenticated users can open the dashboard.', - * requiredOutcomes: [ - * 'Authentication succeeds for a valid user.', - * 'The dashboard displays the account overview.', - * ], - * mustPreserve: [ - * 'Do not remove or weaken the authentication assertion.', + * constraints: [ + * { + * type: 'required_outcome', + * statement: 'Authentication succeeds for a valid user.', + * }, + * { + * type: 'must_preserve', + * statement: 'Do not remove or weaken the authentication assertion.', + * }, * ], * } * ``` @@ -436,38 +454,25 @@ export abstract class Check extends Construct { } const intent = this.#intent as unknown as Record - const supportedFields = new Set(['goal', 'requiredOutcomes', 'mustPreserve']) + const supportedFields = new Set(['goal', 'constraints']) for (const field of Object.keys(intent)) { if (!supportedFields.has(field)) { diagnostics.add(new InvalidPropertyValueDiagnostic( 'intent', new Error( `"intent" contains unknown field "${field}". ` - + 'Supported fields are "goal", "requiredOutcomes", and "mustPreserve".', + + 'Supported fields are "goal" and "constraints".', ), )) } } this.validateIntentStatement(diagnostics, 'intent.goal', 'goal', intent.goal, 2_000) - this.validateIntentStatements( - diagnostics, - 'intent.requiredOutcomes', - 'required outcome', - intent.requiredOutcomes, - ) - this.validateIntentStatements( - diagnostics, - 'intent.mustPreserve', - 'must-preserve guardrail', - intent.mustPreserve, - ) + this.validateIntentConstraints(diagnostics, intent.constraints) } - private validateIntentStatements ( + private validateIntentConstraints ( diagnostics: Diagnostics, - property: 'intent.requiredOutcomes' | 'intent.mustPreserve', - label: 'required outcome' | 'must-preserve guardrail', value: unknown, ): void { if (value === undefined) { @@ -476,28 +481,81 @@ export abstract class Check extends Construct { if (!Array.isArray(value)) { diagnostics.add(new InvalidPropertyValueDiagnostic( - property, - new Error(`"${property}" must be an array of strings.`), + 'intent.constraints', + new Error('"intent.constraints" must be an array of constraint objects.'), )) return } - if (value.length > 20) { - diagnostics.add(new InvalidPropertyValueDiagnostic( - property, - new Error(`"${property}" may contain at most 20 ${label}s, got ${value.length}.`), - )) + const counts: Record = { + required_outcome: 0, + must_preserve: 0, } - for (const [index, statement] of value.entries()) { + for (const [index, constraint] of value.entries()) { + const property = `intent.constraints[${index}]` + if (typeof constraint !== 'object' || constraint === null || Array.isArray(constraint)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + property, + new Error(`"${property}" must be a constraint object.`), + )) + continue + } + + const fields = constraint as Record + const supportedFields = new Set(['type', 'statement']) + for (const field of Object.keys(fields)) { + if (!supportedFields.has(field)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + property, + new Error( + `"${property}" contains unknown field "${field}". ` + + 'Supported fields are "type" and "statement".', + ), + )) + } + } + + const type = fields.type + if (type !== 'required_outcome' && type !== 'must_preserve') { + diagnostics.add(new InvalidPropertyValueDiagnostic( + `${property}.type`, + new Error( + `The intent constraint type must be "required_outcome" or "must_preserve", got ${JSON.stringify(type)}.`, + ), + )) + } else { + counts[type] += 1 + } + + const statementLabel = type === 'required_outcome' + ? 'required-outcome constraint statement' + : type === 'must_preserve' + ? 'must-preserve constraint statement' + : 'constraint statement' this.validateIntentStatement( diagnostics, - `${property}[${index}]`, - label, - statement, + `${property}.statement`, + statementLabel, + fields.statement, 1_000, ) } + + const constraintLabels: Record = { + required_outcome: 'required-outcome constraints', + must_preserve: 'must-preserve constraints', + } + for (const type of ['required_outcome', 'must_preserve'] as const) { + if (counts[type] > 20) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'intent.constraints', + new Error( + `"intent.constraints" may contain at most 20 ${constraintLabels[type]}, got ${counts[type]}.`, + ), + )) + } + } } private validateIntentStatement ( @@ -636,8 +694,12 @@ export abstract class Check extends Construct { ? null : { goal: this.#intent.goal.trim(), - requiredOutcomes: (this.#intent.requiredOutcomes ?? []).map(statement => statement.trim()), - mustPreserve: (this.#intent.mustPreserve ?? []).map(statement => statement.trim()), + requiredOutcomes: (this.#intent.constraints ?? []) + .filter(constraint => constraint.type === 'required_outcome') + .map(constraint => constraint.statement.trim()), + mustPreserve: (this.#intent.constraints ?? []) + .filter(constraint => constraint.type === 'must_preserve') + .map(constraint => constraint.statement.trim()), }, } diff --git a/packages/cli/type-tests/check-intent.ts b/packages/cli/type-tests/check-intent.ts index 6fdb576b9..1e888a393 100644 --- a/packages/cli/type-tests/check-intent.ts +++ b/packages/cli/type-tests/check-intent.ts @@ -52,3 +52,32 @@ export const intentExposure: IntentExposure = { export const goalOnlyIntent: CheckIntent = { goal: 'Verify that authenticated users can open the dashboard.', } + +export const completeIntent: CheckIntent = { + goal: 'Verify that authenticated users can open the dashboard.', + constraints: [ + { + type: 'required_outcome', + statement: 'The dashboard displays the account overview.', + }, + { + type: 'must_preserve', + statement: 'Do not weaken the authentication assertion.', + }, + ], +} + +export const oldIntentShapeIsNotExposed: CheckIntent = { + goal: 'Verify that authenticated users can open the dashboard.', + // @ts-expect-error CheckIntent exposes typed constraints, not backend wire fields. + requiredOutcomes: ['The dashboard displays the account overview.'], +} + +export const unsupportedConstraintTypeIsRejected: CheckIntent = { + goal: 'Verify that authenticated users can open the dashboard.', + constraints: [{ + // @ts-expect-error New constraint types require explicit CLI and backend support. + type: 'nice_to_have', + statement: 'The dashboard loads quickly.', + }], +} From b9b4c0af5071c3104ea4c98064fc782fa5b68c2a Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Thu, 13 Aug 2026 18:26:14 +0200 Subject: [PATCH 4/5] refactor(constructs): uppercase intent constraint types --- .../constructs/__tests__/check-intent.spec.ts | 36 +++++++++---------- .../test-cases/test-intent/test.check.js | 4 +-- .../__tests__/uptime-monitor-codegen.spec.ts | 4 +-- packages/cli/src/constructs/check-codegen.ts | 4 +-- packages/cli/src/constructs/check.ts | 28 +++++++-------- packages/cli/type-tests/check-intent.ts | 6 ++-- 6 files changed, 41 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/constructs/__tests__/check-intent.spec.ts b/packages/cli/src/constructs/__tests__/check-intent.spec.ts index 9b3cf2ee5..63599f95f 100644 --- a/packages/cli/src/constructs/__tests__/check-intent.spec.ts +++ b/packages/cli/src/constructs/__tests__/check-intent.spec.ts @@ -13,19 +13,19 @@ const completeIntent: CheckIntent = { goal: 'Verify that authenticated users can open the dashboard.', constraints: [ { - type: 'required_outcome', + type: 'REQUIRED_OUTCOME', statement: 'Authentication succeeds for a valid user.', }, { - type: 'required_outcome', + type: 'REQUIRED_OUTCOME', statement: 'The dashboard displays the account overview.', }, { - type: 'must_preserve', + type: 'MUST_PRESERVE', statement: 'Do not remove or weaken the authentication assertion.', }, { - type: 'must_preserve', + type: 'MUST_PRESERVE', statement: 'Do not replace the dashboard assertion with a generic page-load assertion.', }, ], @@ -198,7 +198,7 @@ describe('check intent', () => { goal: 'Verify the dashboard.', constraints: Array.from( { length: 20 }, - (_, index) => constraint('required_outcome', `Outcome ${index}`), + (_, index) => constraint('REQUIRED_OUTCOME', `Outcome ${index}`), ), })).isFatal()).toBe(false) @@ -206,7 +206,7 @@ describe('check intent', () => { goal: 'Verify the dashboard.', constraints: Array.from( { length: 21 }, - (_, index) => constraint('required_outcome', `Outcome ${index}`), + (_, index) => constraint('REQUIRED_OUTCOME', `Outcome ${index}`), ), }) expect(messages(diagnostics)).toEqual(expect.arrayContaining([ @@ -219,7 +219,7 @@ describe('check intent', () => { goal: 'Verify the dashboard.', constraints: Array.from( { length: 20 }, - (_, index) => constraint('must_preserve', `Guardrail ${index}`), + (_, index) => constraint('MUST_PRESERVE', `Guardrail ${index}`), ), })).isFatal()).toBe(false) @@ -227,7 +227,7 @@ describe('check intent', () => { goal: 'Verify the dashboard.', constraints: Array.from( { length: 21 }, - (_, index) => constraint('must_preserve', `Guardrail ${index}`), + (_, index) => constraint('MUST_PRESERVE', `Guardrail ${index}`), ), }) expect(messages(diagnostics)).toEqual(expect.arrayContaining([ @@ -236,8 +236,8 @@ describe('check intent', () => { }) it.each([ - ['required-outcome constraint statement', 'required_outcome'], - ['must-preserve constraint statement', 'must_preserve'], + ['required-outcome constraint statement', 'REQUIRED_OUTCOME'], + ['must-preserve constraint statement', 'MUST_PRESERVE'], ] as const)('rejects a blank %s', async (label, type) => { const diagnostics = await validateIntent({ goal: 'Verify the dashboard.', @@ -249,8 +249,8 @@ describe('check intent', () => { }) it.each([ - ['required-outcome constraint statement', 'required_outcome'], - ['must-preserve constraint statement', 'must_preserve'], + ['required-outcome constraint statement', 'REQUIRED_OUTCOME'], + ['must-preserve constraint statement', 'MUST_PRESERVE'], ] as const)('accepts a 1,000-character %s and rejects 1,001 characters', async (label, type) => { expect((await validateIntent({ goal: 'Verify the dashboard.', @@ -271,7 +271,7 @@ describe('check intent', () => { goal: 'Verify the dashboard.', assertion: 'The dashboard is visible.', constraints: [{ - type: 'required_outcome', + type: 'REQUIRED_OUTCOME', statement: 'The dashboard loads.', priority: 'high', }], @@ -305,16 +305,16 @@ describe('check intent', () => { goal: 'Verify the dashboard.', constraints: [ { statement: 'The dashboard loads.' }, - { type: 'nice_to_have', statement: 'The dashboard loads quickly.' }, + { type: 'NICE_TO_HAVE', statement: 'The dashboard loads quickly.' }, ], }) expect(messages(diagnostics)).toEqual(expect.arrayContaining([ expect.stringContaining( - 'The intent constraint type must be "required_outcome" or "must_preserve", got undefined.', + 'The intent constraint type must be "REQUIRED_OUTCOME" or "MUST_PRESERVE", got undefined.', ), expect.stringContaining( - 'The intent constraint type must be "required_outcome" or "must_preserve", got "nice_to_have".', + 'The intent constraint type must be "REQUIRED_OUTCOME" or "MUST_PRESERVE", got "NICE_TO_HAVE".', ), ])) }) @@ -323,8 +323,8 @@ describe('check intent', () => { const diagnostics = await validateIntent({ goal: 'Verify the dashboard.', constraints: [ - { type: 'required_outcome' }, - { type: 'must_preserve', statement: 42 }, + { type: 'REQUIRED_OUTCOME' }, + { type: 'MUST_PRESERVE', statement: 42 }, ], }) diff --git a/packages/cli/src/constructs/__tests__/fixtures/api-check/test-cases/test-intent/test.check.js b/packages/cli/src/constructs/__tests__/fixtures/api-check/test-cases/test-intent/test.check.js index 5593c0cd3..88fa7e7f0 100644 --- a/packages/cli/src/constructs/__tests__/fixtures/api-check/test-cases/test-intent/test.check.js +++ b/packages/cli/src/constructs/__tests__/fixtures/api-check/test-cases/test-intent/test.check.js @@ -6,11 +6,11 @@ new ApiCheck('dashboard-intent', { goal: 'Verify that authenticated users can open the dashboard.', constraints: [ { - type: 'required_outcome', + type: 'REQUIRED_OUTCOME', statement: 'The dashboard displays the account overview.', }, { - type: 'must_preserve', + type: 'MUST_PRESERVE', statement: 'Do not weaken the authentication assertion.', }, ], diff --git a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts index a103747a3..c957b5dbc 100644 --- a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts +++ b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts @@ -126,11 +126,11 @@ describe('GrpcMonitorCodegen', () => { goal: 'Verify that the gRPC health service is available.', constraints: [ { - type: 'required_outcome', + type: 'REQUIRED_OUTCOME', statement: 'The health RPC returns a serving response.', }, { - type: 'must_preserve', + type: 'MUST_PRESERVE', statement: 'Do not weaken the serving-status assertion.', }, ], diff --git a/packages/cli/src/constructs/check-codegen.ts b/packages/cli/src/constructs/check-codegen.ts index 56e7cec9c..00c11af5c 100644 --- a/packages/cli/src/constructs/check-codegen.ts +++ b/packages/cli/src/constructs/check-codegen.ts @@ -105,13 +105,13 @@ export function buildCheckProps ( builder.array('constraints', builder => { for (const statement of requiredOutcomes) { builder.object(builder => { - builder.string('type', 'required_outcome') + builder.string('type', 'REQUIRED_OUTCOME') builder.string('statement', statement) }) } for (const statement of mustPreserve) { builder.object(builder => { - builder.string('type', 'must_preserve') + builder.string('type', 'MUST_PRESERVE') builder.string('statement', statement) }) } diff --git a/packages/cli/src/constructs/check.ts b/packages/cli/src/constructs/check.ts index 97dade31e..00a4aa2c9 100644 --- a/packages/cli/src/constructs/check.ts +++ b/packages/cli/src/constructs/check.ts @@ -53,7 +53,7 @@ export type CheckRetryStrategy = /** * The kind of durable guidance expressed by a check intent constraint. */ -export type CheckIntentConstraintType = 'required_outcome' | 'must_preserve' +export type CheckIntentConstraintType = 'REQUIRED_OUTCOME' | 'MUST_PRESERVE' /** * A typed statement that refines a check's goal. @@ -118,11 +118,11 @@ export interface CheckIntentProps { * goal: 'Verify that authenticated users can open the dashboard.', * constraints: [ * { - * type: 'required_outcome', + * type: 'REQUIRED_OUTCOME', * statement: 'Authentication succeeds for a valid user.', * }, * { - * type: 'must_preserve', + * type: 'MUST_PRESERVE', * statement: 'Do not remove or weaken the authentication assertion.', * }, * ], @@ -488,8 +488,8 @@ export abstract class Check extends Construct { } const counts: Record = { - required_outcome: 0, - must_preserve: 0, + REQUIRED_OUTCOME: 0, + MUST_PRESERVE: 0, } for (const [index, constraint] of value.entries()) { @@ -517,20 +517,20 @@ export abstract class Check extends Construct { } const type = fields.type - if (type !== 'required_outcome' && type !== 'must_preserve') { + if (type !== 'REQUIRED_OUTCOME' && type !== 'MUST_PRESERVE') { diagnostics.add(new InvalidPropertyValueDiagnostic( `${property}.type`, new Error( - `The intent constraint type must be "required_outcome" or "must_preserve", got ${JSON.stringify(type)}.`, + `The intent constraint type must be "REQUIRED_OUTCOME" or "MUST_PRESERVE", got ${JSON.stringify(type)}.`, ), )) } else { counts[type] += 1 } - const statementLabel = type === 'required_outcome' + const statementLabel = type === 'REQUIRED_OUTCOME' ? 'required-outcome constraint statement' - : type === 'must_preserve' + : type === 'MUST_PRESERVE' ? 'must-preserve constraint statement' : 'constraint statement' this.validateIntentStatement( @@ -543,10 +543,10 @@ export abstract class Check extends Construct { } const constraintLabels: Record = { - required_outcome: 'required-outcome constraints', - must_preserve: 'must-preserve constraints', + REQUIRED_OUTCOME: 'required-outcome constraints', + MUST_PRESERVE: 'must-preserve constraints', } - for (const type of ['required_outcome', 'must_preserve'] as const) { + for (const type of ['REQUIRED_OUTCOME', 'MUST_PRESERVE'] as const) { if (counts[type] > 20) { diagnostics.add(new InvalidPropertyValueDiagnostic( 'intent.constraints', @@ -695,10 +695,10 @@ export abstract class Check extends Construct { : { goal: this.#intent.goal.trim(), requiredOutcomes: (this.#intent.constraints ?? []) - .filter(constraint => constraint.type === 'required_outcome') + .filter(constraint => constraint.type === 'REQUIRED_OUTCOME') .map(constraint => constraint.statement.trim()), mustPreserve: (this.#intent.constraints ?? []) - .filter(constraint => constraint.type === 'must_preserve') + .filter(constraint => constraint.type === 'MUST_PRESERVE') .map(constraint => constraint.statement.trim()), }, } diff --git a/packages/cli/type-tests/check-intent.ts b/packages/cli/type-tests/check-intent.ts index 1e888a393..3c2def375 100644 --- a/packages/cli/type-tests/check-intent.ts +++ b/packages/cli/type-tests/check-intent.ts @@ -57,11 +57,11 @@ export const completeIntent: CheckIntent = { goal: 'Verify that authenticated users can open the dashboard.', constraints: [ { - type: 'required_outcome', + type: 'REQUIRED_OUTCOME', statement: 'The dashboard displays the account overview.', }, { - type: 'must_preserve', + type: 'MUST_PRESERVE', statement: 'Do not weaken the authentication assertion.', }, ], @@ -77,7 +77,7 @@ export const unsupportedConstraintTypeIsRejected: CheckIntent = { goal: 'Verify that authenticated users can open the dashboard.', constraints: [{ // @ts-expect-error New constraint types require explicit CLI and backend support. - type: 'nice_to_have', + type: 'NICE_TO_HAVE', statement: 'The dashboard loads quickly.', }], } From 85bc41e3612e7923b9011db8e73b728d5e7d138e Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Fri, 14 Aug 2026 16:04:54 +0200 Subject: [PATCH 5/5] fix(constructs): use constraint-shaped intent wire format --- .../constructs/__tests__/api-check.spec.ts | 14 +++++---- .../constructs/__tests__/check-intent.spec.ts | 15 ++-------- .../__tests__/uptime-monitor-codegen.spec.ts | 29 +++++++++++++++---- packages/cli/src/constructs/check-codegen.ts | 26 +++++++++-------- packages/cli/src/constructs/check.ts | 10 +++---- 5 files changed, 54 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/constructs/__tests__/api-check.spec.ts b/packages/cli/src/constructs/__tests__/api-check.spec.ts index 18dd2420a..ff4ffe3f8 100644 --- a/packages/cli/src/constructs/__tests__/api-check.spec.ts +++ b/packages/cli/src/constructs/__tests__/api-check.spec.ts @@ -161,11 +161,15 @@ describe('ApiCheck', () => { payload: expect.objectContaining({ intent: { goal: 'Verify that authenticated users can open the dashboard.', - requiredOutcomes: [ - 'The dashboard displays the account overview.', - ], - mustPreserve: [ - 'Do not weaken the authentication assertion.', + constraints: [ + { + type: 'REQUIRED_OUTCOME', + statement: 'The dashboard displays the account overview.', + }, + { + type: 'MUST_PRESERVE', + statement: 'Do not weaken the authentication assertion.', + }, ], }, }), diff --git a/packages/cli/src/constructs/__tests__/check-intent.spec.ts b/packages/cli/src/constructs/__tests__/check-intent.spec.ts index 63599f95f..e0d54abfc 100644 --- a/packages/cli/src/constructs/__tests__/check-intent.spec.ts +++ b/packages/cli/src/constructs/__tests__/check-intent.spec.ts @@ -33,14 +33,7 @@ const completeIntent: CheckIntent = { const synthesizedCompleteIntent = { goal: completeIntent.goal, - requiredOutcomes: [ - 'Authentication succeeds for a valid user.', - 'The dashboard displays the account overview.', - ], - mustPreserve: [ - 'Do not remove or weaken the authentication assertion.', - 'Do not replace the dashboard assertion with a generic page-load assertion.', - ], + constraints: completeIntent.constraints, } let nextLogicalId = 0 @@ -91,8 +84,7 @@ describe('check intent', () => { expect(synthesized.intent).toEqual({ goal: 'Verify that authenticated users can open the dashboard.', - requiredOutcomes: [], - mustPreserve: [], + constraints: [], }) }) @@ -148,8 +140,7 @@ describe('check intent', () => { check.intent = { goal: ' Verify the replacement dashboard flow. ' } expect(check.synthesize()).toHaveProperty('intent', { goal: 'Verify the replacement dashboard flow.', - requiredOutcomes: [], - mustPreserve: [], + constraints: [], }) check.intent = null diff --git a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts index c957b5dbc..f3f572b51 100644 --- a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts +++ b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts @@ -113,11 +113,9 @@ describe('GrpcMonitorCodegen', () => { const source = await renderResource(env, p => new GrpcMonitorCodegen(p), resource({ intent: { goal: 'Verify that the gRPC health service is available.', - requiredOutcomes: [ - 'The health RPC returns a serving response.', - ], - mustPreserve: [ - 'Do not weaken the serving-status assertion.', + constraints: [ + { type: 'REQUIRED_OUTCOME', statement: 'The health RPC returns a serving response.' }, + { type: 'MUST_PRESERVE', statement: 'Do not weaken the serving-status assertion.' }, ], }, })) @@ -139,6 +137,27 @@ describe('GrpcMonitorCodegen', () => { expect(source.indexOf('intent:')).toBeLessThan(source.indexOf('request:')) }) + it('supports stored-shape intent from import plans created before the backend cutover', async () => { + const source = await renderResource(env, p => new GrpcMonitorCodegen(p), resource({ + intent: { + goal: 'Verify that the gRPC health service is available.', + requiredOutcomes: ['The health RPC returns a serving response.'], + mustPreserve: ['Do not weaken the serving-status assertion.'], + }, + })) + + expect(source).toContain(`constraints: [ + { + type: 'REQUIRED_OUTCOME', + statement: 'The health RPC returns a serving response.', + }, + { + type: 'MUST_PRESERVE', + statement: 'Do not weaken the serving-status assertion.', + }, + ]`) + }) + it('omits intent when the backend resource has no intent', async () => { const source = await renderResource(env, p => new GrpcMonitorCodegen(p), resource()) expect(source).not.toContain('intent:') diff --git a/packages/cli/src/constructs/check-codegen.ts b/packages/cli/src/constructs/check-codegen.ts index 00c11af5c..029019c0b 100644 --- a/packages/cli/src/constructs/check-codegen.ts +++ b/packages/cli/src/constructs/check-codegen.ts @@ -29,7 +29,13 @@ import { TracerouteMonitorCodegen, TracerouteMonitorResource } from './tracerout */ export interface CheckIntentResource { goal: string + constraints?: Array<{ + type: 'REQUIRED_OUTCOME' | 'MUST_PRESERVE' + statement: string + }> + /** Compatibility with import plans created before construct-shaped intent was deployed. */ requiredOutcomes?: string[] + /** Compatibility with import plans created before construct-shaped intent was deployed. */ mustPreserve?: string[] } @@ -99,20 +105,16 @@ export function buildCheckProps ( builder.object('intent', builder => { builder.string('goal', intent.goal) - const requiredOutcomes = intent.requiredOutcomes ?? [] - const mustPreserve = intent.mustPreserve ?? [] - if (requiredOutcomes.length > 0 || mustPreserve.length > 0) { + const constraints = intent.constraints ?? [ + ...(intent.requiredOutcomes ?? []).map(statement => ({ type: 'REQUIRED_OUTCOME' as const, statement })), + ...(intent.mustPreserve ?? []).map(statement => ({ type: 'MUST_PRESERVE' as const, statement })), + ] + if (constraints.length > 0) { builder.array('constraints', builder => { - for (const statement of requiredOutcomes) { + for (const constraint of constraints) { builder.object(builder => { - builder.string('type', 'REQUIRED_OUTCOME') - builder.string('statement', statement) - }) - } - for (const statement of mustPreserve) { - builder.object(builder => { - builder.string('type', 'MUST_PRESERVE') - builder.string('statement', statement) + builder.string('type', constraint.type) + builder.string('statement', constraint.statement) }) } }) diff --git a/packages/cli/src/constructs/check.ts b/packages/cli/src/constructs/check.ts index 00a4aa2c9..9ba68fbff 100644 --- a/packages/cli/src/constructs/check.ts +++ b/packages/cli/src/constructs/check.ts @@ -694,12 +694,10 @@ export abstract class Check extends Construct { ? null : { goal: this.#intent.goal.trim(), - requiredOutcomes: (this.#intent.constraints ?? []) - .filter(constraint => constraint.type === 'REQUIRED_OUTCOME') - .map(constraint => constraint.statement.trim()), - mustPreserve: (this.#intent.constraints ?? []) - .filter(constraint => constraint.type === 'MUST_PRESERVE') - .map(constraint => constraint.statement.trim()), + constraints: (this.#intent.constraints ?? []).map(constraint => ({ + type: constraint.type, + statement: constraint.statement.trim(), + })), }, }