@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.
- package/flows/epic-stories.js +25 -5
- package/flows/lib/epic-stories.js +76 -8
- 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,309 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
export class YamlError extends Schema.TaggedError()("YamlError", {
|
|
4
|
+
line: Schema.Int,
|
|
5
|
+
detail: Schema.String
|
|
6
|
+
}) {
|
|
7
|
+
get message() {
|
|
8
|
+
return `line ${this.line}: ${this.detail}`;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export const isYamlMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
12
|
+
export const isYamlList = (value) => Array.isArray(value);
|
|
13
|
+
/** Remove a trailing comment that is outside quotes. */
|
|
14
|
+
const stripComment = (text) => {
|
|
15
|
+
let quote;
|
|
16
|
+
for (let index = 0; index < text.length; index++) {
|
|
17
|
+
const char = text[index];
|
|
18
|
+
if (quote !== undefined) {
|
|
19
|
+
if (char === "\\" && quote === '"')
|
|
20
|
+
index++;
|
|
21
|
+
else if (char === quote)
|
|
22
|
+
quote = undefined;
|
|
23
|
+
}
|
|
24
|
+
else if (char === '"' || char === "'") {
|
|
25
|
+
// A quote opens a quoted scalar only where a value starts (after a key,
|
|
26
|
+
// a list dash, or a flow bracket), never inside a plain value (5 'ft).
|
|
27
|
+
const before = text.slice(0, index).trimEnd();
|
|
28
|
+
if (before === "" || /(^|\s)-$|[:[{,]$/.test(before))
|
|
29
|
+
quote = char;
|
|
30
|
+
}
|
|
31
|
+
else if (char === "#" && (index === 0 || /\s/.test(text[index - 1] ?? ""))) {
|
|
32
|
+
return text.slice(0, index).trimEnd();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return text.trimEnd();
|
|
36
|
+
};
|
|
37
|
+
class Reader {
|
|
38
|
+
index = 0;
|
|
39
|
+
lines;
|
|
40
|
+
raw;
|
|
41
|
+
constructor(source) {
|
|
42
|
+
this.raw = source.replace(/^\uFEFF/, "").split(/\r?\n/);
|
|
43
|
+
const lines = [];
|
|
44
|
+
for (const [position, rawLine] of this.raw.entries()) {
|
|
45
|
+
if (/^\s*\t/.test(rawLine) && rawLine.trim() !== "") {
|
|
46
|
+
throw new YamlError({ line: position + 1, detail: "tabs are not allowed for indentation" });
|
|
47
|
+
}
|
|
48
|
+
if (rawLine.trim() === "---" || rawLine.trim() === "...")
|
|
49
|
+
continue;
|
|
50
|
+
const text = stripComment(rawLine);
|
|
51
|
+
if (text.trim() === "")
|
|
52
|
+
continue;
|
|
53
|
+
lines.push({
|
|
54
|
+
number: position + 1,
|
|
55
|
+
indent: text.length - text.trimStart().length,
|
|
56
|
+
text: text.trim()
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
this.lines = lines;
|
|
60
|
+
}
|
|
61
|
+
fail(line, detail) {
|
|
62
|
+
throw new YamlError({ line, detail });
|
|
63
|
+
}
|
|
64
|
+
document() {
|
|
65
|
+
const first = this.lines[0];
|
|
66
|
+
if (first === undefined)
|
|
67
|
+
return null;
|
|
68
|
+
const value = this.block(first.indent);
|
|
69
|
+
const rest = this.lines[this.index];
|
|
70
|
+
if (rest !== undefined)
|
|
71
|
+
this.fail(rest.number, "unexpected content (check indentation)");
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
block(indent) {
|
|
75
|
+
const line = this.lines[this.index];
|
|
76
|
+
if (line === undefined)
|
|
77
|
+
return null;
|
|
78
|
+
return line.text === "-" || line.text.startsWith("- ")
|
|
79
|
+
? this.sequence(indent)
|
|
80
|
+
: this.mapping(indent);
|
|
81
|
+
}
|
|
82
|
+
sequence(indent) {
|
|
83
|
+
const items = [];
|
|
84
|
+
for (;;) {
|
|
85
|
+
const line = this.lines[this.index];
|
|
86
|
+
if (line === undefined || line.indent < indent)
|
|
87
|
+
return items;
|
|
88
|
+
if (line.indent > indent)
|
|
89
|
+
this.fail(line.number, "unexpected indentation");
|
|
90
|
+
if (!(line.text === "-" || line.text.startsWith("- ")))
|
|
91
|
+
return items;
|
|
92
|
+
const rest = line.text === "-" ? "" : line.text.slice(2).trim();
|
|
93
|
+
if (rest === "-" || rest.startsWith("- ")) {
|
|
94
|
+
// `- - a`: a list item that is itself a list, starting on this line.
|
|
95
|
+
const nestedIndent = indent + (line.text.length - rest.length);
|
|
96
|
+
this.lines[this.index] = { number: line.number, indent: nestedIndent, text: rest };
|
|
97
|
+
items.push(this.sequence(nestedIndent));
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
this.index++;
|
|
101
|
+
if (rest === "") {
|
|
102
|
+
const next = this.lines[this.index];
|
|
103
|
+
items.push(next !== undefined && next.indent > indent ? this.block(next.indent) : null);
|
|
104
|
+
}
|
|
105
|
+
else if (this.isMappingStart(rest)) {
|
|
106
|
+
// `- key: value` opens a mapping whose further keys sit two columns in.
|
|
107
|
+
const itemIndent = indent + (line.text.length - rest.length);
|
|
108
|
+
items.push(this.mapping(itemIndent, { text: rest, number: line.number }));
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
items.push(this.scalar(rest, line.number, indent));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
isMappingStart(text) {
|
|
116
|
+
return this.splitKey(text) !== undefined;
|
|
117
|
+
}
|
|
118
|
+
splitKey(text) {
|
|
119
|
+
if (text.startsWith('"') || text.startsWith("'")) {
|
|
120
|
+
const quote = text[0] ?? '"';
|
|
121
|
+
let end = 1;
|
|
122
|
+
while (end < text.length) {
|
|
123
|
+
if (text[end] === "\\" && quote === '"')
|
|
124
|
+
end += 2;
|
|
125
|
+
else if (text[end] === quote && quote === "'" && text[end + 1] === "'")
|
|
126
|
+
end += 2;
|
|
127
|
+
else if (text[end] === quote)
|
|
128
|
+
break;
|
|
129
|
+
else
|
|
130
|
+
end++;
|
|
131
|
+
}
|
|
132
|
+
const after = text.slice(end + 1);
|
|
133
|
+
if (!/^\s*:(\s|$)/.test(after))
|
|
134
|
+
return undefined;
|
|
135
|
+
return {
|
|
136
|
+
key: this.quoted(text.slice(0, end + 1), 0),
|
|
137
|
+
rest: after.replace(/^\s*:/, "").trim()
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const match = /^([^\s:#][^:#]*?|[^\s:#]):(\s+|$)(.*)$/.exec(text);
|
|
141
|
+
if (match === null)
|
|
142
|
+
return undefined;
|
|
143
|
+
return { key: (match[1] ?? "").trim(), rest: match[3] ?? "" };
|
|
144
|
+
}
|
|
145
|
+
mapping(indent, first) {
|
|
146
|
+
const entries = {};
|
|
147
|
+
const add = (text, number, lineIndent) => {
|
|
148
|
+
const split = this.splitKey(text);
|
|
149
|
+
if (split === undefined)
|
|
150
|
+
this.fail(number, `expected "key: value", found ${JSON.stringify(text)}`);
|
|
151
|
+
if (Object.hasOwn(entries, split.key))
|
|
152
|
+
this.fail(number, `duplicate key ${split.key}`);
|
|
153
|
+
if (split.rest === "") {
|
|
154
|
+
const next = this.lines[this.index];
|
|
155
|
+
entries[split.key] =
|
|
156
|
+
next !== undefined &&
|
|
157
|
+
(next.indent > lineIndent ||
|
|
158
|
+
(next.indent === lineIndent && (next.text === "-" || next.text.startsWith("- "))))
|
|
159
|
+
? this.block(next.indent)
|
|
160
|
+
: null;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
entries[split.key] = this.scalar(split.rest, number, lineIndent);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
if (first !== undefined)
|
|
167
|
+
add(first.text, first.number, indent);
|
|
168
|
+
for (;;) {
|
|
169
|
+
const line = this.lines[this.index];
|
|
170
|
+
if (line === undefined || line.indent < indent)
|
|
171
|
+
return entries;
|
|
172
|
+
if (line.indent > indent)
|
|
173
|
+
this.fail(line.number, "unexpected indentation");
|
|
174
|
+
if (line.text === "-" || line.text.startsWith("- "))
|
|
175
|
+
return entries;
|
|
176
|
+
this.index++;
|
|
177
|
+
add(line.text, line.number, indent);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
scalar(text, number, indent) {
|
|
181
|
+
if (text === "~" || text === "null" || text === "Null" || text === "NULL")
|
|
182
|
+
return null;
|
|
183
|
+
if (text === "[]")
|
|
184
|
+
return [];
|
|
185
|
+
if (text === "{}")
|
|
186
|
+
return {};
|
|
187
|
+
if (text.startsWith('"') || text.startsWith("'"))
|
|
188
|
+
return this.quoted(text, number);
|
|
189
|
+
if (text === "|" || text === ">" || /^[|>][-+]?$/.test(text))
|
|
190
|
+
return this.blockScalar(text, indent, number);
|
|
191
|
+
if (/^[[{&*!%@`]/.test(text)) {
|
|
192
|
+
this.fail(number, `unsupported YAML construct at ${JSON.stringify(text.slice(0, 12))}`);
|
|
193
|
+
}
|
|
194
|
+
return text;
|
|
195
|
+
}
|
|
196
|
+
quoted(text, number) {
|
|
197
|
+
const quote = text[0];
|
|
198
|
+
if (text.length < 2 || text[text.length - 1] !== quote) {
|
|
199
|
+
this.fail(number, "unterminated quoted scalar");
|
|
200
|
+
}
|
|
201
|
+
const body = text.slice(1, -1);
|
|
202
|
+
if (quote === "'")
|
|
203
|
+
return body.replace(/''/g, "'");
|
|
204
|
+
return body.replace(/\\(u[0-9A-Fa-f]{4}|.)/g, (_, escape) => {
|
|
205
|
+
if (escape.startsWith("u"))
|
|
206
|
+
return String.fromCharCode(Number.parseInt(escape.slice(1), 16));
|
|
207
|
+
const map = {
|
|
208
|
+
n: "\n",
|
|
209
|
+
t: "\t",
|
|
210
|
+
r: "\r",
|
|
211
|
+
b: "\b",
|
|
212
|
+
f: "\f",
|
|
213
|
+
'"': '"',
|
|
214
|
+
"\\": "\\",
|
|
215
|
+
"/": "/",
|
|
216
|
+
"0": "\0"
|
|
217
|
+
};
|
|
218
|
+
const value = map[escape];
|
|
219
|
+
if (value === undefined)
|
|
220
|
+
this.fail(number, `unknown escape \\${escape}`);
|
|
221
|
+
return value;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
blockScalar(header, indent, headerLine) {
|
|
225
|
+
const folded = header.startsWith(">");
|
|
226
|
+
const keep = header.endsWith("+");
|
|
227
|
+
const strip = header.endsWith("-");
|
|
228
|
+
// Block scalar content keeps its comments and blank lines, so it is read
|
|
229
|
+
// from the raw source rather than the comment-stripped lines.
|
|
230
|
+
// Content starts on the raw line after the header, even when that line
|
|
231
|
+
// looks like a comment (# heading); its indentation is the first
|
|
232
|
+
// non-blank line's.
|
|
233
|
+
let rawIndex = headerLine;
|
|
234
|
+
const firstContent = this.raw.slice(rawIndex).find((raw) => raw.trim() !== "");
|
|
235
|
+
const contentIndent = firstContent === undefined ? 0 : firstContent.length - firstContent.trimStart().length;
|
|
236
|
+
if (firstContent === undefined || contentIndent <= indent)
|
|
237
|
+
return "";
|
|
238
|
+
const collected = [];
|
|
239
|
+
while (rawIndex < this.raw.length) {
|
|
240
|
+
const raw = this.raw[rawIndex] ?? "";
|
|
241
|
+
const rawIndent = raw.length - raw.trimStart().length;
|
|
242
|
+
if (raw.trim() !== "" && rawIndent < contentIndent)
|
|
243
|
+
break;
|
|
244
|
+
collected.push(raw.slice(contentIndent));
|
|
245
|
+
rawIndex++;
|
|
246
|
+
}
|
|
247
|
+
while (this.lines[this.index] !== undefined &&
|
|
248
|
+
(this.lines[this.index]?.number ?? 0) <= rawIndex) {
|
|
249
|
+
this.index++;
|
|
250
|
+
}
|
|
251
|
+
while (collected.length > 0 && collected[collected.length - 1]?.trim() === "")
|
|
252
|
+
collected.pop();
|
|
253
|
+
const text = folded
|
|
254
|
+
? collected.join("\n").replace(/([^\n])\n(?=[^\n])/g, "$1 ")
|
|
255
|
+
: collected.join("\n");
|
|
256
|
+
return strip ? text : keep ? `${text}\n` : `${text}\n`;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
export const parseYaml = (source) => Effect.try({
|
|
260
|
+
try: () => new Reader(source).document(),
|
|
261
|
+
catch: (cause) => cause instanceof YamlError ? cause : new YamlError({ line: 0, detail: "unreadable YAML" })
|
|
262
|
+
});
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Writing
|
|
265
|
+
const plainUnsafe = /^[-?:,[\]{}#&*!|>'"%@`\s]|[\s]$|:$|: |\s#|^(~|null|Null|NULL)$|[\n\r\t]/;
|
|
266
|
+
/** A scalar as YAML: plain when it reads back unchanged, otherwise double-quoted. */
|
|
267
|
+
export const yamlScalar = (value) => {
|
|
268
|
+
if (value === null)
|
|
269
|
+
return "~";
|
|
270
|
+
if (value !== "" &&
|
|
271
|
+
!plainUnsafe.test(value) &&
|
|
272
|
+
![...value].some((char) => char.charCodeAt(0) < 32))
|
|
273
|
+
return value;
|
|
274
|
+
return JSON.stringify(value);
|
|
275
|
+
};
|
|
276
|
+
export const yamlKey = (key) => /^[A-Za-z_@][A-Za-z0-9_.\-@#]*$/.test(key) ? key : JSON.stringify(key);
|
|
277
|
+
/** Serialize a value (no comments); mappings keep insertion order. */
|
|
278
|
+
export const renderYaml = (value, indent = 0) => {
|
|
279
|
+
const pad = " ".repeat(indent);
|
|
280
|
+
if (value === null || typeof value === "string")
|
|
281
|
+
return `${pad}${yamlScalar(value)}\n`;
|
|
282
|
+
if (isYamlList(value)) {
|
|
283
|
+
if (value.length === 0)
|
|
284
|
+
return `${pad}[]\n`;
|
|
285
|
+
return value
|
|
286
|
+
.map((item) => {
|
|
287
|
+
if (item === null || typeof item === "string")
|
|
288
|
+
return `${pad}- ${yamlScalar(item)}\n`;
|
|
289
|
+
const nested = renderYaml(item, indent + 2);
|
|
290
|
+
return `${pad}- ${nested.slice(indent + 2)}`;
|
|
291
|
+
})
|
|
292
|
+
.join("");
|
|
293
|
+
}
|
|
294
|
+
const entries = Object.entries(value);
|
|
295
|
+
if (entries.length === 0)
|
|
296
|
+
return `${pad}{}\n`;
|
|
297
|
+
return entries
|
|
298
|
+
.map(([key, item]) => {
|
|
299
|
+
if (item === null || typeof item === "string") {
|
|
300
|
+
return `${pad}${yamlKey(key)}: ${yamlScalar(item)}\n`;
|
|
301
|
+
}
|
|
302
|
+
if ((isYamlList(item) && item.length === 0) ||
|
|
303
|
+
(isYamlMap(item) && Object.keys(item).length === 0)) {
|
|
304
|
+
return `${pad}${yamlKey(key)}: ${isYamlList(item) ? "[]" : "{}"}\n`;
|
|
305
|
+
}
|
|
306
|
+
return `${pad}${yamlKey(key)}:\n${renderYaml(item, isYamlList(item) ? indent : indent + 2)}`;
|
|
307
|
+
})
|
|
308
|
+
.join("");
|
|
309
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Turn SOAP evidence into a REST design: analysis of masked samples, the 1:1 mapping, an evidence-citing design proposal, its check, and the projected OpenAPI 3.1 contract.
|
|
2
|
+
//
|
|
3
|
+
// Runs after soap-sample, rooted at the same repository. The task text is a
|
|
4
|
+
// command:
|
|
5
|
+
//
|
|
6
|
+
// llm4ts run soap-design --repo . "analyse [operation]" analysis/<op>.md + .json from the exchanges
|
|
7
|
+
// llm4ts run soap-design --repo . mapping design/mapping.json + .md, the 1:1 base
|
|
8
|
+
// llm4ts run soap-design --repo . design the reasoning seat drafts design/api-design.md
|
|
9
|
+
// llm4ts run soap-design --repo . revise redraft against the check's findings
|
|
10
|
+
// llm4ts run soap-design --repo . check the deterministic design review
|
|
11
|
+
// llm4ts run soap-design --repo . openapi design/openapi.yaml from an approved design
|
|
12
|
+
//
|
|
13
|
+
// The design is a typed overlay on the mapping: endpoints name their SOAP
|
|
14
|
+
// operations, models bind to XSD types, properties cite XSD paths, errors
|
|
15
|
+
// cite outcome codes and faults (see the kit README). `check` verifies
|
|
16
|
+
// coverage of every operation, verbs against the confirmed classes in
|
|
17
|
+
// operations.md, the style guide (packs/ace12-rest/api-style.md), every source path, enum maps
|
|
18
|
+
// against declared and observed values, and that every business error the
|
|
19
|
+
// samples showed is mapped. api-design.md is the approval file: editing its
|
|
20
|
+
// JSON block is the review, `Status: approved` the sign-off; an existing
|
|
21
|
+
// file is never overwritten by `design`. openapi refuses a design that is
|
|
22
|
+
// not approved or has check errors. LLM4TS_REASONER picks the seat that
|
|
23
|
+
// drafts (default claude); LLM4TS_SOAP_SERVICE picks the service.
|
|
24
|
+
import { readFile } from "node:fs/promises";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import * as Effect from "effect/Effect";
|
|
27
|
+
import { FlowAborted, Info, makeNodeWorkspace, mock, resolveFlowInput, runFlowMain, runNode, ScriptUsage } from "@llm4ts/runner";
|
|
28
|
+
import { coderFor } from "@llm4ts/runner/Connectors";
|
|
29
|
+
import { analysisPaths, writeAnalyses } from "./lib/soap/Analysis.js";
|
|
30
|
+
import { operationByName } from "./lib/soap/Catalog.js";
|
|
31
|
+
import { blockingIssues, checkDesign, designPaths, parseDesignFile, renderDesignFile } from "./lib/soap/Design.js";
|
|
32
|
+
import { proposeDesign, reviseDesign } from "./lib/soap/DesignProposal.js";
|
|
33
|
+
import { loadCatalog, loadOperationsFile, readIfPresent, serviceDirectory } from "./lib/soap/Discover.js";
|
|
34
|
+
import { encodeMapping, mapService, renderMapping } from "./lib/soap/Mapping.js";
|
|
35
|
+
import { projectOpenApi, renderTypedYaml } from "./lib/soap/OpenApi.js";
|
|
36
|
+
const usage = 'usage: soap-design "analyse [operation] | mapping | design | revise | check | openapi"';
|
|
37
|
+
const styleGuide = () => Effect.tryPromise({
|
|
38
|
+
try: () => readFile(fileURLToPath(new URL("../packs/ace12-rest/api-style.md", import.meta.url)), "utf8"),
|
|
39
|
+
catch: () => FlowAborted.make({ message: "cannot read the kit's packs/ace12-rest/api-style.md" })
|
|
40
|
+
});
|
|
41
|
+
const program = Effect.gen(function* () {
|
|
42
|
+
const input = yield* resolveFlowInput("analyse");
|
|
43
|
+
const [command = "analyse", ...args] = input.prompt.trim().split(/\s+/);
|
|
44
|
+
const environment = process.env;
|
|
45
|
+
const drafting = command === "design" || command === "revise";
|
|
46
|
+
const reasoner = drafting
|
|
47
|
+
? coderFor((environment.LLM4TS_REASONER ?? "claude").trim() || "claude")
|
|
48
|
+
: undefined;
|
|
49
|
+
if (drafting && reasoner === undefined) {
|
|
50
|
+
return yield* ScriptUsage.make({
|
|
51
|
+
message: `unknown LLM4TS_REASONER '${environment.LLM4TS_REASONER ?? ""}'`
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
yield* runNode({
|
|
55
|
+
workDir: input.workDir,
|
|
56
|
+
workspace: input.workspace,
|
|
57
|
+
userPrompt: input.prompt,
|
|
58
|
+
coder: mock,
|
|
59
|
+
...(reasoner === undefined ? {} : { reasoning: reasoner }),
|
|
60
|
+
environment
|
|
61
|
+
}, (context) => Effect.gen(function* () {
|
|
62
|
+
const say = (message) => context.events.publish(Info.make({ message }));
|
|
63
|
+
const workspace = yield* makeNodeWorkspace(input.workDir);
|
|
64
|
+
const requested = environment.LLM4TS_SOAP_SERVICE?.trim();
|
|
65
|
+
const services = (yield* workspace.discover(".llm4ts/soap/*/catalog.json")).map((path) => path.split("/")[2] ?? "");
|
|
66
|
+
const service = requested !== undefined && requested !== ""
|
|
67
|
+
? requested
|
|
68
|
+
: services.length === 1
|
|
69
|
+
? services[0]
|
|
70
|
+
: undefined;
|
|
71
|
+
if (service === undefined) {
|
|
72
|
+
return yield* FlowAborted.make({
|
|
73
|
+
message: services.length === 0
|
|
74
|
+
? "no discovered service here; run soap-discover first"
|
|
75
|
+
: `several services (${services.join(", ")}); set LLM4TS_SOAP_SERVICE`
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const catalog = yield* loadCatalog(workspace, service);
|
|
79
|
+
const operations = yield* loadOperationsFile(workspace, service);
|
|
80
|
+
const paths = designPaths(serviceDirectory(service));
|
|
81
|
+
const mapping = mapService(catalog);
|
|
82
|
+
const allAnalyses = () => writeAnalyses(workspace, catalog, service);
|
|
83
|
+
const report = (issues) => Effect.forEach(issues, (issue) => say(` ${issue.severity} ${issue.where}: ${issue.detail}`), { discard: true });
|
|
84
|
+
switch (command) {
|
|
85
|
+
case "analyse":
|
|
86
|
+
case "analyze": {
|
|
87
|
+
const names = args.length === 0 ? catalog.operations.map((operation) => operation.name) : args;
|
|
88
|
+
for (const name of names) {
|
|
89
|
+
if (operationByName(catalog, name) === undefined)
|
|
90
|
+
return yield* FlowAborted.make({ message: `unknown operation ${name}` });
|
|
91
|
+
}
|
|
92
|
+
for (const analysis of yield* writeAnalyses(workspace, catalog, service, names)) {
|
|
93
|
+
yield* say(`${analysis.operation}: ${analysis.exchanges} exchanges, ${analysis.outcomes.length} outcome codes, ${analysis.faults.length} faults, ${analysis.findings.length} schema findings, ${analysis.observations.length} observations → ${analysisPaths(service, analysis.operation).markdown}`);
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
case "mapping": {
|
|
98
|
+
yield* workspace.write(paths.mapping, encodeMapping(mapping));
|
|
99
|
+
yield* workspace.write(paths.mappingSummary, renderMapping(mapping));
|
|
100
|
+
yield* say(`wrote ${paths.mapping} and ${paths.mappingSummary}`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
case "design":
|
|
104
|
+
case "revise": {
|
|
105
|
+
const existing = yield* readIfPresent(workspace, paths.design);
|
|
106
|
+
if (command === "design" && existing !== undefined) {
|
|
107
|
+
return yield* FlowAborted.make({
|
|
108
|
+
message: `${paths.design} exists; edit it, run "check", or "revise" to redraft it`
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (command === "revise" && existing === undefined) {
|
|
112
|
+
return yield* FlowAborted.make({
|
|
113
|
+
message: `no ${paths.design} yet; run "design" first`
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const analyses = yield* allAnalyses();
|
|
117
|
+
const style = yield* styleGuide();
|
|
118
|
+
yield* workspace.write(paths.mapping, encodeMapping(mapping));
|
|
119
|
+
yield* workspace.write(paths.mappingSummary, renderMapping(mapping));
|
|
120
|
+
const base = { catalog, operations, mapping, analyses, style };
|
|
121
|
+
const draft = existing === undefined
|
|
122
|
+
? yield* proposeDesign(context.reasoning, base)
|
|
123
|
+
: yield* Effect.flatMap(parseDesignFile(existing), (file) => reviseDesign(context.reasoning, {
|
|
124
|
+
...base,
|
|
125
|
+
current: file.design,
|
|
126
|
+
issues: checkDesign(file.design, { catalog, operations, analyses })
|
|
127
|
+
}));
|
|
128
|
+
const issues = checkDesign(draft, { catalog, operations, analyses });
|
|
129
|
+
yield* workspace.write(paths.design, renderDesignFile(draft, issues, "proposed"));
|
|
130
|
+
yield* say(`wrote ${paths.design}: ${draft.endpoints.length} endpoints, ${draft.models.length} models, ${blockingIssues(issues).length} errors, ${issues.length - blockingIssues(issues).length} warnings`);
|
|
131
|
+
yield* report(issues);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
case "check":
|
|
135
|
+
case "openapi": {
|
|
136
|
+
const text = yield* readIfPresent(workspace, paths.design);
|
|
137
|
+
if (text === undefined)
|
|
138
|
+
return yield* FlowAborted.make({
|
|
139
|
+
message: `no ${paths.design} yet; run "design" first`
|
|
140
|
+
});
|
|
141
|
+
const file = yield* parseDesignFile(text);
|
|
142
|
+
const analyses = yield* allAnalyses();
|
|
143
|
+
const issues = checkDesign(file.design, { catalog, operations, analyses });
|
|
144
|
+
const errors = blockingIssues(issues);
|
|
145
|
+
yield* workspace.write(paths.design, renderDesignFile(file.design, issues, file.approved ? "approved" : "proposed"));
|
|
146
|
+
yield* say(`${paths.design}: ${file.approved ? "approved" : "proposed"}, ${errors.length} errors, ${issues.length - errors.length} warnings`);
|
|
147
|
+
yield* report(issues);
|
|
148
|
+
if (command === "check")
|
|
149
|
+
return;
|
|
150
|
+
if (!file.approved)
|
|
151
|
+
return yield* FlowAborted.make({
|
|
152
|
+
message: "the design is not approved; set Status: approved after review"
|
|
153
|
+
});
|
|
154
|
+
if (errors.length > 0)
|
|
155
|
+
return yield* FlowAborted.make({
|
|
156
|
+
message: `the design has ${errors.length} check errors; fix them first`
|
|
157
|
+
});
|
|
158
|
+
yield* workspace.write(paths.openapi, renderTypedYaml(projectOpenApi(file.design)));
|
|
159
|
+
yield* say(`wrote ${paths.openapi} (projected; do not edit)`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
default:
|
|
163
|
+
return yield* FlowAborted.make({ message: `unknown command ${command}\n${usage}` });
|
|
164
|
+
}
|
|
165
|
+
}));
|
|
166
|
+
});
|
|
167
|
+
runFlowMain(program);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Discover a SOAP service: typed catalog of operations and types from its WSDL, open questions, and proposed read/mutating classes; no LLM unless a judgment seat is set.
|
|
2
|
+
//
|
|
3
|
+
// The task text is the WSDL location, a path (relative to the launch
|
|
4
|
+
// directory) or an http(s) URL:
|
|
5
|
+
//
|
|
6
|
+
// llm4ts run soap-discover --repo ~/work/conti-api ./wsdl/DemoBank.wsdl
|
|
7
|
+
//
|
|
8
|
+
// Writes under <repo>/.llm4ts/soap/<service>/:
|
|
9
|
+
//
|
|
10
|
+
// catalog.json the typed catalog every later soap-* step reads
|
|
11
|
+
// catalog.md a human summary: endpoints, operations, fields, open questions
|
|
12
|
+
// operations.md proposed read/mutating classes; review, then set
|
|
13
|
+
// `Status: confirmed`. An existing file is kept as is.
|
|
14
|
+
//
|
|
15
|
+
// The catalog is read by code (document/literal SOAP 1.1/1.2 bindings, WSDL
|
|
16
|
+
// 1.1 and 2.0); anything it does not model is an open question, never a
|
|
17
|
+
// guess. Classes are proposed by a name heuristic and, when
|
|
18
|
+
// LLM4TS_JUDGMENT_PROVIDER names a judgment seat, by a typed judgment.
|
|
19
|
+
// LLM4TS_SOAP_SERVICE overrides the service directory name. For a URL, the
|
|
20
|
+
// fetch side of <repo>/.llm4ts/soap/<LLM4TS_SOAP_SERVICE>/auth.json (Basic,
|
|
21
|
+
// Bearer, mTLS; references only) authenticates every document request.
|
|
22
|
+
import { isAbsolute, resolve } from "node:path";
|
|
23
|
+
import * as Effect from "effect/Effect";
|
|
24
|
+
import { Info, makeNodeWorkspace, mock, resolveFlowInput, runFlowMain, runNode } from "@llm4ts/runner";
|
|
25
|
+
import { judgmentConnectorFromEnvironment } from "@llm4ts/runner/Connectors";
|
|
26
|
+
import { nodeSecretSource, resolveSide } from "./lib/soap/Auth.js";
|
|
27
|
+
import { discoverService, loadAuthProfile } from "./lib/soap/Discover.js";
|
|
28
|
+
import { makeNodeSoapTransport, makeTransportDocumentLoader } from "./lib/soap/Transport.js";
|
|
29
|
+
import { DocumentLoader } from "./lib/soap/Wsdl.js";
|
|
30
|
+
const isUrl = (location) => /^https?:\/\//i.test(location);
|
|
31
|
+
const program = Effect.gen(function* () {
|
|
32
|
+
const input = yield* resolveFlowInput();
|
|
33
|
+
const raw = input.prompt.trim();
|
|
34
|
+
const location = isUrl(raw) || isAbsolute(raw) ? raw : resolve(input.workspace, raw);
|
|
35
|
+
const judgment = yield* judgmentConnectorFromEnvironment();
|
|
36
|
+
const service = process.env.LLM4TS_SOAP_SERVICE?.trim();
|
|
37
|
+
yield* runNode({
|
|
38
|
+
workDir: input.workDir,
|
|
39
|
+
workspace: input.workspace,
|
|
40
|
+
userPrompt: input.prompt,
|
|
41
|
+
// Discovery is deterministic; the mock seat only satisfies the context shape.
|
|
42
|
+
coder: mock,
|
|
43
|
+
...(judgment === undefined ? {} : { judgment }),
|
|
44
|
+
environment: process.env
|
|
45
|
+
}, (context) => Effect.gen(function* () {
|
|
46
|
+
const say = (message) => context.events.publish(Info.make({ message }));
|
|
47
|
+
const workspace = yield* makeNodeWorkspace(input.workDir);
|
|
48
|
+
const profile = service === undefined || service === ""
|
|
49
|
+
? undefined
|
|
50
|
+
: yield* loadAuthProfile(workspace, service);
|
|
51
|
+
if (isUrl(location) && profile === undefined) {
|
|
52
|
+
yield* say("fetching without credentials (set LLM4TS_SOAP_SERVICE and write its auth.json for an authenticated WSDL)");
|
|
53
|
+
}
|
|
54
|
+
const fetchSide = yield* resolveSide(nodeSecretSource(), profile?.fetch);
|
|
55
|
+
const loader = makeTransportDocumentLoader(makeNodeSoapTransport(), fetchSide);
|
|
56
|
+
const result = yield* discoverService({
|
|
57
|
+
workspace,
|
|
58
|
+
location,
|
|
59
|
+
...(service === undefined || service === "" ? {} : { service }),
|
|
60
|
+
...(judgment === undefined || context.judgment === undefined
|
|
61
|
+
? {}
|
|
62
|
+
: { judgment: context.judgment })
|
|
63
|
+
}).pipe(Effect.provideService(DocumentLoader, loader));
|
|
64
|
+
const { catalog } = result;
|
|
65
|
+
yield* say(`${result.service}: ${catalog.operations.length} operations, ${catalog.endpoints.length} endpoints, ${catalog.documents.length} documents, ${catalog.openQuestions.length} open questions`);
|
|
66
|
+
for (const question of catalog.openQuestions) {
|
|
67
|
+
yield* say(` open question [${question.code}] ${question.subject}: ${question.detail}`);
|
|
68
|
+
}
|
|
69
|
+
if (result.proposals !== undefined) {
|
|
70
|
+
const count = (kind) => result.proposals?.filter((proposal) => proposal.proposed === kind).length ?? 0;
|
|
71
|
+
yield* say(`proposed classes: ${count("read")} read, ${count("mutating")} mutating, ${count("unclassified")} unclassified — review ${result.paths.operations} and set Status: confirmed`);
|
|
72
|
+
}
|
|
73
|
+
else if (result.existing !== undefined) {
|
|
74
|
+
yield* say(`kept ${result.paths.operations} (${result.existing.confirmed ? "confirmed" : "still proposed"})`);
|
|
75
|
+
if (result.drift.missing.length > 0) {
|
|
76
|
+
yield* say(` not classified there (treated as unclassified): ${result.drift.missing.join(", ")}`);
|
|
77
|
+
}
|
|
78
|
+
if (result.drift.unknown.length > 0) {
|
|
79
|
+
yield* say(` no longer in the WSDL: ${result.drift.unknown.join(", ")}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
yield* say(`wrote ${result.paths.catalog} and ${result.paths.summary}`);
|
|
83
|
+
}));
|
|
84
|
+
});
|
|
85
|
+
runFlowMain(program);
|