Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Personal Knowledge Base - Entity Type Definitions
|
||||
*
|
||||
* TypeScript interfaces for all knowledge graph entity types.
|
||||
* Properties use camelCase (the YAML files use kebab-case; these represent the parsed/deserialized form).
|
||||
*/
|
||||
|
||||
// --- Entity Types ---
|
||||
|
||||
/**
|
||||
* Union type of all supported entity types in the knowledge graph.
|
||||
*/
|
||||
export type EntityType =
|
||||
| 'person'
|
||||
| 'experience'
|
||||
| 'skill'
|
||||
| 'organization'
|
||||
| 'project'
|
||||
| 'certification'
|
||||
| 'tandem';
|
||||
|
||||
/**
|
||||
* Relationship types between entities in the knowledge graph.
|
||||
*/
|
||||
export type RelationshipType =
|
||||
| 'has_skill'
|
||||
| 'worked_at'
|
||||
| 'collaborated_with'
|
||||
| 'applied_for'
|
||||
| 'partners_with';
|
||||
|
||||
// --- Common Header ---
|
||||
|
||||
/**
|
||||
* Common header present on all entity files.
|
||||
*/
|
||||
export interface EntityHeader {
|
||||
/** Unique identifier in kebab-case */
|
||||
id: string;
|
||||
/** The entity type discriminator */
|
||||
type: EntityType;
|
||||
/** ISO-8601 date when the entity was created */
|
||||
created: string;
|
||||
/** ISO-8601 date when the entity was last modified */
|
||||
modified: string;
|
||||
}
|
||||
|
||||
// --- Person ---
|
||||
|
||||
export interface PersonName {
|
||||
first: string;
|
||||
last: string;
|
||||
display: string;
|
||||
}
|
||||
|
||||
export interface PersonContact {
|
||||
email?: string;
|
||||
linkedin?: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export interface Language {
|
||||
language: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
export interface Education {
|
||||
institution: string;
|
||||
degree: string;
|
||||
field: string;
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface Person extends EntityHeader {
|
||||
type: 'person';
|
||||
name: PersonName;
|
||||
contact?: PersonContact;
|
||||
summary?: string;
|
||||
languages?: Language[];
|
||||
education?: Education[];
|
||||
/** References to Skill entity IDs */
|
||||
skills?: string[];
|
||||
/** References to Experience entity IDs */
|
||||
experiences?: string[];
|
||||
/** References to Certification entity IDs */
|
||||
certifications?: string[];
|
||||
publications?: string[];
|
||||
}
|
||||
|
||||
// --- Experience ---
|
||||
|
||||
export interface Experience extends EntityHeader {
|
||||
type: 'experience';
|
||||
title: string;
|
||||
/** Reference to Organization entity ID */
|
||||
organization: string;
|
||||
start: string;
|
||||
end: string | null;
|
||||
description?: string;
|
||||
achievements?: string[];
|
||||
/** References to Skill entity IDs */
|
||||
skillsUsed?: string[];
|
||||
/** References to Project entity IDs */
|
||||
projects?: string[];
|
||||
}
|
||||
|
||||
// --- Skill ---
|
||||
|
||||
export type SkillCategory =
|
||||
| 'programming-language'
|
||||
| 'framework'
|
||||
| 'methodology'
|
||||
| 'soft-skill'
|
||||
| 'domain';
|
||||
|
||||
export type SkillLevel =
|
||||
| 'beginner'
|
||||
| 'intermediate'
|
||||
| 'advanced'
|
||||
| 'expert';
|
||||
|
||||
export interface Skill extends EntityHeader {
|
||||
type: 'skill';
|
||||
name: string;
|
||||
category: SkillCategory;
|
||||
level: SkillLevel;
|
||||
years?: number;
|
||||
context?: string;
|
||||
}
|
||||
|
||||
// --- Organization ---
|
||||
|
||||
export interface Organization extends EntityHeader {
|
||||
type: 'organization';
|
||||
name: string;
|
||||
industry: string;
|
||||
/** Reference to parent Organization entity ID */
|
||||
parent?: string;
|
||||
website?: string;
|
||||
}
|
||||
|
||||
// --- Project ---
|
||||
|
||||
export interface Project extends EntityHeader {
|
||||
type: 'project';
|
||||
name: string;
|
||||
description?: string;
|
||||
/** Reference to Organization entity ID */
|
||||
organization: string;
|
||||
start: string;
|
||||
end?: string;
|
||||
/** References to Skill entity IDs */
|
||||
skillsUsed?: string[];
|
||||
/** References to Person entity IDs */
|
||||
persons?: string[];
|
||||
}
|
||||
|
||||
// --- Certification ---
|
||||
|
||||
export interface Certification extends EntityHeader {
|
||||
type: 'certification';
|
||||
name: string;
|
||||
issuer: string;
|
||||
date: string;
|
||||
expires?: string;
|
||||
/** Reference to Person entity ID */
|
||||
person: string;
|
||||
}
|
||||
|
||||
// --- Tandem ---
|
||||
|
||||
export interface Tandem extends EntityHeader {
|
||||
type: 'tandem';
|
||||
/** Exactly 2 Person entity IDs */
|
||||
partners: [string, string];
|
||||
sharedVision: string;
|
||||
complementarySkills: string;
|
||||
collaborationModel: string;
|
||||
combinedNarrative?: string;
|
||||
jointCompetencies?: string[];
|
||||
}
|
||||
|
||||
// --- Graph Index ---
|
||||
|
||||
export interface GraphEntity {
|
||||
id: string;
|
||||
type: EntityType;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface GraphRelationship {
|
||||
from: string;
|
||||
to: string;
|
||||
type: RelationshipType;
|
||||
context?: string;
|
||||
}
|
||||
|
||||
export interface GraphIndex {
|
||||
entities: GraphEntity[];
|
||||
relationships: GraphRelationship[];
|
||||
}
|
||||
|
||||
// --- Union type for any entity ---
|
||||
|
||||
export type Entity =
|
||||
| Person
|
||||
| Experience
|
||||
| Skill
|
||||
| Organization
|
||||
| Project
|
||||
| Certification
|
||||
| Tandem;
|
||||
@@ -0,0 +1,661 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
validateEntity,
|
||||
validatePerson,
|
||||
validateExperience,
|
||||
validateSkill,
|
||||
validateOrganization,
|
||||
validateProject,
|
||||
validateCertification,
|
||||
validateTandem,
|
||||
isKebabCase,
|
||||
isValidDate,
|
||||
isTemporallyConsistent,
|
||||
ValidationResult,
|
||||
} from './validate';
|
||||
|
||||
// --- Helper: minimal valid entity headers ---
|
||||
const baseHeader = (type: string) => ({
|
||||
id: 'test-entity',
|
||||
type,
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
});
|
||||
|
||||
describe('isKebabCase', () => {
|
||||
it('accepts valid kebab-case strings', () => {
|
||||
expect(isKebabCase('hello')).toBe(true);
|
||||
expect(isKebabCase('hello-world')).toBe(true);
|
||||
expect(isKebabCase('a-b-c')).toBe(true);
|
||||
expect(isKebabCase('test123')).toBe(true);
|
||||
expect(isKebabCase('my-skill-2')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid kebab-case strings', () => {
|
||||
expect(isKebabCase('')).toBe(false);
|
||||
expect(isKebabCase('-leading')).toBe(false);
|
||||
expect(isKebabCase('trailing-')).toBe(false);
|
||||
expect(isKebabCase('UPPER')).toBe(false);
|
||||
expect(isKebabCase('camelCase')).toBe(false);
|
||||
expect(isKebabCase('has space')).toBe(false);
|
||||
expect(isKebabCase('double--hyphen')).toBe(false);
|
||||
expect(isKebabCase('under_score')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidDate', () => {
|
||||
it('accepts valid ISO dates', () => {
|
||||
expect(isValidDate('2025-01-15')).toBe(true);
|
||||
expect(isValidDate('2025-01')).toBe(true);
|
||||
expect(isValidDate('2020-12-31')).toBe(true);
|
||||
expect(isValidDate('1999-06')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid dates', () => {
|
||||
expect(isValidDate('')).toBe(false);
|
||||
expect(isValidDate('2025')).toBe(false);
|
||||
expect(isValidDate('2025-13')).toBe(false);
|
||||
expect(isValidDate('2025-00')).toBe(false);
|
||||
expect(isValidDate('not-a-date')).toBe(false);
|
||||
expect(isValidDate('2025/01/15')).toBe(false);
|
||||
expect(isValidDate('01-2025')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTemporallyConsistent', () => {
|
||||
it('returns true when end is null or undefined', () => {
|
||||
expect(isTemporallyConsistent('2025-01', null)).toBe(true);
|
||||
expect(isTemporallyConsistent('2025-01', undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when start <= end', () => {
|
||||
expect(isTemporallyConsistent('2020-01', '2025-01')).toBe(true);
|
||||
expect(isTemporallyConsistent('2025-01', '2025-01')).toBe(true);
|
||||
expect(isTemporallyConsistent('2025-01-01', '2025-01-02')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when start > end', () => {
|
||||
expect(isTemporallyConsistent('2025-06', '2025-01')).toBe(false);
|
||||
expect(isTemporallyConsistent('2025-01-15', '2025-01-01')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEntity', () => {
|
||||
it('rejects null/undefined', () => {
|
||||
expect(validateEntity(null).valid).toBe(false);
|
||||
expect(validateEntity(undefined).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-objects', () => {
|
||||
expect(validateEntity('string').valid).toBe(false);
|
||||
expect(validateEntity(42).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing type', () => {
|
||||
const result = validateEntity({ id: 'test' });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing required field: type');
|
||||
});
|
||||
|
||||
it('rejects invalid type', () => {
|
||||
const result = validateEntity({ ...baseHeader('invalid-type') });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('dispatches to correct validator', () => {
|
||||
const person = {
|
||||
...baseHeader('person'),
|
||||
name: { first: 'Andre', last: 'Knie', display: 'Andre Knie' },
|
||||
};
|
||||
expect(validateEntity(person).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePerson', () => {
|
||||
it('accepts a valid person', () => {
|
||||
const person = {
|
||||
...baseHeader('person'),
|
||||
name: { first: 'Andre', last: 'Knie', display: 'Andre Knie' },
|
||||
};
|
||||
expect(validatePerson(person).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing name', () => {
|
||||
const result = validatePerson(baseHeader('person'));
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing required field: name');
|
||||
});
|
||||
|
||||
it('rejects missing name subfields', () => {
|
||||
const person = { ...baseHeader('person'), name: { first: 'Andre' } };
|
||||
const result = validatePerson(person);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing required field: name.last');
|
||||
expect(result.errors).toContain('Missing required field: name.display');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateExperience', () => {
|
||||
it('accepts a valid experience', () => {
|
||||
const exp = {
|
||||
...baseHeader('experience'),
|
||||
title: 'Lead Developer',
|
||||
organization: 'db-cargo',
|
||||
start: '2023-04',
|
||||
end: null,
|
||||
};
|
||||
expect(validateExperience(exp).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing required fields', () => {
|
||||
const result = validateExperience(baseHeader('experience'));
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing required field: title');
|
||||
expect(result.errors).toContain('Missing required field: organization');
|
||||
expect(result.errors).toContain('Missing required field: start');
|
||||
});
|
||||
|
||||
it('detects temporal inconsistency', () => {
|
||||
const exp = {
|
||||
...baseHeader('experience'),
|
||||
title: 'Dev',
|
||||
organization: 'org',
|
||||
start: '2025-06',
|
||||
end: '2025-01',
|
||||
};
|
||||
const result = validateExperience(exp);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('Temporal inconsistency'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSkill', () => {
|
||||
it('accepts a valid skill', () => {
|
||||
const skill = {
|
||||
...baseHeader('skill'),
|
||||
name: 'Python',
|
||||
category: 'programming-language',
|
||||
level: 'expert',
|
||||
};
|
||||
expect(validateSkill(skill).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid category', () => {
|
||||
const skill = {
|
||||
...baseHeader('skill'),
|
||||
name: 'Python',
|
||||
category: 'invalid-cat',
|
||||
level: 'expert',
|
||||
};
|
||||
const result = validateSkill(skill);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('category'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid level', () => {
|
||||
const skill = {
|
||||
...baseHeader('skill'),
|
||||
name: 'Python',
|
||||
category: 'framework',
|
||||
level: 'master',
|
||||
};
|
||||
const result = validateSkill(skill);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('level'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateOrganization', () => {
|
||||
it('accepts a valid organization', () => {
|
||||
const org = {
|
||||
...baseHeader('organization'),
|
||||
name: 'DB Cargo AG',
|
||||
industry: 'logistics',
|
||||
};
|
||||
expect(validateOrganization(org).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing fields', () => {
|
||||
const result = validateOrganization(baseHeader('organization'));
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing required field: name');
|
||||
expect(result.errors).toContain('Missing required field: industry');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateProject', () => {
|
||||
it('accepts a valid project', () => {
|
||||
const project = {
|
||||
...baseHeader('project'),
|
||||
name: 'AI Platform',
|
||||
organization: 'db-cargo',
|
||||
start: '2024-01',
|
||||
};
|
||||
expect(validateProject(project).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('detects temporal inconsistency', () => {
|
||||
const project = {
|
||||
...baseHeader('project'),
|
||||
name: 'AI Platform',
|
||||
organization: 'db-cargo',
|
||||
start: '2025-06',
|
||||
end: '2024-01',
|
||||
};
|
||||
const result = validateProject(project);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('Temporal inconsistency'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCertification', () => {
|
||||
it('accepts a valid certification', () => {
|
||||
const cert = {
|
||||
...baseHeader('certification'),
|
||||
name: 'AWS Solutions Architect',
|
||||
issuer: 'Amazon Web Services',
|
||||
date: '2024-03',
|
||||
person: 'andre-knie',
|
||||
};
|
||||
expect(validateCertification(cert).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing fields', () => {
|
||||
const result = validateCertification(baseHeader('certification'));
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing required field: name');
|
||||
expect(result.errors).toContain('Missing required field: issuer');
|
||||
expect(result.errors).toContain('Missing required field: date');
|
||||
expect(result.errors).toContain('Missing required field: person');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTandem', () => {
|
||||
it('accepts a valid tandem', () => {
|
||||
const tandem = {
|
||||
...baseHeader('tandem'),
|
||||
partners: ['andre-knie', 'claudia-froldi'],
|
||||
sharedVision: 'Shared vision text',
|
||||
complementarySkills: 'Complementary skills text',
|
||||
collaborationModel: 'Collaboration model text',
|
||||
};
|
||||
expect(validateTandem(tandem).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects partners with wrong count', () => {
|
||||
const tandem = {
|
||||
...baseHeader('tandem'),
|
||||
partners: ['only-one'],
|
||||
sharedVision: 'Vision',
|
||||
complementarySkills: 'Skills',
|
||||
collaborationModel: 'Model',
|
||||
};
|
||||
const result = validateTandem(tandem);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('exactly 2'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-array partners', () => {
|
||||
const tandem = {
|
||||
...baseHeader('tandem'),
|
||||
partners: 'not-an-array',
|
||||
sharedVision: 'Vision',
|
||||
complementarySkills: 'Skills',
|
||||
collaborationModel: 'Model',
|
||||
};
|
||||
const result = validateTandem(tandem);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('must be an array'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing tandem-specific fields', () => {
|
||||
const tandem = {
|
||||
...baseHeader('tandem'),
|
||||
partners: ['a', 'b'],
|
||||
};
|
||||
const result = validateTandem(tandem);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing required field: sharedVision');
|
||||
expect(result.errors).toContain('Missing required field: complementarySkills');
|
||||
expect(result.errors).toContain('Missing required field: collaborationModel');
|
||||
});
|
||||
|
||||
it('rejects invalid id format in common fields', () => {
|
||||
const tandem = {
|
||||
id: 'Invalid-ID',
|
||||
type: 'tandem',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
partners: ['a', 'b'],
|
||||
sharedVision: 'Vision',
|
||||
complementarySkills: 'Skills',
|
||||
collaborationModel: 'Model',
|
||||
};
|
||||
const result = validateTandem(tandem);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('kebab-case'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Property-Based Tests (fast-check) ---
|
||||
|
||||
import fc from 'fast-check';
|
||||
|
||||
// --- Arbitraries ---
|
||||
|
||||
/** Generates a valid kebab-case ID */
|
||||
const kebabCaseId = fc
|
||||
.array(
|
||||
fc.stringMatching(/^[a-z0-9]{1,8}$/),
|
||||
{ minLength: 1, maxLength: 4 }
|
||||
)
|
||||
.map(parts => parts.join('-'));
|
||||
|
||||
/** Generates a valid ISO date (YYYY-MM or YYYY-MM-DD) */
|
||||
const isoDate = fc.oneof(
|
||||
fc.record({
|
||||
year: fc.integer({ min: 1990, max: 2030 }),
|
||||
month: fc.integer({ min: 1, max: 12 }),
|
||||
}).map(({ year, month }) => `${year}-${String(month).padStart(2, '0')}`),
|
||||
fc.record({
|
||||
year: fc.integer({ min: 1990, max: 2030 }),
|
||||
month: fc.integer({ min: 1, max: 12 }),
|
||||
day: fc.integer({ min: 1, max: 28 }),
|
||||
}).map(({ year, month, day }) => `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`)
|
||||
);
|
||||
|
||||
/** Generates a valid entity header */
|
||||
const entityHeader = (type: string) =>
|
||||
fc.record({
|
||||
id: kebabCaseId,
|
||||
type: fc.constant(type),
|
||||
created: isoDate,
|
||||
modified: isoDate,
|
||||
});
|
||||
|
||||
/** Generates a valid Person entity */
|
||||
const personArb = entityHeader('person').chain(header =>
|
||||
fc.record({
|
||||
first: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||
last: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||
display: fc.string({ minLength: 1, maxLength: 40 }).filter(s => s.trim().length > 0),
|
||||
}).map(name => ({
|
||||
...header,
|
||||
name: { first: name.first, last: name.last, display: name.display },
|
||||
}))
|
||||
);
|
||||
|
||||
/** Generates a valid Skill entity */
|
||||
const skillArb = entityHeader('skill').chain(header =>
|
||||
fc.record({
|
||||
name: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0),
|
||||
category: fc.constantFrom('programming-language', 'framework', 'methodology', 'soft-skill', 'domain'),
|
||||
level: fc.constantFrom('beginner', 'intermediate', 'advanced', 'expert'),
|
||||
}).map(fields => ({ ...header, ...fields }))
|
||||
);
|
||||
|
||||
/** Generates a valid Organization entity */
|
||||
const orgArb = entityHeader('organization').chain(header =>
|
||||
fc.record({
|
||||
name: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0),
|
||||
industry: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||
}).map(fields => ({ ...header, ...fields }))
|
||||
);
|
||||
|
||||
/** Generates a valid Experience entity */
|
||||
const experienceArb = entityHeader('experience').chain(header =>
|
||||
fc.record({
|
||||
title: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0),
|
||||
organization: kebabCaseId,
|
||||
start: isoDate,
|
||||
}).map(fields => ({ ...header, ...fields, end: null }))
|
||||
);
|
||||
|
||||
/** Generates a valid Project entity */
|
||||
const projectArb = entityHeader('project').chain(header =>
|
||||
fc.record({
|
||||
name: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0),
|
||||
organization: kebabCaseId,
|
||||
start: isoDate,
|
||||
}).map(fields => ({ ...header, ...fields }))
|
||||
);
|
||||
|
||||
/** Generates a valid Certification entity */
|
||||
const certArb = entityHeader('certification').chain(header =>
|
||||
fc.record({
|
||||
name: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0),
|
||||
issuer: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0),
|
||||
date: isoDate,
|
||||
person: kebabCaseId,
|
||||
}).map(fields => ({ ...header, ...fields }))
|
||||
);
|
||||
|
||||
/** Generates a valid Tandem entity */
|
||||
const tandemArb = entityHeader('tandem').chain(header =>
|
||||
fc.record({
|
||||
partner1: kebabCaseId,
|
||||
partner2: kebabCaseId,
|
||||
sharedVision: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||
complementarySkills: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||
collaborationModel: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||
}).map(fields => ({
|
||||
...header,
|
||||
partners: [fields.partner1, fields.partner2] as [string, string],
|
||||
sharedVision: fields.sharedVision,
|
||||
complementarySkills: fields.complementarySkills,
|
||||
collaborationModel: fields.collaborationModel,
|
||||
}))
|
||||
);
|
||||
|
||||
/** Generates any valid entity */
|
||||
const anyValidEntity = fc.oneof(
|
||||
personArb,
|
||||
skillArb,
|
||||
orgArb,
|
||||
experienceArb,
|
||||
projectArb,
|
||||
certArb,
|
||||
tandemArb,
|
||||
);
|
||||
|
||||
// --- Property 1: Entity serialization round-trip ---
|
||||
|
||||
describe('Property 1: Entity serialization round-trip', () => {
|
||||
it('JSON.parse(JSON.stringify(entity)) produces an equivalent entity that passes validation', () => {
|
||||
fc.assert(
|
||||
fc.property(anyValidEntity, (entity) => {
|
||||
const roundTripped = JSON.parse(JSON.stringify(entity));
|
||||
// All attributes preserved
|
||||
expect(roundTripped).toEqual(entity);
|
||||
// Still passes validation
|
||||
const result = validateEntity(roundTripped);
|
||||
expect(result.valid).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates: Requirements 2.1, 8.2
|
||||
*/
|
||||
});
|
||||
|
||||
// --- Property 2: Entity schema validation ---
|
||||
|
||||
describe('Property 2: Entity schema validation', () => {
|
||||
it('accepts any valid entity with all required attributes present and correctly typed', () => {
|
||||
fc.assert(
|
||||
fc.property(anyValidEntity, (entity) => {
|
||||
const result = validateEntity(entity);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects entities missing required fields', () => {
|
||||
const entityTypes = ['person', 'experience', 'skill', 'organization', 'project', 'certification', 'tandem'] as const;
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...entityTypes),
|
||||
kebabCaseId,
|
||||
isoDate,
|
||||
isoDate,
|
||||
(type, id, created, modified) => {
|
||||
// Entity with only header fields — missing type-specific required fields
|
||||
const entity = { id, type, created, modified };
|
||||
const result = validateEntity(entity);
|
||||
// Person needs name, experience needs title/org/start, etc.
|
||||
// All types have additional required fields beyond the header
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates: Requirements 1.1, 1.3
|
||||
*/
|
||||
});
|
||||
|
||||
// --- Property 3: Temporal consistency ---
|
||||
|
||||
describe('Property 3: Temporal consistency', () => {
|
||||
it('validation passes when start <= end for time-bound entities', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
kebabCaseId,
|
||||
isoDate,
|
||||
isoDate,
|
||||
fc.constantFrom('experience', 'project'),
|
||||
(id, date1, date2, type) => {
|
||||
const [start, end] = date1 <= date2 ? [date1, date2] : [date2, date1];
|
||||
const entity: Record<string, unknown> = {
|
||||
id,
|
||||
type,
|
||||
created: start,
|
||||
modified: start,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
if (type === 'experience') {
|
||||
entity.title = 'Test Role';
|
||||
entity.organization = 'test-org';
|
||||
} else {
|
||||
entity.name = 'Test Project';
|
||||
entity.organization = 'test-org';
|
||||
}
|
||||
const result = validateEntity(entity);
|
||||
// Should not have temporal inconsistency errors
|
||||
const temporalErrors = result.errors.filter(e => e.includes('Temporal inconsistency'));
|
||||
expect(temporalErrors).toHaveLength(0);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('validation fails when start > end for time-bound entities', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
kebabCaseId,
|
||||
fc.record({
|
||||
year: fc.integer({ min: 1990, max: 2025 }),
|
||||
month: fc.integer({ min: 1, max: 12 }),
|
||||
}),
|
||||
fc.integer({ min: 1, max: 60 }),
|
||||
fc.constantFrom('experience', 'project'),
|
||||
(id, startParts, monthsLater, type) => {
|
||||
const startStr = `${startParts.year}-${String(startParts.month).padStart(2, '0')}`;
|
||||
// Compute an end date that is strictly before start
|
||||
const endYear = startParts.year - Math.ceil(monthsLater / 12);
|
||||
const endMonth = ((startParts.month - (monthsLater % 12) - 1 + 12) % 12) + 1;
|
||||
const endStr = `${endYear}-${String(endMonth).padStart(2, '0')}`;
|
||||
|
||||
// Only test when end is actually before start (string comparison)
|
||||
fc.pre(endStr < startStr);
|
||||
|
||||
const entity: Record<string, unknown> = {
|
||||
id,
|
||||
type,
|
||||
created: startStr,
|
||||
modified: startStr,
|
||||
start: startStr,
|
||||
end: endStr,
|
||||
};
|
||||
if (type === 'experience') {
|
||||
entity.title = 'Test Role';
|
||||
entity.organization = 'test-org';
|
||||
} else {
|
||||
entity.name = 'Test Project';
|
||||
entity.organization = 'test-org';
|
||||
}
|
||||
const result = validateEntity(entity);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('Temporal inconsistency'))).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates: Requirements 1.4
|
||||
*/
|
||||
});
|
||||
|
||||
// --- Property 15: File naming convention ---
|
||||
|
||||
describe('Property 15: File naming convention', () => {
|
||||
it('any valid entity ID produces a file name matching <entity-id>.yaml with kebab-case', () => {
|
||||
fc.assert(
|
||||
fc.property(kebabCaseId, (id) => {
|
||||
const fileName = `${id}.yaml`;
|
||||
// File name matches pattern: lowercase letters, numbers, hyphens, ending in .yaml
|
||||
expect(fileName).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*\.yaml$/);
|
||||
// The ID itself is valid kebab-case
|
||||
expect(isKebabCase(id)).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('non-kebab-case IDs are rejected by validation', () => {
|
||||
const invalidIds = fc.oneof(
|
||||
fc.constant(''),
|
||||
fc.constant('-leading'),
|
||||
fc.constant('trailing-'),
|
||||
fc.constant('UPPERCASE'),
|
||||
fc.constant('camelCase'),
|
||||
fc.constant('has space'),
|
||||
fc.constant('double--hyphen'),
|
||||
fc.constant('under_score'),
|
||||
fc.stringMatching(/^[A-Z]{1,10}$/),
|
||||
);
|
||||
|
||||
fc.assert(
|
||||
fc.property(invalidIds, isoDate, isoDate, (id, created, modified) => {
|
||||
const entity = {
|
||||
id,
|
||||
type: 'skill',
|
||||
created,
|
||||
modified,
|
||||
name: 'Test',
|
||||
category: 'framework',
|
||||
level: 'expert',
|
||||
};
|
||||
const result = validateEntity(entity);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('kebab-case') || e.includes('id'))).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates: Requirements 9.2
|
||||
*/
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Personal Knowledge Base - Entity Validation Functions
|
||||
*
|
||||
* Validates entity objects against their schema definitions.
|
||||
* Returns descriptive errors listing missing or invalid fields.
|
||||
*/
|
||||
|
||||
import type {
|
||||
EntityType,
|
||||
SkillCategory,
|
||||
SkillLevel,
|
||||
} from './types';
|
||||
|
||||
// --- Validation Result ---
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
const VALID_ENTITY_TYPES: EntityType[] = [
|
||||
'person',
|
||||
'experience',
|
||||
'skill',
|
||||
'organization',
|
||||
'project',
|
||||
'certification',
|
||||
'tandem',
|
||||
];
|
||||
|
||||
const VALID_SKILL_CATEGORIES: SkillCategory[] = [
|
||||
'programming-language',
|
||||
'framework',
|
||||
'methodology',
|
||||
'soft-skill',
|
||||
'domain',
|
||||
];
|
||||
|
||||
const VALID_SKILL_LEVELS: SkillLevel[] = [
|
||||
'beginner',
|
||||
'intermediate',
|
||||
'advanced',
|
||||
'expert',
|
||||
];
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
/**
|
||||
* Validates kebab-case format: lowercase letters, numbers, and hyphens only.
|
||||
* No leading or trailing hyphens allowed.
|
||||
*/
|
||||
export function isKebabCase(id: string): boolean {
|
||||
if (typeof id !== 'string' || id.length === 0) return false;
|
||||
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates ISO date format: YYYY-MM-DD or YYYY-MM.
|
||||
*/
|
||||
export function isValidDate(date: string): boolean {
|
||||
if (typeof date !== 'string') return false;
|
||||
return /^\d{4}-(0[1-9]|1[0-2])(-([0-2]\d|3[01]))?$/.test(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates temporal consistency: start <= end when both are present.
|
||||
* Returns true if end is null/undefined (open-ended) or start <= end.
|
||||
*/
|
||||
export function isTemporallyConsistent(
|
||||
start: string,
|
||||
end: string | null | undefined
|
||||
): boolean {
|
||||
if (end === null || end === undefined) return true;
|
||||
return start <= end;
|
||||
}
|
||||
|
||||
// --- Common Validation ---
|
||||
|
||||
function validateCommonFields(entity: Record<string, unknown>): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!entity.id) {
|
||||
errors.push('Missing required field: id');
|
||||
} else if (typeof entity.id !== 'string') {
|
||||
errors.push('Field "id" must be a string');
|
||||
} else if (!isKebabCase(entity.id)) {
|
||||
errors.push('Field "id" must be in kebab-case format (lowercase letters, numbers, hyphens only, no leading/trailing hyphens)');
|
||||
}
|
||||
|
||||
if (!entity.type) {
|
||||
errors.push('Missing required field: type');
|
||||
} else if (typeof entity.type !== 'string') {
|
||||
errors.push('Field "type" must be a string');
|
||||
} else if (!VALID_ENTITY_TYPES.includes(entity.type as EntityType)) {
|
||||
errors.push(`Field "type" must be one of: ${VALID_ENTITY_TYPES.join(', ')}`);
|
||||
}
|
||||
|
||||
if (!entity.created) {
|
||||
errors.push('Missing required field: created');
|
||||
} else if (typeof entity.created !== 'string') {
|
||||
errors.push('Field "created" must be a string');
|
||||
} else if (!isValidDate(entity.created)) {
|
||||
errors.push('Field "created" must be a valid ISO date (YYYY-MM-DD or YYYY-MM)');
|
||||
}
|
||||
|
||||
if (!entity.modified) {
|
||||
errors.push('Missing required field: modified');
|
||||
} else if (typeof entity.modified !== 'string') {
|
||||
errors.push('Field "modified" must be a string');
|
||||
} else if (!isValidDate(entity.modified)) {
|
||||
errors.push('Field "modified" must be a valid ISO date (YYYY-MM-DD or YYYY-MM)');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// --- Entity-Specific Validators ---
|
||||
|
||||
export function validatePerson(entity: Record<string, unknown>): ValidationResult {
|
||||
const errors = validateCommonFields(entity);
|
||||
|
||||
if (!entity.name) {
|
||||
errors.push('Missing required field: name');
|
||||
} else if (typeof entity.name !== 'object' || entity.name === null) {
|
||||
errors.push('Field "name" must be an object');
|
||||
} else {
|
||||
const name = entity.name as Record<string, unknown>;
|
||||
if (!name.first || typeof name.first !== 'string') {
|
||||
errors.push('Missing required field: name.first');
|
||||
}
|
||||
if (!name.last || typeof name.last !== 'string') {
|
||||
errors.push('Missing required field: name.last');
|
||||
}
|
||||
if (!name.display || typeof name.display !== 'string') {
|
||||
errors.push('Missing required field: name.display');
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
export function validateExperience(entity: Record<string, unknown>): ValidationResult {
|
||||
const errors = validateCommonFields(entity);
|
||||
|
||||
if (!entity.title || typeof entity.title !== 'string') {
|
||||
errors.push('Missing required field: title');
|
||||
}
|
||||
|
||||
if (!entity.organization || typeof entity.organization !== 'string') {
|
||||
errors.push('Missing required field: organization');
|
||||
}
|
||||
|
||||
if (!entity.start) {
|
||||
errors.push('Missing required field: start');
|
||||
} else if (typeof entity.start !== 'string') {
|
||||
errors.push('Field "start" must be a string');
|
||||
} else if (!isValidDate(entity.start)) {
|
||||
errors.push('Field "start" must be a valid ISO date (YYYY-MM-DD or YYYY-MM)');
|
||||
}
|
||||
|
||||
// Temporal consistency check
|
||||
if (
|
||||
entity.start &&
|
||||
typeof entity.start === 'string' &&
|
||||
entity.end !== null &&
|
||||
entity.end !== undefined &&
|
||||
typeof entity.end === 'string'
|
||||
) {
|
||||
if (!isValidDate(entity.end)) {
|
||||
errors.push('Field "end" must be a valid ISO date (YYYY-MM-DD or YYYY-MM)');
|
||||
} else if (!isTemporallyConsistent(entity.start, entity.end)) {
|
||||
errors.push('Temporal inconsistency: start date must be less than or equal to end date');
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
export function validateSkill(entity: Record<string, unknown>): ValidationResult {
|
||||
const errors = validateCommonFields(entity);
|
||||
|
||||
if (!entity.name || typeof entity.name !== 'string') {
|
||||
errors.push('Missing required field: name');
|
||||
}
|
||||
|
||||
if (!entity.category) {
|
||||
errors.push('Missing required field: category');
|
||||
} else if (typeof entity.category !== 'string') {
|
||||
errors.push('Field "category" must be a string');
|
||||
} else if (!VALID_SKILL_CATEGORIES.includes(entity.category as SkillCategory)) {
|
||||
errors.push(`Field "category" must be one of: ${VALID_SKILL_CATEGORIES.join(', ')}`);
|
||||
}
|
||||
|
||||
if (!entity.level) {
|
||||
errors.push('Missing required field: level');
|
||||
} else if (typeof entity.level !== 'string') {
|
||||
errors.push('Field "level" must be a string');
|
||||
} else if (!VALID_SKILL_LEVELS.includes(entity.level as SkillLevel)) {
|
||||
errors.push(`Field "level" must be one of: ${VALID_SKILL_LEVELS.join(', ')}`);
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
export function validateOrganization(entity: Record<string, unknown>): ValidationResult {
|
||||
const errors = validateCommonFields(entity);
|
||||
|
||||
if (!entity.name || typeof entity.name !== 'string') {
|
||||
errors.push('Missing required field: name');
|
||||
}
|
||||
|
||||
if (!entity.industry || typeof entity.industry !== 'string') {
|
||||
errors.push('Missing required field: industry');
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
export function validateProject(entity: Record<string, unknown>): ValidationResult {
|
||||
const errors = validateCommonFields(entity);
|
||||
|
||||
if (!entity.name || typeof entity.name !== 'string') {
|
||||
errors.push('Missing required field: name');
|
||||
}
|
||||
|
||||
if (!entity.organization || typeof entity.organization !== 'string') {
|
||||
errors.push('Missing required field: organization');
|
||||
}
|
||||
|
||||
if (!entity.start) {
|
||||
errors.push('Missing required field: start');
|
||||
} else if (typeof entity.start !== 'string') {
|
||||
errors.push('Field "start" must be a string');
|
||||
} else if (!isValidDate(entity.start)) {
|
||||
errors.push('Field "start" must be a valid ISO date (YYYY-MM-DD or YYYY-MM)');
|
||||
}
|
||||
|
||||
// Temporal consistency check
|
||||
if (
|
||||
entity.start &&
|
||||
typeof entity.start === 'string' &&
|
||||
entity.end !== null &&
|
||||
entity.end !== undefined &&
|
||||
typeof entity.end === 'string'
|
||||
) {
|
||||
if (!isValidDate(entity.end)) {
|
||||
errors.push('Field "end" must be a valid ISO date (YYYY-MM-DD or YYYY-MM)');
|
||||
} else if (!isTemporallyConsistent(entity.start, entity.end)) {
|
||||
errors.push('Temporal inconsistency: start date must be less than or equal to end date');
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
export function validateCertification(entity: Record<string, unknown>): ValidationResult {
|
||||
const errors = validateCommonFields(entity);
|
||||
|
||||
if (!entity.name || typeof entity.name !== 'string') {
|
||||
errors.push('Missing required field: name');
|
||||
}
|
||||
|
||||
if (!entity.issuer || typeof entity.issuer !== 'string') {
|
||||
errors.push('Missing required field: issuer');
|
||||
}
|
||||
|
||||
if (!entity.date) {
|
||||
errors.push('Missing required field: date');
|
||||
} else if (typeof entity.date !== 'string') {
|
||||
errors.push('Field "date" must be a string');
|
||||
} else if (!isValidDate(entity.date)) {
|
||||
errors.push('Field "date" must be a valid ISO date (YYYY-MM-DD or YYYY-MM)');
|
||||
}
|
||||
|
||||
if (!entity.person || typeof entity.person !== 'string') {
|
||||
errors.push('Missing required field: person');
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
export function validateTandem(entity: Record<string, unknown>): ValidationResult {
|
||||
const errors = validateCommonFields(entity);
|
||||
|
||||
if (!entity.partners) {
|
||||
errors.push('Missing required field: partners');
|
||||
} else if (!Array.isArray(entity.partners)) {
|
||||
errors.push('Field "partners" must be an array');
|
||||
} else if (entity.partners.length !== 2) {
|
||||
errors.push('Field "partners" must contain exactly 2 entries');
|
||||
} else if (
|
||||
typeof entity.partners[0] !== 'string' ||
|
||||
typeof entity.partners[1] !== 'string'
|
||||
) {
|
||||
errors.push('Field "partners" must contain exactly 2 strings');
|
||||
}
|
||||
|
||||
if (!entity.sharedVision || typeof entity.sharedVision !== 'string') {
|
||||
errors.push('Missing required field: sharedVision');
|
||||
}
|
||||
|
||||
if (!entity.complementarySkills || typeof entity.complementarySkills !== 'string') {
|
||||
errors.push('Missing required field: complementarySkills');
|
||||
}
|
||||
|
||||
if (!entity.collaborationModel || typeof entity.collaborationModel !== 'string') {
|
||||
errors.push('Missing required field: collaborationModel');
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// --- Main Dispatch Function ---
|
||||
|
||||
/**
|
||||
* Validates an entity by dispatching to the appropriate validator based on the `type` field.
|
||||
* Returns a ValidationResult with descriptive errors if validation fails.
|
||||
*/
|
||||
export function validateEntity(entity: unknown): ValidationResult {
|
||||
if (entity === null || entity === undefined) {
|
||||
return { valid: false, errors: ['Entity must not be null or undefined'] };
|
||||
}
|
||||
|
||||
if (typeof entity !== 'object') {
|
||||
return { valid: false, errors: ['Entity must be an object'] };
|
||||
}
|
||||
|
||||
const obj = entity as Record<string, unknown>;
|
||||
|
||||
if (!obj.type || typeof obj.type !== 'string') {
|
||||
return { valid: false, errors: ['Missing required field: type'] };
|
||||
}
|
||||
|
||||
switch (obj.type) {
|
||||
case 'person':
|
||||
return validatePerson(obj);
|
||||
case 'experience':
|
||||
return validateExperience(obj);
|
||||
case 'skill':
|
||||
return validateSkill(obj);
|
||||
case 'organization':
|
||||
return validateOrganization(obj);
|
||||
case 'project':
|
||||
return validateProject(obj);
|
||||
case 'certification':
|
||||
return validateCertification(obj);
|
||||
case 'tandem':
|
||||
return validateTandem(obj);
|
||||
default:
|
||||
return {
|
||||
valid: false,
|
||||
errors: [`Field "type" must be one of: ${VALID_ENTITY_TYPES.join(', ')}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user