From 82eb7f640fdec0a320a5afbf94e78f95f578bf7e Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Mon, 22 Jun 2026 11:14:11 -0500 Subject: [PATCH] add receiveParamsOfTypes as more comprehensive successor to receiveParamOfType --- docs/guides/examples.md | 2 +- docs/reference/path-patterns.md | 4 +- docs/reference/predicates.md | 11 +- .../konsistent.json | 4 +- .../konsistent.json | 4 +- .../konsistent-reverse.json | 2 +- .../konsistent.json | 2 +- .../konsistent-reverse.json | 2 +- .../declaration-predicates/konsistent.json | 2 +- .../deprecated-function-param/konsistent.json | 16 ++ .../konsistent.json | 5 +- .../services/auth/index.ts | 14 +- .../services/payments/index.ts | 9 +- .../function-signatures/konsistent.json | 5 +- .../services/auth/index.ts | 10 +- .../services/payments/index.ts | 8 +- e2e/placeholder.test.ts | 17 +- .../reusable-convention-package.schema.json | 48 ++++ packages/convention/src/index.test.ts | 1 + packages/convention/src/schemas.ts | 4 + packages/konsistent/konsistent.schema.json | 216 ++++++++++++++++++ .../konsistent/src/commands/commands.test.ts | 27 +++ packages/konsistent/src/commands/validate.ts | 6 + .../src/config/deprecation-warnings.ts | 93 ++++++++ .../src/config/placeholder-validator.test.ts | 3 + .../src/config/placeholder-validator.ts | 80 ++++++- packages/konsistent/src/config/schema.test.ts | 1 + .../predicates/declare-functions.test.ts | 20 ++ .../predicates/declare-functions.ts | 50 +++- .../predicates/export-functions.test.ts | 153 +++++++++++++ .../typescript/predicates/export-functions.ts | 50 +++- 31 files changed, 826 insertions(+), 43 deletions(-) create mode 100644 e2e/fixtures/deprecated-function-param/konsistent.json create mode 100644 packages/konsistent/src/config/deprecation-warnings.ts diff --git a/docs/guides/examples.md b/docs/guides/examples.md index 23505a1..2846882 100644 --- a/docs/guides/examples.md +++ b/docs/guides/examples.md @@ -99,7 +99,7 @@ Service factories must accept a typed config and return a typed service: "exportFunctions": [ { "name": "create${serviceName.toPascalCase()}Service", - "receiveParamOfType": "${serviceName.toPascalCase()}Config", + "receiveParamsOfTypes": ["${serviceName.toPascalCase()}Config"], "returnValueOfType": "${serviceName.toPascalCase()}Service" } ] diff --git a/docs/reference/path-patterns.md b/docs/reference/path-patterns.md index 3dd2c83..5781789 100644 --- a/docs/reference/path-patterns.md +++ b/docs/reference/path-patterns.md @@ -107,7 +107,9 @@ Templates work anywhere a string appears in `must` or `mustNot`: "exportFunctions": [ { "name": "create${adapterName.toPascalCase()}Adapter", - "receiveParamOfType": "${adapterName.toPascalCase()}AdapterConfig", + "receiveParamsOfTypes": [ + "${adapterName.toPascalCase()}AdapterConfig" + ], "returnValueOfType": "${adapterName.toPascalCase()}Adapter" } ] diff --git a/docs/reference/predicates.md b/docs/reference/predicates.md index 852e833..883e373 100644 --- a/docs/reference/predicates.md +++ b/docs/reference/predicates.md @@ -108,14 +108,14 @@ Assert local `const` declarations. ### `declareFunctions` -Assert local function declarations. Optionally validate parameter and return type, using the same fields as `exportFunctions`. +Assert local function declarations. Optionally validate parameters and return type, using the same fields as `exportFunctions`. ```json "must": { "declareFunctions": [ { "name": "createInternalClient", - "receiveParamOfType": "ClientConfig", + "receiveParamsOfTypes": ["ClientConfig"], "returnValueOfType": "Client" } ] @@ -222,14 +222,14 @@ Assert `const` exports specifically. Stricter than `export` — a `function` or ### `exportFunctions` -Assert function exports. Optionally validate the parameter and return type. +Assert function exports. Optionally validate parameters and return type. ```json "must": { "exportFunctions": [ { "name": "create${serviceName.toPascalCase()}Service", - "receiveParamOfType": "${serviceName.toPascalCase()}Config", + "receiveParamsOfTypes": ["${serviceName.toPascalCase()}Config"], "returnValueOfType": "${serviceName.toPascalCase()}Service" } ] @@ -239,7 +239,8 @@ Assert function exports. Optionally validate the parameter and return type. | Field | Type | Description | | --- | --- | --- | | `name` | string | The function name. Templates allowed. | -| `receiveParamOfType` | string | Optional. Type the first parameter must have. | +| `receiveParamsOfTypes` | string[] | Optional. Ordered parameter types to enforce by index. Extra function parameters are allowed. | +| `receiveParamOfType` | string | Deprecated. Optional type at least one parameter must have. Use `receiveParamsOfTypes` instead. | | `returnValueOfType` | string | Optional. Type the return value must have. | The bare-string form (`"exportFunctions": ["myFunction"]`) checks existence only. diff --git a/e2e/fixtures/class-and-function-contracts-broken/konsistent.json b/e2e/fixtures/class-and-function-contracts-broken/konsistent.json index 471179c..f3481c8 100644 --- a/e2e/fixtures/class-and-function-contracts-broken/konsistent.json +++ b/e2e/fixtures/class-and-function-contracts-broken/konsistent.json @@ -22,7 +22,9 @@ "exportFunctions": [ { "name": "create${adapterName.toPascalCase()}Adapter", - "receiveParamOfType": "${adapterName.toPascalCase()}AdapterConfig", + "receiveParamsOfTypes": [ + "${adapterName.toPascalCase()}AdapterConfig" + ], "returnValueOfType": "${adapterName.toPascalCase()}Adapter" } ], diff --git a/e2e/fixtures/class-and-function-contracts/konsistent.json b/e2e/fixtures/class-and-function-contracts/konsistent.json index 471179c..f3481c8 100644 --- a/e2e/fixtures/class-and-function-contracts/konsistent.json +++ b/e2e/fixtures/class-and-function-contracts/konsistent.json @@ -22,7 +22,9 @@ "exportFunctions": [ { "name": "create${adapterName.toPascalCase()}Adapter", - "receiveParamOfType": "${adapterName.toPascalCase()}AdapterConfig", + "receiveParamsOfTypes": [ + "${adapterName.toPascalCase()}AdapterConfig" + ], "returnValueOfType": "${adapterName.toPascalCase()}Adapter" } ], diff --git a/e2e/fixtures/declaration-predicates-broken/konsistent-reverse.json b/e2e/fixtures/declaration-predicates-broken/konsistent-reverse.json index 2a0d5ab..acd123e 100644 --- a/e2e/fixtures/declaration-predicates-broken/konsistent-reverse.json +++ b/e2e/fixtures/declaration-predicates-broken/konsistent-reverse.json @@ -10,7 +10,7 @@ "declareFunctions": [ { "name": "createLocal", - "receiveParamOfType": "LocalConfig", + "receiveParamsOfTypes": ["LocalConfig"], "returnValueOfType": "LocalResult" } ], diff --git a/e2e/fixtures/declaration-predicates-broken/konsistent.json b/e2e/fixtures/declaration-predicates-broken/konsistent.json index ce2561f..e05d996 100644 --- a/e2e/fixtures/declaration-predicates-broken/konsistent.json +++ b/e2e/fixtures/declaration-predicates-broken/konsistent.json @@ -10,7 +10,7 @@ "declareFunctions": [ { "name": "createLocal", - "receiveParamOfType": "LocalConfig", + "receiveParamsOfTypes": ["LocalConfig"], "returnValueOfType": "LocalResult" } ], diff --git a/e2e/fixtures/declaration-predicates/konsistent-reverse.json b/e2e/fixtures/declaration-predicates/konsistent-reverse.json index 2a0d5ab..acd123e 100644 --- a/e2e/fixtures/declaration-predicates/konsistent-reverse.json +++ b/e2e/fixtures/declaration-predicates/konsistent-reverse.json @@ -10,7 +10,7 @@ "declareFunctions": [ { "name": "createLocal", - "receiveParamOfType": "LocalConfig", + "receiveParamsOfTypes": ["LocalConfig"], "returnValueOfType": "LocalResult" } ], diff --git a/e2e/fixtures/declaration-predicates/konsistent.json b/e2e/fixtures/declaration-predicates/konsistent.json index ce2561f..e05d996 100644 --- a/e2e/fixtures/declaration-predicates/konsistent.json +++ b/e2e/fixtures/declaration-predicates/konsistent.json @@ -10,7 +10,7 @@ "declareFunctions": [ { "name": "createLocal", - "receiveParamOfType": "LocalConfig", + "receiveParamsOfTypes": ["LocalConfig"], "returnValueOfType": "LocalResult" } ], diff --git a/e2e/fixtures/deprecated-function-param/konsistent.json b/e2e/fixtures/deprecated-function-param/konsistent.json new file mode 100644 index 0000000..38b6724 --- /dev/null +++ b/e2e/fixtures/deprecated-function-param/konsistent.json @@ -0,0 +1,16 @@ +{ + "version": "v1", + "conventions": [ + { + "paths": "index.ts", + "must": { + "exportFunctions": [ + { + "name": "createThing", + "receiveParamOfType": "ThingConfig" + } + ] + } + } + ] +} diff --git a/e2e/fixtures/function-signatures-broken/konsistent.json b/e2e/fixtures/function-signatures-broken/konsistent.json index c638c2b..8b6b589 100644 --- a/e2e/fixtures/function-signatures-broken/konsistent.json +++ b/e2e/fixtures/function-signatures-broken/konsistent.json @@ -8,7 +8,10 @@ "exportFunctions": [ { "name": "create${serviceName.toPascalCase()}Service", - "receiveParamOfType": "${serviceName.toPascalCase()}Config", + "receiveParamsOfTypes": [ + "${serviceName.toPascalCase()}Config", + "${serviceName.toPascalCase()}Logger" + ], "returnValueOfType": "${serviceName.toPascalCase()}Service" } ] diff --git a/e2e/fixtures/function-signatures-broken/services/auth/index.ts b/e2e/fixtures/function-signatures-broken/services/auth/index.ts index de08e71..08ec936 100644 --- a/e2e/fixtures/function-signatures-broken/services/auth/index.ts +++ b/e2e/fixtures/function-signatures-broken/services/auth/index.ts @@ -1,9 +1,21 @@ export interface Options { apiKey: string; } +export interface AuthConfig { + apiKey: string; +} export interface AuthService { authenticate(): Promise; } -export function createAuthService(config: Options): AuthService { +export interface AuthLogger { + info(message: string): void; +} +export function createAuthService( + config: AuthConfig, + logger: Options, + retryCount: number +): AuthService { + logger.apiKey; + retryCount; return { authenticate: async () => {} }; } diff --git a/e2e/fixtures/function-signatures-broken/services/payments/index.ts b/e2e/fixtures/function-signatures-broken/services/payments/index.ts index 2df0aad..bd1fd39 100644 --- a/e2e/fixtures/function-signatures-broken/services/payments/index.ts +++ b/e2e/fixtures/function-signatures-broken/services/payments/index.ts @@ -4,6 +4,13 @@ export interface PaymentsConfig { export interface PaymentsService { charge(): Promise; } -export function createPaymentsService(config: PaymentsConfig): any { +export interface PaymentsLogger { + info(message: string): void; +} +export function createPaymentsService( + config: PaymentsConfig, + logger: PaymentsLogger +): any { + logger.info(config.apiKey); return { charge: async () => {} }; } diff --git a/e2e/fixtures/function-signatures/konsistent.json b/e2e/fixtures/function-signatures/konsistent.json index c638c2b..8b6b589 100644 --- a/e2e/fixtures/function-signatures/konsistent.json +++ b/e2e/fixtures/function-signatures/konsistent.json @@ -8,7 +8,10 @@ "exportFunctions": [ { "name": "create${serviceName.toPascalCase()}Service", - "receiveParamOfType": "${serviceName.toPascalCase()}Config", + "receiveParamsOfTypes": [ + "${serviceName.toPascalCase()}Config", + "${serviceName.toPascalCase()}Logger" + ], "returnValueOfType": "${serviceName.toPascalCase()}Service" } ] diff --git a/e2e/fixtures/function-signatures/services/auth/index.ts b/e2e/fixtures/function-signatures/services/auth/index.ts index 174a8b0..f34017c 100644 --- a/e2e/fixtures/function-signatures/services/auth/index.ts +++ b/e2e/fixtures/function-signatures/services/auth/index.ts @@ -4,6 +4,14 @@ export interface AuthConfig { export interface AuthService { authenticate(): Promise; } -export function createAuthService(config: AuthConfig): AuthService { +export interface AuthLogger { + info(message: string): void; +} +export function createAuthService( + config: AuthConfig, + logger: AuthLogger, + retryCount: number +): AuthService { + logger.info(`auth retries: ${retryCount}`); return { authenticate: async () => {} }; } diff --git a/e2e/fixtures/function-signatures/services/payments/index.ts b/e2e/fixtures/function-signatures/services/payments/index.ts index 4e0ad91..012d534 100644 --- a/e2e/fixtures/function-signatures/services/payments/index.ts +++ b/e2e/fixtures/function-signatures/services/payments/index.ts @@ -6,8 +6,14 @@ export interface PaymentsService { client: T; charge(): Promise; } +export interface PaymentsLogger { + info(message: string): void; +} export function createPaymentsService( - config: PaymentsConfig + config: PaymentsConfig, + logger: PaymentsLogger, + timeoutMs: number ): PaymentsService { + logger.info(`payments timeout: ${timeoutMs}`); return { client: config.client, charge: async () => {} }; } diff --git a/e2e/placeholder.test.ts b/e2e/placeholder.test.ts index 4fffe39..4e3dbfe 100644 --- a/e2e/placeholder.test.ts +++ b/e2e/placeholder.test.ts @@ -201,7 +201,7 @@ describe("function-signatures-broken fixture", () => { }; expect(error.code ?? error.status).toBe(1); expect(error.stdout).toContain( - 'Function "createAuthService" must receive a parameter of type "AuthConfig"' + 'Function "createAuthService" parameter 2 must be of type "AuthLogger"' ); expect(error.stdout).toContain( 'Function "createPaymentsService" must return value of type "PaymentsService"' @@ -212,6 +212,19 @@ describe("function-signatures-broken fixture", () => { }); }); +describe("deprecated-function-param fixture", () => { + const cwd = resolve(fixturesDir, "deprecated-function-param"); + + it("konsistent validate exits 0 and warns for receiveParamOfType", async () => { + const { stdout, stderr } = await runCli({ args: ["validate"], cwd }); + expect(stdout).toContain("Configuration is valid"); + expect(stderr).toContain('"receiveParamOfType" is deprecated'); + expect(stderr).toContain( + "conventions[0].must.exportFunctions[0].receiveParamOfType" + ); + }); +}); + describe("ai-toolkit-broken-exports fixture", () => { const cwd = resolve(fixturesDir, "ai-toolkit-broken-exports"); @@ -300,7 +313,7 @@ describe("class-and-function-contracts-broken fixture", () => { 'Class "DatabaseAdapter" must extend "BaseAdapter"' ); expect(error.stdout).toContain( - 'Function "createDatabaseAdapter" must receive a parameter of type "DatabaseAdapterConfig"' + 'Function "createDatabaseAdapter" parameter 1 must be of type "DatabaseAdapterConfig"' ); expect(error.stdout).toContain( 'Function "createDatabaseAdapter" must return value of type "DatabaseAdapter"' diff --git a/packages/convention/reusable-convention-package.schema.json b/packages/convention/reusable-convention-package.schema.json index 5573701..a73fac7 100644 --- a/packages/convention/reusable-convention-package.schema.json +++ b/packages/convention/reusable-convention-package.schema.json @@ -156,6 +156,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -354,6 +360,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -603,6 +615,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -801,6 +819,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -1131,6 +1155,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -1329,6 +1359,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -1578,6 +1614,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -1776,6 +1818,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } diff --git a/packages/convention/src/index.test.ts b/packages/convention/src/index.test.ts index b21709d..d21bb4e 100644 --- a/packages/convention/src/index.test.ts +++ b/packages/convention/src/index.test.ts @@ -51,6 +51,7 @@ describe("ReusableConventionV1Schema", () => { { name: "createLocal", receiveParamOfType: "LocalConfig", + receiveParamsOfTypes: ["LocalConfig"], returnValueOfType: "Local", }, ], diff --git a/packages/convention/src/schemas.ts b/packages/convention/src/schemas.ts index cf6fcfe..f704669 100644 --- a/packages/convention/src/schemas.ts +++ b/packages/convention/src/schemas.ts @@ -16,7 +16,11 @@ export const ImportDefinitionV1Schema = z.strictObject({ export const FunctionDefinitionV1Schema = z.strictObject({ name: z.string(), + /** + * Deprecated: use receiveParamsOfTypes instead. + */ receiveParamOfType: z.string().optional(), + receiveParamsOfTypes: z.array(z.string()).optional(), returnValueOfType: z.string().optional(), }); diff --git a/packages/konsistent/konsistent.schema.json b/packages/konsistent/konsistent.schema.json index 5aab1fc..b7bc10c 100644 --- a/packages/konsistent/konsistent.schema.json +++ b/packages/konsistent/konsistent.schema.json @@ -199,6 +199,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -397,6 +403,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -646,6 +658,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -844,6 +862,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -1146,6 +1170,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -1344,6 +1374,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -1663,6 +1699,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -1861,6 +1903,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -2110,6 +2158,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -2308,6 +2362,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -2621,6 +2681,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -2819,6 +2885,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -3068,6 +3140,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -3266,6 +3344,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -3585,6 +3669,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -3783,6 +3873,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -4032,6 +4128,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -4230,6 +4332,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -4488,6 +4596,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -4686,6 +4800,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -4986,6 +5106,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -5184,6 +5310,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -5503,6 +5635,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -5701,6 +5839,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -5950,6 +6094,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -6148,6 +6298,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -6461,6 +6617,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -6659,6 +6821,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -6908,6 +7076,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -7106,6 +7280,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -7425,6 +7605,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -7623,6 +7809,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -7872,6 +8064,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -8070,6 +8268,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -8328,6 +8532,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } @@ -8526,6 +8736,12 @@ "receiveParamOfType": { "type": "string" }, + "receiveParamsOfTypes": { + "type": "array", + "items": { + "type": "string" + } + }, "returnValueOfType": { "type": "string" } diff --git a/packages/konsistent/src/commands/commands.test.ts b/packages/konsistent/src/commands/commands.test.ts index be76a9d..6a1e30f 100644 --- a/packages/konsistent/src/commands/commands.test.ts +++ b/packages/konsistent/src/commands/commands.test.ts @@ -26,6 +26,11 @@ const mixedSeverityPath = resolve( "../../../../e2e/fixtures/mixed-severity" ); +const deprecatedFunctionParamPath = resolve( + import.meta.dirname, + "../../../../e2e/fixtures/deprecated-function-param" +); + afterEach(() => { vi.restoreAllMocks(); }); @@ -132,6 +137,28 @@ describe("validate command", () => { await runCommand(validateCommand, { rawArgs: [] }); expect(logSpy).toHaveBeenCalled(); }); + + it("warns without exiting when receiveParamOfType is used", async () => { + vi.spyOn(process, "cwd").mockReturnValue(deprecatedFunctionParamPath); + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(vi.fn()); + const logSpy = vi.spyOn(console, "log").mockImplementation(vi.fn()); + await runCommand(validateCommand, { rawArgs: [] }); + expect(exitSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Configuration is valid") + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('"receiveParamOfType" is deprecated') + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "conventions[0].must.exportFunctions[0].receiveParamOfType" + ) + ); + }); }); describe("--config-package CLI guards", () => { diff --git a/packages/konsistent/src/commands/validate.ts b/packages/konsistent/src/commands/validate.ts index 08f742a..dbc2595 100644 --- a/packages/konsistent/src/commands/validate.ts +++ b/packages/konsistent/src/commands/validate.ts @@ -1,5 +1,6 @@ import { defineCommand } from "citty"; import pc from "picocolors"; +import { collectDeprecationWarnings } from "../config/deprecation-warnings.js"; import { loadConfig, normalizePlaceholderArg, @@ -48,6 +49,11 @@ export default defineCommand({ process.exit(1); return; } + for (const warning of collectDeprecationWarnings({ + config: result.config, + })) { + console.warn(pc.yellow(warning)); + } console.log(pc.green("Configuration is valid.")); }, }); diff --git a/packages/konsistent/src/config/deprecation-warnings.ts b/packages/konsistent/src/config/deprecation-warnings.ts new file mode 100644 index 0000000..61b128a --- /dev/null +++ b/packages/konsistent/src/config/deprecation-warnings.ts @@ -0,0 +1,93 @@ +import type { ConfigV1, MustBlockV1, MustPredicatesV1 } from "./schema.js"; + +export function collectDeprecationWarnings(opts: { + config: ConfigV1; +}): string[] { + const warnings: string[] = []; + for (const [index, convention] of opts.config.conventions.entries()) { + const prefix = `conventions[${index}]`; + if (Array.isArray(convention.must)) { + for (const [blockIndex, block] of convention.must.entries()) { + collectBlockWarnings({ + block, + path: `${prefix}.must[${blockIndex}]`, + warnings, + }); + } + } else { + collectPredicateWarnings({ + predicates: convention.must, + path: `${prefix}.must`, + warnings, + }); + } + collectPredicateWarnings({ + predicates: convention.mustNot, + path: `${prefix}.mustNot`, + warnings, + }); + } + return warnings; +} + +function collectBlockWarnings(opts: { + block: MustBlockV1; + path: string; + warnings: string[]; +}): void { + const { block, path, warnings } = opts; + collectPredicateWarnings({ + predicates: block.must, + path: `${path}.must`, + warnings, + }); + collectPredicateWarnings({ + predicates: block.mustNot, + path: `${path}.mustNot`, + warnings, + }); +} + +function collectPredicateWarnings(opts: { + predicates: MustPredicatesV1 | undefined; + path: string; + warnings: string[]; +}): void { + const { predicates, path, warnings } = opts; + if (!predicates) { + return; + } + collectFunctionWarnings({ + list: predicates.declareFunctions, + path: `${path}.declareFunctions`, + warnings, + }); + collectFunctionWarnings({ + list: predicates.exportFunctions, + path: `${path}.exportFunctions`, + warnings, + }); +} + +function collectFunctionWarnings(opts: { + list: MustPredicatesV1["declareFunctions"] | undefined; + path: string; + warnings: string[]; +}): void { + const { list, path, warnings } = opts; + if (!list) { + return; + } + for (const [index, entry] of list.entries()) { + if ( + typeof entry === "object" && + entry !== null && + Object.hasOwn(entry, "receiveParamOfType") + ) { + warnings.push( + `Warning: "receiveParamOfType" is deprecated in ${path}[${index}].receiveParamOfType. ` + + 'Use "receiveParamsOfTypes" instead.' + ); + } + } +} diff --git a/packages/konsistent/src/config/placeholder-validator.test.ts b/packages/konsistent/src/config/placeholder-validator.test.ts index 38705eb..bf6aae1 100644 --- a/packages/konsistent/src/config/placeholder-validator.test.ts +++ b/packages/konsistent/src/config/placeholder-validator.test.ts @@ -343,6 +343,7 @@ describe("validatePlaceholders", () => { { name: "create${missingFunction}", receiveParamOfType: "${missingParam}", + receiveParamsOfTypes: ["${missingParamAtIndex}"], returnValueOfType: "${missingReturn}", }, ], @@ -371,7 +372,9 @@ describe("validatePlaceholders", () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain('"${missingType}"'); + expect(result.error).toContain('"${missingParamAtIndex}"'); expect(result.error).toContain("must.declareTypes"); + expect(result.error).toContain("must.declareFunctions"); } }); diff --git a/packages/konsistent/src/config/placeholder-validator.ts b/packages/konsistent/src/config/placeholder-validator.ts index 931bfca..73b1218 100644 --- a/packages/konsistent/src/config/placeholder-validator.ts +++ b/packages/konsistent/src/config/placeholder-validator.ts @@ -224,6 +224,7 @@ function collectUsagesInPredicates(opts: { list: predicates.declareFunctions, key: `${prefix}.declareFunctions`, objectFields: ["name", "receiveParamOfType", "returnValueOfType"], + arrayFields: ["receiveParamsOfTypes"], declared, usages, }); @@ -280,6 +281,7 @@ function collectUsagesInPredicates(opts: { list: predicates.exportFunctions, key: `${prefix}.exportFunctions`, objectFields: ["name", "receiveParamOfType", "returnValueOfType"], + arrayFields: ["receiveParamsOfTypes"], declared, usages, }); @@ -320,6 +322,7 @@ function collectUsagesInDefinitionList(opts: { list: ReadonlyArray> | undefined; key: string; objectFields: string[]; + arrayFields?: string[]; extendField?: boolean; implementField?: boolean; declared: Set; @@ -329,6 +332,7 @@ function collectUsagesInDefinitionList(opts: { list, key, objectFields, + arrayFields, extendField, implementField, declared, @@ -342,23 +346,83 @@ function collectUsagesInDefinitionList(opts: { pushStringUsages({ value: entry, key, declared, usages }); continue; } - for (const field of objectFields) { - const value = entry[field]; - if (typeof value === "string") { - pushStringUsages({ value, key, declared, usages }); - } - } + collectObjectFieldUsages({ + entry, + fields: objectFields, + key, + declared, + usages, + }); + collectArrayFieldUsages({ + entry, + fields: arrayFields ?? [], + key, + declared, + usages, + }); if (extendField) { collectExtendUsages({ value: entry.extend, key, declared, usages }); } if (implementField && Array.isArray(entry.implement)) { - for (const item of entry.implement) { - collectExtendUsages({ value: item, key, declared, usages }); + collectImplementUsages({ + values: entry.implement, + key, + declared, + usages, + }); + } + } +} + +function collectObjectFieldUsages(opts: { + entry: Record; + fields: string[]; + key: string; + declared: Set; + usages: Usage[]; +}): void { + const { entry, fields, key, declared, usages } = opts; + for (const field of fields) { + const value = entry[field]; + if (typeof value === "string") { + pushStringUsages({ value, key, declared, usages }); + } + } +} + +function collectArrayFieldUsages(opts: { + entry: Record; + fields: string[]; + key: string; + declared: Set; + usages: Usage[]; +}): void { + const { entry, fields, key, declared, usages } = opts; + for (const field of fields) { + const value = entry[field]; + if (!Array.isArray(value)) { + continue; + } + for (const item of value) { + if (typeof item === "string") { + pushStringUsages({ value: item, key, declared, usages }); } } } } +function collectImplementUsages(opts: { + values: unknown[]; + key: string; + declared: Set; + usages: Usage[]; +}): void { + const { values, key, declared, usages } = opts; + for (const item of values) { + collectExtendUsages({ value: item, key, declared, usages }); + } +} + function collectExtendUsages(opts: { value: unknown; key: string; diff --git a/packages/konsistent/src/config/schema.test.ts b/packages/konsistent/src/config/schema.test.ts index e1b52dd..da01edf 100644 --- a/packages/konsistent/src/config/schema.test.ts +++ b/packages/konsistent/src/config/schema.test.ts @@ -101,6 +101,7 @@ describe("ConfigV1Schema", () => { { name: "createLocal", receiveParamOfType: "LocalConfig", + receiveParamsOfTypes: ["LocalConfig"], returnValueOfType: "Local", }, ], diff --git a/packages/konsistent/src/typescript/predicates/declare-functions.test.ts b/packages/konsistent/src/typescript/predicates/declare-functions.test.ts index 6aac3e9..1400c94 100644 --- a/packages/konsistent/src/typescript/predicates/declare-functions.test.ts +++ b/packages/konsistent/src/typescript/predicates/declare-functions.test.ts @@ -66,4 +66,24 @@ describe("checkDeclareFunctions", () => { 'Function "createThing" must return value of type "Thing"' ); }); + + it("checks ordered local function params", () => { + const result = checkDeclareFunctions({ + expected: [ + { + name: "createThing", + receiveParamsOfTypes: ["ThingConfig", "ThingContext"], + }, + ], + context: createMockContext({ path: "src/index.ts" }), + fileStructure: parseSource({ + source: + "function createThing(config: ThingConfig, ctx: WrongContext) {}", + }), + }); + expect(result).toHaveLength(1); + expect(result[0].message).toBe( + 'Function "createThing" parameter 2 must be of type "ThingContext"' + ); + }); }); diff --git a/packages/konsistent/src/typescript/predicates/declare-functions.ts b/packages/konsistent/src/typescript/predicates/declare-functions.ts index 014ec97..134716c 100644 --- a/packages/konsistent/src/typescript/predicates/declare-functions.ts +++ b/packages/konsistent/src/typescript/predicates/declare-functions.ts @@ -1,7 +1,11 @@ import type { PredicateContext } from "../../core/context.js"; import type { Diagnostic, DiagnosticSeverity } from "../../core/diagnostics.js"; import { createDiagnostic } from "../../core/diagnostics.js"; -import type { FileStructure, FunctionInfo } from "../types.js"; +import type { + FileStructure, + FunctionInfo, + TypeAnnotationInfo, +} from "../types.js"; import { createExportedDeclarationDiagnostic, createMissingDeclarationDiagnostic, @@ -13,13 +17,23 @@ import { interface FunctionDef { name: string; receiveParamOfType?: string; + receiveParamsOfTypes?: string[]; returnValueOfType?: string; } +function typeMatches(opts: { + actual: TypeAnnotationInfo | undefined; + expected: string; +}): boolean { + const { actual, expected } = opts; + return actual?.text === expected || actual?.baseName === expected; +} + function checkSignature(opts: { funcInfo: FunctionInfo; resolvedName: string; resolvedParamType: string | undefined; + resolvedParamTypes: string[] | undefined; resolvedReturnType: string | undefined; checkContext: DeclarationCheckContext; }): Diagnostic[] { @@ -27,16 +41,15 @@ function checkSignature(opts: { funcInfo, resolvedName, resolvedParamType, + resolvedParamTypes, resolvedReturnType, checkContext, } = opts; const diagnostics: Diagnostic[] = []; if (resolvedParamType) { - const hasParam = funcInfo.params.some( - (p) => - p.typeName?.text === resolvedParamType || - p.typeName?.baseName === resolvedParamType + const hasParam = funcInfo.params.some((p) => + typeMatches({ actual: p.typeName, expected: resolvedParamType }) ); if (!hasParam) { diagnostics.push( @@ -53,10 +66,29 @@ function checkSignature(opts: { } } + if (resolvedParamTypes) { + for (const [index, expectedType] of resolvedParamTypes.entries()) { + const param = funcInfo.params[index]; + if (typeMatches({ actual: param?.typeName, expected: expectedType })) { + continue; + } + diagnostics.push( + createDiagnostic({ + filePath: checkContext.context.path, + predicateName: checkContext.predicateName, + message: `Function "${resolvedName}" parameter ${index + 1} must be of type "${expectedType}"`, + conventionName: checkContext.conventionName, + line: funcInfo.pos.line, + column: funcInfo.pos.column, + severity: checkContext.severity, + }) + ); + } + } + if ( resolvedReturnType && - funcInfo.returnType?.text !== resolvedReturnType && - funcInfo.returnType?.baseName !== resolvedReturnType + !typeMatches({ actual: funcInfo.returnType, expected: resolvedReturnType }) ) { diagnostics.push( createDiagnostic({ @@ -97,6 +129,9 @@ export function checkDeclareFunctions(opts: { const resolvedParamType = definition.receiveParamOfType ? context.resolveTemplate(definition.receiveParamOfType) : undefined; + const resolvedParamTypes = definition.receiveParamsOfTypes?.map((type) => + context.resolveTemplate(type) + ); const resolvedReturnType = definition.returnValueOfType ? context.resolveTemplate(definition.returnValueOfType) : undefined; @@ -137,6 +172,7 @@ export function checkDeclareFunctions(opts: { funcInfo, resolvedName, resolvedParamType, + resolvedParamTypes, resolvedReturnType, checkContext, }) diff --git a/packages/konsistent/src/typescript/predicates/export-functions.test.ts b/packages/konsistent/src/typescript/predicates/export-functions.test.ts index 83ce4c5..f4bcbc4 100644 --- a/packages/konsistent/src/typescript/predicates/export-functions.test.ts +++ b/packages/konsistent/src/typescript/predicates/export-functions.test.ts @@ -164,6 +164,159 @@ describe("checkExportFunctions", () => { expect(result).toEqual([]); }); + it("returns no diagnostic when ordered param types match", () => { + const result = checkExportFunctions({ + expected: [ + { name: "myFunc", receiveParamsOfTypes: ["Request", "Context"] }, + ], + context: createMockContext({ path: "src/index.ts" }), + fileStructure: createMockFileStructure({ + exports: [ + { + name: "myFunc", + kind: "function", + isType: false, + pos: { line: 3, column: 1 }, + }, + ], + functions: [ + { + name: "myFunc", + params: [ + { + name: "req", + typeName: { text: "Request", baseName: "Request" }, + }, + { + name: "ctx", + typeName: { text: "Context", baseName: "Context" }, + }, + { + name: "signal", + typeName: { text: "AbortSignal", baseName: "AbortSignal" }, + }, + ], + pos: { line: 3, column: 1 }, + }, + ], + }), + }); + expect(result).toEqual([]); + }); + + it("returns diagnostic when ordered param type does not match", () => { + const result = checkExportFunctions({ + expected: [ + { name: "myFunc", receiveParamsOfTypes: ["Request", "Context"] }, + ], + context: createMockContext({ path: "src/index.ts" }), + fileStructure: createMockFileStructure({ + exports: [ + { + name: "myFunc", + kind: "function", + isType: false, + pos: { line: 3, column: 1 }, + }, + ], + functions: [ + { + name: "myFunc", + params: [ + { + name: "req", + typeName: { text: "Request", baseName: "Request" }, + }, + { + name: "ctx", + typeName: { text: "WrongContext", baseName: "WrongContext" }, + }, + ], + pos: { line: 3, column: 1 }, + }, + ], + }), + }); + expect(result).toHaveLength(1); + expect(result[0].message).toBe( + 'Function "myFunc" parameter 2 must be of type "Context"' + ); + }); + + it("returns diagnostic when ordered param is missing", () => { + const result = checkExportFunctions({ + expected: [ + { name: "myFunc", receiveParamsOfTypes: ["Request", "Context"] }, + ], + context: createMockContext({ path: "src/index.ts" }), + fileStructure: createMockFileStructure({ + exports: [ + { + name: "myFunc", + kind: "function", + isType: false, + pos: { line: 3, column: 1 }, + }, + ], + functions: [ + { + name: "myFunc", + params: [ + { + name: "req", + typeName: { text: "Request", baseName: "Request" }, + }, + ], + pos: { line: 3, column: 1 }, + }, + ], + }), + }); + expect(result).toHaveLength(1); + expect(result[0].message).toBe( + 'Function "myFunc" parameter 2 must be of type "Context"' + ); + }); + + it("enforces deprecated and ordered param checks when both are present", () => { + const result = checkExportFunctions({ + expected: [ + { + name: "myFunc", + receiveParamOfType: "LegacyRequest", + receiveParamsOfTypes: ["Request"], + }, + ], + context: createMockContext({ path: "src/index.ts" }), + fileStructure: createMockFileStructure({ + exports: [ + { + name: "myFunc", + kind: "function", + isType: false, + pos: { line: 3, column: 1 }, + }, + ], + functions: [ + { + name: "myFunc", + params: [ + { + name: "req", + typeName: { text: "Request", baseName: "Request" }, + }, + ], + pos: { line: 3, column: 1 }, + }, + ], + }), + }); + expect(result).toHaveLength(1); + expect(result[0].message).toBe( + 'Function "myFunc" must receive a parameter of type "LegacyRequest"' + ); + }); + it("returns diagnostic when return type does not match", () => { const result = checkExportFunctions({ expected: [{ name: "myFunc", returnValueOfType: "Promise" }], diff --git a/packages/konsistent/src/typescript/predicates/export-functions.ts b/packages/konsistent/src/typescript/predicates/export-functions.ts index e0d1a89..f9ba095 100644 --- a/packages/konsistent/src/typescript/predicates/export-functions.ts +++ b/packages/konsistent/src/typescript/predicates/export-functions.ts @@ -1,18 +1,32 @@ import type { PredicateContext } from "../../core/context.js"; import type { Diagnostic, DiagnosticSeverity } from "../../core/diagnostics.js"; import { createDiagnostic } from "../../core/diagnostics.js"; -import type { FileStructure, FunctionInfo } from "../types.js"; +import type { + FileStructure, + FunctionInfo, + TypeAnnotationInfo, +} from "../types.js"; interface FunctionDef { name: string; receiveParamOfType?: string; + receiveParamsOfTypes?: string[]; returnValueOfType?: string; } +function typeMatches(opts: { + actual: TypeAnnotationInfo | undefined; + expected: string; +}): boolean { + const { actual, expected } = opts; + return actual?.text === expected || actual?.baseName === expected; +} + function checkSignature(opts: { funcInfo: FunctionInfo; resolvedName: string; resolvedParamType: string | undefined; + resolvedParamTypes: string[] | undefined; resolvedReturnType: string | undefined; context: PredicateContext; conventionName?: string; @@ -22,6 +36,7 @@ function checkSignature(opts: { funcInfo, resolvedName, resolvedParamType, + resolvedParamTypes, resolvedReturnType, context, conventionName, @@ -30,10 +45,8 @@ function checkSignature(opts: { const diagnostics: Diagnostic[] = []; if (resolvedParamType) { - const hasParam = funcInfo.params.some( - (p) => - p.typeName?.text === resolvedParamType || - p.typeName?.baseName === resolvedParamType + const hasParam = funcInfo.params.some((p) => + typeMatches({ actual: p.typeName, expected: resolvedParamType }) ); if (!hasParam) { diagnostics.push( @@ -50,10 +63,29 @@ function checkSignature(opts: { } } + if (resolvedParamTypes) { + for (const [index, expectedType] of resolvedParamTypes.entries()) { + const param = funcInfo.params[index]; + if (typeMatches({ actual: param?.typeName, expected: expectedType })) { + continue; + } + diagnostics.push( + createDiagnostic({ + filePath: context.path, + predicateName: "exportFunctions", + message: `Function "${resolvedName}" parameter ${index + 1} must be of type "${expectedType}"`, + conventionName, + line: funcInfo.pos.line, + column: funcInfo.pos.column, + severity, + }) + ); + } + } + if ( resolvedReturnType && - funcInfo.returnType?.text !== resolvedReturnType && - funcInfo.returnType?.baseName !== resolvedReturnType + !typeMatches({ actual: funcInfo.returnType, expected: resolvedReturnType }) ) { diagnostics.push( createDiagnostic({ @@ -89,6 +121,9 @@ export function checkExportFunctions(opts: { const resolvedParamType = definition.receiveParamOfType ? context.resolveTemplate(definition.receiveParamOfType) : undefined; + const resolvedParamTypes = definition.receiveParamsOfTypes?.map((type) => + context.resolveTemplate(type) + ); const resolvedReturnType = definition.returnValueOfType ? context.resolveTemplate(definition.returnValueOfType) : undefined; @@ -118,6 +153,7 @@ export function checkExportFunctions(opts: { funcInfo, resolvedName, resolvedParamType, + resolvedParamTypes, resolvedReturnType, context, conventionName,