Skip to content

Commit 2a2dd11

Browse files
danielpaulusclaudesorccu
authored
feat(ssl): property-scoped assertion grammar (#1404)
* refactor(cli): property-scoped SSL assertion grammar Replace the flat per-source SSL assertion builder with a property-scoped model mirroring the API check grammar and the monorepo redesign. Sources are now CERTIFICATE | CONNECTION | RESPONSE_TIME | JSON_RESPONSE | TEXT_RESPONSE. SslAssertionBuilder exposes certificate(property), connection(property), responseTime(), jsonResponse(jsonPath) and textResponse(regex?), reusing GeneralAssertionBuilder / NumericAssertionBuilder. The TlsVersion / SignatureAlgorithm / CipherSuite constant maps are kept as assertion targets, with JSDoc updated to the new API. Validation is rekeyed by (source, property): CERTIFICATE/CONNECTION whitelist comparisons per property and flag unknown properties; RESPONSE_TIME / JSON_RESPONSE / TEXT_RESPONSE whitelist per source. Boolean-target validation is retained for boolean properties. Both GREATER_THAN_OR_EQUAL and the regex (MATCHES) operator are removed for SSL; the now-unused SSL-only boolean/matches codegen helpers are dropped from the shared internal codegen. Codegen emits the five new sources and all specs (ssl-assertion, ssl-assertion-codegen, ssl-monitor, uptime-monitor-codegen) plus the ai-context example are migrated to the new grammar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013AYwkvoaANQTH5dGStbBj2 * fix(ssl): align resolvedIp to the full string comparison set resolvedIp allowed EQUALS/NOT_EQUALS/CONTAINS but not NOT_CONTAINS, diverging from the webapp/backend string value type. Use the shared STRING set for parity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013AYwkvoaANQTH5dGStbBj2 * fix(cli): restore type narrowing for SSL typed-constant assertions Convert SslAssertionBuilder.certificate/connection to typed overloads so property names are narrowed to known unions (typos are compile errors + autocomplete), and the two typed-constant properties narrow the builder target type: connection('tlsVersion') -> TlsVersionValue, certificate('signatureAlgorithm') -> SignatureAlgorithmValue. cipherSuite stays unconstrained by prior decision. Add type-level @ts-expect-error coverage proving the narrowing, plus runtime validation coverage for the removed SSL-only operators (MATCHES, GREATER_THAN_OR_EQUAL) and the per-property comparison whitelist (sans/serialNumber/ocspStatus rejects; sans/resolvedIp accepts). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013AYwkvoaANQTH5dGStbBj2 * style(cli): comma member-delimiter in ssl-monitor test type literals Fixes the lint job (@stylistic/member-delimiter-style): single-line type literals use a comma, not a semicolon. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013AYwkvoaANQTH5dGStbBj2 * fix(examples): port SSL check templates to property-scoped grammar The advanced-project and advanced-project-js templates still called the flat SslAssertionBuilder methods (certExpiresInDays, chainTrusted, hostnameVerified, tlsVersion) removed by the property-scoped grammar refactor. These are not documentation-only: create-checkly downloads them as github:checkly/checkly-cli/examples/<template>#<version> and pins checkly to 'latest', so once the new grammar ships, scaffolding the recommended template and running checkly test/deploy would fail with "SslAssertionBuilder.certExpiresInDays is not a function". Semantics are unchanged; the synthesized wire payload is equivalent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): key SSL property whitelists by their property unions The runtime whitelists in ssl-assertion-validation.ts were typed Record<string, PropertyRule>, so nothing tied them to the property unions in ssl-assertion.ts that they mirror. A union member added without a matching rule compiled clean and autocompleted, then failed at the user's checkly deploy with "unknown property". Key them by the unions instead, matching traceroute-assertion-validation.ts and this file's own source-level rule. A missing or misspelled entry is now a compile error. The unions are exported to make that possible, which also lets users name them when building typed wrappers, as the traceroute analogue already allows. They are prefixed Ssl- to suit the flat public export namespace, following the IcmpLatencyProperty / TracerouteResponseTimeProperty convention; both names are new to the public API, so nothing breaks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(cli): one operator class per SSL assertion property certificate(p)/connection(p) returned a GeneralAssertionBuilder exposing every operator with string|number|boolean targets, so certificate('daysUntilExpiry').greaterThan('30 days') compiled, passed deploy validation, and then silently never fired. Give each property its own operator class exposing exactly the comparisons the backend accepts for it, with its target typed. The operator maps are the declaration point: the property unions derive from their keys, and the value-type and comparison tables are keyed by those unions, so a property cannot be added to one and forgotten in another. An unknown property still resolves, and is reported by validation rather than throwing. Validation now reports a non-numeric target for numeric properties and for RESPONSE_TIME, closing the same silent-never-fires hole on the object-literal path that bypasses the builder. Targets are checked only where the accepted set is universal; the enumerated sets stay with the builder's types to avoid restating the value unions. Codegen emits numeric and boolean targets as bare literals so the generated call matches the operators, sharing its numeric predicate with validation so nothing the CLI calls valid is emitted as code that does not compile. Targets outside a property's contract keep their string form rather than being coerced, which would rewrite the assertion on the next deploy. valueForNumericAssertion keeps parseInt for its other callers, whose targets may carry units; SSL opts into Number via the new parse option. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ssl): carry the textResponse regex in the assertion property slot The backend Joi schema and the go-runner (EvaluateRegExp) read the TEXT_RESPONSE extraction pattern from `property`; the `regex` field is never consulted. textResponse(pattern) previously emitted the pattern into `regex`, so it validated fine but was silently ignored at evaluation — the comparison ran against the whole serialized response document. Emit it as `property` (builder + import codegen) and pin the wire shape in tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXP63MTUyPnuc48ZWGovhR * refactor(cli): derive the SSL assertion grammar from one declaration The certificate/connection grammar was stated in four hand-synced places — 19 operator classes, two operator maps, a value-type table, and a comparison whitelist. Review found three ways they could drift while compiling clean: an operator added to a class but not the whitelist, a value type changed without the class's target type, and a property literal copy-pasted onto the wrong class. TypeScript's Record keying caught a missing entry but never a mismatched one. Declare each property once, as data: the operators it exposes and its target type. The property unions, the typed builder methods, the validation whitelist, and codegen's literal kind are all derived from that row, so the three drifts become structurally impossible — a property cannot expose an operator its whitelist rejects, or a target type codegen renders wrong, and there are no repeated literals to mis-copy. The generic machinery (operator catalog, target specs, the mapped type that turns a row into a builder surface) lives in a monitor-agnostic internal/assertion-grammar.ts; the SSL grammar tables and the runtime lookups validation and codegen read live in ssl-assertion-grammar.ts. Both stay out of the public API. Public call shapes, the property unions, wire output, and per-property operator and target narrowing are all unchanged: the pre-existing SSL tests pass untouched. A new consistency spec iterates the grammar and asserts the builder, validation and codegen agree for every declared (property, operator), and that number and boolean properties declare only operators codegen renders as literals — guarding the one drift the type system cannot, using codegen's own predicates rather than restating them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(cli): derive the traceroute assertion grammar from one declaration Migrate the traceroute assertion builder onto the single-declaration grammar system SSL already uses. The three hand-rolled builder classes and the hand-maintained validation tables (property list and per-source comparison whitelists) are replaced by grammar tables in traceroute-assertion-grammar.ts, from which the builder surface, the property union, and the validation whitelists all derive. Traceroute exercises both grammar shapes: RESPONSE_TIME is property-scoped over avg/min/max/stdDev, built via operatorsForProperty; HOP_COUNT and PACKET_LOSS are single scalar sources whose backend grammar omits notEquals, built via a new operatorsForSource helper. RESPONSE_TIME comparisons are derived per statistical property, so a property that later diverges is validated against its own row rather than a sibling's. The builder call shapes, the property union, wire output, and per-source operator narrowing are unchanged; the pre-existing traceroute tests pass untouched, and the diagnostic messages ("has an invalid property", "must not specify a property", "unknown source") are preserved. A consistency spec iterates the grammar and asserts the builder and validation agree for every declared (source, property, operator). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Simo Kinnunen <simo@checklyhq.com>
1 parent 23abb3d commit 2a2dd11

19 files changed

Lines changed: 1368 additions & 580 deletions

examples/advanced-project-js/src/__checks__/uptime/ssl.check.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ new SslMonitor('example-com-ssl', {
1818
alertDaysBeforeExpiry: 30,
1919
},
2020
assertions: [
21-
SslAssertionBuilder.certExpiresInDays().greaterThan(30),
22-
SslAssertionBuilder.chainTrusted().equals(true),
23-
SslAssertionBuilder.hostnameVerified().equals(true),
24-
SslAssertionBuilder.tlsVersion().equals('TLS1.3'),
21+
SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(30),
22+
SslAssertionBuilder.connection('chainTrusted').equals(true),
23+
SslAssertionBuilder.connection('hostnameVerified').equals(true),
24+
SslAssertionBuilder.connection('tlsVersion').equals('TLS1.3'),
2525
],
2626
},
2727
})

