@distilled.cloud/core 1.0.0-rc.7 → 1.0.0-rc.9

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 (57) hide show
  1. package/LICENSE +201 -0
  2. package/lib/codegen/cli.d.ts +8 -6
  3. package/lib/codegen/cli.d.ts.map +1 -1
  4. package/lib/codegen/cli.js +6 -62
  5. package/lib/codegen/cli.js.map +1 -1
  6. package/lib/codegen/graphql.d.ts +6 -0
  7. package/lib/codegen/graphql.d.ts.map +1 -1
  8. package/lib/codegen/graphql.js +14 -4
  9. package/lib/codegen/graphql.js.map +1 -1
  10. package/lib/codegen/openapi-cli.d.ts +17 -3
  11. package/lib/codegen/openapi-cli.d.ts.map +1 -1
  12. package/lib/codegen/openapi-cli.js +50 -48
  13. package/lib/codegen/openapi-cli.js.map +1 -1
  14. package/lib/codegen/openapi.d.ts +27 -1
  15. package/lib/codegen/openapi.d.ts.map +1 -1
  16. package/lib/codegen/openapi.js +81 -3
  17. package/lib/codegen/openapi.js.map +1 -1
  18. package/lib/codegen/patches.d.ts +65 -0
  19. package/lib/codegen/patches.d.ts.map +1 -0
  20. package/lib/codegen/patches.js +236 -0
  21. package/lib/codegen/patches.js.map +1 -0
  22. package/lib/codegen/patches.test.d.ts +2 -0
  23. package/lib/codegen/patches.test.d.ts.map +1 -0
  24. package/lib/codegen/patches.test.js +105 -0
  25. package/lib/codegen/patches.test.js.map +1 -0
  26. package/lib/codegen/proto.d.ts +121 -0
  27. package/lib/codegen/proto.d.ts.map +1 -0
  28. package/lib/codegen/proto.js +962 -0
  29. package/lib/codegen/proto.js.map +1 -0
  30. package/lib/codegen/rewrite-operation-ids.d.ts +131 -0
  31. package/lib/codegen/rewrite-operation-ids.d.ts.map +1 -0
  32. package/lib/codegen/rewrite-operation-ids.js +1079 -0
  33. package/lib/codegen/rewrite-operation-ids.js.map +1 -0
  34. package/lib/codegen/rewrite-operation-ids.test.d.ts +2 -0
  35. package/lib/codegen/rewrite-operation-ids.test.d.ts.map +1 -0
  36. package/lib/codegen/rewrite-operation-ids.test.js +533 -0
  37. package/lib/codegen/rewrite-operation-ids.test.js.map +1 -0
  38. package/lib/codegen/spec-path.d.ts +16 -0
  39. package/lib/codegen/spec-path.d.ts.map +1 -0
  40. package/lib/codegen/spec-path.js +101 -0
  41. package/lib/codegen/spec-path.js.map +1 -0
  42. package/lib/json-patch.d.ts +18 -11
  43. package/lib/json-patch.d.ts.map +1 -1
  44. package/lib/json-patch.js +63 -25
  45. package/lib/json-patch.js.map +1 -1
  46. package/package.json +4 -4
  47. package/src/codegen/cli.ts +14 -91
  48. package/src/codegen/graphql.ts +25 -3
  49. package/src/codegen/openapi-cli.ts +75 -59
  50. package/src/codegen/openapi.ts +118 -4
  51. package/src/codegen/patches.test.ts +130 -0
  52. package/src/codegen/patches.ts +291 -0
  53. package/src/codegen/proto.ts +1128 -0
  54. package/src/codegen/rewrite-operation-ids.test.ts +563 -0
  55. package/src/codegen/rewrite-operation-ids.ts +1206 -0
  56. package/src/codegen/spec-path.ts +115 -0
  57. package/src/json-patch.ts +82 -25
@@ -2,29 +2,29 @@
2
2
  * OpenAPI → Smithy pipeline helper (dev-time only, provider-agnostic).
3
3
  *
