ag-ui-validate 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/LICENSE +21 -0
  2. package/README.md +229 -0
  3. package/dist/catalog-BglXBNbL.js +472 -0
  4. package/dist/catalog-BglXBNbL.js.map +1 -0
  5. package/dist/catalog-Ci9dqc1a.cjs +495 -0
  6. package/dist/catalog-Ci9dqc1a.cjs.map +1 -0
  7. package/dist/cli.js +2783 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/index-Hmqj3r_r.d.cts +52 -0
  10. package/dist/index-oNG1kOp9.d.ts +52 -0
  11. package/dist/index.cjs +14 -0
  12. package/dist/index.d.cts +3 -0
  13. package/dist/index.d.ts +3 -0
  14. package/dist/index.js +3 -0
  15. package/dist/report.cjs +139 -0
  16. package/dist/report.cjs.map +1 -0
  17. package/dist/report.d.cts +85 -0
  18. package/dist/report.d.ts +85 -0
  19. package/dist/report.js +134 -0
  20. package/dist/report.js.map +1 -0
  21. package/dist/src-HmI-kxef.cjs +1596 -0
  22. package/dist/src-HmI-kxef.cjs.map +1 -0
  23. package/dist/src-rGZ2G4qA.js +1555 -0
  24. package/dist/src-rGZ2G4qA.js.map +1 -0
  25. package/dist/transport.cjs +329 -0
  26. package/dist/transport.cjs.map +1 -0
  27. package/dist/transport.d.cts +89 -0
  28. package/dist/transport.d.ts +89 -0
  29. package/dist/transport.js +323 -0
  30. package/dist/transport.js.map +1 -0
  31. package/dist/types-oH_QTnn2.d.cts +148 -0
  32. package/dist/types-oH_QTnn2.d.ts +148 -0
  33. package/dist/vitest.d.ts +28 -0
  34. package/dist/vitest.js +2089 -0
  35. package/dist/vitest.js.map +1 -0
  36. package/package.json +127 -0
  37. package/src/cli-args.ts +202 -0
  38. package/src/cli.ts +147 -0
  39. package/src/index.ts +465 -0
  40. package/src/protocol/event-table.ts +316 -0
  41. package/src/protocol/jsonpatch.ts +220 -0
  42. package/src/report/index.ts +10 -0
  43. package/src/report/json.ts +20 -0
  44. package/src/report/junit.ts +56 -0
  45. package/src/report/pretty.ts +59 -0
  46. package/src/report/sarif.ts +109 -0
  47. package/src/rules/catalog.json +431 -0
  48. package/src/rules/catalog.ts +84 -0
  49. package/src/rules/checks/context.ts +117 -0
  50. package/src/rules/checks/lifecycle.ts +59 -0
  51. package/src/rules/checks/reasoning.ts +97 -0
  52. package/src/rules/checks/state.ts +72 -0
  53. package/src/rules/checks/text.ts +109 -0
  54. package/src/rules/checks/toolcalls.ts +167 -0
  55. package/src/rules/checks/transport.ts +17 -0
  56. package/src/transport/index.ts +331 -0
  57. package/src/transport/ndjson.ts +25 -0
  58. package/src/transport/sse.ts +126 -0
  59. package/src/types.ts +136 -0
  60. package/src/vitest/index.ts +19 -0
  61. package/src/vitest/matcher.ts +77 -0