examples/advanced-project/src/__checks__/uptime/ssl.check.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ new SslMonitor('example-com-ssl', {
1818
alertDaysBeforeExpiry: 30,
1919
},
2020
assertions: [
21-
SslAssertionBuilder.certExpiresInDays().greaterThan(30),
22-
SslAssertionBuilder.chainTrusted().equals(true),
23-
SslAssertionBuilder.hostnameVerified().equals(true),
24-
SslAssertionBuilder.tlsVersion().equals('TLS1.3'),
21+
SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(30),
22+
SslAssertionBuilder.connection('chainTrusted').equals(true),
23+
SslAssertionBuilder.connection('hostnameVerified').equals(true),
24+
SslAssertionBuilder.connection('tlsVersion').equals('TLS1.3'),
2525
],
2626
},
2727
})

packages/cli/src/ai-context/context.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -290,10 +290,10 @@ new SslMonitor('example-com-ssl', {
290290
alertDaysBeforeExpiry: 30,
291291
},
292292
assertions: [
293-
SslAssertionBuilder.certExpiresInDays().greaterThan(30),
294-
SslAssertionBuilder.chainTrusted().equals(true),
295-
SslAssertionBuilder.hostnameVerified().equals(true),
296-
SslAssertionBuilder.tlsVersion().equals('TLS1.3'),
293+
SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(30),
294+
SslAssertionBuilder.connection('chainTrusted').equals(true),
295+
SslAssertionBuilder.connection('hostnameVerified').equals(true),
296+
SslAssertionBuilder.connection('tlsVersion').equals('TLS1.3'),
297297
],
298298
},
299299
})