4
4
  * Owns the spec-side pipeline every OpenAPI-sourced provider shares: read the
5
- * spec file, apply the RFC-6902 patch chain to the OpenAPI document (v0
6
- * semantics: `*.patch.json` files in sorted name order, `{ description,
7
- * patches }` shape; stale targets warn+skip, malformed patches fail the run),
8
- * run the optional preprocess hook, convert with
9
- * {@link convertOpenApiToSmithy}, and write `.generated-specs/<name>.json`.
5
+ * spec file, apply OpenAPI RFC-6902 ops to the document, convert with
6
+ * {@link convertOpenApiToSmithy} (which owns verbNoun naming), apply Smithy
7
+ * RFC-6902 ops (`/shapes`, `/metadata`) to the model, and write
8
+ * `.generated-specs/<name>.json`. Stale targets fail unless
9
+ * `onStalePatch: "warn"`.
10
10
  *
11
- * A provider's `scripts/convert.ts` is: a `runOpenApiConvert` call. The
12
- * separate `scripts/generate.ts` (an SdkSpec + `runGeneratorCli`) then
13
- * compiles the written models. Note: providers whose OpenAPI patches live in
14
- * `patches/` should pass `patchesDir: false` to `runGeneratorCli` — the
15
- * patch chain applies HERE, to the OpenAPI document, not to the Smithy model.
11
+ * A provider's `scripts/convert.ts` is: a `runOpenApiConvert` call.
12
+ * `scripts/generate.ts` compiles the already-patched models and does not
13
+ * apply patches.
16
14
  */
17
15
  import * as fs from "node:fs/promises";
18
16
  import * as path from "node:path";
19
- import {
20
- applyOperation,
21
- isStaleTargetError,
22
- type PatchFile,
23
- } from "../json-patch.ts";
24
17
  import {
25
18
  convertOpenApiToSmithy,
26
19
  type OpenApiConvertOptions,
27
20
  } from "./openapi.ts";
21
+ import {
22
+ applyRfc6902Files,
23
+ finalizeConvert,
24
+ isSmithyPatchPath,
25
+ listRfc6902PatchFiles,
26
+ } from "./patches.ts";
27
+ import { resolveSpecPath } from "./spec-path.ts";
28
28
 
