@mettlecast/domain-cli 0.2.21 → 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.
- package/dist/builder/load-module.js +5 -1
- package/dist/cli.js +60 -3
- package/dist/commands/add-api.d.ts +1 -1
- package/dist/commands/add-api.js +45 -17
- package/dist/commands/add-fixture-factory.d.ts +16 -0
- package/dist/commands/add-fixture-factory.js +60 -0
- package/dist/commands/add-module.js +4 -5
- package/dist/commands/add-seed-page.js +4 -5
- package/dist/commands/build-flows.js +1 -1
- package/dist/commands/dev.d.ts +30 -3
- package/dist/commands/dev.js +52 -11
- package/dist/commands/doctor.d.ts +22 -0
- package/dist/commands/doctor.js +341 -6
- package/dist/commands/generate-openapi.d.ts +21 -0
- package/dist/commands/generate-openapi.js +117 -0
- package/dist/commands/generate-sdk.d.ts +25 -0
- package/dist/commands/generate-sdk.js +98 -0
- package/dist/commands/init.d.ts +14 -0
- package/dist/commands/init.js +62 -0
- package/dist/commands/reseed-page.js +5 -6
- package/dist/commands/upgrade.d.ts +2 -0
- package/dist/commands/upgrade.js +28 -6
- package/dist/commands/why.d.ts +47 -0
- package/dist/commands/why.js +129 -0
- package/dist/templates/api-skeleton.d.ts +5 -1
- package/dist/templates/api-skeleton.js +28 -6
- package/dist/templates/patterns/api/create-with-event.d.ts +5 -0
- package/dist/templates/patterns/api/create-with-event.js +14 -1
- package/dist/templates/patterns/api/idempotent-mutation.d.ts +5 -0
- package/dist/templates/patterns/api/idempotent-mutation.js +20 -0
- package/dist/templates/patterns/api/paginated-list.d.ts +5 -0
- package/dist/templates/patterns/api/paginated-list.js +12 -2
- package/dist/templates/patterns/api/simple-crud.d.ts +5 -0
- package/dist/templates/patterns/api/simple-crud.js +22 -4
- package/dist/templates/patterns/api/streaming-list.d.ts +27 -0
- package/dist/templates/patterns/api/streaming-list.js +91 -0
- package/dist/templates/patterns/api/system-admin.d.ts +5 -0
- package/dist/templates/patterns/api/system-admin.js +14 -4
- package/dist/templates/patterns/api/webhook-receiver-style.d.ts +5 -0
- package/dist/templates/patterns/api/webhook-receiver-style.js +22 -0
- package/dist/utils/s3-fetch.js +23 -32
- package/package.json +4 -1
- package/src/__tests__/commands/add-api.test.ts +160 -0
- package/src/__tests__/commands/dev.test.ts +162 -0
- package/src/__tests__/commands/why.test.ts +199 -0
- package/src/__tests__/doctor.test.ts +336 -1
- package/src/__tests__/smoke/scaffold.test.ts +574 -0
- package/src/builder/load-module.ts +5 -1
- package/src/cli.ts +67 -5
- package/src/commands/add-api.ts +68 -19
- package/src/commands/add-fixture-factory.ts +75 -0
- package/src/commands/add-module.ts +4 -5
- package/src/commands/add-seed-page.ts +4 -5
- package/src/commands/build-flows.ts +2 -2
- package/src/commands/dev.ts +78 -11
- package/src/commands/doctor.ts +379 -12
- package/src/commands/generate-openapi.ts +154 -0
- package/src/commands/generate-sdk.ts +125 -0
- package/src/commands/init.ts +78 -0
- package/src/commands/reseed-page.ts +5 -6
- package/src/commands/upgrade.ts +26 -6
- package/src/commands/why.ts +171 -0
- package/src/templates/api-skeleton.ts +32 -6
- package/src/templates/patterns/api/create-with-event.ts +15 -1
- package/src/templates/patterns/api/idempotent-mutation.ts +21 -0
- package/src/templates/patterns/api/paginated-list.ts +13 -2
- package/src/templates/patterns/api/simple-crud.ts +23 -4
- package/src/templates/patterns/api/streaming-list.ts +91 -0
- package/src/templates/patterns/api/system-admin.ts +15 -4
- package/src/templates/patterns/api/webhook-receiver-style.ts +23 -0
- package/src/utils/s3-fetch.ts +26 -34
package/src/commands/doctor.ts
CHANGED
|
@@ -2,6 +2,14 @@ import { readdir, readFile, access, stat } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { relative } from 'node:path';
|
|
4
4
|
import { readdirSync, existsSync } from 'node:fs';
|
|
5
|
+
import ky from 'ky';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Canonical option-string constant for the --fix flag on the doctor command.
|
|
9
|
+
* Exported so the cli-integration task (packages/domain-cli/src/index.ts) can
|
|
10
|
+
* register the same flag string in commander without string drift.
|
|
11
|
+
*/
|
|
12
|
+
export const DOCTOR_FIX_FLAG = '--fix';
|
|
5
13
|
|
|
6
14
|
/**
|
|
7
15
|
* Options for the doctor command.
|
|
@@ -12,6 +20,22 @@ export interface DoctorOptions {
|
|
|
12
20
|
projectRoot?: string;
|
|
13
21
|
/** Flag to run relocation of old-layout scaffold files to new layout. */
|
|
14
22
|
relocate?: boolean;
|
|
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).
|
|
30
|
+
*/
|
|
31
|
+
fix?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Explicit strict mode. Doctor is ALWAYS strict — any FAIL causes exit 1.
|
|
34
|
+
* This flag exists so CI can pass `--strict` as a self-documenting,
|
|
35
|
+
* fail-closed gate. Currently informational only; behaviour is identical
|
|
36
|
+
* with or without the flag.
|
|
37
|
+
*/
|
|
38
|
+
strict?: boolean;
|
|
15
39
|
}
|
|
16
40
|
|
|
17
41
|
/**
|
|
@@ -62,17 +86,132 @@ function findFiles(dir: string, pattern: RegExp): string[] {
|
|
|
62
86
|
const fullPath = join(currentPath, entry.name);
|
|
63
87
|
if (entry.isDirectory()) {
|
|
64
88
|
walk(fullPath);
|
|
65
|
-
|
|
66
|
-
|
|
89
|
+
} else if (pattern.test(entry.name)) {
|
|
90
|
+
result.push(fullPath);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// Silently skip inaccessible directories
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
walk(dir);
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check W6-1: All domain handler return types reference `Result`.
|
|
104
|
+
* Scans `domains/*\/api/*.ts` for handler function signatures that
|
|
105
|
+
* lack `Result` in their return type annotation.
|
|
106
|
+
*/
|
|
107
|
+
async function checkHandlersUseResult(projectRoot: string): Promise<DoctorCheck> {
|
|
108
|
+
try {
|
|
109
|
+
const domainsDir = join(projectRoot, 'domains');
|
|
110
|
+
if (!existsSync(domainsDir)) {
|
|
111
|
+
return { name: 'Handlers use Result<T>', status: 'PASS',
|
|
112
|
+
message: 'No domains/ — check skipped (optional)',
|
|
113
|
+
kNodeRef: 'K:convention:typed-errors' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const apiFiles = findFiles(domainsDir, /\/api\/[^/]+\.ts$/);
|
|
117
|
+
if (apiFiles.length === 0) {
|
|
118
|
+
return { name: 'Handlers use Result<T>', status: 'PASS',
|
|
119
|
+
message: 'No handler files — check skipped (optional)',
|
|
120
|
+
kNodeRef: 'K:convention:typed-errors' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const missing: string[] = [];
|
|
124
|
+
for (const file of apiFiles) {
|
|
125
|
+
const content = await readFile(file, 'utf8');
|
|
126
|
+
// A file contains a handler export — check the return type
|
|
127
|
+
if (/defineApi\s*\(/.test(content)) {
|
|
128
|
+
// Look for Result<T> in the handler's return type annotation
|
|
129
|
+
if (!/: .*Result</.test(content)) {
|
|
130
|
+
missing.push(relative(projectRoot, file));
|
|
67
131
|
}
|
|
68
132
|
}
|
|
69
|
-
} catch {
|
|
70
|
-
// Silently skip inaccessible directories
|
|
71
133
|
}
|
|
134
|
+
|
|
135
|
+
if (missing.length === 0) {
|
|
136
|
+
return { name: 'Handlers use Result<T>', status: 'PASS',
|
|
137
|
+
message: `All ${apiFiles.length} handler(s) reference Result<T> in their return type`,
|
|
138
|
+
kNodeRef: 'K:convention:typed-errors' };
|
|
139
|
+
} else {
|
|
140
|
+
return { name: 'Handlers use Result<T>', status: 'FAIL',
|
|
141
|
+
message: `${missing.length}/${apiFiles.length} handler(s) missing Result<T> return type:\n ${missing.join('\n ')}\nEach handler's return type must reference \`Result<T, AppError>\`. Replace bare \`return\` values with \`return ok(value)\` and errors with \`return err({...})\`.`,
|
|
142
|
+
fixHint: 'Add `: Promise<Result<T, AppError>>` to the handler\'s return type and import `{ Result, ok, err }` from `@mettlecast/domain-runtime`',
|
|
143
|
+
kNodeRef: 'K:convention:typed-errors' };
|
|
144
|
+
}
|
|
145
|
+
} catch (err) {
|
|
146
|
+
return { name: 'Handlers use Result<T>', status: 'WARN',
|
|
147
|
+
message: `Could not check handler return types: ${String(err)}`,
|
|
148
|
+
kNodeRef: 'K:convention:typed-errors' };
|
|
72
149
|
}
|
|
150
|
+
}
|
|
73
151
|
|
|
74
|
-
|
|
75
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Check W6-2: Generated API clients are fresh.
|
|
154
|
+
* Compares .genhash files against the registry hash to ensure the
|
|
155
|
+
* generated client has not drifted from the source schemas.
|
|
156
|
+
*/
|
|
157
|
+
async function checkGeneratedClientsFresh(projectRoot: string): Promise<DoctorCheck> {
|
|
158
|
+
try {
|
|
159
|
+
const genDir = join(projectRoot, 'frontend', 'src', 'sdk', 'generated');
|
|
160
|
+
if (!existsSync(genDir)) {
|
|
161
|
+
return { name: 'Generated clients fresh', status: 'PASS',
|
|
162
|
+
message: 'No generated clients — check skipped (optional)',
|
|
163
|
+
kNodeRef: 'K:runbook:generated-clients' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const hashFiles = findFiles(genDir, /\.genhash$/);
|
|
167
|
+
if (hashFiles.length === 0) {
|
|
168
|
+
return { name: 'Generated clients fresh', status: 'WARN',
|
|
169
|
+
message: 'Generated client directory exists but no .genhash files — cannot verify freshness',
|
|
170
|
+
kNodeRef: 'K:runbook:generated-clients' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const stale: string[] = [];
|
|
174
|
+
for (const hashFile of hashFiles) {
|
|
175
|
+
const content = await readFile(hashFile, 'utf8').catch(() => null);
|
|
176
|
+
if (!content) {
|
|
177
|
+
stale.push(`${relative(projectRoot, hashFile)} (unreadable)`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
// A .genhash file contains: "<domain> <sha256-of-registry>"
|
|
181
|
+
const parts = content.trim().split(/\s+/);
|
|
182
|
+
if (parts.length !== 2) {
|
|
183
|
+
stale.push(`${relative(projectRoot, hashFile)} (invalid format)`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const [domain, expectedHash] = parts;
|
|
187
|
+
const registryPath = join(projectRoot, '.mc', `${domain}-registry.json`);
|
|
188
|
+
const registryJson = await readFile(registryPath, 'utf8').catch(() => null);
|
|
189
|
+
if (!registryJson) {
|
|
190
|
+
stale.push(`${relative(projectRoot, hashFile)} (registry ${domain}-registry.json not found)`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const { createHash } = await import('node:crypto');
|
|
194
|
+
const actualHash = createHash('sha256').update(registryJson).digest('hex');
|
|
195
|
+
if (actualHash !== expectedHash) {
|
|
196
|
+
stale.push(`${relative(projectRoot, hashFile)} (stale — run \`npx mc-domain-module generate-sdk ${domain}\`)`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (stale.length === 0) {
|
|
201
|
+
return { name: 'Generated clients fresh', status: 'PASS',
|
|
202
|
+
message: `All ${hashFiles.length} generated client(s) are up to date`,
|
|
203
|
+
kNodeRef: 'K:runbook:generated-clients' };
|
|
204
|
+
} else {
|
|
205
|
+
return { name: 'Generated clients fresh', status: 'FAIL',
|
|
206
|
+
message: `${stale.length}/${hashFiles.length} generated client(s) are stale:\n ${stale.join('\n ')}`,
|
|
207
|
+
fixHint: 'Run `npx mc-domain-module generate-sdk <domain>` for each stale domain',
|
|
208
|
+
kNodeRef: 'K:runbook:generated-clients' };
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
return { name: 'Generated clients fresh', status: 'WARN',
|
|
212
|
+
message: `Could not check generated client freshness: ${String(err)}`,
|
|
213
|
+
kNodeRef: 'K:runbook:generated-clients' };
|
|
214
|
+
}
|
|
76
215
|
}
|
|
77
216
|
|
|
78
217
|
/**
|
|
@@ -85,12 +224,23 @@ function findFiles(dir: string, pattern: RegExp): string[] {
|
|
|
85
224
|
export async function runDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
|
|
86
225
|
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
87
226
|
|
|
88
|
-
//
|
|
89
|
-
|
|
227
|
+
// --relocate runs ONLY the relocation routine (existing behaviour).
|
|
228
|
+
// --fix is a superset: it runs relocation first, then the full report.
|
|
229
|
+
// When both are set, --fix takes precedence so the report still runs.
|
|
230
|
+
if (opts.relocate && !opts.fix) {
|
|
90
231
|
return runRelocate(projectRoot);
|
|
91
232
|
}
|
|
92
233
|
|
|
93
|
-
|
|
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).
|
|
237
|
+
let preflightChecks: DoctorCheck[] = [];
|
|
238
|
+
if (opts.fix) {
|
|
239
|
+
const relocationReport = await runRelocate(projectRoot);
|
|
240
|
+
preflightChecks = relocationReport.checks;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const checks: DoctorCheck[] = [...preflightChecks];
|
|
94
244
|
|
|
95
245
|
// Check 1: Domain configs valid
|
|
96
246
|
checks.push(await checkDomainConfigsValid(projectRoot));
|
|
@@ -117,7 +267,7 @@ export async function runDoctor(opts: DoctorOptions = {}): Promise<DoctorReport>
|
|
|
117
267
|
checks.push(await checkRootLevelFlows(projectRoot));
|
|
118
268
|
|
|
119
269
|
// W2 new assertions
|
|
120
|
-
checks.push(await
|
|
270
|
+
checks.push(await checkEveryApiHasFixture(projectRoot));
|
|
121
271
|
checks.push(await checkEventConsumers(projectRoot));
|
|
122
272
|
checks.push(await checkMigrationTenancyTrio(projectRoot));
|
|
123
273
|
checks.push(await checkDomainRlsTest(projectRoot));
|
|
@@ -125,6 +275,16 @@ export async function runDoctor(opts: DoctorOptions = {}): Promise<DoctorReport>
|
|
|
125
275
|
checks.push(await checkActionsHaveTypes(projectRoot));
|
|
126
276
|
checks.push(await checkConventionEvidence(projectRoot));
|
|
127
277
|
|
|
278
|
+
// W5 new checks (scaffolder modernize — 4 new + 1 reuse of checkApiFixtures)
|
|
279
|
+
checks.push(await checkAllHttpClientsUseKy(projectRoot));
|
|
280
|
+
checks.push(await checkAllRoutesUseTanStackRouter(projectRoot));
|
|
281
|
+
checks.push(await checkOtelInitInLambdas(projectRoot));
|
|
282
|
+
checks.push(await checkFrontendUsesStrictTypescript(projectRoot));
|
|
283
|
+
|
|
284
|
+
// W6 (post-review) new checks
|
|
285
|
+
checks.push(await checkHandlersUseResult(projectRoot));
|
|
286
|
+
checks.push(await checkGeneratedClientsFresh(projectRoot));
|
|
287
|
+
|
|
128
288
|
// W1 tracked-files boundary checks
|
|
129
289
|
checks.push(...await checkScaffoldTrackedFilesHaveHeaders(projectRoot));
|
|
130
290
|
checks.push(...await checkSeedPagesHaveNoHeaders(projectRoot));
|
|
@@ -597,6 +757,212 @@ async function checkApiFixtures(projectRoot: string): Promise<DoctorCheck> {
|
|
|
597
757
|
}
|
|
598
758
|
}
|
|
599
759
|
|
|
760
|
+
// ---- W5 new checks (scaffolder modernize) ----
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Check W5-1: All HTTP clients in domains/ use ky or ctx.fetch (no raw fetch()).
|
|
764
|
+
* Reuses the W2 fixture check. Per plan-reviewer WARN #6, this is the
|
|
765
|
+
* "5th new check" counted as a reuse of checkApiFixtures — the
|
|
766
|
+
* `checkEveryApiHasFixture` wrapper below invokes the same logic.
|
|
767
|
+
*/
|
|
768
|
+
async function checkEveryApiHasFixture(projectRoot: string): Promise<DoctorCheck> {
|
|
769
|
+
return checkApiFixtures(projectRoot);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Check W5-2: All HTTP clients in domains/ use ky or ctx.fetch.
|
|
774
|
+
* Detects raw `fetch(` calls (the global browser/node fetch) and FAILs.
|
|
775
|
+
* Allows `ctx.fetch(` (domain-runtime helper) and `ky.fetch(` (ky API).
|
|
776
|
+
*/
|
|
777
|
+
/**
|
|
778
|
+
* Check W5-1: All domain + CLI HTTP clients use ky (no raw fetch).
|
|
779
|
+
* Scans domains/ AND packages/domain-cli/src/commands/ for raw
|
|
780
|
+
* `fetch(` calls. Excludes `ctx.fetch`, `globalThis.fetch`, and
|
|
781
|
+
* comments.
|
|
782
|
+
*/
|
|
783
|
+
|
|
784
|
+
async function checkAllHttpClientsUseKy(projectRoot: string): Promise<DoctorCheck> {
|
|
785
|
+
try {
|
|
786
|
+
const scanDirs = [
|
|
787
|
+
{ dir: join(projectRoot, 'domains'), label: 'domains' },
|
|
788
|
+
{ dir: join(projectRoot, 'packages', 'domain-cli', 'src', 'commands'), label: 'CLI commands' },
|
|
789
|
+
];
|
|
790
|
+
|
|
791
|
+
const violations: string[] = [];
|
|
792
|
+
let totalFiles = 0;
|
|
793
|
+
|
|
794
|
+
for (const { dir, label } of scanDirs) {
|
|
795
|
+
if (!existsSync(dir)) continue;
|
|
796
|
+
const files = findFiles(dir, /\.ts$/);
|
|
797
|
+
totalFiles += files.length;
|
|
798
|
+
|
|
799
|
+
for (const file of files) {
|
|
800
|
+
const content = await readFile(file, 'utf8');
|
|
801
|
+
const lines = content.split('\n');
|
|
802
|
+
for (let i = 0; i < lines.length; i++) {
|
|
803
|
+
const line = lines[i];
|
|
804
|
+
const code = line.replace(/\/\/.*$/, '');
|
|
805
|
+
if (/(?<![\w$.])fetch\s*\(/.test(code)) {
|
|
806
|
+
violations.push(`${relative(projectRoot, file)}:${i + 1} (${label})`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (violations.length === 0) {
|
|
813
|
+
return { name: 'All HTTP clients use ky', status: 'PASS',
|
|
814
|
+
message: `No raw fetch() calls across ${totalFiles} file(s) in domains/ and CLI — all HTTP clients use ky or ctx.fetch`,
|
|
815
|
+
kNodeRef: 'K:convention:http-client-ky' };
|
|
816
|
+
} else {
|
|
817
|
+
return { name: 'All HTTP clients use ky', status: 'FAIL',
|
|
818
|
+
message: `Found ${violations.length} raw fetch() call(s):\n ${violations.join('\n ')}`,
|
|
819
|
+
fixHint: 'Use ky or ctx.fetch from @mettlecast/domain-runtime instead of raw fetch()',
|
|
820
|
+
kNodeRef: 'K:convention:http-client-ky' };
|
|
821
|
+
}
|
|
822
|
+
} catch (err) {
|
|
823
|
+
return { name: 'All HTTP clients use ky', status: 'WARN',
|
|
824
|
+
message: `Could not check HTTP clients: ${String(err)}`,
|
|
825
|
+
kNodeRef: 'K:convention:http-client-ky' };
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Check W5-3: All frontend routes use TanStack Router (no react-router-dom imports).
|
|
831
|
+
* Scans frontend/ for `from 'react-router-dom'` (or double-quoted) imports.
|
|
832
|
+
*/
|
|
833
|
+
async function checkAllRoutesUseTanStackRouter(projectRoot: string): Promise<DoctorCheck> {
|
|
834
|
+
try {
|
|
835
|
+
const frontendDir = join(projectRoot, 'frontend');
|
|
836
|
+
if (!existsSync(frontendDir)) {
|
|
837
|
+
return { name: 'Routes use TanStack Router', status: 'PASS',
|
|
838
|
+
message: 'No frontend/ directory — check skipped (optional)',
|
|
839
|
+
kNodeRef: 'K:convention:tanstack-router' };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const files = findFiles(frontendDir, /\.(ts|tsx|js|jsx)$/);
|
|
843
|
+
const violations: string[] = [];
|
|
844
|
+
|
|
845
|
+
for (const file of files) {
|
|
846
|
+
const content = await readFile(file, 'utf8');
|
|
847
|
+
if (/from\s+['"]react-router-dom['"]/.test(content)) {
|
|
848
|
+
violations.push(relative(projectRoot, file));
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (violations.length === 0) {
|
|
853
|
+
return { name: 'Routes use TanStack Router', status: 'PASS',
|
|
854
|
+
message: 'No react-router-dom imports in frontend/',
|
|
855
|
+
kNodeRef: 'K:convention:tanstack-router' };
|
|
856
|
+
} else {
|
|
857
|
+
return { name: 'Routes use TanStack Router', status: 'FAIL',
|
|
858
|
+
message: `Found ${violations.length} react-router-dom import(s) in frontend/:\n ${violations.join('\n ')}`,
|
|
859
|
+
fixHint: 'Migrate from react-router-dom to @tanstack/react-router',
|
|
860
|
+
kNodeRef: 'K:convention:tanstack-router' };
|
|
861
|
+
}
|
|
862
|
+
} catch (err) {
|
|
863
|
+
return { name: 'Routes use TanStack Router', status: 'WARN',
|
|
864
|
+
message: `Could not check frontend router: ${String(err)}`,
|
|
865
|
+
kNodeRef: 'K:convention:tanstack-router' };
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Check W5-4: Lambda handler files contain initOtel() call.
|
|
871
|
+
* Scans `domains/*\/api/*.ts` and FAILs if any handler is missing initOtel().
|
|
872
|
+
*/
|
|
873
|
+
async function checkOtelInitInLambdas(projectRoot: string): Promise<DoctorCheck> {
|
|
874
|
+
try {
|
|
875
|
+
const domainsDir = join(projectRoot, 'domains');
|
|
876
|
+
let entries: { name: string; isDirectory: () => boolean }[] = [];
|
|
877
|
+
try {
|
|
878
|
+
entries = await readdir(domainsDir, { withFileTypes: true });
|
|
879
|
+
} catch {
|
|
880
|
+
return { name: 'OTel init in Lambdas', status: 'PASS',
|
|
881
|
+
message: 'No domains/ — check skipped (optional)',
|
|
882
|
+
kNodeRef: 'K:convention:otel-init' };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const handlerFiles: string[] = [];
|
|
886
|
+
for (const entry of entries) {
|
|
887
|
+
if (!entry.isDirectory()) continue;
|
|
888
|
+
const apiDir = join(domainsDir, entry.name, 'api');
|
|
889
|
+
const files = findFiles(apiDir, /\.ts$/);
|
|
890
|
+
handlerFiles.push(...files);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (handlerFiles.length === 0) {
|
|
894
|
+
return { name: 'OTel init in Lambdas', status: 'PASS',
|
|
895
|
+
message: 'No Lambda handler files in domains/ (optional)',
|
|
896
|
+
kNodeRef: 'K:convention:otel-init' };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const missing: string[] = [];
|
|
900
|
+
for (const file of handlerFiles) {
|
|
901
|
+
const content = await readFile(file, 'utf8');
|
|
902
|
+
if (!/initOtel\s*\(/.test(content)) {
|
|
903
|
+
missing.push(relative(projectRoot, file));
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (missing.length === 0) {
|
|
908
|
+
return { name: 'OTel init in Lambdas', status: 'PASS',
|
|
909
|
+
message: `All ${handlerFiles.length} Lambda handler(s) call initOtel()`,
|
|
910
|
+
kNodeRef: 'K:convention:otel-init' };
|
|
911
|
+
} else {
|
|
912
|
+
return { name: 'OTel init in Lambdas', status: 'FAIL',
|
|
913
|
+
message: `${missing.length}/${handlerFiles.length} Lambda handler(s) missing initOtel():\n ${missing.join('\n ')}`,
|
|
914
|
+
fixHint: 'Add initOtel() (from @mettlecast/domain-runtime) to each Lambda handler file',
|
|
915
|
+
kNodeRef: 'K:convention:otel-init' };
|
|
916
|
+
}
|
|
917
|
+
} catch (err) {
|
|
918
|
+
return { name: 'OTel init in Lambdas', status: 'WARN',
|
|
919
|
+
message: `Could not check OTel init: ${String(err)}`,
|
|
920
|
+
kNodeRef: 'K:convention:otel-init' };
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Check W5-5: Frontend tsconfig.json has `strict: true`.
|
|
926
|
+
* Reads frontend/tsconfig.json and verifies compilerOptions.strict === true.
|
|
927
|
+
*/
|
|
928
|
+
async function checkFrontendUsesStrictTypescript(projectRoot: string): Promise<DoctorCheck> {
|
|
929
|
+
try {
|
|
930
|
+
const tsconfigPath = join(projectRoot, 'frontend', 'tsconfig.json');
|
|
931
|
+
if (!existsSync(tsconfigPath)) {
|
|
932
|
+
return { name: 'Frontend uses strict TypeScript', status: 'PASS',
|
|
933
|
+
message: 'No frontend/tsconfig.json — check skipped (optional)',
|
|
934
|
+
kNodeRef: 'K:convention:typescript-strict' };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const content = await readFile(tsconfigPath, 'utf8');
|
|
938
|
+
let config: { compilerOptions?: { strict?: unknown } };
|
|
939
|
+
try {
|
|
940
|
+
config = JSON.parse(content);
|
|
941
|
+
} catch (err) {
|
|
942
|
+
return { name: 'Frontend uses strict TypeScript', status: 'FAIL',
|
|
943
|
+
message: `frontend/tsconfig.json is not valid JSON: ${String(err)}`,
|
|
944
|
+
fixHint: 'Fix tsconfig.json JSON syntax',
|
|
945
|
+
kNodeRef: 'K:convention:typescript-strict' };
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const strict = config.compilerOptions?.strict === true;
|
|
949
|
+
if (strict) {
|
|
950
|
+
return { name: 'Frontend uses strict TypeScript', status: 'PASS',
|
|
951
|
+
message: 'frontend/tsconfig.json has strict: true',
|
|
952
|
+
kNodeRef: 'K:convention:typescript-strict' };
|
|
953
|
+
} else {
|
|
954
|
+
return { name: 'Frontend uses strict TypeScript', status: 'FAIL',
|
|
955
|
+
message: 'frontend/tsconfig.json does not have strict: true',
|
|
956
|
+
fixHint: "Add \"strict\": true to compilerOptions in frontend/tsconfig.json",
|
|
957
|
+
kNodeRef: 'K:convention:typescript-strict' };
|
|
958
|
+
}
|
|
959
|
+
} catch (err) {
|
|
960
|
+
return { name: 'Frontend uses strict TypeScript', status: 'WARN',
|
|
961
|
+
message: `Could not check tsconfig: ${String(err)}`,
|
|
962
|
+
kNodeRef: 'K:convention:typescript-strict' };
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
600
966
|
/**
|
|
601
967
|
* Check 10: Every event has consumer or @no-consumers annotation.
|
|
602
968
|
*/
|
|
@@ -796,9 +1162,10 @@ async function checkDomainBrainReachable(projectRoot: string): Promise<DoctorChe
|
|
|
796
1162
|
try {
|
|
797
1163
|
const controller = new AbortController();
|
|
798
1164
|
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
|
799
|
-
const response = await
|
|
800
|
-
|
|
1165
|
+
const response = await ky.get(`${brainEndpoint}/api/graph/nodes`, {
|
|
1166
|
+
signal: controller.signal,
|
|
801
1167
|
headers: { 'Content-Type': 'application/json' },
|
|
1168
|
+
timeout: 3000,
|
|
802
1169
|
});
|
|
803
1170
|
clearTimeout(timeoutId);
|
|
804
1171
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generate-openapi — read a domain's registry and produce an OpenAPI 3.1
|
|
3
|
+
* specification as JSON. The registry is consumed at build time; each
|
|
4
|
+
* `defineApi` entry contributes one path under the domain's route prefix.
|
|
5
|
+
*
|
|
6
|
+
* Usage: npx mc-domain-module generate-openapi <domain>
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { cliLogger } from '../utils/logger.js';
|
|
12
|
+
|
|
13
|
+
export interface GenerateOpenapiOptions {
|
|
14
|
+
domain: string;
|
|
15
|
+
projectRoot?: string;
|
|
16
|
+
/** Output path. Defaults to domains/<domain>/api/openapi.generated.json */
|
|
17
|
+
output?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ApiRegistryEntry {
|
|
21
|
+
id: string;
|
|
22
|
+
path: string;
|
|
23
|
+
method: string;
|
|
24
|
+
tenancy: string;
|
|
25
|
+
versions: Record<string, {
|
|
26
|
+
status: string;
|
|
27
|
+
inputSchema?: unknown;
|
|
28
|
+
outputSchema?: unknown;
|
|
29
|
+
input?: unknown;
|
|
30
|
+
output?: unknown;
|
|
31
|
+
}>;
|
|
32
|
+
examples?: { request?: unknown; response?: unknown };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface DomainRegistry {
|
|
36
|
+
domain: { id: string };
|
|
37
|
+
apis: ApiRegistryEntry[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Extract a Zod schema shape from a registry entry version.
|
|
42
|
+
* The registry may store schemas as `input`/`output` (raw Zod
|
|
43
|
+
* objects) or `inputSchema`/`outputSchema` (JSON Schema shapes).
|
|
44
|
+
* Returns a best-effort JSON Schema object for the OpenAPI spec.
|
|
45
|
+
*/
|
|
46
|
+
function extractSchema(version: ApiRegistryEntry['versions'][string], field: 'input' | 'output'): unknown {
|
|
47
|
+
// Prefer JSON Schema if present
|
|
48
|
+
const schemaField = field === 'input' ? version.inputSchema : version.outputSchema;
|
|
49
|
+
if (schemaField && typeof schemaField === 'object' && schemaField !== null) {
|
|
50
|
+
return schemaField;
|
|
51
|
+
}
|
|
52
|
+
// Fall back to the raw Zod shape — try to produce a minimal JSON
|
|
53
|
+
// Schema from the Zod _def. For full support, add zod-to-json-schema
|
|
54
|
+
// to the CLI dependencies and call `zodToJsonSchema(zodSchema)`.
|
|
55
|
+
const raw = field === 'input' ? version.input : version.output;
|
|
56
|
+
if (raw && typeof raw === 'object' && raw !== null) {
|
|
57
|
+
// Attempt minimal mapping: if the Zod shape has a `type` field
|
|
58
|
+
// from its _def, describe it as JSON Schema.
|
|
59
|
+
const def = raw as { type?: string; items?: unknown; properties?: unknown; required?: string[] };
|
|
60
|
+
return { type: def.type ?? 'object', properties: def.properties, required: def.required };
|
|
61
|
+
}
|
|
62
|
+
return { type: 'object' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function runGenerateOpenapi(options: GenerateOpenapiOptions): Promise<string> {
|
|
66
|
+
const projectRoot = options.projectRoot ?? process.cwd();
|
|
67
|
+
const domain = options.domain;
|
|
68
|
+
const outputPath = options.output ?? join(projectRoot, 'domains', domain, 'api', 'openapi.generated.json');
|
|
69
|
+
|
|
70
|
+
// Read the domain registry
|
|
71
|
+
const registryPath = join(projectRoot, '.mc', `${domain}-registry.json`);
|
|
72
|
+
const registryJson = await readFile(registryPath, 'utf8');
|
|
73
|
+
const registry = JSON.parse(registryJson) as DomainRegistry;
|
|
74
|
+
|
|
75
|
+
const paths: Record<string, unknown> = {};
|
|
76
|
+
|
|
77
|
+
for (const api of registry.apis) {
|
|
78
|
+
const method = (api.method ?? 'get').toLowerCase();
|
|
79
|
+
const fullPath = `/v1/${domain}${api.path}`;
|
|
80
|
+
const v1 = api.versions['v1'] ?? api.versions[Object.keys(api.versions)[0]];
|
|
81
|
+
if (!v1) continue;
|
|
82
|
+
|
|
83
|
+
if (!paths[fullPath]) paths[fullPath] = {};
|
|
84
|
+
|
|
85
|
+
(paths[fullPath] as Record<string, unknown>)[method] = {
|
|
86
|
+
operationId: `${domain}.${api.id}`,
|
|
87
|
+
summary: `${domain}.${api.id}`,
|
|
88
|
+
description: `Version: ${v1.status}`,
|
|
89
|
+
requestBody: {
|
|
90
|
+
content: {
|
|
91
|
+
'application/json': {
|
|
92
|
+
schema: extractSchema(v1, 'input'),
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
responses: {
|
|
97
|
+
'200': {
|
|
98
|
+
description: 'OK',
|
|
99
|
+
content: {
|
|
100
|
+
'application/json': {
|
|
101
|
+
schema: extractSchema(v1, 'output'),
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
'400': {
|
|
106
|
+
description: 'Validation error',
|
|
107
|
+
content: {
|
|
108
|
+
'application/json': {
|
|
109
|
+
schema: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
properties: {
|
|
112
|
+
error: {
|
|
113
|
+
type: 'object',
|
|
114
|
+
properties: {
|
|
115
|
+
kind: { type: 'string', enum: ['validation'] },
|
|
116
|
+
message: { type: 'string' },
|
|
117
|
+
fieldErrors: { type: 'object' },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const openapi = {
|
|
130
|
+
openapi: '3.1.0',
|
|
131
|
+
info: {
|
|
132
|
+
title: `${domain} API`,
|
|
133
|
+
version: '1.0.0',
|
|
134
|
+
description: `Generated from ${domain}-registry.json at build time. Do not edit manually.`,
|
|
135
|
+
},
|
|
136
|
+
paths,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const dir = join(outputPath, '..');
|
|
140
|
+
await mkdir(dir, { recursive: true });
|
|
141
|
+
await writeFile(outputPath, JSON.stringify(openapi, null, 2), 'utf8');
|
|
142
|
+
|
|
143
|
+
cliLogger.info({ outputPath }, 'generate-openapi: spec written');
|
|
144
|
+
return outputPath;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* CLI entry point.
|
|
149
|
+
*/
|
|
150
|
+
export async function runGenerateOpenapiCli(domain: string, opts: { projectRoot?: string; output?: string } = {}): Promise<void> {
|
|
151
|
+
const outputPath = await runGenerateOpenapi({ domain, ...opts });
|
|
152
|
+
// eslint-disable-next-line no-console
|
|
153
|
+
console.log(`OpenAPI spec written to: ${outputPath}`);
|
|
154
|
+
}
|