@llm4ts/shell 2.12.0 → 2.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/flows/epic-stories.js +9 -3
  2. package/flows/lib/epic-stories.js +24 -4
  3. package/kits/soap-ace/README.md +313 -0
  4. package/kits/soap-ace/fixtures/demo-bank-soap/RUNBOOK.md +102 -0
  5. package/kits/soap-ace/fixtures/demo-bank-soap/design/api-design.md +620 -0
  6. package/kits/soap-ace/fixtures/demo-bank-soap/responses/cercaConti.xml +3 -0
  7. package/kits/soap-ace/fixtures/demo-bank-soap/responses/cercaMovimenti.xml +3 -0
  8. package/kits/soap-ace/fixtures/demo-bank-soap/responses/confermaBonifico.xml +3 -0
  9. package/kits/soap-ace/fixtures/demo-bank-soap/responses/dettaglioConto.xml +3 -0
  10. package/kits/soap-ace/fixtures/demo-bank-soap/responses/inserisciBonifico.xml +3 -0
  11. package/kits/soap-ace/fixtures/demo-bank-soap/responses/revocaBonifico.xml +3 -0
  12. package/kits/soap-ace/fixtures/demo-bank-soap/soapui/DemoBank-soapui-project.xml +39 -0
  13. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/DemoBank.wsdl +186 -0
  14. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/LegacyRpc.wsdl +29 -0
  15. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/banking.xsd +10 -0
  16. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/bonifici.xsd +107 -0
  17. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/common.xsd +93 -0
  18. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/conti.xsd +71 -0
  19. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/movimenti.xsd +55 -0
  20. package/kits/soap-ace/flows/lib/soap/Analysis.js +508 -0
  21. package/kits/soap-ace/flows/lib/soap/Auth.js +179 -0
  22. package/kits/soap-ace/flows/lib/soap/Call.js +186 -0
  23. package/kits/soap-ace/flows/lib/soap/CallPolicy.js +50 -0
  24. package/kits/soap-ace/flows/lib/soap/Catalog.js +283 -0
  25. package/kits/soap-ace/flows/lib/soap/Classification.js +254 -0
  26. package/kits/soap-ace/flows/lib/soap/Design.js +497 -0
  27. package/kits/soap-ace/flows/lib/soap/DesignProposal.js +204 -0
  28. package/kits/soap-ace/flows/lib/soap/Discover.js +139 -0
  29. package/kits/soap-ace/flows/lib/soap/Envelope.js +132 -0
  30. package/kits/soap-ace/flows/lib/soap/Epic.js +239 -0
  31. package/kits/soap-ace/flows/lib/soap/Instance.js +1339 -0
  32. package/kits/soap-ace/flows/lib/soap/Mapping.js +130 -0
  33. package/kits/soap-ace/flows/lib/soap/Masking.js +508 -0
  34. package/kits/soap-ace/flows/lib/soap/OpenApi.js +288 -0
  35. package/kits/soap-ace/flows/lib/soap/Samples.js +287 -0
  36. package/kits/soap-ace/flows/lib/soap/Scenarios.js +132 -0
  37. package/kits/soap-ace/flows/lib/soap/SoapUiImport.js +200 -0
  38. package/kits/soap-ace/flows/lib/soap/Stub.js +34 -0
  39. package/kits/soap-ace/flows/lib/soap/Transport.js +167 -0
  40. package/kits/soap-ace/flows/lib/soap/Wsdl.js +533 -0
  41. package/kits/soap-ace/flows/lib/soap/Xml.js +455 -0
  42. package/kits/soap-ace/flows/lib/soap/Xsd.js +720 -0
  43. package/kits/soap-ace/flows/lib/soap/Yaml.js +309 -0
  44. package/kits/soap-ace/flows/soap-design.js +167 -0
  45. package/kits/soap-ace/flows/soap-discover.js +85 -0
  46. package/kits/soap-ace/flows/soap-epic.js +204 -0
  47. package/kits/soap-ace/flows/soap-sample.js +265 -0
  48. package/kits/soap-ace/packs/ace12-rest/api-style.md +63 -0
  49. package/kits/soap-ace/packs/ace12-rest/pack.md +18 -0
  50. package/kits/soap-ace/packs/ace12-rest/prompts/implement.md +8 -0
  51. package/kits/soap-ace/packs/ace12-rest/reviewers/contract.md +5 -0
  52. package/kits/soap-ace/patterns/PAT-ACE-001-esito-to-problem.md +8 -0
  53. package/kits/soap-ace/patterns/PAT-ACE-002-fault-to-problem.md +8 -0
  54. package/kits/soap-ace/patterns/PAT-ACE-003-amounts-and-enums.md +8 -0
  55. package/kits/soap-ace/patterns/PAT-ACE-004-pagination.md +7 -0
  56. package/kits/soap-ace/patterns/PAT-ACE-005-nil-and-absence.md +7 -0
  57. package/kits/soap-ace/scaffolds/ace12-rest-api/CONTRIBUTING.md +56 -0
  58. package/kits/soap-ace/scaffolds/ace12-rest-api/README.md +16 -0
  59. package/kits/soap-ace/scaffolds/ace12-rest-api/scripts/ace-gates-container.sh +8 -0
  60. package/kits/soap-ace/scaffolds/ace12-rest-api/scripts/ace-gates.sh +41 -0
  61. package/package.json +4 -4
