Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/soft-types-match.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@konsistent/convention": patch
"konsistent": patch
---

feat(konsistent): support exact top-level TypeScript type constraints alternatively to JSON schema
10 changes: 10 additions & 0 deletions docs/guides/fixing-violations.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ For `exportConstants`, the value must be a `const`. If a `let` or `function` exi

When a constant entry includes `schema`, the constant must also have a matching explicit type annotation. Add or adjust the annotation only after confirming the initializer and all assignments satisfy it. Schema checks support scalar, literal-union enum, homogeneous array, and inline object annotations; array items and object properties may use a string to require an exact TypeScript type reference. They do not infer initializer types or resolve referenced types. For object schemas, every configured property must be declared. Names in `required` must be non-optional, while other configured names must include `?`.

When an entry includes `type`, its complete explicit annotation must match the
configured TypeScript type expression exactly after template substitution.
Formatting is significant, and konsistent does not resolve imports, aliases, or
semantic equivalence.

#### `exportTypes`

Message: `Missing export type "X"` or `Type "X" ...` for schema mismatches.
Expand All @@ -172,6 +177,11 @@ Search first: grep for `X`, case variants, stripped suffixes (`XConfig` ↔ `X`

When an entry includes `schema`, inspect the local type alias or interface rather than searching for a re-export. Every configured object property must exist with the exact required/optional status expressed by `required`; configured scalar types and type references must match the annotation exactly. Unconfigured properties are allowed unless `additionalProperties` is `false`. Do not replace a local schema-constrained type with a cross-file re-export, because `schema` and `from` are mutually exclusive.

When an entry includes `type`, inspect the right-hand side of the local type
alias. It must match the complete configured expression exactly after template
substitution. Interfaces cannot satisfy `type`, and cross-file re-exports cannot
satisfy it because `type` and `from` are mutually exclusive.

#### `exportFunctions`

Message: `Missing export function "X"` or `Function "X" must receive param of type "Y"` / `... return value of type "Y"`.
Expand Down
76 changes: 60 additions & 16 deletions docs/reference/predicates.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ All declaration predicates accept an array of bare strings or objects with a `na
### `declareTypes`

Assert local type declarations. Interfaces and type aliases qualify. An object
entry can use `schema` to validate the local definition using the same supported
shapes and declaration semantics as `exportTypes`.
entry can use `schema` to validate a supported declaration shape, or `type` to
require an exact type alias expression.

```json
"must": {
Expand All @@ -110,6 +110,10 @@ shapes and declaration semantics as `exportTypes`.
"type": "object",
"properties": { "enabled": { "type": "boolean" } }
}
},
{
"name": "InternalReference",
"type": "${providerId.toPascalCase()}Settings<'internal'>"
}
]
}
Expand All @@ -118,12 +122,13 @@ shapes and declaration semantics as `exportTypes`.
### `declareConstants`

Assert local `const` declarations. Optionally validate an explicit type
annotation using the same `schema` field as `exportConstants`.
annotation using the same `schema` or `type` fields as `exportConstants`.

```json
"must": {
"declareConstants": [
{ "name": "DEFAULT_PORT", "schema": { "type": "number" } }
{ "name": "DEFAULT_PORT", "schema": { "type": "number" } },
{ "name": "settings", "type": "Readonly<ModuleSettings>" }
]
}
```
Expand Down Expand Up @@ -224,8 +229,8 @@ pair. Omitting `alias` forbids the original name under every named export alias.
### `exportTypes`

Assert type-only exports. Exported type aliases and interfaces qualify. Use
`from` to require a re-export, or use `schema` to validate a locally defined
exported type.
`from` to require a re-export, `schema` to validate a supported declaration
shape, or `type` to require an exact type alias expression.

```json
"must": {
Expand Down Expand Up @@ -264,28 +269,50 @@ exported type.
}
```

```json
"must": {
"exportTypes": [
{
"name": "ModuleSettings",
"type": "SharedSettings<'${providerId}'>"
}
]
}
```

| Field | Type | Description |
| --- | --- | --- |
| `name` | string | The original local or re-exported type name. Templates allowed. |
| `alias` | string | Optional. Require a named type export to expose the type under this name. Templates allowed. |
| `from` | string | Optional. Require a type re-export from this module specifier. |
| `schema` | object | Optional. Validate a locally declared type definition. |

