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 <noreply@anthropic.com>
This commit is contained in:
Giancarmine Salucci
2026-03-23 09:06:50 +01:00
parent b3c0849849
commit f31db2db2c
6 changed files with 1030 additions and 0 deletions

View File

@@ -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);
});
});

View File

@@ -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<string, unknown>;
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<string, unknown>;
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);
}

View File

@@ -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
};
}

View File

@@ -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\")."
}
}
}
}
}
}

View File

@@ -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;

View File

@@ -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
});
};