@@ -0,0 +1,283 @@
1
+ import * as Schema from "effect/Schema";
2
+ // The typed catalog of one SOAP service: what `soap-discover` persists as
3
+ // `catalog.json` and every later step (sample authoring, masking by field,
4
+ // analysis, the 1:1 mapping, the REST design) reads. It is produced by the
5
+ // deterministic WSDL/XSD reader, never by a model. Anything the reader does
6
+ // not model is recorded as an open question instead of being guessed.
7
+ export const CatalogVersion = 1;
8
+ export const XsdNamespace = "http://www.w3.org/2001/XMLSchema";
9
+ /** A qualified name in Clark notation, `{namespace}local`. */
10
+ export const QName = Schema.String;
11
+ export const qname = (namespace, local) => `{${namespace}}${local}`;
12
+ export const splitClark = (name) => {
13
+ const close = name.indexOf("}");
14
+ return name.startsWith("{") && close > 0
15
+ ? { namespace: name.slice(1, close), local: name.slice(close + 1) }
16
+ : { namespace: "", local: name };
17
+ };
18
+ export const localName = (name) => splitClark(name).local;
19
+ export const isBuiltin = (name) => splitClark(name).namespace === XsdNamespace;
20
+ export const Occurs = Schema.Union([Schema.Int, Schema.Literal("unbounded")]);
21
+ export class Facets extends Schema.Class("Facets")({
22
+ enumeration: Schema.optionalKey(Schema.Array(Schema.String)),
23
+ pattern: Schema.optionalKey(Schema.Array(Schema.String)),
24
+ length: Schema.optionalKey(Schema.Int),
25
+ minLength: Schema.optionalKey(Schema.Int),
26
+ maxLength: Schema.optionalKey(Schema.Int),
27
+ totalDigits: Schema.optionalKey(Schema.Int),
28
+ fractionDigits: Schema.optionalKey(Schema.Int),
29
+ minInclusive: Schema.optionalKey(Schema.String),
30
+ maxInclusive: Schema.optionalKey(Schema.String),
31
+ minExclusive: Schema.optionalKey(Schema.String),
32
+ maxExclusive: Schema.optionalKey(Schema.String),
33
+ whiteSpace: Schema.optionalKey(Schema.Literals(["preserve", "replace", "collapse"]))
34
+ }) {
35
+ }
36
+ /** A model group (`xs:sequence`, `xs:choice`, `xs:all`) of a complex type's content. */
37
+ export class ModelGroup extends Schema.Class("ModelGroup")({
38
+ /** Index of the group within its type's `groups`. */
39
+ id: Schema.Int,
40
+ kind: Schema.Literals(["sequence", "choice", "all"]),
41
+ minOccurs: Schema.Int,
42
+ maxOccurs: Occurs,
43
+ /** The enclosing group; absent for a group at the top of the content. */
44
+ parent: Schema.optionalKey(Schema.Int),
45
+ /** Index into the type's `fields` of the first field at or after the group's start. */
46
+ start: Schema.Int
47
+ }) {
48
+ }
49
+ /** An element particle's own occurrence bounds, before its groups' bounds fold in. */
50
+ export const ParticleOccurs = Schema.Struct({ min: Schema.Int, max: Occurs });
51
+ /** A child element of a complex type's content model. */
52
+ export class ElementField extends Schema.Class("ElementField")({
53
+ name: Schema.String,
54
+ /** Namespace of the element as it appears on the wire (`""` when unqualified). */
55
+ namespace: Schema.String,
56
+ type: QName,
57
+ /**
58
+ * Effective bounds within one instance of the type: an optional group
59
+ * makes its elements optional, a repeating group makes them repeat.
60
+ */
61
+ minOccurs: Schema.Int,
62
+ maxOccurs: Occurs,
63
+ nillable: Schema.Boolean,
64
+ /** Set when the field sits inside an `xs:choice`: the innermost choice's index within the type. */
65
+ choice: Schema.optionalKey(Schema.Int),
66
+ /** Within a choice: the alternative this field belongs to (a sequence is one alternative). */
67
+ branch: Schema.optionalKey(Schema.Int),
68
+ /** The innermost model group (index into the type's `groups`); the exact content model. */
69
+ group: Schema.optionalKey(Schema.Int),
70
+ /** The element particle's own bounds within `group`. */
71
+ occurs: Schema.optionalKey(ParticleOccurs),
72
+ /** `fixed=`: the only value the element may hold. */
73
+ fixed: Schema.optionalKey(Schema.String),
74
+ documentation: Schema.optionalKey(Schema.String)
75
+ }) {
76
+ }
77
+ export class AttributeField extends Schema.Class("AttributeField")({
78
+ name: Schema.String,
79
+ /** Namespace of the attribute on the wire; absent when unqualified. */
80
+ namespace: Schema.optionalKey(Schema.String),
81
+ type: QName,
82
+ required: Schema.Boolean,
83
+ /** `fixed=`: the only value the attribute may hold. */
84
+ fixed: Schema.optionalKey(Schema.String),
85
+ documentation: Schema.optionalKey(Schema.String)
86
+ }) {
87
+ }
88
+ export class ComplexTypeDef extends Schema.Class("ComplexTypeDef")({
89
+ _tag: Schema.tag("complex"),
90
+ name: QName,
91
+ /** True for a type declared inline in an element; its name is synthesized. */
92
+ anonymous: Schema.Boolean,
93
+ /** `xs:extension` base, already flattened into `fields`/`attributes`. */
94
+ base: Schema.optionalKey(QName),
95
+ fields: Schema.Array(ElementField),
96
+ /** The model groups the fields sit in (see `ElementField.group`). */
97
+ groups: Schema.optionalKey(Schema.Array(ModelGroup)),
98
+ attributes: Schema.Array(AttributeField),
99
+ /** Simple content (`xs:simpleContent`): the text value's type. */
100
+ textType: Schema.optionalKey(QName),
101
+ documentation: Schema.optionalKey(Schema.String)
102
+ }) {
103
+ }
104
+ export class SimpleTypeDef extends Schema.Class("SimpleTypeDef")({
105
+ _tag: Schema.tag("simple"),
106
+ name: QName,
107
+ anonymous: Schema.Boolean,
108
+ /** The restriction base (an `xs:` builtin or another simple type). */
109
+ base: QName,
110
+ facets: Facets,
111
+ /** `xs:list` and `xs:union` types; absent for a restriction. */
112
+ variety: Schema.optionalKey(Schema.Literals(["list", "union"])),
113
+ /** A list's item type. */
114
+ itemType: Schema.optionalKey(QName),
115
+ documentation: Schema.optionalKey(Schema.String)
116
+ }) {
117
+ }
118
+ export const TypeDef = Schema.Union([ComplexTypeDef, SimpleTypeDef]);
119
+ export class ElementDecl extends Schema.Class("ElementDecl")({
120
+ name: QName,
121
+ type: QName,
122
+ nillable: Schema.Boolean,
123
+ fixed: Schema.optionalKey(Schema.String),
124
+ documentation: Schema.optionalKey(Schema.String)
125
+ }) {
126
+ }
127
+ export const SoapVersion = Schema.Literals(["1.1", "1.2"]);
128
+ export class FaultRef extends Schema.Class("FaultRef")({
129
+ name: Schema.String,
130
+ element: QName
131
+ }) {
132
+ }
133
+ export class Operation extends Schema.Class("Operation")({
134
+ name: Schema.String,
135
+ soapAction: Schema.String,
136
+ soapVersion: SoapVersion,
137
+ binding: Schema.String,
138
+ /** Document/literal wrapped: the request element carries the operation name. */
139
+ wrapped: Schema.Boolean,
140
+ input: QName,
141
+ output: Schema.optionalKey(QName),
142
+ faults: Schema.Array(FaultRef),
143
+ documentation: Schema.optionalKey(Schema.String)
144
+ }) {
145
+ }
146
+ export class Endpoint extends Schema.Class("Endpoint")({
147
+ service: Schema.String,
148
+ port: Schema.String,
149
+ binding: Schema.String,
150
+ soapVersion: SoapVersion,
151
+ address: Schema.String
152
+ }) {
153
+ }
154
+ export const OpenQuestionCode = Schema.Literals([
155
+ "xsd-any",
156
+ "xsd-any-attribute",
157
+ "substitution-group",
158
+ "abstract",
159
+ "group-ref",
160
+ "attribute-group",
161
+ "complex-restriction",
162
+ "mixed-content",
163
+ "list-or-union",
164
+ "unresolved-type",
165
+ "unresolved-element",
166
+ "unresolved-attribute",
167
+ "redefine",
168
+ "non-literal-part",
169
+ "unsupported-binding",
170
+ "multi-part-message",
171
+ "missing-address"
172
+ ]);
173
+ export class OpenQuestion extends Schema.Class("OpenQuestion")({
174
+ code: OpenQuestionCode,
175
+ /** Where: `<document>:<line>`, plus the owning type or operation when known. */
176
+ location: Schema.String,
177
+ subject: Schema.String,
178
+ detail: Schema.String
179
+ }) {
180
+ }
181
+ export class WsdlCatalog extends Schema.Class("WsdlCatalog")({
182
+ version: Schema.Literal(CatalogVersion),
183
+ wsdlVersion: Schema.Literals(["1.1", "2.0"]),
184
+ targetNamespace: Schema.String,
185
+ /** Every document read, root first, as given or resolved. */
186
+ documents: Schema.Array(Schema.String),
187
+ endpoints: Schema.Array(Endpoint),
188
+ operations: Schema.Array(Operation),
189
+ elements: Schema.Array(ElementDecl),
190
+ types: Schema.Array(TypeDef),
191
+ openQuestions: Schema.Array(OpenQuestion)
192
+ }) {
193
+ }
194
+ export const typeByName = (catalog, name) => catalog.types.find((type) => type.name === name);
195
+ export const elementByName = (catalog, name) => catalog.elements.find((element) => element.name === name);
196
+ export const operationByName = (catalog, name) => catalog.operations.find((operation) => operation.name === name);
197
+ /**
198
+ * The content model of a complex type as a particle tree, rooted in a
199
+ * sequence that occurs once (an extension's base content, then its own).
200
+ * A catalog written before model groups were recorded is rebuilt from the
201
+ * fields' choice and branch ids.
202
+ */
203
+ export const contentModel = (type) => {
204
+ const groups = type.groups ?? [];
205
+ const exact = type.fields.every((field) => field.group !== undefined);
206
+ const legacyElement = (field) => ({
207
+ kind: "element",
208
+ field,
209
+ min: field.minOccurs,
210
+ max: field.maxOccurs
211
+ });
212
+ if (!exact || groups.length === 0) {
213
+ // Top-level entries: a field, or the id of a choice at its first field.
214
+ const top = [];
215
+ const choices = new Map();
216
+ for (const field of type.fields) {
217
+ if (field.choice === undefined) {
218
+ top.push(field);
219
+ continue;
220
+ }
221
+ const branches = choices.get(field.choice) ?? new Map();
222
+ if (!choices.has(field.choice))
223
+ top.push(field.choice);
224
+ choices.set(field.choice, branches);
225
+ branches.set(field.branch, [...(branches.get(field.branch) ?? []), field]);
226
+ }
227
+ const children = top.map((entry) => {
228
+ if (typeof entry !== "number")
229
+ return legacyElement(entry);
230
+ const alternatives = [...(choices.get(entry)?.values() ?? [])].map((members) => members.length === 1 && members[0] !== undefined
231
+ ? legacyElement(members[0])
232
+ : { kind: "sequence", min: 1, max: 1, children: members.map(legacyElement) });
233
+ return { kind: "choice", min: 1, max: 1, children: alternatives };
234
+ });
235
+ return { kind: "sequence", min: 1, max: 1, children };
236
+ }
237
+ // Children of each group in document order: a group sits before the
238
+ // field it starts at; groups starting together keep their id order.
239
+ const entries = [];
240
+ const childrenOf = (parent) => entries
241
+ .filter((entry) => entry.parent === parent)
242
+ .sort((left, right) => left.order[0] - right.order[0] ||
243
+ left.order[1] - right.order[1] ||
244
+ left.order[2] - right.order[2])
245
+ .map((entry) => entry.build());
246
+ groups.forEach((group) => {
247
+ entries.push({
248
+ parent: group.parent,
249
+ order: [group.start, 0, group.id],
250
+ build: () => ({
251
+ kind: group.kind,
252
+ min: group.minOccurs,
253
+ max: group.maxOccurs,
254
+ children: childrenOf(group.id)
255
+ })
256
+ });
257
+ });
258
+ type.fields.forEach((field, index) => {
259
+ entries.push({
260
+ parent: field.group,
261
+ order: [index, 1, 0],
262
+ build: () => ({
263
+ kind: "element",
264
+ field,
265
+ min: field.occurs?.min ?? field.minOccurs,
266
+ max: field.occurs?.max ?? field.maxOccurs
267
+ })
268
+ });
269
+ });
270
+ return { kind: "sequence", min: 1, max: 1, children: childrenOf(undefined) };
271
+ };
272
+ /** Every element field under a particle, in order. */
273
+ export const particleFields = (particle) => particle.kind === "element" ? [particle.field] : particle.children.flatMap(particleFields);
274
+ /** Whether a particle can be satisfied by no elements at all. */
275
+ export const emptiable = (particle) => {
276
+ if (particle.min === 0)
277
+ return true;
278
+ if (particle.kind === "element")
279
+ return false;
280
+ return particle.kind === "choice"
281
+ ? particle.children.some(emptiable)
282
+ : particle.children.every(emptiable);
283
+ };
@@ -0,0 +1,254 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Schema from "effect/Schema";
3
+ import { choice } from "@llm4ts/core/judgment/Schemas";
4
+ import { certaintyOf, decide } from "@llm4ts/flow/Judgment";
5
+ import { ComplexTypeDef, elementByName, typeByName } from "./Catalog.js";
6
+ // Operation classification (read / mutating) decides two things: whether
7
+ // the tool may call an operation at all in a given environment, and which
8
+ // REST verb the design gives it. The tool only proposes: a name heuristic
9
+ // always, a `Judgment` answer when a judgment seat is configured. The human
10
+ // confirms by editing `operations.md` and flipping its status to
11
+ // `confirmed`; until then every operation counts as unclassified and cannot
12
+ // be called. An existing file is never overwritten.
13
+ export const OperationClass = Schema.Literals(["read", "mutating", "unclassified"]);
14
+ export class OperationsFileError extends Schema.TaggedError()("OperationsFileError", {
15
+ line: Schema.Int,
16
+ detail: Schema.String
17
+ }) {
18
+ get message() {
19
+ return `operations.md:${this.line}: ${this.detail}`;
20
+ }
21
+ }
22
+ // Italian and English verbs seen in bank SOAP estates. Matched against the
23
+ // operation name's leading word (camelCase, snake_case, or PascalCase).
24
+ const readVerbs = [
25
+ "cerca",
26
+ "ricerca",
27
+ "leggi",
28
+ "lettura",
29
+ "lista",
30
+ "elenca",
31
+ "elenco",
32
+ "dettaglio",
33
+ "visualizza",
34
+ "consulta",
35
+ "verifica",
36
+ "controlla",
37
+ "calcola",
38
+ "simula",
39
+ "stato",
40
+ "get",
41
+ "list",
42
+ "find",
43
+ "search",
44
+ "read",
45
+ "inquiry",
46
+ "inquire",
47
+ "query",
48
+ "retrieve",
49
+ "check",
50
+ "validate",
51
+ "fetch",
52
+ "lookup",
53
+ "view",
54
+ "count"
55
+ ];
56
+ const mutatingVerbs = [
57
+ "inserisci",
58
+ "inserimento",
59
+ "crea",
60
+ "creazione",
61
+ "aggiorna",
62
+ "aggiornamento",
63
+ "modifica",
64
+ "cancella",
65
+ "elimina",
66
+ "revoca",
67
+ "annulla",
68
+ "conferma",
69
+ "esegui",
70
+ "blocca",
71
+ "sblocca",
72
+ "autorizza",
73
+ "invia",
74
+ "registra",
75
+ "apri",
76
+ "chiudi",
77
+ "attiva",
78
+ "disattiva",
79
+ "storna",
80
+ "create",
81
+ "insert",
82
+ "add",
83
+ "update",
84
+ "modify",
85
+ "set",
86
+ "delete",
87
+ "remove",
88
+ "cancel",
89
+ "confirm",
90
+ "execute",
91
+ "submit",
92
+ "approve",
93
+ "authorize",
94
+ "block",
95
+ "unblock",
96
+ "open",
97
+ "close",
98
+ "activate",
99
+ "deactivate",
100
+ "send",
101
+ "register",
102
+ "post",
103
+ "put",
104
+ "transfer",
105
+ "pay"
106
+ ];
107
+ /** The operation name's first word, lower-cased. */
108
+ export const leadingWord = (name) => {
109
+ const match = /^[a-z]+|^[A-Z][a-z]+|^[A-Z]+(?![a-z])/.exec(name.replace(/^[_\W]+/, ""));
110
+ return (match?.[0] ?? name).toLowerCase();
111
+ };
112
+ export const heuristicClass = (name) => {
113
+ const word = leadingWord(name);
114
+ if (readVerbs.includes(word))
115
+ return { class: "read", reason: `name starts with "${word}"` };
116
+ if (mutatingVerbs.includes(word)) {
117
+ return { class: "mutating", reason: `name starts with "${word}"` };
118
+ }
119
+ return { class: "unclassified", reason: `no known verb in "${word}"` };
120
+ };
121
+ const fieldNames = (catalog, element) => {
122
+ if (element === undefined)
123
+ return [];
124
+ const type = typeByName(catalog, elementByName(catalog, element)?.type ?? "");
125
+ return type instanceof ComplexTypeDef ? type.fields.map((field) => field.name) : [];
126
+ };
127
+ const classQuestion = choice("Classify this SOAP operation of a bank's backend service. Would calling it change state in the backend (create, update, delete, confirm, execute, block, send), or does it only read?", {
128
+ read: "Only reads or computes: inquiries, lists, details, checks, simulations. Calling it twice has no additional effect.",
129
+ mutating: "Changes state: creates, updates, deletes, confirms, executes, blocks, revokes, or sends something."
130
+ });
131
+ /** Ask the judgment for one operation; `undefined` when it fails or does not answer. */
132
+ export const judgeClass = (judgment, catalog, operation) => judgment
133
+ .judge({
134
+ state: {
135
+ operation: operation.name,
136
+ soapAction: operation.soapAction,
137
+ documentation: operation.documentation ?? "",
138
+ requestFields: fieldNames(catalog, operation.input),
139
+ responseFields: fieldNames(catalog, operation.output),
140
+ faults: operation.faults.map((fault) => fault.name)
141
+ },
142
+ questions: { class: classQuestion }
143
+ })
144
+ .pipe(Effect.map((result) => {
145
+ const answer = result.answers["class"];
146
+ if (answer?.type !== "choice")
147
+ return undefined;
148
+ if (answer.choice !== "read" && answer.choice !== "mutating")
149
+ return undefined;
150
+ return { class: answer.choice, confidence: certaintyOf(answer), decision: decide(answer) };
151
+ }), Effect.orElseSucceed(() => undefined));
152
+ /**
153
+ * Combine the two signals. A judgment the policy would act on wins; one it
154
+ * would hold is ignored; in between, it wins only when the heuristic had no
155
+ * opinion. Disagreement stays visible in the file for the human to settle.
156
+ */
157
+ export const proposeClass = (operation, heuristic, judged) => {
158
+ const proposed = judged === undefined || judged.decision === "hold"
159
+ ? heuristic.class
160
+ : judged.decision === "act" || heuristic.class === "unclassified"
161
+ ? judged.class
162
+ : heuristic.class;
163
+ return { operation, proposed, heuristic, judged };
164
+ };
165
+ export const classifyOperations = (catalog, judgment) => Effect.forEach(catalog.operations, (operation) => Effect.map(judgment === undefined
166
+ ? Effect.succeed(undefined)
167
+ : judgeClass(judgment, catalog, operation), (judged) => proposeClass(operation.name, heuristicClass(operation.name), judged)));
168
+ const describeProposal = (proposal) => {
169
+ const judged = proposal.judged === undefined
170
+ ? ""
171
+ : `; judgment: ${proposal.judged.class} ${proposal.judged.confidence.toFixed(2)} (${proposal.judged.decision})`;
172
+ return `heuristic: ${proposal.heuristic.class}, ${proposal.heuristic.reason}${judged}`;
173
+ };
174
+ export const renderOperationsFile = (service, catalog, proposals) => {
175
+ const lines = [
176
+ `# Operations: ${service}`,
177
+ "",
178
+ "Status: proposed",
179
+ "",
180
+ "Review every `class:` below (read | mutating | unclassified), then change the",
181
+ "status above to `confirmed`. Until then every operation is unclassified:",
182
+ "nothing can be called, and the design cannot choose REST verbs. An",
183
+ "unclassified operation stays uncallable after confirmation. This file is",
184
+ "never overwritten; delete it to get fresh proposals.",
185
+ ""
186
+ ];
187
+ for (const proposal of proposals) {
188
+ const operation = catalog.operations.find((candidate) => candidate.name === proposal.operation);
189
+ lines.push(`## ${proposal.operation}`, "");
190
+ if (operation?.documentation !== undefined)
191
+ lines.push(operation.documentation, "");
192
+ lines.push(`- class: ${proposal.proposed}`, `- evidence: ${describeProposal(proposal)}`, "");
193
+ }
194
+ return lines.join("\n");
195
+ };
196
+ export const parseOperationsFile = (text) => Effect.suspend(() => {
197
+ const lines = text.split(/\r?\n/);
198
+ let confirmed;
199
+ let current;
200
+ const classes = new Map();
201
+ for (const [index, raw] of lines.entries()) {
202
+ const line = raw.trim();
203
+ const status = /^status:\s*(\S+)\s*$/i.exec(line);
204
+ if (status !== null && current === undefined) {
205
+ const value = status[1]?.toLowerCase();
206
+ if (value !== "proposed" && value !== "confirmed") {
207
+ return Effect.fail(new OperationsFileError({
208
+ line: index + 1,
209
+ detail: `status must be proposed or confirmed, found ${status[1] ?? ""}`
210
+ }));
211
+ }
212
+ confirmed = value === "confirmed";
213
+ continue;
214
+ }
215
+ const heading = /^##\s+(\S+)\s*$/.exec(line);
216
+ if (heading !== null) {
217
+ current = heading[1];
218
+ continue;
219
+ }
220
+ const declared = /^-\s*class:\s*(\S+)\s*$/i.exec(line);
221
+ if (declared !== null) {
222
+ if (current === undefined) {
223
+ return Effect.fail(new OperationsFileError({ line: index + 1, detail: "class: outside an operation" }));
224
+ }
225
+ const value = declared[1]?.toLowerCase();
226
+ if (value !== "read" && value !== "mutating" && value !== "unclassified") {
227
+ return Effect.fail(new OperationsFileError({
228
+ line: index + 1,
229
+ detail: `class of ${current} must be read, mutating, or unclassified`
230
+ }));
231
+ }
232
+ if (classes.has(current)) {
233
+ return Effect.fail(new OperationsFileError({ line: index + 1, detail: `${current} is classified twice` }));
234
+ }
235
+ classes.set(current, value);
236
+ }
237
+ }
238
+ if (confirmed === undefined) {
239
+ return Effect.fail(new OperationsFileError({ line: 1, detail: "missing Status: line" }));
240
+ }
241
+ return Effect.succeed({ confirmed, classes });
242
+ });
243
+ /** The class the tool acts on: only a confirmed file counts. */
244
+ export const effectiveClass = (file, operation) => file === undefined || !file.confirmed
245
+ ? "unclassified"
246
+ : (file.classes.get(operation) ?? "unclassified");
247
+ /** Catalog operations the file does not mention, and file entries the catalog lacks. */
248
+ export const operationsDrift = (file, catalog) => {
249
+ const names = new Set(catalog.operations.map((operation) => operation.name));
250
+ return {
251
+ missing: [...names].filter((name) => !file.classes.has(name)),
252
+ unknown: [...file.classes.keys()].filter((name) => !names.has(name))
253
+ };
254
+ };