@mettlecast/domain-cli 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +133 -0
- package/dist/builder/build-registry.d.ts +20 -0
- package/dist/builder/build-registry.js +178 -0
- package/dist/builder/build-types.d.ts +10 -0
- package/dist/builder/build-types.js +32 -0
- package/dist/builder/load-domain-module-config.d.ts +12 -0
- package/dist/builder/load-domain-module-config.js +34 -0
- package/dist/builder/load-module.d.ts +26 -0
- package/dist/builder/load-module.js +114 -0
- package/dist/builder/zod-to-json.d.ts +10 -0
- package/dist/builder/zod-to-json.js +15 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +338 -0
- package/dist/commands/add-api.d.ts +32 -0
- package/dist/commands/add-api.js +106 -0
- package/dist/commands/add-component.d.ts +15 -0
- package/dist/commands/add-component.js +78 -0
- package/dist/commands/add-domain.d.ts +21 -0
- package/dist/commands/add-domain.js +71 -0
- package/dist/commands/add-flow.d.ts +14 -0
- package/dist/commands/add-flow.js +108 -0
- package/dist/commands/add-hook.d.ts +15 -0
- package/dist/commands/add-hook.js +65 -0
- package/dist/commands/add-migration.d.ts +26 -0
- package/dist/commands/add-migration.js +93 -0
- package/dist/commands/add-module.d.ts +7 -0
- package/dist/commands/add-module.js +261 -0
- package/dist/commands/add-page.d.ts +13 -0
- package/dist/commands/add-page.js +170 -0
- package/dist/commands/add-seed-page.d.ts +5 -0
- package/dist/commands/add-seed-page.js +251 -0
- package/dist/commands/add-subscriber.d.ts +30 -0
- package/dist/commands/add-subscriber.js +87 -0
- package/dist/commands/add-test.d.ts +22 -0
- package/dist/commands/add-test.js +285 -0
- package/dist/commands/audit.d.ts +39 -0
- package/dist/commands/audit.js +171 -0
- package/dist/commands/build-catalog.d.ts +68 -0
- package/dist/commands/build-catalog.js +105 -0
- package/dist/commands/build-flows.d.ts +26 -0
- package/dist/commands/build-flows.js +174 -0
- package/dist/commands/build.d.ts +17 -0
- package/dist/commands/build.js +39 -0
- package/dist/commands/check-hashes.d.ts +38 -0
- package/dist/commands/check-hashes.js +97 -0
- package/dist/commands/create-project.d.ts +5 -0
- package/dist/commands/create-project.js +272 -0
- package/dist/commands/dev.d.ts +15 -0
- package/dist/commands/dev.js +27 -0
- package/dist/commands/doctor.d.ts +48 -0
- package/dist/commands/doctor.js +1301 -0
- package/dist/commands/explain.d.ts +34 -0
- package/dist/commands/explain.js +167 -0
- package/dist/commands/power-tune.d.ts +1 -0
- package/dist/commands/power-tune.js +87 -0
- package/dist/commands/reseed-page.d.ts +6 -0
- package/dist/commands/reseed-page.js +297 -0
- package/dist/commands/show-dns.d.ts +15 -0
- package/dist/commands/show-dns.js +101 -0
- package/dist/commands/show.d.ts +46 -0
- package/dist/commands/show.js +135 -0
- package/dist/commands/test.d.ts +18 -0
- package/dist/commands/test.js +56 -0
- package/dist/commands/upgrade-backend.d.ts +9 -0
- package/dist/commands/upgrade-backend.js +46 -0
- package/dist/commands/upgrade-event.d.ts +22 -0
- package/dist/commands/upgrade-event.js +111 -0
- package/dist/commands/upgrade-frontend.d.ts +5 -0
- package/dist/commands/upgrade-frontend.js +99 -0
- package/dist/commands/upgrade.d.ts +7 -0
- package/dist/commands/upgrade.js +495 -0
- package/dist/commands/validate.d.ts +30 -0
- package/dist/commands/validate.js +148 -0
- package/dist/runtime-stubs/hydrate-local-ctx.d.ts +49 -0
- package/dist/runtime-stubs/hydrate-local-ctx.js +45 -0
- package/dist/runtime-stubs/load-secrets.d.ts +11 -0
- package/dist/runtime-stubs/load-secrets.js +37 -0
- package/dist/server/api-server.d.ts +21 -0
- package/dist/server/api-server.js +14 -0
- package/dist/server/mount-routes.d.ts +19 -0
- package/dist/server/mount-routes.js +42 -0
- package/dist/templates/api-skeleton.d.ts +18 -0
- package/dist/templates/api-skeleton.js +69 -0
- package/dist/templates/claude-md.d.ts +4 -0
- package/dist/templates/claude-md.js +7 -0
- package/dist/templates/dashboard-pages/index.d.ts +101 -0
- package/dist/templates/dashboard-pages/index.js +22 -0
- package/dist/templates/domain-config.d.ts +10 -0
- package/dist/templates/domain-config.js +19 -0
- package/dist/templates/events-skeleton.d.ts +9 -0
- package/dist/templates/events-skeleton.js +23 -0
- package/dist/templates/patterns/api/create-with-event.d.ts +5 -0
- package/dist/templates/patterns/api/create-with-event.js +69 -0
- package/dist/templates/patterns/api/idempotent-mutation.d.ts +5 -0
- package/dist/templates/patterns/api/idempotent-mutation.js +99 -0
- package/dist/templates/patterns/api/paginated-list.d.ts +5 -0
- package/dist/templates/patterns/api/paginated-list.js +71 -0
- package/dist/templates/patterns/api/simple-crud.d.ts +6 -0
- package/dist/templates/patterns/api/simple-crud.js +95 -0
- package/dist/templates/patterns/api/system-admin.d.ts +5 -0
- package/dist/templates/patterns/api/system-admin.js +98 -0
- package/dist/templates/patterns/api/webhook-receiver-style.d.ts +5 -0
- package/dist/templates/patterns/api/webhook-receiver-style.js +125 -0
- package/dist/templates/patterns/component/index.d.ts +25 -0
- package/dist/templates/patterns/component/index.js +814 -0
- package/dist/templates/patterns/flow/autonomous-remediation.d.ts +5 -0
- package/dist/templates/patterns/flow/autonomous-remediation.js +98 -0
- package/dist/templates/patterns/flow/fan-out-reaction.d.ts +5 -0
- package/dist/templates/patterns/flow/fan-out-reaction.js +53 -0
- package/dist/templates/patterns/flow/human-in-loop.d.ts +5 -0
- package/dist/templates/patterns/flow/human-in-loop.js +90 -0
- package/dist/templates/patterns/flow/multi-step-orchestration.d.ts +5 -0
- package/dist/templates/patterns/flow/multi-step-orchestration.js +67 -0
- package/dist/templates/patterns/hook/index.d.ts +7 -0
- package/dist/templates/patterns/hook/index.js +378 -0
- package/dist/templates/patterns/migration/column-add.d.ts +5 -0
- package/dist/templates/patterns/migration/column-add.js +52 -0
- package/dist/templates/patterns/migration/event-store.d.ts +6 -0
- package/dist/templates/patterns/migration/event-store.js +97 -0
- package/dist/templates/patterns/migration/index.d.ts +5 -0
- package/dist/templates/patterns/migration/index.js +52 -0
- package/dist/templates/patterns/migration/rls-policy.d.ts +5 -0
- package/dist/templates/patterns/migration/rls-policy.js +78 -0
- package/dist/templates/patterns/migration/tenant-scoped-table.d.ts +6 -0
- package/dist/templates/patterns/migration/tenant-scoped-table.js +81 -0
- package/dist/templates/patterns/page/dashboard.d.ts +4 -0
- package/dist/templates/patterns/page/dashboard.js +164 -0
- package/dist/templates/patterns/page/detail.d.ts +4 -0
- package/dist/templates/patterns/page/detail.js +162 -0
- package/dist/templates/patterns/page/form.d.ts +4 -0
- package/dist/templates/patterns/page/form.js +192 -0
- package/dist/templates/patterns/page/index.d.ts +8 -0
- package/dist/templates/patterns/page/index.js +14 -0
- package/dist/templates/patterns/page/list.d.ts +4 -0
- package/dist/templates/patterns/page/list.js +138 -0
- package/dist/templates/patterns/page/settings.d.ts +4 -0
- package/dist/templates/patterns/page/settings.js +318 -0
- package/dist/templates/patterns/page/wizard.d.ts +4 -0
- package/dist/templates/patterns/page/wizard.js +293 -0
- package/dist/templates/patterns/subscriber/audit-relay.d.ts +5 -0
- package/dist/templates/patterns/subscriber/audit-relay.js +51 -0
- package/dist/templates/patterns/subscriber/cascade-deletion.d.ts +5 -0
- package/dist/templates/patterns/subscriber/cascade-deletion.js +60 -0
- package/dist/templates/patterns/subscriber/single-step-projection.d.ts +5 -0
- package/dist/templates/patterns/subscriber/single-step-projection.js +36 -0
- package/dist/templates/readme.d.ts +4 -0
- package/dist/templates/readme.js +7 -0
- package/dist/templates/subscriber-skeleton.d.ts +11 -0
- package/dist/templates/subscriber-skeleton.js +23 -0
- package/dist/utils/checksum.d.ts +18 -0
- package/dist/utils/checksum.js +39 -0
- package/dist/utils/file-helpers.d.ts +35 -0
- package/dist/utils/file-helpers.js +59 -0
- package/dist/utils/header-inject.d.ts +15 -0
- package/dist/utils/header-inject.js +93 -0
- package/dist/utils/install-file.d.ts +17 -0
- package/dist/utils/install-file.js +78 -0
- package/dist/utils/logger.d.ts +12 -0
- package/dist/utils/logger.js +24 -0
- package/dist/utils/manifest.d.ts +28 -0
- package/dist/utils/manifest.js +93 -0
- package/dist/utils/s3-fetch.d.ts +25 -0
- package/dist/utils/s3-fetch.js +52 -0
- package/dist/utils/scaffold-config.d.ts +95 -0
- package/dist/utils/scaffold-config.js +53 -0
- package/dist/utils/token-substitution.d.ts +35 -0
- package/dist/utils/token-substitution.js +52 -0
- package/dist/utils/zod-extractor.d.ts +10 -0
- package/dist/utils/zod-extractor.js +28 -0
- package/package.json +43 -0
- package/src/__tests__/build-registry.test.ts +115 -0
- package/src/__tests__/build-types.test.ts +100 -0
- package/src/__tests__/builder/walkDomainDir.test.ts +140 -0
- package/src/__tests__/commands/add-domain.test.ts +174 -0
- package/src/__tests__/commands/add-flow.test.ts +69 -0
- package/src/__tests__/commands/add-module.test.ts +207 -0
- package/src/__tests__/commands/build-flows.test.ts +154 -0
- package/src/__tests__/commands/check-hashes.test.ts +142 -0
- package/src/__tests__/commands/create-project.test.ts +712 -0
- package/src/__tests__/commands/upgrade.test.ts +762 -0
- package/src/__tests__/doctor.test.ts +291 -0
- package/src/__tests__/hydrate-local-ctx.test.ts +71 -0
- package/src/__tests__/load-domain-module-config.test.ts +10 -0
- package/src/__tests__/mount-routes.test.ts +100 -0
- package/src/__tests__/scaffold-src/part-a-layout.test.ts +46 -0
- package/src/__tests__/scripts/package-scaffold.test.ts +45 -0
- package/src/__tests__/utils/checksum.test.ts +90 -0
- package/src/__tests__/utils/header-inject.test.ts +89 -0
- package/src/__tests__/utils/install-file.test.ts +207 -0
- package/src/__tests__/utils/manifest.test.ts +229 -0
- package/src/__tests__/utils/token-substitution.test.ts +134 -0
- package/src/__tests__/validate.test.ts +18 -0
- package/src/builder/build-registry.ts +237 -0
- package/src/builder/build-types.ts +36 -0
- package/src/builder/load-domain-module-config.ts +43 -0
- package/src/builder/load-module.ts +133 -0
- package/src/builder/zod-to-json.ts +16 -0
- package/src/cli.ts +387 -0
- package/src/commands/add-api.ts +145 -0
- package/src/commands/add-component.ts +116 -0
- package/src/commands/add-domain.ts +100 -0
- package/src/commands/add-flow.ts +136 -0
- package/src/commands/add-hook.ts +100 -0
- package/src/commands/add-migration.ts +129 -0
- package/src/commands/add-module.ts +347 -0
- package/src/commands/add-page.ts +242 -0
- package/src/commands/add-seed-page.ts +338 -0
- package/src/commands/add-subscriber.ts +123 -0
- package/src/commands/add-test.ts +328 -0
- package/src/commands/audit.ts +217 -0
- package/src/commands/build-catalog.ts +181 -0
- package/src/commands/build-flows.ts +198 -0
- package/src/commands/build.ts +59 -0
- package/src/commands/check-hashes.ts +150 -0
- package/src/commands/create-project.ts +367 -0
- package/src/commands/dev.ts +47 -0
- package/src/commands/doctor.ts +1362 -0
- package/src/commands/explain.ts +209 -0
- package/src/commands/power-tune.ts +112 -0
- package/src/commands/reseed-page.ts +410 -0
- package/src/commands/show-dns.ts +139 -0
- package/src/commands/show.ts +179 -0
- package/src/commands/test.ts +91 -0
- package/src/commands/upgrade-backend.ts +72 -0
- package/src/commands/upgrade-event.ts +175 -0
- package/src/commands/upgrade-frontend.ts +135 -0
- package/src/commands/upgrade.ts +668 -0
- package/src/commands/validate.ts +208 -0
- package/src/runtime-stubs/hydrate-local-ctx.ts +102 -0
- package/src/runtime-stubs/load-secrets.ts +40 -0
- package/src/server/api-server.ts +34 -0
- package/src/server/mount-routes.ts +68 -0
- package/src/templates/api-skeleton.ts +72 -0
- package/src/templates/claude-md.ts +7 -0
- package/src/templates/dashboard-pages/account/api-keys.tsx +107 -0
- package/src/templates/dashboard-pages/account/audit-log.tsx +60 -0
- package/src/templates/dashboard-pages/account/index.ts +5 -0
- package/src/templates/dashboard-pages/account/members.tsx +107 -0
- package/src/templates/dashboard-pages/account/profile.tsx +53 -0
- package/src/templates/dashboard-pages/account/workspace-settings.tsx +64 -0
- package/src/templates/dashboard-pages/auth/accept-invitation.tsx +62 -0
- package/src/templates/dashboard-pages/auth/choose-org.tsx +66 -0
- package/src/templates/dashboard-pages/auth/forgot-password.tsx +69 -0
- package/src/templates/dashboard-pages/auth/index.ts +10 -0
- package/src/templates/dashboard-pages/auth/login.tsx +75 -0
- package/src/templates/dashboard-pages/auth/mfa-setup.tsx +56 -0
- package/src/templates/dashboard-pages/auth/mfa-verify.tsx +49 -0
- package/src/templates/dashboard-pages/auth/reset-password.tsx +64 -0
- package/src/templates/dashboard-pages/auth/sign-out.tsx +20 -0
- package/src/templates/dashboard-pages/auth/signup.tsx +71 -0
- package/src/templates/dashboard-pages/auth/verify-email.tsx +53 -0
- package/src/templates/dashboard-pages/index.ts +22 -0
- package/src/templates/dashboard-pages/shell/dashboard-home.tsx +41 -0
- package/src/templates/dashboard-pages/shell/forbidden.tsx +46 -0
- package/src/templates/dashboard-pages/shell/index.ts +5 -0
- package/src/templates/dashboard-pages/shell/maintenance.tsx +27 -0
- package/src/templates/dashboard-pages/shell/not-found.tsx +34 -0
- package/src/templates/dashboard-pages/shell/server-error.tsx +44 -0
- package/src/templates/domain-config.ts +20 -0
- package/src/templates/events-skeleton.ts +24 -0
- package/src/templates/patterns/api/create-with-event.ts +72 -0
- package/src/templates/patterns/api/idempotent-mutation.ts +102 -0
- package/src/templates/patterns/api/paginated-list.ts +74 -0
- package/src/templates/patterns/api/simple-crud.ts +100 -0
- package/src/templates/patterns/api/system-admin.ts +101 -0
- package/src/templates/patterns/api/webhook-receiver-style.ts +128 -0
- package/src/templates/patterns/component/index.ts +833 -0
- package/src/templates/patterns/flow/autonomous-remediation.ts +101 -0
- package/src/templates/patterns/flow/fan-out-reaction.ts +56 -0
- package/src/templates/patterns/flow/human-in-loop.ts +93 -0
- package/src/templates/patterns/flow/multi-step-orchestration.ts +70 -0
- package/src/templates/patterns/hook/index.ts +382 -0
- package/src/templates/patterns/migration/column-add.ts +54 -0
- package/src/templates/patterns/migration/event-store.ts +99 -0
- package/src/templates/patterns/migration/index.ts +54 -0
- package/src/templates/patterns/migration/rls-policy.ts +80 -0
- package/src/templates/patterns/migration/tenant-scoped-table.ts +83 -0
- package/src/templates/patterns/page/dashboard.ts +164 -0
- package/src/templates/patterns/page/detail.ts +162 -0
- package/src/templates/patterns/page/form.ts +192 -0
- package/src/templates/patterns/page/index.ts +17 -0
- package/src/templates/patterns/page/list.ts +138 -0
- package/src/templates/patterns/page/settings.ts +318 -0
- package/src/templates/patterns/page/wizard.ts +293 -0
- package/src/templates/patterns/subscriber/audit-relay.ts +54 -0
- package/src/templates/patterns/subscriber/cascade-deletion.ts +63 -0
- package/src/templates/patterns/subscriber/single-step-projection.ts +39 -0
- package/src/templates/readme.ts +7 -0
- package/src/templates/subscriber-skeleton.ts +28 -0
- package/src/utils/checksum.ts +43 -0
- package/src/utils/file-helpers.ts +91 -0
- package/src/utils/header-inject.ts +126 -0
- package/src/utils/install-file.ts +99 -0
- package/src/utils/logger.ts +26 -0
- package/src/utils/manifest.ts +137 -0
- package/src/utils/s3-fetch.ts +91 -0
- package/src/utils/scaffold-config.ts +172 -0
- package/src/utils/token-substitution.ts +80 -0
- package/src/utils/zod-extractor.ts +31 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,1362 @@
|
|
|
1
|
+
import { readdir, readFile, access, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { relative } from 'node:path';
|
|
4
|
+
import { readdirSync, existsSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Options for the doctor command.
|
|
8
|
+
* Doctor is ALWAYS strict — any FAIL causes exit code 1.
|
|
9
|
+
*/
|
|
10
|
+
export interface DoctorOptions {
|
|
11
|
+
/** Root directory of the project (defaults to cwd). */
|
|
12
|
+
projectRoot?: string;
|
|
13
|
+
/** Flag to run relocation of old-layout scaffold files to new layout. */
|
|
14
|
+
relocate?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A single structural check result.
|
|
19
|
+
*/
|
|
20
|
+
export interface DoctorCheck {
|
|
21
|
+
/** Short name of the check (e.g. 'Domain configs valid'). */
|
|
22
|
+
name: string;
|
|
23
|
+
/** Status: PASS, WARN, or FAIL. */
|
|
24
|
+
status: 'PASS' | 'WARN' | 'FAIL';
|
|
25
|
+
/** Human-readable message. */
|
|
26
|
+
message: string;
|
|
27
|
+
/** Optional fix suggestion. */
|
|
28
|
+
fixHint?: string;
|
|
29
|
+
/** Reference to a W1 K-node ID. */
|
|
30
|
+
kNodeRef?: string;
|
|
31
|
+
/** Optional file where violation found. */
|
|
32
|
+
file?: string;
|
|
33
|
+
/** Optional line number (if applicable). */
|
|
34
|
+
line?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Result of the doctor command.
|
|
39
|
+
*/
|
|
40
|
+
export interface DoctorReport {
|
|
41
|
+
/** Array of all checks performed. */
|
|
42
|
+
checks: DoctorCheck[];
|
|
43
|
+
/** Exit code: 0 for all PASS/WARN, 1 for any FAIL. */
|
|
44
|
+
exitCode: number;
|
|
45
|
+
/** Convenience boolean: true if exitCode === 0. */
|
|
46
|
+
pass: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Recursively find all files matching a pattern.
|
|
51
|
+
* @param dir - Directory to search
|
|
52
|
+
* @param pattern - File pattern (e.g., /\.ts$/ for TypeScript files)
|
|
53
|
+
* @returns Array of absolute file paths
|
|
54
|
+
*/
|
|
55
|
+
function findFiles(dir: string, pattern: RegExp): string[] {
|
|
56
|
+
const result: string[] = [];
|
|
57
|
+
|
|
58
|
+
function walk(currentPath: string): void {
|
|
59
|
+
try {
|
|
60
|
+
const entries = readdirSync(currentPath, { withFileTypes: true });
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const fullPath = join(currentPath, entry.name);
|
|
63
|
+
if (entry.isDirectory()) {
|
|
64
|
+
walk(fullPath);
|
|
65
|
+
} else if (pattern.test(entry.name)) {
|
|
66
|
+
result.push(fullPath);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// Silently skip inaccessible directories
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
walk(dir);
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Run all structural checks for the current TIB project.
|
|
80
|
+
* Each check runs independently; failure of one does not abort the rest.
|
|
81
|
+
*
|
|
82
|
+
* @param opts - DoctorOptions with optional projectRoot and strict mode.
|
|
83
|
+
* @returns DoctorReport with array of checks and exit code.
|
|
84
|
+
*/
|
|
85
|
+
export async function runDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
|
|
86
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
87
|
+
|
|
88
|
+
// Handle --relocate flag early
|
|
89
|
+
if (opts.relocate) {
|
|
90
|
+
return runRelocate(projectRoot);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const checks: DoctorCheck[] = [];
|
|
94
|
+
|
|
95
|
+
// Check 1: Domain configs valid
|
|
96
|
+
checks.push(await checkDomainConfigsValid(projectRoot));
|
|
97
|
+
|
|
98
|
+
// Check 2: No raw AWS SDK in domains
|
|
99
|
+
checks.push(await checkNoRawAwsSdk(projectRoot));
|
|
100
|
+
|
|
101
|
+
// Check 3: No cross-domain imports
|
|
102
|
+
checks.push(await checkNoCrossDomainImports(projectRoot));
|
|
103
|
+
|
|
104
|
+
// Check 4: APIs declare versions
|
|
105
|
+
checks.push(await checkApisHaveVersions(projectRoot));
|
|
106
|
+
|
|
107
|
+
// Check 5: APIs declare tenancy
|
|
108
|
+
checks.push(await checkApisHaveTenancy(projectRoot));
|
|
109
|
+
|
|
110
|
+
// Check 6: scaffold-config.json matches on-disk domains
|
|
111
|
+
checks.push(await checkScaffoldConfigMatchesDisk(projectRoot));
|
|
112
|
+
|
|
113
|
+
// Check 7: Husky hooks installed
|
|
114
|
+
checks.push(await checkHuskyInstalled(projectRoot));
|
|
115
|
+
|
|
116
|
+
// Check 8: No root-level flows (deprecated)
|
|
117
|
+
checks.push(await checkRootLevelFlows(projectRoot));
|
|
118
|
+
|
|
119
|
+
// W2 new assertions
|
|
120
|
+
checks.push(await checkApiFixtures(projectRoot));
|
|
121
|
+
checks.push(await checkEventConsumers(projectRoot));
|
|
122
|
+
checks.push(await checkMigrationTenancyTrio(projectRoot));
|
|
123
|
+
checks.push(await checkDomainRlsTest(projectRoot));
|
|
124
|
+
checks.push(await checkDomainBrainReachable(projectRoot));
|
|
125
|
+
checks.push(await checkActionsHaveTypes(projectRoot));
|
|
126
|
+
checks.push(await checkConventionEvidence(projectRoot));
|
|
127
|
+
|
|
128
|
+
// W1 tracked-files boundary checks
|
|
129
|
+
checks.push(...await checkScaffoldTrackedFilesHaveHeaders(projectRoot));
|
|
130
|
+
checks.push(...await checkSeedPagesHaveNoHeaders(projectRoot));
|
|
131
|
+
|
|
132
|
+
// Layout validation checks (new in Part C)
|
|
133
|
+
const layoutChecks = await checkLayoutPolicy(projectRoot);
|
|
134
|
+
checks.push(...layoutChecks);
|
|
135
|
+
|
|
136
|
+
// Compute exit code — doctor is always strict: any FAIL → exit 1
|
|
137
|
+
const hasFail = checks.some(c => c.status === 'FAIL');
|
|
138
|
+
const exitCode = hasFail ? 1 : 0;
|
|
139
|
+
|
|
140
|
+
return { checks, exitCode, pass: exitCode === 0 };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function checkDomainConfigsValid(projectRoot: string): Promise<DoctorCheck> {
|
|
144
|
+
try {
|
|
145
|
+
const domainsDir = join(projectRoot, 'domains');
|
|
146
|
+
let domainCount = 0;
|
|
147
|
+
let failedCount = 0;
|
|
148
|
+
|
|
149
|
+
// List directories in domains/
|
|
150
|
+
const entries = await readdir(domainsDir, { withFileTypes: true });
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
if (!entry.isDirectory()) continue;
|
|
153
|
+
domainCount++;
|
|
154
|
+
const configPath = join(domainsDir, entry.name, 'domain.config.ts');
|
|
155
|
+
try {
|
|
156
|
+
await access(configPath);
|
|
157
|
+
} catch {
|
|
158
|
+
failedCount++;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (failedCount === 0) {
|
|
163
|
+
return {
|
|
164
|
+
name: 'Domain configs valid',
|
|
165
|
+
status: 'PASS',
|
|
166
|
+
message: `All ${domainCount} domain.config.ts files exist and are readable`,
|
|
167
|
+
kNodeRef: 'K:runbook:add-domain',
|
|
168
|
+
};
|
|
169
|
+
} else {
|
|
170
|
+
return {
|
|
171
|
+
name: 'Domain configs valid',
|
|
172
|
+
status: 'FAIL',
|
|
173
|
+
message: `${failedCount}/${domainCount} domain.config.ts files are missing or unreadable`,
|
|
174
|
+
fixHint: `Run 'mc-domain-module add-domain <id>' for missing domains`,
|
|
175
|
+
kNodeRef: 'K:runbook:add-domain',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
return {
|
|
180
|
+
name: 'Domain configs valid',
|
|
181
|
+
status: 'FAIL',
|
|
182
|
+
message: `Failed to check domain configs: ${String(err)}`,
|
|
183
|
+
kNodeRef: 'K:runbook:add-domain',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function checkNoRawAwsSdk(projectRoot: string): Promise<DoctorCheck> {
|
|
189
|
+
try {
|
|
190
|
+
const domainsDir = join(projectRoot, 'domains');
|
|
191
|
+
const results: string[] = [];
|
|
192
|
+
|
|
193
|
+
// Find all .ts files in domains/
|
|
194
|
+
const files = findFiles(domainsDir, /\.ts$/);
|
|
195
|
+
|
|
196
|
+
for (const file of files) {
|
|
197
|
+
const content = await readFile(file, 'utf8');
|
|
198
|
+
if (content.includes("from '@aws-sdk/") || content.includes('from "@aws-sdk/')) {
|
|
199
|
+
// Extract the line numbers
|
|
200
|
+
const lines = content.split('\n');
|
|
201
|
+
for (let i = 0; i < lines.length; i++) {
|
|
202
|
+
if (lines[i].includes('@aws-sdk/')) {
|
|
203
|
+
results.push(`${relative(projectRoot, file)}:${i + 1}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (results.length === 0) {
|
|
210
|
+
return {
|
|
211
|
+
name: 'No raw AWS SDK in domains',
|
|
212
|
+
status: 'PASS',
|
|
213
|
+
message: 'No direct @aws-sdk imports detected in domain code',
|
|
214
|
+
kNodeRef: 'K:convention:tier-1-foundations',
|
|
215
|
+
};
|
|
216
|
+
} else {
|
|
217
|
+
return {
|
|
218
|
+
name: 'No raw AWS SDK in domains',
|
|
219
|
+
status: 'FAIL',
|
|
220
|
+
message: `Found ${results.length} raw @aws-sdk imports:\n ${results.join('\n ')}`,
|
|
221
|
+
fixHint: 'Use domain-provided integrations or actions instead of raw AWS SDK',
|
|
222
|
+
kNodeRef: 'K:convention:tier-1-foundations',
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
} catch (err) {
|
|
226
|
+
return {
|
|
227
|
+
name: 'No raw AWS SDK in domains',
|
|
228
|
+
status: 'WARN',
|
|
229
|
+
message: `Could not check for raw AWS SDK: ${String(err)}`,
|
|
230
|
+
kNodeRef: 'K:convention:tier-1-foundations',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function checkNoCrossDomainImports(projectRoot: string): Promise<DoctorCheck> {
|
|
236
|
+
try {
|
|
237
|
+
const domainsDir = join(projectRoot, 'domains');
|
|
238
|
+
const crossDomainImports: string[] = [];
|
|
239
|
+
|
|
240
|
+
const entries = await readdir(domainsDir, { withFileTypes: true });
|
|
241
|
+
for (const entry of entries) {
|
|
242
|
+
if (!entry.isDirectory()) continue;
|
|
243
|
+
const domainId = entry.name;
|
|
244
|
+
const domainPath = join(domainsDir, domainId);
|
|
245
|
+
const files = findFiles(domainPath, /\.ts$/);
|
|
246
|
+
|
|
247
|
+
for (const file of files) {
|
|
248
|
+
const content = await readFile(file, 'utf8');
|
|
249
|
+
// Simple regex to find imports from 'domains/'
|
|
250
|
+
const importRegex = /from\s+['"]([^'"]*domains\/[^'"]*)['"]/g;
|
|
251
|
+
let match;
|
|
252
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
253
|
+
const importPath = match[1];
|
|
254
|
+
// Check if it's from a different domain
|
|
255
|
+
const domainMatch = importPath.match(/domains\/([a-z0-9-]+)/);
|
|
256
|
+
if (domainMatch && domainMatch[1] !== domainId) {
|
|
257
|
+
crossDomainImports.push(
|
|
258
|
+
`${relative(projectRoot, file)} imports from domain '${domainMatch[1]}'`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (crossDomainImports.length === 0) {
|
|
266
|
+
return {
|
|
267
|
+
name: 'No cross-domain imports',
|
|
268
|
+
status: 'PASS',
|
|
269
|
+
message: 'No cross-domain imports detected',
|
|
270
|
+
kNodeRef: 'K:convention:no-cross-domain-import',
|
|
271
|
+
};
|
|
272
|
+
} else {
|
|
273
|
+
return {
|
|
274
|
+
name: 'No cross-domain imports',
|
|
275
|
+
status: 'FAIL',
|
|
276
|
+
message: `Found ${crossDomainImports.length} cross-domain imports:\n ${crossDomainImports.join('\n ')}`,
|
|
277
|
+
fixHint: 'Use domain actions or events to communicate between domains',
|
|
278
|
+
kNodeRef: 'K:convention:no-cross-domain-import',
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
} catch (err) {
|
|
282
|
+
return {
|
|
283
|
+
name: 'No cross-domain imports',
|
|
284
|
+
status: 'WARN',
|
|
285
|
+
message: `Could not check cross-domain imports: ${String(err)}`,
|
|
286
|
+
kNodeRef: 'K:convention:no-cross-domain-import',
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function checkApisHaveVersions(projectRoot: string): Promise<DoctorCheck> {
|
|
292
|
+
try {
|
|
293
|
+
const domainsDir = join(projectRoot, 'domains');
|
|
294
|
+
const allApiFiles: string[] = [];
|
|
295
|
+
|
|
296
|
+
// Find all API files in all domains
|
|
297
|
+
const entries = await readdir(domainsDir, { withFileTypes: true });
|
|
298
|
+
for (const entry of entries) {
|
|
299
|
+
if (!entry.isDirectory()) continue;
|
|
300
|
+
const apiDir = join(domainsDir, entry.name, 'api');
|
|
301
|
+
const apiFiles = findFiles(apiDir, /\.ts$/);
|
|
302
|
+
allApiFiles.push(...apiFiles);
|
|
303
|
+
}
|
|
304
|
+
const apiFiles = allApiFiles;
|
|
305
|
+
|
|
306
|
+
if (apiFiles.length === 0) {
|
|
307
|
+
return {
|
|
308
|
+
name: 'APIs declare versions',
|
|
309
|
+
status: 'PASS',
|
|
310
|
+
message: 'No APIs found (optional)',
|
|
311
|
+
kNodeRef: 'K:runbook:add-domain',
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Simple heuristic: grep for 'versions:' in each API file
|
|
316
|
+
let missingVersions = 0;
|
|
317
|
+
for (const file of apiFiles) {
|
|
318
|
+
const content = await readFile(file, 'utf8');
|
|
319
|
+
if (!content.includes('versions:')) {
|
|
320
|
+
missingVersions++;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (missingVersions === 0) {
|
|
325
|
+
return {
|
|
326
|
+
name: 'APIs declare versions',
|
|
327
|
+
status: 'PASS',
|
|
328
|
+
message: `All ${apiFiles.length} API files declare versions`,
|
|
329
|
+
kNodeRef: 'K:runbook:add-domain',
|
|
330
|
+
};
|
|
331
|
+
} else {
|
|
332
|
+
return {
|
|
333
|
+
name: 'APIs declare versions',
|
|
334
|
+
status: 'FAIL',
|
|
335
|
+
message: `${missingVersions}/${apiFiles.length} API files missing versions: field`,
|
|
336
|
+
fixHint: 'Add versions: { v1: { ... } } to defineApi calls',
|
|
337
|
+
kNodeRef: 'K:runbook:add-domain',
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
} catch (err) {
|
|
341
|
+
return {
|
|
342
|
+
name: 'APIs declare versions',
|
|
343
|
+
status: 'WARN',
|
|
344
|
+
message: `Could not check API versions: ${String(err)}`,
|
|
345
|
+
kNodeRef: 'K:runbook:add-domain',
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function checkApisHaveTenancy(projectRoot: string): Promise<DoctorCheck> {
|
|
351
|
+
try {
|
|
352
|
+
const domainsDir = join(projectRoot, 'domains');
|
|
353
|
+
const allApiFiles: string[] = [];
|
|
354
|
+
|
|
355
|
+
// Find all API files in all domains
|
|
356
|
+
const entries = await readdir(domainsDir, { withFileTypes: true });
|
|
357
|
+
for (const entry of entries) {
|
|
358
|
+
if (!entry.isDirectory()) continue;
|
|
359
|
+
const apiDir = join(domainsDir, entry.name, 'api');
|
|
360
|
+
const apiFiles = findFiles(apiDir, /\.ts$/);
|
|
361
|
+
allApiFiles.push(...apiFiles);
|
|
362
|
+
}
|
|
363
|
+
const apiFiles = allApiFiles;
|
|
364
|
+
|
|
365
|
+
if (apiFiles.length === 0) {
|
|
366
|
+
return {
|
|
367
|
+
name: 'APIs declare tenancy',
|
|
368
|
+
status: 'PASS',
|
|
369
|
+
message: 'No APIs found (optional)',
|
|
370
|
+
kNodeRef: 'K:convention:tier-1-foundations',
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Simple heuristic: grep for 'tenancy:' in each API file
|
|
375
|
+
let missingTenancy = 0;
|
|
376
|
+
for (const file of apiFiles) {
|
|
377
|
+
const content = await readFile(file, 'utf8');
|
|
378
|
+
if (!content.includes('tenancy:')) {
|
|
379
|
+
missingTenancy++;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (missingTenancy === 0) {
|
|
384
|
+
return {
|
|
385
|
+
name: 'APIs declare tenancy',
|
|
386
|
+
status: 'PASS',
|
|
387
|
+
message: `All ${apiFiles.length} API files declare tenancy`,
|
|
388
|
+
kNodeRef: 'K:convention:tier-1-foundations',
|
|
389
|
+
};
|
|
390
|
+
} else {
|
|
391
|
+
return {
|
|
392
|
+
name: 'APIs declare tenancy',
|
|
393
|
+
status: 'FAIL',
|
|
394
|
+
message: `${missingTenancy}/${apiFiles.length} API files missing tenancy: field`,
|
|
395
|
+
fixHint: "Add tenancy: 'required' | 'none' | 'system' to defineApi calls",
|
|
396
|
+
kNodeRef: 'K:convention:tier-1-foundations',
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
} catch (err) {
|
|
400
|
+
return {
|
|
401
|
+
name: 'APIs declare tenancy',
|
|
402
|
+
status: 'WARN',
|
|
403
|
+
message: `Could not check API tenancy: ${String(err)}`,
|
|
404
|
+
kNodeRef: 'K:convention:tier-1-foundations',
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function checkScaffoldConfigMatchesDisk(projectRoot: string): Promise<DoctorCheck> {
|
|
410
|
+
try {
|
|
411
|
+
const configPath = join(projectRoot, '.tib', 'scaffold-config.json');
|
|
412
|
+
const domainsDir = join(projectRoot, 'domains');
|
|
413
|
+
|
|
414
|
+
let configDomains: string[] = [];
|
|
415
|
+
try {
|
|
416
|
+
const configContent = await readFile(configPath, 'utf8');
|
|
417
|
+
const config = JSON.parse(configContent) as { domainIds?: string[] };
|
|
418
|
+
configDomains = config.domainIds ?? [];
|
|
419
|
+
} catch {
|
|
420
|
+
return {
|
|
421
|
+
name: 'scaffold-config.json matches on-disk',
|
|
422
|
+
status: 'FAIL',
|
|
423
|
+
message: 'Could not read .mc/scaffold-config.json',
|
|
424
|
+
fixHint: 'Ensure .mc/scaffold-config.json exists and is valid JSON',
|
|
425
|
+
kNodeRef: 'K:runbook:add-domain',
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const diskDomains = new Set<string>();
|
|
430
|
+
try {
|
|
431
|
+
const entries = await readdir(domainsDir, { withFileTypes: true });
|
|
432
|
+
for (const entry of entries) {
|
|
433
|
+
if (entry.isDirectory()) {
|
|
434
|
+
diskDomains.add(entry.name);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
} catch {
|
|
438
|
+
// domains/ might not exist yet
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const inConfigButNotDisk = configDomains.filter(d => !diskDomains.has(d));
|
|
442
|
+
const onDiskButNotConfig = Array.from(diskDomains).filter(d => !configDomains.includes(d));
|
|
443
|
+
|
|
444
|
+
if (inConfigButNotDisk.length === 0 && onDiskButNotConfig.length === 0) {
|
|
445
|
+
return {
|
|
446
|
+
name: 'scaffold-config.json matches on-disk',
|
|
447
|
+
status: 'PASS',
|
|
448
|
+
message: `Configuration and disk are in sync (${configDomains.length} domains)`,
|
|
449
|
+
kNodeRef: 'K:runbook:add-domain',
|
|
450
|
+
};
|
|
451
|
+
} else {
|
|
452
|
+
const issues: string[] = [];
|
|
453
|
+
if (inConfigButNotDisk.length > 0) {
|
|
454
|
+
issues.push(`In config but not on disk: ${inConfigButNotDisk.join(', ')}`);
|
|
455
|
+
}
|
|
456
|
+
if (onDiskButNotConfig.length > 0) {
|
|
457
|
+
issues.push(`On disk but not in config: ${onDiskButNotConfig.join(', ')}`);
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
name: 'scaffold-config.json matches on-disk',
|
|
461
|
+
status: 'FAIL',
|
|
462
|
+
message: issues.join('; '),
|
|
463
|
+
fixHint: 'Run mc-domain-module add-domain to sync config',
|
|
464
|
+
kNodeRef: 'K:runbook:add-domain',
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
} catch (err) {
|
|
468
|
+
return {
|
|
469
|
+
name: 'scaffold-config.json matches on-disk',
|
|
470
|
+
status: 'WARN',
|
|
471
|
+
message: `Could not check scaffold config: ${String(err)}`,
|
|
472
|
+
kNodeRef: 'K:runbook:add-domain',
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function checkHuskyInstalled(projectRoot: string): Promise<DoctorCheck> {
|
|
478
|
+
try {
|
|
479
|
+
const huskyPath = join(projectRoot, '.husky', 'pre-commit');
|
|
480
|
+
await access(huskyPath);
|
|
481
|
+
const stats = await stat(huskyPath);
|
|
482
|
+
const isExecutable = (stats.mode & 0o111) !== 0;
|
|
483
|
+
|
|
484
|
+
if (isExecutable) {
|
|
485
|
+
return {
|
|
486
|
+
name: 'Husky hooks installed',
|
|
487
|
+
status: 'PASS',
|
|
488
|
+
message: '.husky/pre-commit exists and is executable',
|
|
489
|
+
};
|
|
490
|
+
} else {
|
|
491
|
+
return {
|
|
492
|
+
name: 'Husky hooks installed',
|
|
493
|
+
status: 'WARN',
|
|
494
|
+
message: '.husky/pre-commit exists but is not executable',
|
|
495
|
+
fixHint: 'Run chmod +x .husky/pre-commit',
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
} catch {
|
|
499
|
+
return {
|
|
500
|
+
name: 'Husky hooks installed',
|
|
501
|
+
status: 'FAIL',
|
|
502
|
+
message: '.husky/pre-commit not found or not accessible',
|
|
503
|
+
fixHint: 'Run npx husky install',
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function checkRootLevelFlows(projectRoot: string): Promise<DoctorCheck> {
|
|
509
|
+
try {
|
|
510
|
+
const flowsDir = join(projectRoot, 'flows');
|
|
511
|
+
let files: string[] = [];
|
|
512
|
+
try {
|
|
513
|
+
files = await readdir(flowsDir);
|
|
514
|
+
} catch (err) {
|
|
515
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
516
|
+
return {
|
|
517
|
+
name: 'No root-level flows (deprecated)',
|
|
518
|
+
status: 'PASS',
|
|
519
|
+
message: 'No root-level flows/ directory found',
|
|
520
|
+
kNodeRef: 'K:convention:flow-vs-subscriber-rule',
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
throw err;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const flowFiles = files.filter(f => f.endsWith('.ts') || f.endsWith('.json'));
|
|
527
|
+
if (flowFiles.length === 0) {
|
|
528
|
+
return {
|
|
529
|
+
name: 'No root-level flows (deprecated)',
|
|
530
|
+
status: 'PASS',
|
|
531
|
+
message: 'Root-level flows/ is empty — OK',
|
|
532
|
+
kNodeRef: 'K:convention:flow-vs-subscriber-rule',
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
return {
|
|
536
|
+
name: 'No root-level flows (deprecated)',
|
|
537
|
+
status: 'WARN',
|
|
538
|
+
message: `${flowFiles.length} flow(s) still in root-level flows/: ${flowFiles.join(', ')}`,
|
|
539
|
+
fixHint: 'Move these flows to domains/{owningDomain}/flows/ and delete the root-level copies.',
|
|
540
|
+
kNodeRef: 'K:convention:flow-vs-subscriber-rule',
|
|
541
|
+
};
|
|
542
|
+
} catch (err) {
|
|
543
|
+
return {
|
|
544
|
+
name: 'No root-level flows (deprecated)',
|
|
545
|
+
status: 'WARN',
|
|
546
|
+
message: `Could not check root-level flows/: ${String(err)}`,
|
|
547
|
+
kNodeRef: 'K:convention:flow-vs-subscriber-rule',
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ---- W2 new assertions ----
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Check 9: Every API has a fixture.
|
|
556
|
+
*/
|
|
557
|
+
async function checkApiFixtures(projectRoot: string): Promise<DoctorCheck> {
|
|
558
|
+
try {
|
|
559
|
+
const domainsDir = join(projectRoot, 'domains');
|
|
560
|
+
const apiWithoutFixture: string[] = [];
|
|
561
|
+
|
|
562
|
+
const entries = await readdir(domainsDir, { withFileTypes: true });
|
|
563
|
+
for (const entry of entries) {
|
|
564
|
+
if (!entry.isDirectory()) continue;
|
|
565
|
+
const domainId = entry.name;
|
|
566
|
+
const apiDir = join(domainsDir, domainId, 'api');
|
|
567
|
+
const testDir = join(domainsDir, domainId, 'api', '__tests__');
|
|
568
|
+
|
|
569
|
+
try { await access(apiDir); } catch { continue; }
|
|
570
|
+
|
|
571
|
+
const apiFiles = await readdir(apiDir);
|
|
572
|
+
for (const file of apiFiles) {
|
|
573
|
+
if (!file.endsWith('.ts')) continue;
|
|
574
|
+
const baseName = file.replace(/\.ts$/, '');
|
|
575
|
+
|
|
576
|
+
if (!existsSync(join(testDir, `${baseName}.fixture.json`)) &&
|
|
577
|
+
!existsSync(join(testDir, `${baseName}.test.ts`))) {
|
|
578
|
+
apiWithoutFixture.push(`${domainId}/api/${file}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (apiWithoutFixture.length === 0) {
|
|
584
|
+
return { name: 'Every API has a fixture', status: 'PASS',
|
|
585
|
+
message: 'All API handlers have corresponding fixture or test files',
|
|
586
|
+
kNodeRef: 'K:exit-gate:domain-complete' };
|
|
587
|
+
} else {
|
|
588
|
+
return { name: 'Every API has a fixture', status: 'FAIL',
|
|
589
|
+
message: `${apiWithoutFixture.length} API(s) missing fixture/test:\n ${apiWithoutFixture.join('\n ')}`,
|
|
590
|
+
fixHint: 'Create __tests__/{id}.fixture.json or __tests__/{id}.test.ts for each API',
|
|
591
|
+
kNodeRef: 'K:exit-gate:domain-complete' };
|
|
592
|
+
}
|
|
593
|
+
} catch (err) {
|
|
594
|
+
return { name: 'Every API has a fixture', status: 'WARN',
|
|
595
|
+
message: `Could not check API fixtures: ${String(err)}`,
|
|
596
|
+
kNodeRef: 'K:exit-gate:domain-complete' };
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Check 10: Every event has consumer or @no-consumers annotation.
|
|
602
|
+
*/
|
|
603
|
+
async function checkEventConsumers(projectRoot: string): Promise<DoctorCheck> {
|
|
604
|
+
try {
|
|
605
|
+
const domainsDir = join(projectRoot, 'domains');
|
|
606
|
+
const entries = await readdir(domainsDir, { withFileTypes: true });
|
|
607
|
+
|
|
608
|
+
const eventMap = new Map<string, { domain: string; file: string }>();
|
|
609
|
+
const subscriberEvents = new Set<string>();
|
|
610
|
+
|
|
611
|
+
for (const entry of entries) {
|
|
612
|
+
if (!entry.isDirectory()) continue;
|
|
613
|
+
const domainId = entry.name;
|
|
614
|
+
const domainPath = join(domainsDir, domainId);
|
|
615
|
+
|
|
616
|
+
const publishesDir = join(domainPath, 'publishes');
|
|
617
|
+
try {
|
|
618
|
+
for (const file of await readdir(publishesDir)) {
|
|
619
|
+
if (!file.endsWith('.ts')) continue;
|
|
620
|
+
const filePath = join(publishesDir, file);
|
|
621
|
+
const content = await readFile(filePath, 'utf8');
|
|
622
|
+
const re = /defineEvent\(\{[^}]*id:\s*['"]([^'"]+)['"]/gs;
|
|
623
|
+
let m;
|
|
624
|
+
while ((m = re.exec(content)) !== null) {
|
|
625
|
+
eventMap.set(m[1], { domain: domainId, file: filePath });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
} catch { /* no publishes */ }
|
|
629
|
+
|
|
630
|
+
const subscribersDir = join(domainPath, 'subscribers');
|
|
631
|
+
try {
|
|
632
|
+
for (const file of await readdir(subscribersDir)) {
|
|
633
|
+
if (!file.endsWith('.ts')) continue;
|
|
634
|
+
const content = await readFile(join(subscribersDir, file), 'utf8');
|
|
635
|
+
const re = /defineSubscriber\(\{[^}]*event:\s*['"]([^'"]+)['"]/gs;
|
|
636
|
+
let m;
|
|
637
|
+
while ((m = re.exec(content)) !== null) {
|
|
638
|
+
subscriberEvents.add(m[1]);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
} catch { /* no subscribers */ }
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const orphanedEvents: string[] = [];
|
|
645
|
+
for (const [eventId, info] of eventMap) {
|
|
646
|
+
if (subscriberEvents.has(eventId)) continue;
|
|
647
|
+
try {
|
|
648
|
+
const fc = await readFile(info.file, 'utf8');
|
|
649
|
+
if (fc.includes('@no-consumers')) continue;
|
|
650
|
+
} catch { /* skip */ }
|
|
651
|
+
orphanedEvents.push(`${info.domain}/publishes → ${eventId}`);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (orphanedEvents.length === 0) {
|
|
655
|
+
return { name: 'Every event has consumer or annotation', status: 'PASS',
|
|
656
|
+
message: `All ${eventMap.size} event(s) have consumers or @no-consumers annotations`,
|
|
657
|
+
kNodeRef: 'K:convention:flow-vs-subscriber-rule' };
|
|
658
|
+
} else {
|
|
659
|
+
return { name: 'Every event has consumer or annotation', status: 'FAIL',
|
|
660
|
+
message: `${orphanedEvents.length} event(s) without consumer or @no-consumers:\n ${orphanedEvents.join('\n ')}`,
|
|
661
|
+
fixHint: 'Add a subscriber or // @no-consumers: <reason> annotation',
|
|
662
|
+
kNodeRef: 'K:convention:flow-vs-subscriber-rule' };
|
|
663
|
+
}
|
|
664
|
+
} catch (err) {
|
|
665
|
+
return { name: 'Every event has consumer or annotation', status: 'WARN',
|
|
666
|
+
message: `Could not check event consumers: ${String(err)}`,
|
|
667
|
+
kNodeRef: 'K:convention:flow-vs-subscriber-rule' };
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Check 11: Migrations declare tenancy trio (tenant_id, workspace_id, org_id).
|
|
673
|
+
*/
|
|
674
|
+
async function checkMigrationTenancyTrio(projectRoot: string): Promise<DoctorCheck> {
|
|
675
|
+
try {
|
|
676
|
+
const migrationsDir = join(projectRoot, 'db', 'migrations');
|
|
677
|
+
const domainsDir = join(projectRoot, 'domains');
|
|
678
|
+
|
|
679
|
+
let domainIds: string[] = [];
|
|
680
|
+
try {
|
|
681
|
+
domainIds = (await readdir(domainsDir, { withFileTypes: true }))
|
|
682
|
+
.filter(e => e.isDirectory()).map(e => e.name);
|
|
683
|
+
} catch { domainIds = []; }
|
|
684
|
+
|
|
685
|
+
let migrationFiles: string[] = [];
|
|
686
|
+
try { migrationFiles = await readdir(migrationsDir); } catch {
|
|
687
|
+
return { name: 'Migrations declare tenancy trio', status: 'PASS',
|
|
688
|
+
message: 'No db/migrations/ directory found (optional)',
|
|
689
|
+
kNodeRef: 'K:convention:tenancy-trio' };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const domainMigrations: { file: string; domain: string }[] = [];
|
|
693
|
+
for (const file of migrationFiles) {
|
|
694
|
+
for (const domainId of domainIds) {
|
|
695
|
+
if (file.includes(domainId)) { domainMigrations.push({ file, domain: domainId }); break; }
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (domainMigrations.length === 0) {
|
|
700
|
+
return { name: 'Migrations declare tenancy trio', status: 'PASS',
|
|
701
|
+
message: 'No domain-specific migration files found (optional)',
|
|
702
|
+
kNodeRef: 'K:convention:tenancy-trio' };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const missingTrio: string[] = [];
|
|
706
|
+
for (const { file, domain } of domainMigrations) {
|
|
707
|
+
const content = (await readFile(join(migrationsDir, file), 'utf8')).toLowerCase();
|
|
708
|
+
const missing: string[] = [];
|
|
709
|
+
if (!content.includes('tenant_id')) missing.push('tenant_id');
|
|
710
|
+
if (!content.includes('workspace_id')) missing.push('workspace_id');
|
|
711
|
+
if (!content.includes('org_id')) missing.push('org_id');
|
|
712
|
+
if (missing.length > 0) missingTrio.push(`${file} (domain: ${domain}) missing: ${missing.join(', ')}`);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (missingTrio.length === 0) {
|
|
716
|
+
return { name: 'Migrations declare tenancy trio', status: 'PASS',
|
|
717
|
+
message: `All ${domainMigrations.length} domain migration(s) declare tenant_id, workspace_id, and org_id`,
|
|
718
|
+
kNodeRef: 'K:convention:tenancy-trio' };
|
|
719
|
+
} else {
|
|
720
|
+
return { name: 'Migrations declare tenancy trio', status: 'FAIL',
|
|
721
|
+
message: `${missingTrio.length}/${domainMigrations.length} migration(s) missing tenancy trio:\n ${missingTrio.join('\n ')}`,
|
|
722
|
+
fixHint: 'Add tenant_id, workspace_id, and org_id columns to migration files',
|
|
723
|
+
kNodeRef: 'K:convention:tenancy-trio' };
|
|
724
|
+
}
|
|
725
|
+
} catch (err) {
|
|
726
|
+
return { name: 'Migrations declare tenancy trio', status: 'WARN',
|
|
727
|
+
message: `Could not check migration tenancy trio: ${String(err)}`,
|
|
728
|
+
kNodeRef: 'K:convention:tenancy-trio' };
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Check 12: Domain has RLS test coverage.
|
|
734
|
+
*/
|
|
735
|
+
async function checkDomainRlsTest(projectRoot: string): Promise<DoctorCheck> {
|
|
736
|
+
try {
|
|
737
|
+
const domainsDir = join(projectRoot, 'domains');
|
|
738
|
+
const entries = await readdir(domainsDir, { withFileTypes: true });
|
|
739
|
+
const domainsMissingRls: string[] = [];
|
|
740
|
+
|
|
741
|
+
for (const entry of entries) {
|
|
742
|
+
if (!entry.isDirectory()) continue;
|
|
743
|
+
const domainId = entry.name;
|
|
744
|
+
const domainPath = join(domainsDir, domainId);
|
|
745
|
+
const allFiles = findFiles(domainPath, /\.(ts|json)$/);
|
|
746
|
+
let hasRlsRef = false;
|
|
747
|
+
|
|
748
|
+
for (const file of allFiles) {
|
|
749
|
+
try {
|
|
750
|
+
const lc = (await readFile(file, 'utf8')).toLowerCase();
|
|
751
|
+
if (lc.includes('rls') || lc.includes('row level security') ||
|
|
752
|
+
lc.includes('row-level-security') || lc.includes('rls-test')) {
|
|
753
|
+
hasRlsRef = true; break;
|
|
754
|
+
}
|
|
755
|
+
} catch { /* skip */ }
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (!hasRlsRef) domainsMissingRls.push(domainId);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (domainsMissingRls.length === 0) {
|
|
762
|
+
return { name: 'Domain has RLS test', status: 'PASS',
|
|
763
|
+
message: 'All domains have RLS test coverage',
|
|
764
|
+
kNodeRef: 'K:convention:rls-per-domain-role' };
|
|
765
|
+
} else {
|
|
766
|
+
return { name: 'Domain has RLS test', status: 'FAIL',
|
|
767
|
+
message: `${domainsMissingRls.length} domain(s) missing RLS test: ${domainsMissingRls.join(', ')}`,
|
|
768
|
+
fixHint: 'Add RLS test assertions or fixtures to each domain',
|
|
769
|
+
kNodeRef: 'K:convention:rls-per-domain-role' };
|
|
770
|
+
}
|
|
771
|
+
} catch (err) {
|
|
772
|
+
return { name: 'Domain has RLS test', status: 'WARN',
|
|
773
|
+
message: `Could not check RLS test: ${String(err)}`,
|
|
774
|
+
kNodeRef: 'K:convention:rls-per-domain-role' };
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Check 13: Domain reachable in brain.
|
|
780
|
+
*/
|
|
781
|
+
async function checkDomainBrainReachable(projectRoot: string): Promise<DoctorCheck> {
|
|
782
|
+
try {
|
|
783
|
+
const domainsDir = join(projectRoot, 'domains');
|
|
784
|
+
let domainIds: string[] = [];
|
|
785
|
+
try {
|
|
786
|
+
domainIds = (await readdir(domainsDir, { withFileTypes: true }))
|
|
787
|
+
.filter(e => e.isDirectory()).map(e => e.name);
|
|
788
|
+
} catch {
|
|
789
|
+
return { name: 'Domain reachable in brain', status: 'PASS',
|
|
790
|
+
message: 'No domains found (optional)', kNodeRef: 'K:runbook:add-domain' };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const brainEndpoint = process.env.TIB_BRAIN_ENDPOINT ?? 'http://localhost:3001';
|
|
794
|
+
let brainReachable = false;
|
|
795
|
+
|
|
796
|
+
try {
|
|
797
|
+
const controller = new AbortController();
|
|
798
|
+
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
|
799
|
+
const response = await fetch(`${brainEndpoint}/api/graph/nodes`, {
|
|
800
|
+
method: 'GET', signal: controller.signal,
|
|
801
|
+
headers: { 'Content-Type': 'application/json' },
|
|
802
|
+
});
|
|
803
|
+
clearTimeout(timeoutId);
|
|
804
|
+
|
|
805
|
+
if (response.ok) {
|
|
806
|
+
brainReachable = true;
|
|
807
|
+
const data = await response.json() as { nodes?: Array<{ id?: string }> };
|
|
808
|
+
const nodeIds = new Set((data.nodes ?? []).map(n => n.id).filter(Boolean));
|
|
809
|
+
const missingNodes = domainIds.filter(d => !nodeIds.has(d));
|
|
810
|
+
if (missingNodes.length > 0) {
|
|
811
|
+
return { name: 'Domain reachable in brain', status: 'WARN',
|
|
812
|
+
message: `Brain reachable but domains not found: ${missingNodes.join(', ')}`,
|
|
813
|
+
fixHint: 'Ensure domain is registered in the TIB knowledge hub',
|
|
814
|
+
kNodeRef: 'K:runbook:add-domain' };
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
} catch { /* brain unavailable */ }
|
|
818
|
+
|
|
819
|
+
if (!brainReachable) {
|
|
820
|
+
return { name: 'Domain reachable in brain', status: 'WARN',
|
|
821
|
+
message: `Brain unavailable at ${brainEndpoint} — skipping check`,
|
|
822
|
+
fixHint: 'Start the TIB brain service or set TIB_BRAIN_ENDPOINT',
|
|
823
|
+
kNodeRef: 'K:runbook:add-domain' };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return { name: 'Domain reachable in brain', status: 'PASS',
|
|
827
|
+
message: `All ${domainIds.length} domain(s) found in brain graph`,
|
|
828
|
+
kNodeRef: 'K:runbook:add-domain' };
|
|
829
|
+
} catch (err) {
|
|
830
|
+
return { name: 'Domain reachable in brain', status: 'WARN',
|
|
831
|
+
message: `Could not check brain reachability: ${String(err)}`,
|
|
832
|
+
kNodeRef: 'K:runbook:add-domain' };
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Check 14: Actions have types in .mc/actions-types.d.ts.
|
|
838
|
+
*/
|
|
839
|
+
async function checkActionsHaveTypes(projectRoot: string): Promise<DoctorCheck> {
|
|
840
|
+
try {
|
|
841
|
+
const typesPath = join(projectRoot, '.tib', 'actions-types.d.ts');
|
|
842
|
+
const domainsDir = join(projectRoot, 'domains');
|
|
843
|
+
const discoveredActions: string[] = [];
|
|
844
|
+
|
|
845
|
+
try {
|
|
846
|
+
for (const entry of await readdir(domainsDir, { withFileTypes: true })) {
|
|
847
|
+
if (!entry.isDirectory()) continue;
|
|
848
|
+
const actionDir = join(domainsDir, entry.name, 'actions');
|
|
849
|
+
try {
|
|
850
|
+
for (const file of await readdir(actionDir)) {
|
|
851
|
+
if (!file.endsWith('.ts')) continue;
|
|
852
|
+
const content = await readFile(join(actionDir, file), 'utf8');
|
|
853
|
+
const re = /defineAction\(\{[^}]*id:\s*['"]([^'"]+)['"]/gs;
|
|
854
|
+
let m;
|
|
855
|
+
while ((m = re.exec(content)) !== null) discoveredActions.push(m[1]);
|
|
856
|
+
}
|
|
857
|
+
} catch { /* no actions */ }
|
|
858
|
+
}
|
|
859
|
+
} catch { /* no domains */ }
|
|
860
|
+
|
|
861
|
+
if (discoveredActions.length === 0) {
|
|
862
|
+
return { name: 'Actions have types', status: 'PASS',
|
|
863
|
+
message: 'No actions found (optional)', kNodeRef: 'K:exit-gate:domain-complete' };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
let typesContent = '';
|
|
867
|
+
try { typesContent = await readFile(typesPath, 'utf8'); } catch {
|
|
868
|
+
return { name: 'Actions have types', status: 'FAIL',
|
|
869
|
+
message: `.mc/actions-types.d.ts not found — ${discoveredActions.length} action(s) lack type declarations`,
|
|
870
|
+
fixHint: 'Run mc-domain-module build to generate actions-types.d.ts',
|
|
871
|
+
kNodeRef: 'K:exit-gate:domain-complete' };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const missingTypes = discoveredActions.filter(a => !typesContent.includes(a));
|
|
875
|
+
if (missingTypes.length === 0) {
|
|
876
|
+
return { name: 'Actions have types', status: 'PASS',
|
|
877
|
+
message: `All ${discoveredActions.length} action(s) have type declarations`,
|
|
878
|
+
kNodeRef: 'K:exit-gate:domain-complete' };
|
|
879
|
+
} else {
|
|
880
|
+
return { name: 'Actions have types', status: 'FAIL',
|
|
881
|
+
message: `${missingTypes.length}/${discoveredActions.length} action(s) missing from .mc/actions-types.d.ts:\n ${missingTypes.join('\n ')}`,
|
|
882
|
+
fixHint: 'Run mc-domain-module build to regenerate actions-types.d.ts',
|
|
883
|
+
kNodeRef: 'K:exit-gate:domain-complete' };
|
|
884
|
+
}
|
|
885
|
+
} catch (err) {
|
|
886
|
+
return { name: 'Actions have types', status: 'WARN',
|
|
887
|
+
message: `Could not check action types: ${String(err)}`,
|
|
888
|
+
kNodeRef: 'K:exit-gate:domain-complete' };
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Check 15: W1 conventions have evidence in codebase.
|
|
894
|
+
*/
|
|
895
|
+
async function checkConventionEvidence(projectRoot: string): Promise<DoctorCheck> {
|
|
896
|
+
try {
|
|
897
|
+
const w1KNodeIds = [
|
|
898
|
+
'K:exit-gate:domain-complete', 'K:convention:tenancy-trio',
|
|
899
|
+
'K:convention:no-cross-domain-import', 'K:convention:rls-per-domain-role',
|
|
900
|
+
'K:convention:tier-1-foundations', 'K:convention:flow-vs-subscriber-rule',
|
|
901
|
+
];
|
|
902
|
+
|
|
903
|
+
const domainsDir = join(projectRoot, 'domains');
|
|
904
|
+
let entries: { name: string; isDirectory: () => boolean }[] = [];
|
|
905
|
+
try { entries = await readdir(domainsDir, { withFileTypes: true }); } catch {
|
|
906
|
+
return { name: 'Conventions have evidence', status: 'PASS',
|
|
907
|
+
message: 'No domains found (optional)', kNodeRef: 'K:exit-gate:domain-complete' };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const missingEvidence: string[] = [];
|
|
911
|
+
for (const kNodeId of w1KNodeIds) {
|
|
912
|
+
let found = false;
|
|
913
|
+
for (const entry of entries) {
|
|
914
|
+
if (!entry.isDirectory()) continue;
|
|
915
|
+
const files = findFiles(join(domainsDir, entry.name), /\.(ts|json|md)$/);
|
|
916
|
+
for (const file of files) {
|
|
917
|
+
try {
|
|
918
|
+
const c = await readFile(file, 'utf8');
|
|
919
|
+
if (c.includes(kNodeId) || c.includes(kNodeId.split(':').pop() ?? '')) {
|
|
920
|
+
found = true; break;
|
|
921
|
+
}
|
|
922
|
+
} catch { /* skip */ }
|
|
923
|
+
}
|
|
924
|
+
if (found) break;
|
|
925
|
+
}
|
|
926
|
+
if (!found) {
|
|
927
|
+
for (const rf of [join(projectRoot, 'CLAUDE.md'), join(projectRoot, 'README.md')]) {
|
|
928
|
+
try { if ((await readFile(rf, 'utf8')).includes(kNodeId)) { found = true; break; } } catch { /* skip */ }
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if (!found) missingEvidence.push(kNodeId);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (missingEvidence.length === 0) {
|
|
935
|
+
return { name: 'Conventions have evidence', status: 'PASS',
|
|
936
|
+
message: `All ${w1KNodeIds.length} W1 conventions have evidence in the codebase`,
|
|
937
|
+
kNodeRef: 'K:exit-gate:domain-complete' };
|
|
938
|
+
} else if (missingEvidence.length <= 2) {
|
|
939
|
+
return { name: 'Conventions have evidence', status: 'WARN',
|
|
940
|
+
message: `${missingEvidence.length}/${w1KNodeIds.length} convention(s) lack references: ${missingEvidence.join(', ')}`,
|
|
941
|
+
fixHint: 'Add K-node references in domain files or root CLAUDE.md',
|
|
942
|
+
kNodeRef: 'K:exit-gate:domain-complete' };
|
|
943
|
+
} else {
|
|
944
|
+
return { name: 'Conventions have evidence', status: 'FAIL',
|
|
945
|
+
message: `${missingEvidence.length}/${w1KNodeIds.length} convention(s) lack references: ${missingEvidence.join(', ')}`,
|
|
946
|
+
fixHint: 'Add K-node references in domain files or root CLAUDE.md',
|
|
947
|
+
kNodeRef: 'K:exit-gate:domain-complete' };
|
|
948
|
+
}
|
|
949
|
+
} catch (err) {
|
|
950
|
+
return { name: 'Conventions have evidence', status: 'WARN',
|
|
951
|
+
message: `Could not check convention evidence: ${String(err)}`,
|
|
952
|
+
kNodeRef: 'K:exit-gate:domain-complete' };
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Check W1-A: Scaffold-tracked files in scaffold-src/modules/frontend/ have @mc-scaffold: headers.
|
|
958
|
+
* Scans components/ui/, components/shell/ (non-page), lib/ for files missing the header.
|
|
959
|
+
* Status: WARN per missing file.
|
|
960
|
+
*/
|
|
961
|
+
async function checkScaffoldTrackedFilesHaveHeaders(projectRoot: string): Promise<DoctorCheck[]> {
|
|
962
|
+
const scaffoldFrontendSrc = join(
|
|
963
|
+
projectRoot,
|
|
964
|
+
'scaffold-src', 'modules', 'frontend', 'frontend', 'src'
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
const SCAFFOLD_TRACKED_DIRS = [
|
|
968
|
+
join(scaffoldFrontendSrc, 'components', 'ui'),
|
|
969
|
+
join(scaffoldFrontendSrc, 'components', 'shell'),
|
|
970
|
+
join(scaffoldFrontendSrc, 'lib'),
|
|
971
|
+
];
|
|
972
|
+
|
|
973
|
+
// Seed page paths that live under components/shell but are NOT scaffold-tracked
|
|
974
|
+
const SEED_PAGE_PATTERN = /\/pages\//;
|
|
975
|
+
|
|
976
|
+
const missing: string[] = [];
|
|
977
|
+
|
|
978
|
+
for (const dir of SCAFFOLD_TRACKED_DIRS) {
|
|
979
|
+
const tsFiles = findFiles(dir, /\.(tsx?|css)$/);
|
|
980
|
+
for (const file of tsFiles) {
|
|
981
|
+
// Skip seed pages if somehow nested here
|
|
982
|
+
if (SEED_PAGE_PATTERN.test(file)) continue;
|
|
983
|
+
try {
|
|
984
|
+
const content = await readFile(file, 'utf8');
|
|
985
|
+
if (!content.includes('@mc-scaffold:')) {
|
|
986
|
+
missing.push(relative(projectRoot, file));
|
|
987
|
+
}
|
|
988
|
+
} catch { /* skip unreadable */ }
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (missing.length === 0) {
|
|
993
|
+
return [{
|
|
994
|
+
name: 'Scaffold-tracked files have @mc-scaffold: headers',
|
|
995
|
+
status: 'PASS',
|
|
996
|
+
message: 'All scaffold-tracked frontend files have @mc-scaffold: headers',
|
|
997
|
+
kNodeRef: 'K:convention:scaffold-tracked-files-boundary',
|
|
998
|
+
}];
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
return missing.map(f => ({
|
|
1002
|
+
name: 'Scaffold-tracked files have @mc-scaffold: headers',
|
|
1003
|
+
status: 'WARN' as const,
|
|
1004
|
+
message: `scaffold-tracked file missing @mc-scaffold: header: ${f}`,
|
|
1005
|
+
fixHint: 'Add `@mc-scaffold: frontend@1.0.0` header to this file.',
|
|
1006
|
+
kNodeRef: 'K:convention:scaffold-tracked-files-boundary',
|
|
1007
|
+
file: f,
|
|
1008
|
+
}));
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Check W1-B: Seed pages in scaffold-src/modules/frontend/frontend/src/pages/ must NOT have @mc-scaffold: headers.
|
|
1013
|
+
* Status: FAIL per offending file.
|
|
1014
|
+
*/
|
|
1015
|
+
async function checkSeedPagesHaveNoHeaders(projectRoot: string): Promise<DoctorCheck[]> {
|
|
1016
|
+
const pagesDir = join(
|
|
1017
|
+
projectRoot,
|
|
1018
|
+
'scaffold-src', 'modules', 'frontend', 'frontend', 'src', 'pages'
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
// These barrel index files ARE scaffold-managed (not seed pages)
|
|
1022
|
+
const SCAFFOLD_BARREL_PATTERN = /\/index\.(ts|tsx)$/;
|
|
1023
|
+
|
|
1024
|
+
const violations: string[] = [];
|
|
1025
|
+
|
|
1026
|
+
let allPageFiles: string[] = [];
|
|
1027
|
+
try {
|
|
1028
|
+
allPageFiles = findFiles(pagesDir, /\.(tsx?|css)$/);
|
|
1029
|
+
} catch {
|
|
1030
|
+
// pages dir may not exist in this project root
|
|
1031
|
+
return [{
|
|
1032
|
+
name: 'Seed pages have no @mc-scaffold: headers',
|
|
1033
|
+
status: 'PASS',
|
|
1034
|
+
message: 'No scaffold-src/modules/frontend pages directory found — skipping',
|
|
1035
|
+
kNodeRef: 'K:convention:scaffold-tracked-files-boundary',
|
|
1036
|
+
}];
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
for (const file of allPageFiles) {
|
|
1040
|
+
// Skip barrel index files (they are scaffold-tracked)
|
|
1041
|
+
if (SCAFFOLD_BARREL_PATTERN.test(file)) continue;
|
|
1042
|
+
try {
|
|
1043
|
+
const content = await readFile(file, 'utf8');
|
|
1044
|
+
if (content.includes('@mc-scaffold:')) {
|
|
1045
|
+
violations.push(relative(projectRoot, file));
|
|
1046
|
+
}
|
|
1047
|
+
} catch { /* skip unreadable */ }
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
if (violations.length === 0) {
|
|
1051
|
+
return [{
|
|
1052
|
+
name: 'Seed pages have no @mc-scaffold: headers',
|
|
1053
|
+
status: 'PASS',
|
|
1054
|
+
message: 'No seed page files have @mc-scaffold: headers',
|
|
1055
|
+
kNodeRef: 'K:convention:scaffold-tracked-files-boundary',
|
|
1056
|
+
}];
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
return violations.map(f => ({
|
|
1060
|
+
name: 'Seed pages have no @mc-scaffold: headers',
|
|
1061
|
+
status: 'FAIL' as const,
|
|
1062
|
+
message: `Seed page has @mc-scaffold: header (user-owned file): ${f}`,
|
|
1063
|
+
fixHint: 'Remove the @mc-scaffold: header from this seed page — it is user-owned.',
|
|
1064
|
+
kNodeRef: 'K:convention:scaffold-tracked-files-boundary',
|
|
1065
|
+
file: f,
|
|
1066
|
+
}));
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Check Part C: Layout validation checks
|
|
1071
|
+
* 1. owned files in unexpected locations
|
|
1072
|
+
* 2. tracked/seed files inside .mc/
|
|
1073
|
+
* 3. manifest entries pointing at missing files
|
|
1074
|
+
* 4. scaffold-tracked files (with @mc-scaffold: header) that lack manifest entries
|
|
1075
|
+
*/
|
|
1076
|
+
async function checkLayoutPolicy(projectRoot: string): Promise<DoctorCheck[]> {
|
|
1077
|
+
const results: DoctorCheck[] = [];
|
|
1078
|
+
|
|
1079
|
+
// Read manifest
|
|
1080
|
+
const { readManifest } = await import('../utils/manifest.js');
|
|
1081
|
+
const manifest = await readManifest(projectRoot);
|
|
1082
|
+
|
|
1083
|
+
if (!manifest) {
|
|
1084
|
+
// No manifest — skip layout checks
|
|
1085
|
+
return [];
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Check 1: owned files in unexpected locations
|
|
1089
|
+
// Policy: owned files should be in .mc/ OR .github/workflows/tib-*
|
|
1090
|
+
const ownedOutsideTib = manifest.files.filter(f =>
|
|
1091
|
+
f.policy === 'owned' &&
|
|
1092
|
+
!f.path.startsWith('.mc/') &&
|
|
1093
|
+
!/^\.github\/workflows\/tib-/.test(f.path)
|
|
1094
|
+
);
|
|
1095
|
+
if (ownedOutsideTib.length === 0) {
|
|
1096
|
+
results.push({
|
|
1097
|
+
name: 'Layout: owned files in .mc/',
|
|
1098
|
+
status: 'PASS',
|
|
1099
|
+
message: 'All owned scaffold files are under .mc/ or .github/workflows/tib-*',
|
|
1100
|
+
});
|
|
1101
|
+
} else {
|
|
1102
|
+
for (const f of ownedOutsideTib) {
|
|
1103
|
+
results.push({
|
|
1104
|
+
name: 'Layout: owned files in .mc/',
|
|
1105
|
+
status: 'FAIL',
|
|
1106
|
+
message: `Owned scaffold file outside .mc/: ${f.path}`,
|
|
1107
|
+
fixHint: `Run 'tib doctor --relocate' to migrate ${f.path} to .mc/`,
|
|
1108
|
+
file: f.path,
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Check 2: tracked/seed files inside .mc/
|
|
1114
|
+
const nonOwnedInsideTib = manifest.files.filter(f =>
|
|
1115
|
+
(f.policy === 'tracked' || f.policy === 'seed') &&
|
|
1116
|
+
f.path.startsWith('.mc/')
|
|
1117
|
+
);
|
|
1118
|
+
if (nonOwnedInsideTib.length === 0) {
|
|
1119
|
+
results.push({
|
|
1120
|
+
name: 'Layout: no user files inside .mc/',
|
|
1121
|
+
status: 'PASS',
|
|
1122
|
+
message: 'No tracked/seed files incorrectly placed inside .mc/',
|
|
1123
|
+
});
|
|
1124
|
+
} else {
|
|
1125
|
+
for (const f of nonOwnedInsideTib) {
|
|
1126
|
+
results.push({
|
|
1127
|
+
name: 'Layout: no user files inside .mc/',
|
|
1128
|
+
status: 'FAIL',
|
|
1129
|
+
message: `${f.policy} file inside .mc/ (should be user-owned, outside .mc/): ${f.path}`,
|
|
1130
|
+
file: f.path,
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Check 3: manifest entries pointing at missing files
|
|
1136
|
+
const { access: fsAccess } = await import('node:fs/promises');
|
|
1137
|
+
const { join: pathJoin } = await import('node:path');
|
|
1138
|
+
const missingFiles: string[] = [];
|
|
1139
|
+
for (const f of manifest.files) {
|
|
1140
|
+
try {
|
|
1141
|
+
await fsAccess(pathJoin(projectRoot, f.path));
|
|
1142
|
+
} catch {
|
|
1143
|
+
missingFiles.push(f.path);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
if (missingFiles.length === 0) {
|
|
1147
|
+
results.push({
|
|
1148
|
+
name: 'Layout: manifest entries exist on disk',
|
|
1149
|
+
status: 'PASS',
|
|
1150
|
+
message: `All ${manifest.files.length} manifest entries exist on disk`,
|
|
1151
|
+
});
|
|
1152
|
+
} else {
|
|
1153
|
+
results.push({
|
|
1154
|
+
name: 'Layout: manifest entries exist on disk',
|
|
1155
|
+
status: 'FAIL',
|
|
1156
|
+
message: `${missingFiles.length} manifest entry/entries point to missing files:\n ${missingFiles.join('\n ')}`,
|
|
1157
|
+
fixHint: 'Run tib doctor --relocate or tib upgrade to fix missing files',
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// Check 4: scaffold-tracked files (with @mc-scaffold: header) that lack manifest entries
|
|
1162
|
+
const { readFile: fsReadFile } = await import('node:fs/promises');
|
|
1163
|
+
const manifestPaths = new Set(manifest.files.map(f => f.path));
|
|
1164
|
+
const untracked: string[] = [];
|
|
1165
|
+
|
|
1166
|
+
const walkDir = async (d: string): Promise<void> => {
|
|
1167
|
+
const { readdir } = await import('node:fs/promises');
|
|
1168
|
+
let entries;
|
|
1169
|
+
try { entries = await readdir(d, { withFileTypes: true }); } catch { return; }
|
|
1170
|
+
for (const entry of entries) {
|
|
1171
|
+
const full = pathJoin(d, entry.name);
|
|
1172
|
+
if (entry.isDirectory()) { await walkDir(full); continue; }
|
|
1173
|
+
if (!/\.(ts|tsx|js|yml|yaml)$/.test(entry.name)) continue;
|
|
1174
|
+
const rel = full.replace(projectRoot + '/', '');
|
|
1175
|
+
if (manifestPaths.has(rel)) continue;
|
|
1176
|
+
try {
|
|
1177
|
+
const content = await fsReadFile(full, 'utf-8');
|
|
1178
|
+
if (content.includes('@mc-scaffold:')) untracked.push(rel);
|
|
1179
|
+
} catch { /* skip */ }
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
|
|
1183
|
+
const dirsToScan = ['domains', '.github/workflows'];
|
|
1184
|
+
for (const dir of dirsToScan) {
|
|
1185
|
+
const dirPath = pathJoin(projectRoot, dir);
|
|
1186
|
+
try {
|
|
1187
|
+
await walkDir(dirPath);
|
|
1188
|
+
} catch { /* dir missing */ }
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
if (untracked.length === 0) {
|
|
1192
|
+
results.push({
|
|
1193
|
+
name: 'Layout: scaffold-tracked files have manifest entries',
|
|
1194
|
+
status: 'PASS',
|
|
1195
|
+
message: 'All files with @mc-scaffold: headers are in the manifest',
|
|
1196
|
+
});
|
|
1197
|
+
} else {
|
|
1198
|
+
results.push({
|
|
1199
|
+
name: 'Layout: scaffold-tracked files have manifest entries',
|
|
1200
|
+
status: 'WARN',
|
|
1201
|
+
message: `${untracked.length} file(s) with @mc-scaffold: header not in manifest:\n ${untracked.join('\n ')}`,
|
|
1202
|
+
fixHint: 'Run tib upgrade or tib doctor --relocate to register these files',
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
return results;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Old path → new path mapping for known scaffold files
|
|
1211
|
+
*/
|
|
1212
|
+
const RELOCATION_MAP: Array<{ oldPath: string; newPath: string; policy: 'owned' | 'tracked' | 'seed' }> = [
|
|
1213
|
+
{ oldPath: 'infra/modules/app.ts', newPath: '.mc/infra/modules/app.ts', policy: 'owned' },
|
|
1214
|
+
{ oldPath: 'infra/modules/shared/SharedStack.ts', newPath: '.mc/infra/modules/shared/SharedStack.ts', policy: 'owned' },
|
|
1215
|
+
{ oldPath: 'infra/modules/domains/dispatch-middleware.ts', newPath: '.mc/infra/modules/domains/dispatch-middleware.ts', policy: 'owned' },
|
|
1216
|
+
{ oldPath: 'infra/modules/PowerTuningStack.ts', newPath: '.mc/infra/modules/PowerTuningStack.ts', policy: 'owned' },
|
|
1217
|
+
{ oldPath: 'infra/cdk.json', newPath: '.mc/infra/cdk.json', policy: 'owned' },
|
|
1218
|
+
{ oldPath: 'infra/tsconfig.json', newPath: '.mc/infra/tsconfig.json', policy: 'owned' },
|
|
1219
|
+
{ oldPath: 'infra/package.json', newPath: '.mc/infra/package.json', policy: 'owned' },
|
|
1220
|
+
{ oldPath: 'mc-deploy.yml', newPath: '.github/workflows/mc-deploy.yml', policy: 'owned' },
|
|
1221
|
+
];
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Run relocation of old-layout scaffold files to new layout
|
|
1225
|
+
*/
|
|
1226
|
+
async function runRelocate(projectRoot: string): Promise<DoctorReport> {
|
|
1227
|
+
const checks: DoctorCheck[] = [];
|
|
1228
|
+
|
|
1229
|
+
const { readManifest, writeManifest, upsertManifestFile } = await import('../utils/manifest.js');
|
|
1230
|
+
const { readFile, writeFile, mkdir, unlink } = await import('node:fs/promises');
|
|
1231
|
+
const { computeChecksumFile, computeChecksumString } = await import('../utils/checksum.js');
|
|
1232
|
+
const { join, dirname } = await import('node:path');
|
|
1233
|
+
|
|
1234
|
+
const manifest = await readManifest(projectRoot);
|
|
1235
|
+
if (!manifest) {
|
|
1236
|
+
checks.push({
|
|
1237
|
+
name: 'Relocate: manifest present',
|
|
1238
|
+
status: 'FAIL',
|
|
1239
|
+
message: '.mc/manifest.json not found — cannot relocate without a manifest',
|
|
1240
|
+
fixHint: 'This project may not be a TIB scaffold project',
|
|
1241
|
+
});
|
|
1242
|
+
return { checks, exitCode: 1, pass: false };
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
let moved = 0;
|
|
1246
|
+
let warned = 0;
|
|
1247
|
+
let skipped = 0;
|
|
1248
|
+
|
|
1249
|
+
for (const { oldPath, newPath, policy } of RELOCATION_MAP) {
|
|
1250
|
+
const oldAbsPath = join(projectRoot, oldPath);
|
|
1251
|
+
const newAbsPath = join(projectRoot, newPath);
|
|
1252
|
+
|
|
1253
|
+
// Check if old file exists
|
|
1254
|
+
const { access: fsAccess } = await import('node:fs/promises');
|
|
1255
|
+
try { await fsAccess(oldAbsPath); } catch { skipped++; continue; }
|
|
1256
|
+
|
|
1257
|
+
// Check if it has the scaffold header (confirm it's scaffold-owned)
|
|
1258
|
+
let content: string;
|
|
1259
|
+
try {
|
|
1260
|
+
content = await readFile(oldAbsPath, 'utf-8');
|
|
1261
|
+
} catch {
|
|
1262
|
+
skipped++;
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
if (!content.includes('@mc-scaffold:')) {
|
|
1267
|
+
// File exists but no header — user-owned, leave it
|
|
1268
|
+
checks.push({
|
|
1269
|
+
name: 'Relocate: checking ' + oldPath,
|
|
1270
|
+
status: 'WARN',
|
|
1271
|
+
message: `${oldPath} has no @mc-scaffold: header — left in place (user-owned)`,
|
|
1272
|
+
file: oldPath,
|
|
1273
|
+
});
|
|
1274
|
+
warned++;
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Check for user drift
|
|
1279
|
+
const manifestEntry = manifest.files.find(f => f.path === oldPath);
|
|
1280
|
+
if (manifestEntry) {
|
|
1281
|
+
const diskSha = await computeChecksumFile(oldAbsPath);
|
|
1282
|
+
if (diskSha && diskSha !== manifestEntry.sha256) {
|
|
1283
|
+
// File has been modified — leave with warning
|
|
1284
|
+
checks.push({
|
|
1285
|
+
name: 'Relocate: checking ' + oldPath,
|
|
1286
|
+
status: 'WARN',
|
|
1287
|
+
message: `${oldPath} has local modifications — left in place. Migrate manually: move to ${newPath}`,
|
|
1288
|
+
file: oldPath,
|
|
1289
|
+
});
|
|
1290
|
+
warned++;
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// Check if destination already exists
|
|
1296
|
+
try {
|
|
1297
|
+
await fsAccess(newAbsPath);
|
|
1298
|
+
checks.push({
|
|
1299
|
+
name: 'Relocate: ' + oldPath,
|
|
1300
|
+
status: 'WARN',
|
|
1301
|
+
message: `${newPath} already exists — skipping move of ${oldPath}`,
|
|
1302
|
+
file: newPath,
|
|
1303
|
+
});
|
|
1304
|
+
warned++;
|
|
1305
|
+
continue;
|
|
1306
|
+
} catch { /* destination doesn't exist, good */ }
|
|
1307
|
+
|
|
1308
|
+
// Move the file
|
|
1309
|
+
try {
|
|
1310
|
+
await mkdir(dirname(newAbsPath), { recursive: true });
|
|
1311
|
+
// Copy + delete (rename may fail across filesystems)
|
|
1312
|
+
await writeFile(newAbsPath, content, 'utf-8');
|
|
1313
|
+
|
|
1314
|
+
// Update manifest: remove old entry, add new with new path + policy
|
|
1315
|
+
manifest.files = manifest.files.filter(f => f.path !== oldPath);
|
|
1316
|
+
const sha256 = manifestEntry?.sha256 ?? computeChecksumString(content);
|
|
1317
|
+
upsertManifestFile(manifest, {
|
|
1318
|
+
path: newPath,
|
|
1319
|
+
module: manifestEntry?.module ?? 'backend-lambda',
|
|
1320
|
+
moduleVersion: manifestEntry?.moduleVersion ?? '1.0.0',
|
|
1321
|
+
sha256,
|
|
1322
|
+
wasTemplate: manifestEntry?.wasTemplate ?? false,
|
|
1323
|
+
installedAt: manifestEntry?.installedAt ?? new Date().toISOString(),
|
|
1324
|
+
policy,
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
// Delete old file
|
|
1328
|
+
await unlink(oldAbsPath);
|
|
1329
|
+
|
|
1330
|
+
checks.push({
|
|
1331
|
+
name: 'Relocate: ' + oldPath,
|
|
1332
|
+
status: 'PASS',
|
|
1333
|
+
message: `Moved ${oldPath} → ${newPath} (policy: ${policy})`,
|
|
1334
|
+
file: newPath,
|
|
1335
|
+
});
|
|
1336
|
+
moved++;
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
checks.push({
|
|
1339
|
+
name: 'Relocate: ' + oldPath,
|
|
1340
|
+
status: 'FAIL',
|
|
1341
|
+
message: `Failed to move ${oldPath} → ${newPath}: ${String(err)}`,
|
|
1342
|
+
file: oldPath,
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// Write updated manifest
|
|
1348
|
+
if (moved > 0) {
|
|
1349
|
+
await writeManifest(projectRoot, manifest);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// Summary check
|
|
1353
|
+
checks.push({
|
|
1354
|
+
name: 'Relocate: summary',
|
|
1355
|
+
status: warned > 0 ? 'WARN' : 'PASS',
|
|
1356
|
+
message: `Relocation complete: ${moved} file(s) moved, ${warned} warning(s), ${skipped} skipped (not present)`,
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
const hasFail = checks.some(c => c.status === 'FAIL');
|
|
1360
|
+
const exitCode = hasFail ? 1 : 0;
|
|
1361
|
+
return { checks, exitCode, pass: exitCode === 0 };
|
|
1362
|
+
}
|