@llm4ts/shell 2.11.0 → 2.13.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.
Files changed (61) hide show
  1. package/flows/epic-stories.js +25 -5
  2. package/flows/lib/epic-stories.js +76 -8
  3. package/kits/soap-ace/README.md +313 -0
  4. package/kits/soap-ace/fixtures/demo-bank-soap/RUNBOOK.md +102 -0
  5. package/kits/soap-ace/fixtures/demo-bank-soap/design/api-design.md +620 -0
  6. package/kits/soap-ace/fixtures/demo-bank-soap/responses/cercaConti.xml +3 -0
  7. package/kits/soap-ace/fixtures/demo-bank-soap/responses/cercaMovimenti.xml +3 -0
  8. package/kits/soap-ace/fixtures/demo-bank-soap/responses/confermaBonifico.xml +3 -0
  9. package/kits/soap-ace/fixtures/demo-bank-soap/responses/dettaglioConto.xml +3 -0
  10. package/kits/soap-ace/fixtures/demo-bank-soap/responses/inserisciBonifico.xml +3 -0
  11. package/kits/soap-ace/fixtures/demo-bank-soap/responses/revocaBonifico.xml +3 -0
  12. package/kits/soap-ace/fixtures/demo-bank-soap/soapui/DemoBank-soapui-project.xml +39 -0
  13. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/DemoBank.wsdl +186 -0
  14. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/LegacyRpc.wsdl +29 -0
  15. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/banking.xsd +10 -0
  16. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/bonifici.xsd +107 -0
  17. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/common.xsd +93 -0
  18. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/conti.xsd +71 -0
  19. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/movimenti.xsd +55 -0
  20. package/kits/soap-ace/flows/lib/soap/Analysis.js +508 -0
  21. package/kits/soap-ace/flows/lib/soap/Auth.js +179 -0
  22. package/kits/soap-ace/flows/lib/soap/Call.js +186 -0
  23. package/kits/soap-ace/flows/lib/soap/CallPolicy.js +50 -0
  24. package/kits/soap-ace/flows/lib/soap/Catalog.js +157 -0
  25. package/kits/soap-ace/flows/lib/soap/Classification.js +254 -0
  26. package/kits/soap-ace/flows/lib/soap/Design.js +497 -0
  27. package/kits/soap-ace/flows/lib/soap/DesignProposal.js +204 -0
  28. package/kits/soap-ace/flows/lib/soap/Discover.js +139 -0
  29. package/kits/soap-ace/flows/lib/soap/Envelope.js +132 -0
  30. package/kits/soap-ace/flows/lib/soap/Epic.js +239 -0
  31. package/kits/soap-ace/flows/lib/soap/Instance.js +675 -0
  32. package/kits/soap-ace/flows/lib/soap/Mapping.js +130 -0
  33. package/kits/soap-ace/flows/lib/soap/Masking.js +508 -0
  34. package/kits/soap-ace/flows/lib/soap/OpenApi.js +288 -0
  35. package/kits/soap-ace/flows/lib/soap/Samples.js +287 -0
  36. package/kits/soap-ace/flows/lib/soap/Scenarios.js +132 -0
  37. package/kits/soap-ace/flows/lib/soap/SoapUiImport.js +200 -0
  38. package/kits/soap-ace/flows/lib/soap/Stub.js +34 -0
  39. package/kits/soap-ace/flows/lib/soap/Transport.js +167 -0
  40. package/kits/soap-ace/flows/lib/soap/Wsdl.js +533 -0
  41. package/kits/soap-ace/flows/lib/soap/Xml.js +453 -0
  42. package/kits/soap-ace/flows/lib/soap/Xsd.js +471 -0
  43. package/kits/soap-ace/flows/lib/soap/Yaml.js +309 -0
  44. package/kits/soap-ace/flows/soap-design.js +167 -0
  45. package/kits/soap-ace/flows/soap-discover.js +85 -0
  46. package/kits/soap-ace/flows/soap-epic.js +204 -0
  47. package/kits/soap-ace/flows/soap-sample.js +265 -0
  48. package/kits/soap-ace/packs/ace12-rest/api-style.md +63 -0
  49. package/kits/soap-ace/packs/ace12-rest/pack.md +18 -0
  50. package/kits/soap-ace/packs/ace12-rest/prompts/implement.md +8 -0
  51. package/kits/soap-ace/packs/ace12-rest/reviewers/contract.md +5 -0
  52. package/kits/soap-ace/patterns/PAT-ACE-001-esito-to-problem.md +8 -0
  53. package/kits/soap-ace/patterns/PAT-ACE-002-fault-to-problem.md +8 -0
  54. package/kits/soap-ace/patterns/PAT-ACE-003-amounts-and-enums.md +8 -0
  55. package/kits/soap-ace/patterns/PAT-ACE-004-pagination.md +7 -0
  56. package/kits/soap-ace/patterns/PAT-ACE-005-nil-and-absence.md +7 -0
  57. package/kits/soap-ace/scaffolds/ace12-rest-api/CONTRIBUTING.md +56 -0
  58. package/kits/soap-ace/scaffolds/ace12-rest-api/README.md +16 -0
  59. package/kits/soap-ace/scaffolds/ace12-rest-api/scripts/ace-gates-container.sh +8 -0
  60. package/kits/soap-ace/scaffolds/ace12-rest-api/scripts/ace-gates.sh +41 -0
  61. 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
+ });