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
package/src/cli.ts ADDED
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ // The ag-ui-validate executable — the one deliberately Node-only file in src/.
3
+ // Everything it does is delegated: parsing to cli-args.ts, validation to the
4
+ // core + transport layers, output to the pure reporters. This file only wires
5
+ // process.argv/stdin/stdout/exit codes to those pieces.
6
+ import { createReadStream, readFileSync, writeFileSync } from "node:fs"
7
+ import process from "node:process"
8
+ import { decideExitCode, parseCliArgs, USAGE } from "./cli-args.js"
9
+ import type { CliConfig } from "./cli-args.js"
10
+ import { formatDiagnosticLine, formatReportSummary, toJsonReport, toJUnit, toSarif } from "./report/index.js"
11
+ import { TransportError, validateBody, validateEndpoint } from "./transport/index.js"
12
+ import type { TransportOptions, TransportResult } from "./transport/index.js"
13
+ import type { Diagnostic, ValidatorOptions } from "./types.js"
14
+
15
+ const TOOL_NAME = "ag-ui-validate"
16
+
17
+ function toolVersion(): string {
18
+ try {
19
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
20
+ version?: string
21
+ }
22
+ return pkg.version ?? "0.0.0"
23
+ } catch {
24
+ return "0.0.0"
25
+ }
26
+ }
27
+
28
+ function transportOptions(
29
+ config: CliConfig,
30
+ onDiagnostic: ((d: Diagnostic) => void) | undefined,
31
+ ): TransportOptions {
32
+ const validator: ValidatorOptions = {}
33
+ if (config.features !== undefined) validator.features = config.features
34
+ if (Object.keys(config.severityOverrides).length > 0) {
35
+ validator.severityOverrides = config.severityOverrides
36
+ }
37
+ const opts: TransportOptions = { validator }
38
+ if (onDiagnostic !== undefined) opts.onDiagnostic = onDiagnostic
39
+ return opts
40
+ }
41
+
42
+ async function main(): Promise<number> {
43
+ const parsed = parseCliArgs(process.argv.slice(2))
44
+ if (!parsed.ok) {
45
+ process.stderr.write(`error: ${parsed.error}\n\n${USAGE}`)
46
+ return 2
47
+ }
48
+ const config = parsed.config
49
+ if (config.help) {
50
+ process.stdout.write(USAGE)
51
+ return 0
52
+ }
53
+ const version = toolVersion()
54
+ if (config.version) {
55
+ process.stdout.write(`${TOOL_NAME} ${version}\n`)
56
+ return 0
57
+ }
58
+
59
+ const color =
60
+ config.color ?? (process.stdout.isTTY === true && process.env.NO_COLOR === undefined)
61
+ const pretty = config.format === "pretty"
62
+
63
+ // Pretty mode streams findings as they are detected; machine formats emit
64
+ // one document at the end, so nothing else may touch stdout before it.
65
+ let printed = 0
66
+ const onDiagnostic = pretty
67
+ ? (d: Diagnostic): void => {
68
+ printed += 1
69
+ process.stdout.write(`${formatDiagnosticLine(d, { color })}\n`)
70
+ }
71
+ : undefined
72
+
73
+ const isUrl = /^https?:\/\//i.test(config.target)
74
+ let result: TransportResult
75
+ let targetLabel: string
76
+ if (isUrl) {
77
+ targetLabel = config.target
78
+ const opts = transportOptions(config, onDiagnostic)
79
+ if (Object.keys(config.headers).length > 0) opts.headers = config.headers
80
+ if (config.timeoutMs !== undefined) opts.timeoutMs = config.timeoutMs
81
+ result = await validateEndpoint(config.target, opts)
82
+ } else if (config.target === "-") {
83
+ targetLabel = "stdin"
84
+ if (process.stdin.isTTY === true) {
85
+ process.stderr.write("error: stdin is a terminal — pipe a recording in, or pass a file path\n")
86
+ return 2
87
+ }
88
+ result = await validateBody(process.stdin, null, {
89
+ ...transportOptions(config, onDiagnostic),
90
+ recorded: true,
91
+ })
92
+ } else {
93
+ targetLabel = config.target
94
+ result = await validateBody(createReadStream(config.target), null, {
95
+ ...transportOptions(config, onDiagnostic),
96
+ recorded: true,
97
+ })
98
+ }
99
+
100
+ const report = result.report
101
+ // Line-based SARIF locations only make sense when events map 1:1 to lines.
102
+ const lineOriented = !isUrl && config.target !== "-" && /\.(jsonl|ndjson)$/i.test(config.target)
103
+ const sarifOptions = lineOriented
104
+ ? { toolVersion: version, artifactUri: config.target }
105
+ : { toolVersion: version }
106
+
107
+ if (config.jsonFile !== undefined) {
108
+ const doc = toJsonReport(report, { tool: { name: TOOL_NAME, version }, target: targetLabel })
109
+ writeFileSync(config.jsonFile, `${JSON.stringify(doc, null, 2)}\n`)
110
+ }
111
+ if (config.sarifFile !== undefined) {
112
+ writeFileSync(config.sarifFile, `${JSON.stringify(toSarif(report, sarifOptions), null, 2)}\n`)
113
+ }
114
+ if (config.junitFile !== undefined) {
115
+ writeFileSync(config.junitFile, toJUnit(report, { name: targetLabel }))
116
+ }
117
+
118
+ if (pretty) {
119
+ if (printed > 0) process.stdout.write("\n")
120
+ process.stdout.write(`${formatReportSummary(report, { color })}\n`)
121
+ } else if (config.format === "json") {
122
+ const doc = toJsonReport(report, { tool: { name: TOOL_NAME, version }, target: targetLabel })
123
+ process.stdout.write(`${JSON.stringify(doc, null, 2)}\n`)
124
+ } else if (config.format === "sarif") {
125
+ process.stdout.write(`${JSON.stringify(toSarif(report, sarifOptions), null, 2)}\n`)
126
+ } else {
127
+ process.stdout.write(toJUnit(report, { name: targetLabel }))
128
+ }
129
+
130
+ return decideExitCode(report.summary, config.maxWarnings)
131
+ }
132
+
133
+ main().then(
134
+ (code) => {
135
+ process.exitCode = code
136
+ },
137
+ (e: unknown) => {
138
+ const message =
139
+ e instanceof TransportError
140
+ ? e.message
141
+ : e instanceof Error
142
+ ? (e.stack ?? e.message)
143
+ : String(e)
144
+ process.stderr.write(`error: ${message}\n`)
145
+ process.exitCode = 2
146
+ },
147
+ )
package/src/index.ts ADDED
@@ -0,0 +1,465 @@
1
+ // ag-ui-validate core: a pure function over an AG-UI event sequence.
2
+ // Zero I/O, zero runtime dependencies, isomorphic. Transports, the CLI, and
3
+ // the Vitest matcher are thin wrappers over this module.
4
+ //
5
+ // Two invariants hold everywhere here:
6
+ // - the validator NEVER throws on input: broken input is its input;
7
+ // - every diagnostic cites the spec section that governs it (specUrl).
8
+
9
+ import { DRAFT_EVENT_TYPES, EVENT_TABLE, EVENT_TYPES, SDK_VERSION } from "./protocol/event-table.js"
10
+ import type { EventSpec } from "./protocol/event-table.js"
11
+ import { RULES, formatMessage } from "./rules/catalog.js"
12
+ import type { CheckApi, EmitFn, RunState, StreamState } from "./rules/checks/context.js"
13
+ import { newRunState, str } from "./rules/checks/context.js"
14
+ import { checkRunIdStability, endOfRunSteps, handleStepEvent } from "./rules/checks/lifecycle.js"
15
+ import { closeReasoningChunk, endOfRunReasoning, handleReasoningEvent } from "./rules/checks/reasoning.js"
16
+ import { handleStateEvent } from "./rules/checks/state.js"
17
+ import { closeTextChunk, endOfRunText, handleTextEvent } from "./rules/checks/text.js"
18
+ import { closeToolChunk, endOfRunToolCalls, handleToolCallEvent } from "./rules/checks/toolcalls.js"
19
+ import { TRANSPORT_RULE_IDS, TRANSPORT_SKIP_REASON } from "./rules/checks/transport.js"
20
+ import type {
21
+ CanonicalFeature,
22
+ Diagnostic,
23
+ FeatureMatrix,
24
+ Report,
25
+ Severity,
26
+ Validator,
27
+ ValidatorOptions,
28
+ } from "./types.js"
29
+ import { CANONICAL_FEATURES } from "./types.js"
30
+
31
+ export * from "./types.js"
32
+ export { CATALOG, RULES, formatMessage, validateCatalog } from "./rules/catalog.js"
33
+ export type { Catalog, RuleDefinition, Severity as RuleSeverity, SeverityOrOff } from "./rules/catalog.js"
34
+ export { EVENT_TABLE, EVENT_TYPES, SDK_VERSION } from "./protocol/event-table.js"
35
+ export type { EventCategory, EventSpec, FieldKind, FieldSpec } from "./protocol/event-table.js"
36
+ export { applyPatch, validatePatchShape } from "./protocol/jsonpatch.js"
37
+
38
+ const DRAFTS_META_URL = "https://docs.ag-ui.com/drafts/meta-events"
39
+
40
+ // Features whose exercise cannot be distinguished on a passive stream: both
41
+ // generative-UI features need knowledge of the frontend's tool/component
42
+ // registry, which is out-of-band (see SQ-13).
43
+ const NOT_INFERABLE: readonly CanonicalFeature[] = [
44
+ "agentic-generative-ui",
45
+ "tool-based-generative-ui",
46
+ ]
47
+
48
+ /** "runStarted" → "RUN_STARTED": case-insensitive match against wire types. */
49
+ const CANONICAL_BY_SQUASHED: ReadonlyMap<string, string> = new Map(
50
+ EVENT_TYPES.map((t) => [t.replace(/_/g, "").toLowerCase(), t]),
51
+ )
52
+
53
+ function describeValue(v: unknown): string {
54
+ if (v === null) return "null"
55
+ if (Array.isArray(v)) return "array"
56
+ return typeof v
57
+ }
58
+
59
+ export function createValidator(opts: ValidatorOptions = {}): Validator {
60
+ const overrides = opts.severityOverrides ?? {}
61
+ const declaredFeatures = new Set(opts.features ?? [])
62
+ const layers = new Set(["core", ...(opts.layers ?? [])])
63
+
64
+ const diagnostics: Diagnostic[] = []
65
+ const internalErrors: string[] = []
66
+ const explicitSkips = new Map<string, string>()
67
+ const stream: StreamState = {
68
+ eventCount: 0,
69
+ sawTimestamp: false,
70
+ anySnapshot: false,
71
+ agui001Fired: false,
72
+ features: new Set(),
73
+ }
74
+ let run: RunState | null = null
75
+ let finalized = false
76
+
77
+ function mkEmit(batch: Diagnostic[], current: { index: number; type: string } | null): EmitFn {
78
+ return (ruleId, params, extra = {}) => {
79
+ const rule = RULES.get(ruleId)
80
+ if (rule === undefined) {
81
+ internalErrors.push(`emit() for unknown rule ${ruleId}`)
82
+ return
83
+ }
84
+ const override = overrides[ruleId]
85
+ if (override === "off") return
86
+ const severity: Severity = override ?? extra.severity ?? rule.severity
87
+ const aboutCurrentEvent = extra.eventIndex === undefined && current !== null
88
+ const diag: Diagnostic = {
89
+ rule: ruleId,
90
+ severity,
91
+ message: formatMessage(rule, params) + (extra.messageSuffix ?? ""),
92
+ eventIndex: extra.eventIndex ?? current?.index ?? -1,
93
+ specUrl: extra.specUrl ?? rule.specUrl,
94
+ }
95
+ if (aboutCurrentEvent) diag.eventType = current.type
96
+ if (extra.pointer !== undefined) diag.pointer = extra.pointer
97
+ if (extra.relatedEventIndex !== undefined) diag.relatedEventIndex = extra.relatedEventIndex
98
+ diagnostics.push(diag)
99
+ batch.push(diag)
100
+ }
101
+ }
102
+
103
+ function validateSchema(type: string, spec: EventSpec, ev: Record<string, unknown>, emit: EmitFn): void {
104
+ for (const [field, fs] of Object.entries(spec.fields)) {
105
+ const v = ev[field]
106
+ if (v === undefined) {
107
+ if (fs.required) {
108
+ emit("AGUI504", { type, detail: `missing required field '${field}' (${fs.kind})` }, {
109
+ pointer: `/${field}`,
110
+ })
111
+ }
112
+ continue
113
+ }
114
+ let kindOk = true
115
+ switch (fs.kind) {
116
+ case "string":
117
+ case "number":
118
+ case "boolean":
119
+ kindOk = typeof v === fs.kind
120
+ break
121
+ case "array":
122
+ kindOk = Array.isArray(v)
123
+ break
124
+ case "object":
125
+ kindOk = typeof v === "object" && v !== null && !Array.isArray(v)
126
+ break
127
+ case "any":
128
+ break
129
+ }
130
+ if (!kindOk) {
131
+ emit("AGUI504", { type, detail: `field '${field}' must be ${fs.kind === "array" ? "an" : "a"} ${fs.kind}, got ${describeValue(v)}` }, {
132
+ pointer: `/${field}`,
133
+ })
134
+ continue
135
+ }
136
+ if (fs.enum !== undefined && typeof v === "string" && !fs.enum.includes(v)) {
137
+ emit("AGUI504", { type, detail: `field '${field}' must be one of ${fs.enum.join("|")}, got '${v}'` }, {
138
+ pointer: `/${field}`,
139
+ })
140
+ }
141
+ }
142
+ }
143
+
144
+ /** Chunk streams close implicitly on any event of a different type. */
145
+ function closeChunks(r: RunState, emit: EmitFn, atIndex: number, except?: string): void {
146
+ if (except !== "TEXT_MESSAGE_CHUNK") closeTextChunk(r, atIndex)
147
+ if (except !== "TOOL_CALL_CHUNK") closeToolChunk(r, emit, atIndex)
148
+ if (except !== "REASONING_MESSAGE_CHUNK") closeReasoningChunk(r)
149
+ }
150
+
151
+ /** Unterminated-at-run-end rules. Only on a *clean* end (RUN_FINISHED or a
152
+ * stream that just stops): after RUN_ERROR, open streams are expected
153
+ * debris of the failure, and flagging them would manufacture noise. */
154
+ function endOfRunChecks(r: RunState, emit: EmitFn, atIndex: number): void {
155
+ endOfRunText(r, emit, atIndex)
156
+ endOfRunToolCalls(r, emit, atIndex)
157
+ endOfRunSteps(r, emit, atIndex)
158
+ endOfRunReasoning(r, emit, atIndex)
159
+ }
160
+
161
+ /** Opens the implicit run scope for streams that never announced one. */
162
+ function ensureRun(index: number, type: string, emit: EmitFn): RunState {
163
+ if (run === null) {
164
+ if (!stream.agui001Fired) {
165
+ emit("AGUI001", { type }, {})
166
+ stream.agui001Fired = true
167
+ }
168
+ run = newRunState({ runId: null, threadId: null, startIndex: index, implicit: true })
169
+ }
170
+ return run
171
+ }
172
+
173
+ function processEvent(input: unknown, batch: Diagnostic[]): void {
174
+ const index = stream.eventCount
175
+ stream.eventCount += 1
176
+
177
+ // 1. Parse. Malformed input is a diagnostic, never an exception.
178
+ let parsed: unknown = input
179
+ if (typeof input === "string") {
180
+ try {
181
+ parsed = JSON.parse(input)
182
+ } catch (e) {
183
+ mkEmit(batch, { index, type: "" })("AGUI502", {
184
+ error: e instanceof Error ? e.message : String(e),
185
+ })
186
+ return
187
+ }
188
+ }
189
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
190
+ mkEmit(batch, { index, type: "" })("AGUI502", {
191
+ error: `payload is ${describeValue(parsed)}, expected a JSON object`,
192
+ })
193
+ return
194
+ }
195
+ const ev = parsed as Record<string, unknown>
196
+
197
+ // 2. The declared type is the key to everything else.
198
+ if (typeof ev.type !== "string") {
199
+ mkEmit(batch, { index, type: "" })("AGUI504", {
200
+ type: "(untyped)",
201
+ detail: "event has no string 'type' property",
202
+ }, { pointer: "/type" })
203
+ return
204
+ }
205
+ const type = ev.type
206
+ const emit = mkEmit(batch, { index, type })
207
+
208
+ // 3. Base-event fields.
209
+ if ("timestamp" in ev && ev.timestamp !== undefined) {
210
+ if (typeof ev.timestamp === "number") stream.sawTimestamp = true
211
+ else emit("AGUI504", { type, detail: `field 'timestamp' must be a number, got ${describeValue(ev.timestamp)}` }, { pointer: "/timestamp" })
212
+ }
213
+
214
+ // 4. Unknown types: AGUI503 (documented drafts at info; casing hints for
215
+ // near-misses — SQ-7).
216
+ const spec = EVENT_TABLE[type]
217
+ if (spec === undefined) {
218
+ if (DRAFT_EVENT_TYPES.includes(type)) {
219
+ emit("AGUI503", { type, sdkVersion: SDK_VERSION }, {
220
+ severity: "info",
221
+ specUrl: DRAFTS_META_URL,
222
+ messageSuffix: " — documented draft event type, not yet in @ag-ui/core",
223
+ })
224
+ } else {
225
+ const canonical = CANONICAL_BY_SQUASHED.get(type.replace(/[_\-\s]/g, "").toLowerCase())
226
+ emit("AGUI503", { type, sdkVersion: SDK_VERSION }, {
227
+ ...(canonical !== undefined
228
+ ? { messageSuffix: ` — did you mean '${canonical}'? AG-UI wire types use SCREAMING_SNAKE_CASE` }
229
+ : {}),
230
+ })
231
+ }
232
+ return
233
+ }
234
+
235
+ // 5. Schema validation for the declared type (AGUI504).
236
+ validateSchema(type, spec, ev, emit)
237
+
238
+ // 6. Lifecycle position.
239
+ if (type === "RUN_STARTED") {
240
+ if (run === null) {
241
+ run = newRunState({
242
+ runId: str(ev, "runId") ?? null,
243
+ threadId: str(ev, "threadId") ?? null,
244
+ startIndex: index,
245
+ implicit: false,
246
+ })
247
+ } else if (run.terminal !== null) {
248
+ // A new run after a clean terminal: multi-run streams are legal
249
+ // (serialized logs branch via parentRunId — SQ-6). Fresh scope.
250
+ run = newRunState({
251
+ runId: str(ev, "runId") ?? null,
252
+ threadId: str(ev, "threadId") ?? null,
253
+ startIndex: index,
254
+ implicit: false,
255
+ })
256
+ } else if (run.implicit) {
257
+ // The stream opened without RUN_STARTED (AGUI001 already fired); the
258
+ // late start legitimizes the implicit scope rather than double-firing.
259
+ closeChunks(run, emit, index)
260
+ run.implicit = false
261
+ run.runId = str(ev, "runId") ?? null
262
+ run.threadId = str(ev, "threadId") ?? null
263
+ } else {
264
+ closeChunks(run, emit, index)
265
+ emit("AGUI002", { runId: str(ev, "runId") ?? "(missing)" }, {
266
+ relatedEventIndex: run.startIndex,
267
+ })
268
+ }
269
+ return
270
+ }
271
+
272
+ const r = ensureRun(index, type, emit)
273
+
274
+ // 7. Nothing may follow a terminal event (AGUI004/AGUI005).
275
+ if (r.terminal !== null) {
276
+ const terminalType = r.terminal.type
277
+ if ((type === "RUN_FINISHED" || type === "RUN_ERROR") && type !== terminalType) {
278
+ emit("AGUI005", { type, terminalType }, { relatedEventIndex: r.terminal.index })
279
+ } else {
280
+ emit("AGUI004", { type, terminalType }, { relatedEventIndex: r.terminal.index })
281
+ }
282
+ return
283
+ }
284
+
285
+ closeChunks(r, emit, index, type)
286
+
287
+ // 8. Terminal events.
288
+ if (type === "RUN_FINISHED" || type === "RUN_ERROR") {
289
+ if (type === "RUN_FINISHED") {
290
+ checkRunIdStability(api(index, type, ev, r, emit))
291
+ endOfRunChecks(r, emit, index)
292
+ const outcome = ev.outcome
293
+ if (typeof outcome === "object" && outcome !== null &&
294
+ (outcome as Record<string, unknown>).type === "interrupt") {
295
+ stream.features.add("human-in-the-loop")
296
+ }
297
+ }
298
+ r.terminal = { type, index }
299
+ return
300
+ }
301
+
302
+ // 9. Per-category checks.
303
+ const a = api(index, type, ev, r, emit)
304
+ switch (spec.category) {
305
+ case "lifecycle":
306
+ handleStepEvent(a) // STEP_STARTED / STEP_FINISHED
307
+ break
308
+ case "text":
309
+ handleTextEvent(a)
310
+ break
311
+ case "toolcall":
312
+ handleToolCallEvent(a)
313
+ break
314
+ case "state":
315
+ handleStateEvent(a)
316
+ break
317
+ case "reasoning":
318
+ handleReasoningEvent(a)
319
+ break
320
+ case "thinking":
321
+ // Deprecated but valid (SQ-8): schema-checked above, no ordering rules
322
+ // in the catalog yet — a "deprecated event used" rule is proposed
323
+ // upstream rather than invented here.
324
+ break
325
+ case "activity":
326
+ // No activity rules in the catalog yet.
327
+ break
328
+ case "special":
329
+ if (type === "RAW") {
330
+ const wrapped = ev.event
331
+ if (typeof wrapped === "object" && wrapped !== null) {
332
+ const wrappedType = (wrapped as Record<string, unknown>).type
333
+ if (typeof wrappedType === "string" && EVENT_TABLE[wrappedType] !== undefined) {
334
+ emit("AGUI901", { wrappedType }, { pointer: "/event/type" })
335
+ }
336
+ }
337
+ } else if (type === "CUSTOM") {
338
+ const name = str(ev, "name")
339
+ if (name !== undefined) {
340
+ if (name === "PredictState") stream.features.add("predictive-state-updates")
341
+ if (!/[.:/]/.test(name)) emit("AGUI903", { name }, { pointer: "/name" })
342
+ }
343
+ }
344
+ break
345
+ }
346
+ }
347
+
348
+ function api(
349
+ index: number,
350
+ type: string,
351
+ event: Record<string, unknown>,
352
+ r: RunState,
353
+ emit: EmitFn,
354
+ ): CheckApi {
355
+ return {
356
+ index,
357
+ type,
358
+ event,
359
+ run: r,
360
+ stream,
361
+ emit,
362
+ feature: (f) => stream.features.add(f),
363
+ }
364
+ }
365
+
366
+ return {
367
+ feed(event: unknown): Diagnostic[] {
368
+ const batch: Diagnostic[] = []
369
+ try {
370
+ processEvent(event, batch)
371
+ } catch (e) {
372
+ internalErrors.push(`feed(event ${stream.eventCount - 1}): ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`)
373
+ }
374
+ return batch
375
+ },
376
+
377
+ finalize(): Diagnostic[] {
378
+ if (finalized) return []
379
+ finalized = true
380
+ const batch: Diagnostic[] = []
381
+ const emit = mkEmit(batch, null)
382
+ try {
383
+ if (run !== null && run.terminal === null) {
384
+ closeChunks(run, emit, -1)
385
+ emit("AGUI003", { runId: run.runId ?? "(unknown)" }, {
386
+ eventIndex: -1,
387
+ relatedEventIndex: run.startIndex,
388
+ })
389
+ endOfRunChecks(run, emit, -1)
390
+ }
391
+ if (stream.eventCount > 0 && !stream.sawTimestamp) {
392
+ emit("AGUI902", { eventCount: stream.eventCount }, { eventIndex: -1 })
393
+ }
394
+ if (declaredFeatures.has("shared-state") && !stream.anySnapshot) {
395
+ emit("AGUI305", {}, { eventIndex: -1 })
396
+ }
397
+ } catch (e) {
398
+ internalErrors.push(`finalize(): ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`)
399
+ }
400
+ return batch
401
+ },
402
+
403
+ emitExternal(rule, params = {}, extra = {}): Diagnostic | null {
404
+ const batch: Diagnostic[] = []
405
+ try {
406
+ mkEmit(batch, null)(rule, params, extra)
407
+ } catch (e) {
408
+ internalErrors.push(`emitExternal(${rule}): ${e instanceof Error ? e.message : String(e)}`)
409
+ }
410
+ return batch[0] ?? null
411
+ },
412
+
413
+ markSkipped(rule, reason): void {
414
+ explicitSkips.set(String(rule), String(reason))
415
+ },
416
+
417
+ report(): Report {
418
+ const summary = { errors: 0, warnings: 0, info: 0 }
419
+ for (const d of diagnostics) {
420
+ if (d.severity === "error") summary.errors += 1
421
+ else if (d.severity === "warning") summary.warnings += 1
422
+ else summary.info += 1
423
+ }
424
+
425
+ const features = {} as FeatureMatrix
426
+ for (const f of CANONICAL_FEATURES) {
427
+ features[f] = NOT_INFERABLE.includes(f)
428
+ ? "not-inferable"
429
+ : stream.features.has(f)
430
+ ? "exercised"
431
+ : "not-exercised"
432
+ }
433
+
434
+ let skipped = layers.has("transport")
435
+ ? []
436
+ : TRANSPORT_RULE_IDS
437
+ .filter((id) => overrides[id] !== "off")
438
+ .map((rule) => ({ rule, reason: TRANSPORT_SKIP_REASON }))
439
+ if (!declaredFeatures.has("shared-state") && overrides.AGUI305 !== "off") {
440
+ skipped.push({
441
+ rule: "AGUI305",
442
+ reason: "only evaluated when the 'shared-state' feature is declared via options.features",
443
+ })
444
+ }
445
+ for (const [rule, severity] of Object.entries(overrides)) {
446
+ if (severity === "off" && RULES.has(rule)) {
447
+ skipped.push({ rule, reason: "disabled by severityOverrides" })
448
+ }
449
+ }
450
+ if (explicitSkips.size > 0) {
451
+ skipped = skipped.filter((s) => !explicitSkips.has(s.rule))
452
+ for (const [rule, reason] of explicitSkips) skipped.push({ rule, reason })
453
+ }
454
+
455
+ return {
456
+ diagnostics: [...diagnostics],
457
+ summary,
458
+ features,
459
+ skipped,
460
+ eventCount: stream.eventCount,
461
+ internalErrors: [...internalErrors],
462
+ }
463
+ },
464
+ }
465
+ }