dsh-model-everything 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.
@@ -1,451 +1,483 @@
1
- /**
2
- * The report body: the text a model reads for a this plugin result.
3
- *
4
- * Why this is not `JSON.stringify(value, null, 2)` any more: the structured report is a
5
- * contract (schema, verdict, hashes, check ids, finding codes, evidence — a finding's
6
- * identity is `check|code|<evidence + path>`, so `report_diff` and the test
7
- * suite read the object, never this text). The object is unchanged and still reaches the
8
- * client and the file path; this module only decides what the *model* pays for.
9
- *
10
- * Measured on a real 37,167-character report: 33.1% indentation, 21.7% repeated key names,
11
- * 12.7% punctuation; 105 findings carried 47 distinct facts. So the work this module does —
12
- * silencing passing checks, grouping repeated (state, event) facts, collapsing identical
13
- * lines, dropping warning evidence — is work the model would otherwise have to redo by
14
- * reading every finding. Everything a consumer does not need to act on is handled here.
15
- *
16
- * Determinism: the same report always renders to the same text (first-seen order, stable
17
- * check order, no clock, no randomness), so two runs over the same input stay comparable.
18
- *
19
- * @module model-everything/report-view
20
- */
21
- import type { JsonValue } from './json-value.js'
22
-
23
- /** Rendering limits. Defaults are the shipped behaviour; tests pin them. */
24
- export interface ReportViewOptions {
25
- /** Maximum finding lines per check. Error findings are never dropped, only warnings are capped. */
26
- maxFindingsPerCheck?: number
27
- /** Evidence is inlined for `error` findings only, truncated to this many characters. */
28
- maxEvidenceChars?: number
29
- /** Include passing checks' bodies (default false: a passing check carries no actionable finding). */
30
- includePassing?: boolean
31
- /**
32
- * Print the whole structured report instead of the body. This is the escape hatch for a
33
- * caller that must hand the report to another tool verbatim — `report_diff`
34
- * matches on check + code + evidence, so a body cannot stand in for the object.
35
- */
36
- full?: boolean
37
- }
38
-
39
- const DEFAULTS = { maxFindingsPerCheck: 24, maxEvidenceChars: 160, includePassing: false, full: false } as const
40
-
41
- type Record_ = Record<string, unknown>
42
-
43
- const record = (value: unknown): Record_ | undefined =>
44
- typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record_) : undefined
45
- const array = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
46
- const text = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined)
47
- const count = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
48
-
49
- /** One normalized finding. `evidence` is kept because a finding's identity depends on it. */
50
- export interface ViewFinding {
51
- code: string
52
- severity: string
53
- message: string
54
- evidence?: Record_
55
- file?: string
56
- line?: number
57
- check?: string
58
- }
59
-
60
- /** Normalize one raw finding. Unknown fields are ignored; a missing message renders as the code. */
61
- export function toViewFinding(value: unknown, check?: string): ViewFinding {
62
- const raw = record(value) ?? {}
63
- const code = text(raw.code) ?? 'FINDING'
64
- const file = text(raw.file)
65
- const line = count(raw.line)
66
- return {
67
- code,
68
- severity: text(raw.severity) ?? 'warning',
69
- message: text(raw.message) ?? code,
70
- ...(record(raw.evidence) === undefined ? {} : { evidence: record(raw.evidence) as Record_ }),
71
- ...(file === undefined ? {} : { file }),
72
- ...(line === undefined ? {} : { line }),
73
- ...(check === undefined ? {} : { check }),
74
- }
75
- }
76
-
77
- /**
78
- * The fact a finding states, as a comparable key. The verify/datamodel families report the
79
- * same (state, event) fact from more than one check, so the key ignores which check said it;
80
- * anything else falls back to code + message.
81
- */
82
- export function factKey(finding: ViewFinding): string {
83
- const stateEvent = /^(\S+) silently ignores event (\S+)/.exec(finding.message)
84
- if (stateEvent !== null) return `event-in-state|${stateEvent[1] ?? ''}|${stateEvent[2] ?? ''}`
85
- const eventState = /^Event (\S+) in state (\S+) /.exec(finding.message)
86
- if (eventState !== null) return `event-in-state|${eventState[2] ?? ''}|${eventState[1] ?? ''}`
87
- return `message|${finding.code}|${finding.message}`
88
- }
89
-
90
- /** `[severity/CODE] message` plus, for errors, a compact evidence tail. */
91
- function findingLine(finding: ViewFinding, options: Required<ReportViewOptions>): string {
92
- const where = finding.file === undefined ? '' : ` (${finding.file}${finding.line === undefined ? '' : ':' + String(finding.line)})`
93
- const evidence =
94
- finding.evidence === undefined || finding.severity !== 'error'
95
- ? ''
96
- : ' evidence=' + truncate(JSON.stringify(finding.evidence), options.maxEvidenceChars)
97
- return ` [${finding.severity}/${finding.code}] ${finding.message}${where}${evidence}`
98
- }
99
-
100
- function truncate(value: string, limit: number): string {
101
- return value.length <= limit ? value : value.slice(0, limit - 1) + '…'
102
- }
103
-
104
- /** Collapse identical lines into `line (xN)`, keeping first-seen order. */
105
- function collapse(lines: string[]): string[] {
106
- const order: string[] = []
107
- const seen = new Map<string, number>()
108
- for (const line of lines) {
109
- if (!seen.has(line)) order.push(line)
110
- seen.set(line, (seen.get(line) ?? 0) + 1)
111
- }
112
- return order.map((line) => (seen.get(line) === 1 ? line : `${line} (x${String(seen.get(line))})`))
113
- }
114
-
115
- /**
116
- * Body lines for one group of findings. Findings that restate a fact an earlier check already
117
- * reported are dropped (only their count survives), because re-reading them is pure cost;
118
- * `error` findings are never dropped.
119
- */
120
- function findingLines(findings: ViewFinding[], options: Required<ReportViewOptions>, seenFacts: Set<string>): string[] {
121
- const lines: string[] = []
122
- let repeats = 0
123
- const fresh: ViewFinding[] = []
124
- const addedHere = new Set<string>()
125
- for (const finding of findings) {
126
- const key = factKey(finding)
127
- // A fact another check already reported is dropped here (only its count survives), but a
128
- // fact repeated inside this same group is not a cross-check repeat: it collapses below
129
- // into one line with a multiplier, which keeps the count visible where it belongs.
130
- if (finding.severity !== 'error' && seenFacts.has(key) && !addedHere.has(key)) {
131
- repeats += 1
132
- continue
133
- }
134
- addedHere.add(key)
135
- seenFacts.add(key)
136
- fresh.push(finding)
137
- }
138
- const stateGroups = new Map<string, { severity: string; code: string; events: string[] }>()
139
- const singles: ViewFinding[] = []
140
- for (const finding of fresh) {
141
- const match = /^(\S+) silently ignores event (\S+)/.exec(finding.message) ?? /^Event (\S+) in state (\S+) /.exec(finding.message)
142
- if (match === null) {
143
- singles.push(finding)
144
- continue
145
- }
146
- // `event in state` puts the state second; `state ignores event` puts it first.
147
- const state = / silently ignores event /.test(finding.message) ? match[1] ?? '' : match[2] ?? ''
148
- const event = / silently ignores event /.test(finding.message) ? match[2] ?? '' : match[1] ?? ''
149
- const group = stateGroups.get(state) ?? { severity: finding.severity, code: finding.code, events: [] }
150
- group.events.push(event)
151
- stateGroups.set(state, group)
152
- }
153
- for (const [state, group] of stateGroups) {
154
- lines.push(` ${state} (${String(group.events.length)}) [${group.severity}/${group.code}]: ${group.events.join(', ')}`)
155
- }
156
- const rendered = collapse(singles.map((finding) => findingLine(finding, options)))
157
- const budget = options.maxFindingsPerCheck
158
- const kept: string[] = []
159
- let dropped = 0
160
- for (const line of [...lines, ...rendered]) {
161
- if (kept.length < budget || line.includes('/error]')) kept.push(line)
162
- else dropped += 1
163
- }
164
- if (dropped > 0) kept.push(` +${String(dropped)} more warning line(s) — ask for this check by id to see them all`)
165
- if (repeats > 0) kept.push(` (${String(repeats)} finding(s) here repeat facts already reported above)`)
166
- return kept
167
- }
168
-
169
- /** `ok`/`verdict` header fields, shared by every report family. */
170
- function headerLines(report: Record_, options: Required<ReportViewOptions>): string[] {
171
- const schema = text(report.schema) ?? 'model-everything/report'
172
- const verdict = text(report.verdict)
173
- const ok = report.ok
174
- const summary = record(report.summary) ?? {}
175
- const lines: string[] = []
176
- const bits = [
177
- `${schema}`,
178
- verdict === undefined ? `ok=${String(ok)}` : `verdict=${verdict}`,
179
- text(report.verdictReason) ?? '',
180
- ok === undefined || verdict === undefined ? '' : `ok=${String(ok)}`,
181
- ].filter((bit) => bit !== '')
182
- lines.push(bits.join(' '))
183
- // A refusal carries no model to hash, and the field is an empty string rather than absent, so it rendered
184
- // as `modelHash=…` - which reads as a truncated hash rather than as nothing to hash. An empty value means
185
- // the same thing as a missing one here.
186
- const hashText = text(report.modelHash) ?? text(record(report.hashes)?.modelHash)
187
- const hash = hashText === '' ? undefined : hashText
188
- // The scheme belongs beside the digest: a hex string with no spec cannot be reproduced, and two hashes cannot
189
- // be compared across report versions without it.
190
- const hashSpec = text(report.hashSpec) ?? text(record(report.hashes)?.hashSpec)
191
- const hashBits = hash === undefined ? [] : [`modelHash=${hash.slice(0, 12)}…`, ...(hashSpec === undefined ? [] : [`hashSpec=${hashSpec}`])]
192
- const counts = ['states', 'transitions', 'errors', 'warnings', 'checksRun']
193
- .map((key) => (count(summary[key]) === undefined ? '' : `${key}=${String(count(summary[key]))}`))
194
- .filter((bit) => bit !== '')
195
- if (counts.length > 0 || hashBits.length > 0) lines[0] += ' ' + [...hashBits, ...counts].join(' ')
196
- if (report.ran === false) lines.push('note: the tool did not run (the engine refused the input) — the fields below describe the refusal.')
197
- // `truncated` is the flag that says a search stopped at its budget. It reached the object and never the body, so
198
- // the one reader who sees only the body - the model - read an unfinished search as a complete one. It belongs
199
- // at the top, before the inventory: a reader who stops after the first line still learns the report is partial.
200
- if (summary.truncated === true) {
201
- lines.push('LIMIT the searches stopped at their budget, so part of this report is undecided rather than clean — the *_UNDECIDED findings name what.')
202
- }
203
- // A replaced or clamped bound is the reason the header's numbers are not the ones the caller asked for.
204
- for (const note of array(summary.boundNotes).map(String).filter((note) => note !== '')) lines.push(`BOUND ${note}`)
205
- // Coverage arrives as "described/total" strings, so reading only numbers dropped every value the engine sends
206
- // and the line never appeared. Take both shapes.
207
- const coverage = record(report.narrativeCoverage)
208
- if (coverage !== undefined) {
209
- const parts = Object.entries(coverage)
210
- .map(([key, value]) => {
211
- const shown = count(value) ?? text(value)
212
- return shown === undefined ? undefined : `${key} ${String(shown)}`
213
- })
214
- .filter((part): part is string => part !== undefined)
215
- if (parts.length > 0) lines.push(`NARRATIVE ${parts.join(' / ')} documented`)
216
- }
217
- // The schema ignores `_`-prefixed keys as annotation metadata and the report echoes their paths. Naming them
218
- // here makes the ignoring auditable; dropping them made "the schema ignored your field" invisible.
219
- const metadata = array(report.metadataKeys).map(String).filter((key) => key !== '')
220
- if (metadata.length > 0) lines.push(`METADATA _-prefixed keys the schema ignored: ${metadata.join(', ')}`)
221
- void options
222
- return lines
223
- }
224
-
225
- /** Trailing lines: notes about what was not checked, then the next steps. */
226
- function footerLines(report: Record_): string[] {
227
- const lines: string[] = []
228
- const notes = array(report.coverageNotes).map(String).filter((note) => note !== '')
229
- if (notes.length > 0) lines.push(`COVERAGE ${notes.join(' ')}`)
230
- const steps = array(report.nextSteps).map(String).filter((step) => step !== '')
231
- for (const step of steps) lines.push(`NEXT ${step}`)
232
- const comparison = record(report.comparison)
233
- if (comparison !== undefined) {
234
- const parts = Object.entries(comparison)
235
- .filter(([, value]) => count(value) !== undefined || typeof value === 'string')
236
- .map(([key, value]) => `${key}=${String(value)}`)
237
- if (parts.length > 0) lines.push(`COMPARISON ${parts.join(' ')}`)
238
- }
239
- return lines
240
- }
241
-
242
- /** A `checks[]` report (verify, compose, datamodel): silence pass, expand the rest. */
243
- function renderChecksReport(report: Record_, options: Required<ReportViewOptions>): string[] {
244
- const checks = array(report.checks).map((value) => record(value) ?? {})
245
- const byStatus = (status: string): string[] =>
246
- checks.filter((check) => text(check.status) === status).map((check) => text(check.id) ?? '?')
247
- /**
248
- * Whether a failing check carries an error or only warnings.
249
- *
250
- * `status` is `fail` for any check with a finding, which is the right field for a machine and a
251
- * misleading word for a reader: a report can say `verdict=pass_with_findings ... no error findings` on
252
- * one line and `fail S5 A1 A3` on the next, where all three hold warnings only. The word `fail` beside a
253
- * verdict that says nothing failed is the misreading the reading skill warns about, so the inventory
254
- * distinguishes them and leaves the status field alone.
255
- */
256
- const failingKind = (check: Record_): 'error' | 'warn' => {
257
- const findings = array(check.findings).map((value) => record(value) ?? {})
258
- return findings.some((finding) => text(finding.severity) === 'error') ? 'error' : 'warn'
259
- }
260
- const failing = checks.filter((check) => text(check.status) === 'fail')
261
- const inventory = [
262
- ['pass', byStatus('pass')],
263
- ['fail', failing.filter((check) => failingKind(check) === 'error').map((check) => text(check.id) ?? '?')],
264
- ['warn', failing.filter((check) => failingKind(check) === 'warn').map((check) => text(check.id) ?? '?')],
265
- ['skip', byStatus('skip')],
266
- ]
267
- .filter(([, ids]) => (ids as string[]).length > 0)
268
- .map(([status, ids]) => `${String(status)} ${(ids as string[]).join(' ')}`)
269
- .join(' | ')
270
- const lines = [...headerLines(report, options), `CHECKS ${inventory}`]
271
- const seenFacts = new Set<string>()
272
- for (const check of checks) {
273
- const status = text(check.status) ?? 'skip'
274
- const findings = array(check.findings).map((finding) => toViewFinding(finding, text(check.id)))
275
- if (status === 'pass' && !options.includePassing) continue
276
- const body = findingLines(findings, options, seenFacts)
277
- if (status !== 'fail' && body.length === 0) continue
278
- lines.push('')
279
- // The section header says what the inventory said: `fail` for a check carrying an error, `warn` for one
280
- // carrying warnings only. Printing FAIL here while the CHECKS line grouped the same check under `warn`
281
- // put the misreading one line below where it had just been removed.
282
- const label = status === 'fail' ? (failingKind(check) === 'error' ? 'FAIL' : 'WARN') : status.toUpperCase()
283
- lines.push(
284
- `${label} ${text(check.id) ?? '?'} ${text(check.name) ?? ''} — ${text(check.detail) ?? ''} (${String(findings.length)} finding(s))`.replace(/ +— +$/, ''),
285
- )
286
- lines.push(...body)
287
- }
288
- return [...lines, ...wrapFooter(report)]
289
- }
290
-
291
- /** A flat `findings[]` report (uml review, structure, granularity, concurrency). */
292
- function renderFindingsReport(report: Record_, options: Required<ReportViewOptions>): string[] {
293
- const findings = array(report.findings).map((finding) => toViewFinding(finding))
294
- const errors = findings.filter((finding) => finding.severity === 'error').length
295
- const warnings = findings.length - errors
296
- const byCode = new Map<string, number>()
297
- for (const finding of findings) byCode.set(finding.code, (byCode.get(finding.code) ?? 0) + 1)
298
- // A report can carry notes beside its findings: `uml_parse` puts what the parser could not read in `warnings`, and
299
- // the verdict counts them. The body printed neither, so a report with one parser note said "1 warning finding(s)"
300
- // above "FINDINGS none — nothing was reported". They are printed now, and the empty-roster sentence stops claiming
301
- // nothing was reported when something was.
302
- const notes = array(report.warnings).map(String).filter((note) => note !== '')
303
- const lines = [
304
- ...headerLines(report, options),
305
- ...parseBodyLines(report),
306
- // An empty roster says so in one word. `0 total (0 error, 0 warning):` with a dangling colon read like a
307
- // line that had lost its content.
308
- findings.length === 0
309
- ? (notes.length === 0
310
- ? 'FINDINGS none — nothing was reported'
311
- : `FINDINGS none — ${String(notes.length)} note(s) under WARNING say what could not be carried`)
312
- : `FINDINGS ${String(findings.length)} total (${String(errors)} error, ${String(warnings)} warning): ${[...byCode].map(([code, n]) => `${code} x${String(n)}`).join(' | ')}`,
313
- ]
314
- const body = findingLines(findings, options, new Set<string>())
315
- if (body.length > 0) lines.push('', ...body)
316
- if (notes.length > 0) {
317
- lines.push('')
318
- for (const note of notes) lines.push(`WARNING ${truncate(note, options.maxEvidenceChars * 2)}`)
319
- }
320
- return [...lines, ...wrapFooter(report)]
321
- }
322
-
323
- /**
324
- * A report that carries a model rather than findings about one (`uml_parse`).
325
- *
326
- * The reader asked to read the diagram into a model, and the body printed neither the model nor how the text was
327
- * read, so the one reader who only has the body got `FINDINGS none` as the entire answer. The model is printed as
328
- * compact JSON on one line: it is data the caller feeds to `model_verify`, not prose.
329
- */
330
- function parseBodyLines(report: Record_): string[] {
331
- // Only a parse report: a review report carries a model too, but the caller already has it and the review is about
332
- // what the diagram says. Printing it there would be the same body for two different questions.
333
- if (text(report.action) !== 'parse') return []
334
- const model = record(report.model)
335
- if (model === undefined) return []
336
- const where = ['notation', 'kind', 'family']
337
- .map((key) => (text(report[key]) === undefined ? undefined : `${key}=${String(text(report[key]))}`))
338
- .filter((part): part is string => part !== undefined)
339
- return [...(where.length === 0 ? [] : [`PARSE ${where.join(' ')}`]), `MODEL ${JSON.stringify(model)}`]
340
- }
341
-
342
- /** A baseline diff report: what a change added, removed or changed. */
343
- function renderDeltaReport(report: Record_, options: Required<ReportViewOptions>): string[] {
344
- const lines = headerLines(report, options)
345
- const delta = record(report.delta) ?? report
346
- const section = (key: string): ViewFinding[] => array(delta[key]).map((finding) => toViewFinding(finding))
347
- for (const key of ['added', 'removed']) {
348
- const findings = section(key)
349
- if (findings.length === 0) continue
350
- lines.push('', `${key.toUpperCase()} (${String(findings.length)})`)
351
- lines.push(...findingLines(findings, options, new Set<string>()))
352
- }
353
- // `changed` items are not findings: each carries the two sides of a finding that kept its identity and moved.
354
- // Reading them with `toViewFinding` invented `severity: warning` for an item that has no severity, and dropped
355
- // both messages - so a warning that escalated to an error rendered as `[warning/CODE] CODE`, which says the
356
- // opposite of what happened.
357
- const changes = array(delta.changed)
358
- if (changes.length > 0) {
359
- lines.push('', `CHANGED (${String(changes.length)})`)
360
- for (const raw of changes) {
361
- const change = record(raw) ?? {}
362
- const before = record(change.before) ?? {}
363
- const after = record(change.after) ?? {}
364
- const beforeSeverity = text(before.severity)
365
- const afterSeverity = text(after.severity)
366
- const beforeMessage = text(before.message)
367
- const afterMessage = text(after.message)
368
- const parts: string[] = []
369
- if (beforeSeverity !== afterSeverity) parts.push(`severity ${String(beforeSeverity)} → ${String(afterSeverity)}`)
370
- if (beforeMessage !== afterMessage) {
371
- parts.push(`message "${truncate(String(beforeMessage), options.maxEvidenceChars)}" → "${truncate(String(afterMessage), options.maxEvidenceChars)}"`)
372
- }
373
- lines.push(` ${text(change.code) ?? '?'} ${parts.length === 0 ? 'reported differently' : parts.join('; ')}`)
374
- }
375
- }
376
- const current = text(report.currentVerdict) ?? text(record(report.current)?.verdict)
377
- if (current !== undefined) lines.push(`CURRENT verdict=${current}`)
378
- return [...lines, ...wrapFooter(report)]
379
- }
380
-
381
- /** A payload result (a rendered diagram, an exported file): print the payload, not JSON of it. */
382
- function renderPayloadReport(report: Record_, options: Required<ReportViewOptions>): string[] {
383
- const lines = headerLines(report, options)
384
- const primary = text(report.diagram) ?? text(report.primary)
385
- if (primary !== undefined) lines.push('', primary.trimEnd())
386
- const extras = record(report.extras)
387
- for (const [name, content] of Object.entries(extras ?? {})) {
388
- const body = text(content)
389
- if (body !== undefined) lines.push('', `--- ${name} ---`, body.trimEnd())
390
- }
391
- const warnings = array(report.warnings).map(String).filter((warning) => warning !== '')
392
- for (const warning of warnings) lines.push(`WARNING ${warning}`)
393
- return [...lines, ...wrapFooter(report)]
394
- }
395
-
396
- function wrapFooter(report: Record_): string[] {
397
- const footer = footerLines(report)
398
- return footer.length === 0 ? [] : ['', ...footer]
399
- }
400
-
401
- /**
402
- * A refusal, or a shape this module does not recognize: state the reason first, then the
403
- * remaining fields compactly so nothing is lost, without paying for indentation.
404
- */
405
- function renderFallbackReport(report: Record_, options: Required<ReportViewOptions>): string[] {
406
- const lines = headerLines(report, options)
407
- const message = text(report.error)
408
- if (message !== undefined) {
409
- const code = text(report.errorCode)
410
- lines.push(`ERROR ${message}${code === undefined ? '' : ` [${code}]`}`)
411
- }
412
- const rest: Record_ = { ...report }
413
- delete rest.error
414
- delete rest.errorCode
415
- lines.push('', JSON.stringify(rest))
416
- return [...lines, ...wrapFooter(report)]
417
- }
418
-
419
- /**
420
- * Render any this plugin result as the text a model reads.
421
- *
422
- * Order of recognition: a `checks[]` report, a flat `findings[]` report, a baseline delta,
423
- * a payload result (`diagram`/`primary`), and finally compact JSON so an unrecognized shape
424
- * is still readable rather than silently empty.
425
- */
426
- export function renderReportText(value: unknown, options: ReportViewOptions = {}): string {
427
- const resolved: Required<ReportViewOptions> = { ...DEFAULTS, ...options }
428
- if (resolved.full) return JSON.stringify(value as JsonValue, null, 2)
429
- const report = record(value)
430
- if (report === undefined) return JSON.stringify(value as JsonValue) ?? String(value)
431
- const hasChecks = array(report.checks).some((check) => record(check) !== undefined)
432
- // A report that CARRIES a findings list is a findings report even when the list is empty. Routing on
433
- // length sent a clean report to the unknown-shape fallback, which printed the whole JSON - so the default
434
- // view cost more than the `full` escape hatch it exists to be cheaper than.
435
- const hasFindings = Array.isArray(report.findings)
436
- // A delta is recognised by its shape, not by whether anything moved: a clean comparison is the case a baseline
437
- // is used for most often, and requiring a non-empty list sent exactly that case to the unknown-shape fallback,
438
- // which printed the whole object as JSON.
439
- const isDelta = Array.isArray(report.added) || Array.isArray(report.removed) || Array.isArray(report.changed) || typeof report.currentVerdict === 'string'
440
- const isPayload = typeof report.diagram === 'string' || typeof report.primary === 'string'
441
- const lines = hasChecks
442
- ? renderChecksReport(report, resolved)
443
- : isDelta
444
- ? renderDeltaReport(report, resolved)
445
- : hasFindings
446
- ? renderFindingsReport(report, resolved)
447
- : isPayload
448
- ? renderPayloadReport(report, resolved)
449
- : renderFallbackReport(report, resolved)
450
- return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()
451
- }
1
+ /**
2
+ * The report body: the text a model reads for a this plugin result.
3
+ *
4
+ * Why this is not `JSON.stringify(value, null, 2)` any more: the structured report is a
5
+ * contract (schema, verdict, hashes, check ids, finding codes, evidence — a finding's
6
+ * identity is `check|code|<evidence + path>`, so `report_diff` and the test
7
+ * suite read the object, never this text). The object is unchanged and still reaches the
8
+ * client and the file path; this module only decides what the *model* pays for.
9
+ *
10
+ * Measured on a real 37,167-character report: 33.1% indentation, 21.7% repeated key names,
11
+ * 12.7% punctuation; 105 findings carried 47 distinct facts. So the work this module does —
12
+ * silencing passing checks, grouping repeated (state, event) facts, collapsing identical
13
+ * lines, dropping warning evidence — is work the model would otherwise have to redo by
14
+ * reading every finding. Everything a consumer does not need to act on is handled here.
15
+ *
16
+ * Determinism: the same report always renders to the same text (first-seen order, stable
17
+ * check order, no clock, no randomness), so two runs over the same input stay comparable.
18
+ *
19
+ * @module model-everything/report-view
20
+ */
21
+ import type { JsonValue } from './json-value.js'
22
+
23
+ /** Rendering limits. Defaults are the shipped behaviour; tests pin them. */
24
+ export interface ReportViewOptions {
25
+ /** Maximum finding lines per check. Error findings are never dropped, only warnings are capped. */
26
+ maxFindingsPerCheck?: number
27
+ /** Evidence is inlined for `error` findings only, truncated to this many characters. */
28
+ maxEvidenceChars?: number
29
+ /** Include passing checks' bodies (default false: a passing check carries no actionable finding). */
30
+ includePassing?: boolean
31
+ /**
32
+ * Print the whole structured report instead of the body. This is the escape hatch for a
33
+ * caller that must hand the report to another tool verbatim — `report_diff`
34
+ * matches on check + code + evidence, so a body cannot stand in for the object.
35
+ */
36
+ full?: boolean
37
+ /**
38
+ * Print one check in full: every finding, evidence included, without the per-check budget the
39
+ * digest applies. The digest truncates a check's warnings and tells the reader to ask for it by
40
+ * id; before this option existed that sentence named something the tools could not do, and the
41
+ * only way to see the lines was `full`, which returns the whole structured report.
42
+ */
43
+ check?: string | undefined
44
+ }
45
+
46
+ const DEFAULTS = { maxFindingsPerCheck: 24, maxEvidenceChars: 160, includePassing: false, full: false, check: undefined as string | undefined } as const
47
+
48
+ type Record_ = Record<string, unknown>
49
+
50
+ const record = (value: unknown): Record_ | undefined =>
51
+ typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record_) : undefined
52
+ const array = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
53
+ const text = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined)
54
+ const count = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
55
+
56
+ /** One normalized finding. `evidence` is kept because a finding's identity depends on it. */
57
+ export interface ViewFinding {
58
+ code: string
59
+ severity: string
60
+ message: string
61
+ evidence?: Record_
62
+ file?: string
63
+ line?: number
64
+ check?: string | undefined
65
+ }
66
+
67
+ /** Normalize one raw finding. Unknown fields are ignored; a missing message renders as the code. */
68
+ export function toViewFinding(value: unknown, check?: string): ViewFinding {
69
+ const raw = record(value) ?? {}
70
+ const code = text(raw.code) ?? 'FINDING'
71
+ const file = text(raw.file)
72
+ const line = count(raw.line)
73
+ return {
74
+ code,
75
+ severity: text(raw.severity) ?? 'warning',
76
+ message: text(raw.message) ?? code,
77
+ ...(record(raw.evidence) === undefined ? {} : { evidence: record(raw.evidence) as Record_ }),
78
+ ...(file === undefined ? {} : { file }),
79
+ ...(line === undefined ? {} : { line }),
80
+ ...(check === undefined ? {} : { check }),
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The fact a finding states, as a comparable key. The verify/datamodel families report the
86
+ * same (state, event) fact from more than one check, so the key ignores which check said it;
87
+ * anything else falls back to code + message.
88
+ */
89
+ export function factKey(finding: ViewFinding): string {
90
+ const stateEvent = /^(\S+) silently ignores event (\S+)/.exec(finding.message)
91
+ if (stateEvent !== null) return `event-in-state|${stateEvent[1] ?? ''}|${stateEvent[2] ?? ''}`
92
+ const eventState = /^Event (\S+) in state (\S+) /.exec(finding.message)
93
+ if (eventState !== null) return `event-in-state|${eventState[2] ?? ''}|${eventState[1] ?? ''}`
94
+ return `message|${finding.code}|${finding.message}`
95
+ }
96
+
97
+ /** `[severity/CODE] message` plus, for errors, a compact evidence tail. */
98
+ function findingLine(finding: ViewFinding, options: Required<ReportViewOptions>): string {
99
+ const where = finding.file === undefined ? '' : ` (${finding.file}${finding.line === undefined ? '' : ':' + String(finding.line)})`
100
+ const evidence =
101
+ finding.evidence === undefined || finding.severity !== 'error'
102
+ ? ''
103
+ : ' evidence=' + truncate(JSON.stringify(finding.evidence), options.maxEvidenceChars)
104
+ return ` [${finding.severity}/${finding.code}] ${finding.message}${where}${evidence}`
105
+ }
106
+
107
+ function truncate(value: string, limit: number): string {
108
+ return value.length <= limit ? value : value.slice(0, limit - 1) + '…'
109
+ }
110
+
111
+ /** Collapse identical lines into `line (xN)`, keeping first-seen order. */
112
+ function collapse(lines: string[]): string[] {
113
+ const order: string[] = []
114
+ const seen = new Map<string, number>()
115
+ for (const line of lines) {
116
+ if (!seen.has(line)) order.push(line)
117
+ seen.set(line, (seen.get(line) ?? 0) + 1)
118
+ }
119
+ return order.map((line) => (seen.get(line) === 1 ? line : `${line} (x${String(seen.get(line))})`))
120
+ }
121
+
122
+ /**
123
+ * Body lines for one group of findings. Findings that restate a fact an earlier check already
124
+ * reported are dropped (only their count survives), because re-reading them is pure cost;
125
+ * `error` findings are never dropped.
126
+ */
127
+ function findingLines(findings: ViewFinding[], options: Required<ReportViewOptions>, seenFacts: Set<string>): string[] {
128
+ const lines: string[] = []
129
+ let repeats = 0
130
+ const fresh: ViewFinding[] = []
131
+ const addedHere = new Set<string>()
132
+ for (const finding of findings) {
133
+ const key = factKey(finding)
134
+ // A fact another check already reported is dropped here (only its count survives), but a
135
+ // fact repeated inside this same group is not a cross-check repeat: it collapses below
136
+ // into one line with a multiplier, which keeps the count visible where it belongs.
137
+ if (finding.severity !== 'error' && seenFacts.has(key) && !addedHere.has(key)) {
138
+ repeats += 1
139
+ continue
140
+ }
141
+ addedHere.add(key)
142
+ seenFacts.add(key)
143
+ fresh.push(finding)
144
+ }
145
+ const stateGroups = new Map<string, { severity: string; code: string; events: string[] }>()
146
+ const singles: ViewFinding[] = []
147
+ for (const finding of fresh) {
148
+ const match = /^(\S+) silently ignores event (\S+)/.exec(finding.message) ?? /^Event (\S+) in state (\S+) /.exec(finding.message)
149
+ if (match === null) {
150
+ singles.push(finding)
151
+ continue
152
+ }
153
+ // `event in state` puts the state second; `state ignores event` puts it first.
154
+ const state = / silently ignores event /.test(finding.message) ? match[1] ?? '' : match[2] ?? ''
155
+ const event = / silently ignores event /.test(finding.message) ? match[2] ?? '' : match[1] ?? ''
156
+ const group = stateGroups.get(state) ?? { severity: finding.severity, code: finding.code, events: [] }
157
+ group.events.push(event)
158
+ stateGroups.set(state, group)
159
+ }
160
+ // The same event set on every state is one fact, not one per state: a 26-state machine whose
161
+ // states all ignore the same four events printed 24 near-identical lines, a quarter of the body.
162
+ // States that share a set are counted; a handful still name themselves, because there the names
163
+ // are what the reader acts on.
164
+ const byEventSet = new Map<string, { severity: string; code: string; events: string[]; states: string[] }>()
165
+ for (const [state, group] of stateGroups) {
166
+ const key = `${group.severity}|${group.code}|${group.events.join(',')}`
167
+ const bucket = byEventSet.get(key) ?? { severity: group.severity, code: group.code, events: group.events, states: [] }
168
+ bucket.states.push(state)
169
+ byEventSet.set(key, bucket)
170
+ }
171
+ for (const group of byEventSet.values()) {
172
+ const who = group.states.length > 3 ? `${String(group.states.length)} states` : group.states.join(', ')
173
+ lines.push(` ${who} (${String(group.events.length)}) [${group.severity}/${group.code}]: ${group.events.join(', ')}`)
174
+ }
175
+ const rendered = collapse(singles.map((finding) => findingLine(finding, options)))
176
+ const budget = options.maxFindingsPerCheck
177
+ const kept: string[] = []
178
+ let dropped = 0
179
+ for (const line of [...lines, ...rendered]) {
180
+ if (kept.length < budget || line.includes('/error]')) kept.push(line)
181
+ else dropped += 1
182
+ }
183
+ if (dropped > 0) kept.push(` +${String(dropped)} more warning line(s) — ask for this check by id to see them all`)
184
+ if (repeats > 0) kept.push(` (${String(repeats)} finding(s) here repeat facts already reported above)`)
185
+ return kept
186
+ }
187
+
188
+ /** `ok`/`verdict` header fields, shared by every report family. */
189
+ function headerLines(report: Record_, options: Required<ReportViewOptions>): string[] {
190
+ const schema = text(report.schema) ?? 'model-everything/report'
191
+ const verdict = text(report.verdict)
192
+ const ok = report.ok
193
+ const summary = record(report.summary) ?? {}
194
+ const lines: string[] = []
195
+ const bits = [
196
+ `${schema}`,
197
+ verdict === undefined ? `ok=${String(ok)}` : `verdict=${verdict}`,
198
+ text(report.verdictReason) ?? '',
199
+ ok === undefined || verdict === undefined ? '' : `ok=${String(ok)}`,
200
+ ].filter((bit) => bit !== '')
201
+ lines.push(bits.join(' '))
202
+ // A refusal carries no model to hash, and the field is an empty string rather than absent, so it rendered
203
+ // as `modelHash=…` - which reads as a truncated hash rather than as nothing to hash. An empty value means
204
+ // the same thing as a missing one here.
205
+ const hashText = text(report.modelHash) ?? text(record(report.hashes)?.modelHash)
206
+ const hash = hashText === '' ? undefined : hashText
207
+ // The scheme belongs beside the digest: a hex string with no spec cannot be reproduced, and two hashes cannot
208
+ // be compared across report versions without it.
209
+ const hashSpec = text(report.hashSpec) ?? text(record(report.hashes)?.hashSpec)
210
+ const hashBits = hash === undefined ? [] : [`modelHash=${hash.slice(0, 12)}…`, ...(hashSpec === undefined ? [] : [`hashSpec=${hashSpec}`])]
211
+ const counts = ['states', 'transitions', 'errors', 'warnings', 'checksRun']
212
+ .map((key) => (count(summary[key]) === undefined ? '' : `${key}=${String(count(summary[key]))}`))
213
+ .filter((bit) => bit !== '')
214
+ if (counts.length > 0 || hashBits.length > 0) lines[0] += ' ' + [...hashBits, ...counts].join(' ')
215
+ if (report.ran === false) lines.push('note: the tool did not run (the engine refused the input) — the fields below describe the refusal.')
216
+ // `truncated` is the flag that says a search stopped at its budget. It reached the object and never the body, so
217
+ // the one reader who sees only the body - the model - read an unfinished search as a complete one. It belongs
218
+ // at the top, before the inventory: a reader who stops after the first line still learns the report is partial.
219
+ if (summary.truncated === true) {
220
+ lines.push('LIMIT the searches stopped at their budget, so part of this report is undecided rather than clean — the *_UNDECIDED findings name what.')
221
+ }
222
+ // A replaced or clamped bound is the reason the header's numbers are not the ones the caller asked for.
223
+ for (const note of array(summary.boundNotes).map(String).filter((note) => note !== '')) lines.push(`BOUND ${note}`)
224
+ // Coverage arrives as "described/total" strings, so reading only numbers dropped every value the engine sends
225
+ // and the line never appeared. Take both shapes.
226
+ const coverage = record(report.narrativeCoverage)
227
+ if (coverage !== undefined) {
228
+ const parts = Object.entries(coverage)
229
+ .map(([key, value]) => {
230
+ const shown = count(value) ?? text(value)
231
+ return shown === undefined ? undefined : `${key} ${String(shown)}`
232
+ })
233
+ .filter((part): part is string => part !== undefined)
234
+ if (parts.length > 0) lines.push(`NARRATIVE ${parts.join(' / ')} documented`)
235
+ }
236
+ // The schema ignores `_`-prefixed keys as annotation metadata and the report echoes their paths. Naming them
237
+ // here makes the ignoring auditable; dropping them made "the schema ignored your field" invisible.
238
+ const metadata = array(report.metadataKeys).map(String).filter((key) => key !== '')
239
+ if (metadata.length > 0) lines.push(`METADATA _-prefixed keys the schema ignored: ${metadata.join(', ')}`)
240
+ void options
241
+ return lines
242
+ }
243
+
244
+ /** Trailing lines: notes about what was not checked, then the next steps. */
245
+ function footerLines(report: Record_): string[] {
246
+ const lines: string[] = []
247
+ const notes = array(report.coverageNotes).map(String).filter((note) => note !== '')
248
+ if (notes.length > 0) lines.push(`COVERAGE ${notes.join(' ')}`)
249
+ const steps = array(report.nextSteps).map(String).filter((step) => step !== '')
250
+ for (const step of steps) lines.push(`NEXT ${step}`)
251
+ const comparison = record(report.comparison)
252
+ if (comparison !== undefined) {
253
+ const parts = Object.entries(comparison)
254
+ .filter(([, value]) => count(value) !== undefined || typeof value === 'string')
255
+ .map(([key, value]) => `${key}=${String(value)}`)
256
+ if (parts.length > 0) lines.push(`COMPARISON ${parts.join(' ')}`)
257
+ }
258
+ return lines
259
+ }
260
+
261
+ /** A `checks[]` report (verify, compose, datamodel): silence pass, expand the rest. */
262
+ function renderChecksReport(report: Record_, options: Required<ReportViewOptions>): string[] {
263
+ const checks = array(report.checks).map((value) => record(value) ?? {})
264
+ const byStatus = (status: string): string[] =>
265
+ checks.filter((check) => text(check.status) === status).map((check) => text(check.id) ?? '?')
266
+ /**
267
+ * Whether a failing check carries an error or only warnings.
268
+ *
269
+ * `status` is `fail` for any check with a finding, which is the right field for a machine and a
270
+ * misleading word for a reader: a report can say `verdict=pass_with_findings ... no error findings` on
271
+ * one line and `fail S5 A1 A3` on the next, where all three hold warnings only. The word `fail` beside a
272
+ * verdict that says nothing failed is the misreading the reading skill warns about, so the inventory
273
+ * distinguishes them and leaves the status field alone.
274
+ */
275
+ const failingKind = (check: Record_): 'error' | 'warn' => {
276
+ const findings = array(check.findings).map((value) => record(value) ?? {})
277
+ return findings.some((finding) => text(finding.severity) === 'error') ? 'error' : 'warn'
278
+ }
279
+ const failing = checks.filter((check) => text(check.status) === 'fail')
280
+ const inventory = [
281
+ ['pass', byStatus('pass')],
282
+ ['fail', failing.filter((check) => failingKind(check) === 'error').map((check) => text(check.id) ?? '?')],
283
+ ['warn', failing.filter((check) => failingKind(check) === 'warn').map((check) => text(check.id) ?? '?')],
284
+ ['skip', byStatus('skip')],
285
+ ]
286
+ .filter(([, ids]) => (ids as string[]).length > 0)
287
+ .map(([status, ids]) => `${String(status)} ${(ids as string[]).join(' ')}`)
288
+ .join(' | ')
289
+ const lines = [...headerLines(report, options), `CHECKS ${inventory}`]
290
+ const requested = options.check
291
+ const ids = checks.map((check) => text(check.id)).filter((id): id is string => id !== undefined)
292
+ const selected = requested === undefined ? checks : checks.filter((check) => text(check.id) === requested)
293
+ // A name that matches nothing is answered with the names that exist: silently printing the whole
294
+ // report, or an empty body, both read as "that check was clean".
295
+ if (requested !== undefined && selected.length === 0) {
296
+ lines.push(`NOTE no check \`${requested}\` in this report; it carries: ${ids.join(' ')}`)
297
+ }
298
+ // Asking for one check is a request for its lines: the per-check budget that keeps the digest
299
+ // small does not apply, since the caller has already paid for the narrowing.
300
+ const view = requested === undefined ? options : { ...options, includePassing: true, maxFindingsPerCheck: Number.MAX_SAFE_INTEGER }
301
+ const seenFacts = new Set<string>()
302
+ for (const check of selected) {
303
+ const status = text(check.status) ?? 'skip'
304
+ const findings = array(check.findings).map((finding) => toViewFinding(finding, text(check.id)))
305
+ if (status === 'pass' && !options.includePassing) continue
306
+ const body = findingLines(findings, view, seenFacts)
307
+ if (status !== 'fail' && body.length === 0) continue
308
+ lines.push('')
309
+ // The section header says what the inventory said: `fail` for a check carrying an error, `warn` for one
310
+ // carrying warnings only. Printing FAIL here while the CHECKS line grouped the same check under `warn`
311
+ // put the misreading one line below where it had just been removed.
312
+ const label = status === 'fail' ? (failingKind(check) === 'error' ? 'FAIL' : 'WARN') : status.toUpperCase()
313
+ lines.push(
314
+ `${label} ${text(check.id) ?? '?'} ${text(check.name) ?? ''} — ${text(check.detail) ?? ''} (${String(findings.length)} finding(s))`.replace(/ +— +$/, ''),
315
+ )
316
+ lines.push(...body)
317
+ }
318
+ return [...lines, ...wrapFooter(report)]
319
+ }
320
+
321
+ /** A flat `findings[]` report (uml review, structure, granularity, concurrency). */
322
+ function renderFindingsReport(report: Record_, options: Required<ReportViewOptions>): string[] {
323
+ const findings = array(report.findings).map((finding) => toViewFinding(finding))
324
+ const errors = findings.filter((finding) => finding.severity === 'error').length
325
+ const warnings = findings.length - errors
326
+ const byCode = new Map<string, number>()
327
+ for (const finding of findings) byCode.set(finding.code, (byCode.get(finding.code) ?? 0) + 1)
328
+ // A report can carry notes beside its findings: `uml_parse` puts what the parser could not read in `warnings`, and
329
+ // the verdict counts them. The body printed neither, so a report with one parser note said "1 warning finding(s)"
330
+ // above "FINDINGS none — nothing was reported". They are printed now, and the empty-roster sentence stops claiming
331
+ // nothing was reported when something was.
332
+ const notes = array(report.warnings).map(String).filter((note) => note !== '')
333
+ const lines = [
334
+ ...headerLines(report, options),
335
+ ...parseBodyLines(report),
336
+ // An empty roster says so in one word. `0 total (0 error, 0 warning):` with a dangling colon read like a
337
+ // line that had lost its content.
338
+ findings.length === 0
339
+ ? (notes.length === 0
340
+ ? 'FINDINGS none — nothing was reported'
341
+ : `FINDINGS none — ${String(notes.length)} note(s) under WARNING say what could not be carried`)
342
+ : `FINDINGS ${String(findings.length)} total (${String(errors)} error, ${String(warnings)} warning): ${[...byCode].map(([code, n]) => `${code} x${String(n)}`).join(' | ')}`,
343
+ ]
344
+ const body = findingLines(findings, options, new Set<string>())
345
+ if (body.length > 0) lines.push('', ...body)
346
+ if (notes.length > 0) {
347
+ lines.push('')
348
+ for (const note of notes) lines.push(`WARNING ${truncate(note, options.maxEvidenceChars * 2)}`)
349
+ }
350
+ return [...lines, ...wrapFooter(report)]
351
+ }
352
+
353
+ /**
354
+ * A report that carries a model rather than findings about one (`uml_parse`).
355
+ *
356
+ * The reader asked to read the diagram into a model, and the body printed neither the model nor how the text was
357
+ * read, so the one reader who only has the body got `FINDINGS none` as the entire answer. The model is printed as
358
+ * compact JSON on one line: it is data the caller feeds to `model_verify`, not prose.
359
+ */
360
+ function parseBodyLines(report: Record_): string[] {
361
+ // Only a parse report: a review report carries a model too, but the caller already has it and the review is about
362
+ // what the diagram says. Printing it there would be the same body for two different questions.
363
+ if (text(report.action) !== 'parse') return []
364
+ const model = record(report.model)
365
+ if (model === undefined) return []
366
+ const where = ['notation', 'kind', 'family']
367
+ .map((key) => (text(report[key]) === undefined ? undefined : `${key}=${String(text(report[key]))}`))
368
+ .filter((part): part is string => part !== undefined)
369
+ return [...(where.length === 0 ? [] : [`PARSE ${where.join(' ')}`]), `MODEL ${JSON.stringify(model)}`]
370
+ }
371
+
372
+ /** A baseline diff report: what a change added, removed or changed. */
373
+ function renderDeltaReport(report: Record_, options: Required<ReportViewOptions>): string[] {
374
+ const lines = headerLines(report, options)
375
+ const delta = record(report.delta) ?? report
376
+ const section = (key: string): ViewFinding[] => array(delta[key]).map((finding) => toViewFinding(finding))
377
+ for (const key of ['added', 'removed']) {
378
+ const findings = section(key)
379
+ if (findings.length === 0) continue
380
+ lines.push('', `${key.toUpperCase()} (${String(findings.length)})`)
381
+ lines.push(...findingLines(findings, options, new Set<string>()))
382
+ }
383
+ // `changed` items are not findings: each carries the two sides of a finding that kept its identity and moved.
384
+ // Reading them with `toViewFinding` invented `severity: warning` for an item that has no severity, and dropped
385
+ // both messages - so a warning that escalated to an error rendered as `[warning/CODE] CODE`, which says the
386
+ // opposite of what happened.
387
+ const changes = array(delta.changed)
388
+ if (changes.length > 0) {
389
+ lines.push('', `CHANGED (${String(changes.length)})`)
390
+ for (const raw of changes) {
391
+ const change = record(raw) ?? {}
392
+ const before = record(change.before) ?? {}
393
+ const after = record(change.after) ?? {}
394
+ const beforeSeverity = text(before.severity)
395
+ const afterSeverity = text(after.severity)
396
+ const beforeMessage = text(before.message)
397
+ const afterMessage = text(after.message)
398
+ const parts: string[] = []
399
+ if (beforeSeverity !== afterSeverity) parts.push(`severity ${String(beforeSeverity)} → ${String(afterSeverity)}`)
400
+ if (beforeMessage !== afterMessage) {
401
+ parts.push(`message "${truncate(String(beforeMessage), options.maxEvidenceChars)}" → "${truncate(String(afterMessage), options.maxEvidenceChars)}"`)
402
+ }
403
+ lines.push(` ${text(change.code) ?? '?'} ${parts.length === 0 ? 'reported differently' : parts.join('; ')}`)
404
+ }
405
+ }
406
+ const current = text(report.currentVerdict) ?? text(record(report.current)?.verdict)
407
+ if (current !== undefined) lines.push(`CURRENT verdict=${current}`)
408
+ return [...lines, ...wrapFooter(report)]
409
+ }
410
+
411
+ /** A payload result (a rendered diagram, an exported file): print the payload, not JSON of it. */
412
+ function renderPayloadReport(report: Record_, options: Required<ReportViewOptions>): string[] {
413
+ const lines = headerLines(report, options)
414
+ const primary = text(report.diagram) ?? text(report.primary)
415
+ if (primary !== undefined) lines.push('', primary.trimEnd())
416
+ const extras = record(report.extras)
417
+ for (const [name, content] of Object.entries(extras ?? {})) {
418
+ const body = text(content)
419
+ if (body !== undefined) lines.push('', `--- ${name} ---`, body.trimEnd())
420
+ }
421
+ const warnings = array(report.warnings).map(String).filter((warning) => warning !== '')
422
+ for (const warning of warnings) lines.push(`WARNING ${warning}`)
423
+ return [...lines, ...wrapFooter(report)]
424
+ }
425
+
426
+ function wrapFooter(report: Record_): string[] {
427
+ const footer = footerLines(report)
428
+ return footer.length === 0 ? [] : ['', ...footer]
429
+ }
430
+
431
+ /**
432
+ * A refusal, or a shape this module does not recognize: state the reason first, then the
433
+ * remaining fields compactly so nothing is lost, without paying for indentation.
434
+ */
435
+ function renderFallbackReport(report: Record_, options: Required<ReportViewOptions>): string[] {
436
+ const lines = headerLines(report, options)
437
+ const message = text(report.error)
438
+ if (message !== undefined) {
439
+ const code = text(report.errorCode)
440
+ lines.push(`ERROR ${message}${code === undefined ? '' : ` [${code}]`}`)
441
+ }
442
+ const rest: Record_ = { ...report }
443
+ delete rest.error
444
+ delete rest.errorCode
445
+ lines.push('', JSON.stringify(rest))
446
+ return [...lines, ...wrapFooter(report)]
447
+ }
448
+
449
+ /**
450
+ * Render any this plugin result as the text a model reads.
451
+ *
452
+ * Order of recognition: a `checks[]` report, a flat `findings[]` report, a baseline delta,
453
+ * a payload result (`diagram`/`primary`), and finally compact JSON so an unrecognized shape
454
+ * is still readable rather than silently empty.
455
+ */
456
+ export function renderReportText(value: unknown, options: ReportViewOptions = {}): string {
457
+ const resolved = { ...DEFAULTS, ...options } as Required<ReportViewOptions>
458
+ // Compact rather than pretty: `full` exists so a caller can hand the object on (report_diff matches
459
+ // on check + code + evidence), and indentation is a third of a large report's bytes for no reader.
460
+ if (resolved.full) return JSON.stringify(value as JsonValue)
461
+ const report = record(value)
462
+ if (report === undefined) return JSON.stringify(value as JsonValue) ?? String(value)
463
+ const hasChecks = array(report.checks).some((check) => record(check) !== undefined)
464
+ // A report that CARRIES a findings list is a findings report even when the list is empty. Routing on
465
+ // length sent a clean report to the unknown-shape fallback, which printed the whole JSON - so the default
466
+ // view cost more than the `full` escape hatch it exists to be cheaper than.
467
+ const hasFindings = Array.isArray(report.findings)
468
+ // A delta is recognised by its shape, not by whether anything moved: a clean comparison is the case a baseline
469
+ // is used for most often, and requiring a non-empty list sent exactly that case to the unknown-shape fallback,
470
+ // which printed the whole object as JSON.
471
+ const isDelta = Array.isArray(report.added) || Array.isArray(report.removed) || Array.isArray(report.changed) || typeof report.currentVerdict === 'string'
472
+ const isPayload = typeof report.diagram === 'string' || typeof report.primary === 'string'
473
+ const lines = hasChecks
474
+ ? renderChecksReport(report, resolved)
475
+ : isDelta
476
+ ? renderDeltaReport(report, resolved)
477
+ : hasFindings
478
+ ? renderFindingsReport(report, resolved)
479
+ : isPayload
480
+ ? renderPayloadReport(report, resolved)
481
+ : renderFallbackReport(report, resolved)
482
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()
483
+ }