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:
464
src/lib/server/config/config-parser.test.ts
Normal file
464
src/lib/server/config/config-parser.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
261
src/lib/server/config/config-parser.ts
Normal file
261
src/lib/server/config/config-parser.ts
Normal 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);
|
||||
}
|
||||
151
src/lib/server/config/config-validator.ts
Normal file
151
src/lib/server/config/config-validator.ts
Normal 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
|
||||
};
|
||||
}
|
||||
85
src/lib/server/config/trueref-config.json
Normal file
85
src/lib/server/config/trueref-config.json
Normal 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\")."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/lib/server/config/trueref-config.schema.ts
Normal file
33
src/lib/server/config/trueref-config.schema.ts
Normal 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;
|
||||
Reference in New Issue
Block a user