autotel-schema 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +131 -0
  2. package/dist/cli.cjs +111 -0
  3. package/dist/cli.cjs.map +1 -0
  4. package/dist/cli.d.cts +14 -0
  5. package/dist/cli.d.cts.map +1 -0
  6. package/dist/cli.d.ts +14 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +82 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/contract-DGjxR9nb.d.cts +123 -0
  11. package/dist/contract-DGjxR9nb.d.cts.map +1 -0
  12. package/dist/contract-DGjxR9nb.d.ts +123 -0
  13. package/dist/contract-DGjxR9nb.d.ts.map +1 -0
  14. package/dist/diff-BQPh72vY.d.cts +89 -0
  15. package/dist/diff-BQPh72vY.d.cts.map +1 -0
  16. package/dist/diff-D7qkNn0-.d.ts +89 -0
  17. package/dist/diff-D7qkNn0-.d.ts.map +1 -0
  18. package/dist/diff.cjs +185 -0
  19. package/dist/diff.cjs.map +1 -0
  20. package/dist/diff.d.cts +2 -0
  21. package/dist/diff.d.ts +2 -0
  22. package/dist/diff.js +181 -0
  23. package/dist/diff.js.map +1 -0
  24. package/dist/index.cjs +63 -0
  25. package/dist/index.cjs.map +1 -0
  26. package/dist/index.d.cts +33 -0
  27. package/dist/index.d.cts.map +1 -0
  28. package/dist/index.d.ts +33 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +43 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/processor-CK7LAdaa.d.ts +100 -0
  33. package/dist/processor-CK7LAdaa.d.ts.map +1 -0
  34. package/dist/processor-CkBkzK6y.d.cts +100 -0
  35. package/dist/processor-CkBkzK6y.d.cts.map +1 -0
  36. package/dist/processor-D93TAXvZ.cjs +366 -0
  37. package/dist/processor-D93TAXvZ.cjs.map +1 -0
  38. package/dist/processor-FmvKYllX.js +306 -0
  39. package/dist/processor-FmvKYllX.js.map +1 -0
  40. package/dist/processor.cjs +5 -0
  41. package/dist/processor.d.cts +2 -0
  42. package/dist/processor.d.ts +2 -0
  43. package/dist/processor.js +3 -0
  44. package/dist/snapshot-CyWGJaJT.cjs +119 -0
  45. package/dist/snapshot-CyWGJaJT.cjs.map +1 -0
  46. package/dist/snapshot-h8pb_Up_.js +89 -0
  47. package/dist/snapshot-h8pb_Up_.js.map +1 -0
  48. package/package.json +80 -0
  49. package/src/attrs.ts +23 -0
  50. package/src/cli.ts +117 -0
  51. package/src/contract.test.ts +67 -0
  52. package/src/contract.ts +231 -0
  53. package/src/diff.ts +282 -0
  54. package/src/index.ts +88 -0
  55. package/src/processor.test.ts +74 -0
  56. package/src/processor.ts +152 -0
  57. package/src/redaction.ts +64 -0
  58. package/src/snapshot.test.ts +88 -0
  59. package/src/snapshot.ts +119 -0
  60. package/src/validate.test.ts +100 -0
  61. package/src/validate.ts +237 -0
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { defineContract, type TelemetryContract } from './contract.js';
4
+ import {
5
+ diffSnapshots,
6
+ formatDiff,
7
+ hasBreakingChanges,
8
+ } from './diff.js';
9
+ import { highCardinalityKeys, isHighCardinalityKey } from './redaction.js';
10
+ import {
11
+ contractToSnapshot,
12
+ parseSnapshot,
13
+ serializeSnapshot,
14
+ } from './snapshot.js';
15
+
16
+ const v1: TelemetryContract = defineContract({
17
+ service: 'checkout',
18
+ version: '1.0.0',
19
+ commonAttributes: { 'user.id': { type: 'string', highCardinality: true } },
20
+ spans: {
21
+ 'checkout.charge': {
22
+ attributes: {
23
+ 'payment.provider': { type: 'string', required: true },
24
+ },
25
+ },
26
+ },
27
+ });
28
+
29
+ describe('snapshot round-trip', () => {
30
+ it('is deterministic regardless of key insertion order', () => {
31
+ const reordered = defineContract({
32
+ version: '1.0.0',
33
+ service: 'checkout',
34
+ spans: {
35
+ 'checkout.charge': {
36
+ attributes: { 'payment.provider': { type: 'string', required: true } },
37
+ },
38
+ },
39
+ commonAttributes: { 'user.id': { type: 'string', highCardinality: true } },
40
+ });
41
+ expect(serializeSnapshot(contractToSnapshot(v1))).toBe(
42
+ serializeSnapshot(contractToSnapshot(reordered)),
43
+ );
44
+ });
45
+
46
+ it('serializes and parses back', () => {
47
+ const snap = contractToSnapshot(v1);
48
+ expect(parseSnapshot(serializeSnapshot(snap))).toEqual(snap);
49
+ });
50
+
51
+ it('rejects an unknown snapshot spec', () => {
52
+ expect(() => parseSnapshot('{"spec":"nope/v9","service":"x","version":"1.0.0"}')).toThrowError(
53
+ /unexpected snapshot spec/,
54
+ );
55
+ });
56
+ });
57
+
58
+ describe('diffSnapshots', () => {
59
+ it('classifies a removed span as breaking', () => {
60
+ const v2 = defineContract({ ...v1, version: '2.0.0', spans: {} });
61
+ const diff = diffSnapshots(contractToSnapshot(v1), contractToSnapshot(v2));
62
+ expect(hasBreakingChanges(diff)).toBe(true);
63
+ expect(diff.breaking.some((c) => c.type === 'span_removed')).toBe(true);
64
+ expect(formatDiff(diff)).toMatch(/1\.0\.0 → 2\.0\.0/);
65
+ });
66
+
67
+ it('classifies a new span as additive, not breaking', () => {
68
+ const v2 = defineContract({
69
+ ...v1,
70
+ version: '1.1.0',
71
+ spans: {
72
+ ...v1.spans,
73
+ 'checkout.refund': { attributes: {} },
74
+ },
75
+ });
76
+ const diff = diffSnapshots(contractToSnapshot(v1), contractToSnapshot(v2));
77
+ expect(hasBreakingChanges(diff)).toBe(false);
78
+ expect(diff.additive.some((c) => c.type === 'span_added')).toBe(true);
79
+ });
80
+ });
81
+
82
+ describe('redaction helpers', () => {
83
+ it('collects high-cardinality keys across common + span attributes', () => {
84
+ expect(highCardinalityKeys(v1)).toEqual(['user.id']);
85
+ expect(isHighCardinalityKey(v1, 'user.id')).toBe(true);
86
+ expect(isHighCardinalityKey(v1, 'payment.provider')).toBe(false);
87
+ });
88
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Snapshots — the serializable form of a contract that gets committed and
3
+ * diffed across versions. Checking a snapshot into the repo turns "did this
4
+ * refactor rename a span?" into a reviewable line in a PR instead of a silent
5
+ * break the agent reader discovers at 3am.
6
+ */
7
+
8
+ import { SNAPSHOT_SPEC } from './attrs.js';
9
+ import type {
10
+ AttributeSpec,
11
+ AttributeType,
12
+ Stability,
13
+ TelemetryContract,
14
+ } from './contract.js';
15
+
16
+ /** Flattened, fully-resolved attribute record in a snapshot. */
17
+ export interface SnapshotAttribute {
18
+ type: AttributeType;
19
+ stability: Stability;
20
+ required: boolean;
21
+ highCardinality: boolean;
22
+ enum?: readonly (string | number)[];
23
+ replacedBy?: string;
24
+ description?: string;
25
+ }
26
+
27
+ export interface SnapshotSpan {
28
+ stability: Stability;
29
+ additionalAttributes: boolean;
30
+ description?: string;
31
+ attributes: Record<string, SnapshotAttribute>;
32
+ }
33
+
34
+ /** Canonical, comparable representation of a {@link TelemetryContract}. */
35
+ export interface ContractSnapshot {
36
+ spec: typeof SNAPSHOT_SPEC;
37
+ service: string;
38
+ version: string;
39
+ commonAttributes: Record<string, SnapshotAttribute>;
40
+ spans: Record<string, SnapshotSpan>;
41
+ }
42
+
43
+ function normalizeAttribute(spec: AttributeSpec): SnapshotAttribute {
44
+ const out: SnapshotAttribute = {
45
+ type: spec.type,
46
+ stability: spec.stability ?? 'stable',
47
+ required: spec.required ?? false,
48
+ highCardinality: spec.highCardinality ?? false,
49
+ };
50
+ if (spec.enum) out.enum = [...spec.enum];
51
+ if (spec.replacedBy) out.replacedBy = spec.replacedBy;
52
+ if (spec.description) out.description = spec.description;
53
+ return out;
54
+ }
55
+
56
+ function sortRecord<T>(record: Record<string, T>): Record<string, T> {
57
+ const out: Record<string, T> = {};
58
+ for (const key of Object.keys(record).toSorted()) {
59
+ out[key] = record[key];
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Produce a deterministic, JSON-serializable snapshot from a contract. Keys are
66
+ * sorted so two snapshots of the same logical contract are byte-identical —
67
+ * important for clean `git diff`s and stable CI comparisons.
68
+ */
69
+ export function contractToSnapshot(
70
+ contract: TelemetryContract,
71
+ ): ContractSnapshot {
72
+ const commonAttributes: Record<string, SnapshotAttribute> = {};
73
+ for (const [key, spec] of Object.entries(contract.commonAttributes ?? {})) {
74
+ commonAttributes[key] = normalizeAttribute(spec);
75
+ }
76
+
77
+ const spans: Record<string, SnapshotSpan> = {};
78
+ for (const [name, spanSpec] of Object.entries(contract.spans)) {
79
+ const attributes: Record<string, SnapshotAttribute> = {};
80
+ for (const [key, spec] of Object.entries(spanSpec.attributes ?? {})) {
81
+ attributes[key] = normalizeAttribute(spec);
82
+ }
83
+ const span: SnapshotSpan = {
84
+ stability: spanSpec.stability ?? 'stable',
85
+ additionalAttributes:
86
+ spanSpec.additionalAttributes ?? contract.additionalAttributes ?? false,
87
+ attributes: sortRecord(attributes),
88
+ };
89
+ if (spanSpec.description) span.description = spanSpec.description;
90
+ spans[name] = span;
91
+ }
92
+
93
+ return {
94
+ spec: SNAPSHOT_SPEC,
95
+ service: contract.service,
96
+ version: contract.version,
97
+ commonAttributes: sortRecord(commonAttributes),
98
+ spans: sortRecord(spans),
99
+ };
100
+ }
101
+
102
+ /** Pretty, deterministic JSON for writing a snapshot to disk. */
103
+ export function serializeSnapshot(snapshot: ContractSnapshot): string {
104
+ return JSON.stringify(snapshot, null, 2) + '\n';
105
+ }
106
+
107
+ /** Parse and structurally validate a snapshot read from disk. */
108
+ export function parseSnapshot(json: string): ContractSnapshot {
109
+ const data = JSON.parse(json) as ContractSnapshot;
110
+ if (data.spec !== SNAPSHOT_SPEC) {
111
+ throw new Error(
112
+ `autotel-schema: unexpected snapshot spec "${data.spec}" (expected "${SNAPSHOT_SPEC}")`,
113
+ );
114
+ }
115
+ if (typeof data.service !== 'string' || typeof data.version !== 'string') {
116
+ throw new Error('autotel-schema: snapshot is missing service/version');
117
+ }
118
+ return data;
119
+ }
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { defineContract, type TelemetryContract } from './contract.js';
4
+ import {
5
+ formatViolation,
6
+ hasErrors,
7
+ validateSpan,
8
+ } from './validate.js';
9
+
10
+ const contract: TelemetryContract = defineContract({
11
+ service: 'checkout',
12
+ version: '1.0.0',
13
+ commonAttributes: { 'user.id': { type: 'string' } },
14
+ spans: {
15
+ 'checkout.charge': {
16
+ attributes: {
17
+ 'payment.provider': { type: 'string', required: true, enum: ['stripe', 'paypal'] },
18
+ 'payment.amount_cents': { type: 'number', required: true },
19
+ },
20
+ },
21
+ },
22
+ });
23
+
24
+ describe('validateSpan', () => {
25
+ it('passes a fully-conformant span', () => {
26
+ const v = validateSpan(
27
+ {
28
+ name: 'checkout.charge',
29
+ attributes: { 'payment.provider': 'stripe', 'payment.amount_cents': 999 },
30
+ },
31
+ contract,
32
+ );
33
+ expect(v).toEqual([]);
34
+ });
35
+
36
+ it('flags a missing required attribute as an error', () => {
37
+ const v = validateSpan(
38
+ { name: 'checkout.charge', attributes: { 'payment.provider': 'stripe' } },
39
+ contract,
40
+ );
41
+ expect(v).toHaveLength(1);
42
+ expect(v[0]).toMatchObject({ code: 'missing_required', attribute: 'payment.amount_cents' });
43
+ expect(hasErrors(v)).toBe(true);
44
+ });
45
+
46
+ it('flags a wrong type', () => {
47
+ const v = validateSpan(
48
+ {
49
+ name: 'checkout.charge',
50
+ attributes: { 'payment.provider': 'stripe', 'payment.amount_cents': '999' },
51
+ },
52
+ contract,
53
+ );
54
+ expect(v.some((x) => x.code === 'type_mismatch')).toBe(true);
55
+ });
56
+
57
+ it('flags an enum violation', () => {
58
+ const v = validateSpan(
59
+ {
60
+ name: 'checkout.charge',
61
+ attributes: { 'payment.provider': 'bitcoin', 'payment.amount_cents': 1 },
62
+ },
63
+ contract,
64
+ );
65
+ expect(v.some((x) => x.code === 'enum_violation')).toBe(true);
66
+ });
67
+
68
+ it('warns on an undeclared attribute and suggests a near key', () => {
69
+ const v = validateSpan(
70
+ {
71
+ name: 'checkout.charge',
72
+ attributes: {
73
+ 'payment.provider': 'stripe',
74
+ 'payment.amount_cents': 1,
75
+ 'payment.providr': 'x', // typo of a declared key
76
+ },
77
+ },
78
+ contract,
79
+ );
80
+ const unknown = v.find((x) => x.code === 'unknown_attribute');
81
+ expect(unknown?.severity).toBe('warning');
82
+ expect(unknown?.suggestion).toBe('payment.provider');
83
+ });
84
+
85
+ it('ignores unknown spans unless strictSpanNames is set', () => {
86
+ expect(validateSpan({ name: 'mystery', attributes: {} }, contract)).toEqual([]);
87
+ const strict = validateSpan({ name: 'mystery', attributes: {} }, contract, {
88
+ strictSpanNames: true,
89
+ });
90
+ expect(strict[0]?.code).toBe('unknown_span');
91
+ });
92
+
93
+ it('formats a violation legibly', () => {
94
+ const v = validateSpan(
95
+ { name: 'checkout.charge', attributes: { 'payment.provider': 'stripe' } },
96
+ contract,
97
+ );
98
+ expect(formatViolation(v[0])).toMatch(/\[error\] missing_required @ checkout.charge.payment.amount_cents/);
99
+ });
100
+ });
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Pure span-vs-contract validation. No SDK, no side effects — the same engine
3
+ * the runtime processor ({@link ./processor}) and any test harness can call.
4
+ */
5
+
6
+ import {
7
+ allowsAdditionalAttributes,
8
+ resolveAttributeSpec,
9
+ type AttributeSpec,
10
+ type AttributeType,
11
+ type TelemetryContract,
12
+ } from './contract.js';
13
+
14
+ /** Severity of a contract violation. `error` = a breaking-shaped problem. */
15
+ export type ViolationSeverity = 'error' | 'warning';
16
+
17
+ export type ViolationCode =
18
+ | 'unknown_span'
19
+ | 'unknown_attribute'
20
+ | 'type_mismatch'
21
+ | 'missing_required'
22
+ | 'deprecated_attribute'
23
+ | 'enum_violation';
24
+
25
+ /** A single discrepancy between an emitted span and the contract. */
26
+ export interface SchemaViolation {
27
+ code: ViolationCode;
28
+ severity: ViolationSeverity;
29
+ spanName: string;
30
+ /** Attribute key involved, when the violation is attribute-scoped. */
31
+ attribute?: string;
32
+ message: string;
33
+ /** Nearest declared key, for likely typos (`unknown_attribute` only). */
34
+ suggestion?: string;
35
+ }
36
+
37
+ /** Minimal emitted-span shape — avoids a hard dependency on the OTel SDK. */
38
+ export interface SpanShape {
39
+ name: string;
40
+ attributes: Record<string, unknown>;
41
+ }
42
+
43
+ export interface ValidateOptions {
44
+ /** Report `unknown_span` for span names not in the contract. Default `false`. */
45
+ strictSpanNames?: boolean;
46
+ }
47
+
48
+ /** `'empty[]'` is a distinct marker: an empty array satisfies any array type. */
49
+ function actualType(value: unknown): AttributeType | 'empty[]' | 'unknown' {
50
+ if (typeof value === 'string') return 'string';
51
+ if (typeof value === 'number') return 'number';
52
+ if (typeof value === 'boolean') return 'boolean';
53
+ if (Array.isArray(value)) {
54
+ const first = value.find((v) => v !== null && v !== undefined);
55
+ if (first === undefined) return 'empty[]';
56
+ if (typeof first === 'string') return 'string[]';
57
+ if (typeof first === 'number') return 'number[]';
58
+ if (typeof first === 'boolean') return 'boolean[]';
59
+ }
60
+ return 'unknown';
61
+ }
62
+
63
+ function typeMatches(expected: AttributeType, value: unknown): boolean {
64
+ const actual = actualType(value);
65
+ if (actual === 'unknown') return false;
66
+ if (actual === 'empty[]') return expected.endsWith('[]');
67
+ return actual === expected;
68
+ }
69
+
70
+ /** Levenshtein distance — small, allocation-light, good enough for key typos. */
71
+ function editDistance(a: string, b: string): number {
72
+ const m = a.length;
73
+ const n = b.length;
74
+ if (m === 0) return n;
75
+ if (n === 0) return m;
76
+ let prev = Array.from({ length: n + 1 }, (_, i) => i);
77
+ let curr = Array.from<number>({ length: n + 1 });
78
+ for (let i = 1; i <= m; i++) {
79
+ curr[0] = i;
80
+ for (let j = 1; j <= n; j++) {
81
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
82
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
83
+ }
84
+ [prev, curr] = [curr, prev];
85
+ }
86
+ return prev[n];
87
+ }
88
+
89
+ /**
90
+ * Closest declared key to `key`, when one is within a small edit distance.
91
+ * Turns "you emitted an attribute I don't know" into "did you mean `user.id`?".
92
+ */
93
+ function nearestKey(key: string, candidates: string[]): string | undefined {
94
+ let best: string | undefined;
95
+ let bestDistance = Infinity;
96
+ const threshold = Math.max(1, Math.floor(key.length / 4) + 1);
97
+ for (const candidate of candidates) {
98
+ const d = editDistance(key, candidate);
99
+ if (d < bestDistance && d <= threshold) {
100
+ best = candidate;
101
+ bestDistance = d;
102
+ }
103
+ }
104
+ return best;
105
+ }
106
+
107
+ function declaredKeysFor(
108
+ contract: TelemetryContract,
109
+ spanName: string,
110
+ ): string[] {
111
+ return [
112
+ ...Object.keys(contract.spans[spanName]?.attributes ?? {}),
113
+ ...Object.keys(contract.commonAttributes ?? {}),
114
+ ];
115
+ }
116
+
117
+ function checkValue(
118
+ spanName: string,
119
+ key: string,
120
+ value: unknown,
121
+ spec: AttributeSpec,
122
+ out: SchemaViolation[],
123
+ ): void {
124
+ if (!typeMatches(spec.type, value)) {
125
+ out.push({
126
+ code: 'type_mismatch',
127
+ severity: 'error',
128
+ spanName,
129
+ attribute: key,
130
+ message: `attribute "${key}" should be ${spec.type} but got ${actualType(value)}`,
131
+ });
132
+ return; // a wrong type makes enum/deprecation checks noise
133
+ }
134
+ if (spec.enum && (typeof value === 'string' || typeof value === 'number') && !spec.enum.includes(value)) {
135
+ out.push({
136
+ code: 'enum_violation',
137
+ severity: 'error',
138
+ spanName,
139
+ attribute: key,
140
+ message: `attribute "${key}" value ${JSON.stringify(value)} is not one of ${JSON.stringify(spec.enum)}`,
141
+ });
142
+ }
143
+ if (spec.stability === 'deprecated') {
144
+ const hint = spec.replacedBy
145
+ ? ` — use "${spec.replacedBy}" instead`
146
+ : spec.deprecatedReason
147
+ ? ` — ${spec.deprecatedReason}`
148
+ : '';
149
+ out.push({
150
+ code: 'deprecated_attribute',
151
+ severity: 'warning',
152
+ spanName,
153
+ attribute: key,
154
+ message: `attribute "${key}" is deprecated${hint}`,
155
+ suggestion: spec.replacedBy,
156
+ });
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Validate one emitted span against the contract, returning every discrepancy.
162
+ * Order is deterministic: required-but-missing first, then per-attribute checks
163
+ * in attribute insertion order.
164
+ */
165
+ export function validateSpan(
166
+ span: SpanShape,
167
+ contract: TelemetryContract,
168
+ options: ValidateOptions = {},
169
+ ): SchemaViolation[] {
170
+ const out: SchemaViolation[] = [];
171
+ const spanSpec = contract.spans[span.name];
172
+
173
+ if (!spanSpec) {
174
+ if (options.strictSpanNames) {
175
+ out.push({
176
+ code: 'unknown_span',
177
+ severity: 'warning',
178
+ spanName: span.name,
179
+ message: `span "${span.name}" is not declared in the contract`,
180
+ });
181
+ }
182
+ return out; // unknown span → no attribute contract to check against
183
+ }
184
+
185
+ // Required attributes that never showed up.
186
+ const required = [
187
+ ...Object.entries(spanSpec.attributes ?? {}),
188
+ ...Object.entries(contract.commonAttributes ?? {}),
189
+ ].filter(([, spec]) => spec.required);
190
+ for (const [key] of required) {
191
+ if (!(key in span.attributes)) {
192
+ out.push({
193
+ code: 'missing_required',
194
+ severity: 'error',
195
+ spanName: span.name,
196
+ attribute: key,
197
+ message: `required attribute "${key}" is missing`,
198
+ });
199
+ }
200
+ }
201
+
202
+ const allowExtra = allowsAdditionalAttributes(contract, span.name);
203
+ const declared = allowExtra ? [] : declaredKeysFor(contract, span.name);
204
+
205
+ for (const [key, value] of Object.entries(span.attributes)) {
206
+ if (value === null || value === undefined) continue;
207
+ const spec = resolveAttributeSpec(contract, span.name, key);
208
+ if (!spec) {
209
+ if (!allowExtra) {
210
+ out.push({
211
+ code: 'unknown_attribute',
212
+ severity: 'warning',
213
+ spanName: span.name,
214
+ attribute: key,
215
+ message: `attribute "${key}" is not declared on span "${span.name}"`,
216
+ suggestion: nearestKey(key, declared),
217
+ });
218
+ }
219
+ continue;
220
+ }
221
+ checkValue(span.name, key, value, spec, out);
222
+ }
223
+
224
+ return out;
225
+ }
226
+
227
+ /** `true` when any violation is `error` severity. */
228
+ export function hasErrors(violations: SchemaViolation[]): boolean {
229
+ return violations.some((v) => v.severity === 'error');
230
+ }
231
+
232
+ /** One-line human/agent-readable rendering of a violation. */
233
+ export function formatViolation(v: SchemaViolation): string {
234
+ const where = v.attribute ? `${v.spanName}.${v.attribute}` : v.spanName;
235
+ const suffix = v.suggestion ? ` (did you mean "${v.suggestion}"?)` : '';
236
+ return `[${v.severity}] ${v.code} @ ${where}: ${v.message}${suffix}`;
237
+ }