`from` and `schema` are mutually exclusive. Schema validation never resolves a
type definition from another file. The `schema` field supports the same forms
and declaration semantics documented under [`exportConstants`](#exportconstants).
When `alias` is combined with `schema`, the schema validates the original local
type definition. Alias omission and unsupported export forms behave as
documented for `exportValues`.
| `type` | string | Optional. Require the exact source expression of a locally declared type alias. Templates allowed. |

`from`, `schema`, and `type` are mutually exclusive. Neither validation field
resolves a type definition from another file. The `schema` field supports the
same forms and declaration semantics documented under
[`exportConstants`](#exportconstants). The `type` field supports any TypeScript
type expression, including generic instantiations, unions, intersections, and
tuples, but requires a type alias because interfaces have no alias expression.
When `alias` is combined with `schema` or `type`, the constraint validates the
original local type definition. Alias omission and unsupported export forms
behave as documented for `exportValues`.

### `exportConstants`

Assert `const` exports specifically. Stricter than `exportValues` — a `function` or `let` with the right name will not satisfy this predicate. An object entry can use `schema` to validate the constant's explicit type annotation.
Assert `const` exports specifically. Stricter than `exportValues` — a `function`
or `let` with the right name will not satisfy this predicate. An object entry
can use `schema` to validate a supported shape or `type` to require its complete
explicit type annotation.

```json
"must": {
"exportConstants": [
"pluginId",
{
"name": "settings",
"type": "ModuleSettings<'public'>"
},
{
"name": "mode",
"schema": {
Expand Down Expand Up @@ -320,6 +347,10 @@ Assert `const` exports specifically. Stricter than `exportValues` — a `functio
| --- | --- | --- |
| `name` | string | The constant name. Templates allowed. |
| `schema` | object | Optional. Supported JSON Schema subset for the constant's explicit type annotation. |
| `type` | string | Optional. Exact TypeScript type annotation. Templates allowed. |

`schema` and `type` are mutually exclusive. `exportConstants` checks locally
declared constants and does not accept `from`.

The supported `schema` forms are:

Expand All @@ -330,11 +361,24 @@ The supported `schema` forms are:

Object schemas describe TypeScript declaration shapes rather than ordinary JSON Schema instances. Every name in `properties` must exist in the annotation or definition. Names listed in `required` must be non-optional (`name: Type`); all other configured names must be optional (`name?: Type`). `required` defaults to an empty array. `additionalProperties` defaults to `true`, allowing unconfigured TypeScript properties; set it to `false` to reject them.

Inner TypeScript type references are compared directly to the source annotation without resolving imports or aliases. They support template substitutions, including transformations such as `"${harnessId.toPascalCase()}Data"`. The comparison includes formatting, so `Readonly<MyAuth>` does not match `Readonly< MyAuth >`.
Inner TypeScript type references and top-level `type` constraints are compared
directly to the source annotation or type alias expression without resolving
imports or aliases. They support template substitutions, including
transformations such as `"${harnessId.toPascalCase()}Data"`. The comparison
includes formatting, so `Readonly<MyAuth>` does not match
`Readonly< MyAuth >`.

This is deliberately a strict subset of JSON Schema. Unsupported keywords and shapes are rejected during configuration validation, including `integer`, `$ref`, combinators, nested object or array schemas, tuple schemas, enum array items, schema-valued `additionalProperties`, and constraints such as `minItems`.

Constant schema checks require an explicit annotation on a locally declared constant. Type schema checks require a local type alias or interface definition; interfaces with `extends` are unsupported. Inferred types, top-level named type references, tuples, intersections, index signatures, methods, computed properties, nested inline types, inherited properties, and cross-file re-exports are not resolved. Enum values and property names inside `schema` are literal data and do not expand placeholders.
Constant `schema` and `type` checks require an explicit annotation on a locally
declared constant. Type schema checks require a local type alias or interface
definition; interfaces with `extends` are unsupported. Type checks require a
local type alias. Inferred types, imports, aliases, semantic equivalence, and
cross-file re-exports are not resolved. The limitations on top-level named type
references, tuples, intersections, index signatures, methods, computed
properties, nested inline types, and inherited properties apply only to
`schema`; `type` compares any expression as source text. Enum values and
property names inside `schema` are literal data and do not expand placeholders.

### `exportFunctions`

Expand Down
8 changes: 8 additions & 0 deletions e2e/fixtures/constant-schemas-broken/konsistent.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,17 @@
"type": "array",
"items": "Readonly<MyAuth>"
}
},
{
"name": "localSettings",
"type": "Readonly<ModuleSettings>"
}
],
"exportConstants": [
{
"name": "settings",
"type": "ModuleSettings<'public'>"
},
{
"name": "mode",
"schema": {
Expand Down
3 changes: 3 additions & 0 deletions e2e/fixtures/constant-schemas-broken/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
type OtherAuth = { token: string };
type ModuleSettings<Scope = "local"> = { scope: Scope };

const localPort = 3000;
const localAuths: Readonly<OtherAuth>[] = [];
const localSettings: ModuleSettings = { scope: "local" };

export const settings: ModuleSettings<'internal'> = { scope: "internal" };
export const mode: "development" = "development";
export const tags: number[] = [1];
export const options: { endpoint: string; retries: number } = {
Expand Down
8 changes: 8 additions & 0 deletions e2e/fixtures/constant-schemas/konsistent.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,17 @@
"type": "array",
"items": "Readonly<MyAuth>"
}
},
{
"name": "localSettings",
"type": "Readonly<ModuleSettings>"
}
],
"exportConstants": [
{
"name": "settings",
"type": "ModuleSettings<'public'>"
},
{
"name": "mode",
"schema": {
Expand Down
3 changes: 3 additions & 0 deletions e2e/fixtures/constant-schemas/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
type MyAuth = { token: string };
type ModuleSettings<Scope = "local"> = { scope: Scope };

const localPort: number = 3000;
const localAuths: ReadonlyArray<Readonly<MyAuth>> = [];
const localSettings: Readonly<ModuleSettings> = { scope: "local" };

export const settings: ModuleSettings<'public'> = { scope: "public" };
export const mode: "development" | "production" = "development";
export const tags: readonly string[] = ["stable"];
export const options: { endpoint: string; metadata?: unknown } = {
Expand Down
8 changes: 8 additions & 0 deletions e2e/fixtures/type-schemas-broken/konsistent.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
"data": "${harnessId.toPascalCase()}Data"
}
}
},
{
"name": "InternalReference",
"type": "${harnessId.toPascalCase()}Shared<'internal'>"
}
],
"exportTypes": [
Expand All @@ -36,6 +40,10 @@
"timeout": { "type": "number" }
}
}
},
{
"name": "ModuleReference",
"type": "${harnessId.toPascalCase()}Shared<'public'>"
}
]
}
Expand Down
3 changes: 3 additions & 0 deletions e2e/fixtures/type-schemas-broken/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
type OtherAuth = { token: string };
type OtherData = { value: string };
type TypesShared<Scope> = { scope: Scope };

