@mandujs/core 0.9.39 → 0.9.40
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/README.ko.md +27 -0
- package/README.md +21 -5
- package/package.json +1 -1
- package/src/config/index.ts +1 -0
- package/src/config/mandu.ts +60 -0
- package/src/contract/client-safe.test.ts +42 -0
- package/src/contract/client-safe.ts +114 -0
- package/src/contract/client.ts +12 -11
- package/src/contract/handler.ts +10 -11
- package/src/contract/index.ts +25 -16
- package/src/contract/registry.test.ts +206 -0
- package/src/contract/registry.ts +568 -0
- package/src/contract/schema.ts +48 -12
- package/src/contract/types.ts +58 -35
- package/src/contract/validator.ts +32 -17
- package/src/filling/context.ts +103 -0
- package/src/generator/templates.ts +70 -17
- package/src/guard/analyzer.ts +9 -4
- package/src/guard/check.ts +66 -30
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/file-type.test.ts +24 -0
- package/src/guard/presets/index.ts +193 -60
- package/src/guard/rules.ts +12 -6
- package/src/guard/statistics.ts +6 -0
- package/src/guard/suggestions.ts +9 -2
- package/src/guard/types.ts +11 -1
- package/src/guard/validator.ts +160 -9
- package/src/guard/watcher.ts +2 -0
- package/src/index.ts +8 -1
- package/src/runtime/index.ts +1 -0
- package/src/runtime/streaming-ssr.ts +123 -2
- package/src/seo/index.ts +214 -0
- package/src/seo/integration/ssr.ts +307 -0
- package/src/seo/render/basic.ts +427 -0
- package/src/seo/render/index.ts +143 -0
- package/src/seo/render/jsonld.ts +539 -0
- package/src/seo/render/opengraph.ts +191 -0
- package/src/seo/render/robots.ts +116 -0
- package/src/seo/render/sitemap.ts +137 -0
- package/src/seo/render/twitter.ts +126 -0
- package/src/seo/resolve/index.ts +353 -0
- package/src/seo/resolve/opengraph.ts +143 -0
- package/src/seo/resolve/robots.ts +73 -0
- package/src/seo/resolve/title.ts +94 -0
- package/src/seo/resolve/twitter.ts +73 -0
- package/src/seo/resolve/url.ts +97 -0
- package/src/seo/routes/index.ts +290 -0
- package/src/seo/types.ts +575 -0
- package/src/slot/validator.ts +39 -16
package/src/contract/types.ts
CHANGED
|
@@ -11,10 +11,22 @@ import type {
|
|
|
11
11
|
MethodRequestSchema,
|
|
12
12
|
} from "./schema";
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
* Extract inferred type from Zod schema
|
|
16
|
-
*/
|
|
17
|
-
type InferZod<T> = T extends z.ZodTypeAny ? z.infer<T> : never;
|
|
14
|
+
/**
|
|
15
|
+
* Extract inferred type from Zod schema
|
|
16
|
+
*/
|
|
17
|
+
type InferZod<T> = T extends z.ZodTypeAny ? z.infer<T> : never;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract inferred type from response schema (supports ResponseSchemaWithExamples)
|
|
21
|
+
*/
|
|
22
|
+
export type InferResponseSchema<T> =
|
|
23
|
+
T extends { schema: infer S }
|
|
24
|
+
? S extends z.ZodTypeAny
|
|
25
|
+
? z.infer<S>
|
|
26
|
+
: never
|
|
27
|
+
: T extends z.ZodTypeAny
|
|
28
|
+
? z.infer<T>
|
|
29
|
+
: never;
|
|
18
30
|
|
|
19
31
|
/**
|
|
20
32
|
* Infer request schema types for a single method
|
|
@@ -38,9 +50,9 @@ type InferContractRequest<T extends ContractRequestSchema> = {
|
|
|
38
50
|
/**
|
|
39
51
|
* Infer all response schemas
|
|
40
52
|
*/
|
|
41
|
-
type InferContractResponse<T extends ContractResponseSchema> = {
|
|
42
|
-
[K in keyof T]:
|
|
43
|
-
};
|
|
53
|
+
type InferContractResponse<T extends ContractResponseSchema> = {
|
|
54
|
+
[K in keyof T]: InferResponseSchema<T[K]>;
|
|
55
|
+
};
|
|
44
56
|
|
|
45
57
|
/**
|
|
46
58
|
* Infer full contract types
|
|
@@ -103,22 +115,34 @@ export type InferBody<
|
|
|
103
115
|
/**
|
|
104
116
|
* Extract params type for a specific method
|
|
105
117
|
*/
|
|
106
|
-
export type InferParams<
|
|
107
|
-
T extends ContractSchema,
|
|
108
|
-
M extends keyof T["request"]
|
|
109
|
-
> = T["request"][M] extends MethodRequestSchema
|
|
110
|
-
? T["request"][M]["params"] extends z.ZodTypeAny
|
|
111
|
-
? z.infer<T["request"][M]["params"]>
|
|
112
|
-
: undefined
|
|
113
|
-
: undefined;
|
|
118
|
+
export type InferParams<
|
|
119
|
+
T extends ContractSchema,
|
|
120
|
+
M extends keyof T["request"]
|
|
121
|
+
> = T["request"][M] extends MethodRequestSchema
|
|
122
|
+
? T["request"][M]["params"] extends z.ZodTypeAny
|
|
123
|
+
? z.infer<T["request"][M]["params"]>
|
|
124
|
+
: undefined
|
|
125
|
+
: undefined;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Extract headers type for a specific method
|
|
129
|
+
*/
|
|
130
|
+
export type InferHeaders<
|
|
131
|
+
T extends ContractSchema,
|
|
132
|
+
M extends keyof T["request"]
|
|
133
|
+
> = T["request"][M] extends MethodRequestSchema
|
|
134
|
+
? T["request"][M]["headers"] extends z.ZodTypeAny
|
|
135
|
+
? z.infer<T["request"][M]["headers"]>
|
|
136
|
+
: undefined
|
|
137
|
+
: undefined;
|
|
114
138
|
|
|
115
139
|
/**
|
|
116
140
|
* Extract response type for a specific status code
|
|
117
141
|
*/
|
|
118
|
-
export type InferResponse<
|
|
119
|
-
T extends ContractSchema,
|
|
120
|
-
S extends keyof T["response"]
|
|
121
|
-
> =
|
|
142
|
+
export type InferResponse<
|
|
143
|
+
T extends ContractSchema,
|
|
144
|
+
S extends keyof T["response"]
|
|
145
|
+
> = InferResponseSchema<T["response"][S]>;
|
|
122
146
|
|
|
123
147
|
/**
|
|
124
148
|
* Helper type to get all defined methods in a contract
|
|
@@ -159,21 +183,20 @@ export type RequiredFields<
|
|
|
159
183
|
/**
|
|
160
184
|
* Get the success response type (200 or 201)
|
|
161
185
|
*/
|
|
162
|
-
export type SuccessResponse<T extends ContractSchema> =
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
: never;
|
|
186
|
+
export type SuccessResponse<T extends ContractSchema> =
|
|
187
|
+
InferResponseSchema<T["response"][200]> extends never
|
|
188
|
+
? InferResponseSchema<T["response"][201]>
|
|
189
|
+
: InferResponseSchema<T["response"][200]>;
|
|
167
190
|
|
|
168
191
|
/**
|
|
169
192
|
* Get the error response type (400, 404, 500, etc.)
|
|
170
193
|
*/
|
|
171
|
-
export type ErrorResponse<T extends ContractSchema> =
|
|
172
|
-
|
|
|
173
|
-
|
|
|
174
|
-
|
|
|
175
|
-
|
|
|
176
|
-
|
|
|
194
|
+
export type ErrorResponse<T extends ContractSchema> =
|
|
195
|
+
| InferResponseSchema<T["response"][400]>
|
|
196
|
+
| InferResponseSchema<T["response"][401]>
|
|
197
|
+
| InferResponseSchema<T["response"][403]>
|
|
198
|
+
| InferResponseSchema<T["response"][404]>
|
|
199
|
+
| InferResponseSchema<T["response"][500]>;
|
|
177
200
|
|
|
178
201
|
/**
|
|
179
202
|
* Utility type for strict contract enforcement
|
|
@@ -210,8 +233,8 @@ export type ContractFetchOptions<
|
|
|
210
233
|
/**
|
|
211
234
|
* Response type union for a contract
|
|
212
235
|
*/
|
|
213
|
-
export type ContractResponseUnion<T extends ContractSchema> = {
|
|
214
|
-
[K in keyof T["response"]]: T["response"][K] extends
|
|
215
|
-
?
|
|
216
|
-
:
|
|
217
|
-
}[keyof T["response"]];
|
|
236
|
+
export type ContractResponseUnion<T extends ContractSchema> = {
|
|
237
|
+
[K in keyof T["response"]]: InferResponseSchema<T["response"][K]> extends never
|
|
238
|
+
? never
|
|
239
|
+
: { status: K; data: InferResponseSchema<T["response"][K]> };
|
|
240
|
+
}[keyof T["response"]];
|
|
@@ -13,21 +13,32 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { z } from "zod";
|
|
16
|
-
import type {
|
|
17
|
-
ContractSchema,
|
|
18
|
-
ContractValidationResult,
|
|
19
|
-
ContractValidationError,
|
|
20
|
-
ContractValidationIssue,
|
|
21
|
-
MethodRequestSchema,
|
|
22
|
-
ContractNormalizeMode,
|
|
23
|
-
|
|
16
|
+
import type {
|
|
17
|
+
ContractSchema,
|
|
18
|
+
ContractValidationResult,
|
|
19
|
+
ContractValidationError,
|
|
20
|
+
ContractValidationIssue,
|
|
21
|
+
MethodRequestSchema,
|
|
22
|
+
ContractNormalizeMode,
|
|
23
|
+
ResponseSchemaWithExamples,
|
|
24
|
+
} from "./schema";
|
|
24
25
|
import {
|
|
25
26
|
type NormalizeMode,
|
|
26
27
|
type NormalizeOptions,
|
|
27
28
|
normalizeSchema,
|
|
28
29
|
createCoerceSchema,
|
|
29
30
|
} from "./normalize";
|
|
30
|
-
import { ZodObject } from "zod";
|
|
31
|
+
import { ZodObject } from "zod";
|
|
32
|
+
|
|
33
|
+
function isResponseSchemaWithExamples(
|
|
34
|
+
schema: z.ZodTypeAny | ResponseSchemaWithExamples | undefined
|
|
35
|
+
): schema is ResponseSchemaWithExamples {
|
|
36
|
+
return (
|
|
37
|
+
schema !== undefined &&
|
|
38
|
+
typeof schema === "object" &&
|
|
39
|
+
"schema" in schema
|
|
40
|
+
);
|
|
41
|
+
}
|
|
31
42
|
|
|
32
43
|
/**
|
|
33
44
|
* Validator 옵션
|
|
@@ -442,14 +453,18 @@ export class ContractValidator {
|
|
|
442
453
|
* @param responseBody - The response body (already parsed)
|
|
443
454
|
* @param statusCode - HTTP status code
|
|
444
455
|
*/
|
|
445
|
-
validateResponse(responseBody: unknown, statusCode: number): ContractValidationResult {
|
|
446
|
-
const
|
|
447
|
-
if (!
|
|
448
|
-
// No schema defined for this status code, pass through
|
|
449
|
-
return { success: true };
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
const
|
|
456
|
+
validateResponse(responseBody: unknown, statusCode: number): ContractValidationResult {
|
|
457
|
+
const responseSchemaOrWithExamples = this.contract.response[statusCode];
|
|
458
|
+
if (!responseSchemaOrWithExamples) {
|
|
459
|
+
// No schema defined for this status code, pass through
|
|
460
|
+
return { success: true };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const responseSchema = isResponseSchemaWithExamples(responseSchemaOrWithExamples)
|
|
464
|
+
? responseSchemaOrWithExamples.schema
|
|
465
|
+
: responseSchemaOrWithExamples;
|
|
466
|
+
|
|
467
|
+
const result = responseSchema.safeParse(responseBody);
|
|
453
468
|
if (!result.success) {
|
|
454
469
|
const errors: ContractValidationError[] = [
|
|
455
470
|
{
|
package/src/filling/context.ts
CHANGED
|
@@ -4,6 +4,19 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { ZodSchema } from "zod";
|
|
7
|
+
import type { ContractSchema, ContractMethod } from "../contract/schema";
|
|
8
|
+
import type { InferBody, InferHeaders, InferParams, InferQuery, InferResponse } from "../contract/types";
|
|
9
|
+
import { ContractValidator, type ContractValidatorOptions } from "../contract/validator";
|
|
10
|
+
|
|
11
|
+
type ContractInput<
|
|
12
|
+
TContract extends ContractSchema,
|
|
13
|
+
TMethod extends ContractMethod,
|
|
14
|
+
> = {
|
|
15
|
+
query: InferQuery<TContract, TMethod>;
|
|
16
|
+
body: InferBody<TContract, TMethod>;
|
|
17
|
+
params: InferParams<TContract, TMethod>;
|
|
18
|
+
headers: InferHeaders<TContract, TMethod>;
|
|
19
|
+
};
|
|
7
20
|
|
|
8
21
|
// ========== Cookie Types ==========
|
|
9
22
|
|
|
@@ -316,6 +329,34 @@ export class ManduContext {
|
|
|
316
329
|
return data as T;
|
|
317
330
|
}
|
|
318
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Parse and validate request input via Contract
|
|
334
|
+
* @example
|
|
335
|
+
* const input = await ctx.input(userContract, "POST", { id: "123" })
|
|
336
|
+
*/
|
|
337
|
+
async input<
|
|
338
|
+
TContract extends ContractSchema,
|
|
339
|
+
TMethod extends ContractMethod,
|
|
340
|
+
>(
|
|
341
|
+
contract: TContract,
|
|
342
|
+
method: TMethod,
|
|
343
|
+
pathParams: Record<string, string> = {},
|
|
344
|
+
options: ContractValidatorOptions = {}
|
|
345
|
+
): Promise<ContractInput<TContract, TMethod>> {
|
|
346
|
+
const validator = new ContractValidator(contract, options);
|
|
347
|
+
const result = await validator.validateAndNormalizeRequest(
|
|
348
|
+
this.request,
|
|
349
|
+
method,
|
|
350
|
+
pathParams
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
if (!result.success) {
|
|
354
|
+
throw new ValidationError(result.errors ?? []);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return (result.data ?? {}) as ContractInput<TContract, TMethod>;
|
|
358
|
+
}
|
|
359
|
+
|
|
319
360
|
// ============================================
|
|
320
361
|
// 🥟 Response 보내기
|
|
321
362
|
// ============================================
|
|
@@ -376,6 +417,68 @@ export class ManduContext {
|
|
|
376
417
|
return this.withCookies(response);
|
|
377
418
|
}
|
|
378
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Validate and send response via Contract
|
|
422
|
+
* @example
|
|
423
|
+
* return ctx.output(userContract, 200, { data: users })
|
|
424
|
+
*/
|
|
425
|
+
output<
|
|
426
|
+
TContract extends ContractSchema,
|
|
427
|
+
TStatus extends keyof TContract["response"],
|
|
428
|
+
>(
|
|
429
|
+
contract: TContract,
|
|
430
|
+
status: TStatus,
|
|
431
|
+
data: InferResponse<TContract, TStatus>,
|
|
432
|
+
options: ContractValidatorOptions = {}
|
|
433
|
+
): Response {
|
|
434
|
+
const validator = new ContractValidator(contract, options);
|
|
435
|
+
const result = validator.validateResponse(data, Number(status));
|
|
436
|
+
|
|
437
|
+
if (!result.success) {
|
|
438
|
+
if (options.mode === "strict") {
|
|
439
|
+
const errorResponse = Response.json(
|
|
440
|
+
{
|
|
441
|
+
errorType: "CONTRACT_VIOLATION",
|
|
442
|
+
code: "MANDU_C001",
|
|
443
|
+
message: "Response does not match contract schema",
|
|
444
|
+
summary: "응답이 Contract 스키마와 일치하지 않습니다",
|
|
445
|
+
statusCode: Number(status),
|
|
446
|
+
violations: result.errors,
|
|
447
|
+
timestamp: new Date().toISOString(),
|
|
448
|
+
},
|
|
449
|
+
{ status: 500 }
|
|
450
|
+
);
|
|
451
|
+
return this.withCookies(errorResponse);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
console.warn(
|
|
455
|
+
"\x1b[33m[Mandu] Contract violation in response:\x1b[0m",
|
|
456
|
+
result.errors
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const payload = result.success ? result.data : data;
|
|
461
|
+
return this.json(payload as InferResponse<TContract, TStatus>, Number(status));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** 200 OK with Contract validation */
|
|
465
|
+
okContract<TContract extends ContractSchema>(
|
|
466
|
+
contract: TContract,
|
|
467
|
+
data: InferResponse<TContract, 200>,
|
|
468
|
+
options: ContractValidatorOptions = {}
|
|
469
|
+
): Response {
|
|
470
|
+
return this.output(contract, 200 as keyof TContract["response"], data, options);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** 201 Created with Contract validation */
|
|
474
|
+
createdContract<TContract extends ContractSchema>(
|
|
475
|
+
contract: TContract,
|
|
476
|
+
data: InferResponse<TContract, 201>,
|
|
477
|
+
options: ContractValidatorOptions = {}
|
|
478
|
+
): Response {
|
|
479
|
+
return this.output(contract, 201 as keyof TContract["response"], data, options);
|
|
480
|
+
}
|
|
481
|
+
|
|
379
482
|
/** Custom text response */
|
|
380
483
|
text(data: string, status: number = 200): Response {
|
|
381
484
|
const response = new Response(data, {
|
|
@@ -110,10 +110,14 @@ export default async function handler(
|
|
|
110
110
|
`;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
export function generateSlotLogic(route: RouteSpec): string {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
export function generateSlotLogic(route: RouteSpec): string {
|
|
114
|
+
if (route.contractModule) {
|
|
115
|
+
return generateSlotLogicWithContract(route);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return `// 🥟 Mandu Filling - ${route.id}
|
|
119
|
+
// Pattern: ${route.pattern}
|
|
120
|
+
// 이 파일에서 비즈니스 로직을 구현하세요.
|
|
117
121
|
|
|
118
122
|
import { Mandu } from "@mandujs/core";
|
|
119
123
|
|
|
@@ -145,17 +149,62 @@ export default Mandu.filling()
|
|
|
145
149
|
// 💡 Context (ctx) API:
|
|
146
150
|
// ctx.query - Query parameters { name: 'value' }
|
|
147
151
|
// ctx.params - Path parameters { id: '123' }
|
|
148
|
-
// ctx.body() - Request body (자동 파싱)
|
|
149
|
-
// ctx.body(zodSchema) - Body with validation
|
|
150
|
-
// ctx.headers - Request headers
|
|
151
|
-
// ctx.ok(data) - 200 OK
|
|
152
|
-
// ctx.created(data) - 201 Created
|
|
153
|
-
// ctx.error(msg) - 400 Bad Request
|
|
154
|
-
// ctx.notFound(msg) - 404 Not Found
|
|
155
|
-
// ctx.set(key, value) - Guard에서 Handler로 데이터 전달
|
|
156
|
-
// ctx.get(key) - Guard에서 설정한 데이터 읽기
|
|
157
|
-
`;
|
|
158
|
-
}
|
|
152
|
+
// ctx.body() - Request body (자동 파싱)
|
|
153
|
+
// ctx.body(zodSchema) - Body with validation
|
|
154
|
+
// ctx.headers - Request headers
|
|
155
|
+
// ctx.ok(data) - 200 OK
|
|
156
|
+
// ctx.created(data) - 201 Created
|
|
157
|
+
// ctx.error(msg) - 400 Bad Request
|
|
158
|
+
// ctx.notFound(msg) - 404 Not Found
|
|
159
|
+
// ctx.set(key, value) - Guard에서 Handler로 데이터 전달
|
|
160
|
+
// ctx.get(key) - Guard에서 설정한 데이터 읽기
|
|
161
|
+
`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function generateSlotLogicWithContract(route: RouteSpec): string {
|
|
165
|
+
const contractImportPath = computeSlotImportPath(
|
|
166
|
+
route.contractModule!,
|
|
167
|
+
pathDirname(route.slotModule ?? "spec/slots")
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return `// 🥟 Mandu Filling - ${route.id}
|
|
171
|
+
// Pattern: ${route.pattern}
|
|
172
|
+
// Contract Module: ${route.contractModule}
|
|
173
|
+
// 이 파일에서 비즈니스 로직을 구현하세요.
|
|
174
|
+
|
|
175
|
+
import { Mandu } from "@mandujs/core";
|
|
176
|
+
import contract from "${contractImportPath}";
|
|
177
|
+
|
|
178
|
+
export default Mandu.filling()
|
|
179
|
+
// 📋 GET ${route.pattern}
|
|
180
|
+
.get(async (ctx) => {
|
|
181
|
+
const input = await ctx.input(contract, "GET", ctx.params);
|
|
182
|
+
// TODO: 계약의 응답 코드에 맞게 status를 조정하세요
|
|
183
|
+
return ctx.output(contract, 200, {
|
|
184
|
+
message: "Hello from ${route.id}!",
|
|
185
|
+
input,
|
|
186
|
+
timestamp: new Date().toISOString(),
|
|
187
|
+
});
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// ➕ POST ${route.pattern}
|
|
191
|
+
.post(async (ctx) => {
|
|
192
|
+
const input = await ctx.input(contract, "POST", ctx.params);
|
|
193
|
+
// TODO: 계약의 응답 코드에 맞게 status를 조정하세요
|
|
194
|
+
return ctx.output(contract, 201, {
|
|
195
|
+
message: "Created!",
|
|
196
|
+
input,
|
|
197
|
+
timestamp: new Date().toISOString(),
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// 💡 Contract 기반 사용법:
|
|
202
|
+
// ctx.input(contract, "GET") - Contract로 요청 검증 + 정규화
|
|
203
|
+
// ctx.output(contract, 200, data) - Contract로 응답 검증
|
|
204
|
+
// ctx.okContract(contract, data) - 200 OK (Contract 검증)
|
|
205
|
+
// ctx.createdContract(contract, data) - 201 Created (Contract 검증)
|
|
206
|
+
`;
|
|
207
|
+
}
|
|
159
208
|
|
|
160
209
|
function computeSlotImportPath(slotModule: string, fromDir: string): string {
|
|
161
210
|
// slotModule: "apps/server/slots/users.logic.ts"
|
|
@@ -334,9 +383,13 @@ export default {
|
|
|
334
383
|
* "todo-page" → "TodoPage"
|
|
335
384
|
* "user_profile" → "UserProfile"
|
|
336
385
|
*/
|
|
337
|
-
function toPascalCase(str: string): string {
|
|
386
|
+
function toPascalCase(str: string): string {
|
|
338
387
|
return str
|
|
339
388
|
.split(/[-_]/)
|
|
340
389
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
341
390
|
.join("");
|
|
342
|
-
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function pathDirname(filePath: string): string {
|
|
394
|
+
return filePath.replace(/\\/g, "/").split("/").slice(0, -1).join("/");
|
|
395
|
+
}
|
package/src/guard/analyzer.ts
CHANGED
|
@@ -239,11 +239,16 @@ export function resolveImportLayer(
|
|
|
239
239
|
export function extractSlice(filePath: string, layer: string): string | undefined {
|
|
240
240
|
const relativePath = filePath.replace(/\\/g, "/");
|
|
241
241
|
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
|
|
242
|
+
const marker = `${layer}/`;
|
|
243
|
+
const index = relativePath.indexOf(marker);
|
|
244
|
+
if (index === -1) {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const rest = relativePath.slice(index + marker.length);
|
|
249
|
+
const slice = rest.split("/")[0];
|
|
245
250
|
|
|
246
|
-
return
|
|
251
|
+
return slice || undefined;
|
|
247
252
|
}
|
|
248
253
|
|
|
249
254
|
// ═══════════════════════════════════════════════════════════════════════════
|
package/src/guard/check.ts
CHANGED
|
@@ -1,16 +1,46 @@
|
|
|
1
|
-
import { GUARD_RULES, FORBIDDEN_IMPORTS, type GuardViolation } from "./rules";
|
|
2
|
-
import { verifyLock, computeHash } from "../spec/lock";
|
|
3
|
-
import { runContractGuardCheck } from "./contract-guard";
|
|
4
|
-
import { validateSlotContent } from "../slot/validator";
|
|
5
|
-
import type { RoutesManifest } from "../spec/schema";
|
|
6
|
-
import type { GeneratedMap } from "../generator/generate";
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
1
|
+
import { GUARD_RULES, FORBIDDEN_IMPORTS, type GuardViolation } from "./rules";
|
|
2
|
+
import { verifyLock, computeHash } from "../spec/lock";
|
|
3
|
+
import { runContractGuardCheck } from "./contract-guard";
|
|
4
|
+
import { validateSlotContent } from "../slot/validator";
|
|
5
|
+
import type { RoutesManifest } from "../spec/schema";
|
|
6
|
+
import type { GeneratedMap } from "../generator/generate";
|
|
7
|
+
import { loadManduConfig, type GuardRuleSeverity } from "../config";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import fs from "fs/promises";
|
|
10
|
+
|
|
11
|
+
export interface GuardCheckResult {
|
|
12
|
+
passed: boolean;
|
|
13
|
+
violations: GuardViolation[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeSeverity(level: GuardRuleSeverity): "error" | "warning" | "off" {
|
|
17
|
+
if (level === "warn") return "warning";
|
|
18
|
+
return level;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function applyRuleSeverity(
|
|
22
|
+
violations: GuardViolation[],
|
|
23
|
+
config: { rules?: Record<string, GuardRuleSeverity>; contractRequired?: GuardRuleSeverity }
|
|
24
|
+
): GuardViolation[] {
|
|
25
|
+
const resolved: GuardViolation[] = [];
|
|
26
|
+
const ruleOverrides = config.rules ?? {};
|
|
27
|
+
|
|
28
|
+
for (const violation of violations) {
|
|
29
|
+
let override = ruleOverrides[violation.ruleId];
|
|
30
|
+
if (violation.ruleId === "CONTRACT_MISSING" && config.contractRequired) {
|
|
31
|
+
override = config.contractRequired;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const baseSeverity = violation.severity ?? GUARD_RULES[violation.ruleId]?.severity ?? "error";
|
|
35
|
+
const finalSeverity = override ? normalizeSeverity(override) : baseSeverity;
|
|
36
|
+
|
|
37
|
+
if (finalSeverity === "off") continue;
|
|
38
|
+
|
|
39
|
+
resolved.push({ ...violation, severity: finalSeverity });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return resolved;
|
|
43
|
+
}
|
|
14
44
|
|
|
15
45
|
async function fileExists(filePath: string): Promise<boolean> {
|
|
16
46
|
try {
|
|
@@ -170,13 +200,15 @@ export async function checkSlotContentValidation(
|
|
|
170
200
|
if (issue.severity === "error") {
|
|
171
201
|
// Map slot issue codes to guard rule IDs
|
|
172
202
|
let ruleId = "SLOT_VALIDATION_ERROR";
|
|
173
|
-
if (issue.code === "MISSING_DEFAULT_EXPORT") {
|
|
174
|
-
ruleId = GUARD_RULES.SLOT_MISSING_DEFAULT_EXPORT?.id ?? "SLOT_MISSING_DEFAULT_EXPORT";
|
|
175
|
-
} else if (issue.code === "NO_RESPONSE_PATTERN" || issue.code === "INVALID_HANDLER_RETURN") {
|
|
176
|
-
ruleId = GUARD_RULES.SLOT_INVALID_RETURN?.id ?? "SLOT_INVALID_RETURN";
|
|
177
|
-
} else if (issue.code === "MISSING_FILLING_PATTERN") {
|
|
178
|
-
ruleId = GUARD_RULES.SLOT_MISSING_FILLING_PATTERN?.id ?? "SLOT_MISSING_FILLING_PATTERN";
|
|
179
|
-
}
|
|
203
|
+
if (issue.code === "MISSING_DEFAULT_EXPORT") {
|
|
204
|
+
ruleId = GUARD_RULES.SLOT_MISSING_DEFAULT_EXPORT?.id ?? "SLOT_MISSING_DEFAULT_EXPORT";
|
|
205
|
+
} else if (issue.code === "NO_RESPONSE_PATTERN" || issue.code === "INVALID_HANDLER_RETURN") {
|
|
206
|
+
ruleId = GUARD_RULES.SLOT_INVALID_RETURN?.id ?? "SLOT_INVALID_RETURN";
|
|
207
|
+
} else if (issue.code === "MISSING_FILLING_PATTERN") {
|
|
208
|
+
ruleId = GUARD_RULES.SLOT_MISSING_FILLING_PATTERN?.id ?? "SLOT_MISSING_FILLING_PATTERN";
|
|
209
|
+
} else if (issue.code === "ZOD_DIRECT_IMPORT") {
|
|
210
|
+
ruleId = GUARD_RULES.SLOT_ZOD_DIRECT_IMPORT?.id ?? "SLOT_ZOD_DIRECT_IMPORT";
|
|
211
|
+
}
|
|
180
212
|
|
|
181
213
|
violations.push({
|
|
182
214
|
ruleId,
|
|
@@ -332,11 +364,12 @@ export async function checkSpecDirNaming(
|
|
|
332
364
|
return violations;
|
|
333
365
|
}
|
|
334
366
|
|
|
335
|
-
export async function runGuardCheck(
|
|
336
|
-
manifest: RoutesManifest,
|
|
337
|
-
rootDir: string
|
|
338
|
-
): Promise<GuardCheckResult> {
|
|
339
|
-
const violations: GuardViolation[] = [];
|
|
367
|
+
export async function runGuardCheck(
|
|
368
|
+
manifest: RoutesManifest,
|
|
369
|
+
rootDir: string
|
|
370
|
+
): Promise<GuardCheckResult> {
|
|
371
|
+
const violations: GuardViolation[] = [];
|
|
372
|
+
const config = await loadManduConfig(rootDir);
|
|
340
373
|
|
|
341
374
|
const lockPath = path.join(rootDir, "spec/spec.lock.json");
|
|
342
375
|
const mapPath = path.join(rootDir, "packages/core/map/generated.map.json");
|
|
@@ -392,8 +425,11 @@ export async function runGuardCheck(
|
|
|
392
425
|
const specDirViolations = await checkSpecDirNaming(rootDir);
|
|
393
426
|
violations.push(...specDirViolations);
|
|
394
427
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
428
|
+
const resolvedViolations = applyRuleSeverity(violations, config.guard ?? {});
|
|
429
|
+
const passed = resolvedViolations.every((v) => v.severity !== "error");
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
passed,
|
|
433
|
+
violations: resolvedViolations,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
@@ -199,15 +199,15 @@ export async function checkContractSlotConsistency(
|
|
|
199
199
|
/**
|
|
200
200
|
* Run all contract-related guard checks
|
|
201
201
|
*/
|
|
202
|
-
export async function runContractGuardCheck(
|
|
203
|
-
manifest: RoutesManifest,
|
|
204
|
-
rootDir: string
|
|
205
|
-
): Promise<ContractViolation[]> {
|
|
206
|
-
const violations: ContractViolation[] = [];
|
|
207
|
-
|
|
208
|
-
// Check missing contracts (warning level)
|
|
209
|
-
|
|
210
|
-
|
|
202
|
+
export async function runContractGuardCheck(
|
|
203
|
+
manifest: RoutesManifest,
|
|
204
|
+
rootDir: string
|
|
205
|
+
): Promise<ContractViolation[]> {
|
|
206
|
+
const violations: ContractViolation[] = [];
|
|
207
|
+
|
|
208
|
+
// Check missing contracts (warning level)
|
|
209
|
+
const missingContracts = await checkMissingContract(manifest, rootDir);
|
|
210
|
+
violations.push(...missingContracts);
|
|
211
211
|
|
|
212
212
|
// Check contract file exists
|
|
213
213
|
const notFoundContracts = await checkContractFileExists(manifest, rootDir);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { validateFileAnalysis } from "./validator";
|
|
3
|
+
import { fsdPreset } from "./presets/fsd";
|
|
4
|
+
import type { FileAnalysis, GuardConfig } from "./types";
|
|
5
|
+
|
|
6
|
+
describe("TypeScript-only rule", () => {
|
|
7
|
+
const layers = fsdPreset.layers;
|
|
8
|
+
const config: GuardConfig = {
|
|
9
|
+
preset: "fsd",
|
|
10
|
+
severity: { fileType: "error" },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
it("should flag .jsx files", () => {
|
|
14
|
+
const analysis: FileAnalysis = {
|
|
15
|
+
filePath: "src/pages/home.jsx",
|
|
16
|
+
layer: "pages",
|
|
17
|
+
imports: [],
|
|
18
|
+
analyzedAt: Date.now(),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const violations = validateFileAnalysis(analysis, layers, config);
|
|
22
|
+
expect(violations.some((v) => v.type === "file-type")).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
});
|