vouchington-tooling 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +12 -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/package.json +21 -2
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
  ```
@@ -192,6 +195,15 @@ import {
192
195
  renderSchemaMarkdown,
193
196
  } from 'vouchington-tooling/pg-schema-snapshot'
194
197
  import { buildOpenApiDocument, writeOpenApi } from 'vouchington-tooling/openapi-document'
198
+ import {
199
+ extractResponseContracts,
200
+ validateResponseContract,
201
+ } from 'vouchington-tooling/contract-schema'
202
+ import {
203
+ buildFixtureSchemaLock,
204
+ validateFixtureContracts,
205
+ writeGeneratedFiles,
206
+ } from 'vouchington-tooling/api-fixtures'
195
207
  import { decide, deriveRetryAttempt } from 'vouchington-tooling/transient-retry'
196
208
  import { parseCsvRows, streamCsvRows } from 'vouchington-tooling/csv'
197
209
  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;
@@ -0,0 +1,107 @@
1
+ import ts from './typescript-api.mjs';
2
+ import { canonicalContractSchemaNode } from '../openapi-document/contract-schema-canonical.mjs';
3
+ export function namedObjectDefinition(type, checker) {
4
+ const symbol = type.aliasSymbol ?? type.getSymbol();
5
+ if (!symbol || symbol.name === '__type' || symbol.name === '__object')
6
+ return undefined;
7
+ if (!type.aliasSymbol && !isDeclaredTypeSymbol(symbol))
8
+ return undefined;
9
+ const typeArguments = type.aliasTypeArguments ?? type.typeArguments;
10
+ if (!typeArguments?.length)
11
+ return symbol.name;
12
+ return `${symbol.name}<${typeArguments.map((argument) => typeIdentity(argument, checker)).join(',')}>`;
13
+ }
14
+ /**
15
+ * A plain (non-alias) symbol only identifies a genuine named type when it comes from an actual
16
+ * type-level declaration (interface/class/enum). The checker also attaches a destructuring
17
+ * variable's own symbol to the anonymous object type it synthesizes for a rest-binding pattern
18
+ * (`const { a, ...rest } = x`) — e.g. `rows.map(({ a, ...row }) => row)` names its per-call-site
19
+ * anonymous shape "row" purely from the local variable, not a real type. Treating that as a
20
+ * definition name falsely unifies (or falsely collides) unrelated shapes across call sites that
21
+ * happen to reuse the same rest-variable name.
22
+ */
23
+ function isDeclaredTypeSymbol(symbol) {
24
+ return Boolean(symbol.declarations?.some((declaration) => ts.isInterfaceDeclaration(declaration) ||
25
+ ts.isClassDeclaration(declaration) ||
26
+ ts.isEnumDeclaration(declaration)));
27
+ }
28
+ /**
29
+ * Builds a name-suffix identity for one generic type argument. Recurses into the argument's own
30
+ * type arguments (e.g. `Array<Widget>`) so two different generics instantiated with the same outer
31
+ * shape but different nested element types don't collapse onto the same suffix. Anonymous element
32
+ * types (inline object literals, which have no symbol) fall back to a full structural stringify —
33
+ * without it, `Record<string, Array<{a}>>` and `Record<string, Array<{b}>>` would both identify as
34
+ * bare `Array` and sanitize to the same component name despite differing shapes.
35
+ *
36
+ * Literal type arguments (e.g. the `'topic'` in `PaginatedResult<'topic'>`) must keep their literal
37
+ * value rather than collapsing to the base primitive name — otherwise `PaginatedResult<'topic'>`
38
+ * and `PaginatedResult<'notification'>` both identify as `PaginatedResult<string>` and collide.
39
+ */
40
+ function typeIdentity(type, checker) {
41
+ if (type.flags & ts.TypeFlags.StringLiteral)
42
+ return String(type.value);
43
+ if (type.flags & ts.TypeFlags.NumberLiteral)
44
+ return String(type.value);
45
+ if (type.flags & ts.TypeFlags.BooleanLiteral) {
46
+ return String(type.intrinsicName === 'true');
47
+ }
48
+ if (type.flags & ts.TypeFlags.StringLike)
49
+ return 'string';
50
+ if (type.flags & ts.TypeFlags.NumberLike)
51
+ return 'number';
52
+ if (type.flags & ts.TypeFlags.BooleanLike)
53
+ return 'boolean';
54
+ if (type.flags & ts.TypeFlags.Null)
55
+ return 'null';
56
+ const symbol = type.aliasSymbol ?? type.getSymbol();
57
+ if (symbol && symbol.name !== '__type' && symbol.name !== '__object') {
58
+ const typeArguments = type.aliasTypeArguments ?? type.typeArguments;
59
+ return typeArguments?.length
60
+ ? `${symbol.name}<${typeArguments.map((argument) => typeIdentity(argument, checker)).join(',')}>`
61
+ : symbol.name;
62
+ }
63
+ return checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation);
64
+ }
65
+ export function distinctNodes(nodes) {
66
+ const nodesByCanonicalSchema = new Map();
67
+ for (const node of nodes) {
68
+ const canonicalSchema = canonicalContractSchemaNode(node);
69
+ if (!nodesByCanonicalSchema.has(canonicalSchema)) {
70
+ nodesByCanonicalSchema.set(canonicalSchema, node);
71
+ }
72
+ }
73
+ return [...nodesByCanonicalSchema]
74
+ .toSorted(([left], [right]) => left.localeCompare(right))
75
+ .map(([, node]) => node);
76
+ }
77
+ export function assertNotClass(type, checker) {
78
+ const symbol = type.getSymbol();
79
+ if (symbol?.declarations?.some(ts.isClassDeclaration)) {
80
+ throw unsupportedType(type, checker, 'class instances are not supported');
81
+ }
82
+ }
83
+ export function jsonPromiseType(type, checker) {
84
+ const symbol = type.aliasSymbol ?? type.getSymbol();
85
+ if (symbol?.name !== 'Promise' && symbol?.name !== 'PromiseLike')
86
+ return undefined;
87
+ return checker.getTypeArguments(type)[0];
88
+ }
89
+ export function jsonSerializedType(type, checker) {
90
+ const toJSON = checker.getPropertyOfType(type, 'toJSON');
91
+ if (!toJSON)
92
+ return undefined;
93
+ const declaration = toJSON.valueDeclaration;
94
+ /* v8 ignore next */
95
+ if (!declaration)
96
+ return undefined;
97
+ const toJSONType = checker.getTypeOfSymbolAtLocation(toJSON, declaration);
98
+ const signature = checker.getSignaturesOfType(toJSONType, ts.SignatureKind.Call)[0];
99
+ /* v8 ignore next */
100
+ return signature ? checker.getReturnTypeOfSignature(signature) : undefined;
101
+ }
102
+ export function unsupportedType(type, checker, reason) {
103
+ return new Error(`Unsupported response contract type "${checker.typeToString(type)}": ${reason}`);
104
+ }
105
+ export function compareSymbols(left, right) {
106
+ return left.name.localeCompare(right.name);
107
+ }
@@ -0,0 +1,3 @@
1
+ import type { ContractSchema } from '../openapi-document/contract-schema-types.mts';
2
+ import type { ContractValidationIssue } from './types.mts';
3
+ export declare function validateResponseContract(schema: ContractSchema, value: unknown): ContractValidationIssue[];
@@ -0,0 +1,126 @@
1
+ import { addIssue, addUnexpectedIssue, collectObjectShape, isObject, propertyPath, requireType, } from './validation-helpers.mjs';
2
+ export function validateResponseContract(schema, value) {
3
+ const context = { definitions: schema.definitions, issues: [] };
4
+ validateNode(schema.root, value, '$', context, true);
5
+ return context.issues;
6
+ }
7
+ function validateNode(node, value, path, context, checkUnexpected) {
8
+ switch (node.type) {
9
+ case 'unknown':
10
+ return;
11
+ case 'null':
12
+ return requireType(value === null, 'null', value, path, context);
13
+ case 'boolean':
14
+ return requireType(typeof value === 'boolean', 'boolean', value, path, context);
15
+ case 'number':
16
+ return requireType(typeof value === 'number' && Number.isFinite(value), 'number', value, path, context);
17
+ case 'string':
18
+ return requireType(typeof value === 'string', 'string', value, path, context);
19
+ case 'literal':
20
+ return requireType(Object.is(value, node.value), JSON.stringify(node.value), value, path, context);
21
+ case 'array':
22
+ return validateArray(node.items, value, path, context);
23
+ case 'tuple':
24
+ return validateTuple(node, value, path, context);
25
+ case 'object':
26
+ return validateObject(node, value, path, context, checkUnexpected);
27
+ case 'union':
28
+ return validateUnion(node.variants, value, path, context);
29
+ case 'intersection':
30
+ return validateIntersection(node.variants, value, path, context);
31
+ case 'ref': {
32
+ const definition = context.definitions[node.name];
33
+ if (!definition)
34
+ throw new Error(`Unknown response contract schema reference "${node.name}"`);
35
+ return validateNode(definition, value, path, context, checkUnexpected);
36
+ }
37
+ }
38
+ }
39
+ function validateArray(itemSchema, value, path, context) {
40
+ if (!Array.isArray(value))
41
+ return requireType(false, 'array', value, path, context);
42
+ for (const item of value)
43
+ validateNode(itemSchema, item, `${path}[*]`, context, true);
44
+ }
45
+ function validateTuple(node, value, path, context) {
46
+ if (!Array.isArray(value))
47
+ return requireType(false, 'tuple', value, path, context);
48
+ const requiredLength = node.items.length - node.optionalItems;
49
+ if (value.length < requiredLength) {
50
+ addIssue(context, path, 'type', `Expected at least ${requiredLength} tuple items, received ${value.length}`);
51
+ }
52
+ if (!node.rest && value.length > node.items.length) {
53
+ addIssue(context, path, 'type', `Expected at most ${node.items.length} tuple items, received ${value.length}`);
54
+ }
55
+ node.items.forEach((item, index) => {
56
+ if (index < value.length)
57
+ validateNode(item, value[index], `${path}[${index}]`, context, true);
58
+ });
59
+ if (node.rest) {
60
+ for (const item of value.slice(node.items.length)) {
61
+ validateNode(node.rest, item, `${path}[*]`, context, true);
62
+ }
63
+ }
64
+ }
65
+ function validateObject(node, value, path, context, checkUnexpected) {
66
+ if (!isObject(value))
67
+ return requireType(false, 'object', value, path, context);
68
+ for (const [key, property] of Object.entries(node.properties)) {
69
+ if (!(key in value)) {
70
+ if (property.required) {
71
+ addIssue(context, propertyPath(path, key), 'missing-required', 'Required field is missing');
72
+ }
73
+ continue;
74
+ }
75
+ validateNode(property.schema, value[key], propertyPath(path, key), context, true);
76
+ }
77
+ if (!checkUnexpected)
78
+ return;
79
+ for (const [key, nested] of Object.entries(value)) {
80
+ if (key in node.properties)
81
+ continue;
82
+ if (node.additionalProperties === false) {
83
+ addUnexpectedIssue(context, propertyPath(path, key));
84
+ continue;
85
+ }
86
+ validateNode(node.additionalProperties, nested, `${path}{*}`, context, true);
87
+ }
88
+ }
89
+ function validateUnion(variants, value, path, context) {
90
+ const attempts = variants.map((variant) => validateIsolated(variant, value, path, context.definitions));
91
+ if (attempts.some((issues) => issues.length === 0))
92
+ return;
93
+ const closest = attempts.toSorted((left, right) => left.length - right.length)[0];
94
+ /* v8 ignore next -- unions always have at least one variant */
95
+ if (closest)
96
+ context.issues.push(...closest);
97
+ }
98
+ function validateIntersection(variants, value, path, context) {
99
+ for (const variant of variants)
100
+ validateNode(variant, value, path, context, false);
101
+ if (!isObject(value))
102
+ return;
103
+ const allowedProperties = new Set();
104
+ let additionalSchema;
105
+ const addAdditionalSchema = (schema) => {
106
+ additionalSchema ??= schema;
107
+ };
108
+ for (const variant of variants) {
109
+ collectObjectShape(variant, context.definitions, allowedProperties, addAdditionalSchema);
110
+ }
111
+ for (const [key, nested] of Object.entries(value)) {
112
+ if (allowedProperties.has(key))
113
+ continue;
114
+ if (additionalSchema) {
115
+ validateNode(additionalSchema, nested, `${path}{*}`, context, true);
116
+ }
117
+ else {
118
+ addUnexpectedIssue(context, propertyPath(path, key));
119
+ }
120
+ }
121
+ }
122
+ function validateIsolated(node, value, path, definitions) {
123
+ const isolated = { definitions, issues: [] };
124
+ validateNode(node, value, path, isolated, true);
125
+ return isolated.issues;
126
+ }
@@ -0,0 +1,9 @@
1
+ export { canonicalContractSchema, canonicalContractSchemaNode, hashContractSchema, } from '../openapi-document/contract-schema-canonical.mts';
2
+ export type { ContractSchema, ContractSchemaNode, ContractSchemaProperty, } from '../openapi-document/contract-schema-types.mts';
3
+ export { extractResponseContracts } from './contract-schema-extractor.mts';
4
+ export { extractContractSchema } from './contract-schema-type-extractor.mts';
5
+ export type { ExtractionContext } from './contract-schema-type-extractor.mts';
6
+ export { validateResponseContract } from './contract-schema-validator.mts';
7
+ export type { ContractValidationIssue, ExtractContractSchemaOptions, ExtractedResponseContract, } from './types.mts';
8
+ export { buildVirtualProgramMatrix, virtualProgramBuildCountForTest } from './virtual-program.mts';
9
+ export type { VirtualProgramMatrix } from './virtual-program.mts';
@@ -0,0 +1,5 @@
1
+ export { canonicalContractSchema, canonicalContractSchemaNode, hashContractSchema, } from '../openapi-document/contract-schema-canonical.mjs';
2
+ export { extractResponseContracts } from './contract-schema-extractor.mjs';
3
+ export { extractContractSchema } from './contract-schema-type-extractor.mjs';
4
+ export { validateResponseContract } from './contract-schema-validator.mjs';
5
+ export { buildVirtualProgramMatrix, virtualProgramBuildCountForTest } from './virtual-program.mjs';
@@ -0,0 +1,15 @@
1
+ import type { ContractSchema } from '../openapi-document/contract-schema-types.mts';
2
+ export type ExtractedResponseContract = {
3
+ source: string;
4
+ schema: ContractSchema;
5
+ hash: string;
6
+ };
7
+ export type ContractValidationIssue = {
8
+ path: string;
9
+ kind: 'missing-required' | 'unexpected' | 'type';
10
+ message: string;
11
+ };
12
+ export type ExtractContractSchemaOptions = {
13
+ formatAliases?: Readonly<Record<string, 'uuid'>>;
14
+ boundedArrayAlias?: string;
15
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Classic TypeScript compiler API. typescript@7's package root is version-only;
3
+ * @typescript/typescript6 (or a typescript@5/@6 install aliased as "typescript")
4
+ * provides createProgram/type checker used by contract extraction.
5
+ */
6
+ export { default } from '@typescript/typescript6';
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Classic TypeScript compiler API. typescript@7's package root is version-only;
3
+ * @typescript/typescript6 (or a typescript@5/@6 install aliased as "typescript")
4
+ * provides createProgram/type checker used by contract extraction.
5
+ */
6
+ export { default } from '@typescript/typescript6';
@@ -0,0 +1,12 @@
1
+ import type { ContractSchemaNode } from '../openapi-document/contract-schema-types.mts';
2
+ import type { ContractValidationIssue } from './types.mts';
3
+ export type ValidationContext = {
4
+ definitions: Record<string, ContractSchemaNode>;
5
+ issues: ContractValidationIssue[];
6
+ };
7
+ export declare function requireType(valid: boolean, expected: string, value: unknown, path: string, context: ValidationContext): void;
8
+ export declare function addIssue(context: ValidationContext, path: string, kind: ContractValidationIssue['kind'], message: string): void;
9
+ export declare function addUnexpectedIssue(context: ValidationContext, path: string): void;
10
+ export declare function propertyPath(path: string, property: string): string;
11
+ export declare function isObject(value: unknown): value is Record<string, unknown>;
12
+ export declare function collectObjectShape(node: ContractSchemaNode, definitions: Record<string, ContractSchemaNode>, properties: Set<string>, addAdditional: (schema: ContractSchemaNode) => void): void;
@@ -0,0 +1,43 @@
1
+ export function requireType(valid, expected, value, path, context) {
2
+ if (valid)
3
+ return;
4
+ addIssue(context, path, 'type', `Expected ${expected}, received ${describeValue(value)}`);
5
+ }
6
+ export function addIssue(context, path, kind, message) {
7
+ context.issues.push({ path, kind, message });
8
+ }
9
+ export function addUnexpectedIssue(context, path) {
10
+ addIssue(context, path, 'unexpected', 'Field is not declared by the contract');
11
+ }
12
+ export function propertyPath(path, property) {
13
+ if (/^[A-Za-z_$][\w$]*$/.test(property))
14
+ return `${path}.${property}`;
15
+ return `${path}[${JSON.stringify(property)}]`;
16
+ }
17
+ function describeValue(value) {
18
+ if (value === null)
19
+ return 'null';
20
+ if (Array.isArray(value))
21
+ return 'array';
22
+ return typeof value;
23
+ }
24
+ export function isObject(value) {
25
+ return value != null && typeof value === 'object' && !Array.isArray(value);
26
+ }
27
+ export function collectObjectShape(node, definitions, properties, addAdditional) {
28
+ if (node.type === 'ref') {
29
+ collectObjectShape(definitions[node.name], definitions, properties, addAdditional);
30
+ return;
31
+ }
32
+ if (node.type === 'intersection') {
33
+ for (const variant of node.variants) {
34
+ collectObjectShape(variant, definitions, properties, addAdditional);
35
+ }
36
+ return;
37
+ }
38
+ if (node.type !== 'object')
39
+ return;
40
+ Object.keys(node.properties).forEach((key) => properties.add(key));
41
+ if (node.additionalProperties !== false)
42
+ addAdditional(node.additionalProperties);
43
+ }
@@ -0,0 +1,7 @@
1
+ import ts from './typescript-api.mts';
2
+ export interface VirtualProgramMatrix<Id extends string> {
3
+ readonly program: ts.Program;
4
+ sourceFile(id: Id): ts.SourceFile;
5
+ }
6
+ export declare function buildVirtualProgramMatrix<const Id extends string>(ownerExecution: ImportMeta, sources: Readonly<Record<Id, string>>): VirtualProgramMatrix<Id>;
7
+ export declare function virtualProgramBuildCountForTest(): number;
@@ -0,0 +1,88 @@
1
+ import ts from './typescript-api.mjs';
2
+ const virtualCompilerSourceFiles = new Map();
3
+ const virtualProgramMatrixExecutions = new WeakSet();
4
+ const virtualSourceIdPattern = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
5
+ let virtualProgramBuildCount = 0;
6
+ export function buildVirtualProgramMatrix(ownerExecution, sources) {
7
+ const owner = normalizeOwnerFileUrl(ownerExecution.url);
8
+ const entries = Object.entries(sources);
9
+ if (entries.length === 0)
10
+ throw new Error('A virtual program matrix requires at least one source');
11
+ const sourcesByFileName = new Map();
12
+ const fileNameById = new Map();
13
+ for (const [id, sourceText] of entries) {
14
+ if (!virtualSourceIdPattern.test(id)) {
15
+ throw new Error(`Virtual source ID "${id}" must contain only lowercase letters, digits, and hyphens`);
16
+ }
17
+ const fileName = `/virtual/${id}.ts`;
18
+ sourcesByFileName.set(fileName, `${sourceText}\nexport {}\n`);
19
+ fileNameById.set(id, fileName);
20
+ }
21
+ const options = {
22
+ module: ts.ModuleKind.ESNext,
23
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
24
+ skipLibCheck: true,
25
+ strict: true,
26
+ target: ts.ScriptTarget.ESNext,
27
+ };
28
+ const host = ts.createCompilerHost(options, true);
29
+ const originalGetSourceFile = host.getSourceFile.bind(host);
30
+ host.getSourceFile = (requested, languageVersion, onError, shouldCreateNewSourceFile) => {
31
+ const virtualSource = sourcesByFileName.get(requested);
32
+ if (virtualSource !== undefined) {
33
+ return ts.createSourceFile(requested, virtualSource, languageVersion, true, ts.ScriptKind.TS);
34
+ }
35
+ const cached = virtualCompilerSourceFiles.get(requested);
36
+ if (cached)
37
+ return cached;
38
+ const sourceFile = originalGetSourceFile(requested, languageVersion, onError, shouldCreateNewSourceFile);
39
+ /* v8 ignore next -- host lookup of a missing lib file */
40
+ if (sourceFile)
41
+ virtualCompilerSourceFiles.set(requested, sourceFile);
42
+ return sourceFile;
43
+ };
44
+ host.fileExists = (requested) => sourcesByFileName.has(requested) || ts.sys.fileExists(requested);
45
+ host.readFile = (requested) => sourcesByFileName.get(requested) ?? ts.sys.readFile(requested);
46
+ if (virtualProgramMatrixExecutions.has(ownerExecution)) {
47
+ throw new Error(`Virtual program matrix for "${owner}" was already built; each contract test file execution may build exactly one`);
48
+ }
49
+ const program = ts.createProgram([...sourcesByFileName.keys()], options, host);
50
+ virtualProgramMatrixExecutions.add(ownerExecution);
51
+ virtualProgramBuildCount += 1;
52
+ return {
53
+ program,
54
+ sourceFile(id) {
55
+ const fileName = fileNameById.get(id);
56
+ if (!fileName)
57
+ throw new Error(`Unknown virtual source ID "${id}"`);
58
+ const sourceFile = program.getSourceFile(fileName);
59
+ /* v8 ignore next -- createProgram always materializes matrix source files */
60
+ if (!sourceFile)
61
+ throw new Error(`Unable to load ${fileName}`);
62
+ const diagnostics = [
63
+ ...program.getSyntacticDiagnostics(sourceFile),
64
+ ...program.getSemanticDiagnostics(sourceFile),
65
+ ];
66
+ if (diagnostics.length > 0)
67
+ throw new Error(formatDiagnostics(diagnostics));
68
+ return sourceFile;
69
+ },
70
+ };
71
+ }
72
+ export function virtualProgramBuildCountForTest() {
73
+ return virtualProgramBuildCount;
74
+ }
75
+ function normalizeOwnerFileUrl(ownerFileUrl) {
76
+ const owner = new URL(ownerFileUrl);
77
+ if (owner.protocol !== 'file:') {
78
+ throw new Error(`Virtual program matrix owner must be a file URL, received "${ownerFileUrl}"`);
79
+ }
80
+ return owner.href;
81
+ }
82
+ function formatDiagnostics(diagnostics) {
83
+ return ts.formatDiagnosticsWithColorAndContext(diagnostics, {
84
+ getCanonicalFileName: (fileName) => fileName,
85
+ getCurrentDirectory: () => '/',
86
+ getNewLine: () => '\n',
87
+ });
88
+ }
package/dist/index.d.mts CHANGED
@@ -51,6 +51,10 @@ export { buildSchemaSnapshot, detectRenamedIndexes, generateSchemaSnapshot, inde
51
51
  export type { CatalogQuery, PartitionPolicy, SchemaCatalog, SchemaGrowthMaps, SchemaSnapshot, SchemaTableSnapshot, } from './pg-schema-snapshot/index.mts';
52
52
  export { buildOpenApiDocument, hashContractSchema, nodeToOpenApi, writeOpenApi, } from './openapi-document/index.mts';
53
53
  export type { BuildOpenApiDocumentInput, ContractSchema, OpenApiDocument, RequestContract, ResponseContract, } from './openapi-document/index.mts';
54
+ export { buildVirtualProgramMatrix, extractContractSchema, extractResponseContracts, validateResponseContract, virtualProgramBuildCountForTest, } from './contract-schema/index.mts';
55
+ export type { ContractValidationIssue, ExtractContractSchemaOptions, ExtractedResponseContract, VirtualProgramMatrix, } from './contract-schema/index.mts';
56
+ export { buildFixtureSchemaLock, responseSchemaFor, validateFixtureContracts, writeGeneratedFiles, } from './api-fixtures/index.mts';
57
+ export type { FixtureSchemaLock, FixtureSchemaLockCase, FixtureSchemaLockSource, FixtureValidationCase, FixtureValidationContract, ValidateFixtureContractsOptions, } from './api-fixtures/index.mts';
54
58
  export { decide, deriveRetryAttempt } from './transient-retry/index.mts';
55
59
  export type { DecisionResult, EvaluateRulesOptions, NoMatchReason, RetryContext, RetryDecision, RetryRule, RetryTarget, } from './transient-retry/index.mts';
56
60
  export { escapeSpreadsheetFormula, parseCsvRows, streamCsvRows, stripCsvBom } from './csv/index.mts';
package/dist/index.mjs CHANGED
@@ -27,6 +27,8 @@ export { boundPendingLine, DEFAULT_MAX_PENDING_LINE_LENGTH, DEFAULT_TRUNCATED_LI
27
27
  export { isProcessGroupAlive, ProcessGroupDrainTimeoutError, runBrowserSession, waitForProcessGroupExit, } from './browser-session-runner/index.mjs';
28
28
  export { buildSchemaSnapshot, detectRenamedIndexes, generateSchemaSnapshot, indexShapeKey, readSchemaCatalog, renderSchemaMarkdown, stableStringify, writeSchemaSnapshot, } from './pg-schema-snapshot/index.mjs';
29
29
  export { buildOpenApiDocument, hashContractSchema, nodeToOpenApi, writeOpenApi, } from './openapi-document/index.mjs';
30
+ export { buildVirtualProgramMatrix, extractContractSchema, extractResponseContracts, validateResponseContract, virtualProgramBuildCountForTest, } from './contract-schema/index.mjs';
31
+ export { buildFixtureSchemaLock, responseSchemaFor, validateFixtureContracts, writeGeneratedFiles, } from './api-fixtures/index.mjs';
30
32
  export { decide, deriveRetryAttempt } from './transient-retry/index.mjs';
31
33
  export { escapeSpreadsheetFormula, parseCsvRows, streamCsvRows, stripCsvBom } from './csv/index.mjs';
32
34
  export { MissingResponseBodyError, readResponseBody, readResponseBodyAsBuffer, ResponseBodyTooLargeError, } from './http-body/index.mjs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "Vouchington CLI and extractable tooling libraries.",
5
5
  "homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
6
6
  "bugs": {
@@ -168,6 +168,16 @@
168
168
  "import": "./dist/openapi-document/index.mjs",
169
169
  "default": "./dist/openapi-document/index.mjs"
170
170
  },
171
+ "./contract-schema": {
172
+ "types": "./dist/contract-schema/index.d.mts",
173
+ "import": "./dist/contract-schema/index.mjs",
174
+ "default": "./dist/contract-schema/index.mjs"
175
+ },
176
+ "./api-fixtures": {
177
+ "types": "./dist/api-fixtures/index.d.mts",
178
+ "import": "./dist/api-fixtures/index.mjs",
179
+ "default": "./dist/api-fixtures/index.mjs"
180
+ },
171
181
  "./transient-retry": {
172
182
  "types": "./dist/transient-retry/index.d.mts",
173
183
  "import": "./dist/transient-retry/index.mjs",
@@ -320,8 +330,17 @@
320
330
  "@types/picomatch": "^4.0.3",
321
331
  "agent-blackboard": "^0.5.0"
322
332
  },
333
+ "peerDependencies": {
334
+ "typescript": ">=5"
335
+ },
336
+ "peerDependenciesMeta": {
337
+ "typescript": {
338
+ "optional": true
339
+ }
340
+ },
323
341
  "optionalDependencies": {
324
- "@libpg-query/parser": "^18.0.0"
342
+ "@libpg-query/parser": "^18.0.0",
343
+ "@typescript/typescript6": "^6.0.2"
325
344
  },
326
345
  "engines": {
327
346
  "node": ">=24.0.0"