type InternalSettings = {
enabled?: boolean;
auth?: Readonly<OtherAuth>;
};

type InternalDataSettings = { data?: OtherData };
type InternalReference = TypesShared<'public'>;

export type ModuleSettings = {
model?: string;
reasoning?: "low" | "medium" | "high";
};
export type ModuleReference = TypesShared<'internal'>;
8 changes: 8 additions & 0 deletions e2e/fixtures/type-schemas/konsistent.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
"data": "${harnessId.toPascalCase()}Data"
}
}
},
{
"name": "InternalReference",
"type": "${harnessId.toPascalCase()}Shared<'internal'>"
}
],
"exportTypes": [
Expand All @@ -36,6 +40,10 @@
"timeout": { "type": "number" }
}
}
},
{
"name": "ModuleReference",
"type": "${harnessId.toPascalCase()}Shared<'public'>"
}
]
}
Expand Down
3 changes: 3 additions & 0 deletions e2e/fixtures/type-schemas/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
type MyAuth = { token: string };
type TypesData = { value: string };
type TypesShared<Scope> = { scope: Scope };

type InternalSettings = {
enabled?: boolean;
auth?: Readonly<MyAuth>;
};

type InternalDataSettings = { data?: TypesData };
type InternalReference = TypesShared<'internal'>;

export type ModuleSettings = {
model?: string;
timeout?: number;
reasoning?: "low" | "medium" | "high";
};
export type ModuleReference = TypesShared<'public'>;
12 changes: 12 additions & 0 deletions e2e/new-predicates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ describe("constant-schemas-broken fixture", () => {
expect(error.stdout).toContain(
'Constant "localAuths" must be an array with items of type "Readonly<MyAuth>"'
);
expect(error.stdout).toContain(
'Constant "localSettings" must have type "Readonly<ModuleSettings>"'
);
expect(error.stdout).toContain(
`Constant "settings" must have type "ModuleSettings<'public'>"`
);
expect(error.stdout).toContain(
'Constant "mode" must have exactly the configured enum values'
);
Expand Down Expand Up @@ -118,6 +124,12 @@ describe("type-schemas-broken fixture", () => {
expect(error.stdout).toContain(
'Type "InternalDataSettings" property "data" must be of type "TypesData"'
);
expect(error.stdout).toContain(
`Type "InternalReference" must have type "TypesShared<'internal'>"`
);
expect(error.stdout).toContain(
`Type "ModuleReference" must have type "TypesShared<'public'>"`
);
expect(error.stdout).toContain("type-schemas");
}
});
Expand Down
Loading