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
package/lib/engine.js
CHANGED
|
@@ -53,6 +53,19 @@ function verificationNextSteps(checks, coverage) {
|
|
|
53
53
|
steps.push('Re-run with beforeModel/stateMapping after the change to prove the behaviour did not regress (D1-D4).');
|
|
54
54
|
return steps;
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Repair guidance for a rejected model. A refusal reports the offending path and the rule
|
|
58
|
+
* it broke, but not what to write instead; for fields whose absence is a documented trap,
|
|
59
|
+
* that is the difference between "fix it" and "guess again". Derived only from the
|
|
60
|
+
* validation messages, so the list stays deterministic.
|
|
61
|
+
*/
|
|
62
|
+
function refusalGuidance(errors) {
|
|
63
|
+
const steps = [];
|
|
64
|
+
if (errors.some((message) => message.includes('.description'))) {
|
|
65
|
+
steps.push('Add the required "description" string to every invariant (for example "description": "what this invariant guarantees"); a `_`-prefixed key is not a substitute, and the value takes part in `modelHash`.');
|
|
66
|
+
}
|
|
67
|
+
return steps;
|
|
68
|
+
}
|
|
56
69
|
const DEFAULT_MAX_STATES = 10_000;
|
|
57
70
|
const DEFAULT_MAX_PERMUTATION_EVENTS = 5;
|
|
58
71
|
function stableStringify(value) {
|
|
@@ -90,6 +103,7 @@ export const REPORT_SCHEMAS = {
|
|
|
90
103
|
umlRender: 'logicprobe/uml/render/v1',
|
|
91
104
|
umlParse: 'logicprobe/uml/parse/v1',
|
|
92
105
|
umlReview: 'logicprobe/uml/review/v1',
|
|
106
|
+
umlExplainLabels: 'logicprobe/uml/explain-labels/v1',
|
|
93
107
|
structure: 'logicprobe/structure/v1',
|
|
94
108
|
granularity: 'logicprobe/granularity/v1',
|
|
95
109
|
baseline: 'logicprobe/baseline/v1',
|
|
@@ -889,7 +903,9 @@ function checkResult(id, name, findings, detail) {
|
|
|
889
903
|
return {
|
|
890
904
|
id,
|
|
891
905
|
name,
|
|
892
|
-
|
|
906
|
+
// An info finding is a note, not a defect: it must not flip a check to `fail`, or the
|
|
907
|
+
// check would be dragged into the "resolve the failing checks" list for a remark.
|
|
908
|
+
status: errors + warnings === 0 ? 'pass' : 'fail',
|
|
893
909
|
detail: detail + (errors > 0 ? ' (' + errors + ' errors' + (warnings > 0 ? ', ' + warnings + ' warnings' : '') + ')' : warnings > 0 ? ' (' + warnings + ' warnings)' : ''),
|
|
894
910
|
findings,
|
|
895
911
|
};
|
|
@@ -2716,7 +2732,7 @@ export function runVerification(input, options = {}) {
|
|
|
2716
2732
|
...metadata,
|
|
2717
2733
|
summary: { states: 0, transitions: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
|
|
2718
2734
|
checks: [modelCheck],
|
|
2719
|
-
nextSteps: verificationNextSteps([modelCheck], undefined),
|
|
2735
|
+
nextSteps: [...refusalGuidance(validation.errors), ...verificationNextSteps([modelCheck], undefined)],
|
|
2720
2736
|
};
|
|
2721
2737
|
}
|
|
2722
2738
|
const model = validation.model;
|
|
@@ -2791,6 +2807,16 @@ export function runVerification(input, options = {}) {
|
|
|
2791
2807
|
const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0);
|
|
2792
2808
|
const coverageNotes = computeCoverageNotes(model);
|
|
2793
2809
|
const narrativeCoverage = narrativeCoverageOf(model);
|
|
2810
|
+
// R3: a partial narrative is valid, but the gap must be machine-readable instead of only a
|
|
2811
|
+
// sentence in `nextSteps`. It rides on S5 — the check that already reports undocumented
|
|
2812
|
+
// (state, event) combinations — so the documented check count stays 22.
|
|
2813
|
+
if (narrativeCoverage !== undefined && !narrativeComplete(narrativeCoverage)) {
|
|
2814
|
+
checks.find((check) => check.id === 'S5')?.findings.push({
|
|
2815
|
+
code: 'NARRATIVE_PARTIAL',
|
|
2816
|
+
severity: 'info',
|
|
2817
|
+
message: 'Narrative coverage is partial (states ' + narrativeCoverage.states + ', events ' + narrativeCoverage.events + ', scenarios ' + narrativeCoverage.scenarios + '): the checks still run over the whole model, but the undocumented symbols have to be re-derived by the reader.',
|
|
2818
|
+
});
|
|
2819
|
+
}
|
|
2794
2820
|
return {
|
|
2795
2821
|
ok: true,
|
|
2796
2822
|
ran: true,
|
|
@@ -3004,11 +3030,21 @@ export function runCompositionVerification(machinesInput, options = {}) {
|
|
|
3004
3030
|
.map((machine) => 'machine ' + String(machine.index) + ' at ' + machine.state + ': ' + (machine.blocked.length === 0
|
|
3005
3031
|
? 'empty event alphabet'
|
|
3006
3032
|
: machine.blocked.map((entry) => entry.event + ' (' + entry.reason + ')').join(', ')));
|
|
3033
|
+
// A non-terminal state with no outgoing transition anywhere in its own model is a
|
|
3034
|
+
// modelling gap, not a design defect: without this note the census reads as
|
|
3035
|
+
// "the architecture deadlocks" when the model was simply left unfinished.
|
|
3036
|
+
const gaps = node.runtimes
|
|
3037
|
+
.map((runtime, index) => ({ index, state: runtime.state }))
|
|
3038
|
+
.filter((entry) => !isTerminal(models[entry.index], entry.state)
|
|
3039
|
+
&& !models[entry.index].transitions.some((transition) => transition.from === entry.state));
|
|
3040
|
+
const gapNote = gaps.length === 0
|
|
3041
|
+
? ''
|
|
3042
|
+
: ' Modelling note: ' + gaps.map((gap) => 'machine ' + String(gap.index) + ' state ' + gap.state).join(', ') + ' has no outgoing transition in its own model, so this deadlock may be a modelling gap rather than a design defect.';
|
|
3007
3043
|
c1Findings.push({
|
|
3008
3044
|
code: 'C1_COMPOSITION_DEADLOCK',
|
|
3009
3045
|
severity: 'error',
|
|
3010
3046
|
message: 'Composition deadlock: no machine can advance from (' + node.runtimes.map((runtime) => runtime.state).join(', ') + ') while at least one is not terminal.',
|
|
3011
|
-
detail: 'Shortest counterexample (breadth-first): ' + String(node.path.length) + ' step(s) to reach it. ' + reasons.join(' | '),
|
|
3047
|
+
detail: 'Shortest counterexample (breadth-first): ' + String(node.path.length) + ' step(s) to reach it. ' + reasons.join(' | ') + gapNote,
|
|
3012
3048
|
evidence: { steps: node.path, states: node.runtimes.map((runtime) => runtime.state), depth: node.path.length, perMachine, reasons },
|
|
3013
3049
|
});
|
|
3014
3050
|
continue;
|
|
@@ -3057,7 +3093,7 @@ export function runCompositionVerification(machinesInput, options = {}) {
|
|
|
3057
3093
|
const caveat = truncated ? ' The search was truncated at ' + String(maxStates) + ' composite states, so this may be an artefact of the cap rather than a property of the model.' : '';
|
|
3058
3094
|
c2Findings.push({
|
|
3059
3095
|
code: 'C2_RENDEZVOUS_NEVER_FIRES',
|
|
3060
|
-
severity: '
|
|
3096
|
+
severity: 'error',
|
|
3061
3097
|
message: 'Rendezvous event ' + event + ' can never fire: ' + reason + '.' + caveat,
|
|
3062
3098
|
detail: machines.map((machine) => 'machine ' + String(machine.index) + (machine.declares ? ' declares' : ' does not declare')
|
|
3063
3099
|
+ (machine.everEnabled ? ', enabled at ' + (machine.enabledAt.length === 0 ? 'no visited state' : machine.enabledAt.join('/')) : ', never enabled')).join(' | '),
|
|
@@ -3065,15 +3101,18 @@ export function runCompositionVerification(machinesInput, options = {}) {
|
|
|
3065
3101
|
});
|
|
3066
3102
|
}
|
|
3067
3103
|
}
|
|
3068
|
-
|
|
3069
|
-
|
|
3104
|
+
// C2 is an error, not a warning: a rendezvous that can never fire means the composition
|
|
3105
|
+
// cannot advance through that handshake — the same "stuck forever" outcome C1 reports,
|
|
3106
|
+
// so a gate that only reads `verdict` must be able to stop on it.
|
|
3107
|
+
const errors = c1Findings.length + c2Findings.length;
|
|
3108
|
+
const warnings = 0;
|
|
3070
3109
|
const c1Detail = c1Findings.length > 0
|
|
3071
3110
|
? 'Composition deadlocks: ' + String(c1Findings.length)
|
|
3072
3111
|
: truncated
|
|
3073
3112
|
? 'No composition deadlock found, but the search was truncated at ' + String(maxStates) + ' composite states: absence is not proven'
|
|
3074
3113
|
: 'No composition deadlock reachable';
|
|
3075
3114
|
const c2Detail = c2Findings.length > 0
|
|
3076
|
-
? 'Rendezvous
|
|
3115
|
+
? 'Rendezvous errors: ' + String(c2Findings.length)
|
|
3077
3116
|
: truncated
|
|
3078
3117
|
? 'All rendezvous events fired, but the search was truncated at ' + String(maxStates) + ' composite states'
|
|
3079
3118
|
: 'All rendezvous events can fire';
|
|
@@ -3082,10 +3121,21 @@ export function runCompositionVerification(machinesInput, options = {}) {
|
|
|
3082
3121
|
checkResult('C2', 'Rendezvous Sync', c2Findings, c2Detail),
|
|
3083
3122
|
];
|
|
3084
3123
|
const nextSteps = [];
|
|
3085
|
-
if (errors > 0)
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3124
|
+
if (errors > 0) {
|
|
3125
|
+
const why = c1Findings.length > 0
|
|
3126
|
+
? 'the composition deadlocks at a reachable state, so at least one machine is missing an exit or a handshake partner'
|
|
3127
|
+
: 'a rendezvous event can never fire, so the composition cannot advance through that handshake';
|
|
3128
|
+
nextSteps.push('Resolve the error findings first: ' + why + '.');
|
|
3129
|
+
}
|
|
3130
|
+
// Composition couples machines only through rendezvous: each machine's `variables` are
|
|
3131
|
+
// private to it, so two machines cannot share one predicate. Name them when the input
|
|
3132
|
+
// looks like it is trying to (two or more machines each declare variables).
|
|
3133
|
+
const owners = models
|
|
3134
|
+
.map((entry, index) => ({ index, names: (entry.variables ?? []).map((variable) => variable.name) }))
|
|
3135
|
+
.filter((entry) => entry.names.length > 0);
|
|
3136
|
+
if (owners.length >= 2) {
|
|
3137
|
+
nextSteps.push('Each machine\'s variables are private to it (' + owners.map((entry) => 'machine ' + String(entry.index) + ': ' + entry.names.join(', ')).join('; ') + '): composition couples machines only through rendezvous, so a value two machines must agree on has to be modelled as a handshake, or inside a single machine.');
|
|
3138
|
+
}
|
|
3089
3139
|
nextSteps.push('Re-run the composition after the change; every machine must be able to advance, or explain why it is terminal.');
|
|
3090
3140
|
return {
|
|
3091
3141
|
ok: errors === 0,
|
package/lib/export-tool.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
2
|
import { REPORT_SCHEMAS, DEFAULT_HASH_SPEC, modelHash, refusalVerdict, validateModel, verdictOf } from './engine.js';
|
|
3
3
|
import { exportModel } from './exporters.js';
|
|
4
|
+
import { renderReportText } from './report-view.js';
|
|
4
5
|
/** The model hash of an export's input, when the input validates (the success path always does). */
|
|
5
6
|
function exportHashes(model) {
|
|
6
7
|
const validation = validateModel(model);
|
|
@@ -29,14 +30,19 @@ export const logicProbeExportTool = defineTool({
|
|
|
29
30
|
enum: ['uppaal', 'tla', 'prism', 'spin'],
|
|
30
31
|
description: 'Target tool format.',
|
|
31
32
|
},
|
|
33
|
+
reportView: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
enum: ['digest', 'full'],
|
|
36
|
+
description: 'digest (default) prints the generated file verbatim; full prints the structured result as JSON.',
|
|
37
|
+
},
|
|
32
38
|
},
|
|
33
39
|
output: {
|
|
34
40
|
schema: {
|
|
35
41
|
type: 'json',
|
|
36
42
|
description: 'Export result with generated file content.',
|
|
37
43
|
},
|
|
38
|
-
render(
|
|
39
|
-
return [{ type: 'text', text:
|
|
44
|
+
render(args, value) {
|
|
45
|
+
return [{ type: 'text', text: renderReportText(value, { full: args.reportView === 'full' }) }];
|
|
40
46
|
},
|
|
41
47
|
},
|
|
42
48
|
timeoutMs: 10_000,
|
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* logicprobe — DeepSeek Harness native plugin for the Logic Probe toolbox.
|
|
3
|
-
* Injects the session-start gate text (what this plugin checks, the
|
|
4
|
-
* the
|
|
3
|
+
* Injects the session-start gate text (what this plugin checks, the routing to
|
|
4
|
+
* the skills, the tool list, how to read a report)
|
|
5
5
|
* into the first model step of every agent session, mirroring the SessionStart
|
|
6
6
|
* hook the Claude Code plugin installs.
|
|
7
7
|
* The skill ships in this package's `skills/` directory and is registered at
|
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
* to the first step after their promotion, and the history guard re-injects it
|
|
19
19
|
* there. The default gate text is the dsh-native adaptation of
|
|
20
20
|
* `hooks/session-start-content.md`. Both are a guide to calling the tools: what
|
|
21
|
-
* this plugin checks, the
|
|
22
|
-
*
|
|
21
|
+
* this plugin checks, the routing to the sibling skills, the tool list, and how to
|
|
22
|
+
* read a report. The Red Flags table is enforcement scaffolding and lives in the
|
|
23
|
+
* skill body instead, where a session that needs it can load it.
|
|
23
24
|
* They stay in sync. Method, doctrine and per-domain detail live in the skills
|
|
24
25
|
* and their references, not here. Deployments override via Config.
|
|
25
26
|
*
|
|
@@ -49,41 +50,28 @@ const SKILLS_DIR = fileURLToPath(new URL('../skills', import.meta.url));
|
|
|
49
50
|
const GATE_PLUGIN_ID = 'logicprobe';
|
|
50
51
|
/** Producer-owned message source kind declared in `MessageSourceMap` above. */
|
|
51
52
|
const GATE_SOURCE_KIND = 'plugin:logicprobe';
|
|
52
|
-
const DEFAULT_GATE_CONTENT = `
|
|
53
|
-
<EXTREMELY_IMPORTANT>
|
|
54
|
-
Plugin logicprobe is active. It checks claims about designs and code: facts against the source, behaviour as an executable model.
|
|
55
|
-
|
|
56
|
-
**
|
|
57
|
-
|
|
58
|
-
**
|
|
59
|
-
|
|
60
|
-
**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
- \`
|
|
65
|
-
- \`
|
|
66
|
-
- \`
|
|
67
|
-
- \`
|
|
68
|
-
- \`
|
|
69
|
-
- \`
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
**Red Flags** — if you think any of these, stop:
|
|
76
|
-
|
|
77
|
-
| You think | Reality |
|
|
78
|
-
|-----------|---------|
|
|
79
|
-
| "Too simple to check" | The check is cheap. The silent skip is not. |
|
|
80
|
-
| "I read the code, so I know" | Knowing is not evidence. Cite \`file:line\`, or run the model, or call the claim unverified. |
|
|
81
|
-
| "The spec or the diagram says so" | Both are claims about the code. Reconcile them with a source-side scan. |
|
|
82
|
-
| "I'll check while implementing" | Check before the decision, not after. |
|
|
83
|
-
| "Reasoning is enough" | Reasoning is not a run. One counter-example kills a universal claim. |
|
|
84
|
-
| "The report passed, so the code is fine" | The report covers the model you gave it. Reconcile it, and say what you did not check. |
|
|
85
|
-
|
|
86
|
-
</EXTREMELY_IMPORTANT>
|
|
53
|
+
const DEFAULT_GATE_CONTENT = `
|
|
54
|
+
<EXTREMELY_IMPORTANT>
|
|
55
|
+
Plugin logicprobe is active. It checks claims about designs and code: facts against the source, behaviour as an executable model.
|
|
56
|
+
|
|
57
|
+
**Unsure whether a check applies?** Load \`logicprobe\` first. It is the entry point: it owns the doctrine, the model schema, the depth rules and the routing to its siblings.
|
|
58
|
+
|
|
59
|
+
**Load the skill that matches the claim**: \`logicprobe\` (entry point; claim and behaviour checks), \`logicprobe-uml\` (diagrams), \`logicprobe-structure\` (architecture, dependencies), \`logicprobe-concurrency\` (concurrency claims), \`logicprobe-datamodel\` (data models, migrations).
|
|
60
|
+
|
|
61
|
+
**Tools** (dsh):
|
|
62
|
+
|
|
63
|
+
- \`logicprobe_verify\` — state machines: structural checks, adversarial probes, budgets, deadlines, before/after regression.
|
|
64
|
+
- \`logicprobe_datamodel_verify\` — data models and migrations: structure, migration coverage, copy consistency, rollback, before/after data.
|
|
65
|
+
- \`logicprobe_compose_verify\` — two or more machines: deadlock, and a rendezvous that never fires.
|
|
66
|
+
- \`logicprobe_concurrency_scan\` — finds concurrency claims and routes them. It proves nothing about concurrency.
|
|
67
|
+
- \`logicprobe_export\` — UPPAAL, TLA+, PRISM and SPIN input from a verified model.
|
|
68
|
+
- \`logicprobe_uml\` — render, parse or review a UML diagram.
|
|
69
|
+
- \`logicprobe_structure_verify\` — dependency review of a structure diagram, at one level or several.
|
|
70
|
+
- \`logicprobe_report_diff\` — baseline against current: what a change added, removed or changed.
|
|
71
|
+
|
|
72
|
+
**Read a report**: gate on \`verdict\` (\`pass\`, \`pass_with_findings\`, \`fail\`), never on \`ok\`. Reports carry \`verdictReason\`, \`hashes\` and \`nextSteps\`. Keys starting with \`_\` are notes: ignored, and left out of the model hash. A tool prints the report body: passing checks stay in the inventory line, repeated facts are grouped, and warning evidence is omitted — pass \`reportView: "full"\` only when you need the structured report itself, which \`logicprobe_report_diff\` consumes.
|
|
73
|
+
|
|
74
|
+
</EXTREMELY_IMPORTANT>
|
|
87
75
|
`;
|
|
88
76
|
/**
|
|
89
77
|
* Declare a field as live where this host's schemastery can — `.volatile()`
|
|
@@ -187,7 +175,7 @@ function inspectProvider(config, isToolRegistered, isDataToolRegistered, isConcu
|
|
|
187
175
|
return {
|
|
188
176
|
manifest: {
|
|
189
177
|
id: 'logicprobe',
|
|
190
|
-
description: 'Session-start gate injection and native verification tooling for the Logic Probe toolbox. The gate is a guide to calling the tools: what the plugin checks, the
|
|
178
|
+
description: 'Session-start gate injection and native verification tooling for the Logic Probe toolbox. The gate is a guide to calling the tools: what the plugin checks, the routing to the sibling skills, the tool list, and how to read a report. Doctrine and per-domain detail live in the skills.',
|
|
191
179
|
methods: [
|
|
192
180
|
{
|
|
193
181
|
name: 'status',
|
package/lib/report-diff-tool.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
2
|
import { diffReports } from './baseline.js';
|
|
3
|
+
import { renderReportText } from './report-view.js';
|
|
3
4
|
export const LOGICPROBE_REPORT_DIFF_TOOL_NAME = 'logicprobe_report_diff';
|
|
4
5
|
/**
|
|
5
6
|
* Model-visible DSH tool that compares two reports of the same family — the accepted
|
|
@@ -21,14 +22,19 @@ export const logicProbeReportDiffTool = defineTool({
|
|
|
21
22
|
required: true,
|
|
22
23
|
description: 'The current report to compare against the baseline.',
|
|
23
24
|
},
|
|
25
|
+
reportView: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
enum: ['digest', 'full'],
|
|
28
|
+
description: 'digest (default) prints the readable body; full prints the structured diff report.',
|
|
29
|
+
},
|
|
24
30
|
},
|
|
25
31
|
output: {
|
|
26
32
|
schema: {
|
|
27
33
|
type: 'json',
|
|
28
34
|
description: 'Baseline diff report: added/removed/changed findings, delta verdict, current verdict, summary and next steps.',
|
|
29
35
|
},
|
|
30
|
-
render(
|
|
31
|
-
return [{ type: 'text', text:
|
|
36
|
+
render(args, value) {
|
|
37
|
+
return [{ type: 'text', text: renderReportText(value, { full: args.reportView === 'full' }) }];
|
|
32
38
|
},
|
|
33
39
|
},
|
|
34
40
|
timeoutMs: 10_000,
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
const DEFAULTS = { maxFindingsPerCheck: 24, maxEvidenceChars: 160, includePassing: false, full: false };
|
|
2
|
+
const record = (value) => typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined;
|
|
3
|
+
const array = (value) => (Array.isArray(value) ? value : []);
|
|
4
|
+
const text = (value) => (typeof value === 'string' ? value : undefined);
|
|
5
|
+
const count = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
|
|
6
|
+
/** Normalize one raw finding. Unknown fields are ignored; a missing message renders as the code. */
|
|
7
|
+
export function toViewFinding(value, check) {
|
|
8
|
+
const raw = record(value) ?? {};
|
|
9
|
+
const code = text(raw.code) ?? 'FINDING';
|
|
10
|
+
const file = text(raw.file);
|
|
11
|
+
const line = count(raw.line);
|
|
12
|
+
return {
|
|
13
|
+
code,
|
|
14
|
+
severity: text(raw.severity) ?? 'warning',
|
|
15
|
+
message: text(raw.message) ?? code,
|
|
16
|
+
...(record(raw.evidence) === undefined ? {} : { evidence: record(raw.evidence) }),
|
|
17
|
+
...(file === undefined ? {} : { file }),
|
|
18
|
+
...(line === undefined ? {} : { line }),
|
|
19
|
+
...(check === undefined ? {} : { check }),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The fact a finding states, as a comparable key. The verify/datamodel families report the
|
|
24
|
+
* same (state, event) fact from more than one check, so the key ignores which check said it;
|
|
25
|
+
* anything else falls back to code + message.
|
|
26
|
+
*/
|
|
27
|
+
export function factKey(finding) {
|
|
28
|
+
const stateEvent = /^(\S+) silently ignores event (\S+)/.exec(finding.message);
|
|
29
|
+
if (stateEvent !== null)
|
|
30
|
+
return `event-in-state|${stateEvent[1] ?? ''}|${stateEvent[2] ?? ''}`;
|
|
31
|
+
const eventState = /^Event (\S+) in state (\S+) /.exec(finding.message);
|
|
32
|
+
if (eventState !== null)
|
|
33
|
+
return `event-in-state|${eventState[2] ?? ''}|${eventState[1] ?? ''}`;
|
|
34
|
+
return `message|${finding.code}|${finding.message}`;
|
|
35
|
+
}
|
|
36
|
+
/** `[severity/CODE] message` plus, for errors, a compact evidence tail. */
|
|
37
|
+
function findingLine(finding, options) {
|
|
38
|
+
const where = finding.file === undefined ? '' : ` (${finding.file}${finding.line === undefined ? '' : ':' + String(finding.line)})`;
|
|
39
|
+
const evidence = finding.evidence === undefined || finding.severity !== 'error'
|
|
40
|
+
? ''
|
|
41
|
+
: ' evidence=' + truncate(JSON.stringify(finding.evidence), options.maxEvidenceChars);
|
|
42
|
+
return ` [${finding.severity}/${finding.code}] ${finding.message}${where}${evidence}`;
|
|
43
|
+
}
|
|
44
|
+
function truncate(value, limit) {
|
|
45
|
+
return value.length <= limit ? value : value.slice(0, limit - 1) + '…';
|
|
46
|
+
}
|
|
47
|
+
/** Collapse identical lines into `line (xN)`, keeping first-seen order. */
|
|
48
|
+
function collapse(lines) {
|
|
49
|
+
const order = [];
|
|
50
|
+
const seen = new Map();
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
if (!seen.has(line))
|
|
53
|
+
order.push(line);
|
|
54
|
+
seen.set(line, (seen.get(line) ?? 0) + 1);
|
|
55
|
+
}
|
|
56
|
+
return order.map((line) => (seen.get(line) === 1 ? line : `${line} (x${String(seen.get(line))})`));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Body lines for one group of findings. Findings that restate a fact an earlier check already
|
|
60
|
+
* reported are dropped (only their count survives), because re-reading them is pure cost;
|
|
61
|
+
* `error` findings are never dropped.
|
|
62
|
+
*/
|
|
63
|
+
function findingLines(findings, options, seenFacts) {
|
|
64
|
+
const lines = [];
|
|
65
|
+
let repeats = 0;
|
|
66
|
+
const fresh = [];
|
|
67
|
+
const addedHere = new Set();
|
|
68
|
+
for (const finding of findings) {
|
|
69
|
+
const key = factKey(finding);
|
|
70
|
+
// A fact another check already reported is dropped here (only its count survives), but a
|
|
71
|
+
// fact repeated inside this same group is not a cross-check repeat: it collapses below
|
|
72
|
+
// into one line with a multiplier, which keeps the count visible where it belongs.
|
|
73
|
+
if (finding.severity !== 'error' && seenFacts.has(key) && !addedHere.has(key)) {
|
|
74
|
+
repeats += 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
addedHere.add(key);
|
|
78
|
+
seenFacts.add(key);
|
|
79
|
+
fresh.push(finding);
|
|
80
|
+
}
|
|
81
|
+
const stateGroups = new Map();
|
|
82
|
+
const singles = [];
|
|
83
|
+
for (const finding of fresh) {
|
|
84
|
+
const match = /^(\S+) silently ignores event (\S+)/.exec(finding.message) ?? /^Event (\S+) in state (\S+) /.exec(finding.message);
|
|
85
|
+
if (match === null) {
|
|
86
|
+
singles.push(finding);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// `event in state` puts the state second; `state ignores event` puts it first.
|
|
90
|
+
const state = / silently ignores event /.test(finding.message) ? match[1] ?? '' : match[2] ?? '';
|
|
91
|
+
const event = / silently ignores event /.test(finding.message) ? match[2] ?? '' : match[1] ?? '';
|
|
92
|
+
const group = stateGroups.get(state) ?? { severity: finding.severity, code: finding.code, events: [] };
|
|
93
|
+
group.events.push(event);
|
|
94
|
+
stateGroups.set(state, group);
|
|
95
|
+
}
|
|
96
|
+
for (const [state, group] of stateGroups) {
|
|
97
|
+
lines.push(` ${state} (${String(group.events.length)}) [${group.severity}/${group.code}]: ${group.events.join(', ')}`);
|
|
98
|
+
}
|
|
99
|
+
const rendered = collapse(singles.map((finding) => findingLine(finding, options)));
|
|
100
|
+
const budget = options.maxFindingsPerCheck;
|
|
101
|
+
const kept = [];
|
|
102
|
+
let dropped = 0;
|
|
103
|
+
for (const line of [...lines, ...rendered]) {
|
|
104
|
+
if (kept.length < budget || line.includes('/error]'))
|
|
105
|
+
kept.push(line);
|
|
106
|
+
else
|
|
107
|
+
dropped += 1;
|
|
108
|
+
}
|
|
109
|
+
if (dropped > 0)
|
|
110
|
+
kept.push(` +${String(dropped)} more warning line(s) — ask for this check by id to see them all`);
|
|
111
|
+
if (repeats > 0)
|
|
112
|
+
kept.push(` (${String(repeats)} finding(s) here repeat facts already reported above)`);
|
|
113
|
+
return kept;
|
|
114
|
+
}
|
|
115
|
+
/** `ok`/`verdict` header fields, shared by every report family. */
|
|
116
|
+
function headerLines(report, options) {
|
|
117
|
+
const schema = text(report.schema) ?? 'logicprobe/report';
|
|
118
|
+
const verdict = text(report.verdict);
|
|
119
|
+
const ok = report.ok;
|
|
120
|
+
const summary = record(report.summary) ?? {};
|
|
121
|
+
const lines = [];
|
|
122
|
+
const bits = [
|
|
123
|
+
`${schema}`,
|
|
124
|
+
verdict === undefined ? `ok=${String(ok)}` : `verdict=${verdict}`,
|
|
125
|
+
text(report.verdictReason) ?? '',
|
|
126
|
+
ok === undefined || verdict === undefined ? '' : `ok=${String(ok)}`,
|
|
127
|
+
].filter((bit) => bit !== '');
|
|
128
|
+
lines.push(bits.join(' '));
|
|
129
|
+
const hash = text(report.modelHash) ?? text(record(report.hashes)?.modelHash);
|
|
130
|
+
const counts = ['states', 'transitions', 'errors', 'warnings', 'checksRun']
|
|
131
|
+
.map((key) => (count(summary[key]) === undefined ? '' : `${key}=${String(count(summary[key]))}`))
|
|
132
|
+
.filter((bit) => bit !== '');
|
|
133
|
+
if (counts.length > 0 || hash !== undefined)
|
|
134
|
+
lines[0] += ' ' + [...(hash === undefined ? [] : [`modelHash=${hash.slice(0, 12)}…`]), ...counts].join(' ');
|
|
135
|
+
if (report.ran === false)
|
|
136
|
+
lines.push('note: the tool did not run (the engine refused the input) — the fields below describe the refusal.');
|
|
137
|
+
const coverage = record(report.narrativeCoverage);
|
|
138
|
+
if (coverage !== undefined) {
|
|
139
|
+
const parts = Object.entries(coverage)
|
|
140
|
+
.filter(([, value]) => count(value) !== undefined)
|
|
141
|
+
.map(([key, value]) => `${key} ${String(count(value))}`);
|
|
142
|
+
if (parts.length > 0)
|
|
143
|
+
lines.push(`NARRATIVE ${parts.join(' / ')} documented`);
|
|
144
|
+
}
|
|
145
|
+
void options;
|
|
146
|
+
return lines;
|
|
147
|
+
}
|
|
148
|
+
/** Trailing lines: notes about what was not checked, then the next steps. */
|
|
149
|
+
function footerLines(report) {
|
|
150
|
+
const lines = [];
|
|
151
|
+
const notes = array(report.coverageNotes).map(String).filter((note) => note !== '');
|
|
152
|
+
if (notes.length > 0)
|
|
153
|
+
lines.push(`COVERAGE ${notes.join(' ')}`);
|
|
154
|
+
const steps = array(report.nextSteps).map(String).filter((step) => step !== '');
|
|
155
|
+
for (const step of steps)
|
|
156
|
+
lines.push(`NEXT ${step}`);
|
|
157
|
+
const comparison = record(report.comparison);
|
|
158
|
+
if (comparison !== undefined) {
|
|
159
|
+
const parts = Object.entries(comparison)
|
|
160
|
+
.filter(([, value]) => count(value) !== undefined || typeof value === 'string')
|
|
161
|
+
.map(([key, value]) => `${key}=${String(value)}`);
|
|
162
|
+
if (parts.length > 0)
|
|
163
|
+
lines.push(`COMPARISON ${parts.join(' ')}`);
|
|
164
|
+
}
|
|
165
|
+
return lines;
|
|
166
|
+
}
|
|
167
|
+
/** A `checks[]` report (verify, compose, datamodel): silence pass, expand the rest. */
|
|
168
|
+
function renderChecksReport(report, options) {
|
|
169
|
+
const checks = array(report.checks).map((value) => record(value) ?? {});
|
|
170
|
+
const byStatus = (status) => checks.filter((check) => text(check.status) === status).map((check) => text(check.id) ?? '?');
|
|
171
|
+
const inventory = [
|
|
172
|
+
['pass', byStatus('pass')],
|
|
173
|
+
['fail', byStatus('fail')],
|
|
174
|
+
['skip', byStatus('skip')],
|
|
175
|
+
]
|
|
176
|
+
.filter(([, ids]) => ids.length > 0)
|
|
177
|
+
.map(([status, ids]) => `${String(status)} ${ids.join(' ')}`)
|
|
178
|
+
.join(' | ');
|
|
179
|
+
const lines = [...headerLines(report, options), `CHECKS ${inventory}`];
|
|
180
|
+
const seenFacts = new Set();
|
|
181
|
+
for (const check of checks) {
|
|
182
|
+
const status = text(check.status) ?? 'skip';
|
|
183
|
+
const findings = array(check.findings).map((finding) => toViewFinding(finding, text(check.id)));
|
|
184
|
+
if (status === 'pass' && !options.includePassing)
|
|
185
|
+
continue;
|
|
186
|
+
const body = findingLines(findings, options, seenFacts);
|
|
187
|
+
if (status !== 'fail' && body.length === 0)
|
|
188
|
+
continue;
|
|
189
|
+
lines.push('');
|
|
190
|
+
lines.push(`${status.toUpperCase()} ${text(check.id) ?? '?'} ${text(check.name) ?? ''} — ${text(check.detail) ?? ''} (${String(findings.length)} finding(s))`.replace(/ +— +$/, ''));
|
|
191
|
+
lines.push(...body);
|
|
192
|
+
}
|
|
193
|
+
return [...lines, ...wrapFooter(report)];
|
|
194
|
+
}
|
|
195
|
+
/** A flat `findings[]` report (uml review, structure, granularity, concurrency). */
|
|
196
|
+
function renderFindingsReport(report, options) {
|
|
197
|
+
const findings = array(report.findings).map((finding) => toViewFinding(finding));
|
|
198
|
+
const errors = findings.filter((finding) => finding.severity === 'error').length;
|
|
199
|
+
const warnings = findings.length - errors;
|
|
200
|
+
const byCode = new Map();
|
|
201
|
+
for (const finding of findings)
|
|
202
|
+
byCode.set(finding.code, (byCode.get(finding.code) ?? 0) + 1);
|
|
203
|
+
const lines = [
|
|
204
|
+
...headerLines(report, options),
|
|
205
|
+
`FINDINGS ${String(findings.length)} total (${String(errors)} error, ${String(warnings)} warning): ${[...byCode].map(([code, n]) => `${code} x${String(n)}`).join(' | ')}`,
|
|
206
|
+
];
|
|
207
|
+
const body = findingLines(findings, options, new Set());
|
|
208
|
+
if (body.length > 0)
|
|
209
|
+
lines.push('', ...body);
|
|
210
|
+
return [...lines, ...wrapFooter(report)];
|
|
211
|
+
}
|
|
212
|
+
/** A baseline diff report: what a change added, removed or changed. */
|
|
213
|
+
function renderDeltaReport(report, options) {
|
|
214
|
+
const lines = headerLines(report, options);
|
|
215
|
+
const delta = record(report.delta) ?? report;
|
|
216
|
+
const section = (key) => array(delta[key]).map((finding) => toViewFinding(finding));
|
|
217
|
+
for (const key of ['added', 'removed', 'changed']) {
|
|
218
|
+
const findings = section(key);
|
|
219
|
+
if (findings.length === 0)
|
|
220
|
+
continue;
|
|
221
|
+
lines.push('', `${key.toUpperCase()} (${String(findings.length)})`);
|
|
222
|
+
lines.push(...findingLines(findings, options, new Set()));
|
|
223
|
+
}
|
|
224
|
+
const current = text(report.currentVerdict) ?? text(record(report.current)?.verdict);
|
|
225
|
+
if (current !== undefined)
|
|
226
|
+
lines.push(`CURRENT verdict=${current}`);
|
|
227
|
+
return [...lines, ...wrapFooter(report)];
|
|
228
|
+
}
|
|
229
|
+
/** A payload result (a rendered diagram, an exported file): print the payload, not JSON of it. */
|
|
230
|
+
function renderPayloadReport(report, options) {
|
|
231
|
+
const lines = headerLines(report, options);
|
|
232
|
+
const primary = text(report.diagram) ?? text(report.primary);
|
|
233
|
+
if (primary !== undefined)
|
|
234
|
+
lines.push('', primary.trimEnd());
|
|
235
|
+
const extras = record(report.extras);
|
|
236
|
+
for (const [name, content] of Object.entries(extras ?? {})) {
|
|
237
|
+
const body = text(content);
|
|
238
|
+
if (body !== undefined)
|
|
239
|
+
lines.push('', `--- ${name} ---`, body.trimEnd());
|
|
240
|
+
}
|
|
241
|
+
const warnings = array(report.warnings).map(String).filter((warning) => warning !== '');
|
|
242
|
+
for (const warning of warnings)
|
|
243
|
+
lines.push(`WARNING ${warning}`);
|
|
244
|
+
return [...lines, ...wrapFooter(report)];
|
|
245
|
+
}
|
|
246
|
+
function wrapFooter(report) {
|
|
247
|
+
const footer = footerLines(report);
|
|
248
|
+
return footer.length === 0 ? [] : ['', ...footer];
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* A refusal, or a shape this module does not recognize: state the reason first, then the
|
|
252
|
+
* remaining fields compactly so nothing is lost, without paying for indentation.
|
|
253
|
+
*/
|
|
254
|
+
function renderFallbackReport(report, options) {
|
|
255
|
+
const lines = headerLines(report, options);
|
|
256
|
+
const message = text(report.error);
|
|
257
|
+
if (message !== undefined) {
|
|
258
|
+
const code = text(report.errorCode);
|
|
259
|
+
lines.push(`ERROR ${message}${code === undefined ? '' : ` [${code}]`}`);
|
|
260
|
+
}
|
|
261
|
+
const rest = { ...report };
|
|
262
|
+
delete rest.error;
|
|
263
|
+
delete rest.errorCode;
|
|
264
|
+
lines.push('', JSON.stringify(rest));
|
|
265
|
+
return [...lines, ...wrapFooter(report)];
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Render any logicprobe result as the text a model reads.
|
|
269
|
+
*
|
|
270
|
+
* Order of recognition: a `checks[]` report, a flat `findings[]` report, a baseline delta,
|
|
271
|
+
* a payload result (`diagram`/`primary`), and finally compact JSON so an unrecognized shape
|
|
272
|
+
* is still readable rather than silently empty.
|
|
273
|
+
*/
|
|
274
|
+
export function renderReportText(value, options = {}) {
|
|
275
|
+
const resolved = { ...DEFAULTS, ...options };
|
|
276
|
+
if (resolved.full)
|
|
277
|
+
return JSON.stringify(value, null, 2);
|
|
278
|
+
const report = record(value);
|
|
279
|
+
if (report === undefined)
|
|
280
|
+
return JSON.stringify(value) ?? String(value);
|
|
281
|
+
const hasChecks = array(report.checks).some((check) => record(check) !== undefined);
|
|
282
|
+
const hasFindings = array(report.findings).length > 0;
|
|
283
|
+
const isDelta = array(report.added).length + array(report.removed).length + array(report.changed).length > 0;
|
|
284
|
+
const isPayload = typeof report.diagram === 'string' || typeof report.primary === 'string';
|
|
285
|
+
const lines = hasChecks
|
|
286
|
+
? renderChecksReport(report, resolved)
|
|
287
|
+
: isDelta
|
|
288
|
+
? renderDeltaReport(report, resolved)
|
|
289
|
+
: hasFindings
|
|
290
|
+
? renderFindingsReport(report, resolved)
|
|
291
|
+
: isPayload
|
|
292
|
+
? renderPayloadReport(report, resolved)
|
|
293
|
+
: renderFallbackReport(report, resolved);
|
|
294
|
+
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
|
|
295
|
+
}
|
package/lib/structure-tool.js
CHANGED
|
@@ -2,6 +2,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
|
2
2
|
import { REPORT_SCHEMAS } from './engine.js';
|
|
3
3
|
import { reviewStructure } from './structure.js';
|
|
4
4
|
import { reviewGranularity } from './granularity.js';
|
|
5
|
+
import { renderReportText } from './report-view.js';
|
|
5
6
|
export const LOGICPROBE_STRUCTURE_TOOL_NAME = 'logicprobe_structure_verify';
|
|
6
7
|
/**
|
|
7
8
|
* Model-visible DSH tool that audits a *structure* diagram — components, packages,
|
|
@@ -32,14 +33,19 @@ export const logicProbeStructureTool = defineTool({
|
|
|
32
33
|
type: 'json',
|
|
33
34
|
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.',
|
|
34
35
|
},
|
|
36
|
+
reportView: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
enum: ['digest', 'full'],
|
|
39
|
+
description: 'digest (default) prints the readable body; full prints the structured report, which logicprobe_report_diff needs.',
|
|
40
|
+
},
|
|
35
41
|
},
|
|
36
42
|
output: {
|
|
37
43
|
schema: {
|
|
38
44
|
type: 'json',
|
|
39
45
|
description: 'Structure review report: graph (nodes/edges), findings UML020-UML026, per-edge matrix verdicts, summary and next steps.',
|
|
40
46
|
},
|
|
41
|
-
render(
|
|
42
|
-
return [{ type: 'text', text:
|
|
47
|
+
render(args, value) {
|
|
48
|
+
return [{ type: 'text', text: renderReportText(value, { full: args.reportView === 'full' }) }];
|
|
43
49
|
},
|
|
44
50
|
},
|
|
45
51
|
timeoutMs: 10_000,
|