@llm4ts/shell 2.12.0 → 2.13.1
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/flows/epic-stories.js +9 -3
- package/flows/lib/epic-stories.js +24 -4
- package/kits/soap-ace/README.md +313 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/RUNBOOK.md +102 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/design/api-design.md +620 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/cercaConti.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/cercaMovimenti.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/confermaBonifico.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/dettaglioConto.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/inserisciBonifico.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/revocaBonifico.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/soapui/DemoBank-soapui-project.xml +39 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/DemoBank.wsdl +186 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/LegacyRpc.wsdl +29 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/banking.xsd +10 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/bonifici.xsd +107 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/common.xsd +93 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/conti.xsd +71 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/movimenti.xsd +55 -0
- package/kits/soap-ace/flows/lib/soap/Analysis.js +508 -0
- package/kits/soap-ace/flows/lib/soap/Auth.js +179 -0
- package/kits/soap-ace/flows/lib/soap/Call.js +186 -0
- package/kits/soap-ace/flows/lib/soap/CallPolicy.js +50 -0
- package/kits/soap-ace/flows/lib/soap/Catalog.js +283 -0
- package/kits/soap-ace/flows/lib/soap/Classification.js +254 -0
- package/kits/soap-ace/flows/lib/soap/Design.js +497 -0
- package/kits/soap-ace/flows/lib/soap/DesignProposal.js +204 -0
- package/kits/soap-ace/flows/lib/soap/Discover.js +139 -0
- package/kits/soap-ace/flows/lib/soap/Envelope.js +132 -0
- package/kits/soap-ace/flows/lib/soap/Epic.js +239 -0
- package/kits/soap-ace/flows/lib/soap/Instance.js +1339 -0
- package/kits/soap-ace/flows/lib/soap/Mapping.js +130 -0
- package/kits/soap-ace/flows/lib/soap/Masking.js +508 -0
- package/kits/soap-ace/flows/lib/soap/OpenApi.js +288 -0
- package/kits/soap-ace/flows/lib/soap/Samples.js +287 -0
- package/kits/soap-ace/flows/lib/soap/Scenarios.js +132 -0
- package/kits/soap-ace/flows/lib/soap/SoapUiImport.js +200 -0
- package/kits/soap-ace/flows/lib/soap/Stub.js +34 -0
- package/kits/soap-ace/flows/lib/soap/Transport.js +167 -0
- package/kits/soap-ace/flows/lib/soap/Wsdl.js +533 -0
- package/kits/soap-ace/flows/lib/soap/Xml.js +455 -0
- package/kits/soap-ace/flows/lib/soap/Xsd.js +720 -0
- package/kits/soap-ace/flows/lib/soap/Yaml.js +309 -0
- package/kits/soap-ace/flows/soap-design.js +167 -0
- package/kits/soap-ace/flows/soap-discover.js +85 -0
- package/kits/soap-ace/flows/soap-epic.js +204 -0
- package/kits/soap-ace/flows/soap-sample.js +265 -0
- package/kits/soap-ace/packs/ace12-rest/api-style.md +63 -0
- package/kits/soap-ace/packs/ace12-rest/pack.md +18 -0
- package/kits/soap-ace/packs/ace12-rest/prompts/implement.md +8 -0
- package/kits/soap-ace/packs/ace12-rest/reviewers/contract.md +5 -0
- package/kits/soap-ace/patterns/PAT-ACE-001-esito-to-problem.md +8 -0
- package/kits/soap-ace/patterns/PAT-ACE-002-fault-to-problem.md +8 -0
- package/kits/soap-ace/patterns/PAT-ACE-003-amounts-and-enums.md +8 -0
- package/kits/soap-ace/patterns/PAT-ACE-004-pagination.md +7 -0
- package/kits/soap-ace/patterns/PAT-ACE-005-nil-and-absence.md +7 -0
- package/kits/soap-ace/scaffolds/ace12-rest-api/CONTRIBUTING.md +56 -0
- package/kits/soap-ace/scaffolds/ace12-rest-api/README.md +16 -0
- package/kits/soap-ace/scaffolds/ace12-rest-api/scripts/ace-gates-container.sh +8 -0
- package/kits/soap-ace/scaffolds/ace12-rest-api/scripts/ace-gates.sh +41 -0
- package/package.json +4 -4
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { ComplexTypeDef, elementByName, operationByName, typeByName } from "./Catalog.js";
|
|
4
|
+
import { readEnvelope } from "./Envelope.js";
|
|
5
|
+
import { instanceFromXml, simpleView } from "./Instance.js";
|
|
6
|
+
import { serviceDirectory } from "./Discover.js";
|
|
7
|
+
import { readExchanges } from "./Samples.js";
|
|
8
|
+
import { parseXml } from "./Xml.js";
|
|
9
|
+
import { isYamlList, isYamlMap } from "./Yaml.js";
|
|
10
|
+
// Response analysis: what the recorded exchanges of one operation show about
|
|
11
|
+
// its real behaviour, as evidence the REST design must cite. Everything is
|
|
12
|
+
// computed from masked samples; nothing here calls a model. Paths use `[]`
|
|
13
|
+
// for repeated elements (`conto[].saldo.valore`).
|
|
14
|
+
export class FieldStats extends Schema.Class("FieldStats")({
|
|
15
|
+
path: Schema.String,
|
|
16
|
+
/** From the schema, relative to the parent element: can the field be absent, and repeat? */
|
|
17
|
+
optional: Schema.Boolean,
|
|
18
|
+
repeated: Schema.Boolean,
|
|
19
|
+
/** Responses in which the path appears at least once. */
|
|
20
|
+
presentIn: Schema.Int,
|
|
21
|
+
/** Occurrences with an empty string / xsi:nil. */
|
|
22
|
+
empty: Schema.Int,
|
|
23
|
+
nil: Schema.Int,
|
|
24
|
+
occurrences: Schema.Int,
|
|
25
|
+
/** Up to 12 distinct values (masked), most frequent first. */
|
|
26
|
+
values: Schema.Array(Schema.Struct({ value: Schema.String, count: Schema.Int })),
|
|
27
|
+
distinct: Schema.Int,
|
|
28
|
+
/** Declared enumeration, when there is one. */
|
|
29
|
+
declared: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
30
|
+
maxLength: Schema.Int,
|
|
31
|
+
/** For repeated elements: the most items seen in one response. */
|
|
32
|
+
maxItems: Schema.optionalKey(Schema.Int)
|
|
33
|
+
}) {
|
|
34
|
+
}
|
|
35
|
+
export class OutcomeCode extends Schema.Class("OutcomeCode")({
|
|
36
|
+
code: Schema.String,
|
|
37
|
+
description: Schema.optionalKey(Schema.String),
|
|
38
|
+
count: Schema.Int,
|
|
39
|
+
samples: Schema.Array(Schema.String)
|
|
40
|
+
}) {
|
|
41
|
+
}
|
|
42
|
+
export class FaultStats extends Schema.Class("FaultStats")({
|
|
43
|
+
code: Schema.String,
|
|
44
|
+
reason: Schema.String,
|
|
45
|
+
detailElement: Schema.optionalKey(Schema.String),
|
|
46
|
+
count: Schema.Int,
|
|
47
|
+
samples: Schema.Array(Schema.String)
|
|
48
|
+
}) {
|
|
49
|
+
}
|
|
50
|
+
export class FindingStats extends Schema.Class("FindingStats")({
|
|
51
|
+
path: Schema.String,
|
|
52
|
+
detail: Schema.String,
|
|
53
|
+
count: Schema.Int
|
|
54
|
+
}) {
|
|
55
|
+
}
|
|
56
|
+
export class Pagination extends Schema.Class("Pagination")({
|
|
57
|
+
style: Schema.Literals(["page-number", "offset", "cursor"]),
|
|
58
|
+
requestFields: Schema.Array(Schema.String),
|
|
59
|
+
responseFields: Schema.Array(Schema.String),
|
|
60
|
+
/** The repeated element being paged, when one is evident. */
|
|
61
|
+
items: Schema.optionalKey(Schema.String),
|
|
62
|
+
observed: Schema.String
|
|
63
|
+
}) {
|
|
64
|
+
}
|
|
65
|
+
export class OperationAnalysis extends Schema.Class("OperationAnalysis")({
|
|
66
|
+
version: Schema.Literal(1),
|
|
67
|
+
operation: Schema.String,
|
|
68
|
+
exchanges: Schema.Int,
|
|
69
|
+
fromCalls: Schema.Int,
|
|
70
|
+
fromSoapUi: Schema.Int,
|
|
71
|
+
httpStatuses: Schema.Array(Schema.Struct({ status: Schema.Int, count: Schema.Int })),
|
|
72
|
+
faults: Schema.Array(FaultStats),
|
|
73
|
+
outcomes: Schema.Array(OutcomeCode),
|
|
74
|
+
fields: Schema.Array(FieldStats),
|
|
75
|
+
/** Response fields the schema declares but no sample ever carried. */
|
|
76
|
+
neverObserved: Schema.Array(Schema.String),
|
|
77
|
+
/** Optional request fields no sample request used. */
|
|
78
|
+
requestFieldsUnused: Schema.Array(Schema.String),
|
|
79
|
+
findings: Schema.Array(FindingStats),
|
|
80
|
+
pagination: Schema.optionalKey(Pagination),
|
|
81
|
+
latencyMs: Schema.optionalKey(Schema.Struct({ min: Schema.Int, median: Schema.Int, max: Schema.Int })),
|
|
82
|
+
/** Plain statements the design should consider, derived from the numbers above. */
|
|
83
|
+
observations: Schema.Array(Schema.String)
|
|
84
|
+
}) {
|
|
85
|
+
}
|
|
86
|
+
export const schemaPaths = (catalog, element, maxDepth = 8) => {
|
|
87
|
+
const paths = [];
|
|
88
|
+
const walk = (typeName, prefix, optional, depth, seen) => {
|
|
89
|
+
const type = typeByName(catalog, typeName);
|
|
90
|
+
if (!(type instanceof ComplexTypeDef) || depth > maxDepth || seen.has(typeName))
|
|
91
|
+
return;
|
|
92
|
+
const next = new Set([...seen, typeName]);
|
|
93
|
+
for (const attribute of type.attributes) {
|
|
94
|
+
paths.push({
|
|
95
|
+
path: `${prefix}@${attribute.name}`,
|
|
96
|
+
type: attribute.type,
|
|
97
|
+
optional: !attribute.required,
|
|
98
|
+
repeated: false,
|
|
99
|
+
leaf: true
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
for (const field of type.fields) {
|
|
103
|
+
const repeated = field.maxOccurs === "unbounded" || field.maxOccurs > 1;
|
|
104
|
+
const path = `${prefix}${field.name}${repeated ? "[]" : ""}`;
|
|
105
|
+
const fieldOptional = field.minOccurs === 0 || field.choice !== undefined;
|
|
106
|
+
const child = typeByName(catalog, field.type);
|
|
107
|
+
const leaf = !(child instanceof ComplexTypeDef) || child.textType !== undefined;
|
|
108
|
+
paths.push({ path, type: field.type, optional: fieldOptional, repeated, leaf });
|
|
109
|
+
if (!leaf)
|
|
110
|
+
walk(field.type, `${path}.`, fieldOptional, depth + 1, next);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
walk(element.type, "", false, 1, new Set());
|
|
114
|
+
return paths;
|
|
115
|
+
};
|
|
116
|
+
const newAccumulator = () => ({
|
|
117
|
+
presentIn: 0,
|
|
118
|
+
empty: 0,
|
|
119
|
+
nil: 0,
|
|
120
|
+
occurrences: 0,
|
|
121
|
+
values: new Map(),
|
|
122
|
+
maxLength: 0,
|
|
123
|
+
maxItems: 0
|
|
124
|
+
});
|
|
125
|
+
/** Leaf and container occurrences of one value, keyed by normalized path. */
|
|
126
|
+
const flatten = (value, prefix, into, counts) => {
|
|
127
|
+
if (!isYamlMap(value))
|
|
128
|
+
return;
|
|
129
|
+
for (const [key, item] of Object.entries(value)) {
|
|
130
|
+
if (key === "#text")
|
|
131
|
+
continue;
|
|
132
|
+
if (isYamlList(item)) {
|
|
133
|
+
const path = `${prefix}${key}[]`;
|
|
134
|
+
counts.set(path, Math.max(counts.get(path) ?? 0, item.length));
|
|
135
|
+
for (const entry of item) {
|
|
136
|
+
into.set(path, [...(into.get(path) ?? []), entry]);
|
|
137
|
+
flatten(entry, `${path}.`, into, counts);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const path = `${prefix}${key}`;
|
|
142
|
+
into.set(path, [...(into.get(path) ?? []), item]);
|
|
143
|
+
flatten(item, `${path}.`, into, counts);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const bodyOf = (catalog, envelopeXml, element) => envelopeXml === undefined || envelopeXml === "" || element === undefined
|
|
148
|
+
? Effect.succeed(undefined)
|
|
149
|
+
: Effect.gen(function* () {
|
|
150
|
+
const parsed = yield* parseXml(envelopeXml).pipe(Effect.option);
|
|
151
|
+
if (parsed._tag === "None")
|
|
152
|
+
return undefined;
|
|
153
|
+
const envelope = yield* readEnvelope(parsed.value).pipe(Effect.option);
|
|
154
|
+
if (envelope._tag === "None" || envelope.value.fault !== undefined)
|
|
155
|
+
return undefined;
|
|
156
|
+
const payload = envelope.value.payload;
|
|
157
|
+
return payload === undefined ? undefined : instanceFromXml(catalog, element, payload).value;
|
|
158
|
+
});
|
|
159
|
+
const outcomePath = /(^|\.)(esito|outcome|result|risultato)\.(codice|code|returnCode)$|(^|\.)(codiceEsito|returnCode|resultCode|codiceRitorno)$/i;
|
|
160
|
+
const outcomeDescription = (path) => {
|
|
161
|
+
const base = path.replace(/[^.]+$/, "");
|
|
162
|
+
return ["descrizione", "description", "messaggio", "message"].map((name) => `${base}${name}`);
|
|
163
|
+
};
|
|
164
|
+
const pageRequest = /^(.*\.)?(numeroPagina|pagina|page|pageNumber|pageNo)$/i;
|
|
165
|
+
const pageSize = /^(.*\.)?(dimensionePagina|numeroRecord|pageSize|size|limit|maxRecords|maxResults)$/i;
|
|
166
|
+
const offsetRequest = /^(.*\.)?(offset|startIndex|primoRecord|daRecord|firstRecord)$/i;
|
|
167
|
+
const cursorField = /^(.*\.)?(cursor|continuationToken|nextToken|chiaveRiposizionamento|chiavePaginazione|pagingKey|nextKey)$/i;
|
|
168
|
+
const lastPageField = /^(.*\.)?(ultimaPagina|lastPage|hasMore|altriDati|moreData|isLast)$/i;
|
|
169
|
+
const totalField = /^(.*\.)?(totaleRecord|totalRecords|total|totalCount|totaleElementi)$/i;
|
|
170
|
+
/** OK-prefixed codes (OK00) and all-zero codes (0, 000) are success; 017 is not. */
|
|
171
|
+
export const isSuccessCode = (code) => /^(OK|0+$)/i.test(code);
|
|
172
|
+
const median = (values) => {
|
|
173
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
174
|
+
return sorted[Math.floor((sorted.length - 1) / 2)] ?? 0;
|
|
175
|
+
};
|
|
176
|
+
export const analyseOperation = (catalog, operationName, exchanges) => Effect.gen(function* () {
|
|
177
|
+
const operation = operationByName(catalog, operationName);
|
|
178
|
+
const input = operation === undefined ? undefined : elementByName(catalog, operation.input);
|
|
179
|
+
const output = operation?.output === undefined ? undefined : elementByName(catalog, operation.output);
|
|
180
|
+
const mine = exchanges.filter((exchange) => exchange.operation === operationName);
|
|
181
|
+
const statuses = new Map();
|
|
182
|
+
const faults = new Map();
|
|
183
|
+
const outcomes = new Map();
|
|
184
|
+
const findings = new Map();
|
|
185
|
+
const accumulators = new Map();
|
|
186
|
+
const requestSeen = new Set();
|
|
187
|
+
const latencies = [];
|
|
188
|
+
const itemCounts = new Map();
|
|
189
|
+
let lastPageTrue = 0;
|
|
190
|
+
let lastPageFalse = 0;
|
|
191
|
+
for (const exchange of mine) {
|
|
192
|
+
if (exchange.response !== undefined && exchange.provenance === "call") {
|
|
193
|
+
statuses.set(exchange.response.status, (statuses.get(exchange.response.status) ?? 0) + 1);
|
|
194
|
+
latencies.push(exchange.response.elapsedMs);
|
|
195
|
+
}
|
|
196
|
+
for (const issue of exchange.responseIssues) {
|
|
197
|
+
const detail = issue.detail.replace(/"[^"]*"/g, '"…"');
|
|
198
|
+
const key = `${issue.path.replace(/\[\d+\]/g, "[]")}\u0000${detail}`;
|
|
199
|
+
findings.set(key, (findings.get(key) ?? 0) + 1);
|
|
200
|
+
}
|
|
201
|
+
if (exchange.fault !== undefined) {
|
|
202
|
+
const key = `${exchange.fault.code}\u0000${exchange.fault.detailElement ?? ""}\u0000${exchange.fault.reason}`;
|
|
203
|
+
const entry = faults.get(key) ?? {
|
|
204
|
+
code: exchange.fault.code,
|
|
205
|
+
reason: exchange.fault.reason,
|
|
206
|
+
...(exchange.fault.detailElement === undefined
|
|
207
|
+
? {}
|
|
208
|
+
: { detailElement: exchange.fault.detailElement }),
|
|
209
|
+
count: 0,
|
|
210
|
+
samples: []
|
|
211
|
+
};
|
|
212
|
+
entry.count++;
|
|
213
|
+
entry.samples.push(exchange.name);
|
|
214
|
+
faults.set(key, entry);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const request = yield* bodyOf(catalog, exchange.request?.envelope, input);
|
|
218
|
+
if (request !== undefined) {
|
|
219
|
+
const flat = new Map();
|
|
220
|
+
flatten(request, "", flat, new Map());
|
|
221
|
+
for (const path of flat.keys())
|
|
222
|
+
requestSeen.add(path);
|
|
223
|
+
}
|
|
224
|
+
const response = yield* bodyOf(catalog, exchange.response?.envelope, output);
|
|
225
|
+
if (response === undefined)
|
|
226
|
+
continue;
|
|
227
|
+
const flat = new Map();
|
|
228
|
+
const counts = new Map();
|
|
229
|
+
flatten(response, "", flat, counts);
|
|
230
|
+
for (const [path, count] of counts)
|
|
231
|
+
itemCounts.set(path, [...(itemCounts.get(path) ?? []), count]);
|
|
232
|
+
for (const [path, values] of flat) {
|
|
233
|
+
const accumulator = accumulators.get(path) ?? newAccumulator();
|
|
234
|
+
accumulator.presentIn++;
|
|
235
|
+
accumulator.maxItems = Math.max(accumulator.maxItems, counts.get(path) ?? 0);
|
|
236
|
+
for (const value of values) {
|
|
237
|
+
accumulator.occurrences++;
|
|
238
|
+
if (value === null)
|
|
239
|
+
accumulator.nil++;
|
|
240
|
+
else if (typeof value === "string") {
|
|
241
|
+
if (value.trim() === "")
|
|
242
|
+
accumulator.empty++;
|
|
243
|
+
accumulator.values.set(value, (accumulator.values.get(value) ?? 0) + 1);
|
|
244
|
+
accumulator.maxLength = Math.max(accumulator.maxLength, [...value].length);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
accumulators.set(path, accumulator);
|
|
248
|
+
if (outcomePath.test(path)) {
|
|
249
|
+
for (const value of values) {
|
|
250
|
+
if (typeof value !== "string")
|
|
251
|
+
continue;
|
|
252
|
+
const description = outcomeDescription(path)
|
|
253
|
+
.map((candidate) => flat.get(candidate)?.[0])
|
|
254
|
+
.find((candidate) => typeof candidate === "string" && candidate !== "");
|
|
255
|
+
const entry = outcomes.get(value) ?? { count: 0, samples: [] };
|
|
256
|
+
entry.count++;
|
|
257
|
+
entry.samples.push(exchange.name);
|
|
258
|
+
if (entry.description === undefined && description !== undefined)
|
|
259
|
+
entry.description = description;
|
|
260
|
+
outcomes.set(value, entry);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (lastPageField.test(path)) {
|
|
264
|
+
for (const value of values) {
|
|
265
|
+
if (value === "true" || value === "1" || value === "S")
|
|
266
|
+
lastPageTrue++;
|
|
267
|
+
else if (value === "false" || value === "0" || value === "N")
|
|
268
|
+
lastPageFalse++;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const responsePaths = output === undefined ? [] : schemaPaths(catalog, output);
|
|
274
|
+
const withResponse = [...accumulators.values()].length === 0
|
|
275
|
+
? 0
|
|
276
|
+
: Math.max(...[...accumulators.values()].map((entry) => entry.presentIn));
|
|
277
|
+
const fields = responsePaths
|
|
278
|
+
.filter((schemaPath) => accumulators.has(schemaPath.path))
|
|
279
|
+
.map((schemaPath) => {
|
|
280
|
+
const accumulator = accumulators.get(schemaPath.path) ?? newAccumulator();
|
|
281
|
+
const sorted = [...accumulator.values].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
|
|
282
|
+
const declared = schemaPath.leaf
|
|
283
|
+
? simpleView(catalog, schemaPath.type).facets.find((facets) => facets.enumeration !== undefined)?.enumeration
|
|
284
|
+
: undefined;
|
|
285
|
+
return new FieldStats({
|
|
286
|
+
path: schemaPath.path,
|
|
287
|
+
optional: schemaPath.optional,
|
|
288
|
+
repeated: schemaPath.repeated,
|
|
289
|
+
presentIn: accumulator.presentIn,
|
|
290
|
+
empty: accumulator.empty,
|
|
291
|
+
nil: accumulator.nil,
|
|
292
|
+
occurrences: accumulator.occurrences,
|
|
293
|
+
values: schemaPath.leaf
|
|
294
|
+
? sorted.slice(0, 12).map(([value, count]) => ({ value, count }))
|
|
295
|
+
: [],
|
|
296
|
+
distinct: schemaPath.leaf ? sorted.length : 0,
|
|
297
|
+
...(declared === undefined ? {} : { declared }),
|
|
298
|
+
maxLength: accumulator.maxLength,
|
|
299
|
+
...(schemaPath.repeated ? { maxItems: accumulator.maxItems } : {})
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
const neverObserved = responsePaths
|
|
303
|
+
.filter((schemaPath) => !accumulators.has(schemaPath.path))
|
|
304
|
+
.map((schemaPath) => schemaPath.path)
|
|
305
|
+
.filter((path, _, all) => !all.some((other) => other !== path && path.startsWith(`${other}.`)));
|
|
306
|
+
const requestPaths = input === undefined ? [] : schemaPaths(catalog, input);
|
|
307
|
+
const requestFieldsUnused = requestPaths
|
|
308
|
+
.filter((schemaPath) => schemaPath.optional && !requestSeen.has(schemaPath.path))
|
|
309
|
+
.map((schemaPath) => schemaPath.path)
|
|
310
|
+
.filter((path, _, all) => !all.some((other) => other !== path && path.startsWith(`${other}.`)));
|
|
311
|
+
// Pagination: named request and response fields, plus what the samples showed.
|
|
312
|
+
const requestNames = requestPaths.map((schemaPath) => schemaPath.path);
|
|
313
|
+
const responseNames = responsePaths.map((schemaPath) => schemaPath.path);
|
|
314
|
+
const byPattern = (names, pattern) => names.filter((name) => pattern.test(name));
|
|
315
|
+
const pageFields = [
|
|
316
|
+
...byPattern(requestNames, pageRequest),
|
|
317
|
+
...byPattern(requestNames, pageSize)
|
|
318
|
+
];
|
|
319
|
+
const offsetFields = byPattern(requestNames, offsetRequest);
|
|
320
|
+
const cursorRequest = byPattern(requestNames, cursorField);
|
|
321
|
+
const cursorResponse = byPattern(responseNames, cursorField);
|
|
322
|
+
const responseMarkers = [
|
|
323
|
+
...byPattern(responseNames, lastPageField),
|
|
324
|
+
...byPattern(responseNames, totalField)
|
|
325
|
+
];
|
|
326
|
+
const pagedItems = responsePaths.find((schemaPath) => schemaPath.repeated && !schemaPath.path.includes("[]."))?.path;
|
|
327
|
+
const style = cursorRequest.length > 0 || cursorResponse.length > 0
|
|
328
|
+
? "cursor"
|
|
329
|
+
: offsetFields.length > 0
|
|
330
|
+
? "offset"
|
|
331
|
+
: pageFields.length > 0
|
|
332
|
+
? "page-number"
|
|
333
|
+
: undefined;
|
|
334
|
+
const maxItems = pagedItems === undefined ? [] : (itemCounts.get(pagedItems) ?? []);
|
|
335
|
+
const pagination = style === undefined
|
|
336
|
+
? undefined
|
|
337
|
+
: new Pagination({
|
|
338
|
+
style,
|
|
339
|
+
requestFields: style === "cursor"
|
|
340
|
+
? cursorRequest
|
|
341
|
+
: style === "offset"
|
|
342
|
+
? [...offsetFields, ...byPattern(requestNames, pageSize)]
|
|
343
|
+
: pageFields,
|
|
344
|
+
responseFields: style === "cursor" ? [...cursorResponse, ...responseMarkers] : responseMarkers,
|
|
345
|
+
...(pagedItems === undefined ? {} : { items: pagedItems }),
|
|
346
|
+
observed: mine.length === 0
|
|
347
|
+
? "no samples yet"
|
|
348
|
+
: `${lastPageTrue} responses marked last page, ${lastPageFalse} not; at most ${maxItems.length === 0 ? 0 : Math.max(...maxItems)} ${pagedItems ?? "items"} per response`
|
|
349
|
+
});
|
|
350
|
+
const observations = [];
|
|
351
|
+
const answered = mine.length - [...faults.values()].reduce((sum, fault) => sum + fault.count, 0);
|
|
352
|
+
if (mine.length === 0)
|
|
353
|
+
observations.push("No samples yet: the design has only the WSDL to go on.");
|
|
354
|
+
for (const field of fields) {
|
|
355
|
+
if (field.declared !== undefined) {
|
|
356
|
+
const undeclared = field.values
|
|
357
|
+
.map((entry) => entry.value)
|
|
358
|
+
.filter((value) => !field.declared?.includes(value));
|
|
359
|
+
if (undeclared.length > 0)
|
|
360
|
+
observations.push(`${field.path}: values outside the declared enumeration: ${undeclared.join(", ")}.`);
|
|
361
|
+
}
|
|
362
|
+
else if (field.distinct > 0 &&
|
|
363
|
+
field.distinct <= 6 &&
|
|
364
|
+
field.occurrences >= 4 &&
|
|
365
|
+
field.distinct * 2 <= field.occurrences &&
|
|
366
|
+
field.maxLength <= 20 &&
|
|
367
|
+
field.values.every((entry) => /^[A-Z0-9_]+$/.test(entry.value))) {
|
|
368
|
+
observations.push(`${field.path}: looks like an undeclared code list (${field.values.map((entry) => entry.value).join(", ")}).`);
|
|
369
|
+
}
|
|
370
|
+
if (!field.optional && field.empty === field.occurrences && field.occurrences > 0) {
|
|
371
|
+
observations.push(`${field.path}: required by the schema but always empty in the samples.`);
|
|
372
|
+
}
|
|
373
|
+
if (field.optional &&
|
|
374
|
+
field.presentIn === withResponse &&
|
|
375
|
+
withResponse >= 3 &&
|
|
376
|
+
!field.repeated) {
|
|
377
|
+
observations.push(`${field.path}: optional in the schema, present in every sample response.`);
|
|
378
|
+
}
|
|
379
|
+
if (field.nil > 0)
|
|
380
|
+
observations.push(`${field.path}: sent as xsi:nil ${field.nil} times; decide between null and omission.`);
|
|
381
|
+
}
|
|
382
|
+
const koCodes = [...outcomes.keys()].filter((code) => !isSuccessCode(code));
|
|
383
|
+
if (koCodes.length > 0) {
|
|
384
|
+
observations.push(`Business errors arrive inside successful responses (${koCodes.join(", ")}): each needs an HTTP status and a problem type.`);
|
|
385
|
+
}
|
|
386
|
+
if (faults.size > 0) {
|
|
387
|
+
observations.push(`SOAP faults seen (${[...faults.values()].map((fault) => fault.detailElement ?? fault.code).join(", ")}): map each to a problem type.`);
|
|
388
|
+
}
|
|
389
|
+
if (findings.size > 0)
|
|
390
|
+
observations.push(`${findings.size} distinct schema findings: the WSDL does not fully describe what the service returns.`);
|
|
391
|
+
if (answered > 0 && neverObserved.length > 0)
|
|
392
|
+
observations.push(`${neverObserved.length} response fields never observed; confirm they are still produced before exposing them.`);
|
|
393
|
+
const sortedLatencies = [...latencies].sort((left, right) => left - right);
|
|
394
|
+
return new OperationAnalysis({
|
|
395
|
+
version: 1,
|
|
396
|
+
operation: operationName,
|
|
397
|
+
exchanges: mine.length,
|
|
398
|
+
fromCalls: mine.filter((exchange) => exchange.provenance === "call").length,
|
|
399
|
+
fromSoapUi: mine.filter((exchange) => exchange.provenance === "soapui").length,
|
|
400
|
+
httpStatuses: [...statuses]
|
|
401
|
+
.sort((left, right) => left[0] - right[0])
|
|
402
|
+
.map(([status, count]) => ({ status, count })),
|
|
403
|
+
faults: [...faults.values()].map((fault) => new FaultStats(fault)),
|
|
404
|
+
outcomes: [...outcomes]
|
|
405
|
+
.sort((left, right) => right[1].count - left[1].count)
|
|
406
|
+
.map(([code, entry]) => new OutcomeCode({ code, ...entry })),
|
|
407
|
+
fields,
|
|
408
|
+
neverObserved,
|
|
409
|
+
requestFieldsUnused,
|
|
410
|
+
findings: [...findings]
|
|
411
|
+
.map(([key, count]) => {
|
|
412
|
+
const [path = "", detail = ""] = key.split("\u0000");
|
|
413
|
+
return new FindingStats({ path, detail, count });
|
|
414
|
+
})
|
|
415
|
+
.sort((left, right) => right.count - left.count),
|
|
416
|
+
...(pagination === undefined ? {} : { pagination }),
|
|
417
|
+
...(sortedLatencies.length === 0
|
|
418
|
+
? {}
|
|
419
|
+
: {
|
|
420
|
+
latencyMs: {
|
|
421
|
+
min: sortedLatencies[0] ?? 0,
|
|
422
|
+
median: median(sortedLatencies),
|
|
423
|
+
max: sortedLatencies[sortedLatencies.length - 1] ?? 0
|
|
424
|
+
}
|
|
425
|
+
}),
|
|
426
|
+
observations
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// Rendering
|
|
431
|
+
const cell = (value) => value.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
432
|
+
export const renderAnalysis = (analysis, catalog) => {
|
|
433
|
+
const operation = operationByName(catalog, analysis.operation);
|
|
434
|
+
const lines = [
|
|
435
|
+
`# Analysis: ${analysis.operation}`,
|
|
436
|
+
"",
|
|
437
|
+
`Generated by soap-design from ${analysis.exchanges} masked exchanges (${analysis.fromCalls} calls, ${analysis.fromSoapUi} SoapUI mocks). Regenerate rather than edit.`,
|
|
438
|
+
...(operation?.documentation === undefined ? [] : ["", `> ${operation.documentation}`]),
|
|
439
|
+
"",
|
|
440
|
+
"## Observations",
|
|
441
|
+
"",
|
|
442
|
+
...(analysis.observations.length === 0
|
|
443
|
+
? ["Nothing unusual in the samples."]
|
|
444
|
+
: analysis.observations.map((line) => `- ${line}`)),
|
|
445
|
+
""
|
|
446
|
+
];
|
|
447
|
+
if (analysis.outcomes.length > 0) {
|
|
448
|
+
lines.push("## Business outcome codes", "", "| code | description | count | samples |", "| --- | --- | --- | --- |");
|
|
449
|
+
for (const outcome of analysis.outcomes) {
|
|
450
|
+
lines.push(`| ${cell(outcome.code)} | ${cell(outcome.description ?? "")} | ${outcome.count} | ${outcome.samples.join(", ")} |`);
|
|
451
|
+
}
|
|
452
|
+
lines.push("");
|
|
453
|
+
}
|
|
454
|
+
if (analysis.faults.length > 0) {
|
|
455
|
+
lines.push("## SOAP faults", "", "| code | detail | reason | count | samples |", "| --- | --- | --- | --- | --- |");
|
|
456
|
+
for (const fault of analysis.faults) {
|
|
457
|
+
lines.push(`| ${cell(fault.code)} | ${cell(fault.detailElement ?? "")} | ${cell(fault.reason)} | ${fault.count} | ${fault.samples.join(", ")} |`);
|
|
458
|
+
}
|
|
459
|
+
lines.push("");
|
|
460
|
+
}
|
|
461
|
+
if (analysis.pagination !== undefined) {
|
|
462
|
+
const pagination = analysis.pagination;
|
|
463
|
+
lines.push("## Pagination", "", `- style: ${pagination.style}`, `- request: ${pagination.requestFields.join(", ") || "(none)"}`, `- response: ${pagination.responseFields.join(", ") || "(none)"}`, ...(pagination.items === undefined ? [] : [`- items: ${pagination.items}`]), `- observed: ${pagination.observed}`, "");
|
|
464
|
+
}
|
|
465
|
+
if (analysis.fields.length > 0) {
|
|
466
|
+
lines.push("## Response fields", "", "| path | schema | present | empty/nil | values (masked) |", "| --- | --- | --- | --- | --- |");
|
|
467
|
+
for (const field of analysis.fields) {
|
|
468
|
+
const schema = `${field.optional ? "optional" : "required"}${field.repeated ? `, repeated (max ${field.maxItems ?? 0} seen)` : ""}`;
|
|
469
|
+
const values = field.distinct === 0
|
|
470
|
+
? ""
|
|
471
|
+
: `${field.values.map((entry) => `${entry.value}${entry.count > 1 ? ` ×${entry.count}` : ""}`).join(", ")}${field.distinct > field.values.length ? ` … (${field.distinct} distinct)` : ""}`;
|
|
472
|
+
lines.push(`| ${cell(field.path)} | ${schema} | ${field.presentIn} | ${field.empty}/${field.nil} | ${cell(values)} |`);
|
|
473
|
+
}
|
|
474
|
+
lines.push("");
|
|
475
|
+
}
|
|
476
|
+
if (analysis.findings.length > 0) {
|
|
477
|
+
lines.push("## Schema findings", "", "| path | finding | count |", "| --- | --- | --- |");
|
|
478
|
+
for (const finding of analysis.findings)
|
|
479
|
+
lines.push(`| ${cell(finding.path)} | ${cell(finding.detail)} | ${finding.count} |`);
|
|
480
|
+
lines.push("");
|
|
481
|
+
}
|
|
482
|
+
if (analysis.neverObserved.length > 0) {
|
|
483
|
+
lines.push("## Never observed in responses", "", ...analysis.neverObserved.map((path) => `- ${path}`), "");
|
|
484
|
+
}
|
|
485
|
+
if (analysis.requestFieldsUnused.length > 0) {
|
|
486
|
+
lines.push("## Optional request fields no sample used", "", ...analysis.requestFieldsUnused.map((path) => `- ${path}`), "");
|
|
487
|
+
}
|
|
488
|
+
if (analysis.latencyMs !== undefined) {
|
|
489
|
+
lines.push("## Latency", "", `min ${analysis.latencyMs.min} ms, median ${analysis.latencyMs.median} ms, max ${analysis.latencyMs.max} ms over ${analysis.fromCalls} calls.`, "");
|
|
490
|
+
}
|
|
491
|
+
return lines.join("\n");
|
|
492
|
+
};
|
|
493
|
+
export const encodeAnalysis = (analysis) => `${JSON.stringify(Schema.encodeSync(OperationAnalysis)(analysis), null, 2)}\n`;
|
|
494
|
+
export const analysisPaths = (service, operation) => ({
|
|
495
|
+
markdown: `${serviceDirectory(service)}/analysis/${operation}.md`,
|
|
496
|
+
json: `${serviceDirectory(service)}/analysis/${operation}.json`
|
|
497
|
+
});
|
|
498
|
+
/** Analyse every (or the named) operation from its recorded exchanges and write both files. */
|
|
499
|
+
export const writeAnalyses = (workspace, catalog, service, operations = catalog.operations.map((operation) => operation.name)) => Effect.gen(function* () {
|
|
500
|
+
const exchanges = yield* readExchanges(workspace, service);
|
|
501
|
+
return yield* Effect.forEach(operations, (operation) => Effect.gen(function* () {
|
|
502
|
+
const analysis = yield* analyseOperation(catalog, operation, exchanges);
|
|
503
|
+
const paths = analysisPaths(service, operation);
|
|
504
|
+
yield* workspace.write(paths.markdown, renderAnalysis(analysis, catalog));
|
|
505
|
+
yield* workspace.write(paths.json, encodeAnalysis(analysis));
|
|
506
|
+
return analysis;
|
|
507
|
+
}));
|
|
508
|
+
});
|