Skip to content

Commit fe440d4

Browse files
sorccuclaude
andcommitted
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>
1 parent 09e3fa3 commit fe440d4

7 files changed

Lines changed: 399 additions & 452 deletions

File tree

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+
})
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { Assertion, GeneralAssertionBuilder, toAssertion } from './assertion.js'
2+
3+
// A property-scoped assertion grammar: one declaration per property states the operators
4+
// it exposes and the type of its target. The builder surface, the validation whitelist,
5+
// and codegen's literal kind are all derived from it, so a property's grammar lives in one
6+
// place instead of being restated in a builder class, a value-type table, and a whitelist.
7+
//
8+
// This module is monitor-agnostic; each monitor supplies its own grammar tables.
9+
10+
// The operator catalog: builder method name -> wire comparison. Shared by every monitor
11+
// grammar; adding an operator is one line here plus listing it on the properties that
12+
// accept it.
13+
export const operatorComparisons = {
14+
equals: 'EQUALS',
15+
notEquals: 'NOT_EQUALS',
16+
greaterThan: 'GREATER_THAN',
17+
lessThan: 'LESS_THAN',
18+
contains: 'CONTAINS',
19+
notContains: 'NOT_CONTAINS',
20+
} as const
21+
22+
export type OperatorName = keyof typeof operatorComparisons
23+
24+
// How a target value is written on the wire. Drives validation's target-value check and
25+
// codegen's choice of a bare literal versus a quoted string.
26+
export type TargetValueType = 'number' | 'boolean' | 'string'
27+
28+
// A target spec carries the runtime value kind and, through a phantom type parameter, the
29+
// compile-time target type. The symbol member is optional and never assigned, so a spec is
30+
// just `{ kind }` at runtime.
31+
//
32+
// The grammar constraint below uses the phantom-free supertype `AnyTargetSpec`, never
33+
// `TargetSpec<any>`: a `TargetSpec<any>` constraint would contextually type a bare
34+
// `stringTarget()` call, and contextual inference (`T = any`) would beat the `= string`
35+
// default — silently widening a plain-string property's target to `any`. With no phantom
36+
// member in the constraint there is no inference candidate, so the default holds.
37+
declare const targetType: unique symbol
38+
39+
export interface AnyTargetSpec {
40+
readonly kind: TargetValueType
41+
}
42+
43+
export interface TargetSpec<T extends string | number | boolean> extends AnyTargetSpec {
44+
readonly [targetType]?: T
45+
}
46+
47+
export function numberTarget (): TargetSpec<number> {
48+
return { kind: 'number' }
49+
}
50+
51+
export function booleanTarget (): TargetSpec<boolean> {
52+
return { kind: 'boolean' }
53+
}
54+
55+
export function stringTarget<T extends string = string> (): TargetSpec<T> {
56+
return { kind: 'string' }
57+
}
58+
59+
export interface PropertyGrammar {
60+
readonly operators: readonly OperatorName[]
61+
readonly target: AnyTargetSpec
62+
}
63+
64+
// `const G` keeps each row's operator array a literal tuple without callers writing
65+
// `as const`, so `keyof typeof grammar` and the per-property operator sets stay precise.
66+
export function defineGrammar<const G extends Record<string, PropertyGrammar>> (grammar: G): G {
67+
return grammar
68+
}
69+
70+
type TargetOf<Spec> = Spec extends TargetSpec<infer T> ? T : never
71+
72+
// The builder surface for one property: each declared operator becomes a method taking the
73+
// property's target type. Distributing over a union `Decl` means a builder created from a
74+
// union property exposes only the operators common to every arm.
75+
export type PropertyOperators<Source extends string, Decl extends PropertyGrammar> =
76+
Decl extends PropertyGrammar
77+
? { [Op in Decl['operators'][number]]: (target: TargetOf<Decl['target']>) => Assertion<Source> }
78+
: never
79+
80+
// Builds the operator object for a known property by looping over its declared operators.
81+
// The cast is the one unavoidable bridge in the design: TypeScript cannot connect a runtime
82+
// loop to a mapped type. It is sound because the loop defines exactly the keys in
83+
// `decl.operators`, each routed through `operatorComparisons` — the same data
84+
// `PropertyOperators` is computed from.
85+
function makeOperators<Source extends string, Decl extends PropertyGrammar> (
86+
source: Source,
87+
property: string,
88+
decl: Decl,
89+
): PropertyOperators<Source, Decl> {
90+
const operators: Record<string, (target: string | number | boolean) => Assertion<Source>> = {}
91+
for (const operator of decl.operators) {
92+
operators[operator] = target => toAssertion(source, operatorComparisons[operator], target, property)
93+
}
94+
return operators as PropertyOperators<Source, Decl>
95+
}
96+
97+
// Resolves the operators for a property of a grammar.
98+
//
99+
// The typed signature makes an unknown property a compile error, but plain JavaScript
100+
// callers and object-literal assertions still reach this at runtime with an arbitrary
101+
// string. Those fall back to an unconstrained builder so the call returns something usable;
102+
// the property is then reported by the monitor's validation at deploy time rather than
103+
// throwing here.
104+
export function operatorsForProperty<
105+
Source extends string,
106+
Grammar extends Record<string, PropertyGrammar>,
107+
Property extends keyof Grammar & string,
108+
> (
109+
grammar: Grammar,
110+
source: Source,
111+
property: Property,
112+
): PropertyOperators<Source, Grammar[Property]> {
113+
if (!Object.hasOwn(grammar, property)) {
114+
const fallback = new GeneralAssertionBuilder<Source>(source, property)
115+
return fallback as unknown as PropertyOperators<Source, Grammar[Property]>
116+
}
117+
return makeOperators(source, property, grammar[property])
118+
}
119+
120+
// Runtime derivations shared by validation and codegen, so both read the same grammar the
121+
// builder is generated from.
122+
123+
// The wire comparisons a property accepts, from its declared operators.
124+
export function comparisonsForGrammar (decl: PropertyGrammar): Record<string, true> {
125+
return Object.fromEntries(decl.operators.map(operator => [operatorComparisons[operator], true]))
126+
}
127+
128+
// How a property's target is written on the wire.
129+
export function valueTypeForGrammar (decl: PropertyGrammar): TargetValueType {
130+
return decl.target.kind
131+
}

