diff --git a/packages/cli/src/ai-context/references/configure.md b/packages/cli/src/ai-context/references/configure.md index c5185def..b1802b2a 100644 --- a/packages/cli/src/ai-context/references/configure.md +++ b/packages/cli/src/ai-context/references/configure.md @@ -76,7 +76,7 @@ Run `npx checkly skills manage plan` for the full reference. - Deploy checks using the `npx checkly deploy` command. Use `--output` to see the created, updated, and deleted resources. Use `--verbose` to also include each resource's name and physical ID (UUID), which is useful for programmatically referencing deployed resources (e.g. `npx checkly checks get `). - Use `--preview` to see which resources a deploy would create, update, delete or keep, without applying it: an overview of every resource the deploy touches and a diff of each updated resource's construct as deployed against as in code. A plain interactive `checkly deploy` prints that same preview before asking the user to apply the changes or cancel. The machine-readable forms (`--dry-run`, and the `confirmation_required` envelope) additionally carry the individual properties that would change. -- When the preview shows a resource that was edited outside the project (in the web app or through the API), an interactive `checkly deploy` offers a third choice next to apply and cancel: update the code with the values from Checkly and deploy nothing. It rewrites literal values (strings, numbers, booleans, and arrays or objects of those, including `runtimeId` on runtime checks and groups and the `request` of every check and monitor type) and the helper-spelled properties `frequency` (`Frequency.EVERY_5M`), `retryStrategy` (`RetryStrategyBuilder`), `alertEscalationPolicy` (`AlertEscalationBuilder`) and `assertions` (the class's assertion builder) inside the `new ApiCheck('id', { … })` call of checks and check groups, adds a helper's import when the file lacks it, leaves the rest of the file untouched, and lists everything it could not update with the reason (references to other resources, secrets, scripts, a helper call holding a variable, `doubleCheck` set beside a retry strategy, a check moved to the global alert policy, which needs `alertEscalationPolicy` removed by hand). It exists only in a terminal; there is no flag for it, and the `confirmation_required` envelope is unchanged. +- When the preview shows a resource that was edited outside the project (in the web app or through the API), an interactive `checkly deploy` offers a third choice next to apply and cancel: update the code with the values from Checkly and deploy nothing. It rewrites literal values (strings, numbers, booleans, and arrays or objects of those, including `runtimeId` on runtime checks and groups, the `request` of every check and monitor type, `sslCheckDomain` and `aiAutoRepairEnabled` on browser checks, `aiAutoRepairEnabled` on multistep checks and `prompt` on agentic checks; a multi-line string over a template literal is written as a template literal) and the helper-spelled properties `frequency` (`Frequency.EVERY_5M`), `retryStrategy` (`RetryStrategyBuilder`), `alertEscalationPolicy` (`AlertEscalationBuilder`) and `assertions` (the class's assertion builder) inside the `new ApiCheck('id', { … })` call of checks and check groups, adds a helper's import when the file lacks it, leaves the rest of the file untouched, and lists everything it could not update with the reason (references to other resources, secrets, scripts, an incident trigger, a helper call holding a variable, `doubleCheck` set beside a retry strategy, a check moved to the global alert policy, which needs `alertEscalationPolicy` removed by hand). It exists only in a terminal; there is no flag for it, and the `confirmation_required` envelope is unchanged. - Use `--skip-plan` to deploy without asking Checkly for a plan: nothing is previewed and no plan token is used, so the deploy applies whatever the account looks like when it runs. Resources to delete are still listed before the confirmation, but the code bundle is uploaded before it rather than after. Incompatible with `--preview`, `--dry-run`, `--plan-token` and `--prune-relations`. Prefer a planned deploy unless the plan itself is the problem. - Use `--prune-relations` to also delete the alert channel subscriptions and private location assignments on this project's checks and groups that the project does not manage. Without it they are only reported. diff --git a/packages/cli/src/services/write-back/__tests__/literal-edit.spec.ts b/packages/cli/src/services/write-back/__tests__/literal-edit.spec.ts index 75f88c45..86ebe59c 100644 --- a/packages/cli/src/services/write-back/__tests__/literal-edit.spec.ts +++ b/packages/cli/src/services/write-back/__tests__/literal-edit.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { applyEdits } from '../apply-edits.js' -import { detectStyle, evaluateLiteral, isPlainLiteral, resolvePath } from '../literal-edit.js' +import { detectStyle, evaluateLiteral, isPlainLiteral, resolvePath, templateLiteral } from '../literal-edit.js' import { findConstructOptions, parseSource, WriteBackSkipped } from '../source-file.js' /** @@ -462,3 +462,12 @@ new ApiCheck('api', { name: 'x', tags: [ expect(readsBack('a.ts', nested.text, ['request'], { url: 'u' })).toBe(true) }) }) + +describe('templateLiteral', () => { + it('escapes what a template would read differently, and refuses what it cannot carry', () => { + expect(templateLiteral('a\nb')).toBe('`a\nb`') + expect(templateLiteral('`${x}` \\n\tend')).toBe('`\\`\\${x}\\` \\\\n\tend`') + expect(templateLiteral('a\r\nb')).toBeUndefined() + expect(templateLiteral('a\u0000b')).toBeUndefined() + }) +}) diff --git a/packages/cli/src/services/write-back/__tests__/plan.spec.ts b/packages/cli/src/services/write-back/__tests__/plan.spec.ts index 52937c82..e603d469 100644 --- a/packages/cli/src/services/write-back/__tests__/plan.spec.ts +++ b/packages/cli/src/services/write-back/__tests__/plan.spec.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ApiCheck } from '../../../constructs/api-check.js' import { BrowserCheck } from '../../../constructs/browser-check.js' +import { MultiStepCheck } from '../../../constructs/multi-step-check.js' import { DnsMonitor } from '../../../constructs/dns-monitor.js' import { IcmpMonitor } from '../../../constructs/icmp-monitor.js' import { SslMonitor } from '../../../constructs/ssl-monitor.js' @@ -24,7 +25,7 @@ import { CheckGroupV1 } from '../../../constructs/check-group-v1.js' import { GrpcMonitor } from '../../../constructs/grpc-monitor.js' import { Monitor } from '../../../constructs/monitor.js' import * as literalEdit from '../literal-edit.js' -import { applyWriteBack, type ConstructClass, planWriteBack, type Rule, RULES_BY_CLASS } from '../plan.js' +import { applyWriteBack, type ConstructClass, planWriteBack, type Rule, RULES_BY_CLASS, WRITTEN_BY_CLASS } from '../plan.js' import { AGENTIC_CHECK_OMITTED_PROPS } from '../../../constructs/internal/agentic-check-defaults.js' import { PLAYWRIGHT_CHECK_OMITTED_PROPS } from '../../../constructs/playwright-check-codegen.js' import { CheckGroupV2 } from '../../../constructs/check-group-v2.js' @@ -877,6 +878,79 @@ new CheckGroupV1('own', { name: 'Own', alertEscalationPolicy: AlertEscalationBui } }) + it('writes exactly the top-level keys each class declares', () => { + // The build-time assertion in plan.ts holds every props type to its + // class's written list; this holds the rule table to the same list. + for (const [cls, rules] of RULES_BY_CLASS) { + const targets = new Set(rules.filter(rule => rule.companion === undefined).map(rule => rule.target[0])) + expect([...targets].sort(), cls.name).toEqual([...new Set(WRITTEN_BY_CLASS.get(cls))].sort()) + } + expect(WRITTEN_BY_CLASS.size).toBe(RULES_BY_CLASS.size) + }) + + it('writes the literal props of browser, multistep and agentic checks', async () => { + await declare('literal.check.ts', `import { AgenticCheck, BrowserCheck, MultiStepCheck } from 'checkly/constructs' +new BrowserCheck('browser', { name: 'Browser', sslCheckDomain: 'example.com', code: { content: '' } }) +new MultiStepCheck('multi', { name: 'Multi', aiAutoRepairEnabled: false, code: { content: '' } }) +new AgenticCheck('short', { name: 'Short', prompt: 'Check the login page' }) +new AgenticCheck('long', { + name: 'Long', + prompt: \`Open the page. +Log in.\`, +}) +new AgenticCheck('crlf', { name: 'CRLF', prompt: \`one +two\` }) +`, () => { + new BrowserCheck('browser', { name: 'Browser', sslCheckDomain: 'example.com', code: { content: '' } }) + new MultiStepCheck('multi', { name: 'Multi', aiAutoRepairEnabled: false, code: { content: '' } }) + new AgenticCheck('short', { name: 'Short', prompt: 'Check the login page' } as any) + new AgenticCheck('long', { name: 'Long', prompt: 'Open the page.\nLog in.' } as any) + new AgenticCheck('crlf', { name: 'CRLF', prompt: 'one\ntwo' } as any) + }) + const check = (logicalId: string, checkType: string, changes: DiffEntry['changes'], before: object): DiffEntry => + ({ type: 'check', logicalId, action: 'UPDATE', changes, before: { checkType, name: logicalId, ...before }, redactions: [] }) + const plan = await planWriteBack({ + diff: [ + check('browser', 'BROWSER', [ + { path: '/sslCheckDomain', origin: 'remote', before: 'example.com', after: 'www.example.com' }, + { path: '/aiAutoRepairEnabled', origin: 'remote', before: null, after: true }, + { path: '/playwrightConfig/use/baseURL', origin: 'remote', before: null, after: 'https://example.com' }, + { path: '/triggerIncident', origin: 'remote', before: false, after: true }, + ], { sslCheckDomain: 'www.example.com', aiAutoRepairEnabled: true, playwrightConfig: { use: { baseURL: 'https://example.com' } }, triggerIncident: true }), + check('multi', 'MULTI_STEP', [{ path: '/aiAutoRepairEnabled', origin: 'remote', before: false, after: true }], + { aiAutoRepairEnabled: true }), + check('short', 'AGENTIC', [{ path: '/prompt', origin: 'remote', before: 'Check the login page', after: 'Check the signup page' }], + { prompt: 'Check the signup page' }), + check('long', 'AGENTIC', [{ path: '/prompt', origin: 'remote', before: 'Open the page.\nLog in.', after: 'Open `the` ${page}.\nLog in \\ out.' }], + { prompt: 'Open `the` ${page}.\nLog in \\ out.' }), + check('crlf', 'AGENTIC', [{ path: '/prompt', origin: 'remote', before: 'one\ntwo', after: 'one\r\ntwo' }], + { prompt: 'one\r\ntwo' }), + ], + project, + cwd: dir, + }) + expect(plan.skipped).toEqual([ + 'check browser /playwrightConfig/use/baseURL: this tool does not update the Playwright config yet; ' + + 'set it in checkly.config.ts or on the check by hand', + 'check browser /triggerIncident: Checkly does not report the incident trigger\'s settings; edit it by hand', + ]) + expect(plan.applied.map(line => [line.logicalId, line.property, line.rendered])).toEqual([ + ['browser', 'sslCheckDomain', '\'www.example.com\''], + ['browser', 'aiAutoRepairEnabled', 'true'], + ['multi', 'aiAutoRepairEnabled', 'true'], + ['short', 'prompt', '\'Check the signup page\''], + // A multi-line prompt over a template literal stays one, escaped; a + // carriage return cannot survive a template, so that one is quoted. + ['long', 'prompt', '`Open \\`the\\` \\${page}.\nLog in \\\\ out.`'], + ['crlf', 'prompt', '\'one\\r\\ntwo\''], + ]) + expect(plan.files[0].text).toContain(`new AgenticCheck('long', { + name: 'Long', + prompt: \`Open \\\`the\\\` \\\${page}. +Log in \\\\ out.\`, +})`) + }) + it('maps every key of the SSL request onto the construct spelling', () => { // The one request whose wire shape differs from the construct's; the // other monitors' lists are checked exhaustively at compile time. diff --git a/packages/cli/src/services/write-back/apply-edits.ts b/packages/cli/src/services/write-back/apply-edits.ts index af5bad36..2f0f2d1e 100644 --- a/packages/cli/src/services/write-back/apply-edits.ts +++ b/packages/cli/src/services/write-back/apply-edits.ts @@ -26,6 +26,7 @@ import { memberColumn, memberName, renderValue, + templateLiteral, resolvePath, type SkippedEdit, type SourceStyle, @@ -264,7 +265,13 @@ function render ( ): Rendered { const at = edit.path.join('.') if (helper === undefined) { - return { edit, text: renderValue(edit.value, style, { ...layout, at }), form: 'literal', expected: edit.value, needs: [] } + // A multi-line string over a template literal stays one; anything else + // is quoted the way the file quotes. + const template = node?.type === 'TemplateLiteral' && typeof edit.value === 'string' && edit.value.includes('\n') + ? templateLiteral(edit.value) + : undefined + const text = template ?? renderValue(edit.value, style, { ...layout, at }) + return { edit, text, form: 'literal', expected: edit.value, needs: [] } } if (node !== undefined && helper.literalAlternative !== undefined && isPlainLiteral(node)) { const expected = helper.literalAlternative diff --git a/packages/cli/src/services/write-back/literal-edit.ts b/packages/cli/src/services/write-back/literal-edit.ts index 4f7c168f..1e6a57f8 100644 --- a/packages/cli/src/services/write-back/literal-edit.ts +++ b/packages/cli/src/services/write-back/literal-edit.ts @@ -340,6 +340,20 @@ export function quoteString (value: string, quote: SourceStyle['quote']): string return `'${json.slice(1, -1).replace(/\\"/g, '"').replace(/'/g, '\\\'')}'` } +/** + * `value` as a template literal, for a multi-line string replacing one so + * the author's form is kept; undefined when it cannot read back as the same + * string, which a parser guarantees only for LF and TAB among the control + * characters (CR and CRLF are normalised to LF inside a template). + */ +export function templateLiteral (value: string): string | undefined { + // eslint-disable-next-line no-control-regex + if (/[\u0000-\u0008\u000b-\u001f\u007f]/.test(value)) { + return undefined + } + return '`' + value.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${') + '`' +} + export function renderKey (key: string, quote: SourceStyle['quote']): string { // A bare `__proto__` in an object literal sets the prototype rather than // a property; quoted, it is a property like any other. diff --git a/packages/cli/src/services/write-back/plan.ts b/packages/cli/src/services/write-back/plan.ts index 9bdd7b14..a8849765 100644 --- a/packages/cli/src/services/write-back/plan.ts +++ b/packages/cli/src/services/write-back/plan.ts @@ -4,29 +4,31 @@ import path from 'node:path' import { isDeepStrictEqual } from 'node:util' import * as constructs from '../../constructs/index.js' -import { AgenticCheck } from '../../constructs/agentic-check.js' -import { ApiCheck } from '../../constructs/api-check.js' +import { AgenticCheck, type AgenticCheckProps } from '../../constructs/agentic-check.js' +import { ApiCheck, type ApiCheckDefaultConfig, type ApiCheckProps } from '../../constructs/api-check.js' import type { Request } from '../../constructs/api-request.js' -import { BrowserCheck } from '../../constructs/browser-check.js' -import { CheckGroupV1 } from '../../constructs/check-group-v1.js' -import { CheckGroupV2 } from '../../constructs/check-group-v2.js' +import { BrowserCheck, type BrowserCheckProps } from '../../constructs/browser-check.js' +import type { CheckProps, RuntimeCheckProps } from '../../constructs/check.js' +import { CheckGroupV1, type CheckGroupV1Props } from '../../constructs/check-group-v1.js' +import { CheckGroupV2, type CheckGroupV2Props } from '../../constructs/check-group-v2.js' import type { Construct } from '../../constructs/construct.js' -import { DnsMonitor } from '../../constructs/dns-monitor.js' +import { DnsMonitor, type DnsMonitorProps } from '../../constructs/dns-monitor.js' import type { DnsRequest } from '../../constructs/dns-request.js' -import { GrpcMonitor } from '../../constructs/grpc-monitor.js' +import { GrpcMonitor, type GrpcMonitorProps } from '../../constructs/grpc-monitor.js' import type { GrpcConfig, GrpcRequest } from '../../constructs/grpc-request.js' -import { HeartbeatMonitor } from '../../constructs/heartbeat-monitor.js' -import { IcmpMonitor } from '../../constructs/icmp-monitor.js' +import { HeartbeatMonitor, type HeartbeatMonitorProps } from '../../constructs/heartbeat-monitor.js' +import { IcmpMonitor, type IcmpMonitorProps } from '../../constructs/icmp-monitor.js' import type { IcmpRequest } from '../../constructs/icmp-request.js' -import { MultiStepCheck } from '../../constructs/multi-step-check.js' -import { PlaywrightCheck } from '../../constructs/playwright-check.js' +import type { MonitorProps } from '../../constructs/monitor.js' +import { MultiStepCheck, type MultiStepCheckProps } from '../../constructs/multi-step-check.js' +import { PlaywrightCheck, type PlaywrightCheckProps } from '../../constructs/playwright-check.js' import type { Project, ProjectData } from '../../constructs/project.js' -import { SslMonitor } from '../../constructs/ssl-monitor.js' +import { SslMonitor, type SslMonitorProps } from '../../constructs/ssl-monitor.js' import type { SslConfig, SslRequest } from '../../constructs/ssl-request.js' -import { TcpMonitor, type TcpRequest } from '../../constructs/tcp-monitor.js' -import { TracerouteMonitor } from '../../constructs/traceroute-monitor.js' +import { TcpMonitor, type TcpMonitorProps, type TcpRequest } from '../../constructs/tcp-monitor.js' +import { TracerouteMonitor, type TracerouteMonitorProps } from '../../constructs/traceroute-monitor.js' import type { TracerouteRequest } from '../../constructs/traceroute-request.js' -import { UrlMonitor } from '../../constructs/url-monitor.js' +import { UrlMonitor, type UrlMonitorProps } from '../../constructs/url-monitor.js' import type { UrlRequest } from '../../constructs/url-request.js' import type { DiffChange, DiffEntry } from '../../rest/projects.js' import { hasEscalationPolicy } from '../../constructs/alert-escalation-policy-codegen.js' @@ -169,18 +171,35 @@ const alertRules = (policy: AlertPolicyHolder): Rule[] => [ }, ] +// The keys each class writes are `as const` lists typed against the class's +// props, so that `_everyPropIsListed` below can hold the build to them: a +// key added to a props type has to be written or named as left out. +const CHECK_KEYS = ['name', 'description', 'activated', 'muted', 'shouldFail'] as const satisfies readonly (keyof CheckProps)[] +const CHECK_SET_KEYS = ['tags', 'locations'] as const satisfies readonly (keyof CheckProps)[] +const CHECK_HELPER_KEYS = ['frequency', 'retryStrategy', 'alertEscalationPolicy'] as const satisfies readonly (keyof CheckProps)[] +const CHECK_WRITTEN = [...CHECK_KEYS, ...CHECK_SET_KEYS, ...CHECK_HELPER_KEYS] as const // Every check class takes these; a class whose props omit some (as its // codegen's omitted props say) gets the rest. const CHECK_RULES: Rule[] = [ - identity('name'), identity('description'), identity('activated'), identity('muted'), identity('shouldFail'), - set('tags'), set('locations'), ...FREQUENCY_RULES, ...RETRY_RULES, ...alertRules('check'), + ...CHECK_KEYS.map(key => identity(key)), ...CHECK_SET_KEYS.map(set), + ...FREQUENCY_RULES, ...RETRY_RULES, ...alertRules('check'), ] const omitting = (rules: readonly Rule[], props: readonly string[]): Rule[] => rules.filter(rule => !props.includes(rule.target[0])) +const omit = (keys: readonly K[], omitted: readonly O[]): Exclude[] => + keys.filter((key): key is Exclude => !(omitted as readonly string[]).includes(key)) // Only the classes extending RuntimeCheck take a runtime and environment // variables; a monitor or an agentic check would drop them when synthesized. -const RUNTIME_CHECK_RULES: Rule[] = [identity('runtimeId'), identity('environmentVariables')] -const RESPONSE_TIME_RULES: Rule[] = [identity('degradedResponseTime'), identity('maxResponseTime')] +const RUNTIME_CHECK_KEYS = ['runtimeId', 'environmentVariables'] as const satisfies readonly (keyof RuntimeCheckProps)[] +const RUNTIME_CHECK_RULES: Rule[] = RUNTIME_CHECK_KEYS.map(key => identity(key)) +const RESPONSE_TIME_KEYS = ['degradedResponseTime', 'maxResponseTime'] as const satisfies readonly (keyof ApiCheckProps)[] +const RESPONSE_TIME_RULES: Rule[] = RESPONSE_TIME_KEYS.map(key => identity(key)) +const PACKET_LOSS_KEYS = [ + 'degradedPacketLossThreshold', 'maxPacketLossThreshold', +] as const satisfies readonly (keyof IcmpMonitorProps)[] +const BROWSER_KEYS = ['sslCheckDomain', 'aiAutoRepairEnabled'] as const satisfies readonly (keyof BrowserCheckProps)[] +const MULTI_STEP_KEYS = ['aiAutoRepairEnabled'] as const satisfies readonly (keyof MultiStepCheckProps)[] +const AGENTIC_KEYS = ['prompt'] as const satisfies readonly (keyof AgenticCheckProps)[] // The keys of each request type the account reports under the same name // the construct uses, typed against the construct's interface so a renamed @@ -245,15 +264,89 @@ const _everyRequestKeyIsListed: [ Covers, Covers, ] = [true, true, true, true, true, true, true, true, true, true] -const HEARTBEAT_KEYS = ['period', 'periodUnit', 'grace', 'graceUnit'] +const HEARTBEAT_KEYS = ['period', 'periodUnit', 'grace', 'graceUnit'] as const satisfies readonly (keyof HeartbeatMonitorProps)[] +const GROUP_KEYS = [ + 'name', 'activated', 'muted', 'concurrency', 'environmentVariables', 'runtimeId', +] as const satisfies readonly (keyof CheckGroupV1Props)[] +const GROUP_SET_KEYS = ['tags', 'locations'] as const satisfies readonly (keyof CheckGroupV1Props)[] +const GROUP_HELPER_KEYS = [ + 'apiCheckDefaults', 'retryStrategy', 'alertEscalationPolicy', +] as const satisfies readonly (keyof CheckGroupV1Props)[] +const API_DEFAULT_KEYS = ['url', 'headers', 'queryParameters', 'basicAuth'] as const satisfies readonly (keyof ApiCheckDefaultConfig)[] const GROUP_RULES: Rule[] = [ - identity('name'), identity('activated'), identity('muted'), set('tags'), set('locations'), identity('concurrency'), - identity('environmentVariables'), identity('runtimeId'), - ...under('apiCheckDefaults', ['url', 'headers', 'queryParameters', 'basicAuth']), - assertions('AssertionBuilder', 'apiCheckDefaults'), + ...GROUP_KEYS.map(key => identity(key)), ...GROUP_SET_KEYS.map(set), + ...under('apiCheckDefaults', API_DEFAULT_KEYS), assertions('AssertionBuilder', 'apiCheckDefaults'), ...RETRY_RULES, ] +// What each class writes, at the top level of its props. `request` and +// `apiCheckDefaults` stand for every rule under them. +const API_WRITTEN = [...CHECK_WRITTEN, ...RUNTIME_CHECK_KEYS, ...RESPONSE_TIME_KEYS, 'request'] as const +const BROWSER_WRITTEN = [...CHECK_WRITTEN, ...RUNTIME_CHECK_KEYS, ...BROWSER_KEYS] as const +const MULTI_STEP_WRITTEN = [...CHECK_WRITTEN, ...RUNTIME_CHECK_KEYS, ...MULTI_STEP_KEYS] as const +const PLAYWRIGHT_WRITTEN = [...omit(CHECK_WRITTEN, PLAYWRIGHT_CHECK_OMITTED_PROPS), ...RUNTIME_CHECK_KEYS] as const +const AGENTIC_WRITTEN = [...omit(CHECK_WRITTEN, AGENTIC_CHECK_OMITTED_PROPS), ...AGENTIC_KEYS] as const +// The monitors with a request and response-time thresholds share one list. +const MONITOR_WRITTEN = [...CHECK_WRITTEN, ...RESPONSE_TIME_KEYS, 'request'] as const +const ICMP_WRITTEN = [...CHECK_WRITTEN, ...PACKET_LOSS_KEYS, 'request'] as const +const HEARTBEAT_WRITTEN = [...CHECK_WRITTEN, ...HEARTBEAT_KEYS] as const +const GROUP_WRITTEN = [...GROUP_KEYS, ...GROUP_SET_KEYS, ...GROUP_HELPER_KEYS] as const + +/** + * The props keys the write-back leaves out on purpose, by reason; each + * class names the reasons that apply to it. A key in none of them and in no + * written list fails `_everyPropIsListed`. + */ +/** Names another resource; refused as `references another resource`. */ +type ReferenceKey = 'alertChannels' | 'privateLocations' | 'group' | 'groupId' +/** Script or bundle content, which the plan reports with a cause rather than a value. */ +type ContentKey = 'code' | 'setupScript' | 'tearDownScript' | 'localSetupScript' | 'localTearDownScript' +/** Reaches the account only through the Playwright code bundle. */ +type BundleKey = 'playwrightConfigPath' | 'installCommand' | 'testCommand' | 'pwProjects' | 'pwTags' | 'include' | 'groupName' +/** Never sent to the account. */ +type LocalOnlyKey = 'testOnly' +/** Reported with the reason `NOT_WRITTEN` or `refusal` gives. */ +type NotWrittenKey = 'doubleCheck' | 'runParallel' | 'intent' | 'triggerIncident' +/** A group prop applied to its member checks, never a property of the group resource. */ +type GroupMemberKey = 'frequency' | 'browserChecks' | 'multiStepChecks' +// Named per class below, writable in principle but not yet (`NOT_WRITTEN` +// says so): `engine` is one object sent as two leaves, `agentRuntime` holds +// a set, `playwrightConfig` has credential sections the account blanks to +// null and is usually inherited from the project config. +/** What every check class leaves out. */ +type CheckLeftOut = ReferenceKey | LocalOnlyKey | NotWrittenKey + +/** `true` when every key of `Written` is a key of `T`, `never` otherwise. */ +type Within = Exclude extends never ? true : never +/** Both directions: every key of `T` is written or in `Left`, and every written key is one `T` has. */ +type Exact = + Covers extends true ? Within : never +// A build-time assertion only; nothing reads it. When it fails, the props +// type named at the failing position gained or lost a key: add it to the +// class's written list and a rule, or to the reasons above. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _everyPropIsListed: [ + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Covers, +] = [true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true] + /** * The properties this module writes, per construct class: the ones whose * import-format spelling and construct spelling are both literals with the @@ -279,10 +372,10 @@ export const RULES_BY_CLASS: ReadonlyMap = new ...CHECK_RULES, ...RUNTIME_CHECK_RULES, ...RESPONSE_TIME_RULES, ...under('request', API_REQUEST_KEYS), assertions('AssertionBuilder', 'request'), ]], - [BrowserCheck, [...CHECK_RULES, ...RUNTIME_CHECK_RULES]], - [MultiStepCheck, [...CHECK_RULES, ...RUNTIME_CHECK_RULES]], + [BrowserCheck, [...CHECK_RULES, ...RUNTIME_CHECK_RULES, ...BROWSER_KEYS.map(key => identity(key))]], + [MultiStepCheck, [...CHECK_RULES, ...RUNTIME_CHECK_RULES, ...MULTI_STEP_KEYS.map(key => identity(key))]], [PlaywrightCheck, [...omitting(CHECK_RULES, PLAYWRIGHT_CHECK_OMITTED_PROPS), ...RUNTIME_CHECK_RULES]], - [AgenticCheck, omitting(CHECK_RULES, AGENTIC_CHECK_OMITTED_PROPS)], + [AgenticCheck, [...omitting(CHECK_RULES, AGENTIC_CHECK_OMITTED_PROPS), ...AGENTIC_KEYS.map(key => identity(key))]], [UrlMonitor, [ ...CHECK_RULES, ...RESPONSE_TIME_RULES, ...under('request', URL_REQUEST_KEYS), assertions('UrlAssertionBuilder', 'request'), ]], @@ -300,7 +393,7 @@ export const RULES_BY_CLASS: ReadonlyMap = new assertions('TracerouteAssertionBuilder', 'request'), ]], [IcmpMonitor, [ - ...CHECK_RULES, identity('degradedPacketLossThreshold'), identity('maxPacketLossThreshold'), + ...CHECK_RULES, ...PACKET_LOSS_KEYS.map(key => identity(key)), ...under('request', ICMP_REQUEST_KEYS), assertions('IcmpAssertionBuilder', 'request'), ]], [HeartbeatMonitor, [ @@ -311,6 +404,17 @@ export const RULES_BY_CLASS: ReadonlyMap = new [CheckGroupV2, [...GROUP_RULES, ...alertRules('group-v2')]], ]) +/** The top-level props keys each class's rules write, as the build-time assertion above knows them; the spec holds `RULES_BY_CLASS` to it. */ +export const WRITTEN_BY_CLASS: ReadonlyMap = + new Map([ + [ApiCheck, API_WRITTEN], [BrowserCheck, BROWSER_WRITTEN], [MultiStepCheck, MULTI_STEP_WRITTEN], + [PlaywrightCheck, PLAYWRIGHT_WRITTEN], [AgenticCheck, AGENTIC_WRITTEN], + [UrlMonitor, MONITOR_WRITTEN], [TcpMonitor, MONITOR_WRITTEN], [DnsMonitor, MONITOR_WRITTEN], + [GrpcMonitor, MONITOR_WRITTEN], [SslMonitor, MONITOR_WRITTEN], [TracerouteMonitor, MONITOR_WRITTEN], + [IcmpMonitor, ICMP_WRITTEN], + [HeartbeatMonitor, HEARTBEAT_WRITTEN], [CheckGroupV1, GROUP_WRITTEN], [CheckGroupV2, GROUP_WRITTEN], + ]) + /** Paths that name another resource or a relation rather than a value of this one. */ const REFERENCE_PREFIXES = ['alertChannels', 'privateLocations', 'alertChannelSubscriptions', 'privateLocationAssignments', 'groupId'] @@ -318,6 +422,11 @@ const REFERENCE_PREFIXES = ['alertChannels', 'privateLocations', 'alertChannelSu const NOT_WRITTEN: ReadonlyMap = new Map([ ['doubleCheck', 'replaced by retryStrategy; set the retry strategy in the code by hand'], ['runParallel', 'not a property this tool can update'], + ['triggerIncident', 'Checkly does not report the incident trigger\'s settings; edit it by hand'], + ['playwrightConfig', 'this tool does not update the Playwright config yet; set it in checkly.config.ts or on the check by hand'], + ['engine', 'this tool does not update the engine yet; set it by hand'], + ['engineVersion', 'this tool does not update the engine yet; set it by hand'], + ['agentRuntime', 'this tool does not update agentRuntime yet; set it by hand'], ]) /** The names `checkly/constructs` exports for a construct's class; empty for a class of the user's own. */