@@ -0,0 +1,316 @@
1
+ // AUTO-GENERATED from @ag-ui/core v0.0.58 — do not edit by hand.
2
+ // Regenerate with: node scripts/generate-event-table.mjs
3
+ // test/protocol-drift.test.ts fails if this file drifts from the installed SDK.
4
+
5
+ export type FieldKind = "string" | "number" | "boolean" | "object" | "array" | "any"
6
+
7
+ export type EventCategory =
8
+ | "lifecycle"
9
+ | "text"
10
+ | "toolcall"
11
+ | "state"
12
+ | "activity"
13
+ | "reasoning"
14
+ | "thinking"
15
+ | "special"
16
+
17
+ export interface FieldSpec {
18
+ kind: FieldKind
19
+ required: boolean
20
+ enum?: readonly string[]
21
+ }
22
+
23
+ export interface EventSpec {
24
+ category: EventCategory
25
+ /** Present when the SDK marks the event @deprecated; names the replacement. */
26
+ deprecated?: string
27
+ /** Docs section governing this event. */
28
+ specUrl: string
29
+ /** Wire fields beyond the base-event fields (type, timestamp, rawEvent). */
30
+ fields: Readonly<Record<string, FieldSpec>>
31
+ }
32
+
33
+ /** Version of @ag-ui/core this table was derived from. */
34
+ export const SDK_VERSION = "0.0.58"
35
+
36
+ /** Canonical wire event types and their schemas, derived from @ag-ui/core. */
37
+ export const EVENT_TABLE: Readonly<Record<string, EventSpec>> = {
38
+ "TEXT_MESSAGE_START": {
39
+ category: "text",
40
+ specUrl: "https://docs.ag-ui.com/concepts/events#textmessagestart",
41
+ fields: {
42
+ "messageId": { kind: "string", required: true },
43
+ "role": { kind: "string", required: false, enum: ["developer","system","assistant","user"] },
44
+ "name": { kind: "string", required: false },
45
+ },
46
+ },
47
+ "TEXT_MESSAGE_CONTENT": {
48
+ category: "text",
49
+ specUrl: "https://docs.ag-ui.com/concepts/events#textmessagecontent",
50
+ fields: {
51
+ "messageId": { kind: "string", required: true },
52
+ "delta": { kind: "string", required: true },
53
+ },
54
+ },
55
+ "TEXT_MESSAGE_END": {
56
+ category: "text",
57
+ specUrl: "https://docs.ag-ui.com/concepts/events#textmessageend",
58
+ fields: {
59
+ "messageId": { kind: "string", required: true },
60
+ },
61
+ },
62
+ "TEXT_MESSAGE_CHUNK": {
63
+ category: "text",
64
+ specUrl: "https://docs.ag-ui.com/concepts/events#textmessagechunk",
65
+ fields: {
66
+ "messageId": { kind: "string", required: false },
67
+ "role": { kind: "string", required: false, enum: ["developer","system","assistant","user"] },
68
+ "delta": { kind: "string", required: false },
69
+ "name": { kind: "string", required: false },
70
+ },
71
+ },
72
+ "TOOL_CALL_START": {
73
+ category: "toolcall",
74
+ specUrl: "https://docs.ag-ui.com/concepts/events#toolcallstart",
75
+ fields: {
76
+ "toolCallId": { kind: "string", required: true },
77
+ "toolCallName": { kind: "string", required: true },
78
+ "parentMessageId": { kind: "string", required: false },
79
+ },
80
+ },
81
+ "TOOL_CALL_ARGS": {
82
+ category: "toolcall",
83
+ specUrl: "https://docs.ag-ui.com/concepts/events#toolcallargs",
84
+ fields: {
85
+ "toolCallId": { kind: "string", required: true },
86
+ "delta": { kind: "string", required: true },
87
+ },
88
+ },
89
+ "TOOL_CALL_END": {
90
+ category: "toolcall",
91
+ specUrl: "https://docs.ag-ui.com/concepts/events#toolcallend",
92
+ fields: {
93
+ "toolCallId": { kind: "string", required: true },
94
+ },
95
+ },
96
+ "TOOL_CALL_CHUNK": {
97
+ category: "toolcall",
98
+ specUrl: "https://docs.ag-ui.com/concepts/events#toolcallchunk",
99
+ fields: {
100
+ "toolCallId": { kind: "string", required: false },
101
+ "toolCallName": { kind: "string", required: false },
102
+ "parentMessageId": { kind: "string", required: false },
103
+ "delta": { kind: "string", required: false },
104
+ },
105
+ },
106
+ "TOOL_CALL_RESULT": {
107
+ category: "toolcall",
108
+ specUrl: "https://docs.ag-ui.com/concepts/events#toolcallresult",
109
+ fields: {
110
+ "messageId": { kind: "string", required: true },
111
+ "toolCallId": { kind: "string", required: true },
112
+ "content": { kind: "string", required: true },
113
+ "role": { kind: "string", required: false, enum: ["tool"] },
114
+ },
115
+ },
116
+ "THINKING_START": {
117
+ category: "thinking",
118
+ deprecated: "REASONING_START",
119
+ specUrl: "https://docs.ag-ui.com/concepts/events#thinking-events-deprecated",
120
+ fields: {
121
+ "title": { kind: "string", required: false },
122
+ },
123
+ },
124
+ "THINKING_END": {
125
+ category: "thinking",
126
+ deprecated: "REASONING_END",
127
+ specUrl: "https://docs.ag-ui.com/concepts/events#thinking-events-deprecated",
128
+ fields: {
129
+ },
130
+ },
131
+ "THINKING_TEXT_MESSAGE_START": {
132
+ category: "thinking",
133
+ deprecated: "REASONING_MESSAGE_START",
134
+ specUrl: "https://docs.ag-ui.com/concepts/events#thinking-events-deprecated",
135
+ fields: {
136
+ },
137
+ },
138
+ "THINKING_TEXT_MESSAGE_CONTENT": {
139
+ category: "thinking",
140
+ deprecated: "REASONING_MESSAGE_CONTENT",
141
+ specUrl: "https://docs.ag-ui.com/concepts/events#thinking-events-deprecated",
142
+ fields: {
143
+ "delta": { kind: "string", required: true },
144
+ },
145
+ },
146
+ "THINKING_TEXT_MESSAGE_END": {
147
+ category: "thinking",
148
+ deprecated: "REASONING_MESSAGE_END",
149
+ specUrl: "https://docs.ag-ui.com/concepts/events#thinking-events-deprecated",
150
+ fields: {
151
+ },
152
+ },
153
+ "STATE_SNAPSHOT": {
154
+ category: "state",
155
+ specUrl: "https://docs.ag-ui.com/concepts/events#statesnapshot",
156
+ fields: {
157
+ "snapshot": { kind: "any", required: true },
158
+ },
159
+ },
160
+ "STATE_DELTA": {
161
+ category: "state",
162
+ specUrl: "https://docs.ag-ui.com/concepts/events#statedelta",
163
+ fields: {
164
+ "delta": { kind: "array", required: true },
165
+ },
166
+ },
167
+ "MESSAGES_SNAPSHOT": {
168
+ category: "state",
169
+ specUrl: "https://docs.ag-ui.com/concepts/events#messagessnapshot",
170
+ fields: {
171
+ "messages": { kind: "array", required: true },
172
+ },
173
+ },
174
+ "ACTIVITY_SNAPSHOT": {
175
+ category: "activity",
176
+ specUrl: "https://docs.ag-ui.com/concepts/events#activitysnapshot",
177
+ fields: {
178
+ "messageId": { kind: "string", required: true },
179
+ "activityType": { kind: "string", required: true },
180
+ "content": { kind: "object", required: true },
181
+ "replace": { kind: "boolean", required: false },
182
+ },
183
+ },
184
+ "ACTIVITY_DELTA": {
185
+ category: "activity",
186
+ specUrl: "https://docs.ag-ui.com/concepts/events#activitydelta",
187
+ fields: {
188
+ "messageId": { kind: "string", required: true },
189
+ "activityType": { kind: "string", required: true },
190
+ "patch": { kind: "array", required: true },
191
+ },
192
+ },
193
+ "RAW": {
194
+ category: "special",
195
+ specUrl: "https://docs.ag-ui.com/concepts/events#raw",
196
+ fields: {
197
+ "event": { kind: "any", required: true },
198
+ "source": { kind: "string", required: false },
199
+ },
200
+ },
201
+ "CUSTOM": {
202
+ category: "special",
203
+ specUrl: "https://docs.ag-ui.com/concepts/events#custom",
204
+ fields: {
205
+ "name": { kind: "string", required: true },
206
+ "value": { kind: "any", required: true },
207
+ },
208
+ },
209
+ "RUN_STARTED": {
210
+ category: "lifecycle",
211
+ specUrl: "https://docs.ag-ui.com/concepts/events#runstarted",
212
+ fields: {
213
+ "threadId": { kind: "string", required: true },
214
+ "runId": { kind: "string", required: true },
215
+ "parentRunId": { kind: "string", required: false },
216
+ "input": { kind: "object", required: false },
217
+ },
218
+ },
219
+ "RUN_FINISHED": {
220
+ category: "lifecycle",
221
+ specUrl: "https://docs.ag-ui.com/concepts/events#runfinished",
222
+ fields: {
223
+ "threadId": { kind: "string", required: true },
224
+ "runId": { kind: "string", required: true },
225
+ "result": { kind: "any", required: false },
226
+ "outcome": { kind: "object", required: false },
227
+ "usage": { kind: "array", required: false },
228
+ },
229
+ },
230
+ "RUN_ERROR": {
231
+ category: "lifecycle",
232
+ specUrl: "https://docs.ag-ui.com/concepts/events#runerror",
233
+ fields: {
234
+ "message": { kind: "string", required: true },
235
+ "code": { kind: "string", required: false },
236
+ "usage": { kind: "array", required: false },
237
+ },
238
+ },
239
+ "STEP_STARTED": {
240
+ category: "lifecycle",
241
+ specUrl: "https://docs.ag-ui.com/concepts/events#stepstarted",
242
+ fields: {
243
+ "stepName": { kind: "string", required: true },
244
+ },
245
+ },
246
+ "STEP_FINISHED": {
247
+ category: "lifecycle",
248
+ specUrl: "https://docs.ag-ui.com/concepts/events#stepfinished",
249
+ fields: {
250
+ "stepName": { kind: "string", required: true },
251
+ },
252
+ },
253
+ "REASONING_START": {
254
+ category: "reasoning",
255
+ specUrl: "https://docs.ag-ui.com/concepts/events#reasoningstart",
256
+ fields: {
257
+ "messageId": { kind: "string", required: true },
258
+ },
259
+ },
260
+ "REASONING_MESSAGE_START": {
261
+ category: "reasoning",
262
+ specUrl: "https://docs.ag-ui.com/concepts/events#reasoningmessagestart",
263
+ fields: {
264
+ "messageId": { kind: "string", required: true },
265
+ "role": { kind: "string", required: true, enum: ["reasoning"] },
266
+ },
267
+ },
268
+ "REASONING_MESSAGE_CONTENT": {
269
+ category: "reasoning",
270
+ specUrl: "https://docs.ag-ui.com/concepts/events#reasoningmessagecontent",
271
+ fields: {
272
+ "messageId": { kind: "string", required: true },
273
+ "delta": { kind: "string", required: true },
274
+ },
275
+ },
276
+ "REASONING_MESSAGE_END": {
277
+ category: "reasoning",
278
+ specUrl: "https://docs.ag-ui.com/concepts/events#reasoningmessageend",
279
+ fields: {
280
+ "messageId": { kind: "string", required: true },
281
+ },
282
+ },
283
+ "REASONING_MESSAGE_CHUNK": {
284
+ category: "reasoning",
285
+ specUrl: "https://docs.ag-ui.com/concepts/events#reasoningmessagechunk",
286
+ fields: {
287
+ "messageId": { kind: "string", required: false },
288
+ "delta": { kind: "string", required: false },
289
+ },
290
+ },
291
+ "REASONING_END": {
292
+ category: "reasoning",
293
+ specUrl: "https://docs.ag-ui.com/concepts/events#reasoningend",
294
+ fields: {
295
+ "messageId": { kind: "string", required: true },
296
+ },
297
+ },
298
+ "REASONING_ENCRYPTED_VALUE": {
299
+ category: "reasoning",
300
+ specUrl: "https://docs.ag-ui.com/concepts/events#reasoningencryptedvalue",
301
+ fields: {
302
+ "subtype": { kind: "string", required: true, enum: ["tool-call","message"] },
303
+ "entityId": { kind: "string", required: true },
304
+ "encryptedValue": { kind: "string", required: true },
305
+ },
306
+ },
307
+ }
308
+
309
+ /** All canonical wire `type` values, in @ag-ui/core enum order. */
310
+ export const EVENT_TYPES: readonly string[] = Object.keys(EVENT_TABLE)
311
+
312
+ /**
313
+ * Wire types documented as drafts (https://docs.ag-ui.com/drafts/overview) but
314
+ * not yet in @ag-ui/core. Not errors: reported at info severity.
315
+ */
316
+ export const DRAFT_EVENT_TYPES: readonly string[] = ["META"]
@@ -0,0 +1,220 @@
1
+ // Minimal RFC 6902 (JSON Patch) + RFC 6901 (JSON Pointer) implementation.
2
+ // Hand-rolled so the core keeps zero runtime dependencies. Immutable: the
3
+ // input document is never mutated; ops apply atomically (all or none).
4
+
5
+ export type PatchResult =
6
+ | { ok: true; result: unknown }
7
+ | { ok: false; error: string; opIndex: number }
8
+
9
+ export interface PatchShapeProblem {
10
+ /** Human-readable problem. */
11
+ error: string
12
+ /** Index of the offending op, or -1 when the document itself is not an array. */
13
+ opIndex: number
14
+ /** RFC 6901 pointer into the patch document, e.g. "/0/op". */
15
+ pointer: string
16
+ }
17
+
18
+ const OPS = ["add", "remove", "replace", "move", "copy", "test"] as const
19
+ type OpName = (typeof OPS)[number]
20
+
21
+ /** RFC 6901: "" → whole document; "/a/b" → ["a", "b"]. Returns null when invalid. */
22
+ export function parsePointer(pointer: unknown): string[] | null {
23
+ if (typeof pointer !== "string") return null
24
+ if (pointer === "") return []
25
+ if (!pointer.startsWith("/")) return null
26
+ return pointer
27
+ .slice(1)
28
+ .split("/")
29
+ .map((t) => t.replace(/~1/g, "/").replace(/~0/g, "~"))
30
+ }
31
+
32
+ /** Structural validation of a patch document, without applying it. */
33
+ export function validatePatchShape(patch: unknown): PatchShapeProblem | null {
34
+ if (!Array.isArray(patch)) {
35
+ return { error: "patch document must be an array of operations", opIndex: -1, pointer: "" }
36
+ }
37
+ for (let i = 0; i < patch.length; i++) {
38
+ const op = patch[i]
39
+ if (typeof op !== "object" || op === null || Array.isArray(op)) {
40
+ return { error: `operation ${i} is not an object`, opIndex: i, pointer: `/${i}` }
41
+ }
42
+ const o = op as Record<string, unknown>
43
+ if (!OPS.includes(o.op as OpName)) {
44
+ return { error: `operation ${i} has invalid op '${String(o.op)}'`, opIndex: i, pointer: `/${i}/op` }
45
+ }
46
+ if (parsePointer(o.path) === null) {
47
+ return { error: `operation ${i} (${String(o.op)}) has invalid path`, opIndex: i, pointer: `/${i}/path` }
48
+ }
49
+ if ((o.op === "add" || o.op === "replace" || o.op === "test") && !("value" in o)) {
50
+ return { error: `operation ${i} (${String(o.op)}) is missing value`, opIndex: i, pointer: `/${i}` }
51
+ }
52
+ if ((o.op === "move" || o.op === "copy") && parsePointer(o.from) === null) {
53
+ return { error: `operation ${i} (${String(o.op)}) is missing or has invalid from`, opIndex: i, pointer: `/${i}` }
54
+ }
55
+ }
56
+ return null
57
+ }
58
+
59
+ function clone(value: unknown): unknown {
60
+ if (Array.isArray(value)) return value.map(clone)
61
+ if (typeof value === "object" && value !== null) {
62
+ const out: Record<string, unknown> = {}
63
+ for (const [k, v] of Object.entries(value)) out[k] = clone(v)
64
+ return out
65
+ }
66
+ return value
67
+ }
68
+
69
+ function deepEqual(a: unknown, b: unknown): boolean {
70
+ if (a === b) return true
71
+ if (Array.isArray(a) && Array.isArray(b)) {
72
+ return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]))
73
+ }
74
+ if (typeof a === "object" && a !== null && typeof b === "object" && b !== null && !Array.isArray(a) && !Array.isArray(b)) {
75
+ const ka = Object.keys(a)
76
+ const kb = Object.keys(b as object)
77
+ return ka.length === kb.length && ka.every((k) => deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]))
78
+ }
79
+ return false
80
+ }
81
+
82
+ function arrayIndex(token: string, length: number, allowAppend: boolean): number | null {
83
+ if (allowAppend && token === "-") return length
84
+ if (!/^(0|[1-9]\d*)$/.test(token)) return null
85
+ const i = Number(token)
86
+ return i > length ? null : i
87
+ }
88
+
89
+ interface Located {
90
+ parent: Record<string, unknown> | unknown[] | null // null → the document root itself
91
+ key: string | number
92
+ exists: boolean
93
+ value: unknown
94
+ }
95
+
96
+ function locate(doc: unknown, tokens: string[], forAdd: boolean): Located | string {
97
+ if (tokens.length === 0) return { parent: null, key: "", exists: true, value: doc }
98
+ let current = doc
99
+ for (let i = 0; i < tokens.length - 1; i++) {
100
+ const token = tokens[i]!
101
+ if (Array.isArray(current)) {
102
+ const idx = arrayIndex(token, current.length, false)
103
+ if (idx === null || idx >= current.length) return `path segment '/${token}' does not exist`
104
+ current = current[idx]
105
+ } else if (typeof current === "object" && current !== null) {
106
+ const obj = current as Record<string, unknown>
107
+ if (!(token in obj)) return `path segment '/${token}' does not exist`
108
+ current = obj[token]
109
+ } else {
110
+ return `path segment '/${token}' is not an object or array`
111
+ }
112
+ }
113
+ const last = tokens[tokens.length - 1]!
114
+ if (Array.isArray(current)) {
115
+ const idx = arrayIndex(last, current.length, forAdd)
116
+ if (idx === null) return `'${last}' is not a valid index for an array of length ${current.length}`
117
+ return { parent: current, key: idx, exists: idx < current.length, value: current[idx] }
118
+ }
119
+ if (typeof current === "object" && current !== null) {
120
+ const obj = current as Record<string, unknown>
121
+ return { parent: obj, key: last, exists: last in obj, value: obj[last] }
122
+ }
123
+ return `target of '/${last}' is not an object or array`
124
+ }
125
+
126
+ /** Applies an RFC 6902 patch. Validates shape first; never throws. */
127
+ export function applyPatch(doc: unknown, patch: unknown): PatchResult {
128
+ const shape = validatePatchShape(patch)
129
+ if (shape !== null) return { ok: false, error: shape.error, opIndex: shape.opIndex }
130
+
131
+ let result = clone(doc)
132
+ const ops = patch as Record<string, unknown>[]
133
+
134
+ const getAt = (pointer: string): { ok: true; value: unknown } | { ok: false; error: string } => {
135
+ const loc = locate(result, parsePointer(pointer)!, false)
136
+ if (typeof loc === "string") return { ok: false, error: `${pointer}: ${loc}` }
137
+ if (!loc.exists) return { ok: false, error: `${pointer} does not exist` }
138
+ return { ok: true, value: loc.value }
139
+ }
140
+
141
+ const setAt = (pointer: string, value: unknown, mustExist: boolean): string | null => {
142
+ const tokens = parsePointer(pointer)!
143
+ if (tokens.length === 0) {
144
+ result = value
145
+ return null
146
+ }
147
+ const loc = locate(result, tokens, !mustExist)
148
+ if (typeof loc === "string") return `${pointer}: ${loc}`
149
+ if (mustExist && !loc.exists) return `${pointer} does not exist`
150
+ if (Array.isArray(loc.parent)) {
151
+ if (mustExist) loc.parent[loc.key as number] = value
152
+ else loc.parent.splice(loc.key as number, 0, value)
153
+ } else {
154
+ loc.parent![loc.key as string] = value
155
+ }
156
+ return null
157
+ }
158
+
159
+ const removeAt = (pointer: string): { ok: true; removed: unknown } | { ok: false; error: string } => {
160
+ const tokens = parsePointer(pointer)!
161
+ if (tokens.length === 0) return { ok: false, error: "cannot remove the whole document" }
162
+ const loc = locate(result, tokens, false)
163
+ if (typeof loc === "string") return { ok: false, error: `${pointer}: ${loc}` }
164
+ if (!loc.exists) return { ok: false, error: `${pointer} does not exist` }
165
+ if (Array.isArray(loc.parent)) loc.parent.splice(loc.key as number, 1)
166
+ else delete loc.parent![loc.key as string]
167
+ return { ok: true, removed: loc.value }
168
+ }
169
+
170
+ for (let i = 0; i < ops.length; i++) {
171
+ const op = ops[i]!
172
+ const path = op.path as string
173
+ let error: string | null = null
174
+
175
+ switch (op.op as OpName) {
176
+ case "add":
177
+ error = setAt(path, clone(op.value), false)
178
+ break
179
+ case "replace":
180
+ error = setAt(path, clone(op.value), true)
181
+ break
182
+ case "remove": {
183
+ const r = removeAt(path)
184
+ if (!r.ok) error = r.error
185
+ break
186
+ }
187
+ case "move": {
188
+ const from = op.from as string
189
+ if (path.startsWith(`${from}/`)) {
190
+ error = `cannot move ${from} into its own child ${path}`
191
+ break
192
+ }
193
+ const r = removeAt(from)
194
+ if (!r.ok) { error = r.error; break }
195
+ error = setAt(path, r.removed, false)
196
+ break
197
+ }
198
+ case "copy": {
199
+ const r = getAt(op.from as string)
200
+ if (!r.ok) { error = r.error; break }
201
+ error = setAt(path, clone(r.value), false)
202
+ break
203
+ }
204
+ case "test": {
205
+ const r = getAt(path)
206
+ if (!r.ok) error = r.error
207
+ else if (!deepEqual(r.value, op.value)) error = `test failed at ${path}`
208
+ break
209
+ }
210
+ }
211
+
212
+ if (error !== null) return { ok: false, error, opIndex: i }
213
+ }
214
+ return { ok: true, result }
215
+ }
216
+
217
+ /** RFC 6901 token escaping, for building diagnostic pointers. */
218
+ export function escapePointerToken(token: string): string {
219
+ return token.replace(/~/g, "~0").replace(/\//g, "~1")
220
+ }
@@ -0,0 +1,10 @@
1
+ // ag-ui-validate/report: pure reporters over a core Report. No I/O — callers
2
+ // decide where the strings go.
3
+ export { formatDiagnosticLine, formatReportSummary } from "./pretty.js"
4
+ export type { PrettyOptions } from "./pretty.js"
5
+ export { toJsonReport } from "./json.js"
6
+ export type { JsonReportDocument, JsonReportOptions } from "./json.js"
7
+ export { toSarif } from "./sarif.js"
8
+ export type { SarifLevel, SarifLog, SarifOptions, SarifResult } from "./sarif.js"
9
+ export { toJUnit } from "./junit.js"
10
+ export type { JUnitOptions } from "./junit.js"
@@ -0,0 +1,20 @@
1
+ // Machine-readable report: the core Report plus tool identification, so a
2
+ // stored document is self-describing.
3
+ import type { Report } from "../types.js"
4
+
5
+ export interface JsonReportOptions {
6
+ tool: { name: string; version: string }
7
+ /** What was validated: URL, file path, or "stdin". */
8
+ target?: string
9
+ }
10
+
11
+ export interface JsonReportDocument extends Report {
12
+ tool: { name: string; version: string }
13
+ target?: string
14
+ }
15
+
16
+ export function toJsonReport(report: Report, opts: JsonReportOptions): JsonReportDocument {
17
+ const doc: JsonReportDocument = { tool: opts.tool, ...report }
18
+ if (opts.target !== undefined) doc.target = opts.target
19
+ return doc
20
+ }
@@ -0,0 +1,56 @@
1
+ // JUnit XML output for CI systems. One testcase per finding: errors are
2
+ // <failure>, warnings and info are <skipped> (visible but not build-breaking —
3
+ // the exit code, not the XML, decides pass/fail). A clean report is a single
4
+ // passing testcase so the suite is never empty.
5
+ import type { Report } from "../types.js"
6
+
7
+ export interface JUnitOptions {
8
+ /** Suite name — typically the validated target. */
9
+ name: string
10
+ }
11
+
12
+ function esc(s: string): string {
13
+ return s
14
+ .replace(/&/g, "&amp;")
15
+ .replace(/</g, "&lt;")
16
+ .replace(/>/g, "&gt;")
17
+ .replace(/"/g, "&quot;")
18
+ .replace(/'/g, "&apos;")
19
+ }
20
+
21
+ export function toJUnit(report: Report, opts: JUnitOptions): string {
22
+ const lines: string[] = ['<?xml version="1.0" encoding="UTF-8"?>']
23
+ const suite = esc(opts.name)
24
+
25
+ if (report.diagnostics.length === 0) {
26
+ lines.push('<testsuites name="ag-ui-validate" tests="1" failures="0" errors="0" skipped="0">')
27
+ lines.push(` <testsuite name="${suite}" tests="1" failures="0" errors="0" skipped="0">`)
28
+ lines.push(
29
+ ` <testcase name="AG-UI conformance: no violations across ${report.eventCount} events" classname="${suite}"/>`,
30
+ )
31
+ lines.push(" </testsuite>")
32
+ lines.push("</testsuites>")
33
+ return `${lines.join("\n")}\n`
34
+ }
35
+
36
+ const failures = report.diagnostics.filter((d) => d.severity === "error").length
37
+ const skipped = report.diagnostics.length - failures
38
+ const counts = `tests="${report.diagnostics.length}" failures="${failures}" errors="0" skipped="${skipped}"`
39
+ lines.push(`<testsuites name="ag-ui-validate" ${counts}>`)
40
+ lines.push(` <testsuite name="${suite}" ${counts}>`)
41
+ for (const d of report.diagnostics) {
42
+ const where = d.eventIndex >= 0 ? `event ${d.eventIndex}` : "stream"
43
+ const name = esc(`${d.rule} (${where})`)
44
+ lines.push(` <testcase name="${name}" classname="${suite}">`)
45
+ const body = `${esc(d.message)}\n${esc(d.specUrl)}`
46
+ if (d.severity === "error") {
47
+ lines.push(` <failure message="${esc(d.message)}">${body}</failure>`)
48
+ } else {
49
+ lines.push(` <skipped message="${esc(`${d.severity}: ${d.message}`)}">${body}</skipped>`)
50
+ }
51
+ lines.push(" </testcase>")
52
+ }
53
+ lines.push(" </testsuite>")
54
+ lines.push("</testsuites>")
55
+ return `${lines.join("\n")}\n`
56
+ }
@@ -0,0 +1,59 @@
1
+ // Human-readable output. Pure string formatting — the CLI decides where it
2
+ // goes and whether a TTY wants color.
3
+ import type { Diagnostic, Report, Severity } from "../types.js"
4
+
5
+ export interface PrettyOptions {
6
+ color: boolean
7
+ }
8
+
9
+ const SYMBOL: Record<Severity, string> = { error: "✖", warning: "⚠", info: "ℹ" }
10
+ const SGR: Record<Severity, string> = { error: "31", warning: "33", info: "36" }
11
+
12
+ function paint(code: string, s: string, on: boolean): string {
13
+ return on ? `\x1b[${code}m${s}\x1b[0m` : s
14
+ }
15
+
16
+ export function formatDiagnosticLine(d: Diagnostic, opts: PrettyOptions): string {
17
+ const where = d.eventIndex >= 0 ? `event ${d.eventIndex}` : "—"
18
+ const head = paint(SGR[d.severity], `${SYMBOL[d.severity]} ${d.rule}`, opts.color)
19
+ const meta = paint("2", `${d.severity.padEnd(7)} ${where.padEnd(10)}`, opts.color)
20
+ const cite = paint("2", ` ↳ ${d.specUrl}`, opts.color)
21
+ return `${head} ${meta} ${d.message}\n${cite}`
22
+ }
23
+
24
+ function count(n: number, noun: string): string {
25
+ return `${n} ${noun}${n === 1 ? "" : "s"}`
26
+ }
27
+
28
+ export function formatReportSummary(report: Report, opts: PrettyOptions): string {
29
+ const { errors, warnings, info } = report.summary
30
+ const lines: string[] = []
31
+
32
+ if (errors + warnings + info === 0) {
33
+ lines.push(
34
+ paint("32", `✔ no conformance violations across ${count(report.eventCount, "event")}`, opts.color),
35
+ )
36
+ } else {
37
+ lines.push(
38
+ `${count(errors, "error")}, ${count(warnings, "warning")}, ${info} info across ${count(report.eventCount, "event")}`,
39
+ )
40
+ }
41
+
42
+ const features = Object.entries(report.features)
43
+ const exercised = features.filter(([, s]) => s === "exercised").map(([f]) => f)
44
+ const suffix = exercised.length > 0 ? `: ${exercised.join(", ")}` : ""
45
+ lines.push(`${exercised.length} of ${features.length} AG-UI features exercised${suffix}`)
46
+
47
+ if (report.skipped.length > 0) {
48
+ lines.push(`${count(report.skipped.length, "rule")} not evaluated:`)
49
+ for (const s of report.skipped) {
50
+ lines.push(paint("2", ` – ${s.rule}: ${s.reason}`, opts.color))
51
+ }
52
+ }
53
+
54
+ for (const e of report.internalErrors) {
55
+ lines.push(paint("31", `! internal validator error: ${e}`, opts.color))
56
+ }
57
+
58
+ return lines.join("\n")
59
+ }