diff --git a/packages/resource-id/package.json b/packages/resource-id/package.json new file mode 100644 index 0000000..cee265f --- /dev/null +++ b/packages/resource-id/package.json @@ -0,0 +1,19 @@ +{ + "name": "@guildpass/resource-id", + "version": "2.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "tsc -p test/tsconfig.json && node --test dist/codec.test.js" + } +} \ No newline at end of file diff --git a/packages/resource-id/src/codec.ts b/packages/resource-id/src/codec.ts new file mode 100644 index 0000000..7e4fadb --- /dev/null +++ b/packages/resource-id/src/codec.ts @@ -0,0 +1,285 @@ +import { + ResourceIdentifier, + ResourceIdConfig, + ResourceIdError, + ResourceIdErrorCode, + DEFAULT_CONFIG, +} from './types.js'; + +/** + * Delimiter used to separate namespace from segments + */ +const NAMESPACE_DELIMITER = ':'; + +/** + * Delimiter used to separate segments + */ +const SEGMENT_DELIMITER = '/'; + +/** + * Characters that need to be encoded in segments + */ +const RESERVED_CHARS = new Set([':', '/', '%']); + +/** + * Regular expression for valid namespace characters (alphanumeric, underscore, hyphen) + */ +const NAMESPACE_PATTERN = /^[a-zA-Z0-9_-]+$/; + +/** + * Encode reserved characters in a segment using percent-encoding + */ +function encodeSegment(segment: string): string { + return segment.replace(/[:%/]/g, (char) => { + return '%' + char.charCodeAt(0).toString(16).padStart(2, '0').toUpperCase(); + }); +} + +/** + * Decode percent-encoded characters in a segment + */ +function decodeSegment(encoded: string): string { + try { + return encoded.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => { + const charCode = parseInt(hex, 16); + return String.fromCharCode(charCode); + }); + } catch (error) { + throw new ResourceIdError( + ResourceIdErrorCode.MALFORMED_ENCODING, + `Invalid percent encoding in segment: ${encoded}`, + ); + } +} + +/** + * Validate namespace syntax + */ +function validateNamespace(namespace: string, config: ResourceIdConfig): void { + if (!namespace) { + throw new ResourceIdError( + ResourceIdErrorCode.EMPTY_NAMESPACE, + 'Namespace cannot be empty', + ); + } + + if (namespace.length > config.maxNamespaceLength) { + throw new ResourceIdError( + ResourceIdErrorCode.NAMESPACE_TOO_LONG, + `Namespace exceeds maximum length of ${config.maxNamespaceLength} characters`, + ); + } + + if (!NAMESPACE_PATTERN.test(namespace)) { + throw new ResourceIdError( + ResourceIdErrorCode.INVALID_NAMESPACE, + 'Namespace must contain only alphanumeric characters, underscores, and hyphens', + ); + } +} + +/** + * Validate segment content + */ +function validateSegment(segment: string, config: ResourceIdConfig): void { + if (!segment) { + throw new ResourceIdError( + ResourceIdErrorCode.EMPTY_SEGMENT, + 'Segment cannot be empty', + ); + } + + if (segment.length > config.maxSegmentLength) { + throw new ResourceIdError( + ResourceIdErrorCode.SEGMENT_TOO_LONG, + `Segment exceeds maximum length of ${config.maxSegmentLength} characters`, + ); + } + + // Check for path traversal patterns + if (segment === '.' || segment === '..' || segment.includes('\\')) { + throw new ResourceIdError( + ResourceIdErrorCode.INVALID_SEGMENT, + 'Segment cannot contain path traversal patterns', + ); + } +} + +/** + * Parse a resource identifier string into its components + */ +export function parseResourceId( + identifier: string, + config: ResourceIdConfig = DEFAULT_CONFIG, +): ResourceIdentifier { + if (!identifier) { + throw new ResourceIdError( + ResourceIdErrorCode.INVALID_FORMAT, + 'Identifier cannot be empty', + ); + } + + if (identifier.length > config.maxTotalLength) { + throw new ResourceIdError( + ResourceIdErrorCode.IDENTIFIER_TOO_LONG, + `Identifier exceeds maximum length of ${config.maxTotalLength} characters`, + ); + } + + // Check for malformed percent encoding patterns + if (identifier.includes('%') && !/^([^%]|%[0-9A-Fa-f]{2})*$/.test(identifier)) { + throw new ResourceIdError( + ResourceIdErrorCode.MALFORMED_ENCODING, + 'Invalid percent encoding pattern', + ); + } + + const colonIndex = identifier.indexOf(NAMESPACE_DELIMITER); + if (colonIndex === -1) { + throw new ResourceIdError( + ResourceIdErrorCode.INVALID_FORMAT, + 'Identifier must contain namespace delimiter (:)', + ); + } + + const namespace = identifier.slice(0, colonIndex); + const segmentsPart = identifier.slice(colonIndex + 1); + + validateNamespace(namespace, config); + + if (!segmentsPart) { + throw new ResourceIdError( + ResourceIdErrorCode.EMPTY_SEGMENT, + 'At least one segment is required', + ); + } + + const encodedSegments = segmentsPart.split(SEGMENT_DELIMITER); + + if (encodedSegments.length > config.maxSegments) { + throw new ResourceIdError( + ResourceIdErrorCode.TOO_MANY_SEGMENTS, + `Too many segments: ${encodedSegments.length}, maximum allowed: ${config.maxSegments}`, + ); + } + + const segments: string[] = []; + for (const encodedSegment of encodedSegments) { + if (!encodedSegment) { + throw new ResourceIdError( + ResourceIdErrorCode.EMPTY_SEGMENT, + 'Empty segments are not allowed', + ); + } + + const decodedSegment = decodeSegment(encodedSegment); + validateSegment(decodedSegment, config); + segments.push(decodedSegment); + } + + return { + namespace, + segments: Object.freeze(segments), + }; +} + +/** + * Format a resource identifier into its canonical string representation + */ +export function formatResourceId( + resourceId: ResourceIdentifier, + config: ResourceIdConfig = DEFAULT_CONFIG, +): string { + validateNamespace(resourceId.namespace, config); + + if (!resourceId.segments.length) { + throw new ResourceIdError( + ResourceIdErrorCode.EMPTY_SEGMENT, + 'At least one segment is required', + ); + } + + if (resourceId.segments.length > config.maxSegments) { + throw new ResourceIdError( + ResourceIdErrorCode.TOO_MANY_SEGMENTS, + `Too many segments: ${resourceId.segments.length}, maximum allowed: ${config.maxSegments}`, + ); + } + + const encodedSegments: string[] = []; + for (const segment of resourceId.segments) { + validateSegment(segment, config); + encodedSegments.push(encodeSegment(segment)); + } + + const formatted = `${resourceId.namespace}${NAMESPACE_DELIMITER}${encodedSegments.join(SEGMENT_DELIMITER)}`; + + if (formatted.length > config.maxTotalLength) { + throw new ResourceIdError( + ResourceIdErrorCode.IDENTIFIER_TOO_LONG, + `Formatted identifier exceeds maximum length of ${config.maxTotalLength} characters`, + ); + } + + return formatted; +} + +/** + * Compare two resource identifiers for equality + */ +export function areResourceIdsEqual(a: ResourceIdentifier, b: ResourceIdentifier): boolean { + if (a.namespace !== b.namespace) { + return false; + } + + if (a.segments.length !== b.segments.length) { + return false; + } + + for (let i = 0; i < a.segments.length; i++) { + if (a.segments[i] !== b.segments[i]) { + return false; + } + } + + return true; +} + +/** + * Create a canonical string representation for comparison purposes + */ +export function getCanonicalForm(resourceId: ResourceIdentifier): string { + return formatResourceId(resourceId); +} + +/** + * Compare two resource identifiers lexicographically + * Returns: < 0 if a < b, 0 if a === b, > 0 if a > b + */ +export function compareResourceIds(a: ResourceIdentifier, b: ResourceIdentifier): number { + const canonicalA = getCanonicalForm(a); + const canonicalB = getCanonicalForm(b); + + if (canonicalA < canonicalB) return -1; + if (canonicalA > canonicalB) return 1; + return 0; +} + +/** + * Create a resource identifier with validation + */ +export function createResourceId( + namespace: string, + segments: string[], + config: ResourceIdConfig = DEFAULT_CONFIG, +): ResourceIdentifier { + const resourceId: ResourceIdentifier = { + namespace, + segments: Object.freeze([...segments]), + }; + + // Validate by formatting (which performs all validations) + formatResourceId(resourceId, config); + + return resourceId; +} \ No newline at end of file diff --git a/packages/resource-id/src/index.ts b/packages/resource-id/src/index.ts new file mode 100644 index 0000000..a253f69 --- /dev/null +++ b/packages/resource-id/src/index.ts @@ -0,0 +1,21 @@ +// Export types and interfaces +export type { + ResourceIdentifier, + ResourceIdConfig, +} from './types.js'; + +export { + ResourceIdError, + ResourceIdErrorCode, + DEFAULT_CONFIG, +} from './types.js'; + +// Export codec functions +export { + parseResourceId, + formatResourceId, + areResourceIdsEqual, + getCanonicalForm, + compareResourceIds, + createResourceId, +} from './codec.js'; \ No newline at end of file diff --git a/packages/resource-id/src/types.ts b/packages/resource-id/src/types.ts new file mode 100644 index 0000000..26efdf0 --- /dev/null +++ b/packages/resource-id/src/types.ts @@ -0,0 +1,62 @@ +/** + * A deterministic resource identifier with namespace isolation + */ +export interface ResourceIdentifier { + /** The namespace of the resource (e.g., "community", "document") */ + readonly namespace: string; + /** The segments that identify the specific resource within the namespace */ + readonly segments: readonly string[]; +} + +/** + * Configuration options for resource identifier parsing and formatting + */ +export interface ResourceIdConfig { + /** Maximum length for namespace */ + readonly maxNamespaceLength: number; + /** Maximum length for each segment */ + readonly maxSegmentLength: number; + /** Maximum number of segments */ + readonly maxSegments: number; + /** Maximum total identifier length */ + readonly maxTotalLength: number; +} + +/** + * Error codes for resource identifier validation + */ +export const enum ResourceIdErrorCode { + EMPTY_NAMESPACE = 'EMPTY_NAMESPACE', + EMPTY_SEGMENT = 'EMPTY_SEGMENT', + INVALID_NAMESPACE = 'INVALID_NAMESPACE', + INVALID_SEGMENT = 'INVALID_SEGMENT', + NAMESPACE_TOO_LONG = 'NAMESPACE_TOO_LONG', + SEGMENT_TOO_LONG = 'SEGMENT_TOO_LONG', + TOO_MANY_SEGMENTS = 'TOO_MANY_SEGMENTS', + IDENTIFIER_TOO_LONG = 'IDENTIFIER_TOO_LONG', + MALFORMED_ENCODING = 'MALFORMED_ENCODING', + INVALID_FORMAT = 'INVALID_FORMAT', +} + +/** + * Error thrown when resource identifier validation fails + */ +export class ResourceIdError extends Error { + constructor( + public readonly code: ResourceIdErrorCode, + message: string, + ) { + super(message); + this.name = 'ResourceIdError'; + } +} + +/** + * Default configuration for resource identifiers + */ +export const DEFAULT_CONFIG: ResourceIdConfig = { + maxNamespaceLength: 32, + maxSegmentLength: 64, + maxSegments: 8, + maxTotalLength: 512, +} as const; \ No newline at end of file diff --git a/packages/resource-id/test/codec.test.ts b/packages/resource-id/test/codec.test.ts new file mode 100644 index 0000000..a5eaf56 --- /dev/null +++ b/packages/resource-id/test/codec.test.ts @@ -0,0 +1,360 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert'; +import { + parseResourceId, + formatResourceId, + areResourceIdsEqual, + getCanonicalForm, + compareResourceIds, + createResourceId, + ResourceIdError, + ResourceIdErrorCode, + DEFAULT_CONFIG, + type ResourceIdentifier, +} from '../dist/index.js'; + +describe('Resource Identifier Codec', () => { + describe('parseResourceId', () => { + test('should parse valid simple identifier', () => { + const result = parseResourceId('community:abc123'); + assert.deepStrictEqual(result, { + namespace: 'community', + segments: ['abc123'], + }); + }); + + test('should parse identifier with multiple segments', () => { + const result = parseResourceId('document:folder/subfolder/file'); + assert.deepStrictEqual(result, { + namespace: 'document', + segments: ['folder', 'subfolder', 'file'], + }); + }); + + test('should parse identifier with encoded special characters', () => { + const result = parseResourceId('resource:segment%2Fwith%2Fslashes/another%3Awith%3Acolons'); + assert.deepStrictEqual(result, { + namespace: 'resource', + segments: ['segment/with/slashes', 'another:with:colons'], + }); + }); + + test('should handle Unicode characters in segments', () => { + const result = parseResourceId('community:café/naïve/résumé'); + assert.deepStrictEqual(result, { + namespace: 'community', + segments: ['café', 'naïve', 'résumé'], + }); + }); + + test('should reject empty identifier', () => { + assert.throws( + () => parseResourceId(''), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.INVALID_FORMAT + ); + }); + + test('should reject identifier without namespace delimiter', () => { + assert.throws( + () => parseResourceId('communityabc123'), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.INVALID_FORMAT + ); + }); + + test('should reject empty namespace', () => { + assert.throws( + () => parseResourceId(':abc123'), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.EMPTY_NAMESPACE + ); + }); + + test('should reject empty segments', () => { + assert.throws( + () => parseResourceId('community:'), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.EMPTY_SEGMENT + ); + + assert.throws( + () => parseResourceId('community:abc//def'), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.EMPTY_SEGMENT + ); + }); + + test('should reject namespace with invalid characters', () => { + assert.throws( + () => parseResourceId('commu nity:abc123'), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.INVALID_NAMESPACE + ); + + assert.throws( + () => parseResourceId('commu@nity:abc123'), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.INVALID_NAMESPACE + ); + }); + + test('should reject namespace that is too long', () => { + const longNamespace = 'a'.repeat(DEFAULT_CONFIG.maxNamespaceLength + 1); + assert.throws( + () => parseResourceId(`${longNamespace}:abc123`), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.NAMESPACE_TOO_LONG + ); + }); + + test('should reject segment that is too long', () => { + const longSegment = 'a'.repeat(DEFAULT_CONFIG.maxSegmentLength + 1); + assert.throws( + () => parseResourceId(`community:${longSegment}`), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.SEGMENT_TOO_LONG + ); + }); + + test('should reject too many segments', () => { + const manySegments = Array(DEFAULT_CONFIG.maxSegments + 1).fill('seg').join('/'); + assert.throws( + () => parseResourceId(`community:${manySegments}`), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.TOO_MANY_SEGMENTS + ); + }); + + test('should reject identifier that is too long overall', () => { + const config = { ...DEFAULT_CONFIG, maxTotalLength: 20 }; + assert.throws( + () => parseResourceId('community:verylongsegmentname', config), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.IDENTIFIER_TOO_LONG + ); + }); + + test('should reject malformed percent encoding', () => { + assert.throws( + () => parseResourceId('community:segment%2'), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.MALFORMED_ENCODING + ); + + assert.throws( + () => parseResourceId('community:segment%ZZ'), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.MALFORMED_ENCODING + ); + }); + + test('should reject path traversal patterns', () => { + assert.throws( + () => parseResourceId('community:.'), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.INVALID_SEGMENT + ); + + assert.throws( + () => parseResourceId('community:..'), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.INVALID_SEGMENT + ); + + assert.throws( + () => parseResourceId('community:segment\\\\path'), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.INVALID_SEGMENT + ); + }); + }); + + describe('formatResourceId', () => { + test('should format simple identifier', () => { + const resourceId: ResourceIdentifier = { + namespace: 'community', + segments: ['abc123'], + }; + assert.strictEqual(formatResourceId(resourceId), 'community:abc123'); + }); + + test('should format identifier with multiple segments', () => { + const resourceId: ResourceIdentifier = { + namespace: 'document', + segments: ['folder', 'subfolder', 'file'], + }; + assert.strictEqual(formatResourceId(resourceId), 'document:folder/subfolder/file'); + }); + + test('should encode special characters in segments', () => { + const resourceId: ResourceIdentifier = { + namespace: 'resource', + segments: ['segment/with/slashes', 'another:with:colons', 'percent%signs'], + }; + const expected = 'resource:segment%2Fwith%2Fslashes/another%3Awith%3Acolons/percent%25signs'; + assert.strictEqual(formatResourceId(resourceId), expected); + }); + + test('should handle Unicode characters', () => { + const resourceId: ResourceIdentifier = { + namespace: 'community', + segments: ['café', 'naïve', 'résumé'], + }; + assert.strictEqual(formatResourceId(resourceId), 'community:café/naïve/résumé'); + }); + + test('should reject empty segments array', () => { + const resourceId: ResourceIdentifier = { + namespace: 'community', + segments: [], + }; + assert.throws( + () => formatResourceId(resourceId), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.EMPTY_SEGMENT + ); + }); + }); + + describe('round-trip consistency', () => { + test('should maintain consistency for simple identifiers', () => { + const original = 'community:abc123'; + const parsed = parseResourceId(original); + const formatted = formatResourceId(parsed); + assert.strictEqual(formatted, original); + }); + + test('should maintain consistency for complex identifiers', () => { + const original = 'document:folder/subfolder/file'; + const parsed = parseResourceId(original); + const formatted = formatResourceId(parsed); + assert.strictEqual(formatted, original); + }); + + test('should maintain consistency for encoded identifiers', () => { + const original = 'resource:segment%2Fwith%2Fslashes/another%3Awith%3Acolons'; + const parsed = parseResourceId(original); + const formatted = formatResourceId(parsed); + assert.strictEqual(formatted, original); + }); + + test('should maintain consistency for Unicode identifiers', () => { + const original = 'community:café/naïve/résumé'; + const parsed = parseResourceId(original); + const formatted = formatResourceId(parsed); + assert.strictEqual(formatted, original); + }); + }); + + describe('areResourceIdsEqual', () => { + test('should return true for identical identifiers', () => { + const id1: ResourceIdentifier = { namespace: 'community', segments: ['abc123'] }; + const id2: ResourceIdentifier = { namespace: 'community', segments: ['abc123'] }; + assert.strictEqual(areResourceIdsEqual(id1, id2), true); + }); + + test('should return false for different namespaces', () => { + const id1: ResourceIdentifier = { namespace: 'community', segments: ['abc123'] }; + const id2: ResourceIdentifier = { namespace: 'document', segments: ['abc123'] }; + assert.strictEqual(areResourceIdsEqual(id1, id2), false); + }); + + test('should return false for different segments', () => { + const id1: ResourceIdentifier = { namespace: 'community', segments: ['abc123'] }; + const id2: ResourceIdentifier = { namespace: 'community', segments: ['def456'] }; + assert.strictEqual(areResourceIdsEqual(id1, id2), false); + }); + + test('should return false for different segment counts', () => { + const id1: ResourceIdentifier = { namespace: 'community', segments: ['abc123'] }; + const id2: ResourceIdentifier = { namespace: 'community', segments: ['abc123', 'def456'] }; + assert.strictEqual(areResourceIdsEqual(id1, id2), false); + }); + }); + + describe('getCanonicalForm and compareResourceIds', () => { + test('should produce canonical form', () => { + const resourceId: ResourceIdentifier = { + namespace: 'community', + segments: ['abc123'], + }; + assert.strictEqual(getCanonicalForm(resourceId), 'community:abc123'); + }); + + test('should compare identifiers lexicographically', () => { + const id1: ResourceIdentifier = { namespace: 'community', segments: ['abc'] }; + const id2: ResourceIdentifier = { namespace: 'community', segments: ['def'] }; + const id3: ResourceIdentifier = { namespace: 'document', segments: ['abc'] }; + + assert.strictEqual(compareResourceIds(id1, id1), 0); + assert(compareResourceIds(id1, id2) < 0); + assert(compareResourceIds(id2, id1) > 0); + assert(compareResourceIds(id1, id3) < 0); + }); + }); + + describe('createResourceId', () => { + test('should create valid resource identifier', () => { + const resourceId = createResourceId('community', ['abc123']); + assert.deepStrictEqual(resourceId, { + namespace: 'community', + segments: ['abc123'], + }); + }); + + test('should validate during creation', () => { + assert.throws( + () => createResourceId('', ['abc123']), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.EMPTY_NAMESPACE + ); + }); + + test('should freeze segments array', () => { + const resourceId = createResourceId('community', ['abc123']); + assert(Object.isFrozen(resourceId.segments)); + }); + }); + + describe('custom configuration', () => { + test('should respect custom length limits', () => { + const config = { + maxNamespaceLength: 5, + maxSegmentLength: 5, + maxSegments: 2, + maxTotalLength: 20, + }; + + assert.throws( + () => parseResourceId('toolong:abc', config), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.NAMESPACE_TOO_LONG + ); + + assert.throws( + () => parseResourceId('short:toolong', config), + (err: ResourceIdError) => err.code === ResourceIdErrorCode.SEGMENT_TOO_LONG + ); + }); + }); + + describe('edge cases and boundary conditions', () => { + test('should handle maximum valid lengths', () => { + const maxNamespace = 'a'.repeat(DEFAULT_CONFIG.maxNamespaceLength); + const maxSegment = 'b'.repeat(DEFAULT_CONFIG.maxSegmentLength); + + const result = parseResourceId(`${maxNamespace}:${maxSegment}`); + assert.strictEqual(result.namespace, maxNamespace); + assert.deepStrictEqual(result.segments, [maxSegment]); + }); + + test('should handle maximum number of segments', () => { + const segments = Array(DEFAULT_CONFIG.maxSegments).fill('seg'); + const identifier = `community:${segments.join('/')}`; + + const result = parseResourceId(identifier); + assert.strictEqual(result.segments.length, DEFAULT_CONFIG.maxSegments); + }); + + test('should handle all reserved characters', () => { + const resourceId: ResourceIdentifier = { + namespace: 'test', + segments: ['colon:', 'slash/', 'percent%'], + }; + + const formatted = formatResourceId(resourceId); + const parsed = parseResourceId(formatted); + + assert.deepStrictEqual(parsed, resourceId); + }); + + test('should handle mixed encoded and unencoded content', () => { + const original = 'test:normal/encoded%2Fsegment/normal'; + const parsed = parseResourceId(original); + + assert.deepStrictEqual(parsed.segments, ['normal', 'encoded/segment', 'normal']); + }); + }); +}); \ No newline at end of file diff --git a/packages/resource-id/test/tsconfig.json b/packages/resource-id/test/tsconfig.json new file mode 100644 index 0000000..5c14d64 --- /dev/null +++ b/packages/resource-id/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../dist", + "rootDir": "." + }, + "include": ["*.ts"] +} \ No newline at end of file diff --git a/packages/resource-id/tsconfig.json b/packages/resource-id/tsconfig.json new file mode 100644 index 0000000..6efe8f5 --- /dev/null +++ b/packages/resource-id/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} \ No newline at end of file