@mettlecast/domain-cli 0.2.22 → 0.2.23

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 (69) hide show
  1. package/dist/cli.js +60 -3
  2. package/dist/commands/add-api.d.ts +1 -1
  3. package/dist/commands/add-api.js +45 -17
  4. package/dist/commands/add-fixture-factory.d.ts +16 -0
  5. package/dist/commands/add-fixture-factory.js +60 -0
  6. package/dist/commands/add-module.js +4 -5
  7. package/dist/commands/add-seed-page.js +4 -5
  8. package/dist/commands/build-flows.js +1 -1
  9. package/dist/commands/dev.d.ts +30 -3
  10. package/dist/commands/dev.js +52 -11
  11. package/dist/commands/doctor.d.ts +22 -0
  12. package/dist/commands/doctor.js +341 -6
  13. package/dist/commands/generate-openapi.d.ts +21 -0
  14. package/dist/commands/generate-openapi.js +117 -0
  15. package/dist/commands/generate-sdk.d.ts +25 -0
  16. package/dist/commands/generate-sdk.js +98 -0
  17. package/dist/commands/init.d.ts +14 -0
  18. package/dist/commands/init.js +62 -0
  19. package/dist/commands/reseed-page.js +5 -6
  20. package/dist/commands/upgrade.d.ts +2 -0
  21. package/dist/commands/upgrade.js +28 -6
  22. package/dist/commands/why.d.ts +47 -0
  23. package/dist/commands/why.js +129 -0
  24. package/dist/templates/api-skeleton.d.ts +5 -1
  25. package/dist/templates/api-skeleton.js +28 -6
  26. package/dist/templates/patterns/api/create-with-event.d.ts +5 -0
  27. package/dist/templates/patterns/api/create-with-event.js +14 -1
  28. package/dist/templates/patterns/api/idempotent-mutation.d.ts +5 -0
  29. package/dist/templates/patterns/api/idempotent-mutation.js +20 -0
  30. package/dist/templates/patterns/api/paginated-list.d.ts +5 -0
  31. package/dist/templates/patterns/api/paginated-list.js +12 -2
  32. package/dist/templates/patterns/api/simple-crud.d.ts +5 -0
  33. package/dist/templates/patterns/api/simple-crud.js +22 -4
  34. package/dist/templates/patterns/api/streaming-list.d.ts +27 -0
  35. package/dist/templates/patterns/api/streaming-list.js +91 -0
  36. package/dist/templates/patterns/api/system-admin.d.ts +5 -0
  37. package/dist/templates/patterns/api/system-admin.js +14 -4
  38. package/dist/templates/patterns/api/webhook-receiver-style.d.ts +5 -0
  39. package/dist/templates/patterns/api/webhook-receiver-style.js +22 -0
  40. package/dist/utils/s3-fetch.js +23 -32
  41. package/package.json +4 -1
  42. package/src/__tests__/commands/add-api.test.ts +160 -0
  43. package/src/__tests__/commands/dev.test.ts +162 -0
  44. package/src/__tests__/commands/why.test.ts +199 -0
  45. package/src/__tests__/doctor.test.ts +336 -1
  46. package/src/__tests__/smoke/scaffold.test.ts +574 -0
  47. package/src/cli.ts +67 -5
  48. package/src/commands/add-api.ts +68 -19
  49. package/src/commands/add-fixture-factory.ts +75 -0
  50. package/src/commands/add-module.ts +4 -5
  51. package/src/commands/add-seed-page.ts +4 -5
  52. package/src/commands/build-flows.ts +2 -2
  53. package/src/commands/dev.ts +78 -11
  54. package/src/commands/doctor.ts +379 -12
  55. package/src/commands/generate-openapi.ts +154 -0
  56. package/src/commands/generate-sdk.ts +125 -0
  57. package/src/commands/init.ts +78 -0
  58. package/src/commands/reseed-page.ts +5 -6
  59. package/src/commands/upgrade.ts +26 -6
  60. package/src/commands/why.ts +171 -0
  61. package/src/templates/api-skeleton.ts +32 -6
  62. package/src/templates/patterns/api/create-with-event.ts +15 -1
  63. package/src/templates/patterns/api/idempotent-mutation.ts +21 -0
  64. package/src/templates/patterns/api/paginated-list.ts +13 -2
  65. package/src/templates/patterns/api/simple-crud.ts +23 -4
  66. package/src/templates/patterns/api/streaming-list.ts +91 -0
  67. package/src/templates/patterns/api/system-admin.ts +15 -4
  68. package/src/templates/patterns/api/webhook-receiver-style.ts +23 -0
  69. package/src/utils/s3-fetch.ts +26 -34
