@kosdev-code/kos-codegen-core 3.0.19 → 3.0.21

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 (67) hide show
  1. package/index.d.ts +2 -2
  2. package/index.d.ts.map +1 -1
  3. package/index.js +1527 -569
  4. package/index.js.map +1 -1
  5. package/index.mjs +1523 -565
  6. package/index.mjs.map +1 -1
  7. package/lib/generators/add-container-support/generate-add-container-support.d.ts +14 -0
  8. package/lib/generators/add-container-support/generate-add-container-support.d.ts.map +1 -1
  9. package/lib/generators/add-future-to-model/normalize-options.d.ts.map +1 -1
  10. package/lib/generators/add-future-to-model/service-transformer.d.ts +1 -3
  11. package/lib/generators/add-future-to-model/service-transformer.d.ts.map +1 -1
  12. package/lib/generators/add-parent-aware/generate-add-parent-aware.d.ts +15 -0
  13. package/lib/generators/add-parent-aware/generate-add-parent-aware.d.ts.map +1 -0
  14. package/lib/generators/add-parent-aware/index.d.ts +2 -0
  15. package/lib/generators/add-parent-aware/index.d.ts.map +1 -0
  16. package/lib/generators/augment/find-model-declaration.d.ts +11 -0
  17. package/lib/generators/augment/find-model-declaration.d.ts.map +1 -0
  18. package/lib/generators/augment/model-mocks.d.ts +47 -0
  19. package/lib/generators/augment/model-mocks.d.ts.map +1 -0
  20. package/lib/generators/augment/model-services.d.ts +11 -0
  21. package/lib/generators/augment/model-services.d.ts.map +1 -0
  22. package/lib/generators/augment/resolve-child-model.d.ts +16 -0
  23. package/lib/generators/augment/resolve-child-model.d.ts.map +1 -0
  24. package/lib/generators/augment/resolve-model-file.d.ts.map +1 -1
  25. package/lib/generators/augment/ts-toolkit.d.ts +61 -1
  26. package/lib/generators/augment/ts-toolkit.d.ts.map +1 -1
  27. package/lib/generators/generate-container-model.d.ts +7 -1
  28. package/lib/generators/generate-container-model.d.ts.map +1 -1
  29. package/lib/generators/generate-init.d.ts.map +1 -1
  30. package/lib/generators/generate-model-project.d.ts +36 -0
  31. package/lib/generators/generate-model-project.d.ts.map +1 -0
  32. package/lib/generators/generate-model.d.ts +0 -1
  33. package/lib/generators/generate-model.d.ts.map +1 -1
  34. package/lib/generators/generate-view-model.d.ts +35 -0
  35. package/lib/generators/generate-view-model.d.ts.map +1 -0
  36. package/lib/generators/index.d.ts +21 -16
  37. package/lib/generators/index.d.ts.map +1 -1
  38. package/lib/generators/member-mutators/add-child.d.ts +10 -2
  39. package/lib/generators/member-mutators/add-child.d.ts.map +1 -1
  40. package/lib/generators/member-mutators/add-service-request.d.ts +80 -2
  41. package/lib/generators/member-mutators/add-service-request.d.ts.map +1 -1
  42. package/lib/generators/member-mutators/index.d.ts +1 -1
  43. package/lib/generators/member-mutators/index.d.ts.map +1 -1
  44. package/lib/generators/registration/upsert-chain-entry.d.ts +13 -0
  45. package/lib/generators/registration/upsert-chain-entry.d.ts.map +1 -0
  46. package/lib/generators/service-catalog.d.ts +12 -0
  47. package/lib/generators/service-catalog.d.ts.map +1 -1
  48. package/package.json +2 -2
  49. package/templates/kos-container-model/model/index.ts.template +0 -2
  50. package/templates/kos-container-model/model/types/index.d.ts.template +2 -2
  51. package/templates/kos-model/model/index.ts__template__ +0 -3
  52. package/templates/kos-model-project/project/.eslintrc.json.template +33 -0
  53. package/templates/kos-model-project/project/.kos.json.template +14 -0
  54. package/templates/kos-model-project/project/README.md.template +7 -0
  55. package/templates/kos-model-project/project/package.json.template +9 -0
  56. package/templates/kos-model-project/project/project.json.template +35 -0
  57. package/templates/kos-model-project/project/src/index.ts.template +1 -0
  58. package/templates/kos-model-project/project/src/lib/__projectName__.ts.template +3 -0
  59. package/templates/kos-model-project/project/tsconfig.json.template +20 -0
  60. package/templates/kos-model-project/project/tsconfig.lib.json.template +10 -0
  61. package/templates/kos-model-project/project/vite.config.ts.template +47 -0
  62. package/templates/kos-view-model/__nameDashCase__-view-model.ts.template +30 -0
  63. package/templates/kos-view-model/index.ts.template +1 -0
  64. package/templates/kos-container-model/services/__modelNameDashCase__-services.ts.template +0 -22
  65. package/templates/kos-container-model/services/index.ts.template +0 -1
  66. package/templates/kos-model/services/__nameDashCase__-services.ts.template +0 -58
  67. package/templates/kos-model/services/index.ts.template +0 -1
package/index.mjs CHANGED
@@ -3,7 +3,7 @@ import * as path from "path";
3
3
  import * as ejs from "ejs";
4
4
  import fg from "fast-glob";
5
5
  import prettier from "prettier";
6
- import { Scope, Project, QuoteKind, IndentationText, VariableDeclarationKind, Node } from "ts-morph";
6
+ import { Scope, Project, QuoteKind, IndentationText, VariableDeclarationKind, Node, SyntaxKind } from "ts-morph";
7
7
  import * as ts from "typescript";
8
8
  class TrackingFileSystem {
9
9
  inner;
@@ -474,6 +474,84 @@ function generateSplashProject(codegenFs, templateDir, options) {
474
474
  normalized
475
475
  );
476
476
  }
