@mettlecast/domain-cli 0.2.59 → 0.2.61

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 (119) hide show
  1. package/dist/builder/build-registry.d.ts +1 -1
  2. package/dist/builder/build-registry.js +129 -49
  3. package/dist/builder/build-types.d.ts +1 -1
  4. package/dist/builder/load-module.d.ts +1 -1
  5. package/dist/builder/load-module.js +3 -3
  6. package/dist/cli.js +3 -3
  7. package/dist/commands/add-api.js +2 -2
  8. package/dist/commands/add-domain.js +4 -4
  9. package/dist/commands/add-fixture-factory.js +5 -6
  10. package/dist/commands/build-catalog.d.ts +8 -24
  11. package/dist/commands/build-catalog.js +12 -23
  12. package/dist/commands/build-flows.js +1 -1
  13. package/dist/commands/build.js +7 -6
  14. package/dist/commands/check-hashes.js +2 -2
  15. package/dist/commands/create-project.js +1 -1
  16. package/dist/commands/dev.js +1 -1
  17. package/dist/commands/doctor.d.ts +5 -7
  18. package/dist/commands/doctor.js +110 -202
  19. package/dist/commands/explain.js +13 -13
  20. package/dist/commands/generate-openapi.d.ts +10 -1
  21. package/dist/commands/generate-openapi.js +19 -33
  22. package/dist/commands/power-tune.js +2 -2
  23. package/dist/commands/show-dns.d.ts +1 -1
  24. package/dist/commands/show-dns.js +5 -5
  25. package/dist/commands/show.d.ts +2 -3
  26. package/dist/commands/show.js +0 -2
  27. package/dist/commands/test.js +0 -1
  28. package/dist/commands/upgrade-backend.js +3 -3
  29. package/dist/commands/upgrade.js +17 -11
  30. package/dist/commands/validate.js +144 -39
  31. package/dist/server/api-server.d.ts +1 -1
  32. package/dist/server/mount-routes.d.ts +11 -2
  33. package/dist/server/mount-routes.js +20 -8
  34. package/dist/templates/api-skeleton.d.ts +5 -0
  35. package/dist/templates/api-skeleton.js +28 -27
  36. package/dist/templates/claude-md.js +1 -1
  37. package/dist/templates/patterns/api/create-with-event.d.ts +4 -0
  38. package/dist/templates/patterns/api/create-with-event.js +38 -32
  39. package/dist/templates/patterns/api/idempotent-mutation.d.ts +4 -0
  40. package/dist/templates/patterns/api/idempotent-mutation.js +47 -41
  41. package/dist/templates/patterns/api/paginated-list.d.ts +4 -0
  42. package/dist/templates/patterns/api/paginated-list.js +30 -24
  43. package/dist/templates/patterns/api/simple-crud.d.ts +4 -0
  44. package/dist/templates/patterns/api/simple-crud.js +46 -35
  45. package/dist/templates/patterns/api/streaming-list.d.ts +4 -0
  46. package/dist/templates/patterns/api/streaming-list.js +46 -41
  47. package/dist/templates/patterns/api/system-admin.d.ts +4 -0
  48. package/dist/templates/patterns/api/system-admin.js +59 -52
  49. package/dist/templates/patterns/api/webhook-receiver-style.d.ts +4 -0
  50. package/dist/templates/patterns/api/webhook-receiver-style.js +43 -35
  51. package/dist/types.d.ts +100 -0
  52. package/dist/types.js +1 -0
  53. package/dist/utils/file-helpers.d.ts +0 -2
  54. package/dist/utils/file-helpers.js +2 -3
  55. package/dist/utils/header-inject.js +2 -2
  56. package/dist/utils/install-file.d.ts +1 -1
  57. package/dist/utils/install-file.js +1 -1
  58. package/dist/utils/manifest.js +1 -2
  59. package/dist/utils/scaffold-config.d.ts +8 -2
  60. package/dist/utils/scaffold-config.js +3 -1
  61. package/package.json +1 -1
  62. package/src/__tests__/build-registry.test.ts +43 -20
  63. package/src/__tests__/build-types.test.ts +4 -7
  64. package/src/__tests__/builder/walkDomainDir.test.ts +19 -21
  65. package/src/__tests__/commands/add-api.test.ts +12 -10
  66. package/src/__tests__/commands/add-domain.test.ts +8 -5
  67. package/src/__tests__/commands/check-hashes.test.ts +9 -9
  68. package/src/__tests__/commands/create-project.test.ts +5 -5
  69. package/src/__tests__/commands/dev.test.ts +0 -1
  70. package/src/__tests__/commands/upgrade.test.ts +7 -7
  71. package/src/__tests__/doctor.test.ts +60 -67
  72. package/src/__tests__/mount-routes.test.ts +64 -23
  73. package/src/__tests__/package-freshness.test.ts +94 -0
  74. package/src/__tests__/scaffold-src/part-a-layout.test.ts +10 -10
  75. package/src/__tests__/scripts/package-scaffold.test.ts +5 -5
  76. package/src/__tests__/smoke/scaffold.test.ts +13 -15
  77. package/src/__tests__/utils/install-file.test.ts +2 -2
  78. package/src/__tests__/utils/manifest.test.ts +2 -2
  79. package/src/__tests__/validate.test.ts +570 -1
  80. package/src/builder/build-registry.ts +154 -59
  81. package/src/builder/build-types.ts +1 -1
  82. package/src/builder/load-module.ts +3 -3
  83. package/src/cli.ts +4 -4
  84. package/src/commands/add-api.ts +2 -2
  85. package/src/commands/add-domain.ts +4 -4
  86. package/src/commands/add-fixture-factory.ts +5 -6
  87. package/src/commands/build-catalog.ts +18 -40
  88. package/src/commands/build-flows.ts +1 -1
  89. package/src/commands/build.ts +8 -7
  90. package/src/commands/check-hashes.ts +2 -2
  91. package/src/commands/create-project.ts +1 -1
  92. package/src/commands/dev.ts +1 -1
  93. package/src/commands/doctor.ts +120 -218
  94. package/src/commands/explain.ts +13 -13
  95. package/src/commands/generate-openapi.ts +30 -52
  96. package/src/commands/power-tune.ts +2 -2
  97. package/src/commands/show-dns.ts +5 -5
  98. package/src/commands/show.ts +2 -5
  99. package/src/commands/test.ts +0 -1
  100. package/src/commands/upgrade-backend.ts +3 -3
  101. package/src/commands/upgrade.ts +16 -10
  102. package/src/commands/validate.ts +180 -40
  103. package/src/server/api-server.ts +1 -1
  104. package/src/server/mount-routes.ts +21 -10
  105. package/src/templates/api-skeleton.ts +29 -28
  106. package/src/templates/claude-md.ts +1 -1
  107. package/src/templates/patterns/api/create-with-event.ts +39 -33
  108. package/src/templates/patterns/api/idempotent-mutation.ts +48 -42
  109. package/src/templates/patterns/api/paginated-list.ts +31 -25
  110. package/src/templates/patterns/api/simple-crud.ts +47 -36
  111. package/src/templates/patterns/api/streaming-list.ts +47 -42
  112. package/src/templates/patterns/api/system-admin.ts +60 -53
  113. package/src/templates/patterns/api/webhook-receiver-style.ts +48 -40
  114. package/src/types.ts +128 -0
  115. package/src/utils/file-helpers.ts +2 -5
  116. package/src/utils/header-inject.ts +2 -2
  117. package/src/utils/install-file.ts +1 -1
  118. package/src/utils/manifest.ts +1 -2
  119. package/src/utils/scaffold-config.ts +12 -3
@@ -18,15 +18,13 @@ export const DOCTOR_FIX_FLAG = '--fix';
18
18
  export interface DoctorOptions {
19
19
  /** Root directory of the project (defaults to cwd). */
20
20
  projectRoot?: string;
21
- /** Flag to run relocation of old-layout scaffold files to new layout. */
21
+ /** Deprecated legacy flag retained as a no-op for older automation. */
22
22
  relocate?: boolean;
23
23
  /**
24
- * Auto-remediate before reporting. --fix is a superset of --relocate: it
25
- * first runs the relocation routine (auto-moves owned scaffold files outside
26
- * .mc/ into .mc/ and updates .mc/manifest.json), then continues with the
27
- * full doctor report. On a project that is already clean, --fix is a no-op
28
- * (0 files moved, exit 0). Does NOT auto-generate missing fixtures
29
- * (deferred).
24
+ * Auto-remediate before reporting. The legacy relocation routine is now a
25
+ * no-op because current scaffold manifests intentionally track managed files
26
+ * in their installed project locations (infra/modules, domains, .github, etc.).
27
+ * Does NOT auto-generate missing fixtures (deferred).
30
28
  */
31
29
  fix?: boolean;
32
30
  /**
@@ -123,8 +121,10 @@ async function checkHandlersUseResult(projectRoot: string): Promise<DoctorCheck>
123
121
  const missing: string[] = [];
124
122
  for (const file of apiFiles) {
125
123
  const content = await readFile(file, 'utf8');
126
- // A file contains a handler export — check the return type
127
- if (/defineApi\s*\(/.test(content)) {
124
+ // A file contains a handler export — check the return type.
125
+ // Issue #4689: defineApi was removed. The action-first
126
+ // contract uses defineAction with exposure.type='api'.
127
+ if (/defineAction\s*\(/.test(content)) {
128
128
  // Look for Result<T> in the handler's return type annotation
129
129
  if (!/: .*Result</.test(content)) {
130
130
  missing.push(relative(projectRoot, file));
@@ -224,16 +224,15 @@ async function checkGeneratedClientsFresh(projectRoot: string): Promise<DoctorCh
224
224
  export async function runDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
225
225
  const projectRoot = opts.projectRoot ?? process.cwd();
226
226
 
227
- // --relocate runs ONLY the relocation routine (existing behaviour).
228
- // --fix is a superset: it runs relocation first, then the full report.
227
+ // --relocate is retained as a safe no-op for older automation.
228
+ // --fix runs that no-op preflight first, then the full report.
229
229
  // When both are set, --fix takes precedence so the report still runs.
230
230
  if (opts.relocate && !opts.fix) {
231
231
  return runRelocate(projectRoot);
232
232
  }
233
233
 
234
- // --fix preflight: run the relocation routine and merge its checks into
235
- // the doctor report. On a clean project (no files outside .mc/ to move)
236
- // runRelocate returns a no-op summary check (0 moved, 0 warned, 8 skipped).
234
+ // --fix preflight: run the legacy relocation no-op and merge its check into
235
+ // the doctor report.
237
236
  let preflightChecks: DoctorCheck[] = [];
238
237
  if (opts.fix) {
239
238
  const relocationReport = await runRelocate(projectRoot);
@@ -451,49 +450,64 @@ async function checkNoCrossDomainImports(projectRoot: string): Promise<DoctorChe
451
450
  async function checkApisHaveVersions(projectRoot: string): Promise<DoctorCheck> {
452
451
  try {
453
452
  const domainsDir = join(projectRoot, 'domains');
454
- const allApiFiles: string[] = [];
453
+ const allActionFiles: string[] = [];
455
454
 
456
- // Find all API files in all domains
455
+ // Find API-exposed action files in both actions/ and api/ directories.
456
+ // Issue #4689: defineApi was removed. API-exposed actions live in
457
+ // domains/*/actions/ but legacy api/ directories may still exist.
457
458
  const entries = await readdir(domainsDir, { withFileTypes: true });
458
459
  for (const entry of entries) {
459
460
  if (!entry.isDirectory()) continue;
460
- const apiDir = join(domainsDir, entry.name, 'api');
461
- const apiFiles = findFiles(apiDir, /\.ts$/);
462
- allApiFiles.push(...apiFiles);
461
+ for (const dir of ['actions', 'api']) {
462
+ const actionDir = join(domainsDir, entry.name, dir);
463
+ const files = findFiles(actionDir, /\.ts$/);
464
+ allActionFiles.push(...files);
465
+ }
463
466
  }
464
- const apiFiles = allApiFiles;
465
467
 
466
- if (apiFiles.length === 0) {
468
+ // Only consider files that define an API-exposed action (defineAction
469
+ // with exposure.type === 'api' or exposure: { type: 'api' }).
470
+ const apiExposedFiles: string[] = [];
471
+ for (const file of allActionFiles) {
472
+ const content = await readFile(file, 'utf8');
473
+ if (/defineAction\s*\(/.test(content) && /exposure\s*:\s*\{\s*type\s*:\s*['"]api['"]/.test(content)) {
474
+ apiExposedFiles.push(file);
475
+ }
476
+ }
477
+
478
+ if (apiExposedFiles.length === 0) {
467
479
  return {
468
480
  name: 'APIs declare versions',
469
481
  status: 'PASS',
470
- message: 'No APIs found (optional)',
482
+ message: 'No API-exposed actions found (optional)',
471
483
  kNodeRef: 'K:runbook:add-domain',
472
484
  };
473
485
  }
474
486
 
475
- // Simple heuristic: grep for 'versions:' in each API file
476
- let missingVersions = 0;
477
- for (const file of apiFiles) {
487
+ // Issue #4689: action-first contract requires input/output Zod schemas
488
+ // with `.default({...})` example data. Check that each API-exposed
489
+ // action file contains `.default(` (indicating example data).
490
+ let missingDefaults = 0;
491
+ for (const file of apiExposedFiles) {
478
492
  const content = await readFile(file, 'utf8');
479
- if (!content.includes('versions:')) {
480
- missingVersions++;
493
+ if (!content.includes('.default(')) {
494
+ missingDefaults++;
481
495
  }
482
496
  }
483
497
 
484
- if (missingVersions === 0) {
498
+ if (missingDefaults === 0) {
485
499
  return {
486
500
  name: 'APIs declare versions',
487
501
  status: 'PASS',
488
- message: `All ${apiFiles.length} API files declare versions`,
502
+ message: `All ${apiExposedFiles.length} API-exposed action(s) have .default() example data`,
489
503
  kNodeRef: 'K:runbook:add-domain',
490
504
  };
491
505
  } else {
492
506
  return {
493
507
  name: 'APIs declare versions',
494
508
  status: 'FAIL',
495
- message: `${missingVersions}/${apiFiles.length} API files missing versions: field`,
496
- fixHint: 'Add versions: { v1: { ... } } to defineApi calls',
509
+ message: `${missingDefaults}/${apiExposedFiles.length} API-exposed action(s) missing .default() example data. Add \`.default({...})\` to the top-level input and output Zod schemas in each defineAction({ exposure: { type: 'api', ... } }) call.`,
510
+ fixHint: "Add `input` and `output` Zod schemas with `.default({...})` to defineAction({ exposure: { type: 'api', ... } }) calls",
497
511
  kNodeRef: 'K:runbook:add-domain',
498
512
  };
499
513
  }
@@ -501,7 +515,7 @@ async function checkApisHaveVersions(projectRoot: string): Promise<DoctorCheck>
501
515
  return {
502
516
  name: 'APIs declare versions',
503
517
  status: 'WARN',
504
- message: `Could not check API versions: ${String(err)}`,
518
+ message: `Could not check API schemas: ${String(err)}`,
505
519
  kNodeRef: 'K:runbook:add-domain',
506
520
  };
507
521
  }
@@ -510,30 +524,42 @@ async function checkApisHaveVersions(projectRoot: string): Promise<DoctorCheck>
510
524
  async function checkApisHaveTenancy(projectRoot: string): Promise<DoctorCheck> {
511
525
  try {
512
526
  const domainsDir = join(projectRoot, 'domains');
513
- const allApiFiles: string[] = [];
527
+ const allActionFiles: string[] = [];
514
528
 
515
- // Find all API files in all domains
529
+ // Find API-exposed action files in both actions/ and api/ directories.
530
+ // Issue #4689: defineApi was removed. Tenancy is now declared on the
531
+ // action's exposure block via `exposure.tenancy`.
516
532
  const entries = await readdir(domainsDir, { withFileTypes: true });
517
533
  for (const entry of entries) {
518
534
  if (!entry.isDirectory()) continue;
519
- const apiDir = join(domainsDir, entry.name, 'api');
520
- const apiFiles = findFiles(apiDir, /\.ts$/);
521
- allApiFiles.push(...apiFiles);
535
+ for (const dir of ['actions', 'api']) {
536
+ const actionDir = join(domainsDir, entry.name, dir);
537
+ const files = findFiles(actionDir, /\.ts$/);
538
+ allActionFiles.push(...files);
539
+ }
522
540
  }
523
- const apiFiles = allApiFiles;
524
541
 
525
- if (apiFiles.length === 0) {
542
+ // Only consider files that define an API-exposed action.
543
+ const apiExposedFiles: string[] = [];
544
+ for (const file of allActionFiles) {
545
+ const content = await readFile(file, 'utf8');
546
+ if (/defineAction\s*\(/.test(content) && /exposure\s*:\s*\{\s*type\s*:\s*['"]api['"]/.test(content)) {
547
+ apiExposedFiles.push(file);
548
+ }
549
+ }
550
+
551
+ if (apiExposedFiles.length === 0) {
526
552
  return {
527
553
  name: 'APIs declare tenancy',
528
554
  status: 'PASS',
529
- message: 'No APIs found (optional)',
555
+ message: 'No API-exposed actions found (optional)',
530
556
  kNodeRef: 'K:convention:tier-1-foundations',
531
557
  };
532
558
  }
533
559
 
534
- // Simple heuristic: grep for 'tenancy:' in each API file
560
+ // Check for `tenancy:` anywhere in each API-exposed action file.
535
561
  let missingTenancy = 0;
536
- for (const file of apiFiles) {
562
+ for (const file of apiExposedFiles) {
537
563
  const content = await readFile(file, 'utf8');
538
564
  if (!content.includes('tenancy:')) {
539
565
  missingTenancy++;
@@ -544,15 +570,15 @@ async function checkApisHaveTenancy(projectRoot: string): Promise<DoctorCheck> {
544
570
  return {
545
571
  name: 'APIs declare tenancy',
546
572
  status: 'PASS',
547
- message: `All ${apiFiles.length} API files declare tenancy`,
573
+ message: `All ${apiExposedFiles.length} API-exposed action(s) declare tenancy`,
548
574
  kNodeRef: 'K:convention:tier-1-foundations',
549
575
  };
550
576
  } else {
551
577
  return {
552
578
  name: 'APIs declare tenancy',
553
579
  status: 'FAIL',
554
- message: `${missingTenancy}/${apiFiles.length} API files missing tenancy: field`,
555
- fixHint: "Add tenancy: 'required' | 'none' | 'system' to defineApi calls",
580
+ message: `${missingTenancy}/${apiExposedFiles.length} API-exposed action(s) missing tenancy. Tenancy must be declared on the action's exposure block: \`exposure.tenancy: 'required' | 'none' | 'system'\`.`,
581
+ fixHint: "Add `exposure.tenancy: 'required' | 'none' | 'system'` to defineAction({ exposure: { type: 'api', ... } }) calls",
556
582
  kNodeRef: 'K:convention:tier-1-foundations',
557
583
  };
558
584
  }
@@ -568,14 +594,22 @@ async function checkApisHaveTenancy(projectRoot: string): Promise<DoctorCheck> {
568
594
 
569
595
  async function checkScaffoldConfigMatchesDisk(projectRoot: string): Promise<DoctorCheck> {
570
596
  try {
571
- const configPath = join(projectRoot, '.tib', 'scaffold-config.json');
597
+ const configPath = join(projectRoot, '.mc', 'scaffold-config.json');
572
598
  const domainsDir = join(projectRoot, 'domains');
573
599
 
574
600
  let configDomains: string[] = [];
575
601
  try {
576
602
  const configContent = await readFile(configPath, 'utf8');
577
603
  const config = JSON.parse(configContent) as { domainIds?: string[] };
578
- configDomains = config.domainIds ?? [];
604
+ if (!Array.isArray(config.domainIds)) {
605
+ return {
606
+ name: 'scaffold-config.json matches on-disk',
607
+ status: 'PASS',
608
+ message: '.mc/scaffold-config.json has no domainIds array — domain list sync check skipped',
609
+ kNodeRef: 'K:runbook:add-domain',
610
+ };
611
+ }
612
+ configDomains = config.domainIds;
579
613
  } catch {
580
614
  return {
581
615
  name: 'scaffold-config.json matches on-disk',
@@ -868,7 +902,7 @@ async function checkAllRoutesUseTanStackRouter(projectRoot: string): Promise<Doc
868
902
 
869
903
  /**
870
904
  * Check W5-4: Lambda handler files contain initOtel() call.
871
- * Scans `domains/*\/api/*.ts` and FAILs if any handler is missing initOtel().
905
+ * Scans `domains/*\/actions/*.ts` and `domains/*\/api/*.ts` and FAILs if any handler is missing initOtel().
872
906
  */
873
907
  async function checkOtelInitInLambdas(projectRoot: string): Promise<DoctorCheck> {
874
908
  try {
@@ -885,9 +919,11 @@ async function checkOtelInitInLambdas(projectRoot: string): Promise<DoctorCheck>
885
919
  const handlerFiles: string[] = [];
886
920
  for (const entry of entries) {
887
921
  if (!entry.isDirectory()) continue;
888
- const apiDir = join(domainsDir, entry.name, 'api');
889
- const files = findFiles(apiDir, /\.ts$/);
890
- handlerFiles.push(...files);
922
+ for (const dir of ['actions', 'api']) {
923
+ const handlerDir = join(domainsDir, entry.name, dir);
924
+ const files = findFiles(handlerDir, /\.ts$/);
925
+ handlerFiles.push(...files);
926
+ }
891
927
  }
892
928
 
893
929
  if (handlerFiles.length === 0) {
@@ -1205,7 +1241,7 @@ async function checkDomainBrainReachable(projectRoot: string): Promise<DoctorChe
1205
1241
  */
1206
1242
  async function checkActionsHaveTypes(projectRoot: string): Promise<DoctorCheck> {
1207
1243
  try {
1208
- const typesPath = join(projectRoot, '.tib', 'actions-types.d.ts');
1244
+ const typesPath = join(projectRoot, '.mc', 'actions-types.d.ts');
1209
1245
  const domainsDir = join(projectRoot, 'domains');
1210
1246
  const discoveredActions: string[] = [];
1211
1247
 
@@ -1435,7 +1471,7 @@ async function checkSeedPagesHaveNoHeaders(projectRoot: string): Promise<DoctorC
1435
1471
 
1436
1472
  /**
1437
1473
  * Check Part C: Layout validation checks
1438
- * 1. owned files in unexpected locations
1474
+ * 1. managed files are not registered in obsolete legacy locations
1439
1475
  * 2. tracked/seed files inside .mc/
1440
1476
  * 3. manifest entries pointing at missing files
1441
1477
  * 4. scaffold-tracked files (with @mc-scaffold: header) that lack manifest entries
@@ -1452,26 +1488,29 @@ async function checkLayoutPolicy(projectRoot: string): Promise<DoctorCheck[]> {
1452
1488
  return [];
1453
1489
  }
1454
1490
 
1455
- // Check 1: owned files in unexpected locations
1456
- // Policy: owned files should be in .mc/ OR .github/workflows/tib-*
1457
- const ownedOutsideTib = manifest.files.filter(f =>
1458
- f.policy === 'managed' &&
1459
- !f.path.startsWith('.mc/') &&
1460
- !/^\.github\/workflows\/tib-/.test(f.path)
1491
+ // Check 1: managed files should not be registered in obsolete legacy
1492
+ // locations. Current scaffold manifests intentionally track managed files at
1493
+ // their installed paths (infra/modules, domains, .github, CLAUDE.md, etc.).
1494
+ const legacyManagedLocations = manifest.files.filter(f =>
1495
+ f.policy === 'managed' && (
1496
+ f.path.startsWith('.tib/') ||
1497
+ f.path.startsWith('.mc/infra/') ||
1498
+ f.path === 'mc-deploy.yml'
1499
+ )
1461
1500
  );
1462
- if (ownedOutsideTib.length === 0) {
1501
+ if (legacyManagedLocations.length === 0) {
1463
1502
  results.push({
1464
- name: 'Layout: owned files in .mc/',
1503
+ name: 'Layout: no managed files in legacy locations',
1465
1504
  status: 'PASS',
1466
- message: 'All owned scaffold files are under .mc/ or .github/workflows/tib-*',
1505
+ message: 'Managed scaffold files use current manifest paths',
1467
1506
  });
1468
1507
  } else {
1469
- for (const f of ownedOutsideTib) {
1508
+ for (const f of legacyManagedLocations) {
1470
1509
  results.push({
1471
- name: 'Layout: owned files in .mc/',
1510
+ name: 'Layout: no managed files in legacy locations',
1472
1511
  status: 'FAIL',
1473
- message: `Owned scaffold file outside .mc/: ${f.path}`,
1474
- fixHint: `Run 'tib doctor --relocate' to migrate ${f.path} to .mc/`,
1512
+ message: `Managed scaffold file registered in legacy location: ${f.path}`,
1513
+ fixHint: 'Run `npx mc-domain-module upgrade` to refresh scaffold metadata',
1475
1514
  file: f.path,
1476
1515
  });
1477
1516
  }
@@ -1521,7 +1560,7 @@ async function checkLayoutPolicy(projectRoot: string): Promise<DoctorCheck[]> {
1521
1560
  name: 'Layout: manifest entries exist on disk',
1522
1561
  status: 'FAIL',
1523
1562
  message: `${missingFiles.length} manifest entry/entries point to missing files:\n ${missingFiles.join('\n ')}`,
1524
- fixHint: 'Run tib doctor --relocate or tib upgrade to fix missing files',
1563
+ fixHint: 'Run `npx mc-domain-module upgrade` to refresh scaffold metadata, or restore the missing files',
1525
1564
  });
1526
1565
  }
1527
1566
 
@@ -1566,7 +1605,7 @@ async function checkLayoutPolicy(projectRoot: string): Promise<DoctorCheck[]> {
1566
1605
  name: 'Layout: scaffold-tracked files have manifest entries',
1567
1606
  status: 'WARN',
1568
1607
  message: `${untracked.length} file(s) with @mc-scaffold: header not in manifest:\n ${untracked.join('\n ')}`,
1569
- fixHint: 'Run tib upgrade or tib doctor --relocate to register these files',
1608
+ fixHint: 'Run `npx mc-domain-module upgrade` to refresh scaffold metadata',
1570
1609
  });
1571
1610
  }
1572
1611
 
@@ -1574,156 +1613,19 @@ async function checkLayoutPolicy(projectRoot: string): Promise<DoctorCheck[]> {
1574
1613
  }
1575
1614
 
1576
1615
  /**
1577
- * Old path new path mapping for known scaffold files
1578
- */
1579
- const RELOCATION_MAP: Array<{ oldPath: string; newPath: string; policy: 'managed' | 'editable' | 'seed' }> = [
1580
- { oldPath: 'infra/modules/app.ts', newPath: '.mc/infra/modules/app.ts', policy: 'managed' },
1581
- { oldPath: 'infra/modules/shared/SharedStack.ts', newPath: '.mc/infra/modules/shared/SharedStack.ts', policy: 'managed' },
1582
- { oldPath: 'infra/modules/domains/dispatch-middleware.ts', newPath: '.mc/infra/modules/domains/dispatch-middleware.ts', policy: 'managed' },
1583
- { oldPath: 'infra/modules/PowerTuningStack.ts', newPath: '.mc/infra/modules/PowerTuningStack.ts', policy: 'managed' },
1584
- { oldPath: 'infra/cdk.json', newPath: '.mc/infra/cdk.json', policy: 'managed' },
1585
- { oldPath: 'infra/tsconfig.json', newPath: '.mc/infra/tsconfig.json', policy: 'managed' },
1586
- { oldPath: 'infra/package.json', newPath: '.mc/infra/package.json', policy: 'managed' },
1587
- { oldPath: 'mc-deploy.yml', newPath: '.github/workflows/mc-deploy.yml', policy: 'managed' },
1588
- ];
1589
-
1590
- /**
1591
- * Run relocation of old-layout scaffold files to new layout
1616
+ * Legacy relocation entrypoint retained for older automation.
1617
+ *
1618
+ * Earlier scaffold versions tried to move generated infra into .mc/infra. The
1619
+ * current scaffold contract keeps generated infra under infra/modules and uses
1620
+ * .mc/manifest.json plus .mc/modules-hashes.json for ownership/drift tracking.
1621
+ * Moving files here would corrupt modern projects, so relocation is now a no-op.
1592
1622
  */
1593
1623
  async function runRelocate(projectRoot: string): Promise<DoctorReport> {
1594
- const checks: DoctorCheck[] = [];
1595
-
1596
- const { readManifest, writeManifest, upsertManifestFile } = await import('../utils/manifest.js');
1597
- const { readFile, writeFile, mkdir, unlink } = await import('node:fs/promises');
1598
- const { computeChecksumFile, computeChecksumString } = await import('../utils/checksum.js');
1599
- const { join, dirname } = await import('node:path');
1600
-
1601
- const manifest = await readManifest(projectRoot);
1602
- if (!manifest) {
1603
- checks.push({
1604
- name: 'Relocate: manifest present',
1605
- status: 'FAIL',
1606
- message: '.mc/manifest.json not found — cannot relocate without a manifest',
1607
- fixHint: 'This project may not be a TIB scaffold project',
1608
- });
1609
- return { checks, exitCode: 1, pass: false };
1610
- }
1611
-
1612
- let moved = 0;
1613
- let warned = 0;
1614
- let skipped = 0;
1615
-
1616
- for (const { oldPath, newPath, policy } of RELOCATION_MAP) {
1617
- const oldAbsPath = join(projectRoot, oldPath);
1618
- const newAbsPath = join(projectRoot, newPath);
1619
-
1620
- // Check if old file exists
1621
- const { access: fsAccess } = await import('node:fs/promises');
1622
- try { await fsAccess(oldAbsPath); } catch { skipped++; continue; }
1623
-
1624
- // Check if it has the scaffold header (confirm it's scaffold-owned)
1625
- let content: string;
1626
- try {
1627
- content = await readFile(oldAbsPath, 'utf-8');
1628
- } catch {
1629
- skipped++;
1630
- continue;
1631
- }
1632
-
1633
- if (!content.includes('@mc-scaffold:')) {
1634
- // File exists but no header — user-owned, leave it
1635
- checks.push({
1636
- name: 'Relocate: checking ' + oldPath,
1637
- status: 'WARN',
1638
- message: `${oldPath} has no @mc-scaffold: header — left in place (user-owned)`,
1639
- file: oldPath,
1640
- });
1641
- warned++;
1642
- continue;
1643
- }
1644
-
1645
- // Check for user drift
1646
- const manifestEntry = manifest.files.find(f => f.path === oldPath);
1647
- if (manifestEntry) {
1648
- const diskSha = await computeChecksumFile(oldAbsPath);
1649
- if (diskSha && diskSha !== manifestEntry.sha256) {
1650
- // File has been modified — leave with warning
1651
- checks.push({
1652
- name: 'Relocate: checking ' + oldPath,
1653
- status: 'WARN',
1654
- message: `${oldPath} has local modifications — left in place. Migrate manually: move to ${newPath}`,
1655
- file: oldPath,
1656
- });
1657
- warned++;
1658
- continue;
1659
- }
1660
- }
1661
-
1662
- // Check if destination already exists
1663
- try {
1664
- await fsAccess(newAbsPath);
1665
- checks.push({
1666
- name: 'Relocate: ' + oldPath,
1667
- status: 'WARN',
1668
- message: `${newPath} already exists — skipping move of ${oldPath}`,
1669
- file: newPath,
1670
- });
1671
- warned++;
1672
- continue;
1673
- } catch { /* destination doesn't exist, good */ }
1674
-
1675
- // Move the file
1676
- try {
1677
- await mkdir(dirname(newAbsPath), { recursive: true });
1678
- // Copy + delete (rename may fail across filesystems)
1679
- await writeFile(newAbsPath, content, 'utf-8');
1680
-
1681
- // Update manifest: remove old entry, add new with new path + policy
1682
- manifest.files = manifest.files.filter(f => f.path !== oldPath);
1683
- const sha256 = manifestEntry?.sha256 ?? computeChecksumString(content);
1684
- upsertManifestFile(manifest, {
1685
- path: newPath,
1686
- module: manifestEntry?.module ?? 'domain',
1687
- moduleVersion: manifestEntry?.moduleVersion ?? '1.0.0',
1688
- sha256,
1689
- wasTemplate: manifestEntry?.wasTemplate ?? false,
1690
- installedAt: manifestEntry?.installedAt ?? new Date().toISOString(),
1691
- policy,
1692
- });
1693
-
1694
- // Delete old file
1695
- await unlink(oldAbsPath);
1696
-
1697
- checks.push({
1698
- name: 'Relocate: ' + oldPath,
1699
- status: 'PASS',
1700
- message: `Moved ${oldPath} → ${newPath} (policy: ${policy})`,
1701
- file: newPath,
1702
- });
1703
- moved++;
1704
- } catch (err) {
1705
- checks.push({
1706
- name: 'Relocate: ' + oldPath,
1707
- status: 'FAIL',
1708
- message: `Failed to move ${oldPath} → ${newPath}: ${String(err)}`,
1709
- file: oldPath,
1710
- });
1711
- }
1712
- }
1713
-
1714
- // Write updated manifest
1715
- if (moved > 0) {
1716
- await writeManifest(projectRoot, manifest);
1717
- }
1718
-
1719
- // Summary check
1720
- checks.push({
1721
- name: 'Relocate: summary',
1722
- status: warned > 0 ? 'WARN' : 'PASS',
1723
- message: `Relocation complete: ${moved} file(s) moved, ${warned} warning(s), ${skipped} skipped (not present)`,
1724
- });
1725
-
1726
- const hasFail = checks.some(c => c.status === 'FAIL');
1727
- const exitCode = hasFail ? 1 : 0;
1728
- return { checks, exitCode, pass: exitCode === 0 };
1624
+ void projectRoot;
1625
+ const checks: DoctorCheck[] = [{
1626
+ name: 'Relocate: deprecated no-op',
1627
+ status: 'PASS',
1628
+ message: 'No files moved. Current scaffold layout keeps generated files at their installed paths and tracks ownership via .mc/manifest.json.',
1629
+ }];
1630
+ return { checks, exitCode: 0, pass: true };
1729
1631
  }
@@ -58,25 +58,25 @@ const RULE_EXPLANATIONS: Record<string, RuleExplanation> = {
58
58
  ruleId: 'no-raw-http-server',
59
59
  title: 'No raw HTTP server in domain code',
60
60
  description:
61
- 'Domain code must not create raw HTTP servers (express, fastify, etc.). All HTTP handling goes through defineApi which is wired to API Gateway by the scaffold.',
61
+ 'Domain code must not create raw HTTP servers (express, fastify, etc.). All HTTP handling goes through defineAction with exposure.type=\'api\' which is wired to API Gateway by the scaffold.',
62
62
  severity: 'error',
63
63
  category: 'structure',
64
64
  kNodeRef: 'K:convention:tier-1-foundations',
65
- fixHint: 'Wrap your HTTP handler with defineApi({ ... }). The scaffold handles API Gateway wiring.',
65
+ fixHint: 'Wrap your HTTP handler with defineAction({ exposure: { type: \'api\', ... } }). The scaffold handles API Gateway wiring.',
66
66
  exampleBad: "import express from 'express';\nconst app = express();",
67
- exampleGood: "export const myApi = defineApi({ id: 'my-api', path: '/v1/my-api', ... });",
67
+ exampleGood: "export const myAction = defineAction({ id: 'my-action', exposure: { type: 'api', path: '/v1/my-action', method: 'GET', auth: 'required', tenancy: 'required' }, ... });",
68
68
  },
69
69
  'require-define-primitive': {
70
70
  ruleId: 'require-define-primitive',
71
71
  title: 'Require define primitive factories',
72
72
  description:
73
- 'All domain handlers must use the appropriate factory function: defineApi for HTTP APIs, defineSubscriber for event subscribers, defineAction for cross-domain actions, defineJob for background jobs.',
73
+ 'All domain handlers must use the appropriate factory function: defineAction (HTTP APIs via exposure.type=\'api\', or internal-only actions), defineSubscriber for event subscribers, defineJob for background jobs, defineWebhook for webhooks, defineEvent for event types.',
74
74
  severity: 'error',
75
75
  category: 'structure',
76
76
  kNodeRef: 'K:runbook:add-domain',
77
77
  fixHint: "Wrap your handler with the appropriate define* factory from @mettlecast/domain-runtime.",
78
78
  exampleBad: "export const handler = async (event) => ({ statusCode: 200 });",
79
- exampleGood: "export const myApi = defineApi({ id: 'my-api', ... });",
79
+ exampleGood: "export const myAction = defineAction({ id: 'my-action', exposure: { type: 'api', ... }, ... });",
80
80
  },
81
81
  'flow-domain-ownership': {
82
82
  ruleId: 'flow-domain-ownership',
@@ -106,25 +106,25 @@ const RULE_EXPLANATIONS: Record<string, RuleExplanation> = {
106
106
  ruleId: 'apis-have-versions',
107
107
  title: 'APIs declare versions',
108
108
  description:
109
- 'Every defineApi must have a versions field with at least one version entry. Versioning is mandatory for API evolution.',
109
+ 'Every API-exposed action (defineAction with exposure.type=\'api\') must declare input and output Zod schemas with .default() so the runtime has a concrete example payload. The action-first contract replaced legacy defineApi\'s versions map (#4689).',
110
110
  severity: 'error',
111
111
  category: 'correctness',
112
112
  kNodeRef: 'K:runbook:add-domain',
113
- fixHint: 'Add versions: { v1: { status, input, output, handler } } to your defineApi call.',
114
- exampleBad: "defineApi({ id: 'my-api', path: '/v1/my-api' }) // missing versions",
115
- exampleGood: "defineApi({ id: 'my-api', path: '/v1/my-api', versions: { v1: { ... } } });",
113
+ fixHint: 'Add Zod input/output schemas to your defineAction call (e.g. z.object({...}).default({...})).',
114
+ exampleBad: "defineAction({ id: 'my-action', exposure: { type: 'api', path: '/v1/my-action', ... }, handler: ... }) // missing input/output schemas",
115
+ exampleGood: "defineAction({ id: 'my-action', exposure: { type: 'api', ... }, input: z.object({...}).default({...}), output: z.object({...}).default({...}), handler: ... });",
116
116
  },
117
117
  'apis-have-tenancy': {
118
118
  ruleId: 'apis-have-tenancy',
119
119
  title: 'APIs declare tenancy',
120
120
  description:
121
- 'Every defineApi must declare its tenancy mode: required (tenant-scoped), none (tenant-agnostic like registration), or system (system-internal admin).',
121
+ 'Every defineAction with exposure.type=\'api\' must declare exposure.tenancy: required (tenant-scoped), none (tenant-agnostic like registration), or system (system-internal admin). The action-first contract enforces this at registration time (#4689).',
122
122
  severity: 'error',
123
123
  category: 'correctness',
124
124
  kNodeRef: 'K:convention:tier-1-foundations',
125
- fixHint: "Add tenancy: 'required' | 'none' | 'system' to your defineApi call.",
126
- exampleBad: "defineApi({ id: 'my-api', path: '/v1/my-api' }) // missing tenancy",
127
- exampleGood: "defineApi({ id: 'my-api', path: '/v1/my-api', tenancy: 'required' });",
125
+ fixHint: "Add `exposure.tenancy: 'required' | 'none' | 'system'` to your defineAction call.",
126
+ exampleBad: "defineAction({ id: 'my-action', exposure: { type: 'api', path: '/v1/my-action' }, ... }) // missing exposure.tenancy",
127
+ exampleGood: "defineAction({ id: 'my-action', exposure: { type: 'api', path: '/v1/my-action', method: 'GET', auth: 'required', tenancy: 'required' }, ... });",
128
128
  },
129
129
  'no-cross-domain-imports': {
130
130
  ruleId: 'no-cross-domain-imports',