@llm4ts/shell 2.12.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.
- 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 +157 -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 +675 -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 +453 -0
- package/kits/soap-ace/flows/lib/soap/Xsd.js +471 -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,204 @@
|
|
|
1
|
+
// Plan the IBM ACE 12 implementation of an approved REST design: seed the ACE repository with contracts, stubs, and house rules, and write the story plan epic-stories executes.
|
|
2
|
+
//
|
|
3
|
+
// Runs rooted at the repository where soap-discover/sample/design ran; the
|
|
4
|
+
// ACE repository is --target (a git repository, created beforehand):
|
|
5
|
+
//
|
|
6
|
+
// llm4ts run soap-epic --repo . -- --target ~/work/demo-bank-ace plan
|
|
7
|
+
// llm4ts run soap-epic --repo . doctor # is ACE 12 usable on this machine?
|
|
8
|
+
//
|
|
9
|
+
// `plan` requires design/api-design.md approved with no check errors. It
|
|
10
|
+
// writes into the target: contracts/ (the design, OpenAPI 3.1 and the 3.0.3
|
|
11
|
+
// variant ACE imports, the 1:1 mapping, the WSDL and XSDs), test-data/ (the
|
|
12
|
+
// masked exchanges, the stubs' source), docs/ (analysis reports, ESQL
|
|
13
|
+
// pattern cards) — all regenerated each run — and, only when absent, the
|
|
14
|
+
// ace12-rest-api scaffold (README, CONTRIBUTING.md house rules, gate
|
|
15
|
+
// scripts). Then it derives the story plan (skeleton, policies, shared
|
|
16
|
+
// library, stubs, one story per resource, contract check) and saves it at
|
|
17
|
+
// <target>/.llm4ts/epics/<epic-id>/plan.md, where epic-stories reads it; an
|
|
18
|
+
// existing plan is kept (editing it is the re-plan). It prints the exact
|
|
19
|
+
// epic-stories command: gates via scripts/ace-gates.sh, no worktree setup,
|
|
20
|
+
// concurrency 2, branches only. LLM4TS_ACE_API names the API project
|
|
21
|
+
// (default from the service), LLM4TS_SOAP_SERVICE picks the service.
|
|
22
|
+
import { existsSync } from "node:fs";
|
|
23
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
24
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import * as Effect from "effect/Effect";
|
|
27
|
+
import { makeStoryPlanStore } from "@llm4ts/flow/StoryPlan";
|
|
28
|
+
import { FlowAborted, Info, makeNodeWorkspace, mock, nodePlainFileStore, nodeProcessExecutor, resolveFlowInput, runFlowMain, runNode, ScriptUsage } from "@llm4ts/runner";
|
|
29
|
+
import { analysisPaths, writeAnalyses } from "./lib/soap/Analysis.js";
|
|
30
|
+
import { blockingIssues, checkDesign, designPaths, parseDesignFile } from "./lib/soap/Design.js";
|
|
31
|
+
import { loadCatalog, loadOperationsFile, readIfPresent, serviceDirectory } from "./lib/soap/Discover.js";
|
|
32
|
+
import { apiNameFor, epicSentence, planViolations, seedFiles, storyPlanFor, writeSeed } from "./lib/soap/Epic.js";
|
|
33
|
+
import { readExchanges } from "./lib/soap/Samples.js";
|
|
34
|
+
import { decodeXml } from "./lib/soap/Xml.js";
|
|
35
|
+
const usage = 'usage: soap-epic [--target <ace-repo>] "plan | doctor"';
|
|
36
|
+
const kitRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
37
|
+
const filesUnder = (root) => Effect.promise(async () => {
|
|
38
|
+
const found = [];
|
|
39
|
+
const walk = async (directory) => {
|
|
40
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
41
|
+
const full = join(directory, entry.name);
|
|
42
|
+
if (entry.isDirectory())
|
|
43
|
+
await walk(full);
|
|
44
|
+
else
|
|
45
|
+
found.push({ path: relative(root, full), contents: await readFile(full, "utf8") });
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
if (existsSync(root))
|
|
49
|
+
await walk(root);
|
|
50
|
+
return found;
|
|
51
|
+
});
|
|
52
|
+
const parseEpicFlags = (argv) => Effect.gen(function* () {
|
|
53
|
+
let target;
|
|
54
|
+
const rest = [];
|
|
55
|
+
for (let index = 0; index < argv.length; index++) {
|
|
56
|
+
const argument = argv[index] ?? "";
|
|
57
|
+
if (argument === "--target" || argument.startsWith("--target=")) {
|
|
58
|
+
target = argument.includes("=") ? argument.slice("--target=".length) : argv[++index];
|
|
59
|
+
if (target === undefined || target === "")
|
|
60
|
+
return yield* ScriptUsage.make({ message: `--target needs a path\n${usage}` });
|
|
61
|
+
}
|
|
62
|
+
else
|
|
63
|
+
rest.push(argument);
|
|
64
|
+
}
|
|
65
|
+
return { target, rest };
|
|
66
|
+
});
|
|
67
|
+
const program = Effect.gen(function* () {
|
|
68
|
+
const flags = yield* parseEpicFlags(process.argv.slice(2));
|
|
69
|
+
const input = yield* resolveFlowInput("plan", flags.rest);
|
|
70
|
+
const [command = "plan"] = input.prompt.trim().split(/\s+/);
|
|
71
|
+
const environment = process.env;
|
|
72
|
+
yield* runNode({
|
|
73
|
+
workDir: input.workDir,
|
|
74
|
+
workspace: input.workspace,
|
|
75
|
+
userPrompt: input.prompt,
|
|
76
|
+
coder: mock,
|
|
77
|
+
environment
|
|
78
|
+
}, (context) => Effect.gen(function* () {
|
|
79
|
+
const say = (message) => context.events.publish(Info.make({ message }));
|
|
80
|
+
if (command === "doctor") {
|
|
81
|
+
const profile = environment.MQSI_PROFILE ??
|
|
82
|
+
join(environment.ACE_HOME ?? "/opt/ibm/ace-12", "server", "bin", "mqsiprofile");
|
|
83
|
+
yield* say(`mqsiprofile: ${existsSync(profile) ? "found" : "missing"} at ${profile} (MQSI_PROFILE or ACE_HOME)`);
|
|
84
|
+
if (!existsSync(profile)) {
|
|
85
|
+
return yield* FlowAborted.make({
|
|
86
|
+
message: "ACE 12 is not installed here, or MQSI_PROFILE/ACE_HOME do not point at it; the container gate (scripts/ace-gates-container.sh with ACE_IMAGE) is the alternative"
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// Only presence and version: the gates themselves prove the rest.
|
|
90
|
+
const checks = [
|
|
91
|
+
["ACE version", "mqsiservice -v"],
|
|
92
|
+
["ibmint", "command -v ibmint"],
|
|
93
|
+
["IntegrationServer", "command -v IntegrationServer"],
|
|
94
|
+
["mqsicreateworkdir", "command -v mqsicreateworkdir"]
|
|
95
|
+
];
|
|
96
|
+
for (const [label, check] of checks) {
|
|
97
|
+
const result = yield* nodeProcessExecutor
|
|
98
|
+
.run(["bash", "-c", `set +u; . "${profile}" >/dev/null 2>&1; ${check}`], input.workDir, {})
|
|
99
|
+
.pipe(Effect.result);
|
|
100
|
+
const line = result._tag === "Success" && result.success.exitCode === 0
|
|
101
|
+
? `ok (${result.success.stdout.find((entry) => entry.trim() !== "")?.trim() ?? ""})`
|
|
102
|
+
: "not found";
|
|
103
|
+
yield* say(`${label}: ${line}`);
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (command !== "plan")
|
|
108
|
+
return yield* FlowAborted.make({ message: `unknown command ${command}\n${usage}` });
|
|
109
|
+
if (flags.target === undefined)
|
|
110
|
+
return yield* FlowAborted.make({ message: `plan needs --target <ace-repo>\n${usage}` });
|
|
111
|
+
const targetRoot = resolve(input.workspace, flags.target);
|
|
112
|
+
if (!existsSync(targetRoot))
|
|
113
|
+
return yield* FlowAborted.make({
|
|
114
|
+
message: `${targetRoot} does not exist; create the ACE repository (git init) first`
|
|
115
|
+
});
|
|
116
|
+
if (!existsSync(join(targetRoot, ".git")))
|
|
117
|
+
yield* say(`⚠ ${targetRoot} is not a git repository; epic-stories needs one with at least one commit`);
|
|
118
|
+
const workspace = yield* makeNodeWorkspace(input.workDir);
|
|
119
|
+
const requested = environment.LLM4TS_SOAP_SERVICE?.trim();
|
|
120
|
+
const services = (yield* workspace.discover(".llm4ts/soap/*/catalog.json")).map((path) => path.split("/")[2] ?? "");
|
|
121
|
+
const service = requested !== undefined && requested !== ""
|
|
122
|
+
? requested
|
|
123
|
+
: services.length === 1
|
|
124
|
+
? services[0]
|
|
125
|
+
: undefined;
|
|
126
|
+
if (service === undefined) {
|
|
127
|
+
return yield* FlowAborted.make({
|
|
128
|
+
message: services.length === 0
|
|
129
|
+
? "no discovered service here; run soap-discover first"
|
|
130
|
+
: `several services (${services.join(", ")}); set LLM4TS_SOAP_SERVICE`
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const catalog = yield* loadCatalog(workspace, service);
|
|
134
|
+
const operations = yield* loadOperationsFile(workspace, service);
|
|
135
|
+
const paths = designPaths(serviceDirectory(service));
|
|
136
|
+
const text = yield* readIfPresent(workspace, paths.design);
|
|
137
|
+
if (text === undefined)
|
|
138
|
+
return yield* FlowAborted.make({ message: `no ${paths.design}; run soap-design first` });
|
|
139
|
+
const file = yield* parseDesignFile(text);
|
|
140
|
+
const analyses = yield* writeAnalyses(workspace, catalog, service);
|
|
141
|
+
const errors = blockingIssues(checkDesign(file.design, { catalog, operations, analyses }));
|
|
142
|
+
if (!file.approved || errors.length > 0) {
|
|
143
|
+
return yield* FlowAborted.make({
|
|
144
|
+
message: `${paths.design} must be approved with no check errors (${file.approved ? `${errors.length} errors` : "not approved"}); run soap-design "check"`
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const api = environment.LLM4TS_ACE_API?.trim() || apiNameFor(service);
|
|
148
|
+
const soapRoot = catalog.documents[0] === undefined ? undefined : dirname(catalog.documents[0]);
|
|
149
|
+
// Schemas may be declared in ISO-8859-1: decode by their declaration and
|
|
150
|
+
// re-emit as UTF-8 so the ACE toolkit and git see one encoding.
|
|
151
|
+
const soapDocuments = yield* Effect.forEach(catalog.documents.filter((document) => !/^https?:/i.test(document)), (document) => Effect.gen(function* () {
|
|
152
|
+
const bytes = yield* Effect.promise(() => readFile(document));
|
|
153
|
+
const text = yield* decodeXml(new Uint8Array(bytes), document);
|
|
154
|
+
return {
|
|
155
|
+
path: relative(soapRoot ?? dirname(document), document),
|
|
156
|
+
contents: text.replace(/^(<\?xml[^>]*?encoding\s*=\s*["'])[^"']+(["'])/, "$1UTF-8$2")
|
|
157
|
+
};
|
|
158
|
+
}));
|
|
159
|
+
if (soapDocuments.length < catalog.documents.length) {
|
|
160
|
+
yield* say("⚠ the WSDL was read from a URL: download it and its schemas into contracts/soap/ of the ACE repository");
|
|
161
|
+
}
|
|
162
|
+
const analysisMarkdown = yield* Effect.forEach(catalog.operations, (operation) => Effect.map(workspace.read(analysisPaths(service, operation.name).markdown), (markdown) => ({ operation: operation.name, markdown })));
|
|
163
|
+
const files = seedFiles({
|
|
164
|
+
design: file.design,
|
|
165
|
+
catalog,
|
|
166
|
+
service,
|
|
167
|
+
api,
|
|
168
|
+
scaffold: yield* filesUnder(join(kitRoot, "scaffolds", "ace12-rest-api")),
|
|
169
|
+
patterns: yield* filesUnder(join(kitRoot, "patterns")),
|
|
170
|
+
soapDocuments,
|
|
171
|
+
analyses: analysisMarkdown,
|
|
172
|
+
exchanges: yield* readExchanges(workspace, service)
|
|
173
|
+
});
|
|
174
|
+
// The plan is derived and checked before anything touches the target.
|
|
175
|
+
const plan = storyPlanFor({ design: file.design, service, api, analyses });
|
|
176
|
+
const violations = planViolations(plan);
|
|
177
|
+
if (violations.length > 0)
|
|
178
|
+
return yield* FlowAborted.make({
|
|
179
|
+
message: `derived story plan is invalid:\n${violations.join("\n")}`
|
|
180
|
+
});
|
|
181
|
+
const target = yield* makeNodeWorkspace(targetRoot);
|
|
182
|
+
const seeded = yield* writeSeed(target, files);
|
|
183
|
+
yield* say(`seeded ${targetRoot}: ${seeded.written} files written, ${seeded.kept} team-owned files kept`);
|
|
184
|
+
const planPath = join(targetRoot, ".llm4ts", "epics", plan.epicId, "plan.md");
|
|
185
|
+
const store = makeStoryPlanStore(nodePlainFileStore);
|
|
186
|
+
const existing = yield* store.load(planPath);
|
|
187
|
+
if (existing === undefined) {
|
|
188
|
+
yield* store.save(planPath, plan);
|
|
189
|
+
yield* say(`story plan: ${plan.stories.length} stories at ${planPath}`);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
yield* say(`kept the existing story plan at ${planPath} (${existing.stories.length} stories); delete it to re-derive`);
|
|
193
|
+
}
|
|
194
|
+
for (const story of plan.stories) {
|
|
195
|
+
yield* say(` ${story.id}${story.dependsOn.length === 0 ? "" : ` (after ${story.dependsOn.join(", ")})`}: owns ${story.owned.join(", ")}`);
|
|
196
|
+
}
|
|
197
|
+
yield* say([
|
|
198
|
+
"next: review the plan, commit the seed in the ACE repository, then run",
|
|
199
|
+
` LLM4TS_GATES="bash scripts/ace-gates.sh build;bash scripts/ace-gates.sh test" LLM4TS_WORKTREE_SETUP= \\`,
|
|
200
|
+
` llm4ts run epic-stories --repo ${targetRoot} -- --concurrency 2 ${JSON.stringify(epicSentence(file.design, api, service))}`
|
|
201
|
+
].join("\n"));
|
|
202
|
+
}));
|
|
203
|
+
});
|
|
204
|
+
runFlowMain(program);
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Author, call, and import SOAP samples: YAML request files from the catalog, masked recorded exchanges, SoapUI import, LLM-proposed scenario sets.
|
|
2
|
+
//
|
|
3
|
+
// Runs after soap-discover, rooted at the same repository. The task text is
|
|
4
|
+
// a command:
|
|
5
|
+
//
|
|
6
|
+
// llm4ts run soap-sample --repo . "list" requests and exchanges per operation
|
|
7
|
+
// llm4ts run soap-sample --repo . "init cercaMovimenti" a commented skeleton (happy-path)
|
|
8
|
+
// llm4ts run soap-sample --repo . "init cercaMovimenti fine-mese"
|
|
9
|
+
// llm4ts run soap-sample --repo . "propose cercaMovimenti" scenario set from the reasoning seat
|
|
10
|
+
// llm4ts run soap-sample --repo . "import ./DemoBank-soapui-project.xml"
|
|
11
|
+
// llm4ts run soap-sample --repo . "call cercaMovimenti/happy-path"
|
|
12
|
+
// llm4ts run soap-sample --repo . "call cercaMovimenti" every request of the operation
|
|
13
|
+
// llm4ts run soap-sample --repo . -- --allow-mutating revocaBonifico "call revocaBonifico/gia-eseguito"
|
|
14
|
+
//
|
|
15
|
+
// Requests live in .llm4ts/soap/<service>/samples/<operation>/<name>.request.yaml
|
|
16
|
+
// (or .request.xml). A call validates the request (an invalid one is never
|
|
17
|
+
// sent), checks the confirmed class in operations.md and the environment's
|
|
18
|
+
// mutating policy, sends it with auth.json's call side (Basic/Bearer, mTLS,
|
|
19
|
+
// WS-Security), and records a masked, auth-stripped exchange beside it.
|
|
20
|
+
// Flags: --allow-mutating <operation> (exactly one), --keep-raw (unmasked
|
|
21
|
+
// response under the gitignored raw/). LLM4TS_SOAP_SERVICE picks the service
|
|
22
|
+
// when several were discovered; LLM4TS_SOAP_STUB=<dir> answers calls from
|
|
23
|
+
// <dir>/<operation>.xml instead of the network (rehearsals); LLM4TS_REASONER
|
|
24
|
+
// picks the seat that proposes scenarios (default claude).
|
|
25
|
+
import { resolve } from "node:path";
|
|
26
|
+
import { readFile } from "node:fs/promises";
|
|
27
|
+
import * as Effect from "effect/Effect";
|
|
28
|
+
import { FlowAborted, Info, makeNodeWorkspace, mock, resolveFlowInput, runFlowMain, runNode, ScriptUsage } from "@llm4ts/runner";
|
|
29
|
+
import { coderFor } from "@llm4ts/runner/Connectors";
|
|
30
|
+
import { terminalInteraction } from "@llm4ts/runner/TerminalInteraction";
|
|
31
|
+
import { nodeSecretSource } from "./lib/soap/Auth.js";
|
|
32
|
+
import { callOperation } from "./lib/soap/Call.js";
|
|
33
|
+
import { operationByName } from "./lib/soap/Catalog.js";
|
|
34
|
+
import { effectiveClass } from "./lib/soap/Classification.js";
|
|
35
|
+
import { loadAuthProfile, loadCatalog, loadOperationsFile, readIfPresent } from "./lib/soap/Discover.js";
|
|
36
|
+
import { isSampleName, listRequests, loadMaskingKey, loadMaskingOverrides, readExchanges, renderRequestFile, samplePaths, seedsForService } from "./lib/soap/Samples.js";
|
|
37
|
+
import { proposeScenarios } from "./lib/soap/Scenarios.js";
|
|
38
|
+
import { importSoapUiProject } from "./lib/soap/SoapUiImport.js";
|
|
39
|
+
import { makeDirectoryStubTransport } from "./lib/soap/Stub.js";
|
|
40
|
+
import { makeNodeSoapTransport } from "./lib/soap/Transport.js";
|
|
41
|
+
const usage = [
|
|
42
|
+
'usage: soap-sample [--allow-mutating <operation>] [--keep-raw] "<command>"',
|
|
43
|
+
" list | init <operation> [name] | propose <operation> | import <soapui.xml>",
|
|
44
|
+
" call <operation>[/<name>] | call all"
|
|
45
|
+
].join("\n");
|
|
46
|
+
const parseSampleFlags = (argv) => Effect.gen(function* () {
|
|
47
|
+
let allowMutating;
|
|
48
|
+
let keepRaw = false;
|
|
49
|
+
const rest = [];
|
|
50
|
+
for (let index = 0; index < argv.length; index++) {
|
|
51
|
+
const argument = argv[index] ?? "";
|
|
52
|
+
if (argument === "--keep-raw")
|
|
53
|
+
keepRaw = true;
|
|
54
|
+
else if (argument === "--allow-mutating" || argument.startsWith("--allow-mutating=")) {
|
|
55
|
+
const value = argument.includes("=")
|
|
56
|
+
? argument.slice("--allow-mutating=".length)
|
|
57
|
+
: argv[++index];
|
|
58
|
+
if (value === undefined || value.trim() === "") {
|
|
59
|
+
return yield* ScriptUsage.make({
|
|
60
|
+
message: `--allow-mutating needs an operation name\n${usage}`
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
allowMutating = value.trim();
|
|
64
|
+
}
|
|
65
|
+
else
|
|
66
|
+
rest.push(argument);
|
|
67
|
+
}
|
|
68
|
+
return { allowMutating, keepRaw, rest };
|
|
69
|
+
});
|
|
70
|
+
/** The service directory: LLM4TS_SOAP_SERVICE, or the only one discovered. */
|
|
71
|
+
const pickService = (workspace, requested) => Effect.gen(function* () {
|
|
72
|
+
if (requested !== undefined && requested !== "")
|
|
73
|
+
return requested;
|
|
74
|
+
const catalogs = yield* workspace.discover(".llm4ts/soap/*/catalog.json");
|
|
75
|
+
const services = catalogs.map((path) => path.split("/")[2] ?? "");
|
|
76
|
+
if (services.length === 1)
|
|
77
|
+
return services[0] ?? "";
|
|
78
|
+
return yield* FlowAborted.make({
|
|
79
|
+
message: services.length === 0
|
|
80
|
+
? "no discovered service here; run soap-discover first"
|
|
81
|
+
: `several services (${services.join(", ")}); set LLM4TS_SOAP_SERVICE`
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
const requireOperation = (catalog, name) => name !== undefined && operationByName(catalog, name) !== undefined
|
|
85
|
+
? Effect.succeed(name)
|
|
86
|
+
: FlowAborted.make({
|
|
87
|
+
message: `unknown operation ${name ?? "(none)"}; one of: ${catalog.operations.map((operation) => operation.name).join(", ")}`
|
|
88
|
+
});
|
|
89
|
+
const program = Effect.gen(function* () {
|
|
90
|
+
const flags = yield* parseSampleFlags(process.argv.slice(2));
|
|
91
|
+
const input = yield* resolveFlowInput("list", flags.rest);
|
|
92
|
+
const [command = "list", ...args] = input.prompt.trim().split(/\s+/);
|
|
93
|
+
const environment = process.env;
|
|
94
|
+
const reasoner = command === "propose"
|
|
95
|
+
? coderFor((environment.LLM4TS_REASONER ?? "claude").trim() || "claude")
|
|
96
|
+
: undefined;
|
|
97
|
+
if (command === "propose" && reasoner === undefined) {
|
|
98
|
+
return yield* ScriptUsage.make({
|
|
99
|
+
message: `unknown LLM4TS_REASONER '${environment.LLM4TS_REASONER ?? ""}'`
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
yield* runNode({
|
|
103
|
+
workDir: input.workDir,
|
|
104
|
+
workspace: input.workspace,
|
|
105
|
+
userPrompt: input.prompt,
|
|
106
|
+
coder: mock,
|
|
107
|
+
...(reasoner === undefined ? {} : { reasoning: reasoner }),
|
|
108
|
+
environment
|
|
109
|
+
}, (context) => Effect.gen(function* () {
|
|
110
|
+
const say = (message) => context.events.publish(Info.make({ message }));
|
|
111
|
+
const workspace = yield* makeNodeWorkspace(input.workDir);
|
|
112
|
+
const service = yield* pickService(workspace, environment.LLM4TS_SOAP_SERVICE?.trim());
|
|
113
|
+
const catalog = yield* loadCatalog(workspace, service);
|
|
114
|
+
const operations = yield* loadOperationsFile(workspace, service);
|
|
115
|
+
switch (command) {
|
|
116
|
+
case "list": {
|
|
117
|
+
const exchanges = yield* readExchanges(workspace, service);
|
|
118
|
+
for (const operation of catalog.operations) {
|
|
119
|
+
const requests = yield* listRequests(workspace, service, operation.name);
|
|
120
|
+
const recorded = exchanges.filter((exchange) => exchange.operation === operation.name);
|
|
121
|
+
const faults = recorded.filter((exchange) => exchange.fault !== undefined).length;
|
|
122
|
+
const breaking = recorded.filter((exchange) => exchange.responseIssues.length > 0).length;
|
|
123
|
+
yield* say(`${operation.name} [${effectiveClass(operations, operation.name)}]: ${requests.length} requests${requests.length === 0 ? "" : ` (${requests.join(", ")})`}, ${recorded.length} exchanges, ${faults} faults, ${breaking} schema findings`);
|
|
124
|
+
}
|
|
125
|
+
if (operations?.confirmed !== true) {
|
|
126
|
+
yield* say(`operations.md is not confirmed: nothing can be called yet`);
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
case "init": {
|
|
131
|
+
const operation = yield* requireOperation(catalog, args[0]);
|
|
132
|
+
const name = args[1] ?? "happy-path";
|
|
133
|
+
if (!isSampleName(name)) {
|
|
134
|
+
return yield* FlowAborted.make({ message: `request names are kebab-case: ${name}` });
|
|
135
|
+
}
|
|
136
|
+
const paths = samplePaths(service, operation, name);
|
|
137
|
+
if ((yield* readIfPresent(workspace, paths.yaml)) !== undefined ||
|
|
138
|
+
(yield* readIfPresent(workspace, paths.xml)) !== undefined) {
|
|
139
|
+
return yield* FlowAborted.make({
|
|
140
|
+
message: `${operation}/${name} exists; edit it or pick another name`
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const seeds = yield* seedsForService(workspace, catalog, service);
|
|
144
|
+
yield* workspace.write(paths.yaml, renderRequestFile({ catalog, operation, purpose: name.replace(/-/g, " "), seeds }));
|
|
145
|
+
yield* say(`wrote ${paths.yaml}; edit it, then: soap-sample "call ${operation}/${name}"`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
case "propose": {
|
|
149
|
+
const operation = yield* requireOperation(catalog, args[0]);
|
|
150
|
+
const seeds = yield* seedsForService(workspace, catalog, service);
|
|
151
|
+
const written = yield* proposeScenarios({
|
|
152
|
+
reasoning: context.reasoning,
|
|
153
|
+
workspace,
|
|
154
|
+
catalog,
|
|
155
|
+
service,
|
|
156
|
+
operation,
|
|
157
|
+
operationClass: effectiveClass(operations, operation),
|
|
158
|
+
seeds
|
|
159
|
+
});
|
|
160
|
+
for (const scenario of written) {
|
|
161
|
+
yield* say(scenario.kept
|
|
162
|
+
? `kept existing ${scenario.path}`
|
|
163
|
+
: `wrote ${scenario.path}${scenario.issues.length === 0 ? "" : ` (${scenario.issues.length} problems to fix, listed in the file)`}`);
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
case "import": {
|
|
168
|
+
const file = args.join(" ");
|
|
169
|
+
if (file === "")
|
|
170
|
+
return yield* FlowAborted.make({
|
|
171
|
+
message: `import needs a SoapUI project path\n${usage}`
|
|
172
|
+
});
|
|
173
|
+
const project = yield* Effect.tryPromise({
|
|
174
|
+
try: () => readFile(resolve(input.workspace, file), "utf8"),
|
|
175
|
+
catch: () => FlowAborted.make({ message: `cannot read ${file}` })
|
|
176
|
+
});
|
|
177
|
+
const key = yield* loadMaskingKey(workspace, service);
|
|
178
|
+
const overrides = yield* loadMaskingOverrides(workspace, service);
|
|
179
|
+
const report = yield* importSoapUiProject({
|
|
180
|
+
workspace,
|
|
181
|
+
catalog,
|
|
182
|
+
service,
|
|
183
|
+
project,
|
|
184
|
+
key,
|
|
185
|
+
...(overrides === undefined ? {} : { overrides })
|
|
186
|
+
});
|
|
187
|
+
for (const request of report.requests) {
|
|
188
|
+
yield* say(`request ${request.path}${request.issues.length === 0 ? "" : ` (${request.issues.length} problems, listed in the file)`}`);
|
|
189
|
+
}
|
|
190
|
+
for (const response of report.responses)
|
|
191
|
+
yield* say(`exchange ${response.path}`);
|
|
192
|
+
for (const line of report.skipped)
|
|
193
|
+
yield* say(`skipped: ${line}`);
|
|
194
|
+
if (report.credentialsIgnored > 0) {
|
|
195
|
+
yield* say(`${report.credentialsIgnored} stored credentials in the project were ignored; put references in auth.json instead`);
|
|
196
|
+
}
|
|
197
|
+
yield* say(`masked ${report.masking.entries.reduce((sum, entry) => sum + entry.count, 0)} values`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
case "call": {
|
|
201
|
+
const target = args[0] ?? "";
|
|
202
|
+
const profile = yield* loadAuthProfile(workspace, service);
|
|
203
|
+
const stub = environment.LLM4TS_SOAP_STUB?.trim();
|
|
204
|
+
const transport = stub === undefined || stub === ""
|
|
205
|
+
? makeNodeSoapTransport()
|
|
206
|
+
: makeDirectoryStubTransport(resolve(input.workspace, stub), catalog);
|
|
207
|
+
if (stub !== undefined && stub !== "")
|
|
208
|
+
yield* say(`LLM4TS_SOAP_STUB: answering from ${stub}, not the network`);
|
|
209
|
+
const targets = [];
|
|
210
|
+
const operationNames = target === "all"
|
|
211
|
+
? catalog.operations.map((operation) => operation.name)
|
|
212
|
+
: [yield* requireOperation(catalog, target.split("/")[0])];
|
|
213
|
+
for (const operation of operationNames) {
|
|
214
|
+
const names = target.includes("/")
|
|
215
|
+
? [target.split("/")[1] ?? ""]
|
|
216
|
+
: yield* listRequests(workspace, service, operation);
|
|
217
|
+
for (const name of names)
|
|
218
|
+
targets.push([operation, name]);
|
|
219
|
+
}
|
|
220
|
+
if (targets.length === 0)
|
|
221
|
+
return yield* FlowAborted.make({
|
|
222
|
+
message: `no requests for ${target}; start with soap-sample "init ${target}"`
|
|
223
|
+
});
|
|
224
|
+
const confirm = (question) => process.stdin.isTTY === true
|
|
225
|
+
? terminalInteraction.ask(question).pipe(Effect.map((answer) => /^(y|yes|s|si|sì)$/i.test(answer.trim())), Effect.orElseSucceed(() => false))
|
|
226
|
+
: Effect.succeed(false);
|
|
227
|
+
let failures = 0;
|
|
228
|
+
for (const [operation, name] of targets) {
|
|
229
|
+
const outcome = yield* callOperation({
|
|
230
|
+
workspace,
|
|
231
|
+
catalog,
|
|
232
|
+
service,
|
|
233
|
+
operation,
|
|
234
|
+
name,
|
|
235
|
+
profile,
|
|
236
|
+
operations,
|
|
237
|
+
secrets: nodeSecretSource(environment),
|
|
238
|
+
transport,
|
|
239
|
+
allowMutating: flags.allowMutating,
|
|
240
|
+
confirm,
|
|
241
|
+
keepRaw: flags.keepRaw
|
|
242
|
+
}).pipe(Effect.result);
|
|
243
|
+
if (outcome._tag === "Failure") {
|
|
244
|
+
failures++;
|
|
245
|
+
yield* say(`${operation}/${name}: ${outcome.failure.message}`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const { exchange } = outcome.success;
|
|
249
|
+
const status = exchange.fault !== undefined
|
|
250
|
+
? `fault ${exchange.fault.code} ${exchange.fault.reason}`
|
|
251
|
+
: `HTTP ${exchange.response?.status ?? "?"}`;
|
|
252
|
+
yield* say(`${operation}/${name}: ${status}, ${exchange.response?.elapsedMs ?? 0} ms, ${exchange.responseIssues.length} schema findings, ${exchange.masking.entries.reduce((sum, entry) => sum + entry.count, 0)} values masked → ${outcome.success.path}`);
|
|
253
|
+
}
|
|
254
|
+
if (failures > 0)
|
|
255
|
+
return yield* FlowAborted.make({
|
|
256
|
+
message: `${failures} of ${targets.length} calls did not complete`
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
default:
|
|
261
|
+
return yield* FlowAborted.make({ message: `unknown command ${command}\n${usage}` });
|
|
262
|
+
}
|
|
263
|
+
}));
|
|
264
|
+
});
|
|
265
|
+
runFlowMain(program);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# REST API style guide (soap-ace default)
|
|
2
|
+
|
|
3
|
+
The design step treats every rule here as a hard constraint and the design
|
|
4
|
+
judge scores against it. A bank-specific guide replaces this file by
|
|
5
|
+
shipping a project-tier copy of the kit
|
|
6
|
+
(`.llm4ts/kits/soap-ace/packs/ace12-rest/api-style.md`).
|
|
7
|
+
|
|
8
|
+
## Resources and paths
|
|
9
|
+
|
|
10
|
+
- Resources are plural nouns in kebab-case: `/accounts`, `/credit-transfers`.
|
|
11
|
+
- Identifiers are path segments: `/accounts/{accountId}`. An IBAN used as an
|
|
12
|
+
identifier is still an opaque path parameter.
|
|
13
|
+
- Sub-collections only for true containment:
|
|
14
|
+
`/accounts/{accountId}/movements`.
|
|
15
|
+
- Actions that are not CRUD are sub-resources named by the resulting state,
|
|
16
|
+
POSTed: `/credit-transfers/{id}/confirmation`, `/credit-transfers/{id}/revocation`.
|
|
17
|
+
- Versioning in the path: `/v1/...`. Breaking changes need a new version.
|
|
18
|
+
|
|
19
|
+
## Methods
|
|
20
|
+
|
|
21
|
+
- `GET` only for operations classified `read`; it never changes state.
|
|
22
|
+
- `POST` creates or triggers; `PUT` replaces; `PATCH` (JSON Merge Patch) for
|
|
23
|
+
partial updates; `DELETE` removes.
|
|
24
|
+
- Creating `POST`s return `201` with a `Location` header.
|
|
25
|
+
|
|
26
|
+
## Payloads
|
|
27
|
+
|
|
28
|
+
- JSON with camelCase property names in English; SOAP names are mapped, not
|
|
29
|
+
carried over (`dataContabile` → `bookingDate`).
|
|
30
|
+
- No wrapper objects that only mirror a SOAP element; no `esito` envelope in
|
|
31
|
+
successful responses: success is the HTTP status.
|
|
32
|
+
- Dates are ISO 8601 (`2026-09-25`), timestamps ISO 8601 with offset.
|
|
33
|
+
- Amounts are `{ "amount": "1234.56", "currency": "EUR" }`: the amount is a
|
|
34
|
+
decimal string, the currency ISO 4217.
|
|
35
|
+
- Enumerations are UPPER_SNAKE_CASE strings; values are mapped to English
|
|
36
|
+
when the meaning is clear, otherwise kept and documented.
|
|
37
|
+
- Absent optional values are omitted, not `null`, unless `null` carries meaning.
|
|
38
|
+
|
|
39
|
+
## Errors
|
|
40
|
+
|
|
41
|
+
- Errors are RFC 9457 problem details (`application/problem+json`) with
|
|
42
|
+
`type`, `title`, `status`, `detail`, and a stable machine-readable `code`.
|
|
43
|
+
- SOAP faults and `esito` KO codes map to statuses by meaning: validation →
|
|
44
|
+
`400`/`422`, not found → `404`, state conflict → `409`, authorization →
|
|
45
|
+
`403`, backend unavailable → `503`, anything unmapped → `502`.
|
|
46
|
+
- Backend codes are kept in the problem's `code`, never exposed as the HTTP
|
|
47
|
+
status or as free text only.
|
|
48
|
+
|
|
49
|
+
## Collections
|
|
50
|
+
|
|
51
|
+
- Lists are wrapped: `{ "items": [...], "page": {...} }`.
|
|
52
|
+
- Pagination follows what the SOAP service actually does, as observed in the
|
|
53
|
+
samples: page-number pagination (`page`, `size`, `totalItems` when known)
|
|
54
|
+
or cursor pagination (`next` cursor) — never both on one resource.
|
|
55
|
+
- Filters are query parameters named after the response properties they
|
|
56
|
+
filter (`?status=ACTIVE`, `?bookingDateFrom=...&bookingDateTo=...`).
|
|
57
|
+
|
|
58
|
+
## Traceability
|
|
59
|
+
|
|
60
|
+
- Every endpoint names the SOAP operation(s) it is built from, and every
|
|
61
|
+
property names its XSD path, in the design document.
|
|
62
|
+
- A SOAP operation not exposed is listed as deliberately excluded, with the
|
|
63
|
+
reason.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Pack: ace12-rest
|
|
2
|
+
|
|
3
|
+
source: soap
|
|
4
|
+
scaffold: ../../scaffolds/ace12-rest-api
|
|
5
|
+
sources: .*\.(wsdl|xsd)
|
|
6
|
+
specs-dir: contracts
|
|
7
|
+
features-dir: ACE_Test/src
|
|
8
|
+
|
|
9
|
+
## Gates
|
|
10
|
+
|
|
11
|
+
- build: bash scripts/ace-gates.sh build
|
|
12
|
+
- test: bash scripts/ace-gates.sh test
|
|
13
|
+
|
|
14
|
+
## Judge
|
|
15
|
+
|
|
16
|
+
- contract (0..2): Every operation, parameter, property, status, and problem code of the OpenAPI contract is implemented exactly; nothing is added. Score 2 only if the implementation matches the contract completely.
|
|
17
|
+
- mapping (0..2): Every value is built from the x-soap-source the contract names, amounts stay decimal strings, and every x-soap-enum-map entry, including values seen only in the samples, is handled. Score 2 only if no mapping is guessed.
|
|
18
|
+
- errors (0..2): Every esito code and SOAP fault listed in x-problem-codes becomes its problem response, and nothing unmapped leaks backend text. Score 2 only if every error path is implemented and tested.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Implement the story in the ACE 12 projects of this repository, following
|
|
2
|
+
CONTRIBUTING.md and the pattern cards in docs/patterns/. The contract in
|
|
3
|
+
contracts/ is fixed: implement it, never change it. Build every request and
|
|
4
|
+
response field from the x-soap-source the contract names and the WSDL in the
|
|
5
|
+
shared library; route every business error and fault through the error
|
|
6
|
+
subflow with the status and code x-problem-codes gives. Add ACE unit tests
|
|
7
|
+
against the recorded stubs for the happy path, every mapped error, and every
|
|
8
|
+
enum value.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Review the change against contracts/openapi-ace.yaml: an operation, field,
|
|
2
|
+
status, or problem code that differs from the contract is a finding; so is a
|
|
3
|
+
value built from anything but its x-soap-source, an enum value missing from
|
|
4
|
+
its CASE, an amount emitted as a number, a hard-coded endpoint or credential,
|
|
5
|
+
or a mapped error without a test.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
match: esito|codice|KO\d\d|problem
|
|
3
|
+
---
|
|
4
|
+
A SOAP response can fail inside HTTP 200: `esito.codice` other than the OK
|
|
5
|
+
code is a business error. Test it before mapping the payload; look the code up
|
|
6
|
+
in the operation's `x-problem-codes` and route to the error subflow with the
|
|
7
|
+
HTTP status, problem `code`, and `title` from the contract. Trap: mapping the
|
|
8
|
+
payload first and checking esito afterwards returns half-filled 200s.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
match: Fault|faultcode|ServizioFault|SOAPRequest
|
|
3
|
+
---
|
|
4
|
+
A SOAP fault arrives on the SOAPRequest node's Failure/Fault terminal, not
|
|
5
|
+
Out. Read `faultcode`, `faultstring`, and the detail element's local name,
|
|
6
|
+
map them through `x-problem-codes`, and answer with the problem status.
|
|
7
|
+
Anything unmapped is `502 backend-error`; never forward faultstring text
|
|
8
|
+
verbatim to the client (it can carry internal data).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
match: importo|saldo|valore|divisa|amount|enumMap|x-soap-enum-map
|
|
3
|
+
---
|
|
4
|
+
Amounts leave as `{ "amount": "<decimal string>", "currency": "EUR" }`:
|
|
5
|
+
`CAST(value AS CHARACTER)` keeps the digits the backend sent. Enumerations go
|
|
6
|
+
through a CASE built from `x-soap-enum-map`, one WHEN per SOAP value, and an
|
|
7
|
+
ELSE that raises the mapping error. Trap: values seen in the samples but
|
|
8
|
+
missing from the WSDL (the analysis lists them) must be in the CASE too.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
match: paginazione|numeroPagina|page|size|ultimaPagina
|
|
3
|
+
---
|
|
4
|
+
Page-number pagination maps query `page`/`size` to the SOAP paging fields,
|
|
5
|
+
defaulting as the contract's `x-derivation` says, and builds `page` from the
|
|
6
|
+
response: `hasNext` from the last-page flag (inverted), `totalItems` only when
|
|
7
|
+
the backend sent it. Clamp `size` to the contract maximum before calling.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
match: nil|nillable|nullable|optional
|
|
3
|
+
---
|
|
4
|
+
Absent SOAP elements are omitted from the JSON; `xsi:nil="true"` becomes
|
|
5
|
+
`null` only for properties the contract marks `nullable`, otherwise the
|
|
6
|
+
property is omitted. Never emit empty strings for absent values: an empty
|
|
7
|
+
string is data. Test both an absent and a nil element per nullable property.
|