dsh-model-everything 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en-US.md +319 -0
- package/README.md +319 -3
- package/cordis.patch.yml +20 -0
- package/examples/README.md +48 -0
- package/examples/control-dispatcher.json +113 -0
- package/examples/control-dispatcher.overbudget.json +113 -0
- package/examples/ecommerce-data-model.json +185 -0
- package/examples/order-state-machine.after.json +116 -0
- package/examples/order-state-machine.before.json +101 -0
- package/examples/user-migration.after.json +29 -0
- package/examples/user-migration.before.json +29 -0
- package/gate/locale/en.json +6 -0
- package/gate/locale/zh.json +6 -0
- package/gate/package.json +5 -0
- package/lib/baseline.js +249 -0
- package/lib/compose-tool.js +76 -0
- package/lib/concurrency-tool.js +40 -0
- package/lib/concurrency.js +206 -0
- package/lib/data-engine.js +1141 -0
- package/lib/data-tool.js +79 -0
- package/lib/design-engine.js +1149 -0
- package/lib/design-tool.js +55 -0
- package/lib/engine.js +4343 -0
- package/lib/export-tool.js +78 -0
- package/lib/exporters.js +434 -0
- package/lib/gate.js +274 -0
- package/lib/granularity.js +237 -0
- package/lib/index.js +237 -0
- package/lib/json-value.js +18 -0
- package/lib/option-shape.js +62 -0
- package/lib/report-diff-tool.js +63 -0
- package/lib/report-view.js +408 -0
- package/lib/structure-tool.js +91 -0
- package/lib/structure.js +711 -0
- package/lib/tool.js +85 -0
- package/lib/types/baseline.d.ts +65 -0
- package/lib/types/compose-tool.d.ts +7 -0
- package/lib/types/concurrency-tool.d.ts +8 -0
- package/lib/types/concurrency.d.ts +37 -0
- package/lib/types/data-engine.d.ts +211 -0
- package/lib/types/data-tool.d.ts +10 -0
- package/lib/types/design-engine.d.ts +215 -0
- package/lib/types/design-tool.d.ts +11 -0
- package/lib/types/engine.d.ts +494 -0
- package/lib/types/export-tool.d.ts +7 -0
- package/lib/types/exporters.d.ts +37 -0
- package/lib/types/gate.d.ts +91 -0
- package/lib/types/granularity.d.ts +106 -0
- package/lib/types/index.d.ts +45 -0
- package/lib/types/json-value.d.ts +21 -0
- package/lib/types/option-shape.d.ts +22 -0
- package/lib/types/report-diff-tool.d.ts +8 -0
- package/lib/types/report-view.d.ts +43 -0
- package/lib/types/structure-tool.d.ts +10 -0
- package/lib/types/structure.d.ts +152 -0
- package/lib/types/tool.d.ts +11 -0
- package/lib/types/uml-communication.d.ts +109 -0
- package/lib/types/uml-composite.d.ts +107 -0
- package/lib/types/uml-interaction-overview.d.ts +91 -0
- package/lib/types/uml-profile.d.ts +112 -0
- package/lib/types/uml-structure.d.ts +53 -0
- package/lib/types/uml-syntax.d.ts +296 -0
- package/lib/types/uml-timing.d.ts +152 -0
- package/lib/types/uml-tool.d.ts +12 -0
- package/lib/types/uml-usecase.d.ts +58 -0
- package/lib/types/uml.d.ts +303 -0
- package/lib/uml-communication.js +353 -0
- package/lib/uml-composite.js +442 -0
- package/lib/uml-interaction-overview.js +277 -0
- package/lib/uml-profile.js +373 -0
- package/lib/uml-structure.js +306 -0
- package/lib/uml-syntax.js +545 -0
- package/lib/uml-timing.js +974 -0
- package/lib/uml-tool.js +213 -0
- package/lib/uml-usecase.js +532 -0
- package/lib/uml.js +2110 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +124 -6
- package/skills/model-everything/SKILL.md +482 -0
- package/skills/model-everything/references/dsh-model-schema.md +437 -0
- package/skills/model-everything/references/gap-routing-guide.md +42 -0
- package/skills/model-everything/references/hash-spec.md +152 -0
- package/skills/model-everything/references/logic-verification-guide.md +422 -0
- package/skills/model-everything-datamodel/SKILL.md +130 -0
- package/skills/model-everything-datamodel/references/data-model-guide.md +84 -0
- package/skills/model-everything-datamodel/references/data-model-schema.md +128 -0
- package/skills/model-everything-design/SKILL.md +115 -0
- package/skills/model-everything-interleaving/SKILL.md +63 -0
- package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -0
- package/skills/model-everything-interleaving/references/concurrency-risk-guide.md +63 -0
- package/skills/model-everything-report-reading/SKILL.md +137 -0
- package/skills/model-everything-report-reading/references/reading-a-report.md +166 -0
- package/skills/model-everything-report-reading/references/reading-a-report.mmd +59 -0
- package/skills/model-everything-structure/SKILL.md +96 -0
- package/skills/model-everything-structure/references/structure-review-guide.md +201 -0
- package/skills/model-everything-uml/SKILL.md +60 -0
- package/skills/model-everything-uml/references/uml-modeling-guide.md +551 -0
- package/src/baseline.ts +288 -0
- package/src/compose-tool.ts +78 -0
- package/src/concurrency-tool.ts +44 -0
- package/src/concurrency.ts +254 -0
- package/src/data-engine.ts +1208 -0
- package/src/data-tool.ts +83 -0
- package/src/design-engine.ts +1263 -0
- package/src/design-tool.ts +60 -0
- package/src/engine.ts +4564 -0
- package/src/export-tool.ts +81 -0
- package/src/exporters.ts +399 -0
- package/src/gate.ts +330 -0
- package/src/granularity.ts +312 -0
- package/src/index.ts +289 -0
- package/src/json-value.ts +20 -0
- package/src/option-shape.ts +64 -0
- package/src/report-diff-tool.ts +67 -0
- package/src/report-view.ts +451 -0
- package/src/structure-tool.ts +93 -0
- package/src/structure.ts +786 -0
- package/src/tool.ts +94 -0
- package/src/uml-communication.ts +428 -0
- package/src/uml-composite.ts +520 -0
- package/src/uml-interaction-overview.ts +321 -0
- package/src/uml-profile.ts +439 -0
- package/src/uml-structure.ts +317 -0
- package/src/uml-syntax.ts +764 -0
- package/src/uml-timing.ts +1106 -0
- package/src/uml-tool.ts +230 -0
- package/src/uml-usecase.ts +566 -0
- package/src/uml.ts +2139 -0
|
@@ -0,0 +1,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
|
+
|
|
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
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import type { JsonValue } from './json-value.js'
|
|
3
|
+
import { REPORT_SCHEMAS } from './engine.js'
|
|
4
|
+
import { reviewStructure } from './structure.js'
|
|
5
|
+
import { reviewGranularity, type GranularityDiagram } from './granularity.js'
|
|
6
|
+
import { OptionShapeError, optionRefusal, requireArrayOption } from './option-shape.js'
|
|
7
|
+
import { renderReportText } from './report-view.js'
|
|
8
|
+
|
|
9
|
+
export const STRUCTURE_TOOL_NAME = 'uml_structure_verify'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Model-visible DSH tool that audits a *structure* diagram — components, packages,
|
|
13
|
+
* classes, deployment nodes and the dependency arrows between them — against an
|
|
14
|
+
* optional dependency matrix. A structure diagram is not a state machine: this tool
|
|
15
|
+
* reads the graph the diagram actually draws, instead of refusing it (which is what
|
|
16
|
+
* `uml action=parse` does) or pretending the arrows are state transitions
|
|
17
|
+
* (which is what a false `ok` used to hide).
|
|
18
|
+
*/
|
|
19
|
+
export const umlStructureTool = defineTool({
|
|
20
|
+
name: STRUCTURE_TOOL_NAME,
|
|
21
|
+
description:
|
|
22
|
+
'Audit a structure diagram as a dependency graph. Takes PlantUML component, package, class or deployment text, or a Mermaid class diagram. Reports UML020 isolated node, UML021 dangling reference, UML022 cycle (shortest path), UML023 disallowed edge and UML024 layer violation against a matrix, UML025 missing required edge, and UML026 when no node is declared. The matrix is {rules: [{id, source, allow?, deny?, require?}], layers: [{name, members}], default: "allow"|"deny"}. Ids and members accept globs (`*`, `?`). Deny beats allow, and layers run top-first, so downward edges pass and upward ones are UML024. With a matrix, every edge appears in `edgeVerdicts` with the rules it matched. Pass `diagrams` instead of `diagram` to check several levels at once: each child must refine its parent. This audits the diagram, not the code: reconcile it with a source-side scan. Read `verdict`, not `ok`. For state or activity diagrams use `uml` and `model_verify`.',
|
|
23
|
+
parameters: {
|
|
24
|
+
diagram: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Structure-diagram source text: PlantUML component/package/class/deployment, or a Mermaid class diagram. Omit when `diagrams` is given.',
|
|
27
|
+
},
|
|
28
|
+
diagrams: {
|
|
29
|
+
type: 'json',
|
|
30
|
+
description: 'Multi-granularity mode: an array of {name, diagram, parent?} levels. Each diagram is checked on its own AND against its declared parent (UML028 unknown parent, UML029 refinement violation, UML030 parent cycle), with per-pair node/edge counts.',
|
|
31
|
+
},
|
|
32
|
+
notation: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
enum: ['auto', 'plantuml', 'mermaid'],
|
|
35
|
+
description: 'Diagram language. Default auto (detected from the text).',
|
|
36
|
+
},
|
|
37
|
+
matrix: {
|
|
38
|
+
type: 'json',
|
|
39
|
+
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.',
|
|
40
|
+
},
|
|
41
|
+
reportView: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
enum: ['digest', 'full'],
|
|
44
|
+
description: 'digest (default) prints the readable body; full prints the structured report, which report_diff needs.',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
output: {
|
|
48
|
+
schema: {
|
|
49
|
+
type: 'json',
|
|
50
|
+
description: 'Structure review report: graph (nodes/edges), the findings with their codes, per-edge matrix verdicts, summary and next steps.',
|
|
51
|
+
},
|
|
52
|
+
render(args, value) {
|
|
53
|
+
return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
timeoutMs: 10_000,
|
|
57
|
+
isConcurrencySafe: () => true,
|
|
58
|
+
async execute(args) {
|
|
59
|
+
if (args.diagrams !== undefined) {
|
|
60
|
+
try {
|
|
61
|
+
// `diagrams` was read with `.map` behind no guard, and a level's `diagram` with `.split`, so a string
|
|
62
|
+
// or a nameless level escaped as a raw TypeError. The level is the unit a caller writes by hand, so a
|
|
63
|
+
// missing field has to be named with its position.
|
|
64
|
+
requireArrayOption(args.diagrams, 'diagrams', 'STRUCTURE_OPTION_INVALID')
|
|
65
|
+
for (const [index, level] of (args.diagrams as unknown[]).entries()) {
|
|
66
|
+
if (level === null || typeof level !== 'object' || Array.isArray(level)) continue
|
|
67
|
+
const entry = level as Record<string, unknown>
|
|
68
|
+
if (typeof entry.diagram !== 'string') {
|
|
69
|
+
throw new OptionShapeError('STRUCTURE_OPTION_INVALID', 'diagrams[' + String(index) + '].diagram is ' + (entry.diagram === undefined ? 'missing' : typeof entry.diagram) + ', not a string. Every level needs the diagram text it audits.')
|
|
70
|
+
}
|
|
71
|
+
if (typeof entry.name !== 'string' || entry.name === '') {
|
|
72
|
+
throw new OptionShapeError('STRUCTURE_OPTION_INVALID', 'diagrams[' + String(index) + '].name is ' + (entry.name === undefined ? 'missing' : 'not a non-empty string') + '. The name is how a refinement violation says which level it means.')
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return reviewGranularity({
|
|
76
|
+
diagrams: args.diagrams as unknown as GranularityDiagram[],
|
|
77
|
+
notation: (args.notation ?? 'auto') as 'auto' | 'plantuml' | 'mermaid',
|
|
78
|
+
...(args.matrix === undefined ? {} : { matrix: args.matrix }),
|
|
79
|
+
}) as unknown as JsonValue
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return optionRefusal(error, 'STRUCTURE_OPTION_INVALID') as unknown as JsonValue
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (args.diagram === undefined) {
|
|
85
|
+
return { ok: false, ran: false, verdict: 'fail', verdictReason: 'the tool did not run: pass `diagram` (one structure diagram) or `diagrams` (several levels with parents)', schema: REPORT_SCHEMAS.structure, errorCode: 'UML_INPUT', error: 'structure review needs `diagram` or `diagrams`' } as unknown as JsonValue
|
|
86
|
+
}
|
|
87
|
+
return reviewStructure({
|
|
88
|
+
diagram: args.diagram,
|
|
89
|
+
notation: (args.notation ?? 'auto') as 'auto' | 'plantuml' | 'mermaid',
|
|
90
|
+
...(args.matrix === undefined ? {} : { matrix: args.matrix }),
|
|
91
|
+
}) as unknown as JsonValue
|
|
92
|
+
},
|
|
93
|
+
})
|