packages/cli/src/constructs/internal/ssl-properties.ts

Lines changed: 0 additions & 75 deletions
This file was deleted.

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
valueForGeneralAssertion,
66
valueForNumericAssertion,
77
} from './internal/assertion-codegen.js'
8-
import { isSslNumericTarget, sslPropertyValueType } from './internal/ssl-properties.js'
8+
import { isSslNumericTarget, sslPropertyValueType } from './ssl-assertion-grammar.js'
99
import { SslAssertion } from './ssl-assertion.js'
1010

1111
// Every SSL source addresses its value through the property slot — CERTIFICATE /
@@ -16,11 +16,20 @@ const withProperty = { hasProperty: true, hasRegex: false }
1616

1717
// The comparisons each typed helper can render. A comparison outside the set makes the
1818
// helper throw, which would abort the whole import over a single assertion, so the
19-
// dispatch below checks membership rather than relying on the throw.
20-
const numericComparisons: Record<string, true> = {
19+
// dispatch below checks membership rather than relying on the throw. Exported so the
20+
// grammar consistency spec can assert numeric properties declare only operators this
21+
// renders, rather than restating the set.
22+
export const numericComparisons: Record<string, true> = {
2123
EQUALS: true, NOT_EQUALS: true, GREATER_THAN: true, LESS_THAN: true,
2224
}
2325

26+
// The boolean helper renders only EQUALS as a bare boolean literal; any other comparison
27+
// falls through to the quoted-string form, which would not compile against a boolean
28+
// operator. The grammar spec asserts boolean properties declare only this.
29+
export const booleanComparisons: Record<string, true> = {
30+
EQUALS: true,
31+
}
32+
2433
// The operators for a numeric or boolean property take a `number` / `boolean`, so their
2534
// targets must be emitted as bare literals rather than quoted strings — otherwise the
2635
// generated code does not compile.
@@ -51,7 +60,7 @@ function valueForPropertyScopedAssertion (method: string, assertion: SslAssertio
5160
}
5261

5362
const isRenderableBoolean = valueType === 'boolean'
54-
&& assertion.comparison === 'EQUALS'
63+
&& Object.hasOwn(booleanComparisons, assertion.comparison)
5564
&& (assertion.target === 'true' || assertion.target === 'false')
5665
if (isRenderableBoolean) {
5766
return valueForBooleanAssertion('SslAssertionBuilder', method, assertion, { hasProperty: true })

0 commit comments

Comments
 (0)