vouchington-tooling 0.15.1 → 0.16.1

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.
Files changed (37) hide show
  1. package/README.md +19 -0
  2. package/dist/api-fixtures/fixture-contract-validation.d.mts +22 -0
  3. package/dist/api-fixtures/fixture-contract-validation.mjs +36 -0
  4. package/dist/api-fixtures/index.d.mts +3 -0
  5. package/dist/api-fixtures/index.mjs +3 -0
  6. package/dist/api-fixtures/schema-lock.d.mts +35 -0
  7. package/dist/api-fixtures/schema-lock.mjs +125 -0
  8. package/dist/api-fixtures/write-generated-files.d.mts +6 -0
  9. package/dist/api-fixtures/write-generated-files.mjs +34 -0
  10. package/dist/contract-schema/contract-schema-extractor.d.mts +3 -0
  11. package/dist/contract-schema/contract-schema-extractor.mjs +25 -0
  12. package/dist/contract-schema/contract-schema-object-tuple.d.mts +7 -0
  13. package/dist/contract-schema/contract-schema-object-tuple.mjs +49 -0
  14. package/dist/contract-schema/contract-schema-type-extractor.d.mts +11 -0
  15. package/dist/contract-schema/contract-schema-type-extractor.mjs +170 -0
  16. package/dist/contract-schema/contract-schema-type-utils.d.mts +9 -0
  17. package/dist/contract-schema/contract-schema-type-utils.mjs +107 -0
  18. package/dist/contract-schema/contract-schema-validator.d.mts +3 -0
  19. package/dist/contract-schema/contract-schema-validator.mjs +126 -0
  20. package/dist/contract-schema/index.d.mts +9 -0
  21. package/dist/contract-schema/index.mjs +5 -0
  22. package/dist/contract-schema/types.d.mts +15 -0
  23. package/dist/contract-schema/types.mjs +1 -0
  24. package/dist/contract-schema/typescript-api.d.mts +6 -0
  25. package/dist/contract-schema/typescript-api.mjs +6 -0
  26. package/dist/contract-schema/validation-helpers.d.mts +12 -0
  27. package/dist/contract-schema/validation-helpers.mjs +43 -0
  28. package/dist/contract-schema/virtual-program.d.mts +7 -0
  29. package/dist/contract-schema/virtual-program.mjs +88 -0
  30. package/dist/index.d.mts +4 -0
  31. package/dist/index.mjs +2 -0
  32. package/dist/skill-discovery/target-directory.mjs +4 -2
  33. package/package.json +21 -2
  34. package/scripts/worktree/git-worktrees.sh +122 -0
  35. package/skills/agent-workflow/references/review-response.md +25 -11
  36. package/skills/manifest.json +2 -1
  37. package/skills/stacked-prs/SKILL.md +18 -0
package/README.md CHANGED
@@ -6,6 +6,9 @@ Libraries and the `vouchington` CLI.
6
6
  npm install vouchington-tooling
7
7
  # optional, only if you import vouchington-tooling/sql-ast
8
8
  npm install @libpg-query/parser
9
+ # optional, only if you import vouchington-tooling/contract-schema
10
+ # (classic compiler API; typescript@7's package root is version-only)
11
+ npm install @typescript/typescript6
9
12
  # optional, only for vouchington-tooling/agent-blackboard and agent-blackboard CLI commands
10
13
  npm install agent-blackboard@^0.5.0
11
14
  ```
@@ -144,6 +147,13 @@ Host-lock environment:
144
147
  | `HOST_LOCK_PROCESS_GROUP_DRAIN_SECONDS` | `30` | Time to wait for the command process group |
145
148
  | `HOST_LOCK_ACTIVE` | unset | Set while a lock is held; nested locks fail |
146
149
 
150
+ ## Sourceable Bash libraries
151
+
152
+ `scripts/worktree/git-worktrees.sh` is included in the published package. Source it to parse
153
+ `git worktree list --porcelain` with `git_worktree_*` helpers. Its
154
+ `git_worktree_canonical_path_hash <path>` helper resolves the physical path and prints a stable
155
+ `d` plus the first 12 lowercase hexadecimal characters of its SHA-256 digest.
156
+
147
157
  ## Library
148
158
 
149
159
  ```ts