@@ -0,0 +1,199 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { runWhy, formatWhyReport, runWhyCli } from '../../commands/why.js';
6
+ import * as s3Fetch from '../../utils/s3-fetch.js';
7
+
8
+ describe('why command', () => {
9
+ let tmpDir: string;
10
+ let fetchVersionsJsonSpy: ReturnType<typeof vi.spyOn>;
11
+
12
+ beforeEach(async () => {
13
+ tmpDir = await mkdtemp(join(tmpdir(), 'tib-why-'));
14
+ await mkdir(join(tmpDir, '.mc'), { recursive: true });
15
+ fetchVersionsJsonSpy = vi.spyOn(s3Fetch, 'fetchVersionsJson');
16
+ });
17
+
18
+ afterEach(async () => {
19
+ fetchVersionsJsonSpy.mockRestore();
20
+ try {
21
+ await rm(tmpDir, { recursive: true, force: true });
22
+ } catch {
23
+ // ignore cleanup errors
24
+ }
25
+ });
26
+
27
+ it('returns current + latest versions and reports no upgrade when on latest', async () => {
28
+ await writeFile(
29
+ join(tmpDir, '.mc', 'manifest.json'),
30
+ JSON.stringify({
31
+ scaffoldVersion: '2.5.0',
32
+ enabledModules: ['core', 'backend-lambda', 'frontend'],
33
+ })
34
+ );
35
+ fetchVersionsJsonSpy.mockResolvedValue({
36
+ latest: '2.5.0',
37
+ versions: ['2.5.0', '2.4.0', '2.3.0'],
38
+ });
39
+
40
+ const report = await runWhy({ projectRoot: tmpDir });
41
+
42
+ expect(report.currentVersion).toBe('2.5.0');
43
+ expect(report.latestVersion).toBe('2.5.0');
44
+ expect(report.upgradeAvailable).toBe(false);
45
+ expect(report.versionsBehind).toBe(0);
46
+ expect(report.enabledModules).toEqual(['core', 'backend-lambda', 'frontend']);
47
+ });
48
+
49
+ it('reports upgrade available when current is behind latest', async () => {
50
+ await writeFile(
51
+ join(tmpDir, '.mc', 'manifest.json'),
52
+ JSON.stringify({
53
+ scaffoldVersion: '2.3.0',
54
+ enabledModules: ['core'],
55
+ })
56
+ );
57
+ fetchVersionsJsonSpy.mockResolvedValue({
58
+ latest: '2.5.0',
59
+ versions: ['2.5.0', '2.4.0', '2.3.0', '2.2.0'],
60
+ });
61
+
62
+ const report = await runWhy({ projectRoot: tmpDir });
63
+
64
+ expect(report.currentVersion).toBe('2.3.0');
65
+ expect(report.latestVersion).toBe('2.5.0');
66
+ expect(report.upgradeAvailable).toBe(true);
67
+ expect(report.versionsBehind).toBe(2);
68
+ });
69
+
70
+ it('handles missing manifest gracefully with currentVersion=unknown', async () => {
71
+ // No .mc/manifest.json written
72
+ fetchVersionsJsonSpy.mockResolvedValue({
73
+ latest: '1.0.0',
74
+ versions: ['1.0.0'],
75
+ });
76
+
77
+ const report = await runWhy({ projectRoot: tmpDir });
78
+
79
+ expect(report.currentVersion).toBe('unknown');
80
+ expect(report.latestVersion).toBe('1.0.0');
81
+ expect(report.upgradeAvailable).toBe(true);
82
+ expect(report.versionsBehind).toBe(-1);
83
+ expect(report.enabledModules).toEqual([]);
84
+ });
85
+
86
+ it('passes scaffoldBucket through to the S3 fetch', async () => {
87
+ await writeFile(
88
+ join(tmpDir, '.mc', 'manifest.json'),
89
+ JSON.stringify({ scaffoldVersion: '1.0.0', enabledModules: [] })
90
+ );
91
+ fetchVersionsJsonSpy.mockResolvedValue({
92
+ latest: '1.0.0',
93
+ versions: ['1.0.0'],
94
+ });
95
+
96
+ await runWhy({ projectRoot: tmpDir, scaffoldBucket: 'my-bucket' });
97
+
98
+ expect(fetchVersionsJsonSpy).toHaveBeenCalledWith('my-bucket');
99
+ });
100
+
101
+ it('uses ky under the hood (S3 fetch is wired through the ky-backed s3-fetch utility)', async () => {
102
+ await writeFile(
103
+ join(tmpDir, '.mc', 'manifest.json'),
104
+ JSON.stringify({ scaffoldVersion: '1.0.0', enabledModules: [] })
105
+ );
106
+ fetchVersionsJsonSpy.mockResolvedValue({
107
+ latest: '1.0.0',
108
+ versions: ['1.0.0'],
109
+ });
110
+
111
+ await runWhy({ projectRoot: tmpDir });
112
+
113
+ // The spy is on the s3-fetch module function — s3-fetch.ts itself
114
+ // is now ky-backed, so a single call here proves the ky path is exercised.
115
+ expect(fetchVersionsJsonSpy).toHaveBeenCalledTimes(1);
116
+ });
117
+
118
+ it('formats a human-readable report including enabled modules and pending diff', () => {
119
+ const report = {
120
+ currentVersion: '2.3.0',
121
+ enabledModules: ['core', 'frontend'],
122
+ latestVersion: '2.5.0',
123
+ upgradeAvailable: true,
124
+ versionsBehind: 2,
125
+ allVersions: ['2.5.0', '2.4.0', '2.3.0', '2.2.0'],
126
+ };
127
+
128
+ const out = formatWhyReport(report);
129
+
130
+ expect(out).toContain('Current version : 2.3.0');
131
+ expect(out).toContain('Latest version : 2.5.0');
132
+ expect(out).toContain('Upgrade available: yes (2 release(s) behind)');
133
+ expect(out).toContain('- core');
134
+ expect(out).toContain('- frontend');
135
+ expect(out).toContain('2.5.0');
136
+ expect(out).toContain('2.4.0');
137
+ });
138
+
139
+ it('prints "(none)" for pending diff when on latest', () => {
140
+ const report = {
141
+ currentVersion: '1.0.0',
142
+ enabledModules: [],
143
+ latestVersion: '1.0.0',
144
+ upgradeAvailable: false,
145
+ versionsBehind: 0,
146
+ allVersions: ['1.0.0'],
147
+ };
148
+
149
+ const out = formatWhyReport(report);
150
+
151
+ expect(out).toContain('Upgrade available: no');
152
+ expect(out).toContain('(none)');
153
+ });
154
+
155
+ it('runWhyCli prints human-readable report by default', async () => {
156
+ await writeFile(
157
+ join(tmpDir, '.mc', 'manifest.json'),
158
+ JSON.stringify({ scaffoldVersion: '1.0.0', enabledModules: ['core'] })
159
+ );
160
+ fetchVersionsJsonSpy.mockResolvedValue({
161
+ latest: '1.0.0',
162
+ versions: ['1.0.0'],
163
+ });
164
+
165
+ const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
166
+
167
+ const report = await runWhyCli({ projectRoot: tmpDir });
168
+
169
+ expect(stdoutWriteSpy).toHaveBeenCalled();
170
+ const written = stdoutWriteSpy.mock.calls.map(call => String(call[0])).join('');
171
+ expect(written).toContain('Current version : 1.0.0');
172
+ expect(report.upgradeAvailable).toBe(false);
173
+
174
+ stdoutWriteSpy.mockRestore();
175
+ });
176
+
177
+ it('runWhyCli prints JSON when --json is set', async () => {
178
+ await writeFile(
179
+ join(tmpDir, '.mc', 'manifest.json'),
180
+ JSON.stringify({ scaffoldVersion: '1.0.0', enabledModules: [] })
181
+ );
182
+ fetchVersionsJsonSpy.mockResolvedValue({
183
+ latest: '1.0.0',
184
+ versions: ['1.0.0'],
185
+ });
186
+
187
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
188
+
189
+ await runWhyCli({ projectRoot: tmpDir, json: true });
190
+
191
+ expect(logSpy).toHaveBeenCalled();
192
+ const logged = String(logSpy.mock.calls[0]?.[0] ?? '');
193
+ const parsed = JSON.parse(logged) as { currentVersion: string; latestVersion: string };
194
+ expect(parsed.currentVersion).toBe('1.0.0');
195
+ expect(parsed.latestVersion).toBe('1.0.0');
196
+
197
+ logSpy.mockRestore();
198
+ });
199
+ });
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { runDoctor } from '../commands/doctor.js';
5
+ import { runDoctor, DOCTOR_FIX_FLAG } from '../commands/doctor.js';
6
6
 
