@llm4ts/shell 2.15.0 → 2.16.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.
@@ -14,15 +14,56 @@ source) and shares nothing with this kit.
14
14
 
15
15
  ## Status
16
16
 
17
- | Piece | State |
18
- | -------------------------------------------------- | -------- |
19
- | Strict XML reader (`flows/lib/soap/Xml.ts`) | done |
20
- | WSDL 1.1/2.0 + XSD catalog (`Wsdl.ts`, `Xsd.ts`) | done |
21
- | `demo-bank-soap` fixture | WSDL/XSD |
22
- | `soap-discover` flow and operation classification | pending |
23
- | Masking, auth profile, transport, sample authoring | pending |
24
- | Analysis, REST design, OpenAPI projection | pending |
25
- | `ace12-rest` pack, scaffold, `soap-epic` | pending |
17
+ | Piece | State |
18
+ | ------------------------------------------------------------------------ | ----- |
19
+ | Strict XML reader, WSDL 1.1/2.0 + XSD catalog | done |
20
+ | `soap-discover` flow and operation classification | done |
21
+ | Masking, auth profile, transport, sample authoring | done |
22
+ | Analysis, REST design, OpenAPI projection | done |
23
+ | `ace12-rest` pack, scaffold, `soap-epic` | done |
24
+ | `soap-explore`: the discovery half in one command | done |
25
+ | `demo-bank-soap` fixture ([runbook](fixtures/demo-bank-soap/RUNBOOK.md)) | done |
26
+
27
+ ## Quick start: soap-explore
28
+
29
+ One command from a WSDL to a design draft, the way SoapUI turns a WSDL into
30
+ a project. It stops only where a person decides, and a rerun continues where
31
+ it stopped:
32
+
33
+ ```bash
34
+ llm4ts run soap-explore --repo ~/work/conti-analysis ./wsdl/ContiService.wsdl # or an https URL
35
+ # write .llm4ts/soap/<service>/auth.json: at least {"environment": "test"}
36
+ # review operations.md, set Status: confirmed
37
+ llm4ts run soap-explore --repo ~/work/conti-analysis # continue
38
+ ```
39
+
40
+ | Step | What happens |
41
+ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
42
+ | discover | `catalog.json`/`.md`, `operations.md` with proposed classes (as `soap-discover`) |
43
+ | requests | `samples/<op>/happy-path.request.yaml` for **every** operation, example values from the XSD |
44
+ | probe | calls the **read** operations once, producers first: the IBAN `cercaConti` returns is what `dettaglioConto` is called with. Live values stay in memory; the request file on disk carries the masked one. Mutating: never called |
45
+ | evidence | `analysis/<op>.md`, `design/mapping.json`/`.md` |
46
+ | design | the reasoning seat drafts `design/api-design.md`, `check`, one `revise` round when errors remain; an existing design is only checked; `--no-design` skips |
47
+ | report | `explore.md`: per operation the probe decision and result, the chained values, the design state, and the next command for every gap |
48
+
49
+ **Who may be called.** Without `auth.json` nothing is called (explore never
50
+ writes it: the environment label is yours to declare). With `operations.md`
51
+ confirmed, its `read` operations are called. Before confirmation, an
52
+ operation is called only in dev or test, and only when the file still lists
53
+ it as `read`, the name heuristic says `read`, and the judgment seat
54
+ (`LLM4TS_JUDGMENT_PROVIDER`) answers `read` with an `act` decision; without
55
+ a judgment seat, confirm first. uat always needs confirmation.
56
+
57
+ **Files it owns.** A request explore writes starts with a `# soap-explore:
58
+ generated` line and is regenerated when probed again. Delete that line to
59
+ own the file: explore then sends it as written and never touches it.
60
+ `--refresh` calls the probed operations again; everything else is kept.
61
+
62
+ Approvals stay human: `Status: confirmed` in `operations.md`,
63
+ `Status: approved` in `api-design.md`. Then `soap-design "openapi"` and
64
+ `soap-epic` as below. The individual flows remain for everything past the
65
+ first pass: more scenarios per operation (`soap-sample "propose <op>"`),
66
+ mutating calls, SoapUI import, design revisions.
26
67
 
27
68
  ## soap-discover
28
69
 
@@ -16,6 +16,22 @@ cd ~/work/conti-analysis
16
16
  The analysis repository holds `.llm4ts/soap/<service>/`: catalog, samples,
17
17
  analysis, design. The ACE repository receives the contracts and the code.
18
18
 
19
+ ## Fast path: soap-explore
20
+
21
+ Steps 1 to 4 in one command, rerun after each decision:
22
+
23
+ ```bash
24
+ llm4ts run soap-explore --repo . ./wsdl/ContiService.wsdl # discover, requests, report
25
+ # write auth.json (step 2), review and confirm operations.md
26
+ llm4ts run soap-explore --repo . # probe reads, analyse, draft the design
27
+ ```
28
+
29
+ Read `.llm4ts/soap/<service>/explore.md` after each run: it lists what was
30
+ called, what was not and why, and the next command. On the fixture:
31
+ `LLM4TS_SOAP_STUB=<kit>/fixtures/demo-bank-soap/responses`. The steps below
32
+ are the same work one command at a time, and what to use past the first
33
+ pass.
34
+
19
35
  ## 1. Discover
20
36
 