477
+ const MODEL_PROJECT_SUFFIX = "-models";
478
+ const DEFAULT_MODEL_LIBS_DIR = "libs";
479
+ const JSONC_ESLINT_PARSER_VERSION = "^2.1.0";
480
+ function resolveModelProjectLayout(codegenFs, options) {
481
+ const base = dashCase(options.name);
482
+ const projectName = base.endsWith(MODEL_PROJECT_SUFFIX) ? base : `${base}${MODEL_PROJECT_SUFFIX}`;
483
+ const libsDir = (options.libsDir || DEFAULT_MODEL_LIBS_DIR).replace(
484
+ /\\/g,
485
+ "/"
486
+ );
487
+ const scope = codegenFs ? readNpmScope(codegenFs) : void 0;
488
+ return {
489
+ projectName,
490
+ projectRoot: `${libsDir}/${projectName}`.replace(/^\/+/, ""),
491
+ importPath: scope ? `@${scope}/${projectName}` : projectName
492
+ };
493
+ }
494
+ function readNpmScope(codegenFs) {
495
+ if (!codegenFs.exists("package.json")) {
496
+ return void 0;
497
+ }
498
+ const name = readJson(codegenFs, "package.json").name;
499
+ if (!name?.startsWith("@")) {
500
+ return void 0;
501
+ }
502
+ return name.split("/")[0].slice(1);
503
+ }
504
+ function offsetFromRoot(projectRoot) {
505
+ return projectRoot.split("/").filter(Boolean).map(() => "../").join("");
506
+ }
507
+ function generateModelProject(codegenFs, templateDir, options) {
508
+ const layout = resolveModelProjectLayout(codegenFs, options);
509
+ generateFilesFromTemplates(
510
+ codegenFs,
511
+ path.join(templateDir, "project"),
512
+ layout.projectRoot,
513
+ {
514
+ ...layout,
515
+ projectNameCamelCase: camelCase(layout.projectName),
516
+ offsetFromRoot: offsetFromRoot(layout.projectRoot),
517
+ template: ""
518
+ }
519
+ );
520
+ registerTsconfigPath(codegenFs, layout);
521
+ ensureJsoncEslintParser(codegenFs);
522
+ return layout;
523
+ }
524
+ function registerTsconfigPath(codegenFs, layout) {
525
+ if (!codegenFs.exists("tsconfig.base.json")) {
526
+ return;
527
+ }
528
+ updateJson(codegenFs, "tsconfig.base.json", (json) => {
529
+ json.compilerOptions = json.compilerOptions ?? {};
530
+ json.compilerOptions.paths = json.compilerOptions.paths ?? {};
531
+ json.compilerOptions.paths[layout.importPath] = [
532
+ `${layout.projectRoot}/src/index.ts`
533
+ ];
534
+ return json;
535
+ });
536
+ }
537
+ function ensureJsoncEslintParser(codegenFs) {
538
+ if (!codegenFs.exists("package.json")) {
539
+ return;
540
+ }
541
+ const pkg = readJson(codegenFs, "package.json");
542
+ const devDependencies = pkg.devDependencies ?? {};
543
+ if ("jsonc-eslint-parser" in devDependencies) {
544
+ return;
545
+ }
546
+ devDependencies["jsonc-eslint-parser"] = JSONC_ESLINT_PARSER_VERSION;
547
+ pkg.devDependencies = Object.fromEntries(
548
+ // Codepoint order, matching what Nx's own dependency helper produces.
549
+ Object.entries(devDependencies).sort(
550
+ ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
551
+ )
552
+ );
553
+ writeJson(codegenFs, "package.json", pkg);
554
+ }
477
555
  function generateInit(codegenFs, options) {
478
556
  const logger = getCodegenLogger();
479
557
  const { appProject, modelProject, registrationProject } = options;
@@ -492,7 +570,6 @@ function generateInit(codegenFs, options) {
492
570
  modelDirectory: "lib",
493
571
  appDirectory: "app",
494
572
  components: true,
495
- dataServices: true,
496
573
  internal: false,
497
574
  singleton: false,
498
575
  unitTests: true
@@ -926,6 +1003,58 @@ function normalizeOptions(codegenFs, options, projects) {
926
1003
  template: ""
927
1004
  };
928
1005
  }
1006
+ const UPDATE_RELEASE_VERSION_SCRIPT = `import devkit from "@nx/devkit";
1007
+ import { resolve } from "path";
1008
+ import { readFileSync, writeFileSync } from "fs";
1009
+ import prettier from "prettier";
1010
+
1011
+ // KOS artifact versioning: stamps the project's .kos.json "version" field
1012
+ // (which kabtool bakes into the KAB). Never touches package.json.
1013
+ // Driven by tag-based releases:
1014
+ // nx run-many --target=version --args=--ver=$KOSBUILD_VERSION
1015
+
1016
+ const { readCachedProjectGraph } = devkit;
1017
+ const [, , name, versionArg] = process.argv;
1018
+
1019
+ // "{args.ver}" arrives literally when the target runs without --args=--ver=<v>;
1020
+ // treat that (or a missing arg) as "report current version, change nothing".
1021
+ const version =
1022
+ versionArg && !versionArg.startsWith("{args") ? versionArg : undefined;
1023
+
1024
+ if (!name) {
1025
+ console.error("usage: update-release-version.mjs <project> <version>");
1026
+ process.exit(1);
1027
+ }
1028
+
1029
+ const graph = readCachedProjectGraph();
1030
+ const project = graph.nodes[name];
1031
+ if (!project) {
1032
+ console.error("Unknown project: " + name);
1033
+ process.exit(1);
1034
+ }
1035
+
1036
+ const kosJsonPath = resolve(process.cwd(), project.data.root, ".kos.json");
1037
+ let kosJson;
1038
+ try {
1039
+ kosJson = JSON.parse(readFileSync(kosJsonPath, "utf8"));
1040
+ } catch {
1041
+ console.error("Missing or invalid .kos.json: " + kosJsonPath);
1042
+ process.exit(1);
1043
+ }
1044
+
1045
+ if (!version) {
1046
+ console.log(name + ": " + kosJson.version + " (no --ver given; unchanged)");
1047
+ process.exit(0);
1048
+ }
1049
+
1050
+ const prettierOptions = await prettier.resolveConfig(kosJsonPath);
1051
+ const output = await prettier.format(
1052
+ JSON.stringify({ ...kosJson, version }, null, 2),
1053
+ { ...prettierOptions, parser: "json" }
1054
+ );
1055
+ writeFileSync(kosJsonPath, output);
1056
+ console.log(name + ": version -> " + version);
1057
+ `;
929
1058
  function transformSourceFile(codegenFs, filePath, mutate) {
930
1059
  const content = codegenFs.read(filePath);
931
1060
  if (content === null) {
@@ -1031,12 +1160,137 @@ function ensureModuleConst(sourceFile, spec) {
1031
1160
  });
1032
1161
  return true;
1033
1162
  }
1163
+ function afterImports(sourceFile) {
1164
+ const imports = sourceFile.getImportDeclarations();
1165
+ return imports.length > 0 ? imports[imports.length - 1].getChildIndex() + 1 : 0;
1166
+ }
1167
+ function unquote$1(name) {
1168
+ return name.replace(/^["']|["']$/g, "");
1169
+ }
1170
+ function getConstCatalogLiteral(sourceFile, name) {
1171
+ const declaration = sourceFile.getVariableDeclaration(name);
1172
+ if (!declaration) return null;
1173
+ let initializer = declaration.getInitializer();
1174
+ if (Node.isAsExpression(initializer)) {
1175
+ initializer = initializer.getExpression();
1176
+ }
1177
+ if (!initializer || !Node.isObjectLiteralExpression(initializer)) {
1178
+ throw new Error(
1179
+ `"${name}" exists in ${sourceFile.getBaseName()} but is not an object literal.`
1180
+ );
1181
+ }
1182
+ return initializer;
1183
+ }
1184
+ function ensureConstCatalogEntry(sourceFile, spec) {
1185
+ const matches = spec.equals ?? ((existing, candidate) => existing === candidate);
1186
+ let literal = getConstCatalogLiteral(sourceFile, spec.catalogName);
1187
+ if (!literal) {
1188
+ sourceFile.insertVariableStatement(afterImports(sourceFile), {
1189
+ isExported: true,
1190
+ declarationKind: VariableDeclarationKind.Const,
1191
+ declarations: [{ name: spec.catalogName, initializer: "{} as const" }]
1192
+ });
1193
+ literal = getConstCatalogLiteral(sourceFile, spec.catalogName);
1194
+ if (!literal) {
1195
+ throw new Error(`Failed to create catalog "${spec.catalogName}".`);
1196
+ }
1197
+ literal.addPropertyAssignment({
1198
+ name: spec.key,
1199
+ initializer: spec.initializer,
1200
+ leadingTrivia: spec.leadingTrivia
1201
+ });
1202
+ return { key: spec.key, created: true };
1203
+ }
1204
+ for (const property of literal.getProperties().filter(Node.isPropertyAssignment)) {
1205
+ const existingKey = unquote$1(property.getName());
1206
+ const existingInitializer = property.getInitializerOrThrow().getText();
1207
+ if (matches(existingInitializer, spec.initializer)) {
1208
+ return { key: existingKey, created: false };
1209
+ }
1210
+ if (existingKey === spec.key) {
1211
+ throw new Error(
1212
+ `"${spec.catalogName}.${spec.key}" already exists with a different value (${existingInitializer}). Use a different methodName, or reconcile the catalog by hand.`
1213
+ );
1214
+ }
1215
+ }
1216
+ literal.addPropertyAssignment({
1217
+ name: spec.key,
1218
+ initializer: spec.initializer,
1219
+ leadingTrivia: spec.leadingTrivia
1220
+ });
1221
+ return { key: spec.key, created: true };
1222
+ }
1223
+ function ensureExportedTypeAlias(sourceFile, spec) {
1224
+ if (sourceFile.getTypeAlias(spec.name)) return false;
1225
+ sourceFile.addTypeAlias({
1226
+ name: spec.name,
1227
+ type: spec.type,
1228
+ isExported: true,
1229
+ docs: spec.docs ? [spec.docs] : void 0
1230
+ });
1231
+ return true;
1232
+ }
1233
+ function ensureExportedMapper(sourceFile, spec) {
1234
+ if (sourceFile.getVariableDeclaration(spec.name)) return false;
1235
+ sourceFile.addVariableStatement({
1236
+ isExported: true,
1237
+ declarationKind: VariableDeclarationKind.Const,
1238
+ declarations: [
1239
+ {
1240
+ name: spec.name,
1241
+ initializer: `(raw: ${spec.rawType}): ${spec.dataType} => raw`
1242
+ }
1243
+ ]
1244
+ });
1245
+ return true;
1246
+ }
1247
+ function ensureBarrelExport(sourceFile, moduleSpecifier) {
1248
+ const present = sourceFile.getExportDeclarations().some((d) => d.getModuleSpecifierValue() === moduleSpecifier);
1249
+ if (present) return false;
1250
+ sourceFile.addExportDeclaration({ moduleSpecifier });
1251
+ return true;
1252
+ }
1253
+ function ensureNamedExports(sourceFile, moduleSpecifier, names, isTypeOnly = false) {
1254
+ const existing = sourceFile.getExportDeclarations().find(
1255
+ (d) => d.getModuleSpecifierValue() === moduleSpecifier && d.isTypeOnly() === isTypeOnly
1256
+ );
1257
+ if (!existing) {
1258
+ sourceFile.addExportDeclaration({
1259
+ moduleSpecifier,
1260
+ isTypeOnly,
1261
+ namedExports: names.map((name) => {
1262
+ return { name };
1263
+ })
1264
+ });
1265
+ return true;
1266
+ }
1267
+ const present = new Set(existing.getNamedExports().map((e) => e.getName()));
1268
+ const missing = names.filter((name) => !present.has(name));
1269
+ if (missing.length === 0) return false;
1270
+ existing.addNamedExports(
1271
+ missing.map((name) => {
1272
+ return { name };
1273
+ })
1274
+ );
1275
+ return true;
1276
+ }
1277
+ function ensureExportedInterface(sourceFile, name, properties = [], extendsTypes = []) {
1278
+ if (sourceFile.getInterface(name)) return false;
1279
+ sourceFile.addInterface({
1280
+ name,
1281
+ isExported: true,
1282
+ extends: extendsTypes,
1283
+ properties
1284
+ });
1285
+ return true;
1286
+ }
1034
1287
  function addDecoratedMethod(cls, spec) {
1035
1288
  if (cls.getMethod(spec.name)) return false;
1036
1289
  cls.addMethod({
1037
1290
  name: spec.name,
1038
1291
  isAsync: spec.isAsync,
1039
1292
  returnType: spec.returnType,
1293
+ docs: spec.docs ? [spec.docs] : void 0,
1040
1294
  parameters: spec.parameters?.map((p) => {
1041
1295
  return { name: p.name, type: p.type, hasQuestionToken: p.optional };
1042
1296
  }),
@@ -1044,6 +1298,7 @@ function addDecoratedMethod(cls, spec) {
1044
1298
  decorators: [
1045
1299
  {
1046
1300
  name: spec.decoratorName,
1301
+ typeArguments: spec.decoratorTypeArgs,
1047
1302
  arguments: spec.decoratorArgsText ? [spec.decoratorArgsText] : []
1048
1303
  }
1049
1304
  ]
@@ -1126,17 +1381,28 @@ function listServiceCatalog(codegenFs, query, projects) {
1126
1381
  app,
1127
1382
  version,
1128
1383
  serviceModulePath: posix,
1129
- operations: readOperations(codegenFs, openapiPath)
1384
+ operations: readOperations(codegenFs, openapiPath) ?? []
1130
1385
  });
1131
1386
  }
1132
1387
  return entries.sort(
1133
1388
  (a, b) => a.app.localeCompare(b.app) || a.version.localeCompare(b.version)
1134
1389
  );
1135
1390
  }
1391
+ function readServiceModuleOperations(codegenFs, serviceModuleFile) {
1392
+ const moduleFile = serviceModuleFile.endsWith(".ts") ? serviceModuleFile : `${serviceModuleFile}.ts`;
1393
+ const posix = moduleFile.split(path.sep).join("/");
1394
+ const openapiPath = posix.replace(/service\.ts$/, "openapi.d.ts");
1395
+ for (const candidate of [openapiPath, posix]) {
1396
+ if (!codegenFs.exists(candidate)) continue;
1397
+ const operations = readOperations(codegenFs, candidate);
1398
+ if (operations) return operations;
1399
+ }
1400
+ return null;
1401
+ }
1136
1402
  function readOperations(codegenFs, openapiPath) {
1137
1403
  return readSourceFile(codegenFs, openapiPath, (sf) => {
1138
1404
  const paths = sf.getInterface("paths");
1139
- if (!paths) return [];
1405
+ if (!paths) return null;
1140
1406
  const operations = [];
1141
1407
  for (const pathProp of paths.getProperties()) {
1142
1408
  const servicePath = unquote(pathProp.getName());
@@ -1170,58 +1436,6 @@ function readSummary(methodProp) {
1170
1436
  function unquote(name) {
1171
1437
  return name.replace(/^["']|["']$/g, "");
1172
1438
  }
1173
- const UPDATE_RELEASE_VERSION_SCRIPT = `import devkit from "@nx/devkit";
1174
- import { resolve } from "path";
1175
- import { readFileSync, writeFileSync } from "fs";
1176
- import prettier from "prettier";
1177
-
1178
- // KOS artifact versioning: stamps the project's .kos.json "version" field
1179
- // (which kabtool bakes into the KAB). Never touches package.json.
1180
- // Driven by tag-based releases:
1181
- // nx run-many --target=version --args=--ver=$KOSBUILD_VERSION
1182
-
1183
- const { readCachedProjectGraph } = devkit;
1184
- const [, , name, versionArg] = process.argv;
1185
-
1186
- // "{args.ver}" arrives literally when the target runs without --args=--ver=<v>;
1187
- // treat that (or a missing arg) as "report current version, change nothing".
1188
- const version =
1189
- versionArg && !versionArg.startsWith("{args") ? versionArg : undefined;
1190
-
1191
- if (!name) {
1192
- console.error("usage: update-release-version.mjs <project> <version>");
1193
- process.exit(1);
1194
- }
1195
-
1196
- const graph = readCachedProjectGraph();
1197
- const project = graph.nodes[name];
1198
- if (!project) {
1199
- console.error("Unknown project: " + name);
1200
- process.exit(1);
1201
- }
1202
-
1203
- const kosJsonPath = resolve(process.cwd(), project.data.root, ".kos.json");
1204
- let kosJson;
1205
- try {
1206
- kosJson = JSON.parse(readFileSync(kosJsonPath, "utf8"));
1207
- } catch {
1208
- console.error("Missing or invalid .kos.json: " + kosJsonPath);
1209
- process.exit(1);
1210
- }
1211
-
1212
- if (!version) {
1213
- console.log(name + ": " + kosJson.version + " (no --ver given; unchanged)");
1214
- process.exit(0);
1215
- }
1216
-
1217
- const prettierOptions = await prettier.resolveConfig(kosJsonPath);
1218
- const output = await prettier.format(
1219
- JSON.stringify({ ...kosJson, version }, null, 2),
1220
- { ...prettierOptions, parser: "json" }
1221
- );
1222
- writeFileSync(kosJsonPath, output);
1223
- console.log(name + ": version -> " + version);
1224
- `;
1225
1439
  function appendBarrelExport(codegenFs, indexPath, exportPath) {
1226
1440
  const exportLine = `export * from '${exportPath}'`;
1227
1441
  const content = codegenFs.read(indexPath) ?? "";
@@ -1268,10 +1482,57 @@ function updateModelIndex(codegenFs, indexPath, modelPath) {
1268
1482
  const newContents = printer.printFile(updatedSourceFile);
1269
1483
  codegenFs.write(indexPath, newContents);
1270
1484
  }
1271
- function generateHook(codegenFs, templateDir, options, cwd, projects) {
1272
- if (!options.appProject) {
1273
- throw new Error("No app project specified");
1485
+ function generateCompanionModel(codegenFs, templateDir, options, projects) {
1486
+ const logger = getCodegenLogger();
1487
+ const normalized = normalizeAllValues({
1488
+ companionModelName: options.companionModelName,
1489
+ modelName: options.modelName
1490
+ });
1491
+ const companionChildKosConfig = getKosProjectConfiguration(
1492
+ codegenFs,
1493
+ options.companionModelProject,
1494
+ projects
1495
+ );
1496
+ const parentProject = findProjectByName(
1497
+ codegenFs.root,
1498
+ options.modelProject,
1499
+ projects
1500
+ );
1501
+ const childProject = findProjectByName(
1502
+ codegenFs.root,
1503
+ options.companionModelProject,
1504
+ projects
1505
+ );
1506
+ const projectRoot = childProject?.sourceRoot;
1507
+ if (!projectRoot) {
1508
+ logger.warn(`Companion child project source root not found`);
1509
+ return;
1510
+ }
1511
+ let importPath = "";
1512
+ if (parentProject) {
1513
+ const pkgJsonPath = path.join(parentProject.root, "package.json");
1514
+ try {
1515
+ const pkgJson = readJson(codegenFs, pkgJsonPath);
1516
+ importPath = pkgJson.name || "";
1517
+ } catch {
1518
+ importPath = "";
1519
+ }
1274
1520
  }
1521
+ const modelLocation = companionChildKosConfig?.generator?.defaults?.model?.folder || "";
1522
+ const filePath = path.join(
1523
+ projectRoot,
1524
+ modelLocation,
1525
+ normalized.companionModelNameDashCase
1526
+ );
1527
+ logger.info(`Generating companion model in ${filePath}`);
1528
+ generateFilesFromTemplates(codegenFs, templateDir, filePath, {
1529
+ ...options,
1530
+ ...normalized,
1531
+ importPath
1532
+ });
1533
+ }
1534
+ function generateContainerModel(codegenFs, templateDir, options, cwd, projects) {
1535
+ const logger = getCodegenLogger();
1275
1536
  const currentProject = getProject(codegenFs, cwd);
1276
1537
  const modelProjectName = options.modelProject || currentProject?.name;
1277
1538
  if (!modelProjectName) {
@@ -1279,57 +1540,110 @@ function generateHook(codegenFs, templateDir, options, cwd, projects) {
1279
1540
  "No model project found. Please specify a model project with --modelProject."
1280
1541
  );
1281
1542
  }
1282
- const modelName = options.name || getCurrentDirectoryName(cwd);
1543
+ const modelName = options.modelName || getCurrentDirectoryName(cwd);
1283
1544
  if (!modelName) {
1284
1545
  throw new Error(
1285
1546
  "No model name found. Please specify a model name with --name."
1286
1547
  );
1287
1548
  }
1288
- const kosModelConfig = getKosModelConfiguration(
1289
- codegenFs,
1290
- modelProjectName,
1291
- modelName,
1292
- projects
1293
- );
1294
- options.singleton = kosModelConfig ? !!kosModelConfig.singleton : false;
1295
- options.name = modelName;
1296
- options.modelProject = modelProjectName;
1549
+ options.modelName = modelName;
1550
+ options.name = `${modelName}-container`;
1297
1551
  const normalized = normalizeOptions(codegenFs, options, projects);
1298
- const appProject = findProjectByName(
1552
+ const projectConfig = findProjectByName(
1299
1553
  codegenFs.root,
1300
- normalized.appProject,
1554
+ normalized.modelProject,
1301
1555
  projects
1302
1556
  );
1303
- if (!appProject) {
1304
- throw new Error(`App project '${normalized.appProject}' not found`);
1557
+ if (!projectConfig) {
1558
+ throw new Error(`Model project '${normalized.modelProject}' not found`);
1305
1559
  }
1560
+ addKosModelConfiguration({
1561
+ codegenFs,
1562
+ modelName: normalized.nameDashCase,
1563
+ projectName: projectConfig.name,
1564
+ projectRoot: projectConfig.root,
1565
+ singleton: !!options.singleton,
1566
+ container: true,
1567
+ // The container's exported registration bean (`export const <ProperCase>`).
1568
+ factory: normalized.nameProperCase
1569
+ });
1306
1570
  const kosConfig = getKosProjectConfiguration(
1307
1571
  codegenFs,
1308
- appProject.name,
1572
+ projectConfig.name,
1309
1573
  projects
1310
1574
  );
1311
- const componentLocation = kosConfig?.generator?.defaults?.component?.folder || "";
1312
- options.appDirectory = options.appDirectory || componentLocation;
1313
- const projectRoot = appProject.sourceRoot;
1575
+ const modelLocation = kosConfig?.generator?.defaults?.model?.folder || "";
1576
+ const internal = !!kosConfig?.generator?.internal;
1577
+ options.modelDirectory = options.modelDirectory || modelLocation;
1578
+ const projectRoot = projectConfig.sourceRoot;
1314
1579
  if (projectRoot) {
1315
- generateFilesFromTemplates(
1316
- codegenFs,
1317
- templateDir,
1318
- path.join(
1319
- projectRoot,
1320
- options.appDirectory,
1321
- "hooks",
1322
- normalized.nameDashCase
1323
- ),
1324
- normalized
1580
+ logger.info(
1581
+ `Generating container model ${normalized.nameDashCase} in ${projectRoot}`
1325
1582
  );
1326
- appendBarrelExport(
1327
- codegenFs,
1328
- path.join(projectRoot, options.appDirectory, "hooks", "index.ts"),
1329
- `./${normalized.nameDashCase}`
1583
+ const modelNameDashCase = normalized.modelNameDashCase || normalized.nameDashCase;
1584
+ const modelFolder = path.join(
1585
+ projectRoot,
1586
+ options.modelDirectory || "",
1587
+ modelNameDashCase
1330
1588
  );
1589
+ if (options.existingModel) {
1590
+ addContainerToExistingModelFolder(codegenFs, templateDir, modelFolder, {
1591
+ ...normalized,
1592
+ internal
1593
+ });
1594
+ } else {
1595
+ generateFilesFromTemplates(
1596
+ codegenFs,
1597
+ path.join(templateDir, "model"),
1598
+ modelFolder,
1599
+ { ...normalized, internal }
1600
+ );
1601
+ }
1602
+ const modelIndex = path.join(projectRoot, "index.ts");
1603
+ const modelPath = normalized.modelDirectory ? `${normalized.modelDirectory}/${modelNameDashCase}` : modelNameDashCase;
1604
+ updateModelIndex(codegenFs, modelIndex, modelPath);
1331
1605
  }
1332
1606
  }
1607
+ function addContainerToExistingModelFolder(codegenFs, templateDir, modelFolder, substitutions) {
1608
+ const containerFileName = `${substitutions.nameDashCase}-model.ts`;
1609
+ const containerTemplate = path.join(
1610
+ templateDir,
1611
+ "model",
1612
+ "__nameDashCase__-model.ts.template"
1613
+ );
1614
+ const rendered = ejs.render(
1615
+ fs.readFileSync(containerTemplate, "utf-8"),
1616
+ substitutions,
1617
+ { filename: containerTemplate }
1618
+ );
1619
+ codegenFs.write(path.join(modelFolder, containerFileName), rendered);
1620
+ const typesPath = path.join(modelFolder, "types", "index.d.ts");
1621
+ if (codegenFs.read(typesPath) === null) {
1622
+ codegenFs.write(typesPath, "");
1623
+ }
1624
+ transformSourceFile(codegenFs, typesPath, (sourceFile) => {
1625
+ ensureExportedInterface(
1626
+ sourceFile,
1627
+ `${substitutions.nameProperCase}Options`
1628
+ );
1629
+ });
1630
+ const barrelPath = path.join(modelFolder, "index.ts");
1631
+ if (codegenFs.read(barrelPath) === null) {
1632
+ codegenFs.write(barrelPath, "");
1633
+ }
1634
+ transformSourceFile(codegenFs, barrelPath, (sourceFile) => {
1635
+ const moduleSpecifier = `./${containerFileName.replace(/\.ts$/, "")}`;
1636
+ ensureNamedExports(sourceFile, moduleSpecifier, [
1637
+ substitutions.nameProperCase
1638
+ ]);
1639
+ ensureNamedExports(
1640
+ sourceFile,
1641
+ moduleSpecifier,
1642
+ [`${substitutions.nameProperCase}Model`],
1643
+ true
1644
+ );
1645
+ });
1646
+ }
1333
1647
  function generateContext(codegenFs, templateDir, options, cwd, projects) {
1334
1648
  if (!options.appProject) {
1335
1649
  throw new Error("No app project specified");
@@ -1382,8 +1696,10 @@ function generateContext(codegenFs, templateDir, options, cwd, projects) {
1382
1696
  );
1383
1697
  }
1384
1698
  }
1385
- function generateContainerModel(codegenFs, templateDir, options, cwd, projects) {
1386
- const logger = getCodegenLogger();
1699
+ function generateHook(codegenFs, templateDir, options, cwd, projects) {
1700
+ if (!options.appProject) {
1701
+ throw new Error("No app project specified");
1702
+ }
1387
1703
  const currentProject = getProject(codegenFs, cwd);
1388
1704
  const modelProjectName = options.modelProject || currentProject?.name;
1389
1705
  if (!modelProjectName) {
@@ -1391,120 +1707,56 @@ function generateContainerModel(codegenFs, templateDir, options, cwd, projects)
1391
1707
  "No model project found. Please specify a model project with --modelProject."
1392
1708
  );
1393
1709
  }
1394
- const modelName = options.modelName || getCurrentDirectoryName(cwd);
1710
+ const modelName = options.name || getCurrentDirectoryName(cwd);
1395
1711
  if (!modelName) {
1396
1712
  throw new Error(
1397
1713
  "No model name found. Please specify a model name with --name."
1398
1714
  );
1399
1715
  }
1400
- options.modelName = modelName;
1401
- options.name = `${modelName}-container`;
1716
+ const kosModelConfig = getKosModelConfiguration(
1717
+ codegenFs,
1718
+ modelProjectName,
1719
+ modelName,
1720
+ projects
1721
+ );
1722
+ options.singleton = kosModelConfig ? !!kosModelConfig.singleton : false;
1723
+ options.name = modelName;
1724
+ options.modelProject = modelProjectName;
1402
1725
  const normalized = normalizeOptions(codegenFs, options, projects);
1403
- const projectConfig = findProjectByName(
1726
+ const appProject = findProjectByName(
1404
1727
  codegenFs.root,
1405
- normalized.modelProject,
1728
+ normalized.appProject,
1406
1729
  projects
1407
1730
  );
1408
- if (!projectConfig) {
1409
- throw new Error(`Model project '${normalized.modelProject}' not found`);
1731
+ if (!appProject) {
1732
+ throw new Error(`App project '${normalized.appProject}' not found`);
1410
1733
  }
1411
- addKosModelConfiguration({
1412
- codegenFs,
1413
- modelName: normalized.nameDashCase,
1414
- projectName: projectConfig.name,
1415
- projectRoot: projectConfig.root,
1416
- singleton: !!options.singleton,
1417
- container: true,
1418
- // The container's exported registration bean (`export const <ProperCase>`).
1419
- factory: normalized.nameProperCase
1420
- });
1421
1734
  const kosConfig = getKosProjectConfiguration(
1422
1735
  codegenFs,
1423
- projectConfig.name,
1736
+ appProject.name,
1424
1737
  projects
1425
1738
  );
1426
- const modelLocation = kosConfig?.generator?.defaults?.model?.folder || "";
1427
- const internal = !!kosConfig?.generator?.internal;
1428
- options.modelDirectory = options.modelDirectory || modelLocation;
1429
- const projectRoot = projectConfig.sourceRoot;
1739
+ const componentLocation = kosConfig?.generator?.defaults?.component?.folder || "";
1740
+ options.appDirectory = options.appDirectory || componentLocation;
1741
+ const projectRoot = appProject.sourceRoot;
1430
1742
  if (projectRoot) {
1431
- logger.info(
1432
- `Generating container model ${normalized.nameDashCase} in ${projectRoot}`
1433
- );
1434
- const modelNameDashCase = normalized.modelNameDashCase || normalized.nameDashCase;
1435
1743
  generateFilesFromTemplates(
1436
1744
  codegenFs,
1437
- path.join(templateDir, "model"),
1438
- path.join(projectRoot, options.modelDirectory || "", modelNameDashCase),
1439
- { ...normalized, internal }
1745
+ templateDir,
1746
+ path.join(
1747
+ projectRoot,
1748
+ options.appDirectory,
1749
+ "hooks",
1750
+ normalized.nameDashCase
1751
+ ),
1752
+ normalized
1753
+ );
1754
+ appendBarrelExport(
1755
+ codegenFs,
1756
+ path.join(projectRoot, options.appDirectory, "hooks", "index.ts"),
1757
+ `./${normalized.nameDashCase}`
1440
1758
  );
1441
- if (options.dataServices) {
1442
- logger.info(`Generating data services for ${modelNameDashCase}`);
1443
- generateFilesFromTemplates(
1444
- codegenFs,
1445
- path.join(templateDir, "services"),
1446
- path.join(
1447
- projectRoot,
1448
- options.modelDirectory,
1449
- modelNameDashCase,
1450
- "services"
1451
- ),
1452
- { ...normalized, internal }
1453
- );
1454
- }
1455
- const modelIndex = path.join(projectRoot, "index.ts");
1456
- const modelPath = normalized.modelDirectory ? `${normalized.modelDirectory}/${modelNameDashCase}` : modelNameDashCase;
1457
- updateModelIndex(codegenFs, modelIndex, modelPath);
1458
- }
1459
- }
1460
- function generateCompanionModel(codegenFs, templateDir, options, projects) {
1461
- const logger = getCodegenLogger();
1462
- const normalized = normalizeAllValues({
1463
- companionModelName: options.companionModelName,
1464
- modelName: options.modelName
1465
- });
1466
- const companionChildKosConfig = getKosProjectConfiguration(
1467
- codegenFs,
1468
- options.companionModelProject,
1469
- projects
1470
- );
1471
- const parentProject = findProjectByName(
1472
- codegenFs.root,
1473
- options.modelProject,
1474
- projects
1475
- );
1476
- const childProject = findProjectByName(
1477
- codegenFs.root,
1478
- options.companionModelProject,
1479
- projects
1480
- );
1481
- const projectRoot = childProject?.sourceRoot;
1482
- if (!projectRoot) {
1483
- logger.warn(`Companion child project source root not found`);
1484
- return;
1485
- }
1486
- let importPath = "";
1487
- if (parentProject) {
1488
- const pkgJsonPath = path.join(parentProject.root, "package.json");
1489
- try {
1490
- const pkgJson = readJson(codegenFs, pkgJsonPath);
1491
- importPath = pkgJson.name || "";
1492
- } catch {
1493
- importPath = "";
1494
- }
1495
1759
  }
1496
- const modelLocation = companionChildKosConfig?.generator?.defaults?.model?.folder || "";
1497
- const filePath = path.join(
1498
- projectRoot,
1499
- modelLocation,
1500
- normalized.companionModelNameDashCase
1501
- );
1502
- logger.info(`Generating companion model in ${filePath}`);
1503
- generateFilesFromTemplates(codegenFs, templateDir, filePath, {
1504
- ...options,
1505
- ...normalized,
1506
- importPath
1507
- });
1508
1760
  }
1509
1761
  function generateModel(params) {
1510
1762
  const {
@@ -1552,19 +1804,6 @@ function generateModel(params) {
1552
1804
  path.join(projectRoot, options.modelDirectory, normalized.nameDashCase),
1553
1805
  { ...normalized, internal }
1554
1806
  );
1555
- if (normalized.dataServices) {
1556
- generateFilesFromTemplates(
1557
- codegenFs,
1558
- path.join(modelTemplateDir, "services"),
1559
- path.join(
1560
- projectRoot,
1561
- options.modelDirectory,
1562
- normalized.nameDashCase,
1563
- "services"
1564
- ),
1565
- { ...normalized, internal }
1566
- );
1567
- }
1568
1807
  const modelIndex = path.join(projectRoot, "index.ts");
1569
1808
  const modelPath = options.modelDirectory ? `${options.modelDirectory}/${normalized.nameDashCase}` : normalized.nameDashCase;
1570
1809
  updateModelIndex(codegenFs, modelIndex, modelPath);
@@ -1595,8 +1834,7 @@ function generateModel(params) {
1595
1834
  ...options,
1596
1835
  name: `${normalized.name}-container`,
1597
1836
  modelName: normalized.name,
1598
- singleton: normalized.isContainerSingleton,
1599
- dataServices: normalized.dataServices
1837
+ singleton: normalized.isContainerSingleton
1600
1838
  },
1601
1839
  cwd,
1602
1840
  projects
@@ -1617,6 +1855,55 @@ function generateModel(params) {
1617
1855
  );
1618
1856
  }
1619
1857
  }
1858
+ const DECLARATION_MARKER = "@kosModel";
1859
+ function findModelDeclarationFile(codegenFs, searchRoot, typeIds) {
1860
+ const wanted = new Set(typeIds.filter(Boolean));
1861
+ if (wanted.size === 0) return null;
1862
+ const matches = [];
1863
+ for (const filePath of codegenFs.listFiles(searchRoot)) {
1864
+ if (!filePath.endsWith(".ts") || filePath.endsWith(".d.ts")) continue;
1865
+ const content = codegenFs.read(filePath);
1866
+ if (!content || !content.includes(DECLARATION_MARKER)) continue;
1867
+ const declared = readDeclaredModelType(codegenFs, filePath);
1868
+ if (declared && wanted.has(declared)) matches.push(filePath);
1869
+ }
1870
+ if (matches.length === 0) return null;
1871
+ if (matches.length > 1) {
1872
+ throw new Error(
1873
+ `Model type '${[...wanted].join("' / '")}' is declared in more than one file:
1874
+ ` + matches.sort().map((c) => ` - ${c}`).join("\n") + `
1875
+ Pass modelPath to pick one.`
1876
+ );
1877
+ }
1878
+ return matches[0];
1879
+ }
1880
+ function readDeclaredModelType(codegenFs, filePath) {
1881
+ try {
1882
+ return readSourceFile(codegenFs, filePath, (sf) => {
1883
+ const cls = sf.getClasses().find((c) => c.getDecorator("kosModel"));
1884
+ const arg = cls?.getDecorator("kosModel")?.getArguments()[0];
1885
+ if (!arg) return void 0;
1886
+ const inner = arg.getKindName() === "ObjectLiteralExpression" ? modelTypeIdProperty(arg.getText()) : arg.getText();
1887
+ if (!inner) return void 0;
1888
+ return resolveToStringLiteral(inner.trim(), sf.getFullText());
1889
+ });
1890
+ } catch {
1891
+ return void 0;
1892
+ }
1893
+ }
1894
+ function modelTypeIdProperty(objectText) {
1895
+ const m = objectText.match(/\bmodelTypeId\s*:\s*([^,}]+)/);
1896
+ return m ? m[1] : void 0;
1897
+ }
1898
+ function resolveToStringLiteral(expression, fileText) {
1899
+ const literal = expression.match(/^["'`](.*)["'`]$/);
1900
+ if (literal) return literal[1];
1901
+ if (!/^[A-Za-z_$][\w$]*$/.test(expression)) return void 0;
1902
+ const declared = fileText.match(
1903
+ new RegExp(`\\b(?:const|let|var)\\s+${expression}\\s*(?::[^=]+)?=\\s*["'\`]([^"'\`]+)["'\`]`)
1904
+ );
1905
+ return declared ? declared[1] : void 0;
1906
+ }
1620
1907
  function resolveModelFilePath(codegenFs, query, projects) {
1621
1908
  const kosConfig = getKosProjectConfiguration(
1622
1909
  codegenFs,
@@ -1651,14 +1938,23 @@ function resolveModelFilePath(codegenFs, query, projects) {
1651
1938
  if (codegenFs.exists(modelFilePath)) {
1652
1939
  return { modelFilePath, internal, sourceRoot };
1653
1940
  }
1941
+ const searchRoot = path.join(sourceRoot, modelLocation);
1654
1942
  const discovered = findModelFileByName(
1655
1943
  codegenFs,
1656
- path.join(sourceRoot, modelLocation),
1944
+ searchRoot,
1657
1945
  modelNameDashCase
1658
1946
  );
1659
1947
  if (discovered) {
1660
1948
  return { modelFilePath: discovered, internal, sourceRoot };
1661
1949
  }
1950
+ const declaredType = kosConfig?.models?.[query.modelName]?.type;
1951
+ const byDeclaration = findModelDeclarationFile(codegenFs, searchRoot, [
1952
+ query.modelName,
1953
+ ...declaredType ? [declaredType] : []
1954
+ ]);
1955
+ if (byDeclaration) {
1956
+ return { modelFilePath: byDeclaration, internal, sourceRoot };
1957
+ }
1662
1958
  return { modelFilePath, internal, sourceRoot };
1663
1959
  }
1664
1960
  function findModelFileByName(codegenFs, searchRoot, modelNameDashCase) {
@@ -1674,6 +1970,184 @@ Pass modelPath to pick one.`
1674
1970
  }
1675
1971
  return candidates[0];
1676
1972
  }
1973
+ function resolveChildModelType(codegenFs, query, projects) {
1974
+ const childProject = query.childModelProject || query.modelProject;
1975
+ const childType = `${properCase(query.childModel)}Model`;
1976
+ if (childProject !== query.modelProject) {
1977
+ const project = findProjectByName(codegenFs.root, childProject, projects);
1978
+ const pkgJson = project ? readJson(
1979
+ codegenFs,
1980
+ path.join(project.root, "package.json")
1981
+ ) : void 0;
1982
+ return { childType, childTypeModule: pkgJson?.name };
1983
+ }
1984
+ const { modelFilePath: childFilePath } = resolveModelFilePath(
1985
+ codegenFs,
1986
+ { modelName: query.childModel, modelProject: childProject },
1987
+ projects
1988
+ );
1989
+ const relative = path.relative(path.dirname(query.modelFilePath), childFilePath).replace(/\.ts$/, "");
1990
+ return {
1991
+ childType,
1992
+ childTypeModule: relative.startsWith(".") ? relative : `./${relative}`
1993
+ };
1994
+ }
1995
+ function generateViewModel(codegenFs, templateDir, options, cwd, projects) {
1996
+ const logger = getCodegenLogger();
1997
+ if (!options.name) {
1998
+ throw new Error(
1999
+ "No ViewModel name found. Please specify a name with --name."
2000
+ );
2001
+ }
2002
+ const currentProject = getProject(codegenFs, cwd);
2003
+ const modelProjectName = options.modelProject || currentProject?.name;
2004
+ if (!modelProjectName) {
2005
+ throw new Error(
2006
+ "No model project found. Please specify a model project with --project."
2007
+ );
2008
+ }
2009
+ const normalized = normalizeOptions(
2010
+ codegenFs,
2011
+ { ...options, modelProject: modelProjectName },
2012
+ projects
2013
+ );
2014
+ const projectConfig = findProjectByName(
2015
+ codegenFs.root,
2016
+ modelProjectName,
2017
+ projects
2018
+ );
2019
+ if (!projectConfig) {
2020
+ throw new Error(`Model project '${modelProjectName}' not found`);
2021
+ }
2022
+ const kosConfig = getKosProjectConfiguration(
2023
+ codegenFs,
2024
+ projectConfig.name,
2025
+ projects
2026
+ );
2027
+ const modelDirectory = options.modelDirectory || kosConfig?.generator?.defaults?.model?.folder || "";
2028
+ const internal = !!kosConfig?.generator?.internal;
2029
+ const projectRoot = projectConfig.sourceRoot || path.join(projectConfig.root, "src");
2030
+ const viewModelFolder = path.join(
2031
+ projectRoot,
2032
+ modelDirectory,
2033
+ normalized.nameDashCase
2034
+ );
2035
+ const viewModelFilePath = path.join(
2036
+ viewModelFolder,
2037
+ `${normalized.nameDashCase}-view-model.ts`
2038
+ );
2039
+ if (codegenFs.exists(viewModelFilePath)) {
2040
+ logger.info(`ViewModel already exists: ${viewModelFilePath}`);
2041
+ return { viewModelFilePath, created: false };
2042
+ }
2043
+ const resolved = (options.models || []).map(
2044
+ (source) => resolveSourceModel(
2045
+ codegenFs,
2046
+ source,
2047
+ viewModelFilePath,
2048
+ modelProjectName,
2049
+ projects
2050
+ )
2051
+ );
2052
+ logger.info(
2053
+ `Generating ViewModel ${normalized.nameDashCase} in ${projectRoot}`
2054
+ );
2055
+ const constructorParams = resolved.map(({ name, type }) => ({ name, type }));
2056
+ generateFilesFromTemplates(codegenFs, templateDir, viewModelFolder, {
2057
+ ...normalized,
2058
+ internal,
2059
+ typeId: options.typeId || normalized.nameDashCase,
2060
+ devToolsEnabled: !!options.devToolsEnabled,
2061
+ constructorParams,
2062
+ constructorArgs: constructorParams.map((param) => param.name).join(", "),
2063
+ modelImports: groupImports(resolved)
2064
+ });
2065
+ updateModelIndex(
2066
+ codegenFs,
2067
+ path.join(projectRoot, "index.ts"),
2068
+ modelDirectory ? `${modelDirectory}/${normalized.nameDashCase}` : normalized.nameDashCase
2069
+ );
2070
+ return { viewModelFilePath, created: true };
2071
+ }
2072
+ function resolveSourceModel(codegenFs, source, viewModelFilePath, modelProject, projects) {
2073
+ const sourceProject = source.project || modelProject;
2074
+ if (sourceProject === modelProject) {
2075
+ const { modelFilePath } = resolveModelFilePath(
2076
+ codegenFs,
2077
+ { modelName: source.model, modelProject: sourceProject },
2078
+ projects
2079
+ );
2080
+ if (!codegenFs.exists(modelFilePath)) {
2081
+ throw new Error(
2082
+ `Model '${source.model}' not found in project '${sourceProject}' (looked for ${modelFilePath})`
2083
+ );
2084
+ }
2085
+ } else if (!findProjectByName(codegenFs.root, sourceProject, projects)) {
2086
+ throw new Error(`Model project '${sourceProject}' not found`);
2087
+ }
2088
+ const { childType, childTypeModule } = resolveChildModelType(
2089
+ codegenFs,
2090
+ {
2091
+ modelFilePath: viewModelFilePath,
2092
+ modelProject,
2093
+ childModel: source.model,
2094
+ childModelProject: sourceProject
2095
+ },
2096
+ projects
2097
+ );
2098
+ return {
2099
+ name: camelCase(source.model),
2100
+ type: childType,
2101
+ module: childTypeModule
2102
+ };
2103
+ }
2104
+ function groupImports(resolved) {
2105
+ const byModule = /* @__PURE__ */ new Map();
2106
+ for (const { type, module } of resolved) {
2107
+ if (!module) continue;
2108
+ const types = byModule.get(module);
2109
+ if (!types) {
2110
+ byModule.set(module, [type]);
2111
+ } else if (!types.includes(type)) {
2112
+ types.push(type);
2113
+ }
2114
+ }
2115
+ return [...byModule].map(([module, types]) => ({ module, types }));
2116
+ }
2117
+ function modelBaseName(modelFilePath, modelName) {
2118
+ const base = path.basename(modelFilePath);
2119
+ const match = base.match(/^(.*)-model\.ts$/);
2120
+ return match ? match[1] : modelName;
2121
+ }
2122
+ function servicesFilePathFor(modelFilePath, modelBase) {
2123
+ return path.join(
2124
+ path.dirname(modelFilePath),
2125
+ "services",
2126
+ `${modelBase}-services.ts`
2127
+ );
2128
+ }
2129
+ function header$1(modelBase) {
2130
+ return `/**
2131
+ * Service layer for the ${modelBase} model: the endpoints it calls and the
2132
+ * types derived from them. Standalone service functions for callers outside a
2133
+ * model context belong here too.
2134
+ */
2135
+ `;
2136
+ }
2137
+ function ensureServicesModule(codegenFs, modelFilePath, modelBase) {
2138
+ const servicesFilePath = servicesFilePathFor(modelFilePath, modelBase);
2139
+ if (!codegenFs.exists(servicesFilePath)) {
2140
+ codegenFs.write(servicesFilePath, header$1(modelBase));
2141
+ }
2142
+ const barrelPath = path.join(path.dirname(servicesFilePath), "index.ts");
2143
+ if (!codegenFs.exists(barrelPath)) {
2144
+ codegenFs.write(barrelPath, "");
2145
+ }
2146
+ transformSourceFile(codegenFs, barrelPath, (sf) => {
2147
+ ensureBarrelExport(sf, `./${modelBase}-services`);
2148
+ });
2149
+ return servicesFilePath;
2150
+ }
1677
2151
  function normalizeAddFutureOptions(codegenFs, options, projects) {
1678
2152
  const projectConfiguration = findProjectByName(
1679
2153
  codegenFs.root,
@@ -1708,8 +2182,7 @@ function normalizeAddFutureOptions(codegenFs, options, projects) {
1708
2182
  projects
1709
2183
  );
1710
2184
  const modelDirectory = path.dirname(modelFilePath);
1711
- const servicesDirectory = path.join(modelDirectory, "services");
1712
- const servicesFilePath = codegenFs.exists(servicesDirectory) ? path.join(servicesDirectory, `${nameDashCase}-services.ts`) : void 0;
2185
+ const servicesFilePath = servicesFilePathFor(modelFilePath, nameDashCase);
1713
2186
  const registrationFilePath = path.join(
1714
2187
  modelDirectory,
1715
2188
  `${nameDashCase}-registration.ts`
@@ -1919,170 +2392,78 @@ class ModelFileTransformer {
1919
2392
  });
1920
2393
  }
1921
2394
  }
2395
+ const SDK_MODULE = "@kosdev-code/kos-ui-sdk";
1922
2396
  class ServiceFileTransformer {
1923
- constructor(codegenFs, options) {
1924
- this.codegenFs = codegenFs;
1925
- this.options = options;
1926
- }
1927
- codegenFs;
1928
- options;
1929
- transform() {
1930
- const { servicesFilePath } = this.options;
1931
- if (!servicesFilePath || !this.codegenFs.exists(servicesFilePath)) {
1932
- this.createServicesFile();
1933
- return;
1934
- }
1935
- let content = this.codegenFs.read(servicesFilePath);
1936
- content = this.addFutureImports(content);
1937
- content = this.addFutureService(content);
1938
- content = this.addProgressTypes(content);
1939
- this.codegenFs.write(servicesFilePath, content);
1940
- }
1941
- createServicesFile() {
1942
- const { servicesFilePath, nameProperCase, nameDashCase, nameLowerCase } = this.options;
1943
- if (!servicesFilePath) {
1944
- return;
1945
- }
1946
- const content = `import {
1947
- KosLog,
1948
- type ClientResponse,
1949
- type DeepRequired,
1950
- type ElementType,
1951
- type ServiceResponse,
1952
- type FutureResponse
1953
- } from '@kosdev-code/kos-ui-sdk';
1954
-
1955
- import API, { type KosApi, type ApiPath } from '../../../utils/service';
1956
-
1957
- const log = KosLog.createLogger({name: "${nameDashCase}-service", group: "Services"});
1958
-
1959
- const SERVICE_PATH: ApiPath = "ENTER_SERVICE_PATH"
1960
- export type ${nameProperCase}ClientResponse = ClientResponse<
1961
- KosApi,
1962
- typeof SERVICE_PATH,
1963
- 'get'
1964
- >;
1965
- export type ${nameProperCase}Response = DeepRequired<${nameProperCase}ClientResponse>;
1966
-
1967
- /**
1968
- * @category Service
1969
- * Retrieves the initial ${nameLowerCase} data.
1970
- */
1971
- export const get${nameProperCase} = async (): Promise<ServiceResponse<${nameProperCase}Response>> => {
1972
- log.debug('sending GET for ${nameLowerCase}');
1973
- return await API.get(SERVICE_PATH);
1974
- };
1975
-
1976
- /**
1977
- * @category Service - Future Operation
1978
- * Placeholder for a long-running operation that returns a Future for progress tracking
1979
- *
1980
- * Replace this with your actual long-running service operation
1981
- */
1982
- export const perform${nameProperCase}Operation = async (): Promise<FutureResponse> => {
1983
- // TODO: Implement your long-running service operation here
1984
- // This should return a Future that can be tracked for progress
1985
-
1986
- log.debug('starting long-running ${nameLowerCase} operation');
1987
-
1988
- // Example pattern:
1989
- // return API.post(OPERATION_SERVICE_PATH, {
1990
- // // operation parameters
1991
- // });
1992
-
1993
- // Placeholder - replace with actual implementation
1994
- throw new Error('perform${nameProperCase}Operation not yet implemented');
1995
- };
1996
-
1997
- // Additional Future-aware service types (add as needed)
1998
- export type ${nameProperCase}OperationProgress = {
1999
- // Define your progress data structure here
2000
- stage: string;
2001
- percentComplete: number;
2002
- currentItem?: string;
2003
- totalItems?: number;
2004
- };
2005
-
2006
- export type ${nameProperCase}OperationResult = {
2007
- // Define your operation result structure here
2008
- success: boolean;
2009
- message?: string;
2010
- data?: any;
2011
- };
2012
- `;
2013
- this.codegenFs.write(servicesFilePath, content);
2014
- }
2015
- addFutureImports(content) {
2016
- if (content.includes("FutureResponse")) {
2017
- return content;
2018
- }
2019
- const importRegex = /import {\s*([^}]*)\s*} from '@kosdev-code\/kos-ui-sdk';/;
2020
- const importMatch = content.match(importRegex);
2021
- if (importMatch) {
2022
- const existingImports = importMatch[1];
2023
- if (existingImports.includes("FutureResponse")) {
2024
- return content;
2025
- }
2026
- const cleanedImports = existingImports.trim().replace(/,\s*$/, "");
2027
- const newImports = cleanedImports ? `${cleanedImports},
2028
- type FutureResponse` : `type FutureResponse`;
2029
- const newImportStatement = `import {
2030
- ${newImports}
2031
- } from '@kosdev-code/kos-ui-sdk';`;
2032
- return content.replace(importMatch[0], newImportStatement);
2033
- }
2034
- return content;
2397
+ constructor(codegenFs, options) {
2398
+ this.codegenFs = codegenFs;
2399
+ this.options = options;
2400
+ }
2401
+ codegenFs;
2402
+ options;
2403
+ transform() {
2404
+ const { modelFilePath, nameDashCase } = this.options;
2405
+ if (!modelFilePath) return;
2406
+ const servicesFilePath = ensureServicesModule(
2407
+ this.codegenFs,
2408
+ modelFilePath,
2409
+ nameDashCase
2410
+ );
2411
+ transformSourceFile(this.codegenFs, servicesFilePath, (sf) => {
2412
+ ensureNamedImport(sf, SDK_MODULE, [
2413
+ { name: "KosLog" },
2414
+ { name: "FutureResponse", isTypeOnly: true }
2415
+ ]);
2416
+ ensureModuleConst(sf, {
2417
+ name: "log",
2418
+ initializer: `KosLog.createLogger({ name: "${nameDashCase}-service", group: "Services" })`
2419
+ });
2420
+ this.addFutureOperation(sf);
2421
+ this.addProgressTypes(sf);
2422
+ });
2035
2423
  }
2036
- addFutureService(content) {
2424
+ addFutureOperation(sf) {
2037
2425
  const { nameProperCase, nameLowerCase } = this.options;
2038
- if (content.includes(`perform${nameProperCase}Operation`)) {
2039
- return content;
2040
- }
2041
- const futureService = `
2042
- /**
2043
- * @category Service - Future Operation
2044
- * Placeholder for a long-running operation that returns a Future for progress tracking
2045
- *
2046
- * Replace this with your actual long-running service operation
2047
- */
2048
- export const perform${nameProperCase}Operation = async (): Promise<FutureResponse> => {
2049
- // TODO: Implement your long-running service operation here
2050
- // This should return a Future that can be tracked for progress
2051
-
2052
- log.debug('starting long-running ${nameLowerCase} operation');
2053
-
2054
- // Example pattern:
2055
- // return API.post(OPERATION_SERVICE_PATH, {
2056
- // // operation parameters
2057
- // });
2058
-
2059
- // Placeholder - replace with actual implementation
2060
- throw new Error('perform${nameProperCase}Operation not yet implemented');
2061
- };`;
2062
- return content + "\n" + futureService;
2426
+ const name = `perform${nameProperCase}Operation`;
2427
+ if (sf.getVariableDeclaration(name)) return;
2428
+ sf.addVariableStatement({
2429
+ isExported: true,
2430
+ declarationKind: VariableDeclarationKind.Const,
2431
+ declarations: [
2432
+ {
2433
+ name,
2434
+ initializer: `async (): Promise<FutureResponse> => {
2435
+ log.debug("starting long-running ${nameLowerCase} operation");
2436
+ throw new Error("${name} not yet implemented");
2437
+ }`
2438
+ }
2439
+ ],
2440
+ docs: [
2441
+ {
2442
+ description: `@category Service - Future Operation
2443
+ A long-running operation returning a Future for progress tracking. Replace the body with the real service call.`
2444
+ }
2445
+ ]
2446
+ });
2063
2447
  }
2064
- addProgressTypes(content) {
2448
+ addProgressTypes(sf) {
2065
2449
  const { nameProperCase } = this.options;
2066
- if (content.includes(`${nameProperCase}OperationProgress`)) {
2067
- return content;
2068
- }
2069
- const progressTypes = `
2070
- // Additional Future-aware service types (add as needed)
2071
- export type ${nameProperCase}OperationProgress = {
2072
- // Define your progress data structure here
2450
+ ensureExportedTypeAlias(sf, {
2451
+ name: `${nameProperCase}OperationProgress`,
2452
+ type: `{
2073
2453
  stage: string;
2074
2454
  percentComplete: number;
2075
2455
  currentItem?: string;
2076
2456
  totalItems?: number;
2077
- };
2078
-
2079
- export type ${nameProperCase}OperationResult = {
2080
- // Define your operation result structure here
2457
+ }`
2458
+ });
2459
+ ensureExportedTypeAlias(sf, {
2460
+ name: `${nameProperCase}OperationResult`,
2461
+ type: `{
2081
2462
  success: boolean;
2082
2463
  message?: string;
2083
- data?: any;
2084
- };`;
2085
- return content + "\n" + progressTypes;
2464
+ data?: unknown;
2465
+ }`
2466
+ });
2086
2467
  }
2087
2468
  }
2088
2469
  class RegistrationFileTransformer {
@@ -2241,11 +2622,25 @@ function buildDecoratorArgs(options) {
2241
2622
  }
2242
2623
  function addContainerSupportToModel(codegenFs, options, projects) {
2243
2624
  const logger = getCodegenLogger();
2244
- const childType = options.childType?.trim() || "IKosDataModel";
2245
2625
  const { modelFilePath } = resolveModelFilePath(codegenFs, options, projects);
2246
2626
  if (!codegenFs.exists(modelFilePath)) {
2247
2627
  throw new Error(`Model file not found: ${modelFilePath}`);
2248
2628
  }
2629
+ const resolvedChild = options.childModel ? resolveChildModelType(
2630
+ codegenFs,
2631
+ {
2632
+ modelFilePath,
2633
+ modelProject: options.modelProject,
2634
+ childModel: options.childModel,
2635
+ childModelProject: options.childModelProject
2636
+ },
2637
+ projects
2638
+ ) : {
2639
+ childType: options.childType,
2640
+ childTypeModule: options.childTypeModule
2641
+ };
2642
+ const childType = resolvedChild.childType?.trim() || "IKosDataModel";
2643
+ const childTypeModule = resolvedChild.childTypeModule;
2249
2644
  logger.info(
2250
2645
  `Adding container support (<${childType}>) to model: ${options.modelName}`
2251
2646
  );
@@ -2257,6 +2652,10 @@ function addContainerSupportToModel(codegenFs, options, projects) {
2257
2652
  ]);
2258
2653
  if (childType === "IKosDataModel") {
2259
2654
  ensureNamedImport(sf, sdk, [{ name: "IKosDataModel", isTypeOnly: true }]);
2655
+ } else if (childTypeModule) {
2656
+ ensureNamedImport(sf, childTypeModule, [
2657
+ { name: childType, isTypeOnly: true }
2658
+ ]);
2260
2659
  }
2261
2660
  const cls = getModelClass(sf);
2262
2661
  const className = cls.getName();
@@ -2286,6 +2685,140 @@ function addContainerSupportToModel(codegenFs, options, projects) {
2286
2685
  });
2287
2686
  return { modelFilePath };
2288
2687
  }
2688
+ function addParentAwareToModel(codegenFs, options, projects) {
2689
+ const logger = getCodegenLogger();
2690
+ const { modelFilePath } = resolveModelFilePath(codegenFs, options, projects);
2691
+ if (!codegenFs.exists(modelFilePath)) {
2692
+ throw new Error(`Model file not found: ${modelFilePath}`);
2693
+ }
2694
+ logger.info(`Adding parent awareness to model: ${options.modelName}`);
2695
+ let optionsTypeName;
2696
+ transformSourceFile(codegenFs, modelFilePath, (sf) => {
2697
+ const sdk = resolveSdkModuleSpecifier(sf);
2698
+ ensureNamedImport(sf, sdk, [{ name: "kosParentAware" }]);
2699
+ const cls = getModelClass(sf);
2700
+ addClassDecorator(cls, "kosParentAware", {
2701
+ argsText: options.parentId ? `{ parentId: ${JSON.stringify(options.parentId)} }` : ""
2702
+ });
2703
+ const ctor = cls.getConstructors()[0];
2704
+ optionsTypeName = ctor?.getParameters()[1]?.getTypeNode()?.getText()?.replace(/<.*$/, "");
2705
+ });
2706
+ const optionsFilePath = optionsTypeName ? extendOptionsInterface(codegenFs, modelFilePath, optionsTypeName, logger) : void 0;
2707
+ return { modelFilePath, optionsFilePath };
2708
+ }
2709
+ function extendOptionsInterface(codegenFs, modelFilePath, optionsTypeName, logger) {
2710
+ const typesPath = path.join(
2711
+ path.dirname(modelFilePath),
2712
+ "types",
2713
+ "index.d.ts"
2714
+ );
2715
+ const target = codegenFs.exists(typesPath) ? typesPath : modelFilePath;
2716
+ let extended = false;
2717
+ transformSourceFile(codegenFs, target, (sf) => {
2718
+ const iface = sf.getInterface(optionsTypeName);
2719
+ if (!iface) return;
2720
+ const already = iface.getExtends().some((clause) => clause.getText().includes("KosParentAware"));
2721
+ if (already) {
2722
+ extended = true;
2723
+ return;
2724
+ }
2725
+ ensureNamedImport(sf, "@kosdev-code/kos-ui-sdk", [
2726
+ { name: "KosParentAware", isTypeOnly: true }
2727
+ ]);
2728
+ iface.addExtends("KosParentAware");
2729
+ extended = true;
2730
+ });
2731
+ if (!extended) {
2732
+ logger.warn(
2733
+ `Could not find interface ${optionsTypeName}; add "extends KosParentAware" to it by hand.`
2734
+ );
2735
+ return void 0;
2736
+ }
2737
+ return target;
2738
+ }
2739
+ function firstDecoratorArg(decoratorText) {
2740
+ const open = decoratorText.indexOf("(");
2741
+ if (open === -1) return void 0;
2742
+ const inner = decoratorText.slice(open + 1, decoratorText.lastIndexOf(")")).replace(/\s+/g, " ").trim();
2743
+ return inner || void 0;
2744
+ }
2745
+ function collectDecorated(cls, decoratorName) {
2746
+ const members = [];
2747
+ const visit = (name, decoratorTextOf, typeText) => {
2748
+ const text = decoratorTextOf();
2749
+ if (text === void 0) return;
2750
+ members.push({
2751
+ name,
2752
+ arg: firstDecoratorArg(text),
2753
+ type: typeText || void 0
2754
+ });
2755
+ };
2756
+ for (const m of cls.getMethods()) {
2757
+ const dec = m.getDecorator(decoratorName);
2758
+ if (dec) {
2759
+ visit(m.getName(), () => dec.getText(), m.getReturnTypeNode()?.getText());
2760
+ }
2761
+ }
2762
+ for (const p of cls.getProperties()) {
2763
+ const dec = p.getDecorator(decoratorName);
2764
+ if (dec) {
2765
+ visit(p.getName(), () => dec.getText(), p.getTypeNode()?.getText());
2766
+ }
2767
+ }
2768
+ return members;
2769
+ }
2770
+ function describeModel(codegenFs, options, projects) {
2771
+ const { modelFilePath } = resolveModelFilePath(codegenFs, options, projects);
2772
+ const content = codegenFs.read(modelFilePath);
2773
+ if (content === null) {
2774
+ throw new Error(`Model file not found: ${modelFilePath}`);
2775
+ }
2776
+ const project = new Project({ useInMemoryFileSystem: true });
2777
+ const sf = project.createSourceFile(modelFilePath, content, {
2778
+ overwrite: true
2779
+ });
2780
+ let modelType;
2781
+ const modelTypeDecl = sf.getVariableDeclaration("MODEL_TYPE");
2782
+ if (modelTypeDecl) {
2783
+ const init = modelTypeDecl.getInitializer()?.getText();
2784
+ if (init) modelType = init.replace(/^["'`]|["'`]$/g, "");
2785
+ }
2786
+ const cls = sf.getClasses().find((c) => c.getDecorator("kosModel")) ?? sf.getClasses().find((c) => c.isExported()) ?? sf.getClasses()[0];
2787
+ if (!cls) {
2788
+ return {
2789
+ modelFilePath,
2790
+ modelType,
2791
+ classDecorators: [],
2792
+ singleton: false,
2793
+ isCompanion: false,
2794
+ children: [],
2795
+ dependencies: [],
2796
+ topicHandlers: [],
2797
+ configProperties: [],
2798
+ serviceRequests: [],
2799
+ effects: [],
2800
+ futures: []
2801
+ };
2802
+ }
2803
+ const classDecorators = cls.getDecorators().map((d) => d.getName());
2804
+ const kosModelArg = cls.getDecorator("kosModel") ? firstDecoratorArg(cls.getDecorator("kosModel").getText()) : void 0;
2805
+ const singleton = /\bsingleton\s*:\s*true\b/.test(kosModelArg ?? "");
2806
+ return {
2807
+ modelFilePath,
2808
+ modelType,
2809
+ className: cls.getName(),
2810
+ classDecorators,
2811
+ singleton,
2812
+ isCompanion: classDecorators.includes("kosCompanion"),
2813
+ children: collectDecorated(cls, "kosChild"),
2814
+ dependencies: collectDecorated(cls, "kosDependency"),
2815
+ topicHandlers: collectDecorated(cls, "kosTopicHandler"),
2816
+ configProperties: collectDecorated(cls, "kosConfigProperty"),
2817
+ serviceRequests: collectDecorated(cls, "kosServiceRequest"),
2818
+ effects: collectDecorated(cls, "kosModelEffect"),
2819
+ futures: collectDecorated(cls, "kosFuture")
2820
+ };
2821
+ }
2289
2822
  function addModelEffectToModel(codegenFs, options, projects) {
2290
2823
  const logger = getCodegenLogger();
2291
2824
  const { modelFilePath } = resolveModelFilePath(codegenFs, options, projects);
@@ -2361,7 +2894,18 @@ function addChildToModel(codegenFs, options, projects) {
2361
2894
  logger.info(
2362
2895
  `Adding @kosChild "${options.propertyName}" (${shape}) to ${options.modelName}`
2363
2896
  );
2364
- const childType = options.childType?.trim();
2897
+ const resolvedChild = options.childModel ? resolveChildModelType(
2898
+ codegenFs,
2899
+ {
2900
+ modelFilePath,
2901
+ modelProject: options.modelProject,
2902
+ childModel: options.childModel,
2903
+ childModelProject: options.childModelProject
2904
+ },
2905
+ projects
2906
+ ) : { childType: options.childType, childTypeModule: options.childPackage };
2907
+ const childType = resolvedChild.childType?.trim();
2908
+ const childTypeModule = resolvedChild.childTypeModule;
2365
2909
  transformSourceFile(codegenFs, modelFilePath, (sf) => {
2366
2910
  const sdk = resolveSdkModuleSpecifier(sf);
2367
2911
  const sdkImports = [
@@ -2369,8 +2913,8 @@ function addChildToModel(codegenFs, options, projects) {
2369
2913
  ];
2370
2914
  if (shape === "container") sdkImports.push({ name: "KosModelContainer" });
2371
2915
  ensureNamedImport(sf, sdk, sdkImports);
2372
- if (options.childPackage && childType && /^[A-Za-z_$][\w$]*$/.test(childType) && !FRAMEWORK_TYPES.has(childType)) {
2373
- ensureNamedImport(sf, options.childPackage, [
2916
+ if (childTypeModule && childType && /^[A-Za-z_$][\w$]*$/.test(childType) && !FRAMEWORK_TYPES.has(childType)) {
2917
+ ensureNamedImport(sf, childTypeModule, [
2374
2918
  { name: childType, isTypeOnly: true }
2375
2919
  ]);
2376
2920
  }
@@ -2516,6 +3060,154 @@ function addComputedToModel(codegenFs, options, projects) {
2516
3060
  });
2517
3061
  return { modelFilePath };
2518
3062
  }
3063
+ function mocksFilePathFor(modelFilePath, modelBase) {
3064
+ return path.join(
3065
+ path.dirname(modelFilePath),
3066
+ "mocks",
3067
+ `${modelBase}-mocks.ts`
3068
+ );
3069
+ }
3070
+ function mockRegisterFunctionName(modelBase) {
3071
+ return `register${pascalCase(modelBase)}Mocks`;
3072
+ }
3073
+ function toMockRoutePattern(servicePath) {
3074
+ return servicePath.replace(/\{([^}]+)\}/g, ":$1");
3075
+ }
3076
+ function mockRouteCall(method, pattern, body) {
3077
+ const shorthand = {
3078
+ get: "get",
3079
+ post: "post",
3080
+ put: "put",
3081
+ delete: "del"
3082
+ };
3083
+ const fn = shorthand[method];
3084
+ return fn ? `KosMock.${fn}(${JSON.stringify(pattern)}, ${body});` : `KosMock.route(${JSON.stringify(method.toUpperCase())}, ${JSON.stringify(
3085
+ pattern
3086
+ )}, ${body});`;
3087
+ }
3088
+ function header(modelBase, registerFn) {
3089
+ return `/**
3090
+ * KosMock routes for the ${modelBase} model's PROVISIONAL endpoints — the ones
3091
+ * this project's generated OpenAPI types do not declare.
3092
+ *
3093
+ * NOTHING IMPORTS THIS FILE. Call \`${registerFn}()\` from the app's dev entry,
3094
+ * gated so it cannot run in a production build (\`import.meta.env.DEV\`, a
3095
+ * dev-only entry module, or behind your own switch). Registering a route enables
3096
+ * KosMock, and a mock that shipped would shadow the real endpoint once it exists.
3097
+ *
3098
+ * Unmatched requests still pass through to the real device (KosMock's hybrid
3099
+ * default), so these routes shadow only the paths named below. Every mocked
3100
+ * response carries the \`kos-mocked: true\` wire header, so mock-fed data is
3101
+ * identifiable in devtools.
3102
+ *
3103
+ * DELETE THIS FILE once every endpoint below is in the generated types.
3104
+ */
3105
+ `;
3106
+ }
3107
+ function ensureMockRoute(codegenFs, options) {
3108
+ const mocksFilePath = mocksFilePathFor(
3109
+ options.modelFilePath,
3110
+ options.modelBase
3111
+ );
3112
+ const registerFunction = mockRegisterFunctionName(options.modelBase);
3113
+ const routePattern = toMockRoutePattern(options.servicePath);
3114
+ const sampleName = `SAMPLE_${constantCase(dashCase(options.endpointKey))}`;
3115
+ const samplePlaceholder = !options.sampleText;
3116
+ if (!codegenFs.exists(mocksFilePath)) {
3117
+ codegenFs.write(mocksFilePath, header(options.modelBase, registerFunction));
3118
+ }
3119
+ const sdkSpecifier = options.sdkModuleSpecifier.startsWith(".") ? `../${options.sdkModuleSpecifier}` : options.sdkModuleSpecifier;
3120
+ let routeCreated = false;
3121
+ transformSourceFile(codegenFs, mocksFilePath, (sf) => {
3122
+ ensureNamedImport(sf, sdkSpecifier, [{ name: "KosMock" }]);
3123
+ ensureNamedImport(sf, "../services", [
3124
+ { name: options.rawAlias, isTypeOnly: true }
3125
+ ]);
3126
+ ensureSample(sf, {
3127
+ name: sampleName,
3128
+ type: options.rawAlias,
3129
+ servicePath: options.servicePath,
3130
+ method: options.method,
3131
+ sampleText: options.sampleText
3132
+ });
3133
+ const statement = mockRouteCall(
3134
+ options.method,
3135
+ routePattern,
3136
+ `{ data: ${sampleName} }`
3137
+ );
3138
+ routeCreated = ensureRegisteredRoute(sf, {
3139
+ registerFunction,
3140
+ modelBase: options.modelBase,
3141
+ statement,
3142
+ // Matching on the pattern literal alone would collide across methods.
3143
+ marker: `${options.method.toUpperCase()} ${routePattern}`,
3144
+ matches: (existing) => existing.includes(JSON.stringify(routePattern)) && existing.includes(shorthandOrMethod(options.method))
3145
+ });
3146
+ });
3147
+ return {
3148
+ mocksFilePath,
3149
+ registerFunction,
3150
+ routePattern,
3151
+ sampleName,
3152
+ routeCreated,
3153
+ samplePlaceholder
3154
+ };
3155
+ }
3156
+ function shorthandOrMethod(method) {
3157
+ const shorthand = {
3158
+ get: "KosMock.get(",
3159
+ post: "KosMock.post(",
3160
+ put: "KosMock.put(",
3161
+ delete: "KosMock.del("
3162
+ };
3163
+ return shorthand[method] ?? `"${method.toUpperCase()}"`;
3164
+ }
3165
+ function ensureSample(sf, spec) {
3166
+ if (sf.getVariableDeclaration(spec.name)) return;
3167
+ const lines = [
3168
+ `Sample payload for \`${spec.method.toUpperCase()} ${spec.servicePath}\` — the`,
3169
+ "UNWRAPPED `data` payload, i.e. exactly what the transform receives."
3170
+ ];
3171
+ if (!spec.sampleText) {
3172
+ lines.push(
3173
+ "",
3174
+ "TODO: replace the placeholder with something the backend would actually",
3175
+ "serve. Until then the route answers with nothing and the transform is",
3176
+ "never exercised."
3177
+ );
3178
+ }
3179
+ const docs = lines.join("\n");
3180
+ sf.addVariableStatement({
3181
+ isExported: true,
3182
+ declarationKind: VariableDeclarationKind.Const,
3183
+ docs: [docs],
3184
+ declarations: [
3185
+ {
3186
+ name: spec.name,
3187
+ type: spec.type,
3188
+ initializer: spec.sampleText ?? `null as unknown as ${spec.type}`
3189
+ }
3190
+ ]
3191
+ });
3192
+ }
3193
+ function ensureRegisteredRoute(sf, spec) {
3194
+ let fn = sf.getFunction(spec.registerFunction);
3195
+ if (!fn) {
3196
+ fn = sf.addFunction({
3197
+ name: spec.registerFunction,
3198
+ isExported: true,
3199
+ returnType: "void",
3200
+ docs: [
3201
+ `Arm the ${spec.modelBase} model's provisional endpoints. Call this from a dev-only entry point — never from code that ships.`
3202
+ ]
3203
+ });
3204
+ }
3205
+ const already = fn.getStatements().some((statement) => spec.matches(statement.getText()));
3206
+ if (already) return false;
3207
+ fn.addStatements(`// ${spec.marker}
3208
+ ${spec.statement}`);
3209
+ return true;
3210
+ }
2519
3211
  const SERVICE_MODULE_RE = /\/utils\/services\/.*\/service\.ts$/;
2520
3212
  function findServiceModules(codegenFs, sourceRoot) {
2521
3213
  return codegenFs.listFiles(sourceRoot).filter((f) => SERVICE_MODULE_RE.test(f.split(path.sep).join("/")));
@@ -2525,6 +3217,143 @@ function toImportSpecifier(fromFile, toFileNoExt) {
2525
3217
  if (!rel.startsWith(".")) rel = `./${rel}`;
2526
3218
  return rel;
2527
3219
  }
3220
+ function fromImportSpecifier(fromFile, specifier) {
3221
+ return path.join(path.dirname(fromFile), specifier);
3222
+ }
3223
+ function assertServiceModuleAcceptsTransform(codegenFs, serviceModuleFile, modelProject) {
3224
+ const file = `${serviceModuleFile}.ts`;
3225
+ if (!codegenFs.exists(file)) return;
3226
+ const typeParams = readSourceFile(codegenFs, file, (sf) => {
3227
+ const alias = sf.getTypeAlias("EndpointCtx");
3228
+ return alias ? alias.getTypeParameters().length : null;
3229
+ });
3230
+ if (typeParams === null || typeParams >= 2) return;
3231
+ throw new Error(
3232
+ `${file} predates the transform-aware service layer (EndpointCtx takes ${typeParams} type parameter). Run \`kosui api:generate --project ${modelProject} --helpers-only\` to refresh the helper layer in place (no spec fetch, openapi.d.ts untouched), then add the service request.`
3233
+ );
3234
+ }
3235
+ function assertServiceModuleAcceptsProvisional(codegenFs, serviceModuleFile, modelProject) {
3236
+ const file = `${serviceModuleFile}.ts`;
3237
+ if (!codegenFs.exists(file)) return;
3238
+ const declared = readSourceFile(
3239
+ codegenFs,
3240
+ file,
3241
+ (sf) => Boolean(sf.getFunction("provisionalServiceRequest"))
3242
+ );
3243
+ if (declared) return;
3244
+ throw new Error(
3245
+ `${file} has no \`provisionalServiceRequest\` — the helper layer predates provisional endpoints. Run \`kosui api:generate --project ${modelProject} --helpers-only\` to refresh it in place (no spec fetch, openapi.d.ts untouched), then add the service request.`
3246
+ );
3247
+ }
3248
+ function sameEndpoint(existing, candidate) {
3249
+ const args = (text) => {
3250
+ const match = text.match(
3251
+ /^endpoint\s*\(\s*(['"])(.*?)\1(?:\s+as\s+ApiPath)?\s*,\s*(['"])(.*?)\3\s*,?\s*\)$/
3252
+ );
3253
+ return match ? [match[2], match[4].toLowerCase()] : null;
3254
+ };
3255
+ const a = args(existing.trim());
3256
+ const b = args(candidate.trim());
3257
+ return !!a && !!b && a[0] === b[0] && a[1] === b[1];
3258
+ }
3259
+ function editDistance(a, b) {
3260
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
3261
+ for (let i = 1; i <= a.length; i++) {
3262
+ const current = [i];
3263
+ for (let j = 1; j <= b.length; j++) {
3264
+ current[j] = Math.min(
3265
+ previous[j] + 1,
3266
+ current[j - 1] + 1,
3267
+ previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
3268
+ );
3269
+ }
3270
+ previous = current;
3271
+ }
3272
+ return previous[b.length];
3273
+ }
3274
+ const NEAREST_SHOWN = 5;
3275
+ function provisionalEntryComment(servicePath, method, catalogName) {
3276
+ return `/**
3277
+ * PROVISIONAL — \`${method.toUpperCase()} ${servicePath}\` is NOT in this project's
3278
+ * generated OpenAPI types. \`as ApiPath\` is standing in for a \`paths\` entry that
3279
+ * does not exist, so nothing about this endpoint is checked against a spec and
3280
+ * its response cannot be derived from one. Do not read this as a real endpoint.
3281
+ *
3282
+ * WHEN THE ENDPOINT LANDS:
3283
+ * 1. regenerate this project's API types (\`kosui api:generate\`)
3284
+ * 2. delete \` as ApiPath\` from the entry below
3285
+ * 3. point the endpoint's \`…Raw\` alias at
3286
+ * \`EndpointResponse<typeof ${catalogName}.…>\`
3287
+ * 4. swap \`provisionalServiceRequest\` for \`serviceRequest\` in the model
3288
+ * 5. delete the model's \`mocks/\` module
3289
+ * 6. KEEP the transform — it is real code and survives all of the above
3290
+ */
3291
+ `;
3292
+ }
3293
+ function provisionalMethodDocs(servicePath, method) {
3294
+ return [
3295
+ `PROVISIONAL — \`${method.toUpperCase()} ${servicePath}\` is not in the`,
3296
+ "generated OpenAPI types, so this request is served by a KosMock route, not",
3297
+ "by the device. The feature it backs is NOT complete: report the missing",
3298
+ "endpoint rather than treating this as finished.",
3299
+ "",
3300
+ "See the endpoint's entry in `./services` for what to undo when it lands."
3301
+ ].join("\n");
3302
+ }
3303
+ function rawTypeDocs(servicePath, method, rawType) {
3304
+ const shape = rawType ? [
3305
+ "Stated by hand and unverified: there is no spec entry to check it",
3306
+ "against."
3307
+ ] : [
3308
+ "TODO: state the shape the backend is expected to serve. `unknown`",
3309
+ "compiles, but leaves the response boundary undescribed and the",
3310
+ "transform with nothing to narrow."
3311
+ ];
3312
+ return [
3313
+ `Raw wire shape of \`${method.toUpperCase()} ${servicePath}\` — the UNWRAPPED`,
3314
+ "`data` payload, since the client strips the `{status, data}` envelope",
3315
+ "before the transform runs.",
3316
+ "",
3317
+ ...shape,
3318
+ "",
3319
+ "Replace with `EndpointResponse<…>` once the endpoint is in the generated",
3320
+ "types."
3321
+ ].join("\n");
3322
+ }
3323
+ function removalSteps(spec) {
3324
+ return [
3325
+ `Regenerate the API types for ${spec.modelProject} (generate_api_types), and confirm ${spec.method.toUpperCase()} ${spec.servicePath} is now in them.`,
3326
+ `In ${spec.servicesFilePath}: delete \` as ApiPath\` from \`${spec.catalogName}.${spec.endpointKey}\`.`,
3327
+ `In ${spec.servicesFilePath}: point the endpoint's \`…Raw\` alias at \`EndpointResponse<typeof ${spec.catalogName}.${spec.endpointKey}>\` and drop the hand-written shape.`,
3328
+ `In the model: swap \`provisionalServiceRequest\` for \`serviceRequest\` and drop its explicit type arguments — the response type comes from the spec again.`,
3329
+ spec.mocksFilePath ? `Delete ${spec.mocksFilePath} (and the call to its register function in the app) once no provisional endpoint is left in it.` : `Delete the model's mocks module once no provisional endpoint is left in it.`,
3330
+ `KEEP \`${spec.mapperName}\` and the data type it produces. The transform is real code; it is what let the model read the final shape all along.`
3331
+ ];
3332
+ }
3333
+ function untypedEndpointError(spec) {
3334
+ const methodsForPath = spec.operations.filter((op) => op.path === spec.servicePath).map((op) => op.method);
3335
+ const pathTypedForOtherMethods = methodsForPath.length > 0;
3336
+ const nearest = [...new Set(spec.operations.map((op) => op.path))].sort(
3337
+ (a, b) => editDistance(a, spec.servicePath) - editDistance(b, spec.servicePath)
3338
+ ).slice(0, NEAREST_SHOWN);
3339
+ const waysForward = [
3340
+ `The endpoint EXISTS on the device but this project has not pulled types for it: re-run api:generate (generate_api_types) for ${spec.modelProject}, then add the request unchanged. Confirm on the device first — kos-device search_services / describe_endpoint — since the live OpenAPI is what says whether it exists.`,
3341
+ `The endpoint DOES NOT EXIST yet: pass mock:"auto" (the default) or mock:"always" to emit the provisional, mock-backed form — real decorator, lifecycle, error-first handler and transform, with a KosMock route standing in for the wire response.`,
3342
+ `What is NOT supported: falling back to resolveServiceUrl / ServiceFactory.build / getAll to make the UI render. That compiles and looks finished, which is worse than the hole. A missing endpoint is a blocker to report, not a licence to route around the generated service layer.`
3343
+ ];
3344
+ const message = pathTypedForOtherMethods ? `${spec.method.toUpperCase()} ${spec.servicePath} is not in the generated OpenAPI types for ${spec.modelProject} — the path is typed, but only for: ${methodsForPath.map((m) => m.toUpperCase()).join(", ")}.` : `${spec.method.toUpperCase()} ${spec.servicePath} is not in the generated OpenAPI types for ${spec.modelProject} — the path is absent entirely. Nothing was written.`;
3345
+ const error = new Error(message);
3346
+ error.details = {
3347
+ servicePath: spec.servicePath,
3348
+ method: spec.method,
3349
+ pathTypedForOtherMethods,
3350
+ methodsForPath,
3351
+ otherEndpointsInThisApi: nearest,
3352
+ warning: "These are OTHER endpoints in this API, listed only so they can be ruled out — they are almost certainly NOT what you want. Do not substitute one for the requested path because the strings look similar.",
3353
+ waysForward
3354
+ };
3355
+ return error;
3356
+ }
2528
3357
  function addServiceRequestToModel(codegenFs, options, projects) {
2529
3358
  const logger = getCodegenLogger();
2530
3359
  const { modelFilePath, sourceRoot } = resolveModelFilePath(
@@ -2535,14 +3364,15 @@ function addServiceRequestToModel(codegenFs, options, projects) {
2535
3364
  if (!codegenFs.exists(modelFilePath)) {
2536
3365
  throw new Error(`Model file not found: ${modelFilePath}`);
2537
3366
  }
2538
- let serviceImport = options.serviceModule;
2539
- if (!serviceImport && options.serviceModulePath) {
2540
- serviceImport = toImportSpecifier(
3367
+ let serviceModuleFile;
3368
+ if (options.serviceModulePath) {
3369
+ serviceModuleFile = options.serviceModulePath.replace(/\.ts$/, "");
3370
+ } else if (options.serviceModule) {
3371
+ serviceModuleFile = fromImportSpecifier(
2541
3372
  modelFilePath,
2542
- options.serviceModulePath.replace(/\.ts$/, "")
3373
+ options.serviceModule
2543
3374
  );
2544
- }
2545
- if (!serviceImport) {
3375
+ } else {
2546
3376
  if (!sourceRoot) {
2547
3377
  throw new Error(
2548
3378
  "Cannot auto-resolve the service module without a project source root. Pass serviceModule."
@@ -2554,68 +3384,177 @@ function addServiceRequestToModel(codegenFs, options, projects) {
2554
3384
  "No generated service module found. Run `kosui api:generate` for this project first."
2555
3385
  );
2556
3386
  }
2557
- const specifiers = modules.map(
2558
- (m) => toImportSpecifier(modelFilePath, m.replace(/\.ts$/, ""))
2559
- );
2560
3387
  if (modules.length > 1) {
3388
+ const specifiers = modules.map(
3389
+ (m) => toImportSpecifier(modelFilePath, m.replace(/\.ts$/, ""))
3390
+ );
2561
3391
  throw new Error(
2562
3392
  `Multiple service modules found — pass serviceModule (one of): ${specifiers.join(
2563
3393
  ", "
2564
3394
  )}`
2565
3395
  );
2566
3396
  }
2567
- serviceImport = specifiers[0];
3397
+ serviceModuleFile = modules[0].replace(/\.ts$/, "");
2568
3398
  }
2569
- const method = options.method || "get";
3399
+ assertServiceModuleAcceptsTransform(
3400
+ codegenFs,
3401
+ serviceModuleFile,
3402
+ options.modelProject
3403
+ );
3404
+ const modelBase = modelBaseName(modelFilePath, options.modelName);
3405
+ const servicesFilePath = servicesFilePathFor(modelFilePath, modelBase);
3406
+ const serviceImportFromModel = toImportSpecifier(
3407
+ modelFilePath,
3408
+ serviceModuleFile
3409
+ );
3410
+ const serviceImportFromServices = toImportSpecifier(
3411
+ servicesFilePath,
3412
+ serviceModuleFile
3413
+ );
3414
+ const method = (options.method || "get").toLowerCase();
2570
3415
  const mode = options.mode ?? (options.lifecycle ? "lifecycle" : "method");
2571
3416
  const lifecycle = options.lifecycle || "LOAD";
3417
+ const catalogName = `${pascalCase(modelBase)}Endpoints`;
3418
+ const mockMode = options.mock ?? "auto";
3419
+ const operations = readServiceModuleOperations(codegenFs, serviceModuleFile);
3420
+ const pathValidated = operations !== null;
3421
+ const operationTyped = operations === null || operations.some(
3422
+ (op) => op.path === options.servicePath && op.method === method
3423
+ );
3424
+ if (!operationTyped && mockMode === "never") {
3425
+ throw untypedEndpointError({
3426
+ servicePath: options.servicePath,
3427
+ method,
3428
+ modelProject: options.modelProject,
3429
+ operations: operations ?? []
3430
+ });
3431
+ }
3432
+ const provisional = !operationTyped;
3433
+ const writeMock = provisional || mockMode === "always";
3434
+ if (provisional) {
3435
+ assertServiceModuleAcceptsProvisional(
3436
+ codegenFs,
3437
+ serviceModuleFile,
3438
+ options.modelProject
3439
+ );
3440
+ }
2572
3441
  logger.info(
2573
- `Adding ${mode}-driven @kosServiceRequest "${options.methodName}" (${method} ${options.servicePath}) to ${options.modelName}`
3442
+ `Adding ${mode}-driven ${provisional ? "PROVISIONAL " : ""}@kosServiceRequest "${options.methodName}" (${method} ${options.servicePath}) to ${options.modelName}`
2574
3443
  );
3444
+ if (provisional) {
3445
+ logger.warn(
3446
+ `${method.toUpperCase()} ${options.servicePath} is not in ${options.modelProject}'s generated OpenAPI types — emitting the provisional, mock-backed form. The feature is NOT complete until the endpoint lands.`
3447
+ );
3448
+ }
3449
+ ensureServicesModule(codegenFs, modelFilePath, modelBase);
3450
+ let endpointKey = options.methodName;
3451
+ let endpointCreated = true;
3452
+ let dataAlias = "";
3453
+ let mapperName = "";
3454
+ let ctxAlias = "";
3455
+ let rawAlias = "";
3456
+ transformSourceFile(codegenFs, servicesFilePath, (sf) => {
3457
+ ensureNamedImport(sf, serviceImportFromServices, [{ name: "endpoint" }]);
3458
+ if (provisional) {
3459
+ ensureNamedImport(sf, serviceImportFromServices, [
3460
+ { name: "ApiPath", isTypeOnly: true }
3461
+ ]);
3462
+ }
3463
+ const pathText = provisional ? `${JSON.stringify(options.servicePath)} as ApiPath` : JSON.stringify(options.servicePath);
3464
+ const entry = ensureConstCatalogEntry(sf, {
3465
+ catalogName,
3466
+ key: options.methodName,
3467
+ initializer: `endpoint(${pathText}, ${JSON.stringify(method)})`,
3468
+ equals: sameEndpoint,
3469
+ leadingTrivia: provisional ? provisionalEntryComment(options.servicePath, method, catalogName) : void 0
3470
+ });
3471
+ endpointKey = entry.key;
3472
+ endpointCreated = entry.created;
3473
+ dataAlias = `${pascalCase(endpointKey)}Data`;
3474
+ mapperName = `to${pascalCase(endpointKey)}Data`;
3475
+ ctxAlias = `${pascalCase(endpointKey)}Ctx`;
3476
+ rawAlias = `${pascalCase(endpointKey)}Raw`;
3477
+ let rawType;
3478
+ if (provisional) {
3479
+ ensureExportedTypeAlias(sf, {
3480
+ name: rawAlias,
3481
+ type: options.rawType || "unknown",
3482
+ docs: rawTypeDocs(options.servicePath, method, options.rawType)
3483
+ });
3484
+ rawType = rawAlias;
3485
+ } else {
3486
+ rawType = `EndpointResponse<typeof ${catalogName}.${endpointKey}>`;
3487
+ ensureNamedImport(sf, serviceImportFromServices, [
3488
+ { name: "EndpointResponse", isTypeOnly: true }
3489
+ ]);
3490
+ if (writeMock) {
3491
+ ensureExportedTypeAlias(sf, {
3492
+ name: rawAlias,
3493
+ type: rawType,
3494
+ docs: `Raw wire shape of \`${method.toUpperCase()} ${options.servicePath}\`, as the spec declares it — what a mock for this endpoint must serve.`
3495
+ });
3496
+ }
3497
+ }
3498
+ ensureExportedTypeAlias(sf, { name: dataAlias, type: rawType });
3499
+ ensureExportedMapper(sf, {
3500
+ name: mapperName,
3501
+ rawType,
3502
+ dataType: dataAlias
3503
+ });
3504
+ if (mode === "method") {
3505
+ ensureNamedImport(sf, serviceImportFromServices, [
3506
+ { name: "EndpointCtx", isTypeOnly: true }
3507
+ ]);
3508
+ ensureExportedTypeAlias(sf, {
3509
+ name: ctxAlias,
3510
+ type: `EndpointCtx<typeof ${catalogName}.${endpointKey}, ${dataAlias}>`
3511
+ });
3512
+ }
3513
+ });
2575
3514
  transformSourceFile(codegenFs, modelFilePath, (sf) => {
2576
- const endpointConst = endpointConstName(options.methodName);
2577
3515
  const className = getModelClass(sf).getName();
2578
3516
  if (!className) throw new Error("Model class has no name.");
2579
3517
  const typeParams = getModelClass(sf).getTypeParameters().map((tp) => tp.getText());
2580
- ensureNamedImport(sf, serviceImport, [
2581
- { name: "endpoint" },
2582
- { name: "serviceRequest" }
2583
- ]);
2584
- ensureModuleConst(sf, {
2585
- name: endpointConst,
2586
- initializer: `endpoint(${JSON.stringify(
2587
- options.servicePath
2588
- )}, ${JSON.stringify(method)})`
2589
- });
3518
+ const decoratorName = provisional ? "provisionalServiceRequest" : "serviceRequest";
3519
+ const decoratorTypeArgs = provisional ? [rawAlias, dataAlias] : void 0;
3520
+ ensureNamedImport(sf, serviceImportFromModel, [{ name: decoratorName }]);
2590
3521
  if (mode === "lifecycle") {
2591
- ensureNamedImport(sf, serviceImport, [
2592
- { name: "EndpointResponse", isTypeOnly: true }
3522
+ ensureNamedImport(sf, "./services", [
3523
+ { name: catalogName },
3524
+ { name: mapperName },
3525
+ { name: dataAlias, isTypeOnly: true },
3526
+ ...provisional ? [{ name: rawAlias, isTypeOnly: true }] : []
2593
3527
  ]);
2594
3528
  ensureNamedImport(sf, resolveSdkModuleSpecifier(sf), [
2595
3529
  { name: "DependencyLifecycle" }
2596
3530
  ]);
2597
3531
  addDecoratedMethod(getModelClass(sf), {
2598
3532
  name: options.methodName,
2599
- decoratorName: "serviceRequest",
2600
- decoratorArgsText: `${endpointConst}, { lifecycle: DependencyLifecycle.${lifecycle} }`,
3533
+ decoratorName,
3534
+ decoratorTypeArgs,
3535
+ decoratorArgsText: `${catalogName}.${endpointKey}, { lifecycle: DependencyLifecycle.${lifecycle}, transform: ${mapperName} }`,
3536
+ docs: provisional ? provisionalMethodDocs(options.servicePath, method) : void 0,
2601
3537
  // The manager invokes phase handlers error-first, passing (null, data)
2602
3538
  // on success. The `error` half is only ever reached because
2603
3539
  // `serviceRequest` supplies an errorHandler — the bare decorator
2604
3540
  // defaults to `throw`, which fails the phase instead of calling back.
2605
3541
  parameters: [
2606
3542
  { name: "error", type: "string | null" },
2607
- {
2608
- name: "data",
2609
- type: `EndpointResponse<typeof ${endpointConst}>`
2610
- }
3543
+ { name: "data", type: dataAlias }
2611
3544
  ],
2612
3545
  returnType: "void",
2613
3546
  statements: "// TODO: apply `data` to the model"
2614
3547
  });
2615
3548
  return;
2616
3549
  }
2617
- ensureNamedImport(sf, serviceImport, [
2618
- { name: "EndpointCtx", isTypeOnly: true }
3550
+ ensureNamedImport(sf, "./services", [
3551
+ { name: catalogName },
3552
+ { name: mapperName },
3553
+ { name: ctxAlias, isTypeOnly: true },
3554
+ ...provisional ? [
3555
+ { name: rawAlias, isTypeOnly: true },
3556
+ { name: dataAlias, isTypeOnly: true }
3557
+ ] : []
2619
3558
  ]);
2620
3559
  ensureNamedImport(sf, resolveSdkModuleSpecifier(sf), [
2621
3560
  { name: "executeServiceRequest" },
@@ -2630,16 +3569,12 @@ function addServiceRequestToModel(codegenFs, options, projects) {
2630
3569
  addClassDecorator(getModelClass(sf), "kosLoggerAware", { argsText: "" });
2631
3570
  addDecoratedMethod(getModelClass(sf), {
2632
3571
  name: options.methodName,
2633
- decoratorName: "serviceRequest",
2634
- decoratorArgsText: endpointConst,
3572
+ decoratorName,
3573
+ decoratorTypeArgs,
3574
+ decoratorArgsText: `${catalogName}.${endpointKey}, { transform: ${mapperName} }`,
3575
+ docs: provisional ? provisionalMethodDocs(options.servicePath, method) : void 0,
2635
3576
  // Optional: the framework appends the context, callers never pass it.
2636
- parameters: [
2637
- {
2638
- name: "$ctx",
2639
- type: `EndpointCtx<typeof ${endpointConst}>`,
2640
- optional: true
2641
- }
2642
- ],
3577
+ parameters: [{ name: "$ctx", type: ctxAlias, optional: true }],
2643
3578
  isAsync: true,
2644
3579
  returnType: "Promise<void>",
2645
3580
  statements: [
@@ -2649,169 +3584,43 @@ function addServiceRequestToModel(codegenFs, options, projects) {
2649
3584
  ].join("\n")
2650
3585
  });
2651
3586
  });
2652
- return { modelFilePath, serviceModule: serviceImport, mode };
2653
- }
2654
- function endpointConstName(methodName) {
2655
- const snake = methodName.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").toUpperCase();
2656
- return `ENDPOINT_${snake}`;
2657
- }
2658
- function modelElementType(typeText) {
2659
- let m;
2660
- if (m = typeText.match(/^([A-Za-z_]\w*Model)\s*\[\]$/)) return m[1];
2661
- if (m = typeText.match(/^Array<\s*([A-Za-z_]\w*Model)\s*>$/)) return m[1];
2662
- if (m = typeText.match(/^(?:Map|Set)<[^>]*?\b([A-Za-z_]\w*Model)\b[^>]*>$/))
2663
- return m[1];
2664
- return null;
2665
- }
2666
- function validateModel(codegenFs, options, projects) {
2667
- const { modelFilePath } = resolveModelFilePath(codegenFs, options, projects);
2668
- const content = codegenFs.read(modelFilePath);
2669
- if (content === null) {
2670
- throw new Error(`Model file not found: ${modelFilePath}`);
2671
- }
2672
- const project = new Project({ useInMemoryFileSystem: true });
2673
- const sf = project.createSourceFile(modelFilePath, content, {
2674
- overwrite: true
2675
- });
2676
- const findings = [];
2677
- const hasKosModel = sf.getClasses().some((c) => c.getDecorator("kosModel"));
2678
- if (!hasKosModel) {
2679
- findings.push({
2680
- level: "error",
2681
- rule: "missing-kosModel",
2682
- message: "No @kosModel decorator found — this is not a KOS model."
2683
- });
2684
- }
2685
- const imports = sf.getImportDeclarations();
2686
- const mobxImport = imports.find(
2687
- (d) => /^mobx(-react-lite)?$/.test(d.getModuleSpecifierValue())
2688
- );
2689
- if (mobxImport) {
2690
- findings.push({
2691
- level: "error",
2692
- rule: "mobx-import",
2693
- message: "Imports mobx directly. Reactivity is internal to KOS — remove the mobx import and use KOS reactivity / container models."
2694
- });
2695
- }
2696
- const barrelServiceRequest = imports.find(
2697
- (d) => d.getModuleSpecifierValue() === "@kosdev-code/kos-ui-sdk" && d.getNamedImports().some((n) => n.getName() === "kosServiceRequest")
2698
- );
2699
- if (barrelServiceRequest) {
2700
- findings.push({
2701
- level: "warning",
2702
- rule: "untyped-service-request",
2703
- message: "kosServiceRequest imported from the SDK barrel. Use the typed decorator from the api:generate'd service module so paths are validated against the OpenAPI types."
2704
- });
2705
- }
2706
- for (const cls of sf.getClasses()) {
2707
- for (const prop of cls.getProperties()) {
2708
- const name = prop.getName();
2709
- const typeText = (prop.getTypeNode()?.getText() ?? "").replace(/\s+/g, " ").trim();
2710
- const initText = prop.getInitializer()?.getText() ?? "";
2711
- const hasChild = !!prop.getDecorator("kosChild");
2712
- const isModelContainer = /\bI?KosModelContainer\s*</.test(typeText) || /new\s+KosModelContainer\b/.test(initText);
2713
- if (isModelContainer && !hasChild) {
2714
- findings.push({
2715
- level: "warning",
2716
- rule: "container-missing-kosChild",
2717
- message: `Property '${name}' is a model container but is not marked @kosChild — add @kosChild so its models join the model graph and lifecycle.`
2718
- });
2719
- continue;
2720
- }
2721
- const elem = modelElementType(typeText);
2722
- if (elem && !isModelContainer) {
2723
- findings.push({
2724
- level: "warning",
2725
- rule: "raw-model-collection",
2726
- message: `Property '${name}' is a raw ${typeText} of models — prefer a KosModelContainer<${elem}> (indexing, sorting, lifecycle, delta handling) marked @kosChild.`
2727
- });
2728
- }
2729
- }
2730
- }
2731
- const hasError = findings.some((f) => f.level === "error");
2732
- return { modelFilePath, ok: !hasError, findings };
2733
- }
2734
- function firstDecoratorArg(decoratorText) {
2735
- const open = decoratorText.indexOf("(");
2736
- if (open === -1) return void 0;
2737
- const inner = decoratorText.slice(open + 1, decoratorText.lastIndexOf(")")).replace(/\s+/g, " ").trim();
2738
- return inner || void 0;
2739
- }
2740
- function collectDecorated(cls, decoratorName) {
2741
- const members = [];
2742
- const visit = (name, decoratorTextOf, typeText) => {
2743
- const text = decoratorTextOf();
2744
- if (text === void 0) return;
2745
- members.push({
2746
- name,
2747
- arg: firstDecoratorArg(text),
2748
- type: typeText || void 0
2749
- });
2750
- };
2751
- for (const m of cls.getMethods()) {
2752
- const dec = m.getDecorator(decoratorName);
2753
- if (dec) {
2754
- visit(m.getName(), () => dec.getText(), m.getReturnTypeNode()?.getText());
2755
- }
2756
- }
2757
- for (const p of cls.getProperties()) {
2758
- const dec = p.getDecorator(decoratorName);
2759
- if (dec) {
2760
- visit(p.getName(), () => dec.getText(), p.getTypeNode()?.getText());
2761
- }
2762
- }
2763
- return members;
2764
- }
2765
- function describeModel(codegenFs, options, projects) {
2766
- const { modelFilePath } = resolveModelFilePath(codegenFs, options, projects);
2767
- const content = codegenFs.read(modelFilePath);
2768
- if (content === null) {
2769
- throw new Error(`Model file not found: ${modelFilePath}`);
2770
- }
2771
- const project = new Project({ useInMemoryFileSystem: true });
2772
- const sf = project.createSourceFile(modelFilePath, content, {
2773
- overwrite: true
2774
- });
2775
- let modelType;
2776
- const modelTypeDecl = sf.getVariableDeclaration("MODEL_TYPE");
2777
- if (modelTypeDecl) {
2778
- const init = modelTypeDecl.getInitializer()?.getText();
2779
- if (init) modelType = init.replace(/^["'`]|["'`]$/g, "");
2780
- }
2781
- const cls = sf.getClasses().find((c) => c.getDecorator("kosModel")) ?? sf.getClasses().find((c) => c.isExported()) ?? sf.getClasses()[0];
2782
- if (!cls) {
2783
- return {
3587
+ const mock = writeMock ? ensureMockRoute(codegenFs, {
3588
+ modelFilePath,
3589
+ modelBase,
3590
+ sdkModuleSpecifier: readSourceFile(
3591
+ codegenFs,
2784
3592
  modelFilePath,
2785
- modelType,
2786
- classDecorators: [],
2787
- singleton: false,
2788
- isCompanion: false,
2789
- children: [],
2790
- dependencies: [],
2791
- topicHandlers: [],
2792
- configProperties: [],
2793
- serviceRequests: [],
2794
- effects: [],
2795
- futures: []
2796
- };
2797
- }
2798
- const classDecorators = cls.getDecorators().map((d) => d.getName());
2799
- const kosModelArg = cls.getDecorator("kosModel") ? firstDecoratorArg(cls.getDecorator("kosModel").getText()) : void 0;
2800
- const singleton = /\bsingleton\s*:\s*true\b/.test(kosModelArg ?? "");
3593
+ resolveSdkModuleSpecifier
3594
+ ),
3595
+ rawAlias,
3596
+ servicePath: options.servicePath,
3597
+ method,
3598
+ endpointKey,
3599
+ sampleText: options.sample
3600
+ }) : void 0;
2801
3601
  return {
2802
3602
  modelFilePath,
2803
- modelType,
2804
- className: cls.getName(),
2805
- classDecorators,
2806
- singleton,
2807
- isCompanion: classDecorators.includes("kosCompanion"),
2808
- children: collectDecorated(cls, "kosChild"),
2809
- dependencies: collectDecorated(cls, "kosDependency"),
2810
- topicHandlers: collectDecorated(cls, "kosTopicHandler"),
2811
- configProperties: collectDecorated(cls, "kosConfigProperty"),
2812
- serviceRequests: collectDecorated(cls, "kosServiceRequest"),
2813
- effects: collectDecorated(cls, "kosModelEffect"),
2814
- futures: collectDecorated(cls, "kosFuture")
3603
+ servicesFilePath,
3604
+ serviceModule: serviceImportFromModel,
3605
+ mode,
3606
+ endpointKey,
3607
+ endpointCreated,
3608
+ pathValidated,
3609
+ provisional,
3610
+ mocksFilePath: mock?.mocksFilePath,
3611
+ mockRegisterFunction: mock?.registerFunction,
3612
+ mockRoute: mock && `${method.toUpperCase()} ${mock.routePattern}`,
3613
+ mockSamplePlaceholder: mock?.samplePlaceholder,
3614
+ removalSteps: provisional ? removalSteps({
3615
+ modelProject: options.modelProject,
3616
+ servicePath: options.servicePath,
3617
+ method,
3618
+ catalogName,
3619
+ endpointKey,
3620
+ servicesFilePath,
3621
+ mocksFilePath: mock?.mocksFilePath,
3622
+ mapperName
3623
+ }) : void 0
2815
3624
  };
2816
3625
  }
2817
3626
  const DEFAULT_SDK_PACKAGE = "@kosdev-code/kos-ui-sdk";
@@ -2997,6 +3806,82 @@ function lookupSdkType(codegenFs, options, projects) {
2997
3806
  )}). Check the name, or it may be internal / not part of the public surface.`
2998
3807
  };
2999
3808
  }
3809
+ function modelElementType(typeText) {
3810
+ let m;
3811
+ if (m = typeText.match(/^([A-Za-z_]\w*Model)\s*\[\]$/)) return m[1];
3812
+ if (m = typeText.match(/^Array<\s*([A-Za-z_]\w*Model)\s*>$/)) return m[1];
3813
+ if (m = typeText.match(/^(?:Map|Set)<[^>]*?\b([A-Za-z_]\w*Model)\b[^>]*>$/))
3814
+ return m[1];
3815
+ return null;
3816
+ }
3817
+ function validateModel(codegenFs, options, projects) {
3818
+ const { modelFilePath } = resolveModelFilePath(codegenFs, options, projects);
3819
+ const content = codegenFs.read(modelFilePath);
3820
+ if (content === null) {
3821
+ throw new Error(`Model file not found: ${modelFilePath}`);
3822
+ }
3823
+ const project = new Project({ useInMemoryFileSystem: true });
3824
+ const sf = project.createSourceFile(modelFilePath, content, {
3825
+ overwrite: true
3826
+ });
3827
+ const findings = [];
3828
+ const hasKosModel = sf.getClasses().some((c) => c.getDecorator("kosModel"));
3829
+ if (!hasKosModel) {
3830
+ findings.push({
3831
+ level: "error",
3832
+ rule: "missing-kosModel",
3833
+ message: "No @kosModel decorator found — this is not a KOS model."
3834
+ });
3835
+ }
3836
+ const imports = sf.getImportDeclarations();
3837
+ const mobxImport = imports.find(
3838
+ (d) => /^mobx(-react-lite)?$/.test(d.getModuleSpecifierValue())
3839
+ );
3840
+ if (mobxImport) {
3841
+ findings.push({
3842
+ level: "error",
3843
+ rule: "mobx-import",
3844
+ message: "Imports mobx directly. Reactivity is internal to KOS — remove the mobx import and use KOS reactivity / container models."
3845
+ });
3846
+ }
3847
+ const barrelServiceRequest = imports.find(
3848
+ (d) => d.getModuleSpecifierValue() === "@kosdev-code/kos-ui-sdk" && d.getNamedImports().some((n) => n.getName() === "kosServiceRequest")
3849
+ );
3850
+ if (barrelServiceRequest) {
3851
+ findings.push({
3852
+ level: "warning",
3853
+ rule: "untyped-service-request",
3854
+ message: "kosServiceRequest imported from the SDK barrel. Use the typed decorator from the api:generate'd service module so paths are validated against the OpenAPI types."
3855
+ });
3856
+ }
3857
+ for (const cls of sf.getClasses()) {
3858
+ for (const prop of cls.getProperties()) {
3859
+ const name = prop.getName();
3860
+ const typeText = (prop.getTypeNode()?.getText() ?? "").replace(/\s+/g, " ").trim();
3861
+ const initText = prop.getInitializer()?.getText() ?? "";
3862
+ const hasChild = !!prop.getDecorator("kosChild");
3863
+ const isModelContainer = /\bI?KosModelContainer\s*</.test(typeText) || /new\s+KosModelContainer\b/.test(initText);
3864
+ if (isModelContainer && !hasChild) {
3865
+ findings.push({
3866
+ level: "warning",
3867
+ rule: "container-missing-kosChild",
3868
+ message: `Property '${name}' is a model container but is not marked @kosChild — add @kosChild so its models join the model graph and lifecycle.`
3869
+ });
3870
+ continue;
3871
+ }
3872
+ const elem = modelElementType(typeText);
3873
+ if (elem && !isModelContainer) {
3874
+ findings.push({
3875
+ level: "warning",
3876
+ rule: "raw-model-collection",
3877
+ message: `Property '${name}' is a raw ${typeText} of models — prefer a KosModelContainer<${elem}> (indexing, sorting, lifecycle, delta handling) marked @kosChild.`
3878
+ });
3879
+ }
3880
+ }
3881
+ }
3882
+ const hasError = findings.some((f) => f.level === "error");
3883
+ return { modelFilePath, ok: !hasError, findings };
3884
+ }
3000
3885
  const PLUGIN_TYPES = {
3001
3886
  CUI: "cui",
3002
3887
  UTILITY: "utility",
@@ -3551,9 +4436,76 @@ function updatePluginConfiguration(codegenFs, projectConfig, options) {
3551
4436
  );
3552
4437
  }
3553
4438
  }
4439
+ function parse(content) {
4440
+ const singleQuoted = /^\s*import\b[^"']*'/m.test(content);
4441
+ const project = new Project({
4442
+ useInMemoryFileSystem: true,
4443
+ manipulationSettings: {
4444
+ indentationText: IndentationText.TwoSpaces,
4445
+ quoteKind: singleQuoted ? QuoteKind.Single : QuoteKind.Double
4446
+ }
4447
+ });
4448
+ return project.createSourceFile("registration-chain.ts", content, {
4449
+ overwrite: true
4450
+ });
4451
+ }
4452
+ function chainCalls(sf, name) {
4453
+ return sf.getDescendantsOfKind(SyntaxKind.CallExpression).filter((call) => {
4454
+ const callee = call.getExpression();
4455
+ return callee.getKind() === SyntaxKind.PropertyAccessExpression && callee.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName() === name;
4456
+ });
4457
+ }
4458
+ function indentOfCall(sf, call) {
4459
+ const text = sf.getFullText();
4460
+ const nameNode = call.getExpression().asKindOrThrow(SyntaxKind.PropertyAccessExpression).getNameNode();
4461
+ const lineStart = text.lastIndexOf("\n", nameNode.getStart()) + 1;
4462
+ return text.slice(lineStart).match(/^[ \t]*/)?.[0] ?? "";
4463
+ }
4464
+ function countChainEntries(content) {
4465
+ return chainCalls(parse(content), "model").length;
4466
+ }
4467
+ function upsertChainEntry(content, bean, importSpec) {
4468
+ const sf = parse(content);
4469
+ const modelCalls = chainCalls(sf, "model");
4470
+ const registered = modelCalls.some((call) => {
4471
+ const [arg, ...rest] = call.getArguments();
4472
+ return rest.length === 0 && arg?.getText() === bean;
4473
+ });
4474
+ if (registered) return { changed: false, note: "already registered" };
4475
+ let anchor;
4476
+ let indent;
4477
+ if (modelCalls.length > 0) {
4478
+ anchor = modelCalls.reduce(
4479
+ (last, call) => call.getEnd() > last.getEnd() ? call : last
4480
+ );
4481
+ indent = indentOfCall(sf, anchor);
4482
+ } else {
4483
+ const starts = chainCalls(sf, "models").filter(
4484
+ (call) => call.getArguments().length === 0
4485
+ );
4486
+ anchor = starts[starts.length - 1];
4487
+ if (!anchor) {
4488
+ return {
4489
+ changed: false,
4490
+ error: "no .models() chain found in the registration file"
4491
+ };
4492
+ }
4493
+ const text = sf.getFullText();
4494
+ const lineStart = text.lastIndexOf("\n", anchor.getStart()) + 1;
4495
+ indent = (text.slice(lineStart).match(/^[ \t]*/)?.[0] ?? "") + " ";
4496
+ }
4497
+ anchor.replaceWithText(`${anchor.getText()}
4498
+ ${indent}.model(${bean})`);
4499
+ const imported = sf.getImportDeclarations().some(
4500
+ (decl) => decl.getNamedImports().some((named) => named.getName() === bean)
4501
+ );
4502
+ if (!imported) ensureNamedImport(sf, importSpec, [{ name: bean }]);
4503
+ return { changed: true, content: sf.getFullText(), importAdded: !imported };
4504
+ }
3554
4505
  export {
3555
4506
  BasePluginHandler,
3556
4507
  CONTRIBUTION_TYPE_MAP,
4508
+ DEFAULT_MODEL_LIBS_DIR,
3557
4509
  DirectFileSystem,
3558
4510
  KAB_OUTPUT_DIR,
3559
4511
  KAB_OUTPUT_PATH,
@@ -3575,6 +4527,7 @@ export {
3575
4527
  addJavaArtifactToManifests,
3576
4528
  addKosModelConfiguration,
3577
4529
  addModelEffectToModel,
4530
+ addParentAwareToModel,
3578
4531
  addPropertyToModel,
3579
4532
  addServiceRequestToModel,
3580
4533
  addTopicHandlerToModel,
@@ -3583,6 +4536,7 @@ export {
3583
4536
  buildSbomTarget,
3584
4537
  camelCase,
3585
4538
  constantCase,
4539
+ countChainEntries,
3586
4540
  dashCase,
3587
4541
  describeModel,
3588
4542
  discoverJavaArtifacts,
@@ -3601,8 +4555,10 @@ export {
3601
4555
  generateHook,
3602
4556
  generateInit,
3603
4557
  generateModel,
4558
+ generateModelProject,
3604
4559
  generatePolyglotWorkspace,
3605
4560
  generateSplashProject,
4561
+ generateViewModel,
3606
4562
  getCodegenLogger,
3607
4563
  getCurrentDirectoryName,
3608
4564
  getKosModelConfigProp,
@@ -3620,10 +4576,12 @@ export {
3620
4576
  readNxJson,
3621
4577
  resolveKabPath,
3622
4578
  resolveModelFilePath,
4579
+ resolveModelProjectLayout,
3623
4580
  setCodegenLogger,
3624
4581
  syncCiManifests,
3625
4582
  updateJson,
3626
4583
  updateModelIndex,
4584
+ upsertChainEntry,
3627
4585
  validateModel,
3628
4586
  writeJson
3629
4587
  };