ag-ui-validate 0.1.0 → 0.2.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.
package/src/cli-args.ts CHANGED
@@ -20,6 +20,8 @@ export interface CliConfig {
20
20
  sarifFile?: string
21
21
  junitFile?: string
22
22
  jsonFile?: string
23
+ /** Pretty output only: one line per rule with a count, not one per finding. */
24
+ group: boolean
23
25
  help: boolean
24
26
  version: boolean
25
27
  }
@@ -40,6 +42,7 @@ Output (default: human-readable):
40
42
  --json-file <path> additionally write the JSON report to a file
41
43
  --sarif-file <path> additionally write a SARIF log to a file
42
44
  --junit-file <path> additionally write JUnit XML to a file
45
+ --group one line per rule with a count (large streams)
43
46
  --no-color disable ANSI colors
44
47
 
45
48
  Rules:
@@ -67,6 +70,7 @@ const FLAGS: Record<string, Flag> = {
67
70
  "--help": { takesValue: false, apply: (c) => ((c.help = true), null) },
68
71
  "--version": { takesValue: false, apply: (c) => ((c.version = true), null) },
69
72
  "--no-color": { takesValue: false, apply: (c) => ((c.color = false), null) },
73
+ "--group": { takesValue: false, apply: (c) => ((c.group = true), null) },
70
74
  "--json": { takesValue: false, apply: (c) => setFormat(c, "json") },
71
75
  "--sarif": { takesValue: false, apply: (c) => setFormat(c, "sarif") },
72
76
  "--junit": { takesValue: false, apply: (c) => setFormat(c, "junit") },
@@ -150,6 +154,7 @@ export function parseCliArgs(argv: string[]): ParseResult {
150
154
  color: null,
151
155
  headers: {},
152
156
  severityOverrides: {},
157
+ group: false,
153
158
  help: false,
154
159
  version: false,
155
160
  }
@@ -182,6 +187,12 @@ export function parseCliArgs(argv: string[]): ParseResult {
182
187
  if (error !== null) return { ok: false, error }
183
188
  }
184
189
 
190
+ if (config.group && config.format !== "pretty") {
191
+ return {
192
+ ok: false,
193
+ error: "--group only applies to the default pretty output, not --json/--sarif/--junit",
194
+ }
195
+ }
185
196
  if (targets.length > 1) {
186
197
  return { ok: false, error: `expected exactly one target, got ${targets.length}` }
187
198
  }
package/src/cli.ts CHANGED
@@ -7,7 +7,7 @@ import { createReadStream, readFileSync, writeFileSync } from "node:fs"
7
7
  import process from "node:process"
8
8
  import { decideExitCode, parseCliArgs, USAGE } from "./cli-args.js"
9
9
  import type { CliConfig } from "./cli-args.js"
10
- import { formatDiagnosticLine, formatReportSummary, toJsonReport, toJUnit, toSarif } from "./report/index.js"
10
+ import { formatDiagnosticLine, formatGroupedDiagnostics, formatReportSummary, toJsonReport, toJUnit, toSarif } from "./report/index.js"
11
11
  import { TransportError, validateBody, validateEndpoint } from "./transport/index.js"
12
12
  import type { TransportOptions, TransportResult } from "./transport/index.js"
13
13
  import type { Diagnostic, ValidatorOptions } from "./types.js"
@@ -62,8 +62,9 @@ async function main(): Promise<number> {
62
62
 
63
63
  // Pretty mode streams findings as they are detected; machine formats emit
64
64
  // one document at the end, so nothing else may touch stdout before it.
65
+ // --group defers everything to one collapsed block after the run.
65
66
  let printed = 0
66
- const onDiagnostic = pretty
67
+ const onDiagnostic = pretty && !config.group
67
68
  ? (d: Diagnostic): void => {
68
69
  printed += 1
69
70
  process.stdout.write(`${formatDiagnosticLine(d, { color })}\n`)
@@ -116,7 +117,11 @@ async function main(): Promise<number> {
116
117
  }
117
118
 
118
119
  if (pretty) {
119
- if (printed > 0) process.stdout.write("\n")
120
+ if (config.group && report.diagnostics.length > 0) {
121
+ process.stdout.write(`${formatGroupedDiagnostics(report.diagnostics, { color })}\n\n`)
122
+ } else if (printed > 0) {
123
+ process.stdout.write("\n")
124
+ }
120
125
  process.stdout.write(`${formatReportSummary(report, { color })}\n`)
121
126
  } else if (config.format === "json") {
122
127
  const doc = toJsonReport(report, { tool: { name: TOOL_NAME, version }, target: targetLabel })
@@ -1,6 +1,6 @@
1
1
  // ag-ui-validate/report: pure reporters over a core Report. No I/O — callers
2
2
  // decide where the strings go.
3
- export { formatDiagnosticLine, formatReportSummary } from "./pretty.js"
3
+ export { formatDiagnosticLine, formatGroupedDiagnostics, formatReportSummary } from "./pretty.js"
4
4
  export type { PrettyOptions } from "./pretty.js"
5
5
  export { toJsonReport } from "./json.js"
6
6
  export type { JsonReportDocument, JsonReportOptions } from "./json.js"
@@ -1,5 +1,6 @@
1
1
  // Human-readable output. Pure string formatting — the CLI decides where it
2
2
  // goes and whether a TTY wants color.
3
+ import { RULES } from "../rules/catalog.js"
3
4
  import type { Diagnostic, Report, Severity } from "../types.js"
4
5
 
5
6
  export interface PrettyOptions {
@@ -25,6 +26,48 @@ function count(n: number, noun: string): string {
25
26
  return `${n} ${noun}${n === 1 ? "" : "s"}`
26
27
  }
27
28
 
29
+ const SEVERITY_RANK: Record<Severity, number> = { error: 0, warning: 1, info: 2 }
30
+ const SAMPLE_INDEXES = 3
31
+
32
+ /**
33
+ * One line per rule instead of one per occurrence — for large streams where
34
+ * the same violation repeats. Totals stay honest in the summary; this only
35
+ * changes what is listed.
36
+ */
37
+ export function formatGroupedDiagnostics(diagnostics: Diagnostic[], opts: PrettyOptions): string {
38
+ const groups = new Map<string, Diagnostic[]>()
39
+ for (const d of diagnostics) {
40
+ const list = groups.get(d.rule)
41
+ if (list === undefined) groups.set(d.rule, [d])
42
+ else list.push(d)
43
+ }
44
+
45
+ const sorted = [...groups.values()].sort((a, b) => {
46
+ const bySeverity = SEVERITY_RANK[a[0]!.severity] - SEVERITY_RANK[b[0]!.severity]
47
+ return bySeverity !== 0 ? bySeverity : a[0]!.rule.localeCompare(b[0]!.rule)
48
+ })
49
+
50
+ const lines: string[] = []
51
+ for (const group of sorted) {
52
+ const first = group[0]!
53
+ const title = RULES.get(first.rule)?.title ?? first.message
54
+ const indexes = group.map((d) => d.eventIndex).filter((i) => i >= 0)
55
+ let where: string
56
+ if (indexes.length === 0) {
57
+ where = "stream-level"
58
+ } else {
59
+ const sample = indexes.slice(0, SAMPLE_INDEXES).join(", ")
60
+ const rest = indexes.length - SAMPLE_INDEXES
61
+ where = `events ${sample}${rest > 0 ? ` (+${rest} more)` : ""}`
62
+ }
63
+ const head = paint(SGR[first.severity], `${SYMBOL[first.severity]} ${first.rule}`, opts.color)
64
+ const meta = paint("2", `${first.severity.padEnd(7)} ×${group.length}`, opts.color)
65
+ const cite = paint("2", ` ↳ ${first.specUrl}`, opts.color)
66
+ lines.push(`${head} ${meta} ${title} — ${where}`, cite)
67
+ }
68
+ return lines.join("\n")
69
+ }
70
+
28
71
  export function formatReportSummary(report: Report, opts: PrettyOptions): string {
29
72
  const { errors, warnings, info } = report.summary
30
73
  const lines: string[] = []