dsh-model-everything 0.2.0 → 0.4.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 +104 -76
- package/README.md +84 -71
- package/lib/automaton-tool.js +70 -0
- package/lib/automaton.js +328 -0
- package/lib/compose-tool.js +7 -0
- package/lib/concurrency-tool.js +7 -0
- package/lib/data-tool.js +7 -0
- package/lib/design-tool.js +8 -1
- package/lib/edit-tool.js +113 -0
- package/lib/editor.js +293 -0
- package/lib/engine.js +7 -0
- package/lib/export-tool.js +8 -0
- package/lib/index.js +4 -0
- package/lib/presentation.js +309 -0
- package/lib/report-diff-tool.js +7 -0
- package/lib/report-view.js +62 -10
- package/lib/routing.js +85 -0
- package/lib/scan.js +547 -0
- package/lib/structure-tool.js +35 -1
- package/lib/structure.js +28 -3
- package/lib/tool.js +7 -0
- package/lib/types/automaton-tool.d.ts +2 -0
- package/lib/types/automaton.d.ts +57 -0
- package/lib/types/edit-tool.d.ts +2 -0
- package/lib/types/editor.d.ts +46 -0
- package/lib/types/engine.d.ts +1 -0
- package/lib/types/presentation.d.ts +164 -0
- package/lib/types/routing.d.ts +31 -0
- package/lib/types/scan.d.ts +37 -0
- package/lib/types/structure.d.ts +16 -0
- package/lib/uml-tool.js +21 -6
- package/package.json +6 -3
- package/skills/model-everything/SKILL.md +6 -2
- package/skills/model-everything/references/automata.md +102 -0
- package/src/automaton-tool.ts +74 -0
- package/src/automaton.ts +376 -0
- package/src/compose-tool.ts +13 -5
- package/src/concurrency-tool.ts +8 -0
- package/src/data-tool.ts +8 -0
- package/src/design-tool.ts +76 -68
- package/src/edit-tool.ts +121 -0
- package/src/editor.ts +297 -0
- package/src/engine.ts +7 -0
- package/src/export-tool.ts +90 -81
- package/src/index.ts +4 -0
- package/src/presentation.ts +365 -0
- package/src/report-diff-tool.ts +8 -0
- package/src/report-view.ts +53 -2
- package/src/routing.ts +96 -0
- package/src/scan.ts +564 -0
- package/src/structure-tool.ts +34 -1
- package/src/structure.ts +37 -3
- package/src/tool.ts +7 -0
- package/src/uml-tool.ts +245 -230
package/src/export-tool.ts
CHANGED
|
@@ -1,81 +1,90 @@
|
|
|
1
|
-
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
-
import type { JsonValue } from './json-value.js'
|
|
3
|
-
import { REPORT_SCHEMAS, DEFAULT_HASH_SPEC, modelHash, refusalVerdict, validateModel, verdictOf } from './engine.js'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import type { JsonValue } from './json-value.js'
|
|
3
|
+
import { REPORT_SCHEMAS, DEFAULT_HASH_SPEC, modelHash, refusalVerdict, validateModel, verdictOf } from './engine.js'
|
|
4
|
+
import { cardWithSource, reportCallView, reportCard, reportResultView } from './presentation.js'
|
|
5
|
+
import { ExportError, exportModel, type ExportFormat } from './exporters.js'
|
|
6
|
+
import { renderReportText } from './report-view.js'
|
|
7
|
+
|
|
8
|
+
/** The model hash of an export's input, when the input validates (the success path always does). */
|
|
9
|
+
function exportHashes(model: unknown): { hashSpec: typeof DEFAULT_HASH_SPEC; modelHash?: string } {
|
|
10
|
+
const validation = validateModel(model)
|
|
11
|
+
return validation.ok
|
|
12
|
+
? { hashSpec: DEFAULT_HASH_SPEC, modelHash: modelHash(validation.model) }
|
|
13
|
+
: { hashSpec: DEFAULT_HASH_SPEC }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const EXPORT_TOOL_NAME = 'model_export'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* DSH tool wrapping the external-tool exporters: turn a LogicModelV1 machine into
|
|
20
|
+
* native input for the tool this plugin routes to (UPPAAL / TLA+ / PRISM / SPIN).
|
|
21
|
+
* v1 translates the core machine; unrepresentable invariants become warnings.
|
|
22
|
+
*/
|
|
23
|
+
export const modelExportTool = defineTool({
|
|
24
|
+
name: EXPORT_TOOL_NAME,
|
|
25
|
+
description:
|
|
26
|
+
'Translate a LogicModelV1 into the input language of an external checker - UPPAAL, TLA+, PRISM or SPIN - for a property this plugin does not decide itself (symbolic model checking, unbounded induction, expected reward). format is one of uppaal (.xta + queries), tla (TLC spec + safety property), prism (.pm + .pctl), spin (Promela + ltl). This tool does not verify anything: for the checks it can decide use `model_verify`. Booleans are exported as 0/1 integers; invariants the target cannot express are returned as warnings, never silently dropped. Returns { ok, format, primary, extras?, warnings? } or { ok: false, error } when the model is invalid or the PRISM enumeration is too large.',
|
|
27
|
+
parameters: {
|
|
28
|
+
model: {
|
|
29
|
+
type: 'json',
|
|
30
|
+
required: true,
|
|
31
|
+
description: 'LogicModelV1 state-machine model to export.',
|
|
32
|
+
},
|
|
33
|
+
format: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
required: true,
|
|
36
|
+
enum: ['uppaal', 'tla', 'prism', 'spin'],
|
|
37
|
+
description: 'Target tool format.',
|
|
38
|
+
},
|
|
39
|
+
reportView: {
|
|
40
|
+
type: 'string',
|
|
41
|
+
enum: ['digest', 'full'],
|
|
42
|
+
description: 'digest (default) prints the generated file verbatim; full prints the structured result as JSON.',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
output: {
|
|
46
|
+
schema: {
|
|
47
|
+
type: 'json',
|
|
48
|
+
description: 'Export result with generated file content.',
|
|
49
|
+
},
|
|
50
|
+
render(args, value) {
|
|
51
|
+
return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
|
|
52
|
+
},
|
|
53
|
+
// The card a UI shows, separated from the text the model reads.
|
|
54
|
+
presentationMeta(_args, value) {
|
|
55
|
+
const report = value as Record<string, unknown>
|
|
56
|
+
return cardWithSource(value, EXPORT_TOOL_NAME, typeof report.primary === 'string' ? report.primary : undefined, typeof report.format === 'string' ? 'artifact: ' + report.format : 'artifact') as unknown as JsonValue
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
presentCall: reportCallView(EXPORT_TOOL_NAME),
|
|
60
|
+
presentResult: reportResultView(EXPORT_TOOL_NAME),
|
|
61
|
+
|
|
62
|
+
timeoutMs: 10_000,
|
|
63
|
+
isConcurrencySafe: () => true,
|
|
64
|
+
async execute(args) {
|
|
65
|
+
try {
|
|
66
|
+
const result = exportModel(args.model, args.format as ExportFormat)
|
|
67
|
+
const warnings = result.warnings ?? []
|
|
68
|
+
const nextSteps: string[] = []
|
|
69
|
+
if (warnings.length > 0) nextSteps.push('The target notation cannot express some invariants; the warnings name each one — verify them in the target tool rather than assuming they carried over.')
|
|
70
|
+
nextSteps.push('Run the exported file through its own tool (verify/compile) before trusting the translation; this plugin guarantees the encoding, not the target checker\'s verdict.')
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
ran: true,
|
|
74
|
+
...verdictOf(0, warnings.length),
|
|
75
|
+
schema: REPORT_SCHEMAS.export,
|
|
76
|
+
format: result.format,
|
|
77
|
+
primary: result.primary,
|
|
78
|
+
...(result.extras === undefined ? {} : { extras: result.extras }),
|
|
79
|
+
hashes: exportHashes(args.model),
|
|
80
|
+
warnings,
|
|
81
|
+
nextSteps,
|
|
82
|
+
} as unknown as JsonValue
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
85
|
+
// The exporter's own refusals carry their code: a limit of the encoding is not a defect in the model.
|
|
86
|
+
const errorCode = error instanceof ExportError ? error.code : 'MODEL_INVALID'
|
|
87
|
+
return { ok: false, ran: false, ...refusalVerdict(message), schema: REPORT_SCHEMAS.export, errorCode, error: message } as unknown as JsonValue
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,8 @@ import { umlRenderTool, umlParseTool, umlReviewTool, umlExplainLabelsTool } from
|
|
|
39
39
|
import { umlStructureTool } from './structure-tool.js'
|
|
40
40
|
import { designTool } from './design-tool.js'
|
|
41
41
|
import { reportDiffTool } from './report-diff-tool.js'
|
|
42
|
+
import { modelEditTool } from './edit-tool.js'
|
|
43
|
+
import { modelAutomatonTool } from './automaton-tool.js'
|
|
42
44
|
import { ENGINE_SCHEMA_VERSION } from './engine.js'
|
|
43
45
|
|
|
44
46
|
// DSH 0.1.7-alpha.1 (session format v4) retires the shared
|
|
@@ -245,6 +247,8 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
245
247
|
ctx.effect(() => tools.register(umlStructureTool), 'dsh-model-everything: structure tool')
|
|
246
248
|
ctx.effect(() => tools.register(designTool), 'dsh-model-everything: design review tool')
|
|
247
249
|
ctx.effect(() => tools.register(reportDiffTool), 'dsh-model-everything: report diff tool')
|
|
250
|
+
ctx.effect(() => tools.register(modelEditTool), 'dsh-model-everything: model edit tool')
|
|
251
|
+
ctx.effect(() => tools.register(modelAutomatonTool), 'dsh-model-everything: automaton tool')
|
|
248
252
|
toolRegistered = true
|
|
249
253
|
dataToolRegistered = true
|
|
250
254
|
concurrencyToolRegistered = true
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a UI shows for a report, separated from what the model reads.
|
|
3
|
+
*
|
|
4
|
+
* A report is written for a model: it names every check, every finding and the next step, because the
|
|
5
|
+
* model has to act on all of it. A reader wants four things instead - did it run, what does it decide,
|
|
6
|
+
* what is the first thing wrong, what to do next - and wants the rest folded away.
|
|
7
|
+
*
|
|
8
|
+
* Two host hooks carry that split:
|
|
9
|
+
*
|
|
10
|
+
* - `output.presentationMeta` projects a small JSON payload the host persists on the result; a client
|
|
11
|
+
* card narrows it without parsing prose.
|
|
12
|
+
* - `presentResult` returns the card body from that payload, so a UI that supports it shows the summary
|
|
13
|
+
* and a UI that does not falls back to the raw text. The model-facing content never changes.
|
|
14
|
+
*
|
|
15
|
+
* Both are pure and replayable: they read the persisted value, never the filesystem.
|
|
16
|
+
*/
|
|
17
|
+
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
18
|
+
import { routeFor } from './routing.js'
|
|
19
|
+
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools'
|
|
20
|
+
|
|
21
|
+
/** One finding, as a card needs it: a code to name it and a sentence to read. */
|
|
22
|
+
export interface CardFinding {
|
|
23
|
+
code: string
|
|
24
|
+
severity: string
|
|
25
|
+
message: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The payload a client card narrows. Small on purpose: a card is a summary, not a second report. */
|
|
29
|
+
export interface ReportCard {
|
|
30
|
+
verdict?: string
|
|
31
|
+
verdictReason?: string
|
|
32
|
+
errors: number
|
|
33
|
+
warnings: number
|
|
34
|
+
checksRun?: number
|
|
35
|
+
first: CardFinding | null
|
|
36
|
+
/** The one step a reader should take, already a sentence. */
|
|
37
|
+
next: string | null
|
|
38
|
+
/** Which family produced this, so a card can pick an icon and a colour without parsing codes. */
|
|
39
|
+
family: string
|
|
40
|
+
/** What this family owns, and where the case just outside it goes. One table, not a second prose copy. */
|
|
41
|
+
route?: { owns: string; instead: string }
|
|
42
|
+
/** `model_edit`: the changes this call applied, as the report lists them. */
|
|
43
|
+
changes?: Array<{ kind: string; what: string; detail?: string }>
|
|
44
|
+
/** `model_edit` and `report_diff`: how the findings moved, when either report could compare. */
|
|
45
|
+
delta?: { added: number; removed: number; changed: number; addedErrors: number }
|
|
46
|
+
/** `report_diff`: the first finding the change adds, so a card can lead with it. */
|
|
47
|
+
firstAdded?: CardFinding
|
|
48
|
+
/** `report_diff`: the current report's own verdict, which differs from the delta's when a clean change lands on a failing report. */
|
|
49
|
+
currentVerdict?: string
|
|
50
|
+
/** The artifact a translation or a rendering produced, as the first lines of it. */
|
|
51
|
+
source?: { label: string; lines: string[]; total: number }
|
|
52
|
+
/** `uml_structure_verify`: the graph's own counts, which are measurements rather than findings. */
|
|
53
|
+
graph?: { nodes: number; edges: number; cycles: number; isolated: number; dangling: number }
|
|
54
|
+
/** `model_design_review`: three levels, because a signal is not a violation and a fact is neither. */
|
|
55
|
+
levels?: { violations: number; signals: number; facts: number; skipped: number }
|
|
56
|
+
/** `model_interleaving_scan`: how many claims were found, and in which families. */
|
|
57
|
+
claims?: { absolute: number; keywords: number; families: Array<{ family: string; count: number }> }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const SHORT = 160
|
|
61
|
+
|
|
62
|
+
function shorten(text: string, limit = SHORT): string {
|
|
63
|
+
const flat = text.replace(/\s+/g, ' ').trim()
|
|
64
|
+
return flat.length <= limit ? flat : flat.slice(0, limit - 1).trimEnd() + '…'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
68
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function array(value: unknown): unknown[] {
|
|
72
|
+
return Array.isArray(value) ? value : []
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Project a report into the card payload.
|
|
77
|
+
*
|
|
78
|
+
* Read defensively: this runs on replay too, and a payload that once had a shape may come back with a
|
|
79
|
+
* field missing. A card that throws would take the whole session view with it.
|
|
80
|
+
*/
|
|
81
|
+
export function reportCard(value: unknown, family: string): ReportCard {
|
|
82
|
+
const report = record(value) ?? {}
|
|
83
|
+
const findings = array(report.findings)
|
|
84
|
+
.map((entry) => record(entry))
|
|
85
|
+
.filter((entry): entry is Record<string, unknown> => entry !== undefined)
|
|
86
|
+
// Two shapes live under one payload: the families with a top-level `findings` array, and the verify
|
|
87
|
+
// family, whose findings sit inside `checks` and whose counts live in `summary`. Reading only the first
|
|
88
|
+
// shape made a failing verification card say "0 error(s)" while its own verdict said fail.
|
|
89
|
+
const violations = array(report.violations)
|
|
90
|
+
const signals = array(report.signals)
|
|
91
|
+
const facts = array(report.facts)
|
|
92
|
+
if (findings.length === 0 && violations.length > 0) {
|
|
93
|
+
// The design family keeps three levels apart on purpose: a violation decides the verdict, a signal is a
|
|
94
|
+
// structural fact, and a fact is a measurement. Only the violations are findings here, or the card would
|
|
95
|
+
// show a signal with an error mark.
|
|
96
|
+
for (const entry of violations) {
|
|
97
|
+
const finding = record(entry)
|
|
98
|
+
if (finding !== undefined) findings.push({ ...finding, severity: 'error' })
|
|
99
|
+
}
|
|
100
|
+
} else if (findings.length === 0 && violations.length === 0 && array(report.violations).length === 0 && !Array.isArray(report.violations)) {
|
|
101
|
+
for (const check of array(report.checks)) {
|
|
102
|
+
for (const entry of array(record(check)?.findings)) {
|
|
103
|
+
const finding = record(entry)
|
|
104
|
+
if (finding !== undefined) findings.push(finding)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const severity = (entry: Record<string, unknown>): string => String(entry.severity ?? 'info')
|
|
109
|
+
const errors = findings.filter((entry) => severity(entry) === 'error')
|
|
110
|
+
const warnings = findings.filter((entry) => severity(entry) === 'warning')
|
|
111
|
+
const summary = record(report.summary)
|
|
112
|
+
const summaryErrors = typeof summary?.errors === 'number' ? summary.errors : undefined
|
|
113
|
+
const summaryWarnings = typeof summary?.warnings === 'number' ? summary.warnings : undefined
|
|
114
|
+
const checksRun = typeof summary?.checksRun === 'number'
|
|
115
|
+
? summary.checksRun
|
|
116
|
+
: array(report.checks).length > 0
|
|
117
|
+
? array(report.checks).length
|
|
118
|
+
: undefined
|
|
119
|
+
const first = errors[0] ?? warnings[0]
|
|
120
|
+
const next = array(report.nextSteps).map((step) => String(step))[0]
|
|
121
|
+
const route = routeFor(family)
|
|
122
|
+
const deltaSummary = record(record(report.reportDelta)?.summary) ?? record(report.summary)
|
|
123
|
+
const delta = deltaSummary === undefined
|
|
124
|
+
? undefined
|
|
125
|
+
: {
|
|
126
|
+
added: Number(deltaSummary.added ?? 0),
|
|
127
|
+
removed: Number(deltaSummary.removed ?? 0),
|
|
128
|
+
changed: Number(deltaSummary.changed ?? 0),
|
|
129
|
+
addedErrors: Number(deltaSummary.addedErrors ?? 0),
|
|
130
|
+
}
|
|
131
|
+
const changes = array(report.changes)
|
|
132
|
+
.map((entry) => record(entry))
|
|
133
|
+
.filter((entry): entry is Record<string, unknown> => entry !== undefined)
|
|
134
|
+
.map((entry) => ({ kind: String(entry.kind ?? '?'), what: String(entry.what ?? ''), ...(typeof entry.detail === 'string' && entry.detail !== '' ? { detail: entry.detail } : {}) }))
|
|
135
|
+
const firstAddedRaw = (array(report.added).map((entry) => record(entry)).find((entry) => entry !== undefined && String(entry.severity ?? '') === 'error')
|
|
136
|
+
?? array(report.added).map((entry) => record(entry)).find((entry) => entry !== undefined))
|
|
137
|
+
return {
|
|
138
|
+
...(typeof report.verdict === 'string' ? { verdict: report.verdict } : {}),
|
|
139
|
+
...(typeof report.verdictReason === 'string' ? { verdictReason: shorten(report.verdictReason) } : {}),
|
|
140
|
+
errors: summaryErrors ?? errors.length,
|
|
141
|
+
warnings: summaryWarnings ?? warnings.length,
|
|
142
|
+
...(checksRun === undefined ? {} : { checksRun }),
|
|
143
|
+
first: first === undefined
|
|
144
|
+
? null
|
|
145
|
+
: { code: String(first.code ?? 'UNKNOWN'), severity: severity(first), message: shorten(String(first.message ?? '')) },
|
|
146
|
+
next: next === undefined ? null : shorten(next),
|
|
147
|
+
family,
|
|
148
|
+
...(route === undefined ? {} : { route: { owns: route.owns, instead: route.notFor.tool } }),
|
|
149
|
+
...(changes.length === 0 ? {} : { changes }),
|
|
150
|
+
...(delta === undefined ? {} : { delta }),
|
|
151
|
+
...(firstAddedRaw === undefined ? {} : { firstAdded: { code: String(firstAddedRaw.code ?? '?'), severity: String(firstAddedRaw.severity ?? 'info'), message: shorten(String(firstAddedRaw.message ?? '')) } }),
|
|
152
|
+
...(typeof report.currentVerdict === 'string' ? { currentVerdict: report.currentVerdict } : {}),
|
|
153
|
+
...(typeof summary?.nodes === 'number' && typeof summary?.edges === 'number'
|
|
154
|
+
? { graph: { nodes: summary.nodes, edges: summary.edges, cycles: Number(summary.cycles ?? 0), isolated: Number(summary.isolated ?? 0), dangling: Number(summary.dangling ?? 0) } }
|
|
155
|
+
: {}),
|
|
156
|
+
...(violations.length === 0 && signals.length === 0 && facts.length === 0
|
|
157
|
+
? {}
|
|
158
|
+
: { levels: { violations: violations.length, signals: signals.length, facts: facts.length, skipped: array(report.checksSkipped).length } }),
|
|
159
|
+
...(typeof summary?.absoluteClaims === 'number'
|
|
160
|
+
? { claims: { absolute: summary.absoluteClaims, keywords: Number(summary.keywords ?? 0), families: claimFamilies(findings) } }
|
|
161
|
+
: {}),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The card body: four lines, in the order a reader asks the questions. */
|
|
166
|
+
export function cardBlocks(card: ReportCard): ContentBlock[] {
|
|
167
|
+
const lines: string[] = []
|
|
168
|
+
const head = card.verdict === undefined ? card.family : card.verdict.toUpperCase()
|
|
169
|
+
const counts = card.checksRun === undefined
|
|
170
|
+
? `${String(card.errors)} error(s), ${String(card.warnings)} warning(s)`
|
|
171
|
+
: `${String(card.checksRun)} checks · ${String(card.errors)} error(s) · ${String(card.warnings)} warning(s)`
|
|
172
|
+
lines.push(head + ' — ' + counts)
|
|
173
|
+
if (card.verdictReason !== undefined) lines.push(card.verdictReason)
|
|
174
|
+
if (card.first != null) {
|
|
175
|
+
// A finding with no message prints its code and stops: a colon with nothing after it reads as a
|
|
176
|
+
// truncated message rather than as an absent one.
|
|
177
|
+
const mark = card.first.severity === 'error' ? '✗ ' : '⚠ '
|
|
178
|
+
lines.push(mark + card.first.code + (card.first.message.trim() === '' ? '' : ': ' + card.first.message))
|
|
179
|
+
}
|
|
180
|
+
if (card.next != null) lines.push('next: ' + card.next)
|
|
181
|
+
if (card.source !== undefined) {
|
|
182
|
+
lines.push('')
|
|
183
|
+
const shown = card.source.lines.length
|
|
184
|
+
lines.push(card.source.label + (card.source.total > shown ? ' (first ' + String(shown) + ' of ' + String(card.source.total) + ' lines)' : ''))
|
|
185
|
+
lines.push(...card.source.lines)
|
|
186
|
+
}
|
|
187
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `presentResult` for a report tool: render the card from the persisted payload, and fall back to the
|
|
192
|
+
* raw content by returning undefined when there is none.
|
|
193
|
+
*/
|
|
194
|
+
/** The last path segment, for a title. No `node:path` here: this runs on replay too. */
|
|
195
|
+
function lastSegment(path: string): string {
|
|
196
|
+
const parts = path.split(/[\\/]/).filter((part) => part !== '' && part !== '.')
|
|
197
|
+
return parts[parts.length - 1] ?? path
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The pending card for a report tool: a title naming the artifact, and the file to follow.
|
|
202
|
+
*
|
|
203
|
+
* From the arguments only, because a UI calls this during streaming and again on replay; the file has not
|
|
204
|
+
* been opened at that point and must not be.
|
|
205
|
+
*/
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The card payload with the artifact previewed.
|
|
209
|
+
*
|
|
210
|
+
* A translation and a rendering both produce text a reader wants to see the shape of, and neither should
|
|
211
|
+
* paste the whole file into a card: the preview is bounded, and it says how much it left out. The report
|
|
212
|
+
* keeps the artifact in full for whoever needs it.
|
|
213
|
+
*/
|
|
214
|
+
export function cardWithSource(value: unknown, family: string, text: string | undefined, label: string, limit = 14): ReportCard {
|
|
215
|
+
const card = reportCard(value, family)
|
|
216
|
+
if (text === undefined || text.trim() === '') return card
|
|
217
|
+
const all = text.split(String.fromCharCode(10))
|
|
218
|
+
const lines = all.slice(0, limit)
|
|
219
|
+
return { ...card, source: { label, lines, total: all.length } }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function reportCallView(family: string) {
|
|
223
|
+
return (args: unknown): ToolCallView | undefined => {
|
|
224
|
+
const a = record(args) ?? {}
|
|
225
|
+
const path = ['modelPath', 'repoPath', 'path']
|
|
226
|
+
.map((key) => a[key])
|
|
227
|
+
.find((value): value is string => typeof value === 'string' && value !== '')
|
|
228
|
+
const kind = family === 'model_edit' ? 'edit' : family.startsWith('uml_') ? 'search' : 'other'
|
|
229
|
+
const title = path === undefined ? family : family + ' ' + lastSegment(path)
|
|
230
|
+
return { card: 'generic', title, kind, ...(path === undefined ? {} : { locations: [{ path }] }) }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The card for `model_edit`: what changed, and what the change did to the findings.
|
|
236
|
+
*
|
|
237
|
+
* Both lists are already in the report, so this is a rendering of the same value rather than a second
|
|
238
|
+
* computation - the rule every card here follows.
|
|
239
|
+
*/
|
|
240
|
+
export function editCardBlocks(card: ReportCard): ContentBlock[] {
|
|
241
|
+
const changes = card.changes ?? []
|
|
242
|
+
const lines: string[] = []
|
|
243
|
+
const head = card.verdict === undefined ? card.family : card.verdict.toUpperCase()
|
|
244
|
+
lines.push(head + (changes.length === 0 ? ' — no change was applied' : ' — ' + String(changes.length) + ' change(s)'))
|
|
245
|
+
for (const change of changes.slice(0, 6)) {
|
|
246
|
+
lines.push('· ' + change.kind + ' ' + change.what + (change.detail === undefined ? '' : ' (' + change.detail + ')'))
|
|
247
|
+
}
|
|
248
|
+
if (changes.length > 6) lines.push('· … ' + String(changes.length - 6) + ' more in the report')
|
|
249
|
+
if (card.delta !== undefined) {
|
|
250
|
+
lines.push('findings: added ' + String(card.delta.added) + ' (' + String(card.delta.addedErrors) + ' error) · removed ' + String(card.delta.removed) + ' · changed ' + String(card.delta.changed))
|
|
251
|
+
}
|
|
252
|
+
if (card.next != null) lines.push('next: ' + card.next)
|
|
253
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The card for `report_diff`: does this change add anything, read as counts and the first added error.
|
|
258
|
+
*
|
|
259
|
+
* A diff's `verdict` is about the delta, so the card leads with the delta and never with the current
|
|
260
|
+
* report's own verdict - the two differ exactly when a clean change lands on an already-failing report.
|
|
261
|
+
*/
|
|
262
|
+
export function diffCardBlocks(card: ReportCard): ContentBlock[] {
|
|
263
|
+
const delta = card.delta
|
|
264
|
+
const lines: string[] = []
|
|
265
|
+
const head = card.verdict === undefined ? card.family : card.verdict.toUpperCase()
|
|
266
|
+
lines.push(head + ' — added ' + String(delta?.added ?? 0) + ' (' + String(delta?.addedErrors ?? 0) + ' error) · removed ' + String(delta?.removed ?? 0) + ' · changed ' + String(delta?.changed ?? 0))
|
|
267
|
+
if (card.currentVerdict !== undefined) lines.push('the current report on its own: ' + card.currentVerdict)
|
|
268
|
+
if (card.firstAdded !== undefined) lines.push('✗ ' + card.firstAdded.code + ': ' + card.firstAdded.message)
|
|
269
|
+
if ((delta?.added ?? 0) === 0) lines.push('nothing was added: this change is accepted by the "no new findings" rule')
|
|
270
|
+
if (card.next != null) lines.push('next: ' + card.next)
|
|
271
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** `presentResult` for `model_edit`, falling back to the generic card when the payload has no changes. */
|
|
275
|
+
export function editResultView(family: string) {
|
|
276
|
+
return (_args: unknown, result: { content: ContentBlock[]; meta?: unknown }): ToolResultView | undefined => {
|
|
277
|
+
const card = record(result.meta) as unknown as ReportCard | undefined
|
|
278
|
+
if (card === undefined || card === null) return undefined
|
|
279
|
+
const changeCount = card.changes === undefined ? '' : ' · ' + String(card.changes.length) + ' change(s)'
|
|
280
|
+
return { card: 'generic', title: family + ' ' + (card.verdict ?? '') + changeCount, content: editCardBlocks(card) }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function reportResultView(family: string) {
|
|
285
|
+
return (_args: unknown, result: { content: ContentBlock[]; meta?: unknown }): ToolResultView | undefined => {
|
|
286
|
+
const card = record(result.meta) as unknown as ReportCard | undefined
|
|
287
|
+
if (card === undefined || card === null) return undefined
|
|
288
|
+
if (card.verdict === undefined && card.next == null && card.first == null) return undefined
|
|
289
|
+
return { card: 'generic', title: family + ' ' + (card.verdict ?? '') , content: cardBlocks(card) }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
/** How many findings per family, for the scan card: the reader wants the shape, not the list. */
|
|
295
|
+
function claimFamilies(findings: Array<Record<string, unknown>>): Array<{ family: string; count: number }> {
|
|
296
|
+
const counts = new Map<string, number>()
|
|
297
|
+
for (const finding of findings) {
|
|
298
|
+
const family = typeof finding.family === 'string' ? finding.family : 'other'
|
|
299
|
+
counts.set(family, (counts.get(family) ?? 0) + 1)
|
|
300
|
+
}
|
|
301
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([family, count]) => ({ family, count }))
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The card for `uml_structure_verify`: the graph's shape first, then the first finding. */
|
|
305
|
+
export function structureCardBlocks(card: ReportCard): ContentBlock[] {
|
|
306
|
+
const lines: string[] = []
|
|
307
|
+
const head = card.verdict === undefined ? card.family : card.verdict.toUpperCase()
|
|
308
|
+
lines.push(head + ' — ' + String(card.errors) + ' error(s) · ' + String(card.warnings) + ' warning(s)')
|
|
309
|
+
if (card.graph !== undefined) {
|
|
310
|
+
lines.push('graph: ' + String(card.graph.nodes) + ' nodes · ' + String(card.graph.edges) + ' edges · ' + String(card.graph.cycles) + ' cycle(s) · ' + String(card.graph.isolated) + ' isolated')
|
|
311
|
+
}
|
|
312
|
+
if (card.first != null) lines.push((card.first.severity === 'error' ? '✗ ' : '⚠ ') + card.first.code + ': ' + card.first.message)
|
|
313
|
+
if (card.next != null) lines.push('next: ' + card.next)
|
|
314
|
+
return [{ type: 'text', text: lines.join(String.fromCharCode(10)) }]
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The card for `model_design_review`: violations, then signals, then facts - never one number. */
|
|
318
|
+
export function designCardBlocks(card: ReportCard): ContentBlock[] {
|
|
319
|
+
const lines: string[] = []
|
|
320
|
+
const head = card.verdict === undefined ? card.family : card.verdict.toUpperCase()
|
|
321
|
+
lines.push(head + ' — ' + String(card.errors) + ' violation(s) · ' + String(card.warnings) + ' signal(s)')
|
|
322
|
+
if (card.levels !== undefined) {
|
|
323
|
+
lines.push('levels: ' + String(card.levels.violations) + ' violation · ' + String(card.levels.signals) + ' signal · ' + String(card.levels.facts) + ' fact · ' + String(card.levels.skipped) + ' check(s) skipped')
|
|
324
|
+
}
|
|
325
|
+
if (card.first != null) lines.push('✗ ' + card.first.code + ': ' + card.first.message)
|
|
326
|
+
if (card.levels !== undefined && card.levels.skipped > 0) lines.push('a skipped check examined nothing: silence there is not a pass')
|
|
327
|
+
if (card.next != null) lines.push('next: ' + card.next)
|
|
328
|
+
return [{ type: 'text', text: lines.join(String.fromCharCode(10)) }]
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The card for `model_interleaving_scan`: claims by family, and the reminder that nothing was proven. */
|
|
332
|
+
export function scanCardBlocks(card: ReportCard): ContentBlock[] {
|
|
333
|
+
const lines: string[] = []
|
|
334
|
+
const head = card.verdict === undefined ? card.family : card.verdict.toUpperCase()
|
|
335
|
+
lines.push(head + ' — ' + String(card.errors) + ' absolute claim(s) · ' + String(card.warnings) + ' risk(s)')
|
|
336
|
+
if (card.claims !== undefined) {
|
|
337
|
+
const families = card.claims.families.map((entry) => entry.family + ' ' + String(entry.count)).join(' · ')
|
|
338
|
+
lines.push('claims: ' + String(card.claims.absolute) + ' absolute, ' + String(card.claims.keywords) + ' keyword(s) hit' + (families === '' ? '' : ' · ' + families))
|
|
339
|
+
}
|
|
340
|
+
if (card.first != null) lines.push('✗ ' + card.first.code + ': ' + card.first.message)
|
|
341
|
+
lines.push('this plugin routed the claims; it proved none of them')
|
|
342
|
+
return [{ type: 'text', text: lines.join(String.fromCharCode(10)) }]
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** One `presentResult` per card body, since the shapes differ but the fallback does not. */
|
|
346
|
+
export function familyResultView(family: string, body: (card: ReportCard) => ContentBlock[]) {
|
|
347
|
+
return (_args: unknown, result: { content: ContentBlock[]; meta?: unknown }): ToolResultView | undefined => {
|
|
348
|
+
const card = record(result.meta) as unknown as ReportCard | undefined
|
|
349
|
+
if (card === undefined || card === null) return undefined
|
|
350
|
+
return { card: 'generic', title: family + ' ' + (card.verdict ?? ''), content: body(card) }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export const structureResultView = familyResultView('uml_structure_verify', structureCardBlocks)
|
|
355
|
+
export const designResultView = familyResultView('model_design_review', designCardBlocks)
|
|
356
|
+
export const scanResultView = familyResultView('model_interleaving_scan', scanCardBlocks)
|
|
357
|
+
|
|
358
|
+
/** `presentResult` for `report_diff`: the delta leads, never the current report's own verdict. */
|
|
359
|
+
export function diffResultView(family: string) {
|
|
360
|
+
return (_args: unknown, result: { content: ContentBlock[]; meta?: unknown }): ToolResultView | undefined => {
|
|
361
|
+
const card = record(result.meta) as unknown as ReportCard | undefined
|
|
362
|
+
if (card === undefined || card === null) return undefined
|
|
363
|
+
return { card: 'generic', title: family + ' ' + (card.verdict ?? ''), content: diffCardBlocks(card) }
|
|
364
|
+
}
|
|
365
|
+
}
|
package/src/report-diff-tool.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
2
2
|
import type { JsonValue } from './json-value.js'
|
|
3
3
|
import { diffReports } from './baseline.js'
|
|
4
4
|
import { refusalVerdict } from './engine.js'
|
|
5
|
+
import { diffResultView, reportCallView, reportCard } from './presentation.js'
|
|
5
6
|
import { renderReportText } from './report-view.js'
|
|
6
7
|
|
|
7
8
|
export const REPORT_DIFF_TOOL_NAME = 'report_diff'
|
|
@@ -54,7 +55,14 @@ export const reportDiffTool = defineTool({
|
|
|
54
55
|
render(args, value) {
|
|
55
56
|
return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
|
|
56
57
|
},
|
|
58
|
+
// The card a UI shows, separated from the text the model reads.
|
|
59
|
+
presentationMeta(_args, value) {
|
|
60
|
+
return reportCard(value, REPORT_DIFF_TOOL_NAME) as unknown as JsonValue
|
|
61
|
+
},
|
|
57
62
|
},
|
|
63
|
+
presentCall: reportCallView(REPORT_DIFF_TOOL_NAME),
|
|
64
|
+
presentResult: diffResultView(REPORT_DIFF_TOOL_NAME),
|
|
65
|
+
|
|
58
66
|
timeoutMs: 10_000,
|
|
59
67
|
isConcurrencySafe: () => true,
|
|
60
68
|
async execute(args) {
|