dsh-logicprobe 1.0.0 → 1.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/README.en-US.md +93 -85
- package/README.md +93 -85
- package/lib/compose-tool.js +8 -2
- package/lib/concurrency-tool.js +8 -2
- package/lib/data-tool.js +8 -2
- package/lib/engine.js +61 -11
- package/lib/export-tool.js +8 -2
- package/lib/index.js +28 -40
- package/lib/report-diff-tool.js +8 -2
- package/lib/report-view.js +295 -0
- package/lib/structure-tool.js +8 -2
- package/lib/tool.js +8 -2
- package/lib/types/engine.d.ts +6 -1
- package/lib/types/index.d.ts +5 -4
- package/lib/types/report-view.d.ts +43 -0
- package/lib/uml-tool.js +27 -15
- package/lib/uml.js +16 -6
- package/package.json +2 -2
- package/skills/logicprobe/SKILL.md +26 -9
- package/skills/logicprobe/references/dsh-model-schema.md +25 -4
- package/skills/logicprobe/references/gap-routing-guide.md +10 -9
- package/skills/logicprobe/references/hash-spec.md +25 -0
- package/skills/logicprobe/references/logic-verification-guide.md +1 -1
- package/skills/logicprobe-concurrency/SKILL.md +1 -0
- package/skills/logicprobe-uml/SKILL.md +3 -1
- package/skills/logicprobe-uml/references/uml-modeling-guide.md +7 -6
- package/src/compose-tool.ts +8 -2
- package/src/concurrency-tool.ts +8 -2
- package/src/data-tool.ts +9 -3
- package/src/engine.ts +67 -10
- package/src/export-tool.ts +8 -2
- package/src/index.ts +28 -40
- package/src/report-diff-tool.ts +8 -2
- package/src/report-view.ts +343 -0
- package/src/structure-tool.ts +8 -2
- package/src/tool.ts +8 -2
- package/src/uml-tool.ts +27 -15
- package/src/uml.ts +20 -7
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The report body: the text a model reads for a logicprobe 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 `logicprobe_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 logicprobe/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 — `logicprobe_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) ?? 'logicprobe/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
|
+
const hash = text(report.modelHash) ?? text(record(report.hashes)?.modelHash)
|
|
184
|
+
const counts = ['states', 'transitions', 'errors', 'warnings', 'checksRun']
|
|
185
|
+
.map((key) => (count(summary[key]) === undefined ? '' : `${key}=${String(count(summary[key]))}`))
|
|
186
|
+
.filter((bit) => bit !== '')
|
|
187
|
+
if (counts.length > 0 || hash !== undefined) lines[0] += ' ' + [...(hash === undefined ? [] : [`modelHash=${hash.slice(0, 12)}…`]), ...counts].join(' ')
|
|
188
|
+
if (report.ran === false) lines.push('note: the tool did not run (the engine refused the input) — the fields below describe the refusal.')
|
|
189
|
+
const coverage = record(report.narrativeCoverage)
|
|
190
|
+
if (coverage !== undefined) {
|
|
191
|
+
const parts = Object.entries(coverage)
|
|
192
|
+
.filter(([, value]) => count(value) !== undefined)
|
|
193
|
+
.map(([key, value]) => `${key} ${String(count(value))}`)
|
|
194
|
+
if (parts.length > 0) lines.push(`NARRATIVE ${parts.join(' / ')} documented`)
|
|
195
|
+
}
|
|
196
|
+
void options
|
|
197
|
+
return lines
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Trailing lines: notes about what was not checked, then the next steps. */
|
|
201
|
+
function footerLines(report: Record_): string[] {
|
|
202
|
+
const lines: string[] = []
|
|
203
|
+
const notes = array(report.coverageNotes).map(String).filter((note) => note !== '')
|
|
204
|
+
if (notes.length > 0) lines.push(`COVERAGE ${notes.join(' ')}`)
|
|
205
|
+
const steps = array(report.nextSteps).map(String).filter((step) => step !== '')
|
|
206
|
+
for (const step of steps) lines.push(`NEXT ${step}`)
|
|
207
|
+
const comparison = record(report.comparison)
|
|
208
|
+
if (comparison !== undefined) {
|
|
209
|
+
const parts = Object.entries(comparison)
|
|
210
|
+
.filter(([, value]) => count(value) !== undefined || typeof value === 'string')
|
|
211
|
+
.map(([key, value]) => `${key}=${String(value)}`)
|
|
212
|
+
if (parts.length > 0) lines.push(`COMPARISON ${parts.join(' ')}`)
|
|
213
|
+
}
|
|
214
|
+
return lines
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A `checks[]` report (verify, compose, datamodel): silence pass, expand the rest. */
|
|
218
|
+
function renderChecksReport(report: Record_, options: Required<ReportViewOptions>): string[] {
|
|
219
|
+
const checks = array(report.checks).map((value) => record(value) ?? {})
|
|
220
|
+
const byStatus = (status: string): string[] =>
|
|
221
|
+
checks.filter((check) => text(check.status) === status).map((check) => text(check.id) ?? '?')
|
|
222
|
+
const inventory = [
|
|
223
|
+
['pass', byStatus('pass')],
|
|
224
|
+
['fail', byStatus('fail')],
|
|
225
|
+
['skip', byStatus('skip')],
|
|
226
|
+
]
|
|
227
|
+
.filter(([, ids]) => (ids as string[]).length > 0)
|
|
228
|
+
.map(([status, ids]) => `${String(status)} ${(ids as string[]).join(' ')}`)
|
|
229
|
+
.join(' | ')
|
|
230
|
+
const lines = [...headerLines(report, options), `CHECKS ${inventory}`]
|
|
231
|
+
const seenFacts = new Set<string>()
|
|
232
|
+
for (const check of checks) {
|
|
233
|
+
const status = text(check.status) ?? 'skip'
|
|
234
|
+
const findings = array(check.findings).map((finding) => toViewFinding(finding, text(check.id)))
|
|
235
|
+
if (status === 'pass' && !options.includePassing) continue
|
|
236
|
+
const body = findingLines(findings, options, seenFacts)
|
|
237
|
+
if (status !== 'fail' && body.length === 0) continue
|
|
238
|
+
lines.push('')
|
|
239
|
+
lines.push(
|
|
240
|
+
`${status.toUpperCase()} ${text(check.id) ?? '?'} ${text(check.name) ?? ''} — ${text(check.detail) ?? ''} (${String(findings.length)} finding(s))`.replace(/ +— +$/, ''),
|
|
241
|
+
)
|
|
242
|
+
lines.push(...body)
|
|
243
|
+
}
|
|
244
|
+
return [...lines, ...wrapFooter(report)]
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A flat `findings[]` report (uml review, structure, granularity, concurrency). */
|
|
248
|
+
function renderFindingsReport(report: Record_, options: Required<ReportViewOptions>): string[] {
|
|
249
|
+
const findings = array(report.findings).map((finding) => toViewFinding(finding))
|
|
250
|
+
const errors = findings.filter((finding) => finding.severity === 'error').length
|
|
251
|
+
const warnings = findings.length - errors
|
|
252
|
+
const byCode = new Map<string, number>()
|
|
253
|
+
for (const finding of findings) byCode.set(finding.code, (byCode.get(finding.code) ?? 0) + 1)
|
|
254
|
+
const lines = [
|
|
255
|
+
...headerLines(report, options),
|
|
256
|
+
`FINDINGS ${String(findings.length)} total (${String(errors)} error, ${String(warnings)} warning): ${[...byCode].map(([code, n]) => `${code} x${String(n)}`).join(' | ')}`,
|
|
257
|
+
]
|
|
258
|
+
const body = findingLines(findings, options, new Set<string>())
|
|
259
|
+
if (body.length > 0) lines.push('', ...body)
|
|
260
|
+
return [...lines, ...wrapFooter(report)]
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A baseline diff report: what a change added, removed or changed. */
|
|
264
|
+
function renderDeltaReport(report: Record_, options: Required<ReportViewOptions>): string[] {
|
|
265
|
+
const lines = headerLines(report, options)
|
|
266
|
+
const delta = record(report.delta) ?? report
|
|
267
|
+
const section = (key: string): ViewFinding[] => array(delta[key]).map((finding) => toViewFinding(finding))
|
|
268
|
+
for (const key of ['added', 'removed', 'changed']) {
|
|
269
|
+
const findings = section(key)
|
|
270
|
+
if (findings.length === 0) continue
|
|
271
|
+
lines.push('', `${key.toUpperCase()} (${String(findings.length)})`)
|
|
272
|
+
lines.push(...findingLines(findings, options, new Set<string>()))
|
|
273
|
+
}
|
|
274
|
+
const current = text(report.currentVerdict) ?? text(record(report.current)?.verdict)
|
|
275
|
+
if (current !== undefined) lines.push(`CURRENT verdict=${current}`)
|
|
276
|
+
return [...lines, ...wrapFooter(report)]
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** A payload result (a rendered diagram, an exported file): print the payload, not JSON of it. */
|
|
280
|
+
function renderPayloadReport(report: Record_, options: Required<ReportViewOptions>): string[] {
|
|
281
|
+
const lines = headerLines(report, options)
|
|
282
|
+
const primary = text(report.diagram) ?? text(report.primary)
|
|
283
|
+
if (primary !== undefined) lines.push('', primary.trimEnd())
|
|
284
|
+
const extras = record(report.extras)
|
|
285
|
+
for (const [name, content] of Object.entries(extras ?? {})) {
|
|
286
|
+
const body = text(content)
|
|
287
|
+
if (body !== undefined) lines.push('', `--- ${name} ---`, body.trimEnd())
|
|
288
|
+
}
|
|
289
|
+
const warnings = array(report.warnings).map(String).filter((warning) => warning !== '')
|
|
290
|
+
for (const warning of warnings) lines.push(`WARNING ${warning}`)
|
|
291
|
+
return [...lines, ...wrapFooter(report)]
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function wrapFooter(report: Record_): string[] {
|
|
295
|
+
const footer = footerLines(report)
|
|
296
|
+
return footer.length === 0 ? [] : ['', ...footer]
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* A refusal, or a shape this module does not recognize: state the reason first, then the
|
|
301
|
+
* remaining fields compactly so nothing is lost, without paying for indentation.
|
|
302
|
+
*/
|
|
303
|
+
function renderFallbackReport(report: Record_, options: Required<ReportViewOptions>): string[] {
|
|
304
|
+
const lines = headerLines(report, options)
|
|
305
|
+
const message = text(report.error)
|
|
306
|
+
if (message !== undefined) {
|
|
307
|
+
const code = text(report.errorCode)
|
|
308
|
+
lines.push(`ERROR ${message}${code === undefined ? '' : ` [${code}]`}`)
|
|
309
|
+
}
|
|
310
|
+
const rest: Record_ = { ...report }
|
|
311
|
+
delete rest.error
|
|
312
|
+
delete rest.errorCode
|
|
313
|
+
lines.push('', JSON.stringify(rest))
|
|
314
|
+
return [...lines, ...wrapFooter(report)]
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Render any logicprobe result as the text a model reads.
|
|
319
|
+
*
|
|
320
|
+
* Order of recognition: a `checks[]` report, a flat `findings[]` report, a baseline delta,
|
|
321
|
+
* a payload result (`diagram`/`primary`), and finally compact JSON so an unrecognized shape
|
|
322
|
+
* is still readable rather than silently empty.
|
|
323
|
+
*/
|
|
324
|
+
export function renderReportText(value: unknown, options: ReportViewOptions = {}): string {
|
|
325
|
+
const resolved: Required<ReportViewOptions> = { ...DEFAULTS, ...options }
|
|
326
|
+
if (resolved.full) return JSON.stringify(value as JsonValue, null, 2)
|
|
327
|
+
const report = record(value)
|
|
328
|
+
if (report === undefined) return JSON.stringify(value as JsonValue) ?? String(value)
|
|
329
|
+
const hasChecks = array(report.checks).some((check) => record(check) !== undefined)
|
|
330
|
+
const hasFindings = array(report.findings).length > 0
|
|
331
|
+
const isDelta = array(report.added).length + array(report.removed).length + array(report.changed).length > 0
|
|
332
|
+
const isPayload = typeof report.diagram === 'string' || typeof report.primary === 'string'
|
|
333
|
+
const lines = hasChecks
|
|
334
|
+
? renderChecksReport(report, resolved)
|
|
335
|
+
: isDelta
|
|
336
|
+
? renderDeltaReport(report, resolved)
|
|
337
|
+
: hasFindings
|
|
338
|
+
? renderFindingsReport(report, resolved)
|
|
339
|
+
: isPayload
|
|
340
|
+
? renderPayloadReport(report, resolved)
|
|
341
|
+
: renderFallbackReport(report, resolved)
|
|
342
|
+
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()
|
|
343
|
+
}
|
package/src/structure-tool.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { JsonValue } from './json-value.js'
|
|
|
3
3
|
import { REPORT_SCHEMAS } from './engine.js'
|
|
4
4
|
import { reviewStructure } from './structure.js'
|
|
5
5
|
import { reviewGranularity, type GranularityDiagram } from './granularity.js'
|
|
6
|
+
import { renderReportText } from './report-view.js'
|
|
6
7
|
|
|
7
8
|
export const LOGICPROBE_STRUCTURE_TOOL_NAME = 'logicprobe_structure_verify'
|
|
8
9
|
|
|
@@ -36,14 +37,19 @@ export const logicProbeStructureTool = defineTool({
|
|
|
36
37
|
type: 'json',
|
|
37
38
|
description: 'Optional dependency matrix: {rules: [{id, source, allow?, deny?, require?}], layers: [{name, members}], default: "allow"|"deny"}. Every edge is judged against it and the matched rule ids are reported.',
|
|
38
39
|
},
|
|
40
|
+
reportView: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
enum: ['digest', 'full'],
|
|
43
|
+
description: 'digest (default) prints the readable body; full prints the structured report, which logicprobe_report_diff needs.',
|
|
44
|
+
},
|
|
39
45
|
},
|
|
40
46
|
output: {
|
|
41
47
|
schema: {
|
|
42
48
|
type: 'json',
|
|
43
49
|
description: 'Structure review report: graph (nodes/edges), findings UML020-UML026, per-edge matrix verdicts, summary and next steps.',
|
|
44
50
|
},
|
|
45
|
-
render(
|
|
46
|
-
return [{ type: 'text' as const, text:
|
|
51
|
+
render(args, value) {
|
|
52
|
+
return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
|
|
47
53
|
},
|
|
48
54
|
},
|
|
49
55
|
timeoutMs: 10_000,
|
package/src/tool.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import type { JsonValue } from './json-value.js'
|
|
3
3
|
import { runVerification } from './engine.js'
|
|
4
|
+
import { renderReportText } from './report-view.js'
|
|
4
5
|
|
|
5
6
|
export const LOGICPROBE_VERIFY_TOOL_NAME = 'logicprobe_verify'
|
|
6
7
|
|
|
@@ -39,14 +40,19 @@ export const logicProbeVerifyTool = defineTool({
|
|
|
39
40
|
type: 'json',
|
|
40
41
|
description: 'Optional object mapping BEFORE state ids to AFTER state ids. Omit for identity mapping (same state names).',
|
|
41
42
|
},
|
|
43
|
+
reportView: {
|
|
44
|
+
type: 'string',
|
|
45
|
+
enum: ['digest', 'full'],
|
|
46
|
+
description: 'digest (default) prints the readable body; full prints the structured report, which logicprobe_report_diff needs.',
|
|
47
|
+
},
|
|
42
48
|
},
|
|
43
49
|
output: {
|
|
44
50
|
schema: {
|
|
45
51
|
type: 'json',
|
|
46
52
|
description: 'logicprobe verification report with summary and per-check findings.',
|
|
47
53
|
},
|
|
48
|
-
render(
|
|
49
|
-
return [{ type: 'text' as const, text:
|
|
54
|
+
render(args, value) {
|
|
55
|
+
return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
|
|
50
56
|
},
|
|
51
57
|
},
|
|
52
58
|
timeoutMs: 10_000,
|
package/src/uml-tool.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
3
|
import type { JsonValue } from './json-value.js'
|
|
4
4
|
import { REPORT_SCHEMAS, DEFAULT_HASH_SPEC, modelHash, refusalVerdict, verdictOf, verdictOfFindings, type VerdictSummary } from './engine.js'
|
|
5
|
-
import { renderUml, parseUml, parseFindings, reviewUml, UmlError, type UmlDiagram, type UmlFinding, type UmlNotation, type UmlParseResult } from './uml.js'
|
|
5
|
+
import { renderUml, parseUml, parseFindings, reviewUml, explainLabels, UmlError, type UmlDiagram, type UmlFinding, type UmlNotation, type UmlParseResult } from './uml.js'
|
|
6
|
+
import { renderReportText } from './report-view.js'
|
|
6
7
|
|
|
7
8
|
export const LOGICPROBE_UML_TOOL_NAME = 'logicprobe_uml'
|
|
8
9
|
|
|
@@ -21,13 +22,13 @@ export const LOGICPROBE_UML_TOOL_NAME = 'logicprobe_uml'
|
|
|
21
22
|
export const logicProbeUmlTool = defineTool({
|
|
22
23
|
name: LOGICPROBE_UML_TOOL_NAME,
|
|
23
24
|
description:
|
|
24
|
-
'Model a code flow as UML, and audit the modelling. action="render" turns a LogicModelV1 into diagram source: mermaid (state, activity, sequence) or plantuml (state, sequence). action="parse" reads Mermaid or PlantUML state or activity text back into a LogicModelV1, so a hand-drawn diagram can be verified. A sequence diagram is refused: a trace cannot reconstruct a machine. So is any other diagram family (class, ER, gantt, mindmap). That refusal carries errorCode "UML_NOT_A_STATE_DIAGRAM". A PlantUML component, package or deployment diagram declares no kind, so the parser detects those constructs itself. It returns a by-product model and reports the error UML_NOT_A_STATE_DIAGRAM, with `discardedConstructs` and `discardedEdges`. Never treat that model as a model of the file. action="review" audits the modelling: structural defects (UML002-UML009), documentation gaps (UML010-UML015), and a fidelity check that re-parses the rendered diagram (UML017). Give it a model, a diagram, or both. Rendering never invents structure. A construct the notation cannot express becomes a warning. For dependency questions on a component or class diagram, use `logicprobe_structure_verify` instead. Read `verdict`, not `ok`: error findings mean a failed review.',
|
|
25
|
+
'Model a code flow as UML, and audit the modelling. action="render" turns a LogicModelV1 into diagram source: mermaid (state, activity, sequence) or plantuml (state, sequence). action="parse" reads Mermaid or PlantUML state or activity text back into a LogicModelV1, so a hand-drawn diagram can be verified. A sequence diagram is refused: a trace cannot reconstruct a machine. So is any other diagram family (class, ER, gantt, mindmap). That refusal carries errorCode "UML_NOT_A_STATE_DIAGRAM". A PlantUML component, package or deployment diagram declares no kind, so the parser detects those constructs itself. It returns a by-product model and reports the error UML_NOT_A_STATE_DIAGRAM, with `discardedConstructs` and `discardedEdges`. Never treat that model as a model of the file. action="review" audits the modelling: structural defects (UML002-UML009), documentation gaps (UML010-UML015), and a fidelity check that re-parses the rendered diagram (UML017). Give it a model, a diagram, or both. action="explain-labels" prints the label and comment conventions the parser accepts, without needing a diagram. Rendering never invents structure. A construct the notation cannot express becomes a warning. For dependency questions on a component or class diagram, use `logicprobe_structure_verify` instead. Read `verdict`, not `ok`: error findings mean a failed review.',
|
|
25
26
|
parameters: {
|
|
26
27
|
action: {
|
|
27
28
|
type: 'string',
|
|
28
29
|
required: true,
|
|
29
|
-
enum: ['render', 'parse', 'review'],
|
|
30
|
-
description: 'render = model → UML source; parse = UML source → model; review = audit the modelling (and its fidelity to the model).',
|
|
30
|
+
enum: ['render', 'parse', 'review', 'explain-labels'],
|
|
31
|
+
description: 'render = model → UML source; parse = UML source → model; review = audit the modelling (and its fidelity to the model); explain-labels = print the label and comment conventions this parser accepts.',
|
|
31
32
|
},
|
|
32
33
|
model: {
|
|
33
34
|
type: 'json',
|
|
@@ -55,23 +56,21 @@ export const logicProbeUmlTool = defineTool({
|
|
|
55
56
|
type: 'integer',
|
|
56
57
|
description: 'Cap on the sequence trace length when rendering kind=sequence. Default 60.',
|
|
57
58
|
},
|
|
59
|
+
reportView: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
enum: ['digest', 'full'],
|
|
62
|
+
description: 'digest (default) prints the diagram or report body; full prints the structured result as JSON.',
|
|
63
|
+
},
|
|
58
64
|
},
|
|
59
65
|
output: {
|
|
60
66
|
schema: {
|
|
61
67
|
type: 'json',
|
|
62
68
|
description: 'Render result (diagram source), parse result (LogicModelV1 + labels), or the modelling review report with findings, round-trip diff and next steps.',
|
|
63
69
|
},
|
|
64
|
-
render(
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
if (typeof record.diagram === 'string') {
|
|
69
|
-
return [
|
|
70
|
-
{ type: 'text' as const, text: record.diagram },
|
|
71
|
-
{ type: 'text' as const, text: JSON.stringify(value, null, 2) },
|
|
72
|
-
]
|
|
73
|
-
}
|
|
74
|
-
return [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }]
|
|
70
|
+
render(args, value) {
|
|
71
|
+
// A rendered diagram is source text: the report body prints it verbatim, once.
|
|
72
|
+
// Echoing the whole result as JSON printed the same diagram a second time, escaped.
|
|
73
|
+
return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
|
|
75
74
|
},
|
|
76
75
|
},
|
|
77
76
|
timeoutMs: 10_000,
|
|
@@ -107,6 +106,19 @@ export const logicProbeUmlTool = defineTool({
|
|
|
107
106
|
warnings: result.warnings,
|
|
108
107
|
} as unknown as JsonValue
|
|
109
108
|
}
|
|
109
|
+
// R9: the label contract was documented in the library and in `explainLabels()`, but no
|
|
110
|
+
// tool exposed it, so it was reachable only by reading the source or the docs.
|
|
111
|
+
if (args.action === 'explain-labels') {
|
|
112
|
+
const notation = (args.notation === undefined || args.notation === 'auto' ? 'mermaid' : args.notation) as UmlNotation
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
ran: true,
|
|
116
|
+
...verdictOf(0, 0),
|
|
117
|
+
schema: REPORT_SCHEMAS.umlExplainLabels,
|
|
118
|
+
action: 'explain-labels',
|
|
119
|
+
...explainLabels(notation),
|
|
120
|
+
} as unknown as JsonValue
|
|
121
|
+
}
|
|
110
122
|
const report = reviewUml({
|
|
111
123
|
model: args.model,
|
|
112
124
|
diagram: args.diagram,
|
package/src/uml.ts
CHANGED
|
@@ -870,19 +870,23 @@ function declaredIdentifier(text: string): string | undefined {
|
|
|
870
870
|
* file, which is a false guarantee of exactly the kind this plugin exists to prevent.
|
|
871
871
|
*/
|
|
872
872
|
export function parseFindings(parsed: UmlParseResult): UmlFinding[] {
|
|
873
|
-
|
|
873
|
+
// A note block is reported in `discardedConstructs` for transparency, but it does not make
|
|
874
|
+
// the text "not a state diagram": the rest of the file still is one, so only the constructs
|
|
875
|
+
// that actually replace a state declaration count towards the refusal.
|
|
876
|
+
const unsupported = parsed.discardedConstructs.filter((entry) => entry.construct !== 'note')
|
|
877
|
+
if (unsupported.length === 0) return []
|
|
874
878
|
const counts = new Map<string, number>()
|
|
875
|
-
for (const entry of
|
|
879
|
+
for (const entry of unsupported) counts.set(entry.construct, (counts.get(entry.construct) ?? 0) + 1)
|
|
876
880
|
const summary = [...counts.entries()]
|
|
877
881
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
878
882
|
.map(([construct, count]) => construct + ' ×' + String(count))
|
|
879
883
|
.join(', ')
|
|
880
|
-
const shown =
|
|
884
|
+
const shown = unsupported.slice(0, 12).map((entry) => 'line ' + String(entry.line) + ': ' + entry.text).join(' | ')
|
|
881
885
|
return [{
|
|
882
886
|
code: 'UML_NOT_A_STATE_DIAGRAM',
|
|
883
887
|
severity: 'error',
|
|
884
|
-
message: 'the text is not a state or activity diagram: ' + String(
|
|
885
|
-
detail: shown + (
|
|
888
|
+
message: 'the text is not a state or activity diagram: ' + String(unsupported.length) + ' declaration(s) of unsupported construct(s) (' + summary + ') and ' + String(parsed.discardedEdges) + ' arrow(s) between them were read as states and transitions, so the parsed model is not this diagram.',
|
|
889
|
+
detail: shown + (unsupported.length > 12 ? ' | … ' + String(unsupported.length - 12) + ' more' : ''),
|
|
886
890
|
}]
|
|
887
891
|
}
|
|
888
892
|
|
|
@@ -916,7 +920,16 @@ function parseStateDiagram(text: string, notation: UmlNotation): UmlParseResult
|
|
|
916
920
|
// A multi-line note body is prose, not a statement: skip it silently rather than
|
|
917
921
|
// reporting one ignored line per line of text.
|
|
918
922
|
if (inNote) return
|
|
919
|
-
if (noteStart && !/:\s*.+$/.test(line)) {
|
|
923
|
+
if (noteStart && !/:\s*.+$/.test(line)) {
|
|
924
|
+
// R7: a multi-line note block is prose the notation carries and LogicModelV1 does not.
|
|
925
|
+
// Its body is still skipped line by line, but the block itself is NOT silent: it is
|
|
926
|
+
// reported once, like any other unsupported construct, so "the diagram hash changed and
|
|
927
|
+
// the report said nothing" cannot happen. It stays out of `warnings` (no per-line noise)
|
|
928
|
+
// and `parseFindings` keeps it out of the "not a state diagram" verdict.
|
|
929
|
+
raw.discarded.push({ construct: 'note', line: index + 1, text: line })
|
|
930
|
+
inNote = true
|
|
931
|
+
return
|
|
932
|
+
}
|
|
920
933
|
const note = /^note\s+(?:over|right of|left of)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/i.exec(line)
|
|
921
934
|
if (note !== null) {
|
|
922
935
|
declare(note[1])
|
|
@@ -1084,7 +1097,7 @@ export function explainLabels(notation: UmlNotation = 'mermaid'): LabelExplanati
|
|
|
1084
1097
|
renderedForm: 'state "ID(meaning)" as ID',
|
|
1085
1098
|
ignoredLines: [
|
|
1086
1099
|
{ pattern: 'a `' + prefix + '` comment line that is not a `logicprobe:` directive', behaviour: 'UML_PARSE_IGNORED_LINE warning: it carries no state-machine statement' },
|
|
1087
|
-
{ pattern: 'a `note …` block written over several lines', behaviour: 'skipped
|
|
1100
|
+
{ pattern: 'a `note …` block written over several lines', behaviour: 'its body is skipped line by line (prose, not a statement) and the block itself is reported once in discardedConstructs' },
|
|
1088
1101
|
{ pattern: 'notation chrome: @startuml/@enduml, stateDiagram-v2, direction, classDef/style/linkStyle/click, scale, skinparam, title, hide, autonumber', behaviour: 'skipped silently' },
|
|
1089
1102
|
{ pattern: 'anything else the parser cannot read', behaviour: 'UML_PARSE_IGNORED_LINE warning, and the model is built from what it did read' },
|
|
1090
1103
|
],
|