@supacloud/compiler 0.24.0 → 0.25.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/dist/index.js CHANGED
@@ -125,6 +125,9 @@ function buildContractManifest(graph, artifacts) {
125
125
  ...command?.permission === undefined ? {} : { permission: command.permission },
126
126
  requestSchemas: Object.fromEntries(["body", "params", "query", "headers", "cookie"].flatMap((key) => route[key] === undefined ? [] : [[key, route[key]]])),
127
127
  ...route.response === undefined ? {} : { responseSchema: route.response },
128
+ ...route.responses === undefined ? {} : {
129
+ responseSchemas: Object.fromEntries(Object.entries(route.responses).sort(([left], [right]) => left.localeCompare(right)))
130
+ },
128
131
  ...route.contract?.evidence === undefined ? {} : { evidence: route.contract.evidence }
129
132
  });
130
133
  }
@@ -2696,9 +2699,9 @@ var init_graphql = __esm(() => {
2696
2699
  });
2697
2700
 
2698
2701
  // src/graphql-schema.ts
2699
- import { mkdir as mkdir5, readFile as readFile11 } from "node:fs/promises";
2700
- import { createHash as createHash9 } from "node:crypto";
2701
- import { dirname as dirname10, resolve as resolve15 } from "node:path";
2702
+ import { mkdir as mkdir5, readFile as readFile12 } from "node:fs/promises";
2703
+ import { createHash as createHash10 } from "node:crypto";
2704
+ import { dirname as dirname10, resolve as resolve16 } from "node:path";
2702
2705
  async function pullGraphqlSchema(options) {
2703
2706
  assertGraphqlOptions({ schema: options.output });
2704
2707
  const endpoint = new URL(options.url);
@@ -2738,7 +2741,7 @@ async function pullGraphqlSchema(options) {
2738
2741
  throw new Error("GraphQL schema export failed. Verify caller grants and enable introspection only in the intended development environment.");
2739
2742
  }
2740
2743
  const schema = lexicographicSortSchema(buildClientSchema(data));
2741
- const path = resolve15(options.output);
2744
+ const path = resolve16(options.output);
2742
2745
  const content = path.endsWith(".json") ? JSON.stringify(introspectionFromSchema(schema), null, 2) + `
2743
2746
  ` : `# GENERATED BY supacloud-compiler graphql-schema. DO NOT EDIT.
2744
2747
  # Database First: change database declarations, apply migrations, then re-export for the intended role.
@@ -2746,13 +2749,13 @@ async function pullGraphqlSchema(options) {
2746
2749
  `;
2747
2750
  let previous;
2748
2751
  try {
2749
- previous = await readFile11(path, "utf8");
2752
+ previous = await readFile12(path, "utf8");
2750
2753
  } catch (error) {
2751
2754
  if (!(error instanceof Error && ("code" in error) && error.code === "ENOENT"))
2752
2755
  throw error;
2753
2756
  }
2754
2757
  const upToDate = previous === content;
2755
- const schemaHash = createHash9("sha256").update(content).digest("hex");
2758
+ const schemaHash = createHash10("sha256").update(content).digest("hex");
2756
2759
  if (options.check)
2757
2760
  return { path, schemaHash, upToDate, written: false };
2758
2761
  await mkdir5(dirname10(path), { recursive: true });
@@ -2765,12 +2768,12 @@ var init_graphql_schema = __esm(() => {
2765
2768
  });
2766
2769
 
2767
2770
  // src/database-contracts.ts
2768
- import { createHash as createHash10 } from "node:crypto";
2769
- import { mkdir as mkdir6, readFile as readFile12 } from "node:fs/promises";
2770
- import { dirname as dirname11, relative as relative12, resolve as resolve16 } from "node:path";
2771
+ import { createHash as createHash11 } from "node:crypto";
2772
+ import { mkdir as mkdir6, readFile as readFile13 } from "node:fs/promises";
2773
+ import { dirname as dirname11, relative as relative12, resolve as resolve17 } from "node:path";
2771
2774
  import * as ts13 from "@typescript/typescript6";
2772
2775
  function hash2(value) {
2773
- return createHash10("sha256").update(value).digest("hex");
2776
+ return createHash11("sha256").update(value).digest("hex");
2774
2777
  }
2775
2778
  function importPath(out, path) {
2776
2779
  const value = relative12(out, path).replaceAll("\\", "/").replace(/\.(?:d\.)?[cm]?ts$/, "");
@@ -2795,19 +2798,19 @@ function parseDatabaseContractsOptions(value, directory) {
2795
2798
  throw new TypeError("migrations must be an ordered list of SQL files");
2796
2799
  }
2797
2800
  return {
2798
- rootDir: resolve16(directory, field("rootDir")),
2799
- outDir: resolve16(directory, field("outDir")),
2800
- postgrestTypes: resolve16(directory, field("postgrestTypes")),
2801
- drizzleSchema: resolve16(directory, field("drizzleSchema")),
2801
+ rootDir: resolve17(directory, field("rootDir")),
2802
+ outDir: resolve17(directory, field("outDir")),
2803
+ postgrestTypes: resolve17(directory, field("postgrestTypes")),
2804
+ drizzleSchema: resolve17(directory, field("drizzleSchema")),
2802
2805
  role: field("role"),
2803
- graphql: { ...graphql, schema: resolve16(directory, graphql.schema) },
2804
- migrations: migrations.map((file) => resolve16(directory, file))
2806
+ graphql: { ...graphql, schema: resolve17(directory, graphql.schema) },
2807
+ migrations: migrations.map((file) => resolve17(directory, file))
2805
2808
  };
2806
2809
  }
2807
2810
  async function generateDatabaseContracts(options, check = false) {
2808
- const rootDir = resolve16(options.rootDir), outDir = resolve16(options.outDir);
2809
- const postgrestTypes = resolve16(rootDir, options.postgrestTypes), drizzleSchema = resolve16(rootDir, options.drizzleSchema);
2810
- const snapshot = await readFile12(postgrestTypes, "utf8");
2811
+ const rootDir = resolve17(options.rootDir), outDir = resolve17(options.outDir);
2812
+ const postgrestTypes = resolve17(rootDir, options.postgrestTypes), drizzleSchema = resolve17(rootDir, options.drizzleSchema);
2813
+ const snapshot = await readFile13(postgrestTypes, "utf8");
2811
2814
  const syntax = ts13.createSourceFile(postgrestTypes, snapshot, ts13.ScriptTarget.Latest, true);
2812
2815
  const database = syntax.statements.find((node) => (ts13.isTypeAliasDeclaration(node) || ts13.isInterfaceDeclaration(node)) && node.name.text === "Database" && node.modifiers?.some((modifier) => modifier.kind === ts13.SyntaxKind.ExportKeyword));
2813
2816
  if (!database)
@@ -2834,8 +2837,8 @@ async function generateDatabaseContracts(options, check = false) {
2834
2837
  }
2835
2838
  const inputs = {};
2836
2839
  const addInput = async (path) => {
2837
- const absolute = resolve16(rootDir, path);
2838
- inputs[relative12(rootDir, absolute).replaceAll("\\", "/")] = hash2(await readFile12(absolute, "utf8"));
2840
+ const absolute = resolve17(rootDir, path);
2841
+ inputs[relative12(rootDir, absolute).replaceAll("\\", "/")] = hash2(await readFile13(absolute, "utf8"));
2839
2842
  };
2840
2843
  await addInput(postgrestTypes);
2841
2844
  await addInput(drizzleSchema);
@@ -2851,8 +2854,8 @@ async function generateDatabaseContracts(options, check = false) {
2851
2854
  if (!source.isDeclarationFile && !source.fileName.includes("/node_modules/"))
2852
2855
  await addInput(source.fileName);
2853
2856
  }
2854
- await addInput(resolve16(rootDir, options.graphql.schema));
2855
- if (new Set(options.migrations.map((path) => resolve16(rootDir, path))).size !== options.migrations.length) {
2857
+ await addInput(resolve17(rootDir, options.graphql.schema));
2858
+ if (new Set(options.migrations.map((path) => resolve17(rootDir, path))).size !== options.migrations.length) {
2856
2859
  throw new Error("Duplicate migration in database contracts configuration");
2857
2860
  }
2858
2861
  for (const path of options.migrations)
@@ -2873,17 +2876,17 @@ async function generateDatabaseContracts(options, check = false) {
2873
2876
  version: 1,
2874
2877
  role: options.role,
2875
2878
  inputs: Object.fromEntries(Object.entries(inputs).sort(([a], [b]) => a.localeCompare(b))),
2876
- migrationOrder: options.migrations.map((path) => relative12(rootDir, resolve16(rootDir, path)).replaceAll("\\", "/")),
2879
+ migrationOrder: options.migrations.map((path) => relative12(rootDir, resolve17(rootDir, path)).replaceAll("\\", "/")),
2877
2880
  outputs: Object.fromEntries(Object.entries(files).sort(([a], [b]) => a.localeCompare(b)).map(([file, text]) => [file, hash2(text)]))
2878
2881
  };
2879
2882
  files["database.manifest.json"] = JSON.stringify(manifest, null, 2) + `
2880
2883
  `;
2881
2884
  const mismatches = [];
2882
2885
  for (const [file, content] of Object.entries(files)) {
2883
- const path = resolve16(outDir, file);
2886
+ const path = resolve17(outDir, file);
2884
2887
  let current;
2885
2888
  try {
2886
- current = await readFile12(path, "utf8");
2889
+ current = await readFile13(path, "utf8");
2887
2890
  } catch (error) {
2888
2891
  if (!(error instanceof Error && ("code" in error) && error.code === "ENOENT"))
2889
2892
  throw error;
@@ -2894,13 +2897,13 @@ async function generateDatabaseContracts(options, check = false) {
2894
2897
  if (!check) {
2895
2898
  await mkdir6(outDir, { recursive: true });
2896
2899
  for (const [file, content] of Object.entries(files))
2897
- await writeFileIfChanged(resolve16(outDir, file), content);
2900
+ await writeFileIfChanged(resolve17(outDir, file), content);
2898
2901
  }
2899
2902
  return { upToDate: mismatches.length === 0, mismatches, written: check ? [] : mismatches, manifest };
2900
2903
  }
2901
2904
  async function runDatabaseContractsFile(path, check = false) {
2902
- const absolute = resolve16(path);
2903
- const value = JSON.parse(await readFile12(absolute, "utf8"));
2905
+ const absolute = resolve17(path);
2906
+ const value = JSON.parse(await readFile13(absolute, "utf8"));
2904
2907
  return generateDatabaseContracts(parseDatabaseContractsOptions(value, dirname11(absolute)), check);
2905
2908
  }
2906
2909
  var init_database_contracts = __esm(() => {
@@ -11585,6 +11588,8 @@ function optionsKeyOf(options) {
11585
11588
  requireRouteContracts: options.requireRouteContracts,
11586
11589
  detectOrphanModules: options.detectOrphanModules,
11587
11590
  generateClient: options.generateClient,
11591
+ generateOpenApi: options.generateOpenApi,
11592
+ openApi: options.openApi,
11588
11593
  generatePermissions: options.generatePermissions,
11589
11594
  typeSafety: options.typeSafety,
11590
11595
  treeShakeUnusedProviders: options.treeShakeUnusedProviders,
@@ -11719,6 +11724,16 @@ function findAffectedModules(previous, current, changedFiles) {
11719
11724
 
11720
11725
  // src/watch.ts
11721
11726
  var DEFAULT_DEBOUNCE_MS = 100;
11727
+ function isCompilerConfigurationPath(rootDir, changedPath) {
11728
+ const relativePath = relative9(rootDir, changedPath).split(sep9).join("/");
11729
+ return [
11730
+ "supacloud.config.ts",
11731
+ "supacloud.config.mts",
11732
+ "supacloud.config.js",
11733
+ "supacloud.config.mjs",
11734
+ "tsconfig.json"
11735
+ ].includes(relativePath) || /^tsconfig\.[^/]+\.json$/.test(relativePath);
11736
+ }
11722
11737
  function watchProject(options) {
11723
11738
  const rootDir = resolve10(options.rootDir);
11724
11739
  const outDir = resolve10(options.outDir);
@@ -11811,7 +11826,7 @@ function watchProject(options) {
11811
11826
  const relativePath = relative9(outDir, changedPath);
11812
11827
  if (!relativePath.startsWith("..") && relativePath !== "")
11813
11828
  return;
11814
- if (/\.(tsx?|mts|cts)$/.test(changedPath) || options.graphql && (/\.(graphql|gql)$/.test(changedPath) || changedPath === schemaPath)) {
11829
+ if (/\.(tsx?|mts|cts)$/.test(changedPath) || isCompilerConfigurationPath(rootDir, changedPath) || options.graphql && (/\.(graphql|gql)$/.test(changedPath) || changedPath === schemaPath)) {
11815
11830
  schedule(relative9(rootDir, changedPath));
11816
11831
  }
11817
11832
  });
@@ -12285,798 +12300,989 @@ async function migrateProject(options) {
12285
12300
  issues
12286
12301
  };
12287
12302
  }
12288
- // src/inspect.ts
12289
- import { existsSync as existsSync4 } from "node:fs";
12290
- import { dirname as dirname8, join as join7, relative as relative11, sep as sep10 } from "node:path";
12291
- function formatGraph(graph) {
12292
- const lines = [];
12293
- for (const module of graph.modules) {
12294
- lines.push(`MODULE ${module.name}`);
12295
- lines.push(` file: ${module.file}:${module.line}`);
12296
- lines.push(` imports: ${module.imports.length > 0 ? module.imports.join(", ") : "-"}`);
12297
- lines.push(` providers: ${module.providers.length > 0 ? module.providers.map((p) => p.token).join(", ") : "-"}`);
12298
- lines.push(` controllers: ${module.controllers.length > 0 ? module.controllers.map((c) => c.className).join(", ") : "-"}`);
12299
- lines.push(` commands: ${module.commands.length > 0 ? module.commands.map((c) => c.name).join(", ") : "-"}`);
12303
+ // src/migration-assess.ts
12304
+ import { createHash as createHash9 } from "node:crypto";
12305
+ import { readFile as readFile11 } from "node:fs/promises";
12306
+ import { join as join7, resolve as resolve14 } from "node:path";
12307
+
12308
+ // src/openapi-tools.ts
12309
+ import { mkdir as mkdir4, readFile as readFile10, rename as rename6, unlink as unlink3, writeFile as writeFile5 } from "node:fs/promises";
12310
+ import { dirname as dirname8, resolve as resolve13 } from "node:path";
12311
+ import { pathToFileURL } from "node:url";
12312
+
12313
+ class OpenApiDocumentError extends Error {
12314
+ code = "OPENAPI_DOCUMENT_INVALID";
12315
+ constructor() {
12316
+ super("OpenAPI document is invalid or could not be loaded.");
12317
+ this.name = "OpenApiDocumentError";
12300
12318
  }
12301
- lines.push(`EXTERNAL TOKENS ${graph.externalTokens.length > 0 ? graph.externalTokens.join(", ") : "-"}`);
12302
- return lines.join(`
12303
- `);
12304
12319
  }
12305
- function explainGraph(graph, subject) {
12306
- const module = graph.modules.find((candidate) => candidate.name === subject);
12307
- if (module)
12308
- return explainModule(graph, module);
12309
- const provider = findProvider(graph, subject);
12310
- if (provider)
12311
- return explainProvider(graph, provider.module, provider.provider);
12312
- if (graph.externalTokens.includes(subject)) {
12313
- const references = graph.modules.flatMap((candidate) => [
12314
- ...candidate.providers.filter((item) => item.deps.includes(subject)).map((item) => `${candidate.name}.${item.token}`),
12315
- ...candidate.controllers.filter((item) => item.deps.includes(subject)).map((item) => `${candidate.name}.${item.className}`)
12316
- ]);
12317
- return [
12318
- `EXTERNAL TOKEN ${subject}`,
12319
- " provided by: platform runtime",
12320
- ` references: ${references.length > 0 ? references.join(", ") : "-"}`
12321
- ].join(`
12322
- `);
12323
- }
12324
- const known = [...graph.modules.map((item) => item.name), ...graph.externalTokens].sort();
12325
- throw new Error(`No module, provider, or external token named "${subject}". Known names: ${known.join(", ") || "(none)"}`);
12320
+ var HTTP_METHODS = [
12321
+ "get",
12322
+ "put",
12323
+ "post",
12324
+ "delete",
12325
+ "options",
12326
+ "head",
12327
+ "patch",
12328
+ "trace"
12329
+ ];
12330
+ var COMPONENT_GROUPS = [
12331
+ "schemas",
12332
+ "responses",
12333
+ "parameters",
12334
+ "requestBodies",
12335
+ "headers",
12336
+ "securitySchemes"
12337
+ ];
12338
+ function isRecord(value) {
12339
+ return value !== null && typeof value === "object" && !Array.isArray(value);
12326
12340
  }
12327
- function createContextPack(graph, subject) {
12328
- const subjectModule = graph.modules.find((module) => module.name === subject);
12329
- if (!subjectModule) {
12330
- throw new Error(`No module named "${subject}". Context packs require a module name.`);
12341
+ function stringValue(value) {
12342
+ return typeof value === "string" ? value : undefined;
12343
+ }
12344
+ function recordValue(value) {
12345
+ return isRecord(value) ? value : undefined;
12346
+ }
12347
+ function arrayValue(value) {
12348
+ return Array.isArray(value) ? value : [];
12349
+ }
12350
+ function sortedKeys(value) {
12351
+ return value ? Object.keys(value).sort() : [];
12352
+ }
12353
+ function sameJson(left, right) {
12354
+ return JSON.stringify(left) === JSON.stringify(right);
12355
+ }
12356
+ function decodeJsonPointerPart(value) {
12357
+ return value.replace(/~1/g, "/").replace(/~0/g, "~");
12358
+ }
12359
+ function resolveSchema(value, document, seen = new Set) {
12360
+ const schema = recordValue(value);
12361
+ if (!schema)
12362
+ return;
12363
+ const ref = stringValue(schema.$ref);
12364
+ if (!ref || !ref.startsWith("#/components/schemas/"))
12365
+ return schema;
12366
+ if (seen.has(ref))
12367
+ return schema;
12368
+ const name = decodeJsonPointerPart(ref.slice("#/components/schemas/".length));
12369
+ const components = recordValue(document.components);
12370
+ const schemas = recordValue(components?.schemas);
12371
+ const target = schemas?.[name];
12372
+ if (target === undefined)
12373
+ return schema;
12374
+ return resolveSchema(target, document, new Set([...seen, ref]));
12375
+ }
12376
+ function schemaEnum(value) {
12377
+ return Array.isArray(value?.enum) ? value.enum : undefined;
12378
+ }
12379
+ function schemaProperties(value) {
12380
+ return recordValue(value?.properties);
12381
+ }
12382
+ function schemaRequired(value) {
12383
+ return new Set(arrayValue(value?.required).filter((item) => typeof item === "string"));
12384
+ }
12385
+ function schemaType(value) {
12386
+ return value?.type;
12387
+ }
12388
+ function addChange(changes, change) {
12389
+ if (changes.some((item) => item.kind === change.kind && item.code === change.code && item.path === change.path && item.message === change.message))
12390
+ return;
12391
+ changes.push(change);
12392
+ }
12393
+ function compareSchema(baseValue, currentValue, baseDocument, currentDocument, path, direction, changes, seen = new Set) {
12394
+ const baseSchema = resolveSchema(baseValue, baseDocument);
12395
+ const currentSchema = resolveSchema(currentValue, currentDocument);
12396
+ if (!baseSchema || !currentSchema)
12397
+ return;
12398
+ const pair = `${path}|${JSON.stringify(baseSchema)}|${JSON.stringify(currentSchema)}`;
12399
+ if (seen.has(pair))
12400
+ return;
12401
+ seen.add(pair);
12402
+ if (schemaType(baseSchema) !== undefined && schemaType(currentSchema) !== undefined && !sameJson(schemaType(baseSchema), schemaType(currentSchema))) {
12403
+ addChange(changes, {
12404
+ kind: "breaking",
12405
+ code: "schema-type-changed",
12406
+ path,
12407
+ message: "The schema type changed."
12408
+ });
12331
12409
  }
12332
- const byName = new Map(graph.modules.map((module) => [module.name, module]));
12333
- const selected = new Set([subjectModule.name]);
12334
- const reverseDependents = new Map;
12335
- for (const mod of graph.modules) {
12336
- for (const imp of mod.imports) {
12337
- const list = reverseDependents.get(imp);
12338
- if (list)
12339
- list.push(mod.name);
12340
- else
12341
- reverseDependents.set(imp, [mod.name]);
12410
+ const baseEnum = schemaEnum(baseSchema);
12411
+ const currentEnum = schemaEnum(currentSchema);
12412
+ if (baseEnum && currentEnum) {
12413
+ for (const value of baseEnum) {
12414
+ if (!currentEnum.some((candidate) => sameJson(candidate, value))) {
12415
+ addChange(changes, {
12416
+ kind: "breaking",
12417
+ code: "schema-enum-value-removed",
12418
+ path,
12419
+ message: "An enum value accepted by the previous contract was removed."
12420
+ });
12421
+ break;
12422
+ }
12342
12423
  }
12343
12424
  }
12344
- for (const direction of ["imports", "dependents"]) {
12345
- const visited = new Set([subjectModule.name]);
12346
- const queue = [subjectModule.name];
12347
- let head = 0;
12348
- while (head < queue.length) {
12349
- const current = queue[head++];
12350
- if (!current)
12351
- continue;
12352
- const module = byName.get(current);
12353
- if (!module)
12354
- continue;
12355
- const neighbors = direction === "imports" ? module.imports : reverseDependents.get(module.name) ?? [];
12356
- for (const neighbor of neighbors) {
12357
- if (!visited.has(neighbor) && byName.has(neighbor)) {
12358
- visited.add(neighbor);
12359
- selected.add(neighbor);
12360
- queue.push(neighbor);
12425
+ const baseProperties = schemaProperties(baseSchema);
12426
+ const currentProperties = schemaProperties(currentSchema);
12427
+ if (baseProperties && currentProperties) {
12428
+ for (const name of sortedKeys(baseProperties)) {
12429
+ if (currentProperties[name] === undefined) {
12430
+ const inputRemovalIsBreaking = baseSchema.additionalProperties === false;
12431
+ if (direction === "output" || inputRemovalIsBreaking) {
12432
+ addChange(changes, {
12433
+ kind: "breaking",
12434
+ code: direction === "output" ? "response-property-removed" : "request-property-removed",
12435
+ path: `${path}.properties.${name}`,
12436
+ message: direction === "output" ? "A property returned by the previous contract was removed." : "A request property was removed while additional properties are rejected."
12437
+ });
12361
12438
  }
12439
+ continue;
12362
12440
  }
12441
+ compareSchema(baseProperties[name], currentProperties[name], baseDocument, currentDocument, `${path}.properties.${name}`, direction, changes, seen);
12363
12442
  }
12364
12443
  }
12365
- const modules = graph.modules.filter((module) => selected.has(module.name));
12366
- const queryDocuments = graph.graphql?.documents.filter((file) => modules.some((module) => {
12367
- const path = relative11(dirname8(module.file), file);
12368
- return path !== ".." && !path.startsWith(`..${sep10}`);
12369
- })) ?? [];
12370
- const graphql = graph.graphql ? {
12371
- ...graph.graphql,
12372
- documents: queryDocuments,
12373
- operations: graph.graphql.operations.filter((operation) => queryDocuments.includes(operation.file))
12374
- } : undefined;
12375
- const files = [...new Set(modules.flatMap((module) => [
12376
- module.file,
12377
- ...module.providers.map((provider) => provider.file),
12378
- ...module.controllers.map((controller) => controller.file),
12379
- ...allAspects(module).flatMap((aspect) => aspect.file ? [aspect.file] : [])
12380
- ]).concat(graphql ? [graphql.schema, ...queryDocuments] : []))].sort();
12381
- const referencedTokens = new Set;
12382
- for (const module of modules) {
12383
- for (const provider of module.providers) {
12384
- for (const token of provider.deps)
12385
- referencedTokens.add(token);
12444
+ const baseRequired = schemaRequired(baseSchema);
12445
+ const currentRequired = schemaRequired(currentSchema);
12446
+ if (direction === "input") {
12447
+ for (const name of currentRequired) {
12448
+ if (!baseRequired.has(name)) {
12449
+ addChange(changes, {
12450
+ kind: "breaking",
12451
+ code: "request-property-required",
12452
+ path: `${path}.required`,
12453
+ message: `Request property '${name}' became required.`
12454
+ });
12455
+ }
12386
12456
  }
12387
- for (const controller of module.controllers) {
12388
- for (const token of controller.deps)
12389
- referencedTokens.add(token);
12457
+ if (baseSchema.additionalProperties !== false && currentSchema.additionalProperties === false) {
12458
+ addChange(changes, {
12459
+ kind: "breaking",
12460
+ code: "request-additional-properties-rejected",
12461
+ path,
12462
+ message: "The request schema now rejects additional properties."
12463
+ });
12464
+ }
12465
+ } else {
12466
+ for (const name of baseRequired) {
12467
+ if (!currentRequired.has(name)) {
12468
+ addChange(changes, {
12469
+ kind: "breaking",
12470
+ code: "response-property-optional",
12471
+ path: `${path}.required`,
12472
+ message: `Response property '${name}' is no longer guaranteed.`
12473
+ });
12474
+ }
12390
12475
  }
12391
12476
  }
12392
- return {
12393
- version: 1,
12394
- subject: subjectModule.name,
12395
- modules,
12396
- files,
12397
- externalTokens: graph.externalTokens.filter((token) => referencedTokens.has(token)),
12398
- executionPlans: createExecutionPlans({ ...graph, modules }),
12399
- routeContracts: inspectRouteContracts({ ...graph, modules }),
12400
- ...graphql ? { graphql } : {},
12401
- diagnostics: (graph.diagnostics ?? []).filter((diagnostic) => diagnostic.file === undefined || files.includes(diagnostic.file)),
12402
- relatedModules: {
12403
- imports: subjectModule.imports.filter((name) => selected.has(name)),
12404
- importedBy: graph.modules.filter((module) => module.imports.includes(subjectModule.name)).map((module) => module.name).sort()
12405
- }
12406
- };
12407
12477
  }
12408
- function allAspects(module) {
12409
- return [
12410
- ...module.aspects ?? [],
12411
- ...module.controllers.flatMap((controller) => controller.routes.flatMap((route) => route.aspects ?? [])),
12412
- ...module.commands.flatMap((command) => command.aspects ?? []),
12413
- ...(module.jobs ?? []).flatMap((job) => job.aspects ?? [])
12414
- ];
12415
- }
12416
- function createExecutionPlans(graph) {
12417
- const aspects = (boundary, refs = []) => refs.map((ref, index) => `${boundary}.aspect[${index}]:${ref.name}`);
12418
- return graph.modules.flatMap((module) => [
12419
- ...module.controllers.flatMap((controller) => controller.routes.map((route) => {
12420
- const command = module.commands.find((item) => item.className === route.command);
12421
- const path = `${controller.path}/${route.path}`.replace(/\/+/g, "/");
12422
- return {
12423
- module: module.name,
12424
- kind: "route",
12425
- name: `${route.method} ${path.length > 1 ? path.replace(/\/+$/, "") : path}`,
12426
- ...command ? { command: command.name } : {},
12427
- stages: [
12428
- ...aspects(`module:${module.name}`, module.aspects),
12429
- ...aspects("route", route.aspects),
12430
- ...aspects("command", command?.aspects),
12431
- ...command ? [
12432
- "commandExecutor",
12433
- "authorize",
12434
- ...command.rpc ? [`rpc:${command.rpc}`] : [
12435
- ...command.idempotency === "required" ? ["idempotency"] : [],
12436
- ...command.transaction === "required" ? ["transaction"] : []
12437
- ]
12438
- ] : [],
12439
- "handler",
12440
- ...command?.audit && !command.rpc ? ["audit"] : []
12441
- ]
12442
- };
12443
- })),
12444
- ...module.commands.map((command) => ({
12445
- module: module.name,
12446
- kind: "command",
12447
- name: command.name,
12448
- command: command.name,
12449
- stages: [
12450
- "authorize",
12451
- ...command.rpc ? [`rpc:${command.rpc}`] : [
12452
- ...command.idempotency === "required" ? ["idempotency"] : [],
12453
- ...command.transaction === "required" ? ["transaction"] : []
12454
- ],
12455
- ...aspects(`module:${module.name}`, module.aspects),
12456
- ...aspects("command", command.aspects),
12457
- "handler",
12458
- ...command.audit && !command.rpc ? ["audit"] : []
12459
- ]
12460
- })),
12461
- ...(module.jobs ?? []).map((job) => ({
12462
- module: module.name,
12463
- kind: "job",
12464
- name: job.name,
12465
- stages: [...aspects(`module:${module.name}`, module.aspects), ...aspects("job", job.aspects), "jobExecutor", "handler"]
12466
- }))
12467
- ]);
12478
+ function parameterKey(value) {
12479
+ const parameter = recordValue(value);
12480
+ const name = stringValue(parameter?.name);
12481
+ const location = stringValue(parameter?.in);
12482
+ return name && location ? `${location}:${name}` : undefined;
12468
12483
  }
12469
- function doctorProject(rootDir, outDir, graph, upToDate, diagnostics = []) {
12470
- const checks = [
12471
- {
12472
- name: "project-root",
12473
- ok: existsSync4(rootDir),
12474
- detail: existsSync4(rootDir) ? rootDir : `missing: ${rootDir}`
12475
- },
12476
- {
12477
- name: "tsconfig",
12478
- ok: existsSync4(join7(rootDir, "tsconfig.json")),
12479
- detail: existsSync4(join7(rootDir, "tsconfig.json")) ? "tsconfig.json found" : "tsconfig.json missing"
12480
- },
12481
- {
12482
- name: "modules",
12483
- ok: graph.modules.length > 0,
12484
- detail: `${graph.modules.length} module(s) discovered`
12485
- },
12486
- {
12487
- name: "generated-artifacts",
12488
- ok: upToDate,
12489
- detail: upToDate ? "application.ts and app.manifest.json are up to date" : "generated artifacts are missing or stale"
12484
+ function operationParameters(pathItem, operation) {
12485
+ const result = new Map;
12486
+ for (const source of [pathItem.parameters, operation.parameters]) {
12487
+ for (const item of arrayValue(source)) {
12488
+ const parameter = recordValue(item);
12489
+ const key = parameterKey(parameter);
12490
+ if (parameter && key)
12491
+ result.set(key, parameter);
12490
12492
  }
12491
- ];
12492
- const allDiagnostics = [...graph.diagnostics ?? [], ...diagnostics];
12493
- return {
12494
- checks,
12495
- diagnostics: allDiagnostics,
12496
- errors: allDiagnostics.filter((diagnostic) => diagnostic.severity === "error").length + checks.filter((check) => !check.ok).length
12497
- };
12498
- }
12499
- function explainModule(graph, module) {
12500
- const dependents = graph.modules.filter((candidate) => candidate.imports.includes(module.name)).map((candidate) => candidate.name);
12501
- return [
12502
- `MODULE ${module.name}`,
12503
- ` file: ${module.file}:${module.line}`,
12504
- ` imports: ${module.imports.length > 0 ? module.imports.join(", ") : "-"}`,
12505
- ` imported by: ${dependents.length > 0 ? dependents.join(", ") : "-"}`,
12506
- ` providers: ${module.providers.length > 0 ? module.providers.map((provider) => provider.token).join(", ") : "-"}`,
12507
- ` controllers: ${module.controllers.length > 0 ? module.controllers.map((controller) => controller.className).join(", ") : "-"}`,
12508
- ` commands: ${module.commands.length > 0 ? module.commands.map((command) => command.name).join(", ") : "-"}`,
12509
- ...createExecutionPlans({ ...graph, modules: [module] }).map((plan) => ` execution ${plan.name}: ${plan.stages.join(" -> ")}`)
12510
- ].join(`
12511
- `);
12512
- }
12513
- function explainProvider(graph, module, provider) {
12514
- const dependents = graph.modules.flatMap((candidate) => [
12515
- ...candidate.providers.filter((item) => item.deps.includes(provider.token)).map((item) => `${candidate.name}.${item.token}`),
12516
- ...candidate.controllers.filter((item) => item.deps.includes(provider.token)).map((item) => `${candidate.name}.${item.className}`)
12517
- ]);
12518
- return [
12519
- `PROVIDER ${provider.token}`,
12520
- ` module: ${module.name}`,
12521
- ` file: ${provider.file}:${provider.line}`,
12522
- ` kind: ${provider.kind}`,
12523
- ` scope: ${provider.scope}`,
12524
- ` exported: ${provider.exported ? "yes" : "no"}`,
12525
- ` deps: ${provider.deps.length > 0 ? provider.deps.join(", ") : "-"}`,
12526
- ` depended on by: ${dependents.length > 0 ? dependents.join(", ") : "-"}`
12527
- ].join(`
12528
- `);
12529
- }
12530
- function findProvider(graph, subject) {
12531
- for (const module of graph.modules) {
12532
- const provider = module.providers.find((candidate) => candidate.token === subject || candidate.useClass === subject || candidate.useFactoryName === subject);
12533
- if (provider)
12534
- return { module, provider };
12535
12493
  }
12536
- return;
12494
+ return result;
12537
12495
  }
12538
- function exportGraphMermaid(graph) {
12539
- const lines = ["graph TD"];
12540
- for (const mod of graph.modules) {
12541
- const safeId = mod.name.replace(/[^a-zA-Z0-9_]/g, "_");
12542
- lines.push(` ${safeId}["${mod.className ?? mod.name}"]`);
12543
- for (const imp of mod.imports) {
12544
- const safeImp = imp.replace(/[^a-zA-Z0-9_]/g, "_");
12545
- lines.push(` ${safeId} --> ${safeImp}`);
12546
- }
12547
- }
12548
- return lines.join(`
12549
- `);
12496
+ function parameterSchema(value) {
12497
+ return value.schema;
12550
12498
  }
12551
- function exportGraphDot(graph) {
12552
- const lines = [
12553
- "digraph ApplicationGraph {",
12554
- " rankdir=LR;",
12555
- ' node [shape=box, fontname="Helvetica"];'
12556
- ];
12557
- for (const mod of graph.modules) {
12558
- lines.push(` "${mod.name}" [label="${mod.className ?? mod.name}"];`);
12559
- for (const imp of mod.imports) {
12560
- lines.push(` "${mod.name}" -> "${imp}";`);
12499
+ function compareParameters(basePathItem, currentPathItem, baseOperation, currentOperation, baseDocument, currentDocument, path, changes) {
12500
+ const baseParameters = operationParameters(basePathItem, baseOperation);
12501
+ const currentParameters = operationParameters(currentPathItem, currentOperation);
12502
+ for (const key of [...baseParameters.keys()].sort()) {
12503
+ const baseParameter = baseParameters.get(key);
12504
+ const currentParameter = currentParameters.get(key);
12505
+ if (!baseParameter || !currentParameter) {
12506
+ addChange(changes, {
12507
+ kind: "breaking",
12508
+ code: "parameter-removed",
12509
+ path: `${path}.parameters.${key}`,
12510
+ message: "A parameter from the previous contract was removed."
12511
+ });
12512
+ continue;
12513
+ }
12514
+ if (currentParameter.required === true && baseParameter.required !== true) {
12515
+ addChange(changes, {
12516
+ kind: "breaking",
12517
+ code: "parameter-required",
12518
+ path: `${path}.parameters.${key}`,
12519
+ message: "An optional parameter became required."
12520
+ });
12521
+ }
12522
+ if (parameterSchema(baseParameter) !== undefined && parameterSchema(currentParameter) !== undefined) {
12523
+ compareSchema(parameterSchema(baseParameter), parameterSchema(currentParameter), baseDocument, currentDocument, `${path}.parameters.${key}.schema`, "input", changes);
12561
12524
  }
12562
12525
  }
12563
- lines.push("}");
12564
- return lines.join(`
12565
- `);
12566
- }
12567
-
12568
- // src/index.ts
12569
- init_generate();
12570
- init_contract_manifest();
12571
-
12572
- // src/openapi-tools.ts
12573
- import { mkdir as mkdir4, readFile as readFile10, rename as rename6, unlink as unlink3, writeFile as writeFile5 } from "node:fs/promises";
12574
- import { dirname as dirname9, resolve as resolve13 } from "node:path";
12575
- import { pathToFileURL } from "node:url";
12576
-
12577
- class OpenApiDocumentError extends Error {
12578
- code = "OPENAPI_DOCUMENT_INVALID";
12579
- constructor() {
12580
- super("OpenAPI document is invalid or could not be loaded.");
12581
- this.name = "OpenApiDocumentError";
12526
+ for (const key of [...currentParameters.keys()].sort()) {
12527
+ if (baseParameters.has(key))
12528
+ continue;
12529
+ const parameter = currentParameters.get(key);
12530
+ if (!parameter)
12531
+ continue;
12532
+ addChange(changes, {
12533
+ kind: parameter.required === true ? "breaking" : "non-breaking",
12534
+ code: parameter.required === true ? "parameter-required" : "parameter-added",
12535
+ path: `${path}.parameters.${key}`,
12536
+ message: parameter.required === true ? "A new required parameter was added." : "An optional parameter was added."
12537
+ });
12582
12538
  }
12583
12539
  }
12584
- var HTTP_METHODS = [
12585
- "get",
12586
- "put",
12587
- "post",
12588
- "delete",
12589
- "options",
12590
- "head",
12591
- "patch",
12592
- "trace"
12593
- ];
12594
- var COMPONENT_GROUPS = [
12595
- "schemas",
12596
- "responses",
12597
- "parameters",
12598
- "requestBodies",
12599
- "headers",
12600
- "securitySchemes"
12601
- ];
12602
- function isRecord(value) {
12603
- return value !== null && typeof value === "object" && !Array.isArray(value);
12604
- }
12605
- function stringValue(value) {
12606
- return typeof value === "string" ? value : undefined;
12607
- }
12608
- function recordValue(value) {
12609
- return isRecord(value) ? value : undefined;
12610
- }
12611
- function arrayValue(value) {
12612
- return Array.isArray(value) ? value : [];
12613
- }
12614
- function sortedKeys(value) {
12615
- return value ? Object.keys(value).sort() : [];
12616
- }
12617
- function sameJson(left, right) {
12618
- return JSON.stringify(left) === JSON.stringify(right);
12619
- }
12620
- function decodeJsonPointerPart(value) {
12621
- return value.replace(/~1/g, "/").replace(/~0/g, "~");
12622
- }
12623
- function resolveSchema(value, document, seen = new Set) {
12624
- const schema = recordValue(value);
12625
- if (!schema)
12626
- return;
12627
- const ref = stringValue(schema.$ref);
12628
- if (!ref || !ref.startsWith("#/components/schemas/"))
12629
- return schema;
12630
- if (seen.has(ref))
12631
- return schema;
12632
- const name = decodeJsonPointerPart(ref.slice("#/components/schemas/".length));
12633
- const components = recordValue(document.components);
12634
- const schemas = recordValue(components?.schemas);
12635
- const target = schemas?.[name];
12636
- if (target === undefined)
12637
- return schema;
12638
- return resolveSchema(target, document, new Set([...seen, ref]));
12639
- }
12640
- function schemaEnum(value) {
12641
- return Array.isArray(value?.enum) ? value.enum : undefined;
12642
- }
12643
- function schemaProperties(value) {
12644
- return recordValue(value?.properties);
12645
- }
12646
- function schemaRequired(value) {
12647
- return new Set(arrayValue(value?.required).filter((item) => typeof item === "string"));
12648
- }
12649
- function schemaType(value) {
12650
- return value?.type;
12651
- }
12652
- function addChange(changes, change) {
12653
- if (changes.some((item) => item.kind === change.kind && item.code === change.code && item.path === change.path && item.message === change.message))
12654
- return;
12655
- changes.push(change);
12656
- }
12657
- function compareSchema(baseValue, currentValue, baseDocument, currentDocument, path, direction, changes, seen = new Set) {
12658
- const baseSchema = resolveSchema(baseValue, baseDocument);
12659
- const currentSchema = resolveSchema(currentValue, currentDocument);
12660
- if (!baseSchema || !currentSchema)
12661
- return;
12662
- const pair = `${path}|${JSON.stringify(baseSchema)}|${JSON.stringify(currentSchema)}`;
12663
- if (seen.has(pair))
12540
+ function compareRequestBody(baseOperation, currentOperation, baseDocument, currentDocument, path, changes) {
12541
+ const baseBody = recordValue(baseOperation.requestBody);
12542
+ const currentBody = recordValue(currentOperation.requestBody);
12543
+ if (!baseBody || !currentBody) {
12544
+ if (baseBody && !currentBody) {
12545
+ addChange(changes, {
12546
+ kind: "breaking",
12547
+ code: "request-body-removed",
12548
+ path: `${path}.requestBody`,
12549
+ message: "A request body from the previous contract was removed."
12550
+ });
12551
+ } else if (!baseBody && currentBody?.required === true) {
12552
+ addChange(changes, {
12553
+ kind: "breaking",
12554
+ code: "request-body-required",
12555
+ path: `${path}.requestBody`,
12556
+ message: "A request body became required."
12557
+ });
12558
+ } else if (!baseBody && currentBody) {
12559
+ addChange(changes, {
12560
+ kind: "non-breaking",
12561
+ code: "request-body-added",
12562
+ path: `${path}.requestBody`,
12563
+ message: "An optional request body was added."
12564
+ });
12565
+ }
12664
12566
  return;
12665
- seen.add(pair);
12666
- if (schemaType(baseSchema) !== undefined && schemaType(currentSchema) !== undefined && !sameJson(schemaType(baseSchema), schemaType(currentSchema))) {
12567
+ }
12568
+ if (currentBody.required === true && baseBody.required !== true) {
12667
12569
  addChange(changes, {
12668
12570
  kind: "breaking",
12669
- code: "schema-type-changed",
12670
- path,
12671
- message: "The schema type changed."
12571
+ code: "request-body-required",
12572
+ path: `${path}.requestBody`,
12573
+ message: "An optional request body became required."
12672
12574
  });
12673
12575
  }
12674
- const baseEnum = schemaEnum(baseSchema);
12675
- const currentEnum = schemaEnum(currentSchema);
12676
- if (baseEnum && currentEnum) {
12677
- for (const value of baseEnum) {
12678
- if (!currentEnum.some((candidate) => sameJson(candidate, value))) {
12679
- addChange(changes, {
12680
- kind: "breaking",
12681
- code: "schema-enum-value-removed",
12682
- path,
12683
- message: "An enum value accepted by the previous contract was removed."
12684
- });
12685
- break;
12686
- }
12576
+ const baseContent = recordValue(baseBody.content);
12577
+ const currentContent = recordValue(currentBody.content);
12578
+ for (const mediaType of sortedKeys(baseContent)) {
12579
+ const baseMedia = recordValue(baseContent?.[mediaType]);
12580
+ const currentMedia = recordValue(currentContent?.[mediaType]);
12581
+ if (!baseMedia || !currentMedia) {
12582
+ addChange(changes, {
12583
+ kind: "breaking",
12584
+ code: "request-media-type-removed",
12585
+ path: `${path}.requestBody.content.${mediaType}`,
12586
+ message: "A request media type from the previous contract was removed."
12587
+ });
12588
+ continue;
12687
12589
  }
12688
- }
12689
- const baseProperties = schemaProperties(baseSchema);
12690
- const currentProperties = schemaProperties(currentSchema);
12691
- if (baseProperties && currentProperties) {
12692
- for (const name of sortedKeys(baseProperties)) {
12693
- if (currentProperties[name] === undefined) {
12694
- const inputRemovalIsBreaking = baseSchema.additionalProperties === false;
12695
- if (direction === "output" || inputRemovalIsBreaking) {
12696
- addChange(changes, {
12697
- kind: "breaking",
12698
- code: direction === "output" ? "response-property-removed" : "request-property-removed",
12699
- path: `${path}.properties.${name}`,
12700
- message: direction === "output" ? "A property returned by the previous contract was removed." : "A request property was removed while additional properties are rejected."
12701
- });
12702
- }
12703
- continue;
12704
- }
12705
- compareSchema(baseProperties[name], currentProperties[name], baseDocument, currentDocument, `${path}.properties.${name}`, direction, changes, seen);
12590
+ if (baseMedia.schema !== undefined && currentMedia.schema !== undefined) {
12591
+ compareSchema(baseMedia.schema, currentMedia.schema, baseDocument, currentDocument, `${path}.requestBody.content.${mediaType}.schema`, "input", changes);
12706
12592
  }
12707
12593
  }
12708
- const baseRequired = schemaRequired(baseSchema);
12709
- const currentRequired = schemaRequired(currentSchema);
12710
- if (direction === "input") {
12711
- for (const name of currentRequired) {
12712
- if (!baseRequired.has(name)) {
12594
+ }
12595
+ function compareResponses(baseOperation, currentOperation, baseDocument, currentDocument, path, changes) {
12596
+ const baseResponses = recordValue(baseOperation.responses);
12597
+ const currentResponses = recordValue(currentOperation.responses);
12598
+ for (const status of sortedKeys(baseResponses)) {
12599
+ const baseResponse = recordValue(baseResponses?.[status]);
12600
+ const currentResponse = recordValue(currentResponses?.[status]);
12601
+ if (!baseResponse || !currentResponse) {
12602
+ addChange(changes, {
12603
+ kind: "breaking",
12604
+ code: "response-removed",
12605
+ path: `${path}.responses.${status}`,
12606
+ message: "A response status from the previous contract was removed."
12607
+ });
12608
+ continue;
12609
+ }
12610
+ const baseContent = recordValue(baseResponse.content);
12611
+ const currentContent = recordValue(currentResponse.content);
12612
+ for (const mediaType of sortedKeys(baseContent)) {
12613
+ const baseMedia = recordValue(baseContent?.[mediaType]);
12614
+ const currentMedia = recordValue(currentContent?.[mediaType]);
12615
+ if (!baseMedia || !currentMedia) {
12713
12616
  addChange(changes, {
12714
12617
  kind: "breaking",
12715
- code: "request-property-required",
12716
- path: `${path}.required`,
12717
- message: `Request property '${name}' became required.`
12618
+ code: "response-media-type-removed",
12619
+ path: `${path}.responses.${status}.content.${mediaType}`,
12620
+ message: "A response media type from the previous contract was removed."
12718
12621
  });
12622
+ continue;
12623
+ }
12624
+ if (baseMedia.schema !== undefined && currentMedia.schema !== undefined) {
12625
+ compareSchema(baseMedia.schema, currentMedia.schema, baseDocument, currentDocument, `${path}.responses.${status}.content.${mediaType}.schema`, "output", changes);
12719
12626
  }
12720
12627
  }
12721
- if (baseSchema.additionalProperties !== false && currentSchema.additionalProperties === false) {
12628
+ }
12629
+ for (const status of sortedKeys(currentResponses)) {
12630
+ if (baseResponses?.[status] !== undefined)
12631
+ continue;
12632
+ addChange(changes, {
12633
+ kind: "non-breaking",
12634
+ code: "response-added",
12635
+ path: `${path}.responses.${status}`,
12636
+ message: "A response status was added."
12637
+ });
12638
+ }
12639
+ }
12640
+ function compareOperation(basePathItem, currentPathItem, baseOperation, currentOperation, baseDocument, currentDocument, path, changes) {
12641
+ compareParameters(basePathItem, currentPathItem, baseOperation, currentOperation, baseDocument, currentDocument, path, changes);
12642
+ compareRequestBody(baseOperation, currentOperation, baseDocument, currentDocument, path, changes);
12643
+ compareResponses(baseOperation, currentOperation, baseDocument, currentDocument, path, changes);
12644
+ const baseSecurity = baseOperation.security;
12645
+ const currentSecurity = currentOperation.security;
12646
+ if (Array.isArray(currentSecurity) && currentSecurity.length > 0 && (!Array.isArray(baseSecurity) || baseSecurity.length === 0)) {
12647
+ addChange(changes, {
12648
+ kind: "breaking",
12649
+ code: "security-requirement-added",
12650
+ path: `${path}.security`,
12651
+ message: "The operation now requires authentication or an additional security scheme."
12652
+ });
12653
+ }
12654
+ }
12655
+ function compareComponents(baseDocument, currentDocument, changes) {
12656
+ const baseComponents = recordValue(baseDocument.components);
12657
+ const currentComponents = recordValue(currentDocument.components);
12658
+ for (const group of COMPONENT_GROUPS) {
12659
+ const baseGroup = recordValue(baseComponents?.[group]);
12660
+ const currentGroup = recordValue(currentComponents?.[group]);
12661
+ for (const name of sortedKeys(baseGroup)) {
12662
+ if (currentGroup?.[name] !== undefined)
12663
+ continue;
12722
12664
  addChange(changes, {
12723
12665
  kind: "breaking",
12724
- code: "request-additional-properties-rejected",
12725
- path,
12726
- message: "The request schema now rejects additional properties."
12666
+ code: "component-removed",
12667
+ path: `components.${group}.${name}`,
12668
+ message: "A reusable component from the previous contract was removed."
12727
12669
  });
12728
12670
  }
12729
- } else {
12730
- for (const name of baseRequired) {
12731
- if (!currentRequired.has(name)) {
12732
- addChange(changes, {
12733
- kind: "breaking",
12734
- code: "response-property-optional",
12735
- path: `${path}.required`,
12736
- message: `Response property '${name}' is no longer guaranteed.`
12737
- });
12738
- }
12671
+ for (const name of sortedKeys(currentGroup)) {
12672
+ if (baseGroup?.[name] !== undefined)
12673
+ continue;
12674
+ addChange(changes, {
12675
+ kind: "non-breaking",
12676
+ code: "component-added",
12677
+ path: `components.${group}.${name}`,
12678
+ message: "A reusable component was added."
12679
+ });
12739
12680
  }
12740
12681
  }
12741
12682
  }
12742
- function parameterKey(value) {
12743
- const parameter = recordValue(value);
12744
- const name = stringValue(parameter?.name);
12745
- const location = stringValue(parameter?.in);
12746
- return name && location ? `${location}:${name}` : undefined;
12683
+ function parseOpenApiDocument(value) {
12684
+ if (!isRecord(value) || typeof value.openapi !== "string" || !/^3\.[0-9]+(?:\.[0-9]+)?$/.test(value.openapi) || !isRecord(value.info) || !isRecord(value.paths)) {
12685
+ throw new OpenApiDocumentError;
12686
+ }
12687
+ return { ...value, openapi: value.openapi, info: value.info, paths: value.paths };
12747
12688
  }
12748
- function operationParameters(pathItem, operation) {
12749
- const result = new Map;
12750
- for (const source of [pathItem.parameters, operation.parameters]) {
12751
- for (const item of arrayValue(source)) {
12752
- const parameter = recordValue(item);
12753
- const key = parameterKey(parameter);
12754
- if (parameter && key)
12755
- result.set(key, parameter);
12756
- }
12689
+ function serializeOpenApiJson(document, space = 2) {
12690
+ const parsed = parseOpenApiDocument(document);
12691
+ if (!Number.isInteger(space) || space < 0 || space > 10)
12692
+ throw new OpenApiDocumentError;
12693
+ const serialized = JSON.stringify(parsed, null, space);
12694
+ if (serialized === undefined)
12695
+ throw new OpenApiDocumentError;
12696
+ return `${serialized}
12697
+ `;
12698
+ }
12699
+ async function readOpenApiJson(path) {
12700
+ try {
12701
+ const value = JSON.parse(await readFile10(resolve13(path), "utf8"));
12702
+ return parseOpenApiDocument(value);
12703
+ } catch (error) {
12704
+ if (error instanceof OpenApiDocumentError)
12705
+ throw error;
12706
+ throw new OpenApiDocumentError;
12757
12707
  }
12758
- return result;
12759
12708
  }
12760
- function parameterSchema(value) {
12761
- return value.schema;
12709
+ async function writeOpenApiJson(document, outputPath, space = 2) {
12710
+ const path = resolve13(outputPath);
12711
+ const content = serializeOpenApiJson(document, space);
12712
+ await mkdir4(dirname8(path), { recursive: true });
12713
+ try {
12714
+ if (await readFile10(path, "utf8") === content)
12715
+ return { path, written: false };
12716
+ } catch {}
12717
+ const temporaryPath = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
12718
+ try {
12719
+ await writeFile5(temporaryPath, content, "utf8");
12720
+ await rename6(temporaryPath, path);
12721
+ } catch (error) {
12722
+ await unlink3(temporaryPath).catch(() => {
12723
+ return;
12724
+ });
12725
+ throw error;
12726
+ }
12727
+ return { path, written: true };
12762
12728
  }
12763
- function compareParameters(basePathItem, currentPathItem, baseOperation, currentOperation, baseDocument, currentDocument, path, changes) {
12764
- const baseParameters = operationParameters(basePathItem, baseOperation);
12765
- const currentParameters = operationParameters(currentPathItem, currentOperation);
12766
- for (const key of [...baseParameters.keys()].sort()) {
12767
- const baseParameter = baseParameters.get(key);
12768
- const currentParameter = currentParameters.get(key);
12769
- if (!baseParameter || !currentParameter) {
12729
+ async function loadGeneratedOpenApiDocument(modulePath) {
12730
+ try {
12731
+ const moduleUrl = pathToFileURL(resolve13(modulePath));
12732
+ moduleUrl.searchParams.set("supacloud-openapi-export", "1");
12733
+ const loaded = await import(moduleUrl.href);
12734
+ if (!isRecord(loaded))
12735
+ throw new OpenApiDocumentError;
12736
+ return parseOpenApiDocument(loaded.OPENAPI_DOCUMENT);
12737
+ } catch (error) {
12738
+ if (error instanceof OpenApiDocumentError)
12739
+ throw error;
12740
+ throw new OpenApiDocumentError;
12741
+ }
12742
+ }
12743
+ async function exportGeneratedOpenApiJson(options) {
12744
+ const document = await loadGeneratedOpenApiDocument(options.modulePath);
12745
+ return writeOpenApiJson(document, options.outputPath, options.space);
12746
+ }
12747
+ function diffOpenApiDocuments(baseValue, currentValue) {
12748
+ const baseDocument = parseOpenApiDocument(baseValue);
12749
+ const currentDocument = parseOpenApiDocument(currentValue);
12750
+ const changes = [];
12751
+ const basePaths = baseDocument.paths;
12752
+ const currentPaths = currentDocument.paths;
12753
+ for (const path of sortedKeys(basePaths)) {
12754
+ const basePathItem = recordValue(basePaths[path]);
12755
+ const currentPathItem = recordValue(currentPaths[path]);
12756
+ if (!basePathItem || !currentPathItem) {
12770
12757
  addChange(changes, {
12771
12758
  kind: "breaking",
12772
- code: "parameter-removed",
12773
- path: `${path}.parameters.${key}`,
12774
- message: "A parameter from the previous contract was removed."
12759
+ code: "path-removed",
12760
+ path: `paths.${path}`,
12761
+ message: "A path from the previous contract was removed."
12775
12762
  });
12776
12763
  continue;
12777
12764
  }
12778
- if (currentParameter.required === true && baseParameter.required !== true) {
12779
- addChange(changes, {
12780
- kind: "breaking",
12781
- code: "parameter-required",
12782
- path: `${path}.parameters.${key}`,
12783
- message: "An optional parameter became required."
12784
- });
12785
- }
12786
- if (parameterSchema(baseParameter) !== undefined && parameterSchema(currentParameter) !== undefined) {
12787
- compareSchema(parameterSchema(baseParameter), parameterSchema(currentParameter), baseDocument, currentDocument, `${path}.parameters.${key}.schema`, "input", changes);
12765
+ for (const method of HTTP_METHODS) {
12766
+ const baseOperation = recordValue(basePathItem[method]);
12767
+ const currentOperation = recordValue(currentPathItem[method]);
12768
+ if (!baseOperation || !currentOperation) {
12769
+ if (baseOperation) {
12770
+ addChange(changes, {
12771
+ kind: "breaking",
12772
+ code: "operation-removed",
12773
+ path: `paths.${path}.${method}`,
12774
+ message: "An operation from the previous contract was removed."
12775
+ });
12776
+ } else if (currentOperation) {
12777
+ addChange(changes, {
12778
+ kind: "non-breaking",
12779
+ code: "operation-added",
12780
+ path: `paths.${path}.${method}`,
12781
+ message: "An operation was added to an existing path."
12782
+ });
12783
+ }
12784
+ continue;
12785
+ }
12786
+ compareOperation(basePathItem, currentPathItem, baseOperation, currentOperation, baseDocument, currentDocument, `paths.${path}.${method}`, changes);
12788
12787
  }
12789
12788
  }
12790
- for (const key of [...currentParameters.keys()].sort()) {
12791
- if (baseParameters.has(key))
12792
- continue;
12793
- const parameter = currentParameters.get(key);
12794
- if (!parameter)
12789
+ for (const path of sortedKeys(currentPaths)) {
12790
+ if (basePaths[path] !== undefined)
12795
12791
  continue;
12796
12792
  addChange(changes, {
12797
- kind: parameter.required === true ? "breaking" : "non-breaking",
12798
- code: parameter.required === true ? "parameter-required" : "parameter-added",
12799
- path: `${path}.parameters.${key}`,
12800
- message: parameter.required === true ? "A new required parameter was added." : "An optional parameter was added."
12793
+ kind: "non-breaking",
12794
+ code: "path-added",
12795
+ path: `paths.${path}`,
12796
+ message: "A path was added."
12801
12797
  });
12802
12798
  }
12799
+ compareComponents(baseDocument, currentDocument, changes);
12800
+ const breaking = changes.filter((change) => change.kind === "breaking");
12801
+ return { ok: breaking.length === 0, breaking, changes };
12803
12802
  }
12804
- function compareRequestBody(baseOperation, currentOperation, baseDocument, currentDocument, path, changes) {
12805
- const baseBody = recordValue(baseOperation.requestBody);
12806
- const currentBody = recordValue(currentOperation.requestBody);
12807
- if (!baseBody || !currentBody) {
12808
- if (baseBody && !currentBody) {
12809
- addChange(changes, {
12810
- kind: "breaking",
12811
- code: "request-body-removed",
12812
- path: `${path}.requestBody`,
12813
- message: "A request body from the previous contract was removed."
12814
- });
12815
- } else if (!baseBody && currentBody?.required === true) {
12816
- addChange(changes, {
12817
- kind: "breaking",
12818
- code: "request-body-required",
12819
- path: `${path}.requestBody`,
12820
- message: "A request body became required."
12803
+ function formatOpenApiDiff(result) {
12804
+ if (result.changes.length === 0)
12805
+ return "OpenAPI diff passed: no contract changes.";
12806
+ return [
12807
+ `OpenAPI diff ${result.ok ? "passed" : "failed"}: ${result.breaking.length} breaking change(s).`,
12808
+ ...result.changes.map((change) => `${change.kind === "breaking" ? "BREAKING" : "NON-BREAKING"} ${change.code} at ${change.path}: ${change.message}`)
12809
+ ].join(`
12810
+ `);
12811
+ }
12812
+
12813
+ // src/migration-assess.ts
12814
+ var STATUS_PRIORITY = {
12815
+ compatible: 0,
12816
+ "needs-review": 1,
12817
+ "not-proven": 2,
12818
+ unsupported: 3,
12819
+ breaking: 4
12820
+ };
12821
+ function isRecord2(value) {
12822
+ return value !== null && typeof value === "object" && !Array.isArray(value);
12823
+ }
12824
+ function arrayLength(value) {
12825
+ return Array.isArray(value) ? value.length : undefined;
12826
+ }
12827
+ async function sha256(path) {
12828
+ return createHash9("sha256").update(await readFile11(path)).digest("hex");
12829
+ }
12830
+ function overallStatus(findings) {
12831
+ return findings.reduce((current, finding) => STATUS_PRIORITY[finding.status] > STATUS_PRIORITY[current] ? finding.status : current, "compatible");
12832
+ }
12833
+ async function assessMigration(options) {
12834
+ const projectDir = resolve14(options.projectDir);
12835
+ const compile = {
12836
+ ...options.compile,
12837
+ rootDir: resolve14(options.compile.rootDir),
12838
+ outDir: resolve14(options.compile.outDir)
12839
+ };
12840
+ const findings = [];
12841
+ const dependencyProblems = await checkMigrationDependencies(projectDir);
12842
+ if (dependencyProblems.length > 0) {
12843
+ findings.push({
12844
+ code: "migration-dependency-tuple-not-proven",
12845
+ status: "not-proven",
12846
+ message: "The installed compiler migration dependency tuple is not the exact tested tuple.",
12847
+ evidence: dependencyProblems.join("; "),
12848
+ remediation: "Install the exact tested package versions before applying a source migration."
12849
+ });
12850
+ }
12851
+ let compiler = {
12852
+ upToDate: false,
12853
+ diagnostics: [],
12854
+ mismatches: []
12855
+ };
12856
+ try {
12857
+ const checked = await checkProject(compile);
12858
+ compiler = {
12859
+ upToDate: checked.upToDate,
12860
+ diagnostics: checked.diagnostics,
12861
+ mismatches: checked.mismatches
12862
+ };
12863
+ const errors = checked.diagnostics.filter((diagnostic) => diagnostic.severity === "error");
12864
+ if (errors.length > 0) {
12865
+ findings.push({
12866
+ code: "compiler-governance-not-proven",
12867
+ status: "not-proven",
12868
+ message: `Compiler governance has ${errors.length} error(s).`,
12869
+ remediation: "Resolve compiler diagnostics without weakening governance, then reassess."
12821
12870
  });
12822
- } else if (!baseBody && currentBody) {
12823
- addChange(changes, {
12824
- kind: "non-breaking",
12825
- code: "request-body-added",
12826
- path: `${path}.requestBody`,
12827
- message: "An optional request body was added."
12871
+ }
12872
+ if (!checked.upToDate) {
12873
+ findings.push({
12874
+ code: "generated-artifact-drift",
12875
+ status: "needs-review",
12876
+ message: "Generated artifacts do not match the current source and compiler configuration.",
12877
+ evidence: checked.mismatches.join("; "),
12878
+ remediation: "Regenerate candidate artifacts in isolation and review the diff before adoption."
12828
12879
  });
12829
12880
  }
12830
- return;
12881
+ } catch {
12882
+ findings.push({
12883
+ code: "compiler-check-unavailable",
12884
+ status: "not-proven",
12885
+ message: "The local compiler check could not complete.",
12886
+ remediation: "Verify the project source, configuration and installed dependencies, then reassess."
12887
+ });
12831
12888
  }
12832
- if (currentBody.required === true && baseBody.required !== true) {
12833
- addChange(changes, {
12834
- kind: "breaking",
12835
- code: "request-body-required",
12836
- path: `${path}.requestBody`,
12837
- message: "An optional request body became required."
12889
+ const contractsManifestPath = join7(compile.outDir, "contracts.manifest.json");
12890
+ let contractsManifest = {
12891
+ path: contractsManifestPath,
12892
+ present: false
12893
+ };
12894
+ try {
12895
+ const parsed = JSON.parse(await readFile11(contractsManifestPath, "utf8"));
12896
+ if (!isRecord2(parsed) || parsed.version !== 1)
12897
+ throw new Error("invalid manifest");
12898
+ contractsManifest = {
12899
+ path: contractsManifestPath,
12900
+ present: true,
12901
+ sha256: await sha256(contractsManifestPath),
12902
+ version: 1,
12903
+ ...arrayLength(parsed.commands) === undefined ? {} : { commands: arrayLength(parsed.commands) },
12904
+ ...arrayLength(parsed.routes) === undefined ? {} : { routes: arrayLength(parsed.routes) },
12905
+ ...arrayLength(parsed.permissions) === undefined ? {} : { permissions: arrayLength(parsed.permissions) }
12906
+ };
12907
+ } catch {
12908
+ findings.push({
12909
+ code: "contract-manifest-not-proven",
12910
+ status: "not-proven",
12911
+ message: "contracts.manifest.json is missing or invalid.",
12912
+ evidence: contractsManifestPath,
12913
+ remediation: "Generate and review the contract manifest before migration."
12838
12914
  });
12839
12915
  }
12840
- const baseContent = recordValue(baseBody.content);
12841
- const currentContent = recordValue(currentBody.content);
12842
- for (const mediaType of sortedKeys(baseContent)) {
12843
- const baseMedia = recordValue(baseContent?.[mediaType]);
12844
- const currentMedia = recordValue(currentContent?.[mediaType]);
12845
- if (!baseMedia || !currentMedia) {
12846
- addChange(changes, {
12847
- kind: "breaking",
12848
- code: "request-media-type-removed",
12849
- path: `${path}.requestBody.content.${mediaType}`,
12850
- message: "A request media type from the previous contract was removed."
12916
+ let openApi;
12917
+ if (options.baselineOpenApiPath && options.currentOpenApiPath) {
12918
+ const baselinePath = resolve14(projectDir, options.baselineOpenApiPath);
12919
+ const currentPath = resolve14(projectDir, options.currentOpenApiPath);
12920
+ try {
12921
+ const diff = diffOpenApiDocuments(await readOpenApiJson(baselinePath), await readOpenApiJson(currentPath));
12922
+ openApi = {
12923
+ baselinePath,
12924
+ baselineSha256: await sha256(baselinePath),
12925
+ currentPath,
12926
+ currentSha256: await sha256(currentPath),
12927
+ diff
12928
+ };
12929
+ if (!diff.ok) {
12930
+ findings.push({
12931
+ code: "openapi-breaking-change",
12932
+ status: "breaking",
12933
+ message: `OpenAPI contains ${diff.breaking.length} breaking change(s).`,
12934
+ remediation: "Review and explicitly approve or redesign each breaking contract change."
12935
+ });
12936
+ } else if (diff.changes.length > 0) {
12937
+ findings.push({
12938
+ code: "openapi-compatible-change-review",
12939
+ status: "needs-review",
12940
+ message: `OpenAPI contains ${diff.changes.length} non-breaking change(s) requiring release review.`
12941
+ });
12942
+ }
12943
+ } catch {
12944
+ findings.push({
12945
+ code: "openapi-diff-not-proven",
12946
+ status: "not-proven",
12947
+ message: "The OpenAPI baseline or current document could not be loaded.",
12948
+ remediation: "Export both OpenAPI documents as JSON and rerun the assessment."
12851
12949
  });
12852
- continue;
12853
- }
12854
- if (baseMedia.schema !== undefined && currentMedia.schema !== undefined) {
12855
- compareSchema(baseMedia.schema, currentMedia.schema, baseDocument, currentDocument, `${path}.requestBody.content.${mediaType}.schema`, "input", changes);
12856
12950
  }
12951
+ } else {
12952
+ findings.push({
12953
+ code: "openapi-baseline-not-proven",
12954
+ status: "not-proven",
12955
+ message: "No complete OpenAPI baseline/current pair was supplied.",
12956
+ remediation: "Provide both --baseline-openapi and --current-openapi for compatibility evidence."
12957
+ });
12857
12958
  }
12959
+ findings.push({
12960
+ code: "rendering-mode-preserved",
12961
+ status: "compatible",
12962
+ message: "SupaCloud migration does not require SSR and does not change the existing rendering topology.",
12963
+ evidence: `selected=${options.renderMode ?? "unspecified"}`
12964
+ });
12965
+ const status = overallStatus(findings);
12966
+ return {
12967
+ version: 1,
12968
+ ok: status === "compatible" || status === "needs-review",
12969
+ status,
12970
+ readOnly: true,
12971
+ writesPerformed: false,
12972
+ rendering: {
12973
+ selected: options.renderMode ?? "unspecified",
12974
+ ssrRequired: false,
12975
+ supportedModes: ["browser", "ssr", "edge", "trusted-server"],
12976
+ guidance: "Keep the application's existing CSR, SPA, SSR or edge topology; use trusted adapters only for governed server operations."
12977
+ },
12978
+ dependencies: {
12979
+ expected: migrationDependencies(),
12980
+ problems: dependencyProblems
12981
+ },
12982
+ compiler,
12983
+ artifacts: {
12984
+ contractsManifest,
12985
+ ...openApi === undefined ? {} : { openApi }
12986
+ },
12987
+ findings
12988
+ };
12858
12989
  }
12859
- function compareResponses(baseOperation, currentOperation, baseDocument, currentDocument, path, changes) {
12860
- const baseResponses = recordValue(baseOperation.responses);
12861
- const currentResponses = recordValue(currentOperation.responses);
12862
- for (const status of sortedKeys(baseResponses)) {
12863
- const baseResponse = recordValue(baseResponses?.[status]);
12864
- const currentResponse = recordValue(currentResponses?.[status]);
12865
- if (!baseResponse || !currentResponse) {
12866
- addChange(changes, {
12867
- kind: "breaking",
12868
- code: "response-removed",
12869
- path: `${path}.responses.${status}`,
12870
- message: "A response status from the previous contract was removed."
12871
- });
12872
- continue;
12990
+ function formatMigrationAssessment(result) {
12991
+ return [
12992
+ `Migration assessment: ${result.status}. Read-only; no files, databases or remote environments were changed.`,
12993
+ `Rendering: ${result.rendering.selected}; SSR required: no.`,
12994
+ ...result.findings.map((finding) => `${finding.status.toUpperCase()} ${finding.code}: ${finding.message}`)
12995
+ ].join(`
12996
+ `);
12997
+ }
12998
+ // src/inspect.ts
12999
+ import { existsSync as existsSync4 } from "node:fs";
13000
+ import { dirname as dirname9, join as join8, relative as relative11, sep as sep10 } from "node:path";
13001
+ function formatGraph(graph) {
13002
+ const lines = [];
13003
+ for (const module of graph.modules) {
13004
+ lines.push(`MODULE ${module.name}`);
13005
+ lines.push(` file: ${module.file}:${module.line}`);
13006
+ lines.push(` imports: ${module.imports.length > 0 ? module.imports.join(", ") : "-"}`);
13007
+ lines.push(` providers: ${module.providers.length > 0 ? module.providers.map((p) => p.token).join(", ") : "-"}`);
13008
+ lines.push(` controllers: ${module.controllers.length > 0 ? module.controllers.map((c) => c.className).join(", ") : "-"}`);
13009
+ lines.push(` commands: ${module.commands.length > 0 ? module.commands.map((c) => c.name).join(", ") : "-"}`);
13010
+ }
13011
+ lines.push(`EXTERNAL TOKENS ${graph.externalTokens.length > 0 ? graph.externalTokens.join(", ") : "-"}`);
13012
+ return lines.join(`
13013
+ `);
13014
+ }
13015
+ function explainGraph(graph, subject) {
13016
+ const module = graph.modules.find((candidate) => candidate.name === subject);
13017
+ if (module)
13018
+ return explainModule(graph, module);
13019
+ const provider = findProvider(graph, subject);
13020
+ if (provider)
13021
+ return explainProvider(graph, provider.module, provider.provider);
13022
+ if (graph.externalTokens.includes(subject)) {
13023
+ const references = graph.modules.flatMap((candidate) => [
13024
+ ...candidate.providers.filter((item) => item.deps.includes(subject)).map((item) => `${candidate.name}.${item.token}`),
13025
+ ...candidate.controllers.filter((item) => item.deps.includes(subject)).map((item) => `${candidate.name}.${item.className}`)
13026
+ ]);
13027
+ return [
13028
+ `EXTERNAL TOKEN ${subject}`,
13029
+ " provided by: platform runtime",
13030
+ ` references: ${references.length > 0 ? references.join(", ") : "-"}`
13031
+ ].join(`
13032
+ `);
13033
+ }
13034
+ const known = [...graph.modules.map((item) => item.name), ...graph.externalTokens].sort();
13035
+ throw new Error(`No module, provider, or external token named "${subject}". Known names: ${known.join(", ") || "(none)"}`);
13036
+ }
13037
+ function createContextPack(graph, subject) {
13038
+ const subjectModule = graph.modules.find((module) => module.name === subject);
13039
+ if (!subjectModule) {
13040
+ throw new Error(`No module named "${subject}". Context packs require a module name.`);
13041
+ }
13042
+ const byName = new Map(graph.modules.map((module) => [module.name, module]));
13043
+ const selected = new Set([subjectModule.name]);
13044
+ const reverseDependents = new Map;
13045
+ for (const mod of graph.modules) {
13046
+ for (const imp of mod.imports) {
13047
+ const list = reverseDependents.get(imp);
13048
+ if (list)
13049
+ list.push(mod.name);
13050
+ else
13051
+ reverseDependents.set(imp, [mod.name]);
12873
13052
  }
12874
- const baseContent = recordValue(baseResponse.content);
12875
- const currentContent = recordValue(currentResponse.content);
12876
- for (const mediaType of sortedKeys(baseContent)) {
12877
- const baseMedia = recordValue(baseContent?.[mediaType]);
12878
- const currentMedia = recordValue(currentContent?.[mediaType]);
12879
- if (!baseMedia || !currentMedia) {
12880
- addChange(changes, {
12881
- kind: "breaking",
12882
- code: "response-media-type-removed",
12883
- path: `${path}.responses.${status}.content.${mediaType}`,
12884
- message: "A response media type from the previous contract was removed."
12885
- });
13053
+ }
13054
+ for (const direction of ["imports", "dependents"]) {
13055
+ const visited = new Set([subjectModule.name]);
13056
+ const queue = [subjectModule.name];
13057
+ let head = 0;
13058
+ while (head < queue.length) {
13059
+ const current = queue[head++];
13060
+ if (!current)
12886
13061
  continue;
12887
- }
12888
- if (baseMedia.schema !== undefined && currentMedia.schema !== undefined) {
12889
- compareSchema(baseMedia.schema, currentMedia.schema, baseDocument, currentDocument, `${path}.responses.${status}.content.${mediaType}.schema`, "output", changes);
13062
+ const module = byName.get(current);
13063
+ if (!module)
13064
+ continue;
13065
+ const neighbors = direction === "imports" ? module.imports : reverseDependents.get(module.name) ?? [];
13066
+ for (const neighbor of neighbors) {
13067
+ if (!visited.has(neighbor) && byName.has(neighbor)) {
13068
+ visited.add(neighbor);
13069
+ selected.add(neighbor);
13070
+ queue.push(neighbor);
13071
+ }
12890
13072
  }
12891
13073
  }
12892
13074
  }
12893
- for (const status of sortedKeys(currentResponses)) {
12894
- if (baseResponses?.[status] !== undefined)
12895
- continue;
12896
- addChange(changes, {
12897
- kind: "non-breaking",
12898
- code: "response-added",
12899
- path: `${path}.responses.${status}`,
12900
- message: "A response status was added."
12901
- });
13075
+ const modules = graph.modules.filter((module) => selected.has(module.name));
13076
+ const queryDocuments = graph.graphql?.documents.filter((file) => modules.some((module) => {
13077
+ const path = relative11(dirname9(module.file), file);
13078
+ return path !== ".." && !path.startsWith(`..${sep10}`);
13079
+ })) ?? [];
13080
+ const graphql = graph.graphql ? {
13081
+ ...graph.graphql,
13082
+ documents: queryDocuments,
13083
+ operations: graph.graphql.operations.filter((operation) => queryDocuments.includes(operation.file))
13084
+ } : undefined;
13085
+ const files = [...new Set(modules.flatMap((module) => [
13086
+ module.file,
13087
+ ...module.providers.map((provider) => provider.file),
13088
+ ...module.controllers.map((controller) => controller.file),
13089
+ ...allAspects(module).flatMap((aspect) => aspect.file ? [aspect.file] : [])
13090
+ ]).concat(graphql ? [graphql.schema, ...queryDocuments] : []))].sort();
13091
+ const referencedTokens = new Set;
13092
+ for (const module of modules) {
13093
+ for (const provider of module.providers) {
13094
+ for (const token of provider.deps)
13095
+ referencedTokens.add(token);
13096
+ }
13097
+ for (const controller of module.controllers) {
13098
+ for (const token of controller.deps)
13099
+ referencedTokens.add(token);
13100
+ }
12902
13101
  }
13102
+ return {
13103
+ version: 1,
13104
+ subject: subjectModule.name,
13105
+ modules,
13106
+ files,
13107
+ externalTokens: graph.externalTokens.filter((token) => referencedTokens.has(token)),
13108
+ executionPlans: createExecutionPlans({ ...graph, modules }),
13109
+ routeContracts: inspectRouteContracts({ ...graph, modules }),
13110
+ ...graphql ? { graphql } : {},
13111
+ diagnostics: (graph.diagnostics ?? []).filter((diagnostic) => diagnostic.file === undefined || files.includes(diagnostic.file)),
13112
+ relatedModules: {
13113
+ imports: subjectModule.imports.filter((name) => selected.has(name)),
13114
+ importedBy: graph.modules.filter((module) => module.imports.includes(subjectModule.name)).map((module) => module.name).sort()
13115
+ }
13116
+ };
12903
13117
  }
12904
- function compareOperation(basePathItem, currentPathItem, baseOperation, currentOperation, baseDocument, currentDocument, path, changes) {
12905
- compareParameters(basePathItem, currentPathItem, baseOperation, currentOperation, baseDocument, currentDocument, path, changes);
12906
- compareRequestBody(baseOperation, currentOperation, baseDocument, currentDocument, path, changes);
12907
- compareResponses(baseOperation, currentOperation, baseDocument, currentDocument, path, changes);
12908
- const baseSecurity = baseOperation.security;
12909
- const currentSecurity = currentOperation.security;
12910
- if (Array.isArray(currentSecurity) && currentSecurity.length > 0 && (!Array.isArray(baseSecurity) || baseSecurity.length === 0)) {
12911
- addChange(changes, {
12912
- kind: "breaking",
12913
- code: "security-requirement-added",
12914
- path: `${path}.security`,
12915
- message: "The operation now requires authentication or an additional security scheme."
12916
- });
12917
- }
13118
+ function allAspects(module) {
13119
+ return [
13120
+ ...module.aspects ?? [],
13121
+ ...module.controllers.flatMap((controller) => controller.routes.flatMap((route) => route.aspects ?? [])),
13122
+ ...module.commands.flatMap((command) => command.aspects ?? []),
13123
+ ...(module.jobs ?? []).flatMap((job) => job.aspects ?? [])
13124
+ ];
13125
+ }
13126
+ function createExecutionPlans(graph) {
13127
+ const aspects = (boundary, refs = []) => refs.map((ref, index) => `${boundary}.aspect[${index}]:${ref.name}`);
13128
+ return graph.modules.flatMap((module) => [
13129
+ ...module.controllers.flatMap((controller) => controller.routes.map((route) => {
13130
+ const command = module.commands.find((item) => item.className === route.command);
13131
+ const path = `${controller.path}/${route.path}`.replace(/\/+/g, "/");
13132
+ return {
13133
+ module: module.name,
13134
+ kind: "route",
13135
+ name: `${route.method} ${path.length > 1 ? path.replace(/\/+$/, "") : path}`,
13136
+ ...command ? { command: command.name } : {},
13137
+ stages: [
13138
+ ...aspects(`module:${module.name}`, module.aspects),
13139
+ ...aspects("route", route.aspects),
13140
+ ...aspects("command", command?.aspects),
13141
+ ...command ? [
13142
+ "commandExecutor",
13143
+ "authorize",
13144
+ ...command.rpc ? [`rpc:${command.rpc}`] : [
13145
+ ...command.idempotency === "required" ? ["idempotency"] : [],
13146
+ ...command.transaction === "required" ? ["transaction"] : []
13147
+ ]
13148
+ ] : [],
13149
+ "handler",
13150
+ ...command?.audit && !command.rpc ? ["audit"] : []
13151
+ ]
13152
+ };
13153
+ })),
13154
+ ...module.commands.map((command) => ({
13155
+ module: module.name,
13156
+ kind: "command",
13157
+ name: command.name,
13158
+ command: command.name,
13159
+ stages: [
13160
+ "authorize",
13161
+ ...command.rpc ? [`rpc:${command.rpc}`] : [
13162
+ ...command.idempotency === "required" ? ["idempotency"] : [],
13163
+ ...command.transaction === "required" ? ["transaction"] : []
13164
+ ],
13165
+ ...aspects(`module:${module.name}`, module.aspects),
13166
+ ...aspects("command", command.aspects),
13167
+ "handler",
13168
+ ...command.audit && !command.rpc ? ["audit"] : []
13169
+ ]
13170
+ })),
13171
+ ...(module.jobs ?? []).map((job) => ({
13172
+ module: module.name,
13173
+ kind: "job",
13174
+ name: job.name,
13175
+ stages: [...aspects(`module:${module.name}`, module.aspects), ...aspects("job", job.aspects), "jobExecutor", "handler"]
13176
+ }))
13177
+ ]);
12918
13178
  }
12919
- function compareComponents(baseDocument, currentDocument, changes) {
12920
- const baseComponents = recordValue(baseDocument.components);
12921
- const currentComponents = recordValue(currentDocument.components);
12922
- for (const group of COMPONENT_GROUPS) {
12923
- const baseGroup = recordValue(baseComponents?.[group]);
12924
- const currentGroup = recordValue(currentComponents?.[group]);
12925
- for (const name of sortedKeys(baseGroup)) {
12926
- if (currentGroup?.[name] !== undefined)
12927
- continue;
12928
- addChange(changes, {
12929
- kind: "breaking",
12930
- code: "component-removed",
12931
- path: `components.${group}.${name}`,
12932
- message: "A reusable component from the previous contract was removed."
12933
- });
12934
- }
12935
- for (const name of sortedKeys(currentGroup)) {
12936
- if (baseGroup?.[name] !== undefined)
12937
- continue;
12938
- addChange(changes, {
12939
- kind: "non-breaking",
12940
- code: "component-added",
12941
- path: `components.${group}.${name}`,
12942
- message: "A reusable component was added."
12943
- });
13179
+ function doctorProject(rootDir, outDir, graph, upToDate, diagnostics = []) {
13180
+ const checks = [
13181
+ {
13182
+ name: "project-root",
13183
+ ok: existsSync4(rootDir),
13184
+ detail: existsSync4(rootDir) ? rootDir : `missing: ${rootDir}`
13185
+ },
13186
+ {
13187
+ name: "tsconfig",
13188
+ ok: existsSync4(join8(rootDir, "tsconfig.json")),
13189
+ detail: existsSync4(join8(rootDir, "tsconfig.json")) ? "tsconfig.json found" : "tsconfig.json missing"
13190
+ },
13191
+ {
13192
+ name: "modules",
13193
+ ok: graph.modules.length > 0,
13194
+ detail: `${graph.modules.length} module(s) discovered`
13195
+ },
13196
+ {
13197
+ name: "generated-artifacts",
13198
+ ok: upToDate,
13199
+ detail: upToDate ? "application.ts and app.manifest.json are up to date" : "generated artifacts are missing or stale"
12944
13200
  }
12945
- }
12946
- }
12947
- function parseOpenApiDocument(value) {
12948
- if (!isRecord(value) || typeof value.openapi !== "string" || !/^3\.[0-9]+(?:\.[0-9]+)?$/.test(value.openapi) || !isRecord(value.info) || !isRecord(value.paths)) {
12949
- throw new OpenApiDocumentError;
12950
- }
12951
- return { ...value, openapi: value.openapi, info: value.info, paths: value.paths };
13201
+ ];
13202
+ const allDiagnostics = [...graph.diagnostics ?? [], ...diagnostics];
13203
+ return {
13204
+ checks,
13205
+ diagnostics: allDiagnostics,
13206
+ errors: allDiagnostics.filter((diagnostic) => diagnostic.severity === "error").length + checks.filter((check) => !check.ok).length
13207
+ };
12952
13208
  }
12953
- function serializeOpenApiJson(document, space = 2) {
12954
- const parsed = parseOpenApiDocument(document);
12955
- if (!Number.isInteger(space) || space < 0 || space > 10)
12956
- throw new OpenApiDocumentError;
12957
- const serialized = JSON.stringify(parsed, null, space);
12958
- if (serialized === undefined)
12959
- throw new OpenApiDocumentError;
12960
- return `${serialized}
12961
- `;
13209
+ function explainModule(graph, module) {
13210
+ const dependents = graph.modules.filter((candidate) => candidate.imports.includes(module.name)).map((candidate) => candidate.name);
13211
+ return [
13212
+ `MODULE ${module.name}`,
13213
+ ` file: ${module.file}:${module.line}`,
13214
+ ` imports: ${module.imports.length > 0 ? module.imports.join(", ") : "-"}`,
13215
+ ` imported by: ${dependents.length > 0 ? dependents.join(", ") : "-"}`,
13216
+ ` providers: ${module.providers.length > 0 ? module.providers.map((provider) => provider.token).join(", ") : "-"}`,
13217
+ ` controllers: ${module.controllers.length > 0 ? module.controllers.map((controller) => controller.className).join(", ") : "-"}`,
13218
+ ` commands: ${module.commands.length > 0 ? module.commands.map((command) => command.name).join(", ") : "-"}`,
13219
+ ...createExecutionPlans({ ...graph, modules: [module] }).map((plan) => ` execution ${plan.name}: ${plan.stages.join(" -> ")}`)
13220
+ ].join(`
13221
+ `);
12962
13222
  }
12963
- async function readOpenApiJson(path) {
12964
- try {
12965
- const value = JSON.parse(await readFile10(resolve13(path), "utf8"));
12966
- return parseOpenApiDocument(value);
12967
- } catch (error) {
12968
- if (error instanceof OpenApiDocumentError)
12969
- throw error;
12970
- throw new OpenApiDocumentError;
12971
- }
13223
+ function explainProvider(graph, module, provider) {
13224
+ const dependents = graph.modules.flatMap((candidate) => [
13225
+ ...candidate.providers.filter((item) => item.deps.includes(provider.token)).map((item) => `${candidate.name}.${item.token}`),
13226
+ ...candidate.controllers.filter((item) => item.deps.includes(provider.token)).map((item) => `${candidate.name}.${item.className}`)
13227
+ ]);
13228
+ return [
13229
+ `PROVIDER ${provider.token}`,
13230
+ ` module: ${module.name}`,
13231
+ ` file: ${provider.file}:${provider.line}`,
13232
+ ` kind: ${provider.kind}`,
13233
+ ` scope: ${provider.scope}`,
13234
+ ` exported: ${provider.exported ? "yes" : "no"}`,
13235
+ ` deps: ${provider.deps.length > 0 ? provider.deps.join(", ") : "-"}`,
13236
+ ` depended on by: ${dependents.length > 0 ? dependents.join(", ") : "-"}`
13237
+ ].join(`
13238
+ `);
12972
13239
  }
12973
- async function writeOpenApiJson(document, outputPath, space = 2) {
12974
- const path = resolve13(outputPath);
12975
- const content = serializeOpenApiJson(document, space);
12976
- await mkdir4(dirname9(path), { recursive: true });
12977
- try {
12978
- if (await readFile10(path, "utf8") === content)
12979
- return { path, written: false };
12980
- } catch {}
12981
- const temporaryPath = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
12982
- try {
12983
- await writeFile5(temporaryPath, content, "utf8");
12984
- await rename6(temporaryPath, path);
12985
- } catch (error) {
12986
- await unlink3(temporaryPath).catch(() => {
12987
- return;
12988
- });
12989
- throw error;
13240
+ function findProvider(graph, subject) {
13241
+ for (const module of graph.modules) {
13242
+ const provider = module.providers.find((candidate) => candidate.token === subject || candidate.useClass === subject || candidate.useFactoryName === subject);
13243
+ if (provider)
13244
+ return { module, provider };
12990
13245
  }
12991
- return { path, written: true };
13246
+ return;
12992
13247
  }
12993
- async function loadGeneratedOpenApiDocument(modulePath) {
12994
- try {
12995
- const moduleUrl = pathToFileURL(resolve13(modulePath));
12996
- moduleUrl.searchParams.set("supacloud-openapi-export", "1");
12997
- const loaded = await import(moduleUrl.href);
12998
- if (!isRecord(loaded))
12999
- throw new OpenApiDocumentError;
13000
- return parseOpenApiDocument(loaded.OPENAPI_DOCUMENT);
13001
- } catch (error) {
13002
- if (error instanceof OpenApiDocumentError)
13003
- throw error;
13004
- throw new OpenApiDocumentError;
13248
+ function exportGraphMermaid(graph) {
13249
+ const lines = ["graph TD"];
13250
+ for (const mod of graph.modules) {
13251
+ const safeId = mod.name.replace(/[^a-zA-Z0-9_]/g, "_");
13252
+ lines.push(` ${safeId}["${mod.className ?? mod.name}"]`);
13253
+ for (const imp of mod.imports) {
13254
+ const safeImp = imp.replace(/[^a-zA-Z0-9_]/g, "_");
13255
+ lines.push(` ${safeId} --> ${safeImp}`);
13256
+ }
13005
13257
  }
13258
+ return lines.join(`
13259
+ `);
13006
13260
  }
13007
- async function exportGeneratedOpenApiJson(options) {
13008
- const document = await loadGeneratedOpenApiDocument(options.modulePath);
13009
- return writeOpenApiJson(document, options.outputPath, options.space);
13010
- }
13011
- function diffOpenApiDocuments(baseValue, currentValue) {
13012
- const baseDocument = parseOpenApiDocument(baseValue);
13013
- const currentDocument = parseOpenApiDocument(currentValue);
13014
- const changes = [];
13015
- const basePaths = baseDocument.paths;
13016
- const currentPaths = currentDocument.paths;
13017
- for (const path of sortedKeys(basePaths)) {
13018
- const basePathItem = recordValue(basePaths[path]);
13019
- const currentPathItem = recordValue(currentPaths[path]);
13020
- if (!basePathItem || !currentPathItem) {
13021
- addChange(changes, {
13022
- kind: "breaking",
13023
- code: "path-removed",
13024
- path: `paths.${path}`,
13025
- message: "A path from the previous contract was removed."
13026
- });
13027
- continue;
13028
- }
13029
- for (const method of HTTP_METHODS) {
13030
- const baseOperation = recordValue(basePathItem[method]);
13031
- const currentOperation = recordValue(currentPathItem[method]);
13032
- if (!baseOperation || !currentOperation) {
13033
- if (baseOperation) {
13034
- addChange(changes, {
13035
- kind: "breaking",
13036
- code: "operation-removed",
13037
- path: `paths.${path}.${method}`,
13038
- message: "An operation from the previous contract was removed."
13039
- });
13040
- } else if (currentOperation) {
13041
- addChange(changes, {
13042
- kind: "non-breaking",
13043
- code: "operation-added",
13044
- path: `paths.${path}.${method}`,
13045
- message: "An operation was added to an existing path."
13046
- });
13047
- }
13048
- continue;
13049
- }
13050
- compareOperation(basePathItem, currentPathItem, baseOperation, currentOperation, baseDocument, currentDocument, `paths.${path}.${method}`, changes);
13261
+ function exportGraphDot(graph) {
13262
+ const lines = [
13263
+ "digraph ApplicationGraph {",
13264
+ " rankdir=LR;",
13265
+ ' node [shape=box, fontname="Helvetica"];'
13266
+ ];
13267
+ for (const mod of graph.modules) {
13268
+ lines.push(` "${mod.name}" [label="${mod.className ?? mod.name}"];`);
13269
+ for (const imp of mod.imports) {
13270
+ lines.push(` "${mod.name}" -> "${imp}";`);
13051
13271
  }
13052
13272
  }
13053
- for (const path of sortedKeys(currentPaths)) {
13054
- if (basePaths[path] !== undefined)
13055
- continue;
13056
- addChange(changes, {
13057
- kind: "non-breaking",
13058
- code: "path-added",
13059
- path: `paths.${path}`,
13060
- message: "A path was added."
13061
- });
13062
- }
13063
- compareComponents(baseDocument, currentDocument, changes);
13064
- const breaking = changes.filter((change) => change.kind === "breaking");
13065
- return { ok: breaking.length === 0, breaking, changes };
13066
- }
13067
- function formatOpenApiDiff(result) {
13068
- if (result.changes.length === 0)
13069
- return "OpenAPI diff passed: no contract changes.";
13070
- return [
13071
- `OpenAPI diff ${result.ok ? "passed" : "failed"}: ${result.breaking.length} breaking change(s).`,
13072
- ...result.changes.map((change) => `${change.kind === "breaking" ? "BREAKING" : "NON-BREAKING"} ${change.code} at ${change.path}: ${change.message}`)
13073
- ].join(`
13273
+ lines.push("}");
13274
+ return lines.join(`
13074
13275
  `);
13075
13276
  }
13277
+
13278
+ // src/index.ts
13279
+ init_generate();
13280
+ init_contract_manifest();
13281
+
13076
13282
  // src/config.ts
13077
13283
  init_graphql_options();
13078
13284
  import { existsSync as existsSync5 } from "node:fs";
13079
- import { join as join8, resolve as resolve14 } from "node:path";
13285
+ import { join as join9, resolve as resolve15 } from "node:path";
13080
13286
  import { pathToFileURL as pathToFileURL2 } from "node:url";
13081
13287
  var DEFAULT_SUPACLOUD_CONFIG = {
13082
13288
  graphql: false,
@@ -13169,8 +13375,8 @@ function validateGovernanceConfig(config) {
13169
13375
  function resolveSupacloudConfig(config = {}, cwd = process.cwd()) {
13170
13376
  const resolved = defineSupacloudConfig(config);
13171
13377
  return {
13172
- rootDir: resolve14(cwd, resolved.root ?? DEFAULT_SUPACLOUD_CONFIG.root),
13173
- outDir: resolve14(cwd, resolved.outDir ?? DEFAULT_SUPACLOUD_CONFIG.outDir),
13378
+ rootDir: resolve15(cwd, resolved.root ?? DEFAULT_SUPACLOUD_CONFIG.root),
13379
+ outDir: resolve15(cwd, resolved.outDir ?? DEFAULT_SUPACLOUD_CONFIG.outDir),
13174
13380
  include: resolved.include ?? [...DEFAULT_SUPACLOUD_CONFIG.include],
13175
13381
  strict: resolved.strict ?? DEFAULT_SUPACLOUD_CONFIG.strict,
13176
13382
  requireRouteContracts: resolved.requireRouteContracts ?? DEFAULT_SUPACLOUD_CONFIG.requireRouteContracts,
@@ -13188,16 +13394,16 @@ function resolveSupacloudConfig(config = {}, cwd = process.cwd()) {
13188
13394
  treeShakeUnusedProviders: resolved.treeShakeUnusedProviders ?? DEFAULT_SUPACLOUD_CONFIG.treeShakeUnusedProviders,
13189
13395
  graphql: resolved.graphql ? {
13190
13396
  ...resolved.graphql,
13191
- schema: resolve14(cwd, resolved.graphql.schema)
13397
+ schema: resolve15(cwd, resolved.graphql.schema)
13192
13398
  } : undefined
13193
13399
  };
13194
13400
  }
13195
13401
  async function loadSupacloudConfig(cwd = process.cwd()) {
13196
13402
  const candidates = [
13197
- join8(cwd, "supacloud.config.ts"),
13198
- join8(cwd, "supacloud.config.mts"),
13199
- join8(cwd, "supacloud.config.js"),
13200
- join8(cwd, "supacloud.config.mjs")
13403
+ join9(cwd, "supacloud.config.ts"),
13404
+ join9(cwd, "supacloud.config.mts"),
13405
+ join9(cwd, "supacloud.config.js"),
13406
+ join9(cwd, "supacloud.config.mjs")
13201
13407
  ];
13202
13408
  const configPath = candidates.find((candidate) => existsSync5(candidate));
13203
13409
  if (!configPath)
@@ -13234,6 +13440,7 @@ export {
13234
13440
  TraitCompiler,
13235
13441
  analyzeProject,
13236
13442
  applyDiagnosticFix,
13443
+ assessMigration,
13237
13444
  buildContractManifest,
13238
13445
  buildDeliveryProject,
13239
13446
  camelName,
@@ -13256,6 +13463,7 @@ export {
13256
13463
  exportGraphMermaid,
13257
13464
  formatDeliveryPlan,
13258
13465
  formatGraph,
13466
+ formatMigrationAssessment,
13259
13467
  formatOpenApiDiff,
13260
13468
  generateApplication,
13261
13469
  generateDatabaseContracts,