packages/cli/src/constructs/__tests__/ssl-assertion-codegen.spec.ts

Lines changed: 99 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,34 +13,119 @@ function render (assertion: SslAssertion): string {
1313
}
1414

1515
describe('SSL Assertion Codegen', () => {
16-
it('generates the new SSL assertion sources', () => {
16+
it('generates the property-scoped SSL assertion sources', () => {
1717
const cases: { input: SslAssertion, expected: string }[] = [
18-
// Boolean sources emit a boolean literal, not a quoted string.
18+
// CERTIFICATE / CONNECTION emit the property name as the first call argument.
19+
// Numeric properties emit an unquoted number, matching the builder's typed target.
1920
{
20-
input: { source: 'OCSP_STAPLED', property: '', comparison: 'EQUALS', target: 'true', regex: null },
21-
expected: 'SslAssertionBuilder.ocspStapled().equals(true)\n',
21+
input: { source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'GREATER_THAN', target: '30', regex: null },
22+
expected: 'SslAssertionBuilder.certificate(\'daysUntilExpiry\').greaterThan(30)\n',
2223
},
2324
{
24-
input: { source: 'CERT_NOT_EXPIRED', property: '', comparison: 'EQUALS', target: 'true', regex: null },
25-
expected: 'SslAssertionBuilder.certNotExpired().equals(true)\n',
25+
input: { source: 'CERTIFICATE', property: 'signatureAlgorithm', comparison: 'EQUALS', target: 'SHA256-RSA', regex: null },
26+
expected: 'SslAssertionBuilder.certificate(\'signatureAlgorithm\').equals(\'SHA256-RSA\')\n',
2627
},
28+
// Boolean properties emit a boolean literal, not a quoted string.
2729
{
28-
input: { source: 'CHAIN_TRUSTED', property: '', comparison: 'EQUALS', target: 'false', regex: null },
29-
expected: 'SslAssertionBuilder.chainTrusted().equals(false)\n',
30+
input: { source: 'CERTIFICATE', property: 'selfSigned', comparison: 'EQUALS', target: 'false', regex: null },
31+
expected: 'SslAssertionBuilder.certificate(\'selfSigned\').equals(false)\n',
3032
},
31-
// Numeric key size emits an unquoted number.
3233
{
33-
input: { source: 'KEY_SIZE_BITS', property: '', comparison: 'EQUALS', target: '2048', regex: null },
34-
expected: 'SslAssertionBuilder.keySizeBits().equals(2048)\n',
34+
input: { source: 'CONNECTION', property: 'chainTrusted', comparison: 'EQUALS', target: 'true', regex: null },
35+
expected: 'SslAssertionBuilder.connection(\'chainTrusted\').equals(true)\n',
3536
},
36-
// The string sources support the MATCHES (regex) operator.
3737
{
38-
input: { source: 'CIPHER_SUITE', property: '', comparison: 'MATCHES', target: 'TLS_(AES|CHACHA)', regex: null },
39-
expected: 'SslAssertionBuilder.cipherSuite().matches(\'TLS_(AES|CHACHA)\')\n',
38+
input: { source: 'CONNECTION', property: 'tlsVersion', comparison: 'EQUALS', target: 'TLS1.3', regex: null },
39+
expected: 'SslAssertionBuilder.connection(\'tlsVersion\').equals(\'TLS1.3\')\n',
40+
},
41+
// RESPONSE_TIME is numeric — the target is an unquoted number and no property is emitted.
42+
{
43+
input: { source: 'RESPONSE_TIME', property: '', comparison: 'LESS_THAN', target: '1000', regex: null },
44+
expected: 'SslAssertionBuilder.responseTime().lessThan(1000)\n',
45+
},
46+
// JSON_RESPONSE emits the JSONPath property.
47+
{
48+
input: { source: 'JSON_RESPONSE', property: '$.status', comparison: 'EQUALS', target: 'ok', regex: null },
49+
expected: 'SslAssertionBuilder.jsonResponse(\'$.status\').equals(\'ok\')\n',
50+
},
51+
// TEXT_RESPONSE carries its regex in the property slot (the backend/runner contract).
52+
{
53+
input: { source: 'TEXT_RESPONSE', property: '', comparison: 'CONTAINS', target: 'healthy', regex: null },
54+
expected: 'SslAssertionBuilder.textResponse().contains(\'healthy\')\n',
55+
},
56+
{
57+
input: { source: 'TEXT_RESPONSE', property: 'status: (\\w+)', comparison: 'EQUALS', target: 'ok', regex: null },
58+
expected: 'SslAssertionBuilder.textResponse(\'status: (\\\\w+)\').equals(\'ok\')\n',
4059
},
4160
]
4261
for (const test of cases) {
4362
expect(render(test.input)).toEqual(test.expected)
4463
}
4564
})
65+
66+
// The backend accepts any target string, so a monitor created via the UI or API can
67+
// carry a target the builder's typed operators cannot express. Rendering it as a typed
68+
// literal anyway would rewrite the assertion on the next deploy ('yes' becoming false,
69+
// '30.5' becoming 30), so these fall back to the string form, which preserves the value.
70+
it('does not coerce a target its typed operators cannot express', () => {
71+
const cases: { input: SslAssertion, expected: string }[] = [
72+
{
73+
input: { source: 'CERTIFICATE', property: 'selfSigned', comparison: 'EQUALS', target: 'yes', regex: null },
74+
expected: 'SslAssertionBuilder.certificate(\'selfSigned\').equals(\'yes\')\n',
75+
},
76+
{
77+
input: { source: 'CERTIFICATE', property: 'keySizeBits', comparison: 'EQUALS', target: '', regex: null },
78+
expected: 'SslAssertionBuilder.certificate(\'keySizeBits\').equals(\'\')\n',
79+
},
80+
{
81+
input: { source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'GREATER_THAN', target: 'abc', regex: null },
82+
expected: 'SslAssertionBuilder.certificate(\'daysUntilExpiry\').greaterThan(\'abc\')\n',
83+
},
84+
{
85+
// parseInt would take the leading prefix and silently assert against 5.
86+
input: { source: 'CERTIFICATE', property: 'keySizeBits', comparison: 'EQUALS', target: '5%', regex: null },
87+
expected: 'SslAssertionBuilder.certificate(\'keySizeBits\').equals(\'5%\')\n',
88+
},
89+
]
90+
for (const test of cases) {
91+
expect(render(test.input)).toEqual(test.expected)
92+
}
93+
})
94+
95+
// Codegen must render every target validateSslAssertion accepts as a numeric literal:
96+
// the property's operators take a number, so a quoted target would not compile. parseInt
97+
// would truncate the fractional and exponent forms.
98+
it('renders every numeric target validation accepts as a number literal', () => {
99+
const cases: { target: string, expected: string }[] = [
100+
{ target: '30', expected: '30' },
101+
{ target: '30.5', expected: '30.5' },
102+
{ target: '0.5', expected: '0.5' },
103+
{ target: '1e3', expected: '1000' },
104+
{ target: '-5', expected: '-5' },
105+
{ target: ' 30 ', expected: '30' },
106+
]
107+
for (const { target, expected } of cases) {
108+
expect(render(
109+
{ source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'GREATER_THAN', target, regex: null },
110+
)).toEqual(`SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(${expected})\n`)
111+
}
112+
})
113+
114+
// A comparison outside a typed helper's set makes the helper throw, which would abort
115+
// the whole import over one assertion.
116+
it('renders rather than throws on a comparison the property does not support', () => {
117+
expect(render(
118+
{ source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'CONTAINS', target: '30', regex: null },
119+
)).toEqual('SslAssertionBuilder.certificate(\'daysUntilExpiry\').contains(\'30\')\n')
120+
expect(render(
121+
{ source: 'CERTIFICATE', property: 'selfSigned', comparison: 'NOT_EQUALS', target: 'true', regex: null },
122+
)).toEqual('SslAssertionBuilder.certificate(\'selfSigned\').notEquals(\'true\')\n')
123+
})
124+
125+
// An unknown property reaches codegen from remote data; validation reports it.
126+
it('renders an unknown property rather than throwing', () => {
127+
expect(render(
128+
{ source: 'CERTIFICATE', property: 'bogusProperty', comparison: 'EQUALS', target: 'x', regex: null },
129+
)).toEqual('SslAssertionBuilder.certificate(\'bogusProperty\').equals(\'x\')\n')
130+
})
46131
})
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, it, expect } from 'vitest'
2+
3+
import { Diagnostics } from '../diagnostics.js'
4+
import { operatorComparisons } from '../internal/assertion-grammar.js'
5+
import { certificateGrammar, connectionGrammar } from '../ssl-assertion-grammar.js'
6+
import { booleanComparisons, numericComparisons, valueForSslAssertion } from '../ssl-assertion-codegen.js'
7+
import { validateSslAssertion } from '../ssl-assertion-validation.js'
8+
import { SslAssertion, SslAssertionBuilder } from '../ssl-assertion.js'
9+
import { GeneratedFile, Output } from '../../sourcegen/index.js'
10+
11+
// The grammar tables in internal/ssl-grammar.ts are the single declaration the builder,
12+
// the validation whitelist, and codegen are all derived from. These tests iterate that
13+
// data and assert the three consumers agree for every declared (property, operator), so a
14+
// drift between them cannot land — replacing the per-class synchronization the previous
15+
// design could not hold by hand.
16+
17+
function render (assertion: SslAssertion): string {
18+
const output = new Output()
19+
valueForSslAssertion(new GeneratedFile('foo.ts'), assertion).render(output)
20+
return output.finalize()
21+
}
22+
23+
// A runtime target of the right shape for a property's value kind. The builder stringifies
24+
// it, so any in-kind value round-trips; validation then accepts it.
25+
function sampleTarget (kind: string): string | number | boolean {
26+
switch (kind) {
27+
case 'number':
28+
return 1
29+
case 'boolean':
30+
return true
31+
default:
32+
return 'x'
33+
}
34+
}
35+
36+
const sources = [
37+
{ source: 'CERTIFICATE', method: 'certificate', grammar: certificateGrammar },
38+
{ source: 'CONNECTION', method: 'connection', grammar: connectionGrammar },
39+
] as const
40+
41+
describe('SSL assertion grammar', () => {
42+
it('routes every declared operator through the public builder to the right wire fields', () => {
43+
for (const { source, method, grammar } of sources) {
44+
for (const [property, decl] of Object.entries(grammar)) {
45+
for (const operator of decl.operators) {
46+
type Builder = Record<string, (t: unknown) => SslAssertion>
47+
const builder = (SslAssertionBuilder[method] as (p: string) => Builder)(property)
48+
const assertion = builder[operator](sampleTarget(decl.target.kind))
49+
expect(assertion).toMatchObject({
50+
source,
51+
property,
52+
comparison: operatorComparisons[operator],
53+
})
54+
}
55+
}
56+
}
57+
})
58+
59+
it('accepts every declared (property, operator) through validation', () => {
60+
for (const { source, grammar } of sources) {
61+
for (const [property, decl] of Object.entries(grammar)) {
62+
for (const operator of decl.operators) {
63+
const assertion: SslAssertion = {
64+
source,
65+
property,
66+
comparison: operatorComparisons[operator],
67+
target: String(sampleTarget(decl.target.kind)),
68+
regex: null,
69+
}
70+
const diags = new Diagnostics()
71+
validateSslAssertion(diags, assertion, 0)
72+
expect(diags.isFatal(), `${source}.${property}.${operator} should validate`).toEqual(false)
73+
}
74+
}
75+
}
76+
})
77+
78+
it('renders every declared (property, operator) naming the same property and operator', () => {
79+
for (const { method, grammar } of sources) {
80+
for (const [property, decl] of Object.entries(grammar)) {
81+
for (const operator of decl.operators) {
82+
const assertion: SslAssertion = {
83+
source: method === 'certificate' ? 'CERTIFICATE' : 'CONNECTION',
84+
property,
85+
comparison: operatorComparisons[operator],
86+
target: String(sampleTarget(decl.target.kind)),
87+
regex: null,
88+
}
89+
const source = render(assertion)
90+
expect(source).toContain(`SslAssertionBuilder.${method}('${property}')`)
91+
expect(source).toContain(`.${operator}(`)
92+
}
93+
}
94+
}
95+
})
96+
97+
// Codegen renders a number target as a bare number literal and a boolean as a bare
98+
// boolean, both only for the comparisons it has typed paths for. A property of that kind
99+
// declaring any other operator would fall through to the quoted-string form, which does
100+
// not compile against the property's typed builder method — the drift this guards against.
101+
//
102+
// The renderable sets are codegen's own predicates (numericComparisons / booleanComparisons),
103+
// not restated here, so this cannot fall out of step with what codegen actually renders.
104+
it('lets number and boolean properties declare only operators codegen renders as literals', () => {
105+
const renderableFor: Record<string, Record<string, true>> = {
106+
number: numericComparisons,
107+
boolean: booleanComparisons,
108+
}
109+
for (const { grammar } of sources) {
110+
for (const [property, decl] of Object.entries(grammar)) {
111+
const renderable = renderableFor[decl.target.kind]
112+
if (renderable === undefined) {
113+
continue
114+
}
115+
for (const operator of decl.operators) {
116+
const comparison = operatorComparisons[operator]
117+
expect(Object.hasOwn(renderable, comparison), `${property}.${operator} is not rendered as a ${decl.target.kind} literal`).toBe(true)
118+
}
119+
}
120+
}
121+
})
122+
})

0 commit comments

Comments
 (0)