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,825 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm, readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import {
|
||||
readGraphIndex,
|
||||
writeGraphIndex,
|
||||
addEntity,
|
||||
removeEntity,
|
||||
addRelationship,
|
||||
removeRelationship,
|
||||
validateRelationshipType,
|
||||
getGraphIndexPath,
|
||||
rebuildGraphIndex,
|
||||
} from './index-manager';
|
||||
import { writeEntity } from '../io/entity-files';
|
||||
|
||||
describe('index-manager', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'graph-index-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('readGraphIndex', () => {
|
||||
it('returns empty index when file does not exist', async () => {
|
||||
const index = await readGraphIndex(tempDir);
|
||||
expect(index).toEqual({ entities: [], relationships: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeGraphIndex / readGraphIndex round-trip', () => {
|
||||
it('writes and reads back the same index', async () => {
|
||||
const index = {
|
||||
entities: [{ id: 'andre-knie', type: 'person' as const, name: 'Andre Knie' }],
|
||||
relationships: [{ from: 'andre-knie', to: 'python', type: 'has_skill' as const }],
|
||||
};
|
||||
await writeGraphIndex(index, tempDir);
|
||||
const result = await readGraphIndex(tempDir);
|
||||
expect(result).toEqual(index);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addEntity', () => {
|
||||
it('adds a new entity to an empty index', async () => {
|
||||
await addEntity({ id: 'python', type: 'skill', name: 'Python' }, tempDir);
|
||||
const index = await readGraphIndex(tempDir);
|
||||
expect(index.entities).toHaveLength(1);
|
||||
expect(index.entities[0]).toEqual({ id: 'python', type: 'skill', name: 'Python' });
|
||||
});
|
||||
|
||||
it('replaces an existing entity with the same ID', async () => {
|
||||
await addEntity({ id: 'python', type: 'skill', name: 'Python' }, tempDir);
|
||||
await addEntity({ id: 'python', type: 'skill', name: 'Python 3' }, tempDir);
|
||||
const index = await readGraphIndex(tempDir);
|
||||
expect(index.entities).toHaveLength(1);
|
||||
expect(index.entities[0].name).toBe('Python 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeEntity', () => {
|
||||
it('removes an entity and its relationships', async () => {
|
||||
await writeGraphIndex(
|
||||
{
|
||||
entities: [
|
||||
{ id: 'andre-knie', type: 'person', name: 'Andre Knie' },
|
||||
{ id: 'python', type: 'skill', name: 'Python' },
|
||||
],
|
||||
relationships: [
|
||||
{ from: 'andre-knie', to: 'python', type: 'has_skill' },
|
||||
],
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
await removeEntity('python', tempDir);
|
||||
const index = await readGraphIndex(tempDir);
|
||||
expect(index.entities).toHaveLength(1);
|
||||
expect(index.entities[0].id).toBe('andre-knie');
|
||||
expect(index.relationships).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRelationship', () => {
|
||||
it('adds a valid relationship', async () => {
|
||||
await addRelationship({ from: 'andre-knie', to: 'db-cargo', type: 'worked_at' }, tempDir);
|
||||
const index = await readGraphIndex(tempDir);
|
||||
expect(index.relationships).toHaveLength(1);
|
||||
expect(index.relationships[0]).toEqual({ from: 'andre-knie', to: 'db-cargo', type: 'worked_at' });
|
||||
});
|
||||
|
||||
it('does not duplicate identical relationships', async () => {
|
||||
await addRelationship({ from: 'andre-knie', to: 'db-cargo', type: 'worked_at' }, tempDir);
|
||||
await addRelationship({ from: 'andre-knie', to: 'db-cargo', type: 'worked_at' }, tempDir);
|
||||
const index = await readGraphIndex(tempDir);
|
||||
expect(index.relationships).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects invalid relationship types', async () => {
|
||||
await expect(
|
||||
addRelationship({ from: 'a', to: 'b', type: 'invalid_type' as any }, tempDir)
|
||||
).rejects.toThrow('Invalid relationship type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeRelationship', () => {
|
||||
it('removes a matching relationship', async () => {
|
||||
await writeGraphIndex(
|
||||
{
|
||||
entities: [],
|
||||
relationships: [
|
||||
{ from: 'andre-knie', to: 'python', type: 'has_skill' },
|
||||
{ from: 'andre-knie', to: 'db-cargo', type: 'worked_at' },
|
||||
],
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
await removeRelationship('andre-knie', 'python', 'has_skill', tempDir);
|
||||
const index = await readGraphIndex(tempDir);
|
||||
expect(index.relationships).toHaveLength(1);
|
||||
expect(index.relationships[0].type).toBe('worked_at');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRelationshipType', () => {
|
||||
it('accepts all valid types', () => {
|
||||
const validTypes = ['has_skill', 'worked_at', 'collaborated_with', 'applied_for', 'partners_with'];
|
||||
for (const t of validTypes) {
|
||||
expect(() => validateRelationshipType(t)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid types', () => {
|
||||
expect(() => validateRelationshipType('likes')).toThrow('Invalid relationship type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGraphIndexPath', () => {
|
||||
it('returns correct path relative to basePath', () => {
|
||||
const path = getGraphIndexPath('/my/project');
|
||||
expect(path).toBe(join('/my/project', 'kb', 'graph-index.yaml'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rebuildGraphIndex', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'rebuild-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns empty index when no entities exist', async () => {
|
||||
const result = await rebuildGraphIndex(tempDir);
|
||||
expect(result.index.entities).toEqual([]);
|
||||
expect(result.index.relationships).toEqual([]);
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds entities list from entity files', async () => {
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'python',
|
||||
type: 'skill',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
name: 'Python',
|
||||
category: 'programming-language',
|
||||
level: 'expert',
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'db-cargo',
|
||||
type: 'organization',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
name: 'DB Cargo AG',
|
||||
industry: 'logistics',
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
|
||||
const result = await rebuildGraphIndex(tempDir);
|
||||
expect(result.index.entities).toHaveLength(2);
|
||||
const ids = result.index.entities.map((e) => e.id).sort();
|
||||
expect(ids).toEqual(['db-cargo', 'python']);
|
||||
});
|
||||
|
||||
it('infers has_skill relationships from person skills', async () => {
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'python',
|
||||
type: 'skill',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
name: 'Python',
|
||||
category: 'programming-language',
|
||||
level: 'expert',
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'andre-knie',
|
||||
type: 'person',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
name: { first: 'Andre', last: 'Knie', display: 'Andre Knie' },
|
||||
skills: ['python'],
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
|
||||
const result = await rebuildGraphIndex(tempDir);
|
||||
expect(result.index.relationships).toContainEqual({
|
||||
from: 'andre-knie',
|
||||
to: 'python',
|
||||
type: 'has_skill',
|
||||
});
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('infers worked_at from experience organization', async () => {
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'db-cargo',
|
||||
type: 'organization',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
name: 'DB Cargo AG',
|
||||
industry: 'logistics',
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'lead-innovation',
|
||||
type: 'experience',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
title: 'Lead Innovation',
|
||||
organization: 'db-cargo',
|
||||
start: '2023-04',
|
||||
end: null,
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
|
||||
const result = await rebuildGraphIndex(tempDir);
|
||||
expect(result.index.relationships).toContainEqual({
|
||||
from: 'lead-innovation',
|
||||
to: 'db-cargo',
|
||||
type: 'worked_at',
|
||||
});
|
||||
});
|
||||
|
||||
it('infers partners_with from tandem entity', async () => {
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'andre-knie',
|
||||
type: 'person',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
name: { first: 'Andre', last: 'Knie', display: 'Andre Knie' },
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'claudia-froldi',
|
||||
type: 'person',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
name: { first: 'Claudia', last: 'Froldi', display: 'Claudia Froldi' },
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'froldi-knie',
|
||||
type: 'tandem',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
partners: ['andre-knie', 'claudia-froldi'],
|
||||
sharedVision: 'Shared vision text',
|
||||
complementarySkills: 'Complementary skills text',
|
||||
collaborationModel: 'Collaboration model text',
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
|
||||
const result = await rebuildGraphIndex(tempDir);
|
||||
expect(result.index.relationships).toContainEqual({
|
||||
from: 'andre-knie',
|
||||
to: 'claudia-froldi',
|
||||
type: 'partners_with',
|
||||
context: 'froldi-knie',
|
||||
});
|
||||
});
|
||||
|
||||
it('infers collaborated_with from project persons', async () => {
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'andre-knie',
|
||||
type: 'person',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
name: { first: 'Andre', last: 'Knie', display: 'Andre Knie' },
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'claudia-froldi',
|
||||
type: 'person',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
name: { first: 'Claudia', last: 'Froldi', display: 'Claudia Froldi' },
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'db-cargo',
|
||||
type: 'organization',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
name: 'DB Cargo AG',
|
||||
industry: 'logistics',
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'ai-project',
|
||||
type: 'project',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
name: 'AI Project',
|
||||
organization: 'db-cargo',
|
||||
start: '2024-01',
|
||||
persons: ['andre-knie', 'claudia-froldi'],
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
|
||||
const result = await rebuildGraphIndex(tempDir);
|
||||
expect(result.index.relationships).toContainEqual({
|
||||
from: 'andre-knie',
|
||||
to: 'claudia-froldi',
|
||||
type: 'collaborated_with',
|
||||
context: 'ai-project',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects broken references and adds warnings', async () => {
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'andre-knie',
|
||||
type: 'person',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
name: { first: 'Andre', last: 'Knie', display: 'Andre Knie' },
|
||||
skills: ['nonexistent-skill'],
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
|
||||
const result = await rebuildGraphIndex(tempDir);
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
expect(result.warnings[0]).toContain('nonexistent-skill');
|
||||
// Broken references should NOT appear in relationships
|
||||
expect(result.index.relationships).toEqual([]);
|
||||
});
|
||||
|
||||
it('writes index to disk when writeToDisk is true', async () => {
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'python',
|
||||
type: 'skill',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
name: 'Python',
|
||||
category: 'programming-language',
|
||||
level: 'expert',
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
|
||||
await rebuildGraphIndex(tempDir, true);
|
||||
const index = await readGraphIndex(tempDir);
|
||||
expect(index.entities).toHaveLength(1);
|
||||
expect(index.entities[0].id).toBe('python');
|
||||
});
|
||||
|
||||
it('does not write to disk when writeToDisk is false', async () => {
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'python',
|
||||
type: 'skill',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
name: 'Python',
|
||||
category: 'programming-language',
|
||||
level: 'expert',
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
|
||||
await rebuildGraphIndex(tempDir, false);
|
||||
const index = await readGraphIndex(tempDir);
|
||||
// No file written, so should be empty
|
||||
expect(index.entities).toEqual([]);
|
||||
});
|
||||
|
||||
it('infers has_skill from experience skillsUsed', async () => {
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'python',
|
||||
type: 'skill',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
name: 'Python',
|
||||
category: 'programming-language',
|
||||
level: 'expert',
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'db-cargo',
|
||||
type: 'organization',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
name: 'DB Cargo AG',
|
||||
industry: 'logistics',
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
await writeEntity(
|
||||
{
|
||||
id: 'lead-innovation',
|
||||
type: 'experience',
|
||||
created: '2025-01-15',
|
||||
modified: '2025-01-15',
|
||||
title: 'Lead Innovation',
|
||||
organization: 'db-cargo',
|
||||
start: '2023-04',
|
||||
end: null,
|
||||
skillsUsed: ['python'],
|
||||
},
|
||||
tempDir
|
||||
);
|
||||
|
||||
const result = await rebuildGraphIndex(tempDir);
|
||||
expect(result.index.relationships).toContainEqual({
|
||||
from: 'lead-innovation',
|
||||
to: 'python',
|
||||
type: 'has_skill',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Property-Based Tests (fast-check) ---
|
||||
|
||||
import fc from 'fast-check';
|
||||
import { validateTandem } from '../schemas/validate';
|
||||
|
||||
// --- Arbitraries (reusing patterns from validate.test.ts) ---
|
||||
|
||||
/** Generates a valid kebab-case ID */
|
||||
const kebabCaseId = fc
|
||||
.array(
|
||||
fc.stringMatching(/^[a-z0-9]{1,8}$/),
|
||||
{ minLength: 1, maxLength: 3 }
|
||||
)
|
||||
.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')}`)
|
||||
);
|
||||
|
||||
/** Valid relationship types */
|
||||
const VALID_RELATIONSHIP_TYPES = ['has_skill', 'worked_at', 'collaborated_with', 'applied_for', 'partners_with'] as const;
|
||||
|
||||
/** Generates a unique set of kebab-case IDs */
|
||||
const uniqueKebabIds = (minLength: number, maxLength: number) =>
|
||||
fc.uniqueArray(kebabCaseId, { minLength, maxLength, comparator: (a, b) => a === b });
|
||||
|
||||
/** Generates a valid Skill entity for writing */
|
||||
const skillEntityArb = (id: string) =>
|
||||
fc.record({
|
||||
created: isoDate,
|
||||
modified: isoDate,
|
||||
name: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||
category: fc.constantFrom('programming-language' as const, 'framework' as const, 'methodology' as const, 'soft-skill' as const, 'domain' as const),
|
||||
level: fc.constantFrom('beginner' as const, 'intermediate' as const, 'advanced' as const, 'expert' as const),
|
||||
}).map(fields => ({
|
||||
id,
|
||||
type: 'skill' as const,
|
||||
...fields,
|
||||
}));
|
||||
|
||||
/** Generates a valid Organization entity for writing */
|
||||
const orgEntityArb = (id: string) =>
|
||||
fc.record({
|
||||
created: isoDate,
|
||||
modified: isoDate,
|
||||
name: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||
industry: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||
}).map(fields => ({
|
||||
id,
|
||||
type: 'organization' as const,
|
||||
...fields,
|
||||
}));
|
||||
|
||||
/** Generates a valid Person entity for writing */
|
||||
const personEntityArb = (id: string) =>
|
||||
fc.record({
|
||||
created: isoDate,
|
||||
modified: isoDate,
|
||||
first: fc.string({ minLength: 1, maxLength: 15 }).filter(s => s.trim().length > 0),
|
||||
last: fc.string({ minLength: 1, maxLength: 15 }).filter(s => s.trim().length > 0),
|
||||
display: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0),
|
||||
}).map(fields => ({
|
||||
id,
|
||||
type: 'person' as const,
|
||||
created: fields.created,
|
||||
modified: fields.modified,
|
||||
name: { first: fields.first, last: fields.last, display: fields.display },
|
||||
}));
|
||||
|
||||
// --- Property 4: Graph index consistency ---
|
||||
|
||||
describe('Property 4: Graph index consistency', () => {
|
||||
/**
|
||||
* Validates: Requirements 1.2, 9.4
|
||||
*/
|
||||
|
||||
it('after rebuilding, the graph index lists exactly the entities written (no missing, no extra)', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
uniqueKebabIds(1, 5).chain(skillIds =>
|
||||
fc.tuple(
|
||||
fc.constant(skillIds),
|
||||
...skillIds.map(id => skillEntityArb(id))
|
||||
)
|
||||
),
|
||||
async (tuple) => {
|
||||
const [ids, ...entities] = tuple as [string[], ...any[]];
|
||||
const dir = await mkdtemp(join(tmpdir(), 'prop4-'));
|
||||
try {
|
||||
for (const entity of entities) {
|
||||
await writeEntity(entity, dir);
|
||||
}
|
||||
const result = await rebuildGraphIndex(dir);
|
||||
const indexIds = result.index.entities.map(e => e.id).sort();
|
||||
expect(indexIds).toEqual([...ids].sort());
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('all relationships reference entity IDs that exist in the entities list', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
uniqueKebabIds(2, 4).chain(personIds =>
|
||||
uniqueKebabIds(1, 3).chain(skillIds => {
|
||||
// Ensure no overlap between person and skill IDs
|
||||
const filteredSkillIds = skillIds.filter(s => !personIds.includes(s));
|
||||
if (filteredSkillIds.length === 0) return fc.constant(null);
|
||||
return fc.tuple(
|
||||
fc.constant(personIds),
|
||||
fc.constant(filteredSkillIds),
|
||||
...personIds.map(id => personEntityArb(id)),
|
||||
...filteredSkillIds.map(id => skillEntityArb(id)),
|
||||
);
|
||||
})
|
||||
).filter(v => v !== null),
|
||||
async (tuple) => {
|
||||
const arr = tuple as any[];
|
||||
const personIds = arr[0] as string[];
|
||||
const skillIds = arr[1] as string[];
|
||||
const personEntities = arr.slice(2, 2 + personIds.length);
|
||||
const skillEntities = arr.slice(2 + personIds.length);
|
||||
|
||||
// Assign skills to persons so relationships are inferred
|
||||
const personsWithSkills = personEntities.map((p: any, i: number) => ({
|
||||
...p,
|
||||
skills: [skillIds[i % skillIds.length]],
|
||||
}));
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), 'prop4b-'));
|
||||
try {
|
||||
for (const entity of [...personsWithSkills, ...skillEntities]) {
|
||||
await writeEntity(entity, dir);
|
||||
}
|
||||
const result = await rebuildGraphIndex(dir);
|
||||
const entityIdSet = new Set(result.index.entities.map(e => e.id));
|
||||
|
||||
for (const rel of result.index.relationships) {
|
||||
expect(entityIdSet.has(rel.from)).toBe(true);
|
||||
expect(entityIdSet.has(rel.to)).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('all relationship types are from the defined set', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
uniqueKebabIds(2, 3).chain(personIds =>
|
||||
uniqueKebabIds(1, 2).chain(skillIds => {
|
||||
const filteredSkillIds = skillIds.filter(s => !personIds.includes(s));
|
||||
if (filteredSkillIds.length === 0) return fc.constant(null);
|
||||
return fc.tuple(
|
||||
fc.constant(personIds),
|
||||
fc.constant(filteredSkillIds),
|
||||
...personIds.map(id => personEntityArb(id)),
|
||||
...filteredSkillIds.map(id => skillEntityArb(id)),
|
||||
);
|
||||
})
|
||||
).filter(v => v !== null),
|
||||
async (tuple) => {
|
||||
const arr = tuple as any[];
|
||||
const personIds = arr[0] as string[];
|
||||
const skillIds = arr[1] as string[];
|
||||
const personEntities = arr.slice(2, 2 + personIds.length);
|
||||
const skillEntities = arr.slice(2 + personIds.length);
|
||||
|
||||
const personsWithSkills = personEntities.map((p: any, i: number) => ({
|
||||
...p,
|
||||
skills: [skillIds[i % skillIds.length]],
|
||||
}));
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), 'prop4c-'));
|
||||
try {
|
||||
for (const entity of [...personsWithSkills, ...skillEntities]) {
|
||||
await writeEntity(entity, dir);
|
||||
}
|
||||
const result = await rebuildGraphIndex(dir);
|
||||
|
||||
for (const rel of result.index.relationships) {
|
||||
expect(VALID_RELATIONSHIP_TYPES).toContain(rel.type);
|
||||
}
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Property 5: Tandem entity integrity ---
|
||||
|
||||
describe('Property 5: Tandem entity integrity', () => {
|
||||
/**
|
||||
* Validates: Requirements 2.3, 2.4
|
||||
*/
|
||||
|
||||
it('a valid tandem references exactly two partners and contains all tandem-specific fields', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
uniqueKebabIds(3, 3).chain(ids => {
|
||||
const [tandemId, partner1Id, partner2Id] = ids;
|
||||
return fc.tuple(
|
||||
fc.constant(tandemId),
|
||||
fc.constant(partner1Id),
|
||||
fc.constant(partner2Id),
|
||||
personEntityArb(partner1Id),
|
||||
personEntityArb(partner2Id),
|
||||
fc.record({
|
||||
created: isoDate,
|
||||
modified: isoDate,
|
||||
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),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
async ([tandemId, partner1Id, partner2Id, person1, person2, tandemFields]) => {
|
||||
const tandemEntity = {
|
||||
id: tandemId,
|
||||
type: 'tandem' as const,
|
||||
created: tandemFields.created,
|
||||
modified: tandemFields.modified,
|
||||
partners: [partner1Id, partner2Id] as [string, string],
|
||||
sharedVision: tandemFields.sharedVision,
|
||||
complementarySkills: tandemFields.complementarySkills,
|
||||
collaborationModel: tandemFields.collaborationModel,
|
||||
};
|
||||
|
||||
// Validate the tandem entity passes schema validation
|
||||
const validationResult = validateTandem(tandemEntity as any);
|
||||
expect(validationResult.valid).toBe(true);
|
||||
|
||||
// Verify it has exactly 2 partners
|
||||
expect(tandemEntity.partners).toHaveLength(2);
|
||||
|
||||
// Verify all tandem-specific fields are present and non-empty
|
||||
expect(tandemEntity.sharedVision.trim().length).toBeGreaterThan(0);
|
||||
expect(tandemEntity.complementarySkills.trim().length).toBeGreaterThan(0);
|
||||
expect(tandemEntity.collaborationModel.trim().length).toBeGreaterThan(0);
|
||||
|
||||
// Write to disk and rebuild to verify partners reference existing persons
|
||||
const dir = await mkdtemp(join(tmpdir(), 'prop5-'));
|
||||
try {
|
||||
await writeEntity(person1 as any, dir);
|
||||
await writeEntity(person2 as any, dir);
|
||||
await writeEntity(tandemEntity as any, dir);
|
||||
|
||||
const result = await rebuildGraphIndex(dir);
|
||||
|
||||
// Both partners should exist in the entity list
|
||||
const entityIds = new Set(result.index.entities.map(e => e.id));
|
||||
expect(entityIds.has(partner1Id)).toBe(true);
|
||||
expect(entityIds.has(partner2Id)).toBe(true);
|
||||
|
||||
// The tandem should produce a partners_with relationship
|
||||
const partnerRels = result.index.relationships.filter(r => r.type === 'partners_with');
|
||||
expect(partnerRels.length).toBeGreaterThan(0);
|
||||
// The relationship should reference both partners
|
||||
const partnerRel = partnerRels.find(
|
||||
r => (r.from === partner1Id && r.to === partner2Id) ||
|
||||
(r.from === partner2Id && r.to === partner1Id)
|
||||
);
|
||||
expect(partnerRel).toBeDefined();
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('a tandem without exactly 2 partners fails validation', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
kebabCaseId,
|
||||
isoDate,
|
||||
isoDate,
|
||||
fc.array(kebabCaseId, { minLength: 0, maxLength: 5 }).filter(arr => arr.length !== 2),
|
||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||
(id, created, modified, partners, sharedVision, complementarySkills, collaborationModel) => {
|
||||
const tandem = {
|
||||
id,
|
||||
type: 'tandem',
|
||||
created,
|
||||
modified,
|
||||
partners,
|
||||
sharedVision,
|
||||
complementarySkills,
|
||||
collaborationModel,
|
||||
};
|
||||
const result = validateTandem(tandem as any);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('exactly 2') || e.includes('must be an array'))).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('a tandem missing any tandem-specific field fails validation', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
kebabCaseId,
|
||||
isoDate,
|
||||
isoDate,
|
||||
kebabCaseId,
|
||||
kebabCaseId,
|
||||
fc.constantFrom('sharedVision', 'complementarySkills', 'collaborationModel'),
|
||||
(id, created, modified, partner1, partner2, fieldToRemove) => {
|
||||
const tandem: Record<string, unknown> = {
|
||||
id,
|
||||
type: 'tandem',
|
||||
created,
|
||||
modified,
|
||||
partners: [partner1, partner2],
|
||||
sharedVision: 'Shared vision text',
|
||||
complementarySkills: 'Complementary skills text',
|
||||
collaborationModel: 'Collaboration model text',
|
||||
};
|
||||
delete tandem[fieldToRemove];
|
||||
const result = validateTandem(tandem as any);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes(fieldToRemove))).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Graph index read/write logic for the knowledge base.
|
||||
*
|
||||
* Manages `kb/graph-index.yaml` — the central index of all entities and relationships.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { parse, stringify } from 'yaml';
|
||||
import type {
|
||||
GraphIndex,
|
||||
GraphEntity,
|
||||
GraphRelationship,
|
||||
RelationshipType,
|
||||
EntityType,
|
||||
Entity,
|
||||
} from '../schemas/types';
|
||||
import { listEntities, readEntity } from '../io/entity-files';
|
||||
|
||||
const GRAPH_INDEX_PATH = join('kb', 'graph-index.yaml');
|
||||
|
||||
const VALID_RELATIONSHIP_TYPES: ReadonlySet<string> = new Set<RelationshipType>([
|
||||
'has_skill',
|
||||
'worked_at',
|
||||
'collaborated_with',
|
||||
'applied_for',
|
||||
'partners_with',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns the full path to the graph index file.
|
||||
*/
|
||||
export function getGraphIndexPath(basePath: string = process.cwd()): string {
|
||||
return join(basePath, GRAPH_INDEX_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the graph index from disk.
|
||||
* Returns an empty index if the file doesn't exist yet.
|
||||
*/
|
||||
export async function readGraphIndex(basePath: string = process.cwd()): Promise<GraphIndex> {
|
||||
const filePath = getGraphIndexPath(basePath);
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(filePath, 'utf-8');
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { entities: [], relationships: [] };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const parsed = parse(content);
|
||||
if (!parsed) {
|
||||
return { entities: [], relationships: [] };
|
||||
}
|
||||
return {
|
||||
entities: Array.isArray(parsed.entities) ? parsed.entities : [],
|
||||
relationships: Array.isArray(parsed.relationships) ? parsed.relationships : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the graph index to disk.
|
||||
* Creates the parent directory if it doesn't exist.
|
||||
*/
|
||||
export async function writeGraphIndex(index: GraphIndex, basePath: string = process.cwd()): Promise<void> {
|
||||
const filePath = getGraphIndexPath(basePath);
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
const yaml = stringify(index, { lineWidth: 0 });
|
||||
await writeFile(filePath, yaml, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an entity to the graph index.
|
||||
* If an entity with the same ID already exists, it is replaced.
|
||||
*/
|
||||
export async function addEntity(entity: GraphEntity, basePath: string = process.cwd()): Promise<void> {
|
||||
const index = await readGraphIndex(basePath);
|
||||
const existing = index.entities.findIndex((e) => e.id === entity.id);
|
||||
if (existing >= 0) {
|
||||
index.entities[existing] = entity;
|
||||
} else {
|
||||
index.entities.push(entity);
|
||||
}
|
||||
await writeGraphIndex(index, basePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an entity from the graph index by ID.
|
||||
* Also removes any relationships that reference the entity.
|
||||
*/
|
||||
export async function removeEntity(entityId: string, basePath: string = process.cwd()): Promise<void> {
|
||||
const index = await readGraphIndex(basePath);
|
||||
index.entities = index.entities.filter((e) => e.id !== entityId);
|
||||
index.relationships = index.relationships.filter(
|
||||
(r) => r.from !== entityId && r.to !== entityId
|
||||
);
|
||||
await writeGraphIndex(index, basePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a relationship type is from the defined set.
|
||||
* Throws an error if the type is invalid.
|
||||
*/
|
||||
export function validateRelationshipType(type: string): asserts type is RelationshipType {
|
||||
if (!VALID_RELATIONSHIP_TYPES.has(type)) {
|
||||
throw new Error(
|
||||
`Invalid relationship type "${type}". Must be one of: ${[...VALID_RELATIONSHIP_TYPES].join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a relationship to the graph index.
|
||||
* Validates the relationship type before adding.
|
||||
* If an identical relationship already exists, it is not duplicated.
|
||||
*/
|
||||
export async function addRelationship(relationship: GraphRelationship, basePath: string = process.cwd()): Promise<void> {
|
||||
validateRelationshipType(relationship.type);
|
||||
const index = await readGraphIndex(basePath);
|
||||
const duplicate = index.relationships.some(
|
||||
(r) => r.from === relationship.from && r.to === relationship.to && r.type === relationship.type
|
||||
);
|
||||
if (!duplicate) {
|
||||
index.relationships.push(relationship);
|
||||
await writeGraphIndex(index, basePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a relationship from the graph index.
|
||||
* Matches on from, to, and type.
|
||||
*/
|
||||
export async function removeRelationship(
|
||||
from: string,
|
||||
to: string,
|
||||
type: RelationshipType,
|
||||
basePath: string = process.cwd()
|
||||
): Promise<void> {
|
||||
const index = await readGraphIndex(basePath);
|
||||
index.relationships = index.relationships.filter(
|
||||
(r) => !(r.from === from && r.to === to && r.type === type)
|
||||
);
|
||||
await writeGraphIndex(index, basePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a graph index rebuild operation.
|
||||
*/
|
||||
export interface RebuildResult {
|
||||
index: GraphIndex;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** All entity types to scan during rebuild. */
|
||||
const ALL_ENTITY_TYPES: EntityType[] = [
|
||||
'person',
|
||||
'experience',
|
||||
'skill',
|
||||
'organization',
|
||||
'project',
|
||||
'certification',
|
||||
'tandem',
|
||||
];
|
||||
|
||||
/**
|
||||
* Extracts the display name from an entity based on its type.
|
||||
*/
|
||||
function getEntityDisplayName(entity: Entity): string {
|
||||
switch (entity.type) {
|
||||
case 'person':
|
||||
return entity.name.display;
|
||||
case 'experience':
|
||||
return entity.title;
|
||||
case 'skill':
|
||||
return entity.name;
|
||||
case 'organization':
|
||||
return entity.name;
|
||||
case 'project':
|
||||
return entity.name;
|
||||
case 'certification':
|
||||
return entity.name;
|
||||
case 'tandem':
|
||||
return entity.partners.join(' & ');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the graph index by scanning all entity directories.
|
||||
*
|
||||
* Reads every entity file, builds the entities list, infers relationships,
|
||||
* and detects broken references (entity IDs referenced but no file exists).
|
||||
*
|
||||
* @param basePath - Root directory of the project (for testability)
|
||||
* @param writeToDisk - If true, writes the rebuilt index to kb/graph-index.yaml
|
||||
* @returns The rebuilt index and a list of broken reference warnings
|
||||
*/
|
||||
export async function rebuildGraphIndex(
|
||||
basePath: string = process.cwd(),
|
||||
writeToDisk: boolean = false
|
||||
): Promise<RebuildResult> {
|
||||
const entities: GraphEntity[] = [];
|
||||
const relationships: GraphRelationship[] = [];
|
||||
const warnings: string[] = [];
|
||||
const knownIds = new Set<string>();
|
||||
|
||||
// Phase 1: Scan all entity directories and build entity list
|
||||
const entityMap = new Map<string, Entity>();
|
||||
|
||||
for (const type of ALL_ENTITY_TYPES) {
|
||||
const ids = await listEntities(type, basePath);
|
||||
for (const id of ids) {
|
||||
let entity: Entity;
|
||||
try {
|
||||
entity = await readEntity(type, id, basePath);
|
||||
} catch {
|
||||
warnings.push(`Failed to read ${type} entity "${id}"`);
|
||||
continue;
|
||||
}
|
||||
knownIds.add(id);
|
||||
entityMap.set(id, entity);
|
||||
entities.push({
|
||||
id: entity.id,
|
||||
type: entity.type,
|
||||
name: getEntityDisplayName(entity),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Infer relationships from entity data
|
||||
const addedRelationships = new Set<string>();
|
||||
|
||||
function addRel(from: string, to: string, type: RelationshipType, context?: string): void {
|
||||
const key = `${from}|${to}|${type}`;
|
||||
if (addedRelationships.has(key)) return;
|
||||
addedRelationships.add(key);
|
||||
|
||||
if (!knownIds.has(to)) {
|
||||
warnings.push(`Broken reference: ${from} → ${to} (type: ${type})`);
|
||||
return;
|
||||
}
|
||||
const rel: GraphRelationship = { from, to, type };
|
||||
if (context) rel.context = context;
|
||||
relationships.push(rel);
|
||||
}
|
||||
|
||||
for (const [id, entity] of entityMap) {
|
||||
switch (entity.type) {
|
||||
case 'person': {
|
||||
if (entity.skills) {
|
||||
for (const skillId of entity.skills) {
|
||||
addRel(id, skillId, 'has_skill');
|
||||
}
|
||||
}
|
||||
if (entity.experiences) {
|
||||
for (const expId of entity.experiences) {
|
||||
const exp = entityMap.get(expId);
|
||||
if (exp && exp.type === 'experience') {
|
||||
addRel(id, exp.organization, 'worked_at', expId);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'experience': {
|
||||
addRel(id, entity.organization, 'worked_at');
|
||||
if (entity.skillsUsed) {
|
||||
for (const skillId of entity.skillsUsed) {
|
||||
addRel(id, skillId, 'has_skill');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'project': {
|
||||
addRel(id, entity.organization, 'worked_at');
|
||||
if (entity.persons) {
|
||||
for (let i = 0; i < entity.persons.length; i++) {
|
||||
for (let j = i + 1; j < entity.persons.length; j++) {
|
||||
addRel(entity.persons[i], entity.persons[j], 'collaborated_with', id);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tandem': {
|
||||
const [p1, p2] = entity.partners;
|
||||
addRel(p1, p2, 'partners_with', id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const index: GraphIndex = { entities, relationships };
|
||||
|
||||
if (writeToDisk) {
|
||||
await writeGraphIndex(index, basePath);
|
||||
}
|
||||
|
||||
return { index, warnings };
|
||||
}
|
||||
Reference in New Issue
Block a user