29
29
  export interface OpenApiSpecEntry {
30
30
  /** Output model name — written to `<outDir>/<name>.json`. */
@@ -35,7 +35,7 @@ export interface OpenApiSpecEntry {
35
35
  * Hook between patching and conversion (e.g. path prefixing, server
36
36
  * rewrites). May mutate the spec in place or return a replacement.
37
37
  */
38
- readonly preprocess?: (spec: any) => unknown | void;
38
+ readonly preprocess?: (spec: any) => unknown | void | Promise<unknown | void>;
39
39
  /** Per-spec converter option overrides (merged over the shared options). */
40
40
  readonly options?: Partial<OpenApiConvertOptions>;
41
41
  }
@@ -46,9 +46,10 @@ export interface RunOpenApiConvertOptions {
46
46
  readonly specs: readonly OpenApiSpecEntry[];
47
47
  /**
48
48
  * RFC-6902 patch chain root, relative to `root`. Default `"patches"`;
49
- * `false` disables. Layout: `<patchesDir>/<name>/*.patch.json` when the
49
+ * `false` disables. Layout: `<patchesDir>/<name>/*.json` when the
50
50
  * per-spec directory exists; for single-spec providers a flat
51
- * `<patchesDir>/*.patch.json` also works.
51
+ * `<patchesDir>/*.json` also works. Ops under `/shapes` or `/metadata`
52
+ * apply to the Smithy model after conversion; the rest apply to OpenAPI.
52
53
  */
53
54
  readonly patchesDir?: string | false;
54
55
  /** Output directory, relative to `root`. Default `".generated-specs"`. */
@@ -60,6 +61,19 @@ export interface RunOpenApiConvertOptions {
60
61
  readonly parse?: (text: string, specPath: string) => unknown;
61
62
  /** Shared converter options (per-spec `options` merge over these). */
62
63
  readonly options: OpenApiConvertOptions;
64
+ /**
65
+ * What to do when a patch JSON pointer does not resolve. Default `"fail"`:
66
+ * silent skip is how a whole patch chain can vanish after an upstream path
67
+ * prefix change. `"warn"` restores the old skip-and-continue behaviour.
68
+ */
69
+ readonly onStalePatch?: "fail" | "warn";
70
+ /**
71
+ * Run {@link finalizeConvert} on the written models (reference check +
72
+ * finalized marker; naming already happened in the converter). Default
73
+ * true. Pass false when the caller finalizes once after several convert
74
+ * steps (Fly machines + sprites + addons).
75
+ */
76
+ readonly finalize?: boolean;
63
77
  }
64
78
 
65
79
  const exists = async (p: string): Promise<boolean> => {
@@ -81,6 +95,7 @@ export const runOpenApiConvert = async (
81
95
  ? undefined
82
96
  : path.resolve(o.root, o.patchesDir ?? "patches");
83
97
  const parse = o.parse ?? ((text: string) => JSON.parse(text));
98
+ const onStalePatch = o.onStalePatch ?? "fail";
84
99
 
85
100
  await fs.mkdir(outDir, { recursive: true });
86
101
 
@@ -88,10 +103,11 @@ export const runOpenApiConvert = async (
88
103
  console.log(` Output: ${outDir}`);
89
104
 
90
105
  for (const entry of o.specs) {
91
- const specPath = path.resolve(o.root, entry.specPath);
106
+ // Production path by default; `specs/.local` under DISTILLED_SPECS_LOCAL.
107
+ const specPath = resolveSpecPath(o.root, entry.specPath);
92
108
  let spec: any = parse(await fs.readFile(specPath, "utf8"), specPath);
93
109
 
94
- // ---- RFC-6902 patch chain (applies to the OpenAPI document) ----
110
+ // ---- RFC-6902: OpenAPI ops before convert, Smithy ops after ----
95
111
  let patchDir: string | undefined;
96
112
  if (patchRoot && (await exists(patchRoot))) {
97
113
  const perSpec = path.join(patchRoot, entry.name);
@@ -101,66 +117,66 @@ export const runOpenApiConvert = async (
101
117
  patchDir = patchRoot;
102
118
  }
103
119
  }
104
- let patchFileCount = 0;
105
- let staleOps = 0;
106
- const badPatches: string[] = [];
107
- if (patchDir) {
108
- const files = (await fs.readdir(patchDir))
109
- .filter((f) => f.endsWith(".patch.json"))
110
- .sort((a, b) => a.localeCompare(b));
111
- for (const pf of files) {
112
- const parsed = JSON.parse(
113
- await fs.readFile(path.join(patchDir, pf), "utf8"),
114
- ) as PatchFile;
115
- for (const patchOp of parsed.patches ?? []) {
116
- try {
117
- applyOperation(spec, patchOp);
118
- } catch (e) {
119
- const msg = e instanceof Error ? e.message : String(e);
120
- if (isStaleTargetError(msg)) {
121
- // Spec drift — the patch target no longer exists upstream.
122
- staleOps++;
123
- console.warn(
124
- ` ⚠️ stale: ${entry.name}/${pf} [${patchOp.op} ${patchOp.path}]`,
125
- );
126
- } else {
127
- badPatches.push(
128
- `${entry.name}/${pf} [${patchOp.op} ${patchOp.path}]: ${msg}`,
129
- );
130
- }
131
- }
132
- }
133
- patchFileCount++;
134
- }
135
- }
136
- if (badPatches.length) {
137
- for (const b of badPatches) console.error(`❌ bad patch: ${b}`);
120
+ const patchFiles = patchDir ? await listRfc6902PatchFiles(patchDir) : [];
121
+ const patchLabel = (file: string) => `${entry.name}/${path.basename(file)}`;
122
+ const openapiPatches = await applyRfc6902Files(spec, patchFiles, {
123
+ onStalePatch,
124
+ include: (op) => !isSmithyPatchPath(op.path),
125
+ label: patchLabel,
126
+ });
127
+ if (openapiPatches.errors.length) {
128
+ for (const b of openapiPatches.errors)
129
+ console.error(`❌ bad patch: ${b}`);
138
130
  throw new Error(
139
- `${badPatches.length} malformed patch operation(s) — fix or remove them`,
131
+ `${openapiPatches.errors.length} patch operation(s) failed — fix the pointers or delete the patch`,
140
132
  );
141
133
  }
142
134
 
143
135
  // ---- Preprocess hook ----
144
136
  if (entry.preprocess) {
145
- const replaced = entry.preprocess(spec);
137
+ const replaced = await entry.preprocess(spec);
146
138
  if (replaced !== undefined) spec = replaced;
147
139
  }
148
140
 
149
- // ---- Convert + write ----
141
+ // ---- Convert, Smithy patches, write ----
150
142
  const model = convertOpenApiToSmithy(spec, {
151
143
  ...o.options,
152
144
  ...entry.options,
153
145
  });
146
+ const smithyPatches = await applyRfc6902Files(model, patchFiles, {
147
+ onStalePatch,
148
+ include: (op) => isSmithyPatchPath(op.path),
149
+ label: patchLabel,
150
+ });
151
+ if (smithyPatches.errors.length) {
152
+ for (const b of smithyPatches.errors) console.error(`❌ bad patch: ${b}`);
153
+ throw new Error(
154
+ `${smithyPatches.errors.length} Smithy patch operation(s) failed — fix the pointers or delete the patch`,
155
+ );
156
+ }
154
157
  const opCount = Object.values(model.shapes).filter(
155
158
  (s: any) => s.type === "operation",
156
159
  ).length;
157
160
  const outPath = path.join(outDir, `${entry.name}.json`);
158
161
  await fs.writeFile(outPath, JSON.stringify(model, null, 2) + "\n");
162
+ const staleOps = openapiPatches.stale + smithyPatches.stale;
159
163
  console.log(
160
164
  ` ✅ ${entry.name}: ${opCount} operations, ${Object.keys(model.shapes).length} shapes` +
161
- (patchFileCount
162
- ? ` (${patchFileCount} patch file(s) applied${staleOps ? `, ${staleOps} stale op(s) skipped` : ""})`
165
+ (patchFiles.length
166
+ ? ` (${patchFiles.length} patch file(s) applied${staleOps ? `, ${staleOps} stale op(s) skipped` : ""})`
163
167
  : ""),
164
168
  );
165
169
  }
170
+
171
+ if (o.finalize !== false) {
172
+ const written = new Set(o.specs.map((s) => s.name));
173
+ await finalizeConvert({
174
+ root: o.root,
175
+ outDir,
176
+ // Patches and naming already ran above.
177
+ patchesDir: false,
178
+ operationNaming: "as-is",
179
+ include: (resource) => written.has(resource),
180
+ });
181
+ }
166
182
  };
@@ -9,7 +9,9 @@
9
9
  *
10
10
  * • one operation per (path × get/post/put/patch/delete — plus head/options
11
11
  * when a provider opts in via `extraHttpMethods`), deprecated skipped by
12
- * default; op shape name = PascalCase(operationId)
12
+ * default; op shape name = PascalCase(operationId), or verbNoun
13
+ * (`Apps_list` → `ListApps`) when {@link OpenApiConvertOptions.operationNaming}
14
+ * is `"verbNoun"`
13
15
  * • input `<Op>Request`: path params → `smithy.api#httpLabel` (+required),
14
16
  * query params → `smithy.api#httpQuery`, header params →
15
17
  * `smithy.api#httpHeader` when `headerParams` is on (dropped otherwise,
@@ -42,6 +44,15 @@
42
44
  * (with the non-standard `mode` member the core runtime dispatches on)
43
45
  */
44
46
 
47
+ import {
48
+ isMechanicalOperationId,
49
+ isVerbatimRouteId,
50
+ pathToVerbNoun,
51
+ resolveOperationName,
52
+ toVerbNoun,
53
+ type OperationIdRewrite,
54
+ } from "./rewrite-operation-ids.ts";
55
+
45
56
  // ============================================================================
46
57
  // Trait ids (the `com.distilled.openapi` vocabulary)
47
58
  // ============================================================================
@@ -177,6 +188,29 @@ export interface OpenApiConvertOptions {
177
188
  * {@link ERROR_MATCHERS_TRAIT} traits; overrides are merged over it.
178
189
  */
179
190
  readonly errorShapes?: Readonly<Record<string, any>>;
191
+ /**
192
+ * How to turn an OpenAPI `operationId` into the Smithy/SDK operation name.
193
+ * This is a convert policy, not a spec patch — the OpenAPI document is
194
+ * left alone.
195
+ *
196
+ * - `"verbNoun"` (default): `Apps_list` / `ConfigsList` / `showContact` →
197
+ * `listApps` / `listConfigs` / `getApp`. Already verb-first ids stay.
198
+ * An operation with no `operationId`, or a mechanical one that only
199
+ * restates the method and path (`get-api-card`, `post_v1_users`), is
200
+ * named from the route instead: `GET /users` → `listUsers`,
201
+ * `GET /users/{id}` → `getUser`, `POST /users/{id}/reset` →
202
+ * `resetUser`.
203
+ * - `"as-is"`: `PascalCase(operationId)` (`Apps_list` → `AppsList`).
204
+ *
205
+ * {@link operationNames} overrides win (lookup by `"METHOD path"`, then
206
+ * operationId).
207
+ */
208
+ readonly operationNaming?: "as-is" | "verbNoun";
209
+ /**
210
+ * Per-operation name overrides for {@link operationNaming}. Use
211
+ * `"METHOD path"` keys when two methods share an `operationId`.
212
+ */
213
+ readonly operationNames?: OperationIdRewrite;
180
214
  }
181
215
 
182
216
  export interface SmithyModel {
@@ -1079,6 +1113,37 @@ const successSchema = (
1079
1113
  return { schema: undefined };
1080
1114
  };
1081
1115
 
1116
+ /**
1117
+ * Whether the success body is a collection: an array, or an object whose
1118
+ * array members outnumber its scalar ones (envelopes like `{ data: [] }`,
1119
+ * `{ items: [], total }`). `undefined` when there is no body to judge.
1120
+ */
1121
+ const responseIsCollection = (
1122
+ ctx: Ctx,
1123
+ responses: any,
1124
+ order: readonly string[],
1125
+ ): boolean | undefined => {
1126
+ const { schema } = successSchema(ctx, responses, order);
1127
+ if (!schema) return undefined;
1128
+ const s = deref(ctx, schema);
1129
+ if (!s || typeof s !== "object") return undefined;
1130
+ if (s.type === "array" || s.items) return true;
1131
+ const props = s.properties;
1132
+ if (!props || typeof props !== "object") {
1133
+ return s.allOf || s.oneOf || s.anyOf ? undefined : false;
1134
+ }
1135
+ let arrays = 0;
1136
+ let others = 0;
1137
+ for (const prop of Object.values(props)) {
1138
+ const p = deref(ctx, prop);
1139
+ if (p?.type === "array" || p?.items) arrays++;
1140
+ else others++;
1141
+ }
1142
+ if (arrays === 0) return false;
1143
+ // `{ data: [...] }`, `{ items, next_cursor }`, `{ results, count, page }`
1144
+ return arrays >= 1 && others <= 3;
1145
+ };
1146
+
1082
1147
  // ============================================================================
1083
1148
  // Pagination detection (v0 detectPagination)
1084
1149
  // ============================================================================
@@ -1227,11 +1292,60 @@ export const convertOpenApiToSmithy = (
1227
1292
  if (!op || typeof op !== "object") continue;
1228
1293
  if (skipDeprecated && op.deprecated === true) continue;
1229
1294
 
1230
- const opName = pascal(
1295
+ const rawOperationId =
1231
1296
  typeof op.operationId === "string" && op.operationId
1232
1297
  ? op.operationId
1233
- : `${method}_${rawPath}`,
1234
- );
1298
+ : `${method}_${rawPath}`;
1299
+ const idCtx = { path: rawPath, method };
1300
+ const naming = options.operationNaming ?? "verbNoun";
1301
+ let named =
1302
+ options.operationNames !== undefined
1303
+ ? resolveOperationName(options.operationNames, rawOperationId, idCtx)
1304
+ : undefined;
1305
+ if (named === undefined && naming === "verbNoun") {
1306
+ // A mechanical id (`get-api-card`, or none at all) is named from
1307
+ // the route. A hand-chosen one that merely starts with the method
1308
+ // and reads the route in order (`get-feeds` on /feeds) keeps its
1309
+ // own nouns; only the HTTP-method verb is normalised.
1310
+ if (typeof op.operationId !== "string" || !op.operationId) {
1311
+ // No id: everything comes from the route, including whether a
1312
+ // GET on a collection route is `list` or `get`.
1313
+ named = pathToVerbNoun(idCtx, {
1314
+ returnsCollection: responseIsCollection(
1315
+ ctx,
1316
+ op.responses,
1317
+ successStatuses,
1318
+ ),
1319
+ });
1320
+ } else if (isMechanicalOperationId(op.operationId, idCtx)) {
1321
+ // Method-prefixed and reading the route (`get-feeds`,
1322
+ // `postV1AppsByAppIdPromote`, `deleteProjectJWKS`): keep the
1323
+ // author's tokens and casing; normalise `post`/`patch`, drop
1324
+ // parameter clauses (`ByAppId`, `ById`) and api/version roots.
1325
+ named = pathToVerbNoun(idCtx, {
1326
+ nouns: op.operationId,
1327
+ verbatim: isVerbatimRouteId(op.operationId, idCtx),
1328
+ });
1329
+ } else {
1330
+ named = toVerbNoun(rawOperationId);
1331
+ }
1332
+ }
1333
+ let resolved: string = named ?? rawOperationId;
1334
+ // Two routes can derive the same name (`/collections/{slug}` and
1335
+ // `/collections/{slug}-{id}`); suffix the trailing parameter rather
1336
+ // than a counter.
1337
+ if (
1338
+ naming === "verbNoun" &&
1339
+ ctx.names.has(pascal(resolved)) &&
1340
+ isMechanicalOperationId(
1341
+ typeof op.operationId === "string" ? op.operationId : undefined,
1342
+ idCtx,
1343
+ )
1344
+ ) {
1345
+ const lastParam = /\{([^}]+)\}[^/]*$/.exec(rawPath)?.[1];
1346
+ if (lastParam) resolved = `${resolved}By${pascal(lastParam)}`;
1347
+ }
1348
+ const opName = pascal(resolved);
1235
1349
 
1236
1350
  const params = collectParams(ctx, pathItem, op);
1237
1351
 
@@ -0,0 +1,130 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ FINALIZED_KEY,
7
+ danglingTargets,
8
+ finalizeConvert,
9
+ syncServiceOperations,
10
+ } from "./patches.ts";
11
+
12
+ const model = () => ({
13
+ smithy: "2.0",
14
+ shapes: {
15
+ "ns#Svc": {
16
+ type: "service",
17
+ operations: [{ target: "ns#AppsList" }, { target: "ns#AppsGet" }],
18
+ },
19
+ "ns#AppsList": {
20
+ type: "operation",
21
+ input: { target: "ns#AppsListRequest" },
22
+ output: { target: "smithy.api#Unit" },
23
+ },
24
+ "ns#AppsListRequest": { type: "structure", members: {} },
25
+ "ns#AppsGet": {
26
+ type: "operation",
27
+ input: { target: "smithy.api#Unit" },
28
+ output: { target: "smithy.api#Unit" },
29
+ },
30
+ },
31
+ });
32
+
33
+ const scaffold = (patches?: Record<string, unknown>) => {
34
+ const root = mkdtempSync(join(tmpdir(), "finalize-"));
35
+ mkdirSync(join(root, ".generated-specs"));
36
+ writeFileSync(
37
+ join(root, ".generated-specs", "svc.json"),
38
+ JSON.stringify(model()),
39
+ );
40
+ if (patches) {
41
+ mkdirSync(join(root, "patches", "svc"), { recursive: true });
42
+ writeFileSync(
43
+ join(root, "patches", "svc", "a.json"),
44
+ JSON.stringify(patches),
45
+ );
46
+ }
47
+ return root;
48
+ };
49
+
50
+ const read = (root: string) =>
51
+ JSON.parse(readFileSync(join(root, ".generated-specs", "svc.json"), "utf8"));
52
+
53
+ describe("finalizeConvert", () => {
54
+ test("renames, syncs the service list, stamps the marker", async () => {
55
+ const root = scaffold();
56
+ await finalizeConvert({ root });
57
+ const m = read(root);
58
+ expect(Object.keys(m.shapes).sort()).toEqual(
59
+ ["ns#Svc", "ns#ListApps", "ns#ListAppsRequest", "ns#GetApp"].sort(),
60
+ );
61
+ expect(m.shapes["ns#Svc"].operations).toEqual([
62
+ { target: "ns#ListApps" },
63
+ { target: "ns#GetApp" },
64
+ ]);
65
+ expect(m.metadata[FINALIZED_KEY]).toBe(true);
66
+ });
67
+
68
+ test("refuses a second pass", async () => {
69
+ const root = scaffold();
70
+ await finalizeConvert({ root });
71
+ await expect(finalizeConvert({ root })).rejects.toThrow(
72
+ /already finalized/,
73
+ );
74
+ });
75
+
76
+ test("applies Smithy patches and repairs the service list after a move", async () => {
77
+ const root = scaffold({
78
+ patches: [
79
+ { op: "move", from: "/shapes/ns#AppsGet", path: "/shapes/ns#FetchApp" },
80
+ ],
81
+ });
82
+ await finalizeConvert({ root });
83
+ const m = read(root);
84
+ expect(m.shapes["ns#FetchApp"]).toBeDefined();
85
+ expect(m.shapes["ns#Svc"].operations.map((o: any) => o.target)).toEqual([
86
+ "ns#ListApps",
87
+ "ns#FetchApp",
88
+ ]);
89
+ });
90
+
91
+ test("fails on a stale patch pointer by default", async () => {
92
+ const root = scaffold({
93
+ patches: [{ op: "remove", path: "/shapes/ns#Missing" }],
94
+ });
95
+ await expect(finalizeConvert({ root })).rejects.toThrow(/failed/);
96
+ });
97
+
98
+ test("fails on dangling targets and leaves the model unstamped", async () => {
99
+ const root = scaffold({
100
+ patches: [
101
+ {
102
+ op: "replace",
103
+ path: "/shapes/ns#AppsList/output/target",
104
+ value: "ns#Nope",
105
+ },
106
+ ],
107
+ });
108
+ await expect(finalizeConvert({ root })).rejects.toThrow(/do not exist/);
109
+ expect(read(root).metadata?.[FINALIZED_KEY]).toBeUndefined();
110
+ });
111
+ });
112
+
113
+ describe("danglingTargets", () => {
114
+ test("ignores prelude ids", () => {
115
+ expect(danglingTargets(model())).toEqual([]);
116
+ });
117
+ });
118
+
119
+ describe("syncServiceOperations", () => {
120
+ test("drops missing and appends unlisted, keeping order", () => {
121
+ const m = model();
122
+ delete (m.shapes as any)["ns#AppsGet"];
123
+ (m.shapes as any)["ns#Zed"] = { type: "operation" };
124
+ expect(syncServiceOperations(m)).toBe(1);
125
+ expect((m.shapes as any)["ns#Svc"].operations).toEqual([
126
+ { target: "ns#AppsList" },
127
+ { target: "ns#Zed" },
128
+ ]);
129
+ });
130
+ });