@@ -192,6 +202,15 @@ import {
192
202
  renderSchemaMarkdown,
193
203
  } from 'vouchington-tooling/pg-schema-snapshot'
194
204
  import { buildOpenApiDocument, writeOpenApi } from 'vouchington-tooling/openapi-document'
205
+ import {
206
+ extractResponseContracts,
207
+ validateResponseContract,
208
+ } from 'vouchington-tooling/contract-schema'
209
+ import {
210
+ buildFixtureSchemaLock,
211
+ validateFixtureContracts,
212
+ writeGeneratedFiles,
213
+ } from 'vouchington-tooling/api-fixtures'
195
214
  import { decide, deriveRetryAttempt } from 'vouchington-tooling/transient-retry'
196
215
  import { parseCsvRows, streamCsvRows } from 'vouchington-tooling/csv'
197
216
  import {
@@ -0,0 +1,22 @@
1
+ import type { ContractSchema } from '../openapi-document/contract-schema-types.mts';
2
+ export type FixtureValidationCase = {
3
+ id: string;
4
+ method: string;
5
+ route: {
6
+ routeTemplate: string;
7
+ };
8
+ status: number;
9
+ body: unknown;
10
+ backendResponseContractKey: string;
11
+ };
12
+ export type FixtureValidationContract = {
13
+ method: string;
14
+ routeTemplate: string;
15
+ schema: ContractSchema;
16
+ statusCodes?: readonly number[];
17
+ };
18
+ export type ValidateFixtureContractsOptions = {
19
+ routeShape: (routeTemplate: string) => string;
20
+ statusCodesForContract: (contract: FixtureValidationContract) => number[];
21
+ };
22
+ export declare function validateFixtureContracts(fixtureCases: readonly FixtureValidationCase[], contracts: Record<string, FixtureValidationContract>, options: ValidateFixtureContractsOptions): void;
@@ -0,0 +1,36 @@
1
+ import { validateResponseContract } from '../contract-schema/contract-schema-validator.mjs';
2
+ export function validateFixtureContracts(fixtureCases, contracts, options) {
3
+ const usedKeys = new Set();
4
+ const errors = [];
5
+ for (const fixtureCase of fixtureCases) {
6
+ const key = fixtureCase.backendResponseContractKey;
7
+ const contract = contracts[key];
8
+ if (!contract) {
9
+ errors.push(`${fixtureCase.id}: response contract "${key}" was not found`);
10
+ continue;
11
+ }
12
+ usedKeys.add(key);
13
+ const fixtureOperation = `${fixtureCase.method}:${fixtureCase.route.routeTemplate}`;
14
+ const contractOperation = `${contract.method}:${contract.routeTemplate}`;
15
+ if (fixtureCase.method !== contract.method ||
16
+ options.routeShape(fixtureCase.route.routeTemplate) !==
17
+ options.routeShape(contract.routeTemplate)) {
18
+ errors.push(`${fixtureCase.id}: fixture operation ${fixtureOperation} does not match response contract "${key}" (${contractOperation})`);
19
+ continue;
20
+ }
21
+ const statuses = options.statusCodesForContract(contract);
22
+ if (!statuses.includes(fixtureCase.status)) {
23
+ errors.push(`${fixtureCase.id}: status ${fixtureCase.status} is not declared by response contract "${key}" (${contractOperation}); available statuses: ${statuses.join(', ') || 'none (status unknown)'}`);
24
+ }
25
+ for (const issue of validateResponseContract(contract.schema, fixtureCase.body)) {
26
+ errors.push(`${fixtureCase.id} ${issue.path}: ${issue.message}`);
27
+ }
28
+ }
29
+ for (const key of Object.keys(contracts)) {
30
+ if (!usedKeys.has(key))
31
+ errors.push(`Response contract "${key}" is not used by a fixture`);
32
+ }
33
+ if (errors.length > 0) {
34
+ throw new Error(['Fixture contract validation failed:', ...errors].join('\n'));
35
+ }
36
+ }
@@ -0,0 +1,3 @@
1
+ export { validateFixtureContracts, type FixtureValidationCase, type FixtureValidationContract, type ValidateFixtureContractsOptions, } from './fixture-contract-validation.mts';
2
+ export { buildFixtureSchemaLock, responseSchemaFor, type FixtureSchemaLock, type FixtureSchemaLockCase, type FixtureSchemaLockSource, } from './schema-lock.mts';
3
+ export { writeGeneratedFiles } from './write-generated-files.mts';
@@ -0,0 +1,3 @@
1
+ export { validateFixtureContracts, } from './fixture-contract-validation.mjs';
2
+ export { buildFixtureSchemaLock, responseSchemaFor, } from './schema-lock.mjs';
3
+ export { writeGeneratedFiles } from './write-generated-files.mjs';
@@ -0,0 +1,35 @@
1
+ export type FixtureSchemaLockSource = {
2
+ kind: 'generated';
3
+ generator: string;
4
+ caseRoot: string;
5
+ };
6
+ export type FixtureSchemaLock = {
7
+ version: 2;
8
+ source: FixtureSchemaLockSource;
9
+ schemas: Record<string, {
10
+ hash: string;
11
+ fixtureIds: string[];
12
+ }>;
13
+ backendResponseContracts: Record<string, {
14
+ hash: string;
15
+ fixtureIds: string[];
16
+ }>;
17
+ };
18
+ export type FixtureSchemaLockCase = {
19
+ id: string;
20
+ responseSchemaKey?: string;
21
+ body: unknown;
22
+ backendResponseContractKey: string;
23
+ };
24
+ export declare function responseSchemaFor(id: string, key: string | undefined, body: unknown, discriminatorKeys: ReadonlySet<string>): {
25
+ key: string;
26
+ hash: string;
27
+ };
28
+ export declare function buildFixtureSchemaLock({ cases, backendContracts, discriminatorKeys, source, }: {
29
+ cases: readonly FixtureSchemaLockCase[];
30
+ backendContracts: Record<string, {
31
+ hash: string;
32
+ }>;
33
+ discriminatorKeys: ReadonlySet<string>;
34
+ source: FixtureSchemaLockSource;
35
+ }): FixtureSchemaLock;
@@ -0,0 +1,125 @@
1
+ import { createHash } from 'node:crypto';
2
+ export function responseSchemaFor(id, key, body, discriminatorKeys) {
3
+ return {
4
+ key: key ?? id,
5
+ hash: createHash('sha256')
6
+ .update(stableSchemaStringify(schemaShape(body, discriminatorKeys)))
7
+ .digest('hex'),
8
+ };
9
+ }
10
+ export function buildFixtureSchemaLock({ cases, backendContracts, discriminatorKeys, source, }) {
11
+ const schemas = new Map();
12
+ const casesById = new Map(cases.map((fixtureCase) => [fixtureCase.id, fixtureCase]));
13
+ const fixtureIdsByBackendContract = new Map();
14
+ for (const fixtureCase of cases) {
15
+ const backendFixtureIds = fixtureIdsByBackendContract.get(fixtureCase.backendResponseContractKey);
16
+ if (backendFixtureIds)
17
+ backendFixtureIds.push(fixtureCase.id);
18
+ else
19
+ fixtureIdsByBackendContract.set(fixtureCase.backendResponseContractKey, [fixtureCase.id]);
20
+ const schema = responseSchemaFor(fixtureCase.id, fixtureCase.responseSchemaKey, fixtureCase.body, discriminatorKeys);
21
+ const existing = schemas.get(schema.key);
22
+ if (existing) {
23
+ if (existing.hash !== schema.hash) {
24
+ const existingCase = casesById.get(existing.fixtureIds[0]);
25
+ throw new Error([
26
+ `Fixture response schema key "${schema.key}" maps to multiple shapes.`,
27
+ `Existing fixtures: ${existing.fixtureIds.join(', ')}`,
28
+ `Mismatched fixture: ${fixtureCase.id}`,
29
+ 'Existing shape:',
30
+ stableSchemaStringify(schemaShape(existingCase.body, discriminatorKeys)),
31
+ 'Mismatched shape:',
32
+ stableSchemaStringify(schemaShape(fixtureCase.body, discriminatorKeys)),
33
+ ].join('\n'));
34
+ }
35
+ existing.fixtureIds.push(fixtureCase.id);
36
+ continue;
37
+ }
38
+ schemas.set(schema.key, { hash: schema.hash, fixtureIds: [fixtureCase.id] });
39
+ }
40
+ return {
41
+ version: 2,
42
+ source,
43
+ schemas: Object.fromEntries([...schemas.entries()].map(([key, schema]) => [
44
+ key,
45
+ { ...schema, fixtureIds: schema.fixtureIds.toSorted() },
46
+ ])),
47
+ backendResponseContracts: Object.fromEntries(Object.entries(backendContracts).map(([key, contract]) => [
48
+ key,
49
+ {
50
+ hash: contract.hash,
51
+ fixtureIds: (fixtureIdsByBackendContract.get(key) ?? []).toSorted(),
52
+ },
53
+ ])),
54
+ };
55
+ }
56
+ function schemaShape(value, discriminatorKeys, propertyKey) {
57
+ if (hasToJSON(value))
58
+ return schemaShape(value.toJSON(), discriminatorKeys, propertyKey);
59
+ if (value === null)
60
+ return { type: 'null' };
61
+ if (Array.isArray(value)) {
62
+ return {
63
+ type: 'array',
64
+ items: distinctSchemaShapes(value.map((item) => item === undefined ? { type: 'null' } : schemaShape(item, discriminatorKeys))),
65
+ };
66
+ }
67
+ if (value === undefined)
68
+ return { type: 'null' };
69
+ if (typeof value !== 'object') {
70
+ if (typeof value === 'string' && propertyKey && discriminatorKeys.has(propertyKey)) {
71
+ return { type: 'string', const: value };
72
+ }
73
+ if (typeof value === 'number')
74
+ return { type: Number.isInteger(value) ? 'integer' : 'number' };
75
+ return { type: typeof value };
76
+ }
77
+ const entries = Object.entries(value).filter(([, nested]) => nested !== undefined);
78
+ if (isIdKeyedMap(entries)) {
79
+ return {
80
+ type: 'id-map',
81
+ values: distinctSchemaShapes(entries.map(([, nested]) => schemaShape(nested, discriminatorKeys))),
82
+ };
83
+ }
84
+ return {
85
+ type: 'object',
86
+ properties: Object.fromEntries(entries
87
+ .toSorted(([left], [right]) => left.localeCompare(right))
88
+ .map(([key, nested]) => [key, schemaShape(nested, discriminatorKeys, key)])),
89
+ };
90
+ }
91
+ function stableSchemaStringify(value) {
92
+ return JSON.stringify(sortSchemaValue(value));
93
+ }
94
+ function distinctSchemaShapes(shapes) {
95
+ return Array.from(new Set(shapes.map(stableSchemaStringify)))
96
+ .toSorted()
97
+ .map((shape) => JSON.parse(shape));
98
+ }
99
+ function hasToJSON(value) {
100
+ if (value == null || typeof value !== 'object')
101
+ return false;
102
+ return typeof value.toJSON === 'function';
103
+ }
104
+ function isIdKeyedMap(entries) {
105
+ return (entries.length > 0 &&
106
+ entries.every(([key, nested]) => isFixtureIdKey(key) && isPlainObject(nested)));
107
+ }
108
+ function isFixtureIdKey(key) {
109
+ return (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(key) ||
110
+ /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/i.test(key) ||
111
+ /^[a-z][a-z0-9-]*-\d+$/i.test(key) ||
112
+ /^[a-z]\d+$/i.test(key));
113
+ }
114
+ function isPlainObject(value) {
115
+ return value != null && typeof value === 'object' && !Array.isArray(value);
116
+ }
117
+ function sortSchemaValue(value) {
118
+ if (Array.isArray(value))
119
+ return value.map(sortSchemaValue);
120
+ if (value == null || typeof value !== 'object')
121
+ return value;
122
+ return Object.fromEntries(Object.entries(value)
123
+ .toSorted(([left], [right]) => left.localeCompare(right))
124
+ .map(([key, nested]) => [key, sortSchemaValue(nested)]));
125
+ }
@@ -0,0 +1,6 @@
1
+ export declare function writeGeneratedFiles({ files, check, obsoleteDirectory, staleError, }: {
2
+ files: ReadonlyMap<string, string>;
3
+ check?: boolean;
4
+ obsoleteDirectory?: string;
5
+ staleError: (paths: string[]) => Error;
6
+ }): Promise<void>;
@@ -0,0 +1,34 @@
1
+ import { mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ export async function writeGeneratedFiles({ files, check = false, obsoleteDirectory, staleError, }) {
4
+ const obsoletePaths = obsoleteDirectory ? await obsoleteJsonPaths(obsoleteDirectory, files) : [];
5
+ const stale = [];
6
+ for (const [path, content] of files) {
7
+ if (check) {
8
+ const actual = await readFile(path, 'utf8').catch((err) => err.code === 'ENOENT' ? null : Promise.reject(err));
9
+ if (actual !== content)
10
+ stale.push(path);
11
+ continue;
12
+ }
13
+ await mkdir(dirname(path), { recursive: true });
14
+ await writeFile(path, content);
15
+ }
16
+ if (check)
17
+ stale.push(...obsoletePaths);
18
+ else
19
+ await Promise.all(obsoletePaths.map((path) => unlink(path)));
20
+ if (stale.length > 0)
21
+ throw staleError(stale);
22
+ }
23
+ async function obsoleteJsonPaths(directory, files) {
24
+ const expected = new Set([...files.keys()].filter((path) => dirname(path) === directory));
25
+ const entries = await readdir(directory, { withFileTypes: true }).catch((err) => (err.code === 'ENOENT' ? [] : Promise.reject(err)));
26
+ const obsoletePaths = [];
27
+ for (const entry of entries) {
28
+ const path = join(directory, entry.name);
29
+ if (entry.isFile() && entry.name.endsWith('.json') && !expected.has(path)) {
30
+ obsoletePaths.push(path);
31
+ }
32
+ }
33
+ return obsoletePaths;
34
+ }
@@ -0,0 +1,3 @@
1
+ import ts from './typescript-api.mts';
2
+ import type { ExtractContractSchemaOptions, ExtractedResponseContract } from './types.mts';
3
+ export declare function extractResponseContracts(program: ts.Program, sourceFile: ts.SourceFile, registryName?: string, options?: ExtractContractSchemaOptions): Record<string, ExtractedResponseContract>;
@@ -0,0 +1,25 @@
1
+ import ts from './typescript-api.mjs';
2
+ import { extractContractSchema } from './contract-schema-type-extractor.mjs';
3
+ import { compareSymbols } from './contract-schema-type-utils.mjs';
4
+ export function extractResponseContracts(program, sourceFile, registryName = 'ApiResponseContracts', options = {}) {
5
+ const checker = program.getTypeChecker();
6
+ const registry = findRegistryDeclaration(sourceFile, registryName);
7
+ const registryType = checker.getTypeAtLocation(registry);
8
+ const contracts = {};
9
+ for (const property of checker.getPropertiesOfType(registryType).toSorted(compareSymbols)) {
10
+ const declaration = property.valueDeclaration;
11
+ /* v8 ignore next */
12
+ if (!declaration)
13
+ throw new Error(`Contract "${property.name}" has no declaration`);
14
+ const propertyType = checker.getTypeOfSymbolAtLocation(property, declaration);
15
+ contracts[property.name] = extractContractSchema(propertyType, checker, sourceFile.fileName, options);
16
+ }
17
+ return contracts;
18
+ }
19
+ function findRegistryDeclaration(sourceFile, name) {
20
+ const declaration = sourceFile.statements.find((statement) => ts.isInterfaceDeclaration(statement) && statement.name.text === name);
21
+ if (!declaration || !ts.isInterfaceDeclaration(declaration)) {
22
+ throw new Error(`Response contract registry interface "${name}" was not found`);
23
+ }
24
+ return declaration;
25
+ }
@@ -0,0 +1,7 @@
1
+ import ts from './typescript-api.mts';
2
+ import type { ContractSchemaNode } from '../openapi-document/contract-schema-types.mts';
3
+ import type { ExtractionContext } from './contract-schema-type-extractor.mts';
4
+ type SchemaForType = (type: ts.Type, context: ExtractionContext) => ContractSchemaNode;
5
+ export declare function objectSchema(type: ts.Type, context: ExtractionContext, schemaForType: SchemaForType): ContractSchemaNode;
6
+ export declare function tupleSchema(type: ts.TupleType, context: ExtractionContext, schemaForType: SchemaForType): ContractSchemaNode;
7
+ export {};
@@ -0,0 +1,49 @@
1
+ import ts from './typescript-api.mjs';
2
+ import { compareSymbols } from './contract-schema-type-utils.mjs';
3
+ export function objectSchema(type, context, schemaForType) {
4
+ const properties = {};
5
+ for (const property of context.checker.getPropertiesOfType(type).toSorted(compareSymbols)) {
6
+ const declaration = property.valueDeclaration;
7
+ /* v8 ignore start */
8
+ const propertyType = declaration
9
+ ? context.checker.getTypeOfSymbolAtLocation(property, declaration)
10
+ : typeOfSyntheticProperty(property, context.checker);
11
+ if (!propertyType)
12
+ throw new Error(`Property "${property.name}" has no type`);
13
+ /* v8 ignore stop */
14
+ try {
15
+ properties[property.name] = {
16
+ required: !(property.flags & ts.SymbolFlags.Optional),
17
+ schema: schemaForType(propertyType, context),
18
+ };
19
+ }
20
+ catch (error) {
21
+ throw new Error(`Property "${property.name}": ${error.message}`, { cause: error });
22
+ }
23
+ }
24
+ const stringIndex = context.checker.getIndexInfoOfType(type, ts.IndexKind.String);
25
+ return {
26
+ type: 'object',
27
+ properties,
28
+ additionalProperties: stringIndex ? schemaForType(stringIndex.type, context) : false,
29
+ };
30
+ }
31
+ /* v8 ignore start -- checker internal used only for compiler-synthesized properties */
32
+ function typeOfSyntheticProperty(property, checker) {
33
+ return checker.getTypeOfSymbol?.(property);
34
+ }
35
+ /* v8 ignore stop */
36
+ export function tupleSchema(type, context, schemaForType) {
37
+ const items = context.checker.getTypeArguments(type);
38
+ const flags = type.target.elementFlags;
39
+ const restIndex = flags.findIndex((flag) => Boolean(flag & ts.ElementFlags.Variable));
40
+ const fixedItems = restIndex === -1 ? items : items.slice(0, restIndex);
41
+ return {
42
+ type: 'tuple',
43
+ items: fixedItems.map((item) => schemaForType(item, context)),
44
+ optionalItems: flags
45
+ .slice(0, fixedItems.length)
46
+ .filter((flag) => Boolean(flag & ts.ElementFlags.Optional)).length,
47
+ ...(restIndex === -1 ? {} : { rest: schemaForType(items[restIndex], context) }),
48
+ };
49
+ }
@@ -0,0 +1,11 @@
1
+ import ts from './typescript-api.mts';
2
+ import type { ContractSchemaNode } from '../openapi-document/contract-schema-types.mts';
3
+ import type { ExtractContractSchemaOptions, ExtractedResponseContract } from './types.mts';
4
+ export type ExtractionContext = {
5
+ checker: ts.TypeChecker;
6
+ definitions: Map<string, ContractSchemaNode>;
7
+ definitionTypes: Map<string, ts.Type>;
8
+ activeTypes: Map<ts.Type, string>;
9
+ options: ExtractContractSchemaOptions;
10
+ };
11
+ export declare function extractContractSchema(type: ts.Type, checker: ts.TypeChecker, source: string, options?: ExtractContractSchemaOptions): ExtractedResponseContract;
@@ -0,0 +1,170 @@
1
+ import ts from './typescript-api.mjs';
2
+ import { hashContractSchema } from '../openapi-document/contract-schema-canonical.mjs';
3
+ import { objectSchema, tupleSchema } from './contract-schema-object-tuple.mjs';
4
+ import { assertNotClass, distinctNodes, jsonPromiseType, jsonSerializedType, namedObjectDefinition, unsupportedType, } from './contract-schema-type-utils.mjs';
5
+ export function extractContractSchema(type, checker, source, options = {}) {
6
+ const definitions = new Map();
7
+ const context = {
8
+ checker,
9
+ definitions,
10
+ definitionTypes: new Map(),
11
+ activeTypes: new Map(),
12
+ options,
13
+ };
14
+ let root;
15
+ try {
16
+ root = schemaForType(type, context);
17
+ }
18
+ catch (error) {
19
+ /* v8 ignore next -- schemaForType throws Error */
20
+ if (!(error instanceof Error))
21
+ throw error;
22
+ throw new Error(`${source}: ${error.message}`, { cause: error });
23
+ }
24
+ const schema = {
25
+ root,
26
+ definitions: Object.fromEntries([...definitions.entries()].toSorted(([a], [b]) => a.localeCompare(b))),
27
+ };
28
+ return { source, schema, hash: hashContractSchema(schema) };
29
+ }
30
+ function schemaForType(type, context) {
31
+ const { checker, options } = context;
32
+ if (type.flags & ts.TypeFlags.Any)
33
+ throw unsupportedType(type, checker, 'any is not allowed');
34
+ if (type.flags & ts.TypeFlags.Unknown)
35
+ return { type: 'unknown' };
36
+ const formatAlias = type.aliasSymbol ? options.formatAliases?.[type.aliasSymbol.name] : undefined;
37
+ if (formatAlias)
38
+ return { type: 'string', format: formatAlias };
39
+ if (type.flags & ts.TypeFlags.Never)
40
+ throw unsupportedType(type, checker, 'never is not supported');
41
+ if (type.flags & ts.TypeFlags.Null)
42
+ return { type: 'null' };
43
+ if (type.flags & ts.TypeFlags.BooleanLiteral) {
44
+ return {
45
+ type: 'literal',
46
+ value: type.intrinsicName === 'true',
47
+ };
48
+ }
49
+ if (type.flags & ts.TypeFlags.StringLiteral) {
50
+ return { type: 'literal', value: type.value };
51
+ }
52
+ if (type.flags & ts.TypeFlags.NumberLiteral) {
53
+ return { type: 'literal', value: type.value };
54
+ }
55
+ if (type.flags & ts.TypeFlags.StringLike)
56
+ return { type: 'string' };
57
+ if (type.flags & ts.TypeFlags.NumberLike)
58
+ return { type: 'number' };
59
+ if (type.flags & ts.TypeFlags.BooleanLike)
60
+ return { type: 'boolean' };
61
+ if (type.isUnion()) {
62
+ const variants = type.types.filter((variant) => !(variant.flags & ts.TypeFlags.Undefined));
63
+ /* v8 ignore next */
64
+ if (variants.length === 0)
65
+ throw unsupportedType(type, checker, 'undefined-only types are not supported');
66
+ if (variants.length === 1)
67
+ return schemaForType(variants[0], context);
68
+ if (variants.length === 2 &&
69
+ variants.every((variant) => variant.flags & ts.TypeFlags.BooleanLiteral)) {
70
+ return { type: 'boolean' };
71
+ }
72
+ const buildUnion = () => ({
73
+ type: 'union',
74
+ variants: distinctNodes(variants.map((variant) => schemaForType(variant, context))),
75
+ });
76
+ const definitionName = type.aliasSymbol ? namedObjectDefinition(type, checker) : undefined;
77
+ if (definitionName)
78
+ return schemaForNamedType(type, definitionName, context, buildUnion);
79
+ return buildUnion();
80
+ }
81
+ if (type.isIntersection()) {
82
+ return {
83
+ type: 'intersection',
84
+ variants: distinctNodes(type.types.map((variant) => schemaForType(variant, context))),
85
+ };
86
+ }
87
+ /* v8 ignore next */
88
+ if (!(type.flags & ts.TypeFlags.Object))
89
+ throw unsupportedType(type, checker, 'unsupported type');
90
+ const serializedType = jsonSerializedType(type, checker);
91
+ if (serializedType)
92
+ return schemaForType(serializedType, context);
93
+ const promisedType = jsonPromiseType(type, checker);
94
+ if (promisedType)
95
+ return schemaForType(promisedType, context);
96
+ if (checker.isTupleType(type))
97
+ return tupleSchema(type, context, schemaForType);
98
+ const constrainedArray = boundedArraySchema(type, context);
99
+ if (constrainedArray)
100
+ return constrainedArray;
101
+ if (checker.isArrayType(type) || checker.isArrayLikeType(type)) {
102
+ const typeArguments = checker.getTypeArguments(type);
103
+ /* v8 ignore next */
104
+ if (!typeArguments[0])
105
+ throw unsupportedType(type, checker, 'array element type is missing');
106
+ return { type: 'array', items: schemaForType(typeArguments[0], context) };
107
+ }
108
+ if (checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0) {
109
+ throw unsupportedType(type, checker, 'callable types are not supported');
110
+ }
111
+ if (checker.getSignaturesOfType(type, ts.SignatureKind.Construct).length > 0) {
112
+ throw unsupportedType(type, checker, 'constructable types are not supported');
113
+ }
114
+ assertNotClass(type, checker);
115
+ const definitionName = namedObjectDefinition(type, checker);
116
+ if (definitionName) {
117
+ return schemaForNamedType(type, definitionName, context, () => objectSchema(type, context, schemaForType));
118
+ }
119
+ return objectSchema(type, context, schemaForType);
120
+ }
121
+ function boundedArraySchema(type, context) {
122
+ const alias = context.options.boundedArrayAlias;
123
+ if (!alias || type.aliasSymbol?.name !== alias)
124
+ return undefined;
125
+ const [itemType, minItemsType, maxItemsType, uniqueItemsType] = type.aliasTypeArguments ?? [];
126
+ if (!itemType || !minItemsType || !maxItemsType || !uniqueItemsType) {
127
+ throw unsupportedType(type, context.checker, `${alias} requires four type arguments`);
128
+ }
129
+ const minItems = numberLiteralValue(minItemsType, type, context.checker, alias);
130
+ const maxItems = numberLiteralValue(maxItemsType, type, context.checker, alias);
131
+ if (minItems < 0 || maxItems < minItems) {
132
+ throw unsupportedType(type, context.checker, `${alias} has invalid item bounds`);
133
+ }
134
+ if (!(uniqueItemsType.flags & ts.TypeFlags.BooleanLiteral)) {
135
+ throw unsupportedType(type, context.checker, `${alias} uniqueness must be literal`);
136
+ }
137
+ return {
138
+ type: 'array',
139
+ items: schemaForType(itemType, context),
140
+ minItems,
141
+ maxItems,
142
+ uniqueItems: uniqueItemsType.intrinsicName === 'true',
143
+ };
144
+ }
145
+ function numberLiteralValue(type, owner, checker, alias) {
146
+ if (!(type.flags & ts.TypeFlags.NumberLiteral)) {
147
+ throw unsupportedType(owner, checker, `${alias} bounds must be numeric literals`);
148
+ }
149
+ return type.value;
150
+ }
151
+ function schemaForNamedType(type, name, context, build) {
152
+ const existingType = context.definitionTypes.get(name);
153
+ /* v8 ignore start -- distinct checker types sharing a definition name */
154
+ if (existingType && existingType !== type) {
155
+ const flags = ts.TypeFormatFlags.NoTruncation;
156
+ const existingIdentity = context.checker.typeToString(existingType, undefined, flags);
157
+ const incomingIdentity = context.checker.typeToString(type, undefined, flags);
158
+ if (existingIdentity === incomingIdentity)
159
+ return { type: 'ref', name };
160
+ throw new Error(`Response contract schema definition name collision: "${name}"`);
161
+ }
162
+ /* v8 ignore stop */
163
+ if (context.definitions.has(name) || context.activeTypes.has(type))
164
+ return { type: 'ref', name };
165
+ context.definitionTypes.set(name, type);
166
+ context.activeTypes.set(type, name);
167
+ context.definitions.set(name, build());
168
+ context.activeTypes.delete(type);
169
+ return { type: 'ref', name };
170
+ }
@@ -0,0 +1,9 @@
1
+ import ts from './typescript-api.mts';
2
+ import type { ContractSchemaNode } from '../openapi-document/contract-schema-types.mts';
3
+ export declare function namedObjectDefinition(type: ts.Type, checker: ts.TypeChecker): string | undefined;
4
+ export declare function distinctNodes(nodes: ContractSchemaNode[]): ContractSchemaNode[];
5
+ export declare function assertNotClass(type: ts.Type, checker: ts.TypeChecker): void;
6
+ export declare function jsonPromiseType(type: ts.Type, checker: ts.TypeChecker): ts.Type | undefined;
7
+ export declare function jsonSerializedType(type: ts.Type, checker: ts.TypeChecker): ts.Type | undefined;
8
+ export declare function unsupportedType(type: ts.Type, checker: ts.TypeChecker, reason: string): Error;
9
+ export declare function compareSymbols(left: ts.Symbol, right: ts.Symbol): number;