21
37
  ```bash
@@ -69,7 +69,7 @@ export const callOperation = (options) => Effect.gen(function* () {
69
69
  detail: `no auth profile; write ${`.llm4ts/soap/${service}/auth.json`} with at least {"environment": "dev"}`
70
70
  });
71
71
  }
72
- const request = yield* readRequestFile(workspace, catalog, service, operation.name, name);
72
+ const request = options.request ?? (yield* readRequestFile(workspace, catalog, service, operation.name, name));
73
73
  const issues = [
74
74
  ...request.readIssues,
75
75
  ...validateInstance(catalog, input, request.body)
@@ -130,6 +130,7 @@ export const callOperation = (options) => Effect.gen(function* () {
130
130
  // which quote values) is read from the masked, security-stripped tree:
131
131
  // nothing unmasked reaches the exchange.
132
132
  let fault;
133
+ let payload;
133
134
  if (parsedResponse._tag === "None") {
134
135
  responseIssues.push({
135
136
  path: "(response)",
@@ -148,11 +149,14 @@ export const callOperation = (options) => Effect.gen(function* () {
148
149
  }
149
150
  else if (operation.output !== undefined) {
150
151
  const output = elementByName(catalog, operation.output);
151
- const payload = read.value.payload;
152
- if (output !== undefined && payload !== undefined) {
153
- responseIssues.push(...instanceFromXml(catalog, output, payload).issues);
152
+ const masked = read.value.payload;
153
+ if (output !== undefined && masked !== undefined) {
154
+ responseIssues.push(...instanceFromXml(catalog, output, masked).issues);
155
+ const live = yield* readEnvelope(stripSecurity(parsedResponse.value)).pipe(Effect.option);
156
+ if (live._tag === "Some" && live.value.payload !== undefined)
157
+ payload = { live: live.value.payload, masked };
154
158
  }
155
- else if (payload === undefined) {
159
+ else if (masked === undefined) {
156
160
  responseIssues.push({ path: "(response)", detail: "empty Body" });
157
161
  }
158
162
  }
@@ -182,5 +186,5 @@ export const callOperation = (options) => Effect.gen(function* () {
182
186
  masking: mergeReports([maskedRequest.report, maskedResponse.report])
183
187
  });
184
188
  const path = yield* writeExchange(workspace, service, exchange);
185
- return { exchange, path, permission };
189
+ return { exchange, path, permission, ...(payload === undefined ? {} : { payload }) };
186
190
  });
@@ -0,0 +1,476 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { schemaPaths, writeAnalyses } from "./Analysis.js";
3
+ import { callOperation, RequestInvalid } from "./Call.js";
4
+ import { elementByName } from "./Catalog.js";
5
+ import { heuristicClass } from "./Classification.js";
6
+ import { blockingIssues, checkDesign, designPaths, parseDesignFile, renderDesignFile } from "./Design.js";
7
+ import { proposeDesign, reviseDesign } from "./DesignProposal.js";
8
+ import { readIfPresent, serviceDirectory, servicePaths } from "./Discover.js";
9
+ import { instanceFromXml } from "./Instance.js";
10
+ import { encodeMapping, mapService, renderMapping } from "./Mapping.js";
11
+ import { collectSeeds, readExchanges, renderRequestFile, samplePaths, seedsForService } from "./Samples.js";
12
+ import { isYamlMap, parseYaml } from "./Yaml.js";
13
+ // soap-explore: the whole discovery half in one pass, stopping only where a
14
+ // person decides. Given a catalog it writes a request for every operation,
15
+ // calls the read operations once in producer-first order (values from one
16
+ // response seed the next request), and reports every gap with the command
17
+ // that closes it. Each step keeps what exists, so a rerun continues.
18
+ //
19
+ // Chained values are live only in memory: the call sends the value the
20
+ // service answered with, the request file on disk carries its masked form.
21
+ export const exploreRequestName = "happy-path";
22
+ /** First line of a request file soap-explore owns; deleting it hands the file to the user. */
23
+ export const exploreMarker = "# soap-explore: generated. Delete this line to keep your edits; soap-explore then leaves the file alone.";
24
+ export const explorePaths = (service) => ({
25
+ report: `${serviceDirectory(service)}/explore.md`
26
+ });
27
+ /**
28
+ * Which operations the probe may call. Only reads, ever. A confirmed
29
+ * operations.md decides alone; before confirmation, a provisional probe needs
30
+ * dev or test, the file still saying read, the name heuristic saying read,
31
+ * and the judgment saying read with an `act` decision. `judged` undefined
32
+ * means no judgment seat is configured.
33
+ */
34
+ export const probeDecisions = (options) => options.catalog.operations.map(({ name }) => {
35
+ const skip = (reason) => ({ operation: name, probe: false, reason });
36
+ if (options.environment === undefined)
37
+ return skip("no auth.json: nothing is called");
38
+ const file = options.operations;
39
+ const listed = file?.classes.get(name) ?? "unclassified";
40
+ if (file?.confirmed === true) {
41
+ if (listed === "read") {
42
+ return { operation: name, probe: true, basis: "confirmed", reason: "confirmed read" };
43
+ }
44
+ return skip(listed === "mutating"
45
+ ? "mutating: never probed"
46
+ : "unclassified in operations.md: not callable");
47
+ }
48
+ if (options.environment === "uat")
49
+ return skip("uat: confirm operations.md before any call");
50
+ if (listed !== "read") {
51
+ return skip(listed === "mutating" ? "mutating: never probed" : "unclassified: confirm operations.md");
52
+ }
53
+ const heuristic = heuristicClass(name);
54
+ if (heuristic.class !== "read") {
55
+ return skip(`heuristic says ${heuristic.class}: confirm operations.md to call it`);
56
+ }
57
+ if (options.judged === undefined) {
58
+ return skip("no judgment seat (LLM4TS_JUDGMENT_PROVIDER): confirm operations.md to call it");
59
+ }
60
+ const judged = options.judged.get(name);
61
+ if (judged === undefined)
62
+ return skip("the judgment did not answer: confirm operations.md");
63
+ if (judged.class !== "read" || judged.decision !== "act") {
64
+ return skip(`judgment says ${judged.class} ${judged.confidence.toFixed(2)} (${judged.decision}): confirm operations.md`);
65
+ }
66
+ return {
67
+ operation: name,
68
+ probe: true,
69
+ basis: "provisional",
70
+ reason: `provisional read: heuristic and judgment agree (${judged.confidence.toFixed(2)})`
71
+ };
72
+ });
73
+ // ---------------------------------------------------------------------------
74
+ // Producer-first order
75
+ const fieldName = (path) => (path.split(".").pop() ?? path).replace(/\[\]$/, "").replace(/^@/, "");
76
+ /** Leaf field names of an element; `requiredOnly` drops optional ones and those under optional parents. */
77
+ export const leafNames = (catalog, element, requiredOnly) => {
78
+ const declared = element === undefined ? undefined : elementByName(catalog, element);
79
+ if (declared === undefined)
80
+ return new Set();
81
+ const paths = schemaPaths(catalog, declared);
82
+ const optional = paths.filter((path) => path.optional).map((path) => path.path);
83
+ return new Set(paths
84
+ .filter((path) => path.leaf &&
85
+ (!requiredOnly ||
86
+ (!path.optional &&
87
+ !optional.some((prefix) => path.path.startsWith(`${prefix}.`) || path.path.startsWith(`${prefix}@`)))))
88
+ .map((path) => fieldName(path.path)));
89
+ };
90
+ /**
91
+ * Order `names` so producers run first: A precedes B when a leaf of A's
92
+ * response is a required input field of B. Kahn's algorithm; ties and cycles
93
+ * resolve in catalog order.
94
+ */
95
+ export const probeOrder = (catalog, names) => {
96
+ const operations = catalog.operations.filter((operation) => names.includes(operation.name));
97
+ const producers = new Map();
98
+ for (const consumer of operations) {
99
+ const needs = leafNames(catalog, consumer.input, true);
100
+ const links = [];
101
+ for (const producer of operations) {
102
+ if (producer === consumer)
103
+ continue;
104
+ const gives = leafNames(catalog, producer.output, false);
105
+ const fields = [...needs].filter((field) => gives.has(field));
106
+ if (fields.length > 0)
107
+ links.push({ producer: producer.name, fields });
108
+ }
109
+ producers.set(consumer.name, links);
110
+ }
111
+ const order = [];
112
+ const remaining = operations.map((operation) => operation.name);
113
+ while (remaining.length > 0) {
114
+ const ready = remaining.find((name) => (producers.get(name) ?? []).every((link) => !remaining.includes(link.producer))) ?? remaining[0];
115
+ if (ready === undefined)
116
+ break;
117
+ order.push(ready);
118
+ remaining.splice(remaining.indexOf(ready), 1);
119
+ }
120
+ return { order, producers };
121
+ };
122
+ /** First value per field name in a response payload. */
123
+ export const payloadSeeds = (catalog, operation, payload) => {
124
+ const output = operation.output === undefined ? undefined : elementByName(catalog, operation.output);
125
+ if (output === undefined)
126
+ return new Map();
127
+ const seeds = new Map();
128
+ for (const [key, value] of collectSeeds([instanceFromXml(catalog, output, payload).value])) {
129
+ if (key === "#text")
130
+ continue;
131
+ const name = key.replace(/^@/, "");
132
+ if (!seeds.has(name))
133
+ seeds.set(name, value);
134
+ }
135
+ return seeds;
136
+ };
137
+ const describeExchange = (exchange) => exchange.fault !== undefined
138
+ ? `fault ${exchange.fault.code} ${exchange.fault.reason}`
139
+ : `HTTP ${exchange.response?.status ?? "?"}`;
140
+ const withSeeds = (base, chained, fields) => {
141
+ const seeds = new Map(base);
142
+ for (const field of fields) {
143
+ const value = chained.get(field);
144
+ if (value !== undefined)
145
+ seeds.set(field, value);
146
+ }
147
+ return seeds;
148
+ };
149
+ export const exploreService = (options) => Effect.gen(function* () {
150
+ const { workspace, catalog, service } = options;
151
+ const progress = options.progress ?? (() => Effect.void);
152
+ const decisions = probeDecisions({
153
+ catalog,
154
+ operations: options.operations,
155
+ environment: options.profile?.environment,
156
+ judged: options.judged
157
+ });
158
+ const probed = decisions.filter((decision) => decision.probe).map((d) => d.operation);
159
+ const plan = probeOrder(catalog, probed);
160
+ // The probe calls only operations it decided on, as confirmed reads.
161
+ const probeFile = {
162
+ confirmed: true,
163
+ classes: new Map(probed.map((name) => [name, "read"]))
164
+ };
165
+ const serviceSeeds = yield* seedsForService(workspace, catalog, service);
166
+ const live = new Map();
167
+ const masked = new Map();
168
+ // Which operation's response supplied each chained field.
169
+ const suppliedBy = new Map();
170
+ const reports = new Map();
171
+ /**
172
+ * Write the request when absent; regenerate one explore owns only before
173
+ * calling it. A file the user owns (no marker line, or the XML form) is
174
+ * never touched.
175
+ */
176
+ const ensureRequest = (operation, chainedFields, notes, regenerate) => Effect.gen(function* () {
177
+ const paths = samplePaths(service, operation.name, exploreRequestName);
178
+ const yaml = yield* readIfPresent(workspace, paths.yaml);
179
+ const xml = yield* readIfPresent(workspace, paths.xml);
180
+ if (xml !== undefined ||
181
+ (yaml !== undefined && (!regenerate || !yaml.startsWith(exploreMarker)))) {
182
+ return "kept";
183
+ }
184
+ const text = renderRequestFile({
185
+ catalog,
186
+ operation: operation.name,
187
+ purpose: "happy path",
188
+ seeds: withSeeds(serviceSeeds, masked, chainedFields)
189
+ });
190
+ yield* workspace.write(paths.yaml, [exploreMarker, ...notes, text].join("\n"));
191
+ return yaml === undefined ? "written" : "regenerated";
192
+ });
193
+ for (const name of plan.order) {
194
+ const operation = catalog.operations.find((candidate) => candidate.name === name);
195
+ const decision = decisions.find((candidate) => candidate.operation === name);
196
+ if (operation === undefined || decision === undefined)
197
+ continue;
198
+ const listed = options.operations?.classes.get(name) ?? "unclassified";
199
+ const paths = samplePaths(service, name, exploreRequestName);
200
+ const recorded = yield* readIfPresent(workspace, paths.exchange);
201
+ const links = (plan.producers.get(name) ?? [])
202
+ .map((link) => ({
203
+ ...link,
204
+ fields: link.fields.filter((field) => suppliedBy.get(field) === link.producer)
205
+ }))
206
+ .filter((link) => link.fields.length > 0);
207
+ const chainedFields = new Set(links.flatMap((link) => link.fields));
208
+ if (recorded !== undefined && !options.refresh) {
209
+ const request = yield* ensureRequest(operation, new Set(), [], false);
210
+ reports.set(name, {
211
+ operation: name,
212
+ listed,
213
+ decision,
214
+ request,
215
+ outcome: { kind: "recorded", detail: "recorded earlier; --refresh calls it again" },
216
+ chained: []
217
+ });
218
+ continue;
219
+ }
220
+ const notes = links.length === 0
221
+ ? []
222
+ : [
223
+ `# Chained at probe time: ${links.map((link) => `${link.fields.join(", ")} from ${link.producer}`).join("; ")}.`,
224
+ "# Personal values appear masked here; soap-explore sent the live ones and never stores them."
225
+ ];
226
+ const request = yield* ensureRequest(operation, chainedFields, notes, true);
227
+ // A file explore owns is sent with the live chained values; the file on
228
+ // disk keeps the masked ones. A user's file is sent as written.
229
+ const override = request === "kept" || chainedFields.size === 0
230
+ ? undefined
231
+ : yield* parseYaml(renderRequestFile({
232
+ catalog,
233
+ operation: name,
234
+ purpose: "happy path",
235
+ seeds: withSeeds(serviceSeeds, live, chainedFields)
236
+ })).pipe(Effect.option);
237
+ const body = override !== undefined && override._tag === "Some" && isYamlMap(override.value)
238
+ ? override.value["body"]
239
+ : undefined;
240
+ const outcome = yield* callOperation({
241
+ workspace,
242
+ catalog,
243
+ service,
244
+ operation: name,
245
+ name: exploreRequestName,
246
+ profile: options.profile,
247
+ operations: probeFile,
248
+ secrets: options.secrets,
249
+ transport: options.transport,
250
+ allowMutating: undefined,
251
+ confirm: () => Effect.succeed(false),
252
+ ...(body === undefined
253
+ ? {}
254
+ : { request: { purpose: "happy path", body, form: "yaml", readIssues: [] } }),
255
+ ...(options.now === undefined ? {} : { now: options.now }),
256
+ ...(options.random === undefined ? {} : { random: options.random })
257
+ }).pipe(Effect.result);
258
+ let result;
259
+ if (outcome._tag === "Failure") {
260
+ // A request that did not validate may carry live chained values in
261
+ // its issue text: report where, never what.
262
+ const error = outcome.failure;
263
+ result = {
264
+ kind: "failed",
265
+ detail: error instanceof RequestInvalid
266
+ ? `not sent, the request does not validate at ${error.issues.map((issue) => issue.path).join(", ")}`
267
+ : error.message
268
+ };
269
+ }
270
+ else {
271
+ const { exchange, payload } = outcome.success;
272
+ result = {
273
+ kind: "called",
274
+ detail: `${describeExchange(exchange)}, ${exchange.response?.elapsedMs ?? 0} ms`,
275
+ findings: exchange.responseIssues.length
276
+ };
277
+ if (payload !== undefined) {
278
+ for (const [field, value] of payloadSeeds(catalog, operation, payload.live)) {
279
+ if (!live.has(field)) {
280
+ live.set(field, value);
281
+ suppliedBy.set(field, name);
282
+ }
283
+ }
284
+ for (const [field, value] of payloadSeeds(catalog, operation, payload.masked)) {
285
+ if (!masked.has(field))
286
+ masked.set(field, value);
287
+ }
288
+ }
289
+ }
290
+ yield* progress(`${name}: ${result.detail}${links.length === 0 ? "" : ` (chained ${[...chainedFields].join(", ")})`}`);
291
+ reports.set(name, {
292
+ operation: name,
293
+ listed,
294
+ decision,
295
+ request,
296
+ outcome: result,
297
+ chained: links
298
+ });
299
+ }
300
+ // Every other operation gets a request to edit, seeded with what the
301
+ // probe learnt (masked), and the reason it was not called.
302
+ for (const operation of catalog.operations) {
303
+ if (reports.has(operation.name))
304
+ continue;
305
+ const decision = decisions.find((candidate) => candidate.operation === operation.name);
306
+ if (decision === undefined)
307
+ continue;
308
+ const needs = leafNames(catalog, operation.input, true);
309
+ const request = yield* ensureRequest(operation, new Set([...needs].filter((field) => masked.has(field))), [], false);
310
+ const recorded = yield* readIfPresent(workspace, samplePaths(service, operation.name, exploreRequestName).exchange);
311
+ reports.set(operation.name, {
312
+ operation: operation.name,
313
+ listed: options.operations?.classes.get(operation.name) ?? "unclassified",
314
+ decision,
315
+ request,
316
+ outcome: recorded === undefined
317
+ ? { kind: "not-called", detail: decision.reason }
318
+ : { kind: "recorded", detail: "recorded earlier" },
319
+ chained: []
320
+ });
321
+ }
322
+ return {
323
+ decisions,
324
+ plan,
325
+ operations: catalog.operations.flatMap((operation) => {
326
+ const report = reports.get(operation.name);
327
+ return report === undefined ? [] : [report];
328
+ })
329
+ };
330
+ });
331
+ /** analysis/<op>.md for every operation, then design/mapping.json + .md. */
332
+ export const writeEvidence = (workspace, catalog, service) => Effect.gen(function* () {
333
+ const analyses = yield* writeAnalyses(workspace, catalog, service);
334
+ const mapping = mapService(catalog);
335
+ const paths = designPaths(serviceDirectory(service));
336
+ yield* workspace.write(paths.mapping, encodeMapping(mapping));
337
+ yield* workspace.write(paths.mappingSummary, renderMapping(mapping));
338
+ const exchanges = (yield* readExchanges(workspace, service)).length;
339
+ return { analyses, exchanges };
340
+ });
341
+ /**
342
+ * The design step: an existing api-design.md is only checked; otherwise,
343
+ * with evidence and a reasoning seat, draft it, check it, and revise once
344
+ * when errors remain. The file is always written as proposed.
345
+ */
346
+ export const designStep = (options) => Effect.gen(function* () {
347
+ const { workspace, catalog, operations } = options;
348
+ const analyses = options.evidence.analyses;
349
+ const paths = designPaths(serviceDirectory(options.service));
350
+ const existing = yield* readIfPresent(workspace, paths.design);
351
+ if (existing !== undefined) {
352
+ const file = yield* parseDesignFile(existing).pipe(Effect.result);
353
+ if (file._tag === "Failure")
354
+ return { kind: "failed", detail: file.failure.message };
355
+ const issues = checkDesign(file.success.design, { catalog, operations, analyses });
356
+ yield* workspace.write(paths.design, renderDesignFile(file.success.design, issues, file.success.approved ? "approved" : "proposed"));
357
+ const errors = blockingIssues(issues).length;
358
+ return {
359
+ kind: "checked",
360
+ approved: file.success.approved,
361
+ errors,
362
+ warnings: issues.length - errors
363
+ };
364
+ }
365
+ if ("off" in options.reasoning)
366
+ return { kind: "skipped", reason: options.reasoning.off };
367
+ if (options.evidence.exchanges === 0) {
368
+ return { kind: "skipped", reason: "no exchanges yet: the design needs evidence" };
369
+ }
370
+ const reasoning = options.reasoning;
371
+ const base = {
372
+ catalog,
373
+ operations,
374
+ mapping: mapService(catalog),
375
+ analyses,
376
+ style: options.style
377
+ };
378
+ const drafted = yield* proposeDesign(reasoning, base).pipe(Effect.result);
379
+ if (drafted._tag === "Failure") {
380
+ return { kind: "failed", detail: `drafting failed: ${drafted.failure.message}` };
381
+ }
382
+ let design = drafted.success;
383
+ let issues = checkDesign(design, { catalog, operations, analyses });
384
+ let kind = "drafted";
385
+ if (blockingIssues(issues).length > 0) {
386
+ const revised = yield* reviseDesign(reasoning, { ...base, current: design, issues }).pipe(Effect.result);
387
+ if (revised._tag === "Success") {
388
+ design = revised.success;
389
+ issues = checkDesign(design, { catalog, operations, analyses });
390
+ kind = "revised";
391
+ }
392
+ }
393
+ yield* workspace.write(paths.design, renderDesignFile(design, issues, "proposed"));
394
+ const errors = blockingIssues(issues).length;
395
+ return { kind, approved: false, errors, warnings: issues.length - errors };
396
+ });
397
+ // ---------------------------------------------------------------------------
398
+ // Report
399
+ const outcomeText = (outcome) => outcome.kind === "called"
400
+ ? `called: ${outcome.detail}${outcome.findings === 0 ? "" : `, ${outcome.findings} schema findings`}`
401
+ : `${outcome.kind}: ${outcome.detail}`;
402
+ const cell = (text) => text.replace(/\|/g, "\\|").replace(/\n/g, " ");
403
+ export const renderExploreReport = (options) => {
404
+ const { service, catalog, result, design } = options;
405
+ const paths = servicePaths(service);
406
+ const lines = [
407
+ `# Explore: ${service}`,
408
+ "",
409
+ "Regenerated by every `soap-explore` run. Every value in the files it points at is masked.",
410
+ "",
411
+ `- operations: ${catalog.operations.length}, open questions: ${catalog.openQuestions.length} (see catalog.md)`,
412
+ `- environment: ${options.profile?.environment ?? "none (no auth.json)"}`,
413
+ `- operations.md: ${options.operations === undefined ? "missing" : options.operations.confirmed ? "confirmed" : "proposed"}`,
414
+ `- exchanges recorded: ${options.evidence.exchanges}`,
415
+ "",
416
+ "## Operations",
417
+ "",
418
+ "| Operation | Class | Probe | Request | Result |",
419
+ "| --- | --- | --- | --- | --- |"
420
+ ];
421
+ for (const report of result.operations) {
422
+ const request = `samples/${report.operation}/${exploreRequestName} (${report.request})`;
423
+ lines.push(`| ${cell(report.operation)} | ${report.listed}${options.operations?.confirmed === true ? "" : " (proposed)"} | ${report.decision.probe ? `yes, ${report.decision.basis ?? ""}` : "no"} | ${cell(request)} | ${cell(report.decision.probe ? outcomeText(report.outcome) : report.outcome.kind === "recorded" ? outcomeText(report.outcome) : `not called: ${report.decision.reason}`)} |`);
424
+ }
425
+ const chained = result.operations.filter((report) => report.chained.length > 0);
426
+ if (chained.length > 0) {
427
+ lines.push("", "## Chained values", "");
428
+ for (const report of chained) {
429
+ lines.push(`- ${report.operation} ← ${report.chained.map((link) => `${link.producer} (${link.fields.join(", ")})`).join("; ")}`);
430
+ }
431
+ }
432
+ lines.push("", "## Design", "");
433
+ switch (design.kind) {
434
+ case "skipped":
435
+ lines.push(`Not drafted: ${design.reason}.`);
436
+ break;
437
+ case "failed":
438
+ lines.push(`Not drafted: ${design.detail}`);
439
+ break;
440
+ default:
441
+ lines.push(`design/api-design.md ${design.kind}: ${design.approved ? "approved" : "proposed"}, ${design.errors} errors, ${design.warnings} warnings.`);
442
+ }
443
+ const next = [];
444
+ if (options.profile === undefined) {
445
+ next.push(`Write \`${paths.auth}\` to enable calls, at least \`{"environment": "test"}\` (dev, test, or uat; add "endpoint" when the WSDL names another environment, and auth by env:/file: reference). Then rerun soap-explore.`);
446
+ }
447
+ if (options.operations !== undefined && !options.operations.confirmed) {
448
+ next.push(`Review the classes in \`${paths.operations}\` and set \`Status: confirmed\`; then rerun soap-explore to probe every read operation.`);
449
+ }
450
+ for (const report of result.operations) {
451
+ if (report.outcome.kind === "failed") {
452
+ next.push(`${report.operation}: fix \`samples/${report.operation}/${exploreRequestName}.request.yaml\` (delete its first line to keep your edits), then \`soap-sample "call ${report.operation}/${exploreRequestName}"\`.`);
453
+ }
454
+ }
455
+ const mutating = result.operations
456
+ .filter((report) => report.listed === "mutating" && report.outcome.kind === "not-called")
457
+ .map((report) => report.operation);
458
+ if (options.profile !== undefined && mutating.length > 0) {
459
+ next.push(`Mutating operations are never probed (${mutating.join(", ")}): edit a request, then \`soap-sample -- --allow-mutating <operation> "call <operation>/${exploreRequestName}"\` when a test call is acceptable.`);
460
+ }
461
+ if (options.evidence.exchanges > 0) {
462
+ next.push('More evidence per operation (empty result, business fault, paging): `soap-sample "propose <operation>"`, then `soap-sample "call <operation>"`, then rerun soap-explore.');
463
+ }
464
+ if (design.kind === "skipped" || design.kind === "failed") {
465
+ if (options.evidence.exchanges > 0)
466
+ next.push('Draft the design: `soap-design "design"`.');
467
+ }
468
+ else if (!design.approved) {
469
+ next.push('Review `design/api-design.md`, run `soap-design "check"` until it has no errors, set `Status: approved`, then `soap-design "openapi"` and `soap-epic`.');
470
+ }
471
+ else if (design.errors === 0) {
472
+ next.push('The design is approved: `soap-design "openapi"`, then `soap-epic -- --target <ace-repo> plan`.');
473
+ }
474
+ lines.push("", "## Next", "", ...next.map((line) => `- ${line}`), "");
475
+ return lines.join("\n");
476
+ };
@@ -0,0 +1,190 @@
1
+ // One pass from a WSDL to a design draft: discover, a request for every operation, a read-only probe with values chained between calls, analyses, the 1:1 mapping, and a drafted REST design; stops only where a person decides.
2
+ //
3
+ // The task text is the WSDL location (path or http(s) URL), as for
4
+ // soap-discover; without one it continues from the discovered service:
5
+ //
6
+ // llm4ts run soap-explore --repo ~/work/conti-api ./wsdl/DemoBank.wsdl
7
+ // llm4ts run soap-explore --repo . # rerun: continue where it stopped
8
+ // llm4ts run soap-explore --repo . -- --refresh # call the probed operations again
9
+ //
10
+ // Steps, each keeping what already exists (so a rerun continues):
11
+ //
12
+ // 1. discover catalog.json/.md; operations.md with proposed classes
13
+ // 2. requests samples/<op>/happy-path.request.yaml for every operation
14
+ // 3. probe calls read operations only, producers first; values a
15
+ // response returns seed the next request (live in memory,
16
+ // masked on disk). Before operations.md is confirmed an
17
+ // operation is probed only in dev/test when the name
18
+ // heuristic and the judgment seat (LLM4TS_JUDGMENT_PROVIDER)
19
+ // both say read. Mutating operations are never called.
20
+ // 4. evidence analysis/<op>.md, design/mapping.json/.md
21
+ // 5. design the reasoning seat (LLM4TS_REASONER, default claude)
22
+ // drafts design/api-design.md, check, one revise round;
23
+ // an existing design is only checked. --no-design skips.
24
+ // 6. report explore.md: per operation what happened, and the next
25
+ // command for every gap
26
+ //
27
+ // Calls need auth.json (at least {"environment": "test"}); explore never
28
+ // writes it. Approval stays human: operations.md `Status: confirmed`,
29
+ // api-design.md `Status: approved`. LLM4TS_SOAP_SERVICE names the service
30
+ // (and the auth.json used to fetch a URL); LLM4TS_SOAP_STUB=<dir> answers
31
+ // calls from <dir>/<operation>.xml instead of the network.
32
+ import { readFile } from "node:fs/promises";
33
+ import { isAbsolute, resolve } from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+ import * as Effect from "effect/Effect";
36
+ import { FlowAborted, Info, makeNodeWorkspace, mock, resolveFlowInput, runFlowMain, runNode, ScriptUsage } from "@llm4ts/runner";
37
+ import { coderFor, judgmentConnectorFromEnvironment } from "@llm4ts/runner/Connectors";
38
+ import { nodeSecretSource, resolveSide } from "./lib/soap/Auth.js";
39
+ import { heuristicClass, judgeClass } from "./lib/soap/Classification.js";
40
+ import { discoverService, loadAuthProfile, loadCatalog, loadOperationsFile } from "./lib/soap/Discover.js";
41
+ import { designStep, explorePaths, exploreService, renderExploreReport, writeEvidence } from "./lib/soap/Explore.js";
42
+ import { makeDirectoryStubTransport } from "./lib/soap/Stub.js";
43
+ import { makeNodeSoapTransport, makeTransportDocumentLoader } from "./lib/soap/Transport.js";
44
+ import { DocumentLoader } from "./lib/soap/Wsdl.js";
45
+ const usage = 'usage: soap-explore [--refresh] [--no-design] "<wsdl path or URL>"';
46
+ const isUrl = (location) => /^https?:\/\//i.test(location);
47
+ const parseExploreFlags = (argv) => Effect.gen(function* () {
48
+ let refresh = false;
49
+ let design = true;
50
+ const rest = [];
51
+ for (const argument of argv) {
52
+ if (argument === "--refresh")
53
+ refresh = true;
54
+ else if (argument === "--no-design")
55
+ design = false;
56
+ else if (argument.startsWith("--refresh=") || argument.startsWith("--no-design="))
57
+ return yield* ScriptUsage.make({ message: `${argument} takes no value\n${usage}` });
58
+ else
59
+ rest.push(argument);
60
+ }
61
+ return { refresh, design, rest };
62
+ });
63
+ const styleGuide = () => Effect.tryPromise({
64
+ try: () => readFile(fileURLToPath(new URL("../packs/ace12-rest/api-style.md", import.meta.url)), "utf8"),
65
+ catch: () => FlowAborted.make({ message: "cannot read the kit's packs/ace12-rest/api-style.md" })
66
+ });
67
+ const program = Effect.gen(function* () {
68
+ const flags = yield* parseExploreFlags(process.argv.slice(2));
69
+ const input = yield* resolveFlowInput("", flags.rest);
70
+ const raw = input.prompt.trim();
71
+ const location = raw === "" ? undefined : isUrl(raw) || isAbsolute(raw) ? raw : resolve(input.workspace, raw);
72
+ const environment = process.env;
73
+ const requested = environment.LLM4TS_SOAP_SERVICE?.trim() || undefined;
74
+ const judgment = yield* judgmentConnectorFromEnvironment();
75
+ const reasonerName = (environment.LLM4TS_REASONER ?? "claude").trim() || "claude";
76
+ const reasoner = flags.design ? coderFor(reasonerName) : undefined;
77
+ if (flags.design && reasoner === undefined) {
78
+ return yield* ScriptUsage.make({ message: `unknown LLM4TS_REASONER '${reasonerName}'` });
79
+ }
80
+ yield* runNode({
81
+ workDir: input.workDir,
82
+ workspace: input.workspace,
83
+ userPrompt: input.prompt,
84
+ coder: mock,
85
+ ...(reasoner === undefined ? {} : { reasoning: reasoner }),
86
+ ...(judgment === undefined ? {} : { judgment }),
87
+ environment
88
+ }, (context) => Effect.gen(function* () {
89
+ const say = (message) => context.events.publish(Info.make({ message }));
90
+ const workspace = yield* makeNodeWorkspace(input.workDir);
91
+ const seat = judgment === undefined ? undefined : context.judgment;
92
+ // 1. discover (or continue from the discovered service)
93
+ let service;
94
+ let catalog;
95
+ let judged;
96
+ if (location !== undefined) {
97
+ const fetchProfile = requested === undefined ? undefined : yield* loadAuthProfile(workspace, requested);
98
+ const fetchSide = yield* resolveSide(nodeSecretSource(environment), fetchProfile?.fetch);
99
+ const discovered = yield* discoverService({
100
+ workspace,
101
+ location,
102
+ ...(requested === undefined ? {} : { service: requested }),
103
+ ...(seat === undefined ? {} : { judgment: seat })
104
+ }).pipe(Effect.provideService(DocumentLoader, makeTransportDocumentLoader(makeNodeSoapTransport(), fetchSide)));
105
+ service = discovered.service;
106
+ catalog = discovered.catalog;
107
+ // Classes judged at discovery are reused; otherwise judged below.
108
+ if (discovered.proposals !== undefined && seat !== undefined) {
109
+ judged = new Map(discovered.proposals.map((proposal) => [proposal.operation, proposal.judged]));
110
+ }
111
+ yield* say(`discovered ${service}: ${catalog.operations.length} operations, ${catalog.openQuestions.length} open questions`);
112
+ }
113
+ else {
114
+ const services = (yield* workspace.discover(".llm4ts/soap/*/catalog.json")).map((path) => path.split("/")[2] ?? "");
115
+ const picked = requested ?? (services.length === 1 ? services[0] : undefined);
116
+ if (picked === undefined) {
117
+ return yield* FlowAborted.make({
118
+ message: services.length === 0
119
+ ? `nothing discovered here; give the WSDL\n${usage}`
120
+ : `several services (${services.join(", ")}); set LLM4TS_SOAP_SERVICE`
121
+ });
122
+ }
123
+ service = picked;
124
+ catalog = yield* loadCatalog(workspace, service);
125
+ yield* say(`continuing ${service}: ${catalog.operations.length} operations`);
126
+ }
127
+ const profile = yield* loadAuthProfile(workspace, service);
128
+ const operations = yield* loadOperationsFile(workspace, service);
129
+ // Provisional probing asks the judgment only where the listed class
130
+ // and the heuristic already say read.
131
+ if (seat !== undefined && judged === undefined && operations?.confirmed !== true) {
132
+ const candidates = catalog.operations.filter((operation) => operations?.classes.get(operation.name) === "read" &&
133
+ heuristicClass(operation.name).class === "read");
134
+ judged = new Map(yield* Effect.forEach(candidates, (operation) => Effect.map(judgeClass(seat, catalog, operation), (answer) => [operation.name, answer])));
135
+ }
136
+ // 2-3. requests and the probe
137
+ const stub = environment.LLM4TS_SOAP_STUB?.trim();
138
+ const transport = stub === undefined || stub === ""
139
+ ? makeNodeSoapTransport()
140
+ : makeDirectoryStubTransport(resolve(input.workspace, stub), catalog);
141
+ if (stub !== undefined && stub !== "")
142
+ yield* say(`LLM4TS_SOAP_STUB: answering from ${stub}, not the network`);
143
+ const result = yield* exploreService({
144
+ workspace,
145
+ catalog,
146
+ service,
147
+ profile,
148
+ operations,
149
+ judged,
150
+ secrets: nodeSecretSource(environment),
151
+ transport,
152
+ refresh: flags.refresh,
153
+ progress: (line) => say(` ${line}`)
154
+ });
155
+ const probed = result.decisions.filter((decision) => decision.probe).length;
156
+ yield* say(`probe: ${probed} of ${catalog.operations.length} operations callable now${profile === undefined ? " (no auth.json)" : ""}`);
157
+ // 4. evidence
158
+ const evidence = yield* writeEvidence(workspace, catalog, service);
159
+ // 5. design
160
+ const design = yield* designStep({
161
+ workspace,
162
+ catalog,
163
+ service,
164
+ operations,
165
+ evidence,
166
+ reasoning: flags.design ? context.reasoning : { off: "--no-design" },
167
+ style: yield* styleGuide()
168
+ });
169
+ yield* say(design.kind === "skipped" || design.kind === "failed"
170
+ ? `design: not drafted (${design.kind === "skipped" ? design.reason : design.detail})`
171
+ : `design: ${design.kind}, ${design.approved ? "approved" : "proposed"}, ${design.errors} errors, ${design.warnings} warnings`);
172
+ // 6. report
173
+ const report = renderExploreReport({
174
+ service,
175
+ catalog,
176
+ profile,
177
+ operations,
178
+ result,
179
+ design,
180
+ evidence
181
+ });
182
+ const path = explorePaths(service).report;
183
+ yield* workspace.write(path, report);
184
+ const next = report.split("## Next")[1]?.trim().split("\n") ?? [];
185
+ yield* say(`wrote ${path}; next:`);
186
+ for (const line of next)
187
+ yield* say(` ${line}`);
188
+ }));
189
+ });
190
+ runFlowMain(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/shell",
3
- "version": "2.15.0",
3
+ "version": "2.16.0",
4
4
  "description": "Interactive shell and CLI for llm4ts: flow discovery, run-a-flow, and view",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -52,9 +52,9 @@
52
52
  "dependencies": {
53
53
  "@effect/platform-node": "4.0.0-rc.115",
54
54
  "@effect/platform-node-shared": "4.0.0-rc.115",
55
- "@llm4ts/core": "2.15.0",
56
- "@llm4ts/runner": "2.15.0",
57
- "@llm4ts/flow": "2.15.0"
55
+ "@llm4ts/core": "2.16.0",
56
+ "@llm4ts/flow": "2.16.0",
57
+ "@llm4ts/runner": "2.16.0"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "effect": "4.0.0-rc.115"