7
7
  describe('runDoctor', () => {
8
8
  let tempDir: string;
@@ -293,4 +293,339 @@ describe('runDoctor', () => {
293
293
  expect(check?.status).toBe('FAIL');
294
294
  });
295
295
  });
296
+
297
+ describe('--fix flag', () => {
298
+ it('exports DOCTOR_FIX_FLAG constant for cli-integration to consume', () => {
299
+ expect(DOCTOR_FIX_FLAG).toBe('--fix');
300
+ });
301
+
302
+ it('moves scaffold-owned files from old layout to .mc/ and updates manifest', async () => {
303
+ await mkdir(join(tempDir, '.tib'), { recursive: true });
304
+ await mkdir(join(tempDir, '.mc'), { recursive: true });
305
+ await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
306
+ await mkdir(join(tempDir, 'domains'), { recursive: true });
307
+ await mkdir(join(tempDir, '.husky'), { recursive: true });
308
+
309
+ const appContent = '// @mc-scaffold: backend-lambda@1.0.0\nimport * as cdk from "aws-cdk-lib";\n';
310
+ await writeFile(join(tempDir, 'infra', 'modules', 'app.ts'), appContent);
311
+ await writeFile(join(tempDir, '.husky', 'pre-commit'), '#!/bin/sh');
312
+ await writeFile(join(tempDir, '.tib', 'scaffold-config.json'), JSON.stringify({ domainIds: [] }));
313
+
314
+ const { computeChecksumString } = await import('../utils/checksum.js');
315
+ const sha256 = computeChecksumString(appContent);
316
+
317
+ const manifest = {
318
+ $schema: 'https://mc-scaffold.s3.amazonaws.com/schema/manifest.v2.json',
319
+ scaffoldVersion: '1.0.0',
320
+ createdAt: '2026-01-01T00:00:00Z',
321
+ updatedAt: '2026-01-01T00:00:00Z',
322
+ projectName: 'test',
323
+ awsRegion: 'eu-north-1',
324
+ enabledModules: ['backend-lambda'],
325
+ files: [
326
+ { path: 'infra/modules/app.ts', module: 'backend-lambda', moduleVersion: '1.0.0',
327
+ sha256, wasTemplate: true, installedAt: '2026-01-01T00:00:00Z', policy: 'managed' as const }
328
+ ]
329
+ };
330
+ await writeFile(join(tempDir, '.mc', 'manifest.json'), JSON.stringify(manifest));
331
+
332
+ const report = await runDoctor({ projectRoot: tempDir, fix: true });
333
+
334
+ // File moved from old layout to .mc/
335
+ const { existsSync } = await import('node:fs');
336
+ expect(existsSync(join(tempDir, '.mc', 'infra', 'modules', 'app.ts'))).toBe(true);
337
+ expect(existsSync(join(tempDir, 'infra', 'modules', 'app.ts'))).toBe(false);
338
+
339
+ // Manifest updated: old path removed, new path added
340
+ const manifestOnDisk = JSON.parse(
341
+ await (await import('node:fs/promises')).readFile(join(tempDir, '.mc', 'manifest.json'), 'utf-8')
342
+ );
343
+ const paths = manifestOnDisk.files.map((f: { path: string }) => f.path);
344
+ expect(paths).toContain('.mc/infra/modules/app.ts');
345
+ expect(paths).not.toContain('infra/modules/app.ts');
346
+
347
+ // Report includes the relocation summary
348
+ const summary = report.checks.find(c => c.name === 'Relocate: summary');
349
+ expect(summary).toBeDefined();
350
+ expect(summary?.message).toMatch(/1 file\(s\) moved/);
351
+
352
+ // Per-file move check
353
+ const moveCheck = report.checks.find(
354
+ c => c.name === 'Relocate: infra/modules/app.ts'
355
+ );
356
+ expect(moveCheck?.status).toBe('PASS');
357
+
358
+ // Doctor's full report still ran (superset of --relocate)
359
+ expect(report.checks.some(c => c.name === 'Domain configs valid')).toBe(true);
360
+ });
361
+
362
+ it('is a no-op on a clean project (exit 0, 0 files moved)', async () => {
363
+ await mkdir(join(tempDir, '.tib'), { recursive: true });
364
+ await mkdir(join(tempDir, '.mc'), { recursive: true });
365
+ await mkdir(join(tempDir, 'domains'), { recursive: true });
366
+ await mkdir(join(tempDir, '.husky'), { recursive: true });
367
+
368
+ await writeFile(join(tempDir, '.tib', 'scaffold-config.json'), JSON.stringify({ domainIds: [] }));
369
+ await writeFile(join(tempDir, '.husky', 'pre-commit'), '#!/bin/sh');
370
+
371
+ const manifest = {
372
+ $schema: 'https://mc-scaffold.s3.amazonaws.com/schema/manifest.v2.json',
373
+ scaffoldVersion: '1.0.0',
374
+ createdAt: '2026-01-01T00:00:00Z',
375
+ updatedAt: '2026-01-01T00:00:00Z',
376
+ projectName: 'test',
377
+ awsRegion: 'eu-north-1',
378
+ enabledModules: ['backend-lambda'],
379
+ files: []
380
+ };
381
+ await writeFile(join(tempDir, '.mc', 'manifest.json'), JSON.stringify(manifest));
382
+
383
+ const report = await runDoctor({ projectRoot: tempDir, fix: true });
384
+
385
+ // No files moved
386
+ const summary = report.checks.find(c => c.name === 'Relocate: summary');
387
+ expect(summary).toBeDefined();
388
+ expect(summary?.message).toMatch(/0 file\(s\) moved/);
389
+ expect(summary?.status).toBe('PASS');
390
+
391
+ // Clean exit (no FAIL anywhere in the report)
392
+ expect(report.exitCode).toBe(0);
393
+ });
394
+ });
395
+
396
+ describe('W5 new checks (scaffolder modernize)', () => {
397
+ /** Build a minimal temp project that passes the W1/W2 baseline checks
398
+ * so the new W5 checks can be evaluated in isolation. */
399
+ async function buildBaseProject(): Promise<void> {
400
+ await mkdir(join(tempDir, '.tib'), { recursive: true });
401
+ await mkdir(join(tempDir, 'domains'), { recursive: true });
402
+ await mkdir(join(tempDir, '.husky'), { recursive: true });
403
+ await writeFile(
404
+ join(tempDir, '.tib', 'scaffold-config.json'),
405
+ JSON.stringify({ domainIds: [] })
406
+ );
407
+ await writeFile(join(tempDir, '.husky', 'pre-commit'), '#!/bin/sh');
408
+ }
409
+
410
+ it('all 5 W5 checks are present in the report', async () => {
411
+ await buildBaseProject();
412
+
413
+ const report = await runDoctor({ projectRoot: tempDir });
414
+
415
+ const expectedW5 = [
416
+ 'Every API has a fixture', // checkEveryApiHasFixture (reuses checkApiFixtures)
417
+ 'All HTTP clients use ky',
418
+ 'Routes use TanStack Router',
419
+ 'OTel init in Lambdas',
420
+ 'Frontend uses strict TypeScript',
421
+ ];
422
+ for (const name of expectedW5) {
423
+ expect(report.checks.some(c => c.name === name)).toBe(true);
424
+ }
425
+ });
426
+
427
+ it('checkEveryApiHasFixture reuses checkApiFixtures (alias yields identical result)', async () => {
428
+ await buildBaseProject();
429
+ // No APIs → both should PASS with the same message
430
+ const report = await runDoctor({ projectRoot: tempDir });
431
+
432
+ const fixtureCheck = report.checks.find(c => c.name === 'Every API has a fixture');
433
+ expect(fixtureCheck).toBeDefined();
434
+ expect(fixtureCheck?.status).toBe('PASS');
435
+ // No duplicate entry — the wrapper replaces the original, not adds alongside
436
+ const matches = report.checks.filter(c => c.name === 'Every API has a fixture');
437
+ expect(matches.length).toBe(1);
438
+ });
439
+
440
+ it('checkAllHttpClientsUseKy: PASS when no raw fetch in domains/', async () => {
441
+ await buildBaseProject();
442
+ await mkdir(join(tempDir, 'domains', 'sample'), { recursive: true });
443
+ await writeFile(
444
+ join(tempDir, 'domains', 'sample', 'client.ts'),
445
+ 'import ky from "ky";\nexport const get = () => ky.get("/foo");'
446
+ );
447
+
448
+ const report = await runDoctor({ projectRoot: tempDir });
449
+ const check = report.checks.find(c => c.name === 'All HTTP clients use ky');
450
+ expect(check?.status).toBe('PASS');
451
+ });
452
+
453
+ it('checkAllHttpClientsUseKy: PASS when only ctx.fetch is used in domains/', async () => {
454
+ await buildBaseProject();
455
+ await mkdir(join(tempDir, 'domains', 'sample'), { recursive: true });
456
+ await writeFile(
457
+ join(tempDir, 'domains', 'sample', 'client.ts'),
458
+ 'export const get = (ctx: any) => ctx.fetch.fetch("https://api.example.com");'
459
+ );
460
+
461
+ const report = await runDoctor({ projectRoot: tempDir });
462
+ const check = report.checks.find(c => c.name === 'All HTTP clients use ky');
463
+ expect(check?.status).toBe('PASS');
464
+ });
465
+
466
+ it('checkAllHttpClientsUseKy: FAIL when raw fetch( is used in domains/', async () => {
467
+ await buildBaseProject();
468
+ await mkdir(join(tempDir, 'domains', 'sample'), { recursive: true });
469
+ await writeFile(
470
+ join(tempDir, 'domains', 'sample', 'client.ts'),
471
+ 'export const get = () => fetch("https://api.example.com");'
472
+ );
473
+
474
+ const report = await runDoctor({ projectRoot: tempDir });
475
+ const check = report.checks.find(c => c.name === 'All HTTP clients use ky');
476
+ expect(check?.status).toBe('FAIL');
477
+ expect(check?.message).toMatch(/raw fetch/i);
478
+ expect(check?.message).toMatch(/client\.ts/);
479
+ });
480
+
481
+ it('checkAllRoutesUseTanStackRouter: PASS when no react-router-dom in frontend/', async () => {
482
+ await buildBaseProject();
483
+ await mkdir(join(tempDir, 'frontend', 'src'), { recursive: true });
484
+ await writeFile(
485
+ join(tempDir, 'frontend', 'src', 'router.tsx'),
486
+ 'import { createRouter } from "@tanstack/react-router";'
487
+ );
488
+
489
+ const report = await runDoctor({ projectRoot: tempDir });
490
+ const check = report.checks.find(c => c.name === 'Routes use TanStack Router');
491
+ expect(check?.status).toBe('PASS');
492
+ });
493
+
494
+ it('checkAllRoutesUseTanStackRouter: FAIL when react-router-dom is imported in frontend/', async () => {
495
+ await buildBaseProject();
496
+ await mkdir(join(tempDir, 'frontend', 'src'), { recursive: true });
497
+ await writeFile(
498
+ join(tempDir, 'frontend', 'src', 'App.tsx'),
499
+ "import { useNavigate } from 'react-router-dom';\nexport const App = () => null;"
500
+ );
501
+
502
+ const report = await runDoctor({ projectRoot: tempDir });
503
+ const check = report.checks.find(c => c.name === 'Routes use TanStack Router');
504
+ expect(check?.status).toBe('FAIL');
505
+ expect(check?.message).toMatch(/react-router-dom/);
506
+ expect(check?.message).toMatch(/App\.tsx/);
507
+ });
508
+
509
+ it('checkAllRoutesUseTanStackRouter: PASS when no frontend/ directory exists', async () => {
510
+ await buildBaseProject();
511
+
512
+ const report = await runDoctor({ projectRoot: tempDir });
513
+ const check = report.checks.find(c => c.name === 'Routes use TanStack Router');
514
+ expect(check?.status).toBe('PASS');
515
+ });
516
+
517
+ it('checkOtelInitInLambdas: PASS when every handler file contains initOtel()', async () => {
518
+ await buildBaseProject();
519
+ await mkdir(join(tempDir, 'domains', 'sample', 'api'), { recursive: true });
520
+ await writeFile(
521
+ join(tempDir, 'domains', 'sample', 'api', 'get.ts'),
522
+ 'import { initOtel } from "@mettlecast/domain-runtime";\ninitOtel();\nexport const get = async () => ({});'
523
+ );
524
+
525
+ const report = await runDoctor({ projectRoot: tempDir });
526
+ const check = report.checks.find(c => c.name === 'OTel init in Lambdas');
527
+ expect(check?.status).toBe('PASS');
528
+ });
529
+
530
+ it('checkOtelInitInLambdas: FAIL when a handler file is missing initOtel()', async () => {
531
+ await buildBaseProject();
532
+ await mkdir(join(tempDir, 'domains', 'sample', 'api'), { recursive: true });
533
+ await writeFile(
534
+ join(tempDir, 'domains', 'sample', 'api', 'noOtel.ts'),
535
+ 'export const get = async () => ({});'
536
+ );
537
+
538
+ const report = await runDoctor({ projectRoot: tempDir });
539
+ const check = report.checks.find(c => c.name === 'OTel init in Lambdas');
540
+ expect(check?.status).toBe('FAIL');
541
+ expect(check?.message).toMatch(/noOtel\.ts/);
542
+ expect(check?.fixHint).toMatch(/initOtel/);
543
+ });
544
+
545
+ it('checkOtelInitInLambdas: PASS when no domains/ directory exists', async () => {
546
+ await buildBaseProject();
547
+
548
+ const report = await runDoctor({ projectRoot: tempDir });
549
+ const check = report.checks.find(c => c.name === 'OTel init in Lambdas');
550
+ expect(check?.status).toBe('PASS');
551
+ });
552
+
553
+ it('checkFrontendUsesStrictTypescript: PASS when frontend/tsconfig.json has strict: true', async () => {
554
+ await buildBaseProject();
555
+ await mkdir(join(tempDir, 'frontend'), { recursive: true });
556
+ await writeFile(
557
+ join(tempDir, 'frontend', 'tsconfig.json'),
558
+ JSON.stringify({ compilerOptions: { strict: true, target: 'ES2022' } })
559
+ );
560
+
561
+ const report = await runDoctor({ projectRoot: tempDir });
562
+ const check = report.checks.find(c => c.name === 'Frontend uses strict TypeScript');
563
+ expect(check?.status).toBe('PASS');
564
+ });
565
+
566
+ it('checkFrontendUsesStrictTypescript: FAIL when strict is missing', async () => {
567
+ await buildBaseProject();
568
+ await mkdir(join(tempDir, 'frontend'), { recursive: true });
569
+ await writeFile(
570
+ join(tempDir, 'frontend', 'tsconfig.json'),
571
+ JSON.stringify({ compilerOptions: { target: 'ES2022' } })
572
+ );
573
+
574
+ const report = await runDoctor({ projectRoot: tempDir });
575
+ const check = report.checks.find(c => c.name === 'Frontend uses strict TypeScript');
576
+ expect(check?.status).toBe('FAIL');
577
+ });
578
+
579
+ it('checkFrontendUsesStrictTypescript: FAIL when strict is false', async () => {
580
+ await buildBaseProject();
581
+ await mkdir(join(tempDir, 'frontend'), { recursive: true });
582
+ await writeFile(
583
+ join(tempDir, 'frontend', 'tsconfig.json'),
584
+ JSON.stringify({ compilerOptions: { strict: false } })
585
+ );
586
+
587
+ const report = await runDoctor({ projectRoot: tempDir });
588
+ const check = report.checks.find(c => c.name === 'Frontend uses strict TypeScript');
589
+ expect(check?.status).toBe('FAIL');
590
+ });
591
+
592
+ it('checkFrontendUsesStrictTypescript: PASS when no frontend/tsconfig.json', async () => {
593
+ await buildBaseProject();
594
+
595
+ const report = await runDoctor({ projectRoot: tempDir });
596
+ const check = report.checks.find(c => c.name === 'Frontend uses strict TypeScript');
597
+ expect(check?.status).toBe('PASS');
598
+ });
599
+
600
+ it('checkFrontendUsesStrictTypescript: FAIL when tsconfig.json is invalid JSON', async () => {
601
+ await buildBaseProject();
602
+ await mkdir(join(tempDir, 'frontend'), { recursive: true });
603
+ await writeFile(
604
+ join(tempDir, 'frontend', 'tsconfig.json'),
605
+ '{ not valid json }'
606
+ );
607
+
608
+ const report = await runDoctor({ projectRoot: tempDir });
609
+ const check = report.checks.find(c => c.name === 'Frontend uses strict TypeScript');
610
+ expect(check?.status).toBe('FAIL');
611
+ expect(check?.message).toMatch(/not valid JSON/);
612
+ });
613
+
614
+ it('doctor always exits non-zero on any FAIL (--strict is no-op confirmation)', async () => {
615
+ await buildBaseProject();
616
+ await mkdir(join(tempDir, 'frontend', 'src'), { recursive: true });
617
+ await writeFile(
618
+ join(tempDir, 'frontend', 'src', 'App.tsx'),
619
+ "import { Link } from 'react-router-dom';\nexport const App = () => null;"
620
+ );
621
+
622
+ // Run with and without --strict — both should exit non-zero (doctor is
623
+ // always strict, the flag is a self-documenting no-op).
624
+ const reportStrict = await runDoctor({ projectRoot: tempDir, strict: true });
625
+ const reportPlain = await runDoctor({ projectRoot: tempDir });
626
+
627
+ expect(reportStrict.exitCode).toBe(1);
628
+ expect(reportPlain.exitCode).toBe(1);
629
+ });
630
+ });
296
631
  });