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:
2026-06-30 20:39:52 +02:00
parent 2f2b295531
commit a5f8fb49ab
1717 changed files with 447332 additions and 0 deletions
+825
View File
@@ -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 }
);
});
});
+300
View File
@@ -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 };
}