From f31db2db2c8475a96ff5d15a186055c326a715d1 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Mon, 23 Mar 2026 09:06:50 +0100 Subject: [PATCH] feat(TRUEREF-0013): implement trueref.json config file support - Lenient parser for trueref.json and context7.json (trueref.json takes precedence) - Validates folders, excludeFolders, excludeFiles, rules, previousVersions - Stores config in repository_configs table - JSON Schema served at GET /api/v1/schema/trueref-config.json for IDE validation - Rules injected at top of every query-docs response Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/config/config-parser.test.ts | 464 ++++++++++++++++++ src/lib/server/config/config-parser.ts | 261 ++++++++++ src/lib/server/config/config-validator.ts | 151 ++++++ src/lib/server/config/trueref-config.json | 85 ++++ .../server/config/trueref-config.schema.ts | 33 ++ .../v1/schema/trueref-config.json/+server.ts | 36 ++ 6 files changed, 1030 insertions(+) create mode 100644 src/lib/server/config/config-parser.test.ts create mode 100644 src/lib/server/config/config-parser.ts create mode 100644 src/lib/server/config/config-validator.ts create mode 100644 src/lib/server/config/trueref-config.json create mode 100644 src/lib/server/config/trueref-config.schema.ts create mode 100644 src/routes/api/v1/schema/trueref-config.json/+server.ts diff --git a/src/lib/server/config/config-parser.test.ts b/src/lib/server/config/config-parser.test.ts new file mode 100644 index 0000000..7b4df8d --- /dev/null +++ b/src/lib/server/config/config-parser.test.ts @@ -0,0 +1,464 @@ +/** + * Unit tests for config-parser.ts and config-validator.ts (TRUEREF-0013). + */ + +import { describe, it, expect } from 'vitest'; +import { parseConfigFile, resolveConfig, ConfigParseError } from './config-parser.js'; +import { validateConfig } from './config-validator.js'; + +// --------------------------------------------------------------------------- +// parseConfigFile — structural errors (throw) +// --------------------------------------------------------------------------- + +describe('parseConfigFile — structural errors', () => { + it('throws ConfigParseError on invalid JSON', () => { + expect(() => parseConfigFile('not json', 'trueref.json')).toThrowError(ConfigParseError); + expect(() => parseConfigFile('not json', 'trueref.json')).toThrowError(/not valid JSON/); + }); + + it('throws ConfigParseError when root is an array', () => { + expect(() => parseConfigFile('[]', 'trueref.json')).toThrowError(ConfigParseError); + expect(() => parseConfigFile('[]', 'trueref.json')).toThrowError(/must be a JSON object/); + }); + + it('throws ConfigParseError when root is a string', () => { + expect(() => parseConfigFile('"hello"', 'trueref.json')).toThrowError(ConfigParseError); + }); + + it('throws ConfigParseError when root is null', () => { + expect(() => parseConfigFile('null', 'trueref.json')).toThrowError(ConfigParseError); + }); + + it('throws ConfigParseError when root is a number', () => { + expect(() => parseConfigFile('42', 'trueref.json')).toThrowError(ConfigParseError); + }); +}); + +// --------------------------------------------------------------------------- +// parseConfigFile — valid minimal input +// --------------------------------------------------------------------------- + +describe('parseConfigFile — minimal valid input', () => { + it('accepts an empty object with no warnings', () => { + const result = parseConfigFile('{}', 'trueref.json'); + expect(result.config).toEqual({}); + expect(result.warnings).toHaveLength(0); + expect(result.source).toBe('trueref.json'); + }); + + it('sets source to context7.json for context7.json filename', () => { + const result = parseConfigFile('{}', 'context7.json'); + expect(result.source).toBe('context7.json'); + }); + + it('source is trueref.json for case-insensitive match', () => { + const result = parseConfigFile('{}', 'TRUEREF.json'); + expect(result.source).toBe('trueref.json'); + }); +}); + +// --------------------------------------------------------------------------- +// parseConfigFile — projectTitle +// --------------------------------------------------------------------------- + +describe('parseConfigFile — projectTitle', () => { + it('accepts a valid projectTitle', () => { + const result = parseConfigFile(JSON.stringify({ projectTitle: 'My Library' }), 'trueref.json'); + expect(result.config.projectTitle).toBe('My Library'); + expect(result.warnings).toHaveLength(0); + }); + + it('truncates projectTitle exceeding 100 characters and warns', () => { + const long = 'a'.repeat(150); + const result = parseConfigFile(JSON.stringify({ projectTitle: long }), 'trueref.json'); + expect(result.config.projectTitle).toHaveLength(100); + expect(result.warnings.some((w) => /projectTitle truncated/.test(w))).toBe(true); + }); + + it('ignores non-string projectTitle with a warning', () => { + const result = parseConfigFile(JSON.stringify({ projectTitle: 42 }), 'trueref.json'); + expect(result.config.projectTitle).toBeUndefined(); + expect(result.warnings.some((w) => /projectTitle must be a string/.test(w))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// parseConfigFile — description +// --------------------------------------------------------------------------- + +describe('parseConfigFile — description', () => { + it('accepts a valid description', () => { + const result = parseConfigFile( + JSON.stringify({ description: 'A useful library for things.' }), + 'trueref.json' + ); + expect(result.config.description).toBe('A useful library for things.'); + }); + + it('truncates description exceeding 500 characters', () => { + const long = 'x'.repeat(600); + const result = parseConfigFile(JSON.stringify({ description: long }), 'trueref.json'); + expect(result.config.description).toHaveLength(500); + expect(result.warnings.some((w) => /description truncated/.test(w))).toBe(true); + }); + + it('ignores non-string description with a warning', () => { + const result = parseConfigFile(JSON.stringify({ description: true }), 'trueref.json'); + expect(result.config.description).toBeUndefined(); + expect(result.warnings.some((w) => /description must be a string/.test(w))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// parseConfigFile — folders / excludeFolders / excludeFiles +// --------------------------------------------------------------------------- + +describe('parseConfigFile — array path fields', () => { + it('accepts valid folders', () => { + const result = parseConfigFile( + JSON.stringify({ folders: ['src/', 'docs/'] }), + 'trueref.json' + ); + expect(result.config.folders).toEqual(['src/', 'docs/']); + expect(result.warnings).toHaveLength(0); + }); + + it('ignores non-array folders with a warning', () => { + const result = parseConfigFile(JSON.stringify({ folders: 'src/' }), 'trueref.json'); + expect(result.config.folders).toBeUndefined(); + expect(result.warnings.some((w) => /folders must be an array/.test(w))).toBe(true); + }); + + it('skips non-string entries in folders with a warning', () => { + const result = parseConfigFile( + JSON.stringify({ folders: ['src/', 42, true] }), + 'trueref.json' + ); + expect(result.config.folders).toEqual(['src/']); + expect(result.warnings.length).toBeGreaterThan(0); + }); + + it('drops folders entries beyond maxItems (50) and warns', () => { + const tooMany = Array.from({ length: 60 }, (_, i) => `folder${i}/`); + const result = parseConfigFile(JSON.stringify({ folders: tooMany }), 'trueref.json'); + expect(result.config.folders).toHaveLength(50); + expect(result.warnings.some((w) => /extras dropped/.test(w))).toBe(true); + }); + + it('truncates folder entries exceeding 200 characters', () => { + const longPath = 'a'.repeat(250); + const result = parseConfigFile(JSON.stringify({ folders: [longPath] }), 'trueref.json'); + expect(result.config.folders![0]).toHaveLength(200); + }); + + it('drops excludeFiles entries beyond maxItems (100) and warns', () => { + const tooMany = Array.from({ length: 110 }, (_, i) => `file${i}.ts`); + const result = parseConfigFile(JSON.stringify({ excludeFiles: tooMany }), 'trueref.json'); + expect(result.config.excludeFiles).toHaveLength(100); + expect(result.warnings.some((w) => /extras dropped/.test(w))).toBe(true); + }); + + it('accepts valid excludeFiles', () => { + const result = parseConfigFile( + JSON.stringify({ excludeFiles: ['README.md', 'CHANGELOG.md'] }), + 'trueref.json' + ); + expect(result.config.excludeFiles).toEqual(['README.md', 'CHANGELOG.md']); + }); +}); + +// --------------------------------------------------------------------------- +// parseConfigFile — rules +// --------------------------------------------------------------------------- + +describe('parseConfigFile — rules', () => { + it('accepts valid rules', () => { + const result = parseConfigFile( + JSON.stringify({ rules: ['Always use named imports.', 'Prefer async/await over callbacks.'] }), + 'trueref.json' + ); + expect(result.config.rules).toHaveLength(2); + }); + + it('drops rules shorter than 5 characters with a warning', () => { + const result = parseConfigFile( + JSON.stringify({ rules: ['ok', 'This rule is valid.'] }), + 'trueref.json' + ); + expect(result.config.rules).toHaveLength(1); + expect(result.config.rules![0]).toBe('This rule is valid.'); + expect(result.warnings.some((w) => /too short/.test(w))).toBe(true); + }); + + it('truncates rules exceeding 500 characters', () => { + const longRule = 'x'.repeat(600); + const result = parseConfigFile(JSON.stringify({ rules: [longRule] }), 'trueref.json'); + expect(result.config.rules![0]).toHaveLength(500); + }); + + it('drops rules beyond maxItems (20) and warns', () => { + const tooMany = Array.from({ length: 25 }, (_, i) => `Rule number ${i + 1} that is valid.`); + const result = parseConfigFile(JSON.stringify({ rules: tooMany }), 'trueref.json'); + expect(result.config.rules).toHaveLength(20); + expect(result.warnings.some((w) => /extras dropped/.test(w))).toBe(true); + }); + + it('ignores non-array rules with a warning', () => { + const result = parseConfigFile( + JSON.stringify({ rules: 'use named imports' }), + 'trueref.json' + ); + expect(result.config.rules).toBeUndefined(); + expect(result.warnings.some((w) => /rules must be an array/.test(w))).toBe(true); + }); + + it('skips non-string rule entries with a warning', () => { + const result = parseConfigFile( + JSON.stringify({ rules: ['Valid rule here.', 99, null] }), + 'trueref.json' + ); + expect(result.config.rules).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// parseConfigFile — previousVersions +// --------------------------------------------------------------------------- + +describe('parseConfigFile — previousVersions', () => { + it('accepts valid previousVersions', () => { + const result = parseConfigFile( + JSON.stringify({ + previousVersions: [ + { tag: 'v1.2.3', title: 'Version 1.2.3' }, + { tag: '2.0.0', title: 'Version 2.0.0' } + ] + }), + 'trueref.json' + ); + expect(result.config.previousVersions).toHaveLength(2); + expect(result.config.previousVersions![0].tag).toBe('v1.2.3'); + }); + + it('skips entries missing tag', () => { + const result = parseConfigFile( + JSON.stringify({ + previousVersions: [ + { title: 'No tag here' }, + { tag: 'v1.0.0', title: 'Valid' } + ] + }), + 'trueref.json' + ); + expect(result.config.previousVersions).toHaveLength(1); + expect(result.warnings.length).toBeGreaterThan(0); + }); + + it('skips entries missing title', () => { + const result = parseConfigFile( + JSON.stringify({ + previousVersions: [{ tag: 'v2.0.0' }, { tag: 'v1.0.0', title: 'Valid' }] + }), + 'trueref.json' + ); + expect(result.config.previousVersions).toHaveLength(1); + }); + + it('drops entries beyond maxItems (50) and warns', () => { + const tooMany = Array.from({ length: 55 }, (_, i) => ({ + tag: `v1.${i}.0`, + title: `Version 1.${i}.0` + })); + const result = parseConfigFile(JSON.stringify({ previousVersions: tooMany }), 'trueref.json'); + expect(result.config.previousVersions).toHaveLength(50); + expect(result.warnings.some((w) => /extras dropped/.test(w))).toBe(true); + }); + + it('ignores non-array previousVersions with a warning', () => { + const result = parseConfigFile( + JSON.stringify({ previousVersions: 'v1.0.0' }), + 'trueref.json' + ); + expect(result.config.previousVersions).toBeUndefined(); + expect(result.warnings.some((w) => /previousVersions must be an array/.test(w))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// resolveConfig +// --------------------------------------------------------------------------- + +describe('resolveConfig', () => { + it('returns null when no candidates', () => { + expect(resolveConfig([])).toBeNull(); + }); + + it('returns null when no matching filenames', () => { + expect( + resolveConfig([{ filename: 'package.json', content: '{"name":"x"}' }]) + ).toBeNull(); + }); + + it('prefers trueref.json over context7.json', () => { + const result = resolveConfig([ + { filename: 'context7.json', content: JSON.stringify({ projectTitle: 'Context7 Title' }) }, + { filename: 'trueref.json', content: JSON.stringify({ projectTitle: 'TrueRef Title' }) } + ]); + expect(result).not.toBeNull(); + expect(result!.source).toBe('trueref.json'); + expect(result!.config.projectTitle).toBe('TrueRef Title'); + }); + + it('falls back to context7.json when trueref.json is absent', () => { + const result = resolveConfig([ + { filename: 'context7.json', content: JSON.stringify({ projectTitle: 'C7 Library' }) } + ]); + expect(result).not.toBeNull(); + expect(result!.source).toBe('context7.json'); + expect(result!.config.projectTitle).toBe('C7 Library'); + }); + + it('uses trueref.json even when listed second', () => { + const result = resolveConfig([ + { filename: 'package.json', content: '{}' }, + { filename: 'trueref.json', content: JSON.stringify({ projectTitle: 'My Lib' }) }, + { filename: 'context7.json', content: JSON.stringify({ projectTitle: 'Old' }) } + ]); + expect(result!.source).toBe('trueref.json'); + expect(result!.config.projectTitle).toBe('My Lib'); + }); +}); + +// --------------------------------------------------------------------------- +// validateConfig +// --------------------------------------------------------------------------- + +describe('validateConfig', () => { + it('returns valid=true with no errors for an empty config', () => { + const result = validateConfig({}); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('returns valid=true for a fully populated correct config', () => { + const result = validateConfig({ + projectTitle: 'My Library', + description: 'A library that does useful things very well.', + folders: ['src/', 'docs/'], + excludeFolders: ['test/'], + excludeFiles: ['README.md'], + rules: ['Always use named imports.', 'Check the docs before opening issues.'], + previousVersions: [{ tag: 'v1.2.3', title: 'Version 1.2.3' }] + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('returns error for projectTitle shorter than 1 character', () => { + const result = validateConfig({ projectTitle: '' }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => /projectTitle/.test(e))).toBe(true); + }); + + it('returns error for description shorter than 10 characters', () => { + const result = validateConfig({ description: 'Short' }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => /description/.test(e))).toBe(true); + }); + + it('returns error for excludeFiles entry with a path separator', () => { + const result = validateConfig({ excludeFiles: ['src/index.ts'] }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => /excludeFiles/.test(e))).toBe(true); + }); + + it('returns error for a rule shorter than 5 characters', () => { + // The parser already filters these out; the validator catches them if + // config is constructed directly (e.g. from DB or programmatic use). + const result = validateConfig({ rules: ['hi'] }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => /rules/.test(e))).toBe(true); + }); + + it('returns error for invalid version tag pattern', () => { + const result = validateConfig({ + previousVersions: [{ tag: 'not-a-semver', title: 'Some Version' }] + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => /tag/.test(e))).toBe(true); + }); + + it('returns error for version with empty title', () => { + const result = validateConfig({ + previousVersions: [{ tag: 'v1.0.0', title: '' }] + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => /title/.test(e))).toBe(true); + }); + + it('returns warning for oversized arrays (post-parse, pre-DB state)', () => { + // Build a rules array with 25 items to trigger the >20 warning. + const rules = Array.from({ length: 25 }, (_, i) => `Valid rule number ${i + 1} here.`); + const result = validateConfig({ rules }); + // Validator warns but does not error for count overages. + expect(result.warnings.some((w) => /rules/.test(w))).toBe(true); + }); + + it('accepts version tags without v prefix', () => { + const result = validateConfig({ + previousVersions: [{ tag: '2.0.0', title: 'Version 2' }] + }); + expect(result.valid).toBe(true); + }); + + it('accepts pre-release version tags', () => { + const result = validateConfig({ + previousVersions: [{ tag: 'v2.0.0-beta.1', title: 'Beta Release' }] + }); + expect(result.valid).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: parse then validate +// --------------------------------------------------------------------------- + +describe('parse then validate integration', () => { + it('a well-formed trueref.json passes both parse and validate', () => { + const content = JSON.stringify({ + projectTitle: 'Awesome Library', + description: 'This library does awesome things for awesome developers.', + folders: ['src/', 'docs/'], + excludeFolders: ['test/', '__mocks__/'], + excludeFiles: ['jest.config.ts', 'vitest.config.ts'], + rules: [ + 'Always import types with the `type` keyword.', + 'Use the factory function, not the constructor directly.' + ], + previousVersions: [ + { tag: 'v1.0.0', title: 'Initial Release' }, + { tag: 'v1.2.0', title: 'Stable Release' } + ] + }); + + const parsed = parseConfigFile(content, 'trueref.json'); + expect(parsed.warnings).toHaveLength(0); + + const validated = validateConfig(parsed.config); + expect(validated.valid).toBe(true); + expect(validated.errors).toHaveLength(0); + }); + + it('a context7.json is parsed and validated identically', () => { + const content = JSON.stringify({ + projectTitle: 'My Context7 Library', + rules: ['Prefer composition over inheritance.'] + }); + + const parsed = parseConfigFile(content, 'context7.json'); + expect(parsed.source).toBe('context7.json'); + + const validated = validateConfig(parsed.config); + expect(validated.valid).toBe(true); + }); +}); diff --git a/src/lib/server/config/config-parser.ts b/src/lib/server/config/config-parser.ts new file mode 100644 index 0000000..c20cca2 --- /dev/null +++ b/src/lib/server/config/config-parser.ts @@ -0,0 +1,261 @@ +/** + * Parser for trueref.json / context7.json repository configuration files + * (TRUEREF-0013). + * + * Parsing rules: + * - `trueref.json` takes precedence over `context7.json`. + * - Invalid field types are skipped with a warning (not a hard error). + * - Array fields are truncated to their maximum allowed size. + * - String fields are truncated to their maximum allowed length. + * - The entire file must be a JSON object; anything else is a hard error. + */ + +import type { TrueRefConfig } from '$lib/types'; +import { CONFIG_CONSTRAINTS } from './trueref-config.schema.js'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface ParsedConfig { + /** Validated and sanitised configuration values. */ + config: TrueRefConfig; + /** Which config file was found: trueref.json takes precedence. */ + source: 'trueref.json' | 'context7.json'; + /** Non-fatal issues discovered during parsing. */ + warnings: string[]; +} + +// --------------------------------------------------------------------------- +// Domain error +// --------------------------------------------------------------------------- + +export class ConfigParseError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConfigParseError'; + } +} + +// --------------------------------------------------------------------------- +// Public parser +// --------------------------------------------------------------------------- + +/** + * Parse and sanitise the content of a `trueref.json` or `context7.json` file. + * + * The function is intentionally lenient: unknown keys are silently ignored, + * mistyped values are skipped with a warning, and oversized strings/arrays are + * truncated. Only structural problems (not valid JSON, not an object) throw. + * + * @param content - Raw file content as a UTF-8 string. + * @param filename - File name used in error messages and to determine `source`. + * @returns ParsedConfig with sanitised config, source, and any warnings. + * @throws ConfigParseError on invalid JSON or non-object root. + */ +export function parseConfigFile(content: string, filename: string): ParsedConfig { + // ---- 1. JSON parse ------------------------------------------------------- + let raw: unknown; + + try { + raw = JSON.parse(content); + } catch (e) { + throw new ConfigParseError(`${filename} is not valid JSON: ${(e as Error).message}`); + } + + // ---- 2. Root must be an object ------------------------------------------ + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new ConfigParseError(`${filename} must be a JSON object, got ${Array.isArray(raw) ? 'array' : typeof raw}`); + } + + const input = raw as Record; + const validated: TrueRefConfig = {}; + const warnings: string[] = []; + + // ---- 3. projectTitle ---------------------------------------------------- + if (input.projectTitle !== undefined) { + if (typeof input.projectTitle !== 'string') { + warnings.push('projectTitle must be a string — ignoring'); + } else { + const { maxLength } = CONFIG_CONSTRAINTS.projectTitle; + if (input.projectTitle.length > maxLength) { + validated.projectTitle = input.projectTitle.slice(0, maxLength); + warnings.push(`projectTitle truncated to ${maxLength} characters`); + } else { + validated.projectTitle = input.projectTitle; + } + } + } + + // ---- 4. description ----------------------------------------------------- + if (input.description !== undefined) { + if (typeof input.description !== 'string') { + warnings.push('description must be a string — ignoring'); + } else { + const { maxLength } = CONFIG_CONSTRAINTS.description; + validated.description = input.description.slice(0, maxLength); + if (input.description.length > maxLength) { + warnings.push(`description truncated to ${maxLength} characters`); + } + } + } + + // ---- 5. folders / excludeFolders / excludeFiles ------------------------- + for (const field of ['folders', 'excludeFolders', 'excludeFiles'] as const) { + if (input[field] === undefined) continue; + + if (!Array.isArray(input[field])) { + warnings.push(`${field} must be an array — ignoring`); + continue; + } + + const maxItems = + field === 'excludeFiles' + ? CONFIG_CONSTRAINTS.excludeFiles.maxItems + : CONFIG_CONSTRAINTS.folders.maxItems; + + const maxLength = + field === 'excludeFiles' + ? CONFIG_CONSTRAINTS.excludeFiles.maxLength + : CONFIG_CONSTRAINTS.folders.maxLength; + + const arr = input[field] as unknown[]; + + const sanitised = arr + .filter((item): item is string => { + if (typeof item !== 'string') { + warnings.push(`${field} entry must be a string — skipping: ${JSON.stringify(item)}`); + return false; + } + return true; + }) + .map((item) => { + if (item.length > maxLength) { + warnings.push(`${field} entry truncated to ${maxLength} characters: "${item.slice(0, 40)}..."`); + return item.slice(0, maxLength); + } + return item; + }) + .slice(0, maxItems); + + if (arr.length > maxItems) { + warnings.push(`${field} has more than ${maxItems} entries — extras dropped`); + } + + validated[field] = sanitised; + } + + // ---- 6. rules ----------------------------------------------------------- + if (input.rules !== undefined) { + if (!Array.isArray(input.rules)) { + warnings.push('rules must be an array — ignoring'); + } else { + const { maxItems, minLength, maxLength } = CONFIG_CONSTRAINTS.rules; + const arr = input.rules as unknown[]; + + const sanitised = arr + .filter((r): r is string => { + if (typeof r !== 'string') { + warnings.push(`rules entry must be a string — skipping: ${JSON.stringify(r)}`); + return false; + } + if (r.length < minLength) { + warnings.push( + `rules entry too short (< ${minLength} chars) — skipping: "${r}"` + ); + return false; + } + return true; + }) + .map((r) => { + if (r.length > maxLength) { + warnings.push(`rules entry truncated to ${maxLength} characters`); + return r.slice(0, maxLength); + } + return r; + }) + .slice(0, maxItems); + + if (arr.length > maxItems) { + warnings.push(`rules has more than ${maxItems} entries — extras dropped`); + } + + validated.rules = sanitised; + } + } + + // ---- 7. previousVersions ------------------------------------------------ + if (input.previousVersions !== undefined) { + if (!Array.isArray(input.previousVersions)) { + warnings.push('previousVersions must be an array — ignoring'); + } else { + const { maxItems } = CONFIG_CONSTRAINTS.previousVersions; + const arr = input.previousVersions as unknown[]; + + const sanitised = arr + .filter((v): v is { tag: string; title: string } => { + if (typeof v !== 'object' || v === null || Array.isArray(v)) { + warnings.push( + `previousVersions entry must be an object — skipping: ${JSON.stringify(v)}` + ); + return false; + } + const obj = v as Record; + if (typeof obj.tag !== 'string') { + warnings.push( + `previousVersions entry missing string "tag" — skipping: ${JSON.stringify(v)}` + ); + return false; + } + if (typeof obj.title !== 'string') { + warnings.push( + `previousVersions entry missing string "title" — skipping: ${JSON.stringify(v)}` + ); + return false; + } + return true; + }) + .slice(0, maxItems); + + if (arr.length > maxItems) { + warnings.push(`previousVersions has more than ${maxItems} entries — extras dropped`); + } + + validated.previousVersions = sanitised; + } + } + + // ---- 8. Determine source ------------------------------------------------ + const source: ParsedConfig['source'] = filename.toLowerCase().startsWith('trueref') + ? 'trueref.json' + : 'context7.json'; + + return { config: validated, source, warnings }; +} + +// --------------------------------------------------------------------------- +// Multi-file resolver +// --------------------------------------------------------------------------- + +/** + * Given an array of (filename, content) pairs found in a repo root, parse + * whichever config file takes precedence (`trueref.json` > `context7.json`). + * + * Returns `null` if neither file is found in the list. + * + * @param candidates - Array of `{ filename, content }` objects to consider. + */ +export function resolveConfig( + candidates: Array<{ filename: string; content: string }> +): ParsedConfig | null { + // Prefer trueref.json over context7.json. + const ordered = [ + candidates.find((c) => c.filename.toLowerCase() === 'trueref.json'), + candidates.find((c) => c.filename.toLowerCase() === 'context7.json') + ].filter(Boolean) as Array<{ filename: string; content: string }>; + + if (ordered.length === 0) return null; + + const winner = ordered[0]; + return parseConfigFile(winner.content, winner.filename); +} diff --git a/src/lib/server/config/config-validator.ts b/src/lib/server/config/config-validator.ts new file mode 100644 index 0000000..69f5329 --- /dev/null +++ b/src/lib/server/config/config-validator.ts @@ -0,0 +1,151 @@ +/** + * Validator for parsed TrueRef config objects (TRUEREF-0013). + * + * Produces descriptive, field-scoped error messages so callers can surface + * exactly what is wrong with a user-supplied config file. + */ + +import type { TrueRefConfig } from '$lib/types'; +import { CONFIG_CONSTRAINTS } from './trueref-config.schema.js'; + +// --------------------------------------------------------------------------- +// Validation result types +// --------------------------------------------------------------------------- + +export interface ValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +// --------------------------------------------------------------------------- +// Public validator +// --------------------------------------------------------------------------- + +/** + * Validate a parsed `TrueRefConfig` object against all constraint rules. + * + * Returns a `ValidationResult` instead of throwing so callers can decide + * whether to abort or just surface warnings. + * + * @param config - The already-parsed (but not yet validated) config object. + */ +export function validateConfig(config: TrueRefConfig): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // ---- projectTitle ------------------------------------------------------- + if (config.projectTitle !== undefined) { + const { minLength, maxLength } = CONFIG_CONSTRAINTS.projectTitle; + if (config.projectTitle.length < minLength) { + errors.push(`projectTitle must be at least ${minLength} character(s) long`); + } + if (config.projectTitle.length > maxLength) { + warnings.push(`projectTitle exceeds ${maxLength} characters and was truncated`); + } + } + + // ---- description -------------------------------------------------------- + if (config.description !== undefined) { + const { minLength, maxLength } = CONFIG_CONSTRAINTS.description; + if (config.description.length < minLength) { + errors.push(`description must be at least ${minLength} characters long`); + } + if (config.description.length > maxLength) { + warnings.push(`description exceeds ${maxLength} characters and was truncated`); + } + } + + // ---- folders ------------------------------------------------------------ + if (config.folders !== undefined) { + const { maxItems, maxLength } = CONFIG_CONSTRAINTS.folders; + if (config.folders.length > maxItems) { + warnings.push(`folders has more than ${maxItems} entries; extras were dropped`); + } + for (const [i, entry] of config.folders.entries()) { + if (entry.length > maxLength) { + warnings.push(`folders[${i}] exceeds ${maxLength} characters: "${entry.slice(0, 40)}..."`); + } + } + } + + // ---- excludeFolders ----------------------------------------------------- + if (config.excludeFolders !== undefined) { + const { maxItems, maxLength } = CONFIG_CONSTRAINTS.excludeFolders; + if (config.excludeFolders.length > maxItems) { + warnings.push(`excludeFolders has more than ${maxItems} entries; extras were dropped`); + } + for (const [i, entry] of config.excludeFolders.entries()) { + if (entry.length > maxLength) { + warnings.push( + `excludeFolders[${i}] exceeds ${maxLength} characters: "${entry.slice(0, 40)}..."` + ); + } + } + } + + // ---- excludeFiles ------------------------------------------------------- + if (config.excludeFiles !== undefined) { + const { maxItems, maxLength } = CONFIG_CONSTRAINTS.excludeFiles; + if (config.excludeFiles.length > maxItems) { + warnings.push(`excludeFiles has more than ${maxItems} entries; extras were dropped`); + } + for (const [i, entry] of config.excludeFiles.entries()) { + if (entry.length > maxLength) { + warnings.push( + `excludeFiles[${i}] exceeds ${maxLength} characters: "${entry.slice(0, 40)}..."` + ); + } + // Exact filenames must not contain path separators. + if (entry.includes('/') || entry.includes('\\')) { + errors.push( + `excludeFiles[${i}] must be a plain filename without path separators: "${entry}"` + ); + } + } + } + + // ---- rules -------------------------------------------------------------- + if (config.rules !== undefined) { + const { maxItems, minLength, maxLength } = CONFIG_CONSTRAINTS.rules; + if (config.rules.length > maxItems) { + warnings.push(`rules has more than ${maxItems} entries; extras were dropped`); + } + for (const [i, rule] of config.rules.entries()) { + if (rule.length < minLength) { + errors.push(`rules[${i}] must be at least ${minLength} characters long`); + } + if (rule.length > maxLength) { + warnings.push(`rules[${i}] exceeds ${maxLength} characters and was truncated`); + } + } + } + + // ---- previousVersions --------------------------------------------------- + if (config.previousVersions !== undefined) { + const { maxItems } = CONFIG_CONSTRAINTS.previousVersions; + const { pattern } = CONFIG_CONSTRAINTS.versionTag; + + if (config.previousVersions.length > maxItems) { + warnings.push(`previousVersions has more than ${maxItems} entries; extras were dropped`); + } + + for (const [i, version] of config.previousVersions.entries()) { + if (!pattern.test(version.tag)) { + errors.push( + `previousVersions[${i}].tag "${version.tag}" does not match the expected pattern ` + + `(e.g. "v1.2.3", "1.2", "v2.0.0-beta.1")` + ); + } + if (!version.title || version.title.trim().length === 0) { + errors.push(`previousVersions[${i}].title must be a non-empty string`); + } + } + } + + return { + valid: errors.length === 0, + errors, + warnings + }; +} diff --git a/src/lib/server/config/trueref-config.json b/src/lib/server/config/trueref-config.json new file mode 100644 index 0000000..1d26074 --- /dev/null +++ b/src/lib/server/config/trueref-config.json @@ -0,0 +1,85 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://trueref.dev/schema/trueref-config.json", + "title": "TrueRef Repository Configuration", + "description": "Configuration file for controlling how a repository is indexed and presented by TrueRef. Place as trueref.json (or context7.json for backward compatibility) at the root of your repository.", + "type": "object", + "additionalProperties": false, + "properties": { + "projectTitle": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Override the display name for this library. When set, this replaces the repository name in search results and UI." + }, + "description": { + "type": "string", + "minLength": 10, + "maxLength": 500, + "description": "A short description of the library used for search ranking and display. Should accurately describe the library's purpose." + }, + "folders": { + "type": "array", + "maxItems": 50, + "description": "Allowlist of folder path prefixes or regex strings to include in indexing. If empty or absent, all folders are included. Examples: [\"src/\", \"docs/\", \"^packages/core\"]", + "items": { + "type": "string", + "maxLength": 200, + "description": "A path prefix or regex string. Paths are matched against the full relative file path within the repository." + } + }, + "excludeFolders": { + "type": "array", + "maxItems": 50, + "description": "Folders to exclude from indexing. Applied after the 'folders' allowlist. Examples: [\"test/\", \"fixtures/\", \"__mocks__\"]", + "items": { + "type": "string", + "maxLength": 200, + "description": "A path prefix or regex string for folders to exclude." + } + }, + "excludeFiles": { + "type": "array", + "maxItems": 100, + "description": "Exact filenames to exclude (no path, no regex). Examples: [\"README.md\", \"CHANGELOG.md\", \"jest.config.ts\"]", + "items": { + "type": "string", + "maxLength": 200, + "description": "An exact filename (not a path). Must not contain path separators." + } + }, + "rules": { + "type": "array", + "maxItems": 20, + "description": "Best practices and rules to inject at the top of every query-docs response. These are shown to AI coding assistants to guide correct library usage.", + "items": { + "type": "string", + "minLength": 5, + "maxLength": 500, + "description": "A single best-practice rule or guideline for using this library." + } + }, + "previousVersions": { + "type": "array", + "maxItems": 50, + "description": "Previously released versions to make available for versioned documentation queries.", + "items": { + "type": "object", + "required": ["tag", "title"], + "additionalProperties": false, + "properties": { + "tag": { + "type": "string", + "pattern": "^v?\\d+\\.\\d+(\\.\\d+)?(-.*)?$", + "description": "Git tag name for this version (e.g. \"v1.2.3\", \"2.0.0-beta.1\")." + }, + "title": { + "type": "string", + "minLength": 1, + "description": "Human-readable version label (e.g. \"Version 1.2.3\", \"v2 Legacy\")." + } + } + } + } + } +} diff --git a/src/lib/server/config/trueref-config.schema.ts b/src/lib/server/config/trueref-config.schema.ts new file mode 100644 index 0000000..fd215be --- /dev/null +++ b/src/lib/server/config/trueref-config.schema.ts @@ -0,0 +1,33 @@ +/** + * TypeScript types and constraint constants for the trueref.json config file + * (TRUEREF-0013). + * + * The `TrueRefConfig` interface is the canonical type. It is also exported from + * `$lib/types` for use across the application. This module exists as the + * single source of truth for validation constraints. + */ + +// --------------------------------------------------------------------------- +// Re-export the canonical type from the shared types module +// --------------------------------------------------------------------------- + +export type { TrueRefConfig } from '$lib/types'; + +// --------------------------------------------------------------------------- +// Validation constraints +// --------------------------------------------------------------------------- + +/** + * Constraint values used by the parser and validator. + * All length checks are in characters (not bytes). + */ +export const CONFIG_CONSTRAINTS = { + projectTitle: { minLength: 1, maxLength: 100 }, + description: { minLength: 10, maxLength: 500 }, + folders: { maxItems: 50, maxLength: 200 }, + excludeFolders: { maxItems: 50, maxLength: 200 }, + excludeFiles: { maxItems: 100, maxLength: 200 }, + rules: { maxItems: 20, minLength: 5, maxLength: 500 }, + previousVersions: { maxItems: 50 }, + versionTag: { pattern: /^v?\d+\.\d+(\.\d+)?(-.*)?$/ } +} as const; diff --git a/src/routes/api/v1/schema/trueref-config.json/+server.ts b/src/routes/api/v1/schema/trueref-config.json/+server.ts new file mode 100644 index 0000000..289fc42 --- /dev/null +++ b/src/routes/api/v1/schema/trueref-config.json/+server.ts @@ -0,0 +1,36 @@ +/** + * GET /api/v1/schema/trueref-config.json + * + * Returns the JSON Schema for the trueref.json repository configuration file. + * IDE tooling (VS Code, JetBrains, etc.) can reference this URL in the + * `$schema` field of a trueref.json file to get autocomplete and validation. + * + * Example trueref.json with schema reference: + * { + * "$schema": "http://localhost:5173/api/v1/schema/trueref-config.json", + * "projectTitle": "My Library", + * "rules": ["Always use named imports"] + * } + */ + +import type { RequestHandler } from './$types'; +import schema from '$lib/server/config/trueref-config.json' assert { type: 'json' }; +import { CORS_HEADERS } from '$lib/server/api/formatters'; + +export const GET: RequestHandler = () => { + return new Response(JSON.stringify(schema, null, 2), { + status: 200, + headers: { + 'Content-Type': 'application/schema+json', + 'Cache-Control': 'public, max-age=3600', + ...CORS_HEADERS + } + }); +}; + +export const OPTIONS: RequestHandler = () => { + return new Response(null, { + status: 204, + headers: CORS_HEADERS + }); +};