takt/src/__tests__/facet-resolution.test.ts
2026-03-06 09:16:14 +09:00

615 lines
20 KiB
TypeScript

/**
* Tests for name-based facet resolution (layer system).
*
* Covers:
* - isResourcePath() helper
* - resolveFacetByName() 3-layer resolution (project → user → builtin)
* - resolveRefToContent() with facetType and context
* - resolvePersona() with context (name-based resolution)
* - parseFacetType() CLI mapping
* - Facet directory path helpers
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
isResourcePath,
resolveFacetByName,
resolveRefToContent,
resolveRefList,
resolvePersona,
type FacetResolutionContext,
type PieceSections,
} from '../infra/config/loaders/resource-resolver.js';
import {
getProjectFacetDir,
getGlobalFacetDir,
getBuiltinFacetDir,
type FacetType,
} from '../infra/config/paths.js';
import { parseFacetType, VALID_FACET_TYPES } from '../features/config/ejectBuiltin.js';
import { normalizePieceConfig } from '../infra/config/loaders/pieceParser.js';
describe('isResourcePath', () => {
it('should return true for relative paths starting with ./', () => {
expect(isResourcePath('./personas/coder.md')).toBe(true);
});
it('should return true for relative paths starting with ../', () => {
expect(isResourcePath('../personas/coder.md')).toBe(true);
});
it('should return true for absolute paths', () => {
expect(isResourcePath('/home/user/coder.md')).toBe(true);
});
it('should return true for home directory paths', () => {
expect(isResourcePath('~/coder.md')).toBe(true);
});
it('should return true for paths ending with .md', () => {
expect(isResourcePath('coder.md')).toBe(true);
});
it('should return false for plain names', () => {
expect(isResourcePath('coder')).toBe(false);
expect(isResourcePath('architecture-reviewer')).toBe(false);
expect(isResourcePath('coding')).toBe(false);
});
});
describe('resolveFacetByName', () => {
let tempDir: string;
let projectDir: string;
let context: FacetResolutionContext;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'takt-facet-test-'));
projectDir = join(tempDir, 'project');
mkdirSync(projectDir, { recursive: true });
context = { projectDir, lang: 'ja' };
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('should resolve from builtin when no project/user override exists', () => {
// Builtin personas exist in the real builtins directory
const content = resolveFacetByName('coder', 'personas', context);
expect(content).toBeDefined();
expect(content).toContain(''); // Just verify it returns something
});
it('should resolve from project layer over builtin', () => {
const projectPersonasDir = join(projectDir, '.takt', 'facets', 'personas');
mkdirSync(projectPersonasDir, { recursive: true });
writeFileSync(join(projectPersonasDir, 'coder.md'), 'Project-level coder persona');
const content = resolveFacetByName('coder', 'personas', context);
expect(content).toBe('Project-level coder persona');
});
it('should return undefined when facet not found in any layer', () => {
const content = resolveFacetByName('nonexistent-facet-xyz', 'personas', context);
expect(content).toBeUndefined();
});
it('should resolve different facet types', () => {
const projectPoliciesDir = join(projectDir, '.takt', 'facets', 'policies');
mkdirSync(projectPoliciesDir, { recursive: true });
writeFileSync(join(projectPoliciesDir, 'custom-policy.md'), 'Custom policy content');
const content = resolveFacetByName('custom-policy', 'policies', context);
expect(content).toBe('Custom policy content');
});
it('should try project before builtin', () => {
// Create project override
const projectPersonasDir = join(projectDir, '.takt', 'facets', 'personas');
mkdirSync(projectPersonasDir, { recursive: true });
writeFileSync(join(projectPersonasDir, 'coder.md'), 'OVERRIDE');
const content = resolveFacetByName('coder', 'personas', context);
expect(content).toBe('OVERRIDE');
});
});
describe('resolveRefToContent with layer resolution', () => {
let tempDir: string;
let context: FacetResolutionContext;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'takt-ref-test-'));
context = { projectDir: tempDir, lang: 'ja' };
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('should prefer resolvedMap over layer resolution', () => {
const resolvedMap = { 'coding': 'Map content for coding' };
const content = resolveRefToContent('coding', resolvedMap, tempDir, 'policies', context);
expect(content).toBe('Map content for coding');
});
it('should use layer resolution for name refs when not in resolvedMap', () => {
const policiesDir = join(tempDir, '.takt', 'facets', 'policies');
mkdirSync(policiesDir, { recursive: true });
writeFileSync(join(policiesDir, 'coding.md'), 'Project coding policy');
const content = resolveRefToContent('coding', undefined, tempDir, 'policies', context);
expect(content).toBe('Project coding policy');
});
it('should use path resolution for path-like refs', () => {
const policyFile = join(tempDir, 'my-policy.md');
writeFileSync(policyFile, 'Inline policy');
const content = resolveRefToContent('./my-policy.md', undefined, tempDir);
expect(content).toBe('Inline policy');
});
it('should fall back to path resolution when no context', () => {
const content = resolveRefToContent('some-name', undefined, tempDir);
// No context, no file — returns the spec as-is (inline content behavior)
expect(content).toBe('some-name');
});
it('should fall back to resolveResourceContent when facet not found with context', () => {
// Given: facetType and context provided, but no matching facet file exists
// When: resolveRefToContent is called with a name that has no facet file
const content = resolveRefToContent(
'nonexistent-facet-xyz',
undefined,
tempDir,
'policies',
context,
);
// Then: falls back to resolveResourceContent, which returns the ref as inline content
expect(content).toBe('nonexistent-facet-xyz');
});
});
describe('resolveRefList with layer resolution', () => {
let tempDir: string;
let context: FacetResolutionContext;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'takt-reflist-test-'));
context = { projectDir: tempDir, lang: 'ja' };
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('should resolve array of name refs via layer resolution', () => {
const policiesDir = join(tempDir, '.takt', 'facets', 'policies');
mkdirSync(policiesDir, { recursive: true });
writeFileSync(join(policiesDir, 'policy-a.md'), 'Policy A content');
writeFileSync(join(policiesDir, 'policy-b.md'), 'Policy B content');
const result = resolveRefList(
['policy-a', 'policy-b'],
undefined,
tempDir,
'policies',
context,
);
expect(result).toEqual(['Policy A content', 'Policy B content']);
});
it('should handle mixed array of name refs and path refs', () => {
const policiesDir = join(tempDir, '.takt', 'facets', 'policies');
mkdirSync(policiesDir, { recursive: true });
writeFileSync(join(policiesDir, 'name-policy.md'), 'Name-resolved policy');
const pathFile = join(tempDir, 'local-policy.md');
writeFileSync(pathFile, 'Path-resolved policy');
const result = resolveRefList(
['name-policy', './local-policy.md'],
undefined,
tempDir,
'policies',
context,
);
expect(result).toEqual(['Name-resolved policy', 'Path-resolved policy']);
});
it('should return undefined for undefined input', () => {
const result = resolveRefList(undefined, undefined, tempDir, 'policies', context);
expect(result).toBeUndefined();
});
it('should handle single string ref (not array)', () => {
const policiesDir = join(tempDir, '.takt', 'facets', 'policies');
mkdirSync(policiesDir, { recursive: true });
writeFileSync(join(policiesDir, 'single.md'), 'Single policy');
const result = resolveRefList(
'single',
undefined,
tempDir,
'policies',
context,
);
expect(result).toEqual(['Single policy']);
});
it('should prefer resolvedMap over layer resolution', () => {
const resolvedMap = { coding: 'Map content for coding' };
const result = resolveRefList(
['coding'],
resolvedMap,
tempDir,
'policies',
context,
);
expect(result).toEqual(['Map content for coding']);
});
});
describe('resolvePersona with layer resolution', () => {
let tempDir: string;
let projectDir: string;
let context: FacetResolutionContext;
const emptySections: PieceSections = {};
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'takt-persona-test-'));
projectDir = join(tempDir, 'project');
mkdirSync(projectDir, { recursive: true });
context = { projectDir, lang: 'ja' };
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('should resolve persona by name from builtin', () => {
const result = resolvePersona('coder', emptySections, tempDir, context);
expect(result.personaSpec).toBe('coder');
expect(result.personaPath).toBeDefined();
expect(result.personaPath).toContain('coder.md');
});
it('should resolve persona from project layer', () => {
const projectPersonasDir = join(projectDir, '.takt', 'facets', 'personas');
mkdirSync(projectPersonasDir, { recursive: true });
const personaPath = join(projectPersonasDir, 'custom-persona.md');
writeFileSync(personaPath, 'Custom persona content');
const result = resolvePersona('custom-persona', emptySections, tempDir, context);
expect(result.personaSpec).toBe('custom-persona');
expect(result.personaPath).toBe(personaPath);
});
it('should prefer section map over layer resolution', () => {
const personaFile = join(tempDir, 'explicit.md');
writeFileSync(personaFile, 'Explicit persona');
const sections: PieceSections = {
personas: { 'my-persona': './explicit.md' },
};
const result = resolvePersona('my-persona', sections, tempDir, context);
expect(result.personaSpec).toBe('./explicit.md');
expect(result.personaPath).toBe(personaFile);
});
it('should handle path-like persona specs directly', () => {
const personaFile = join(tempDir, 'personas', 'coder.md');
mkdirSync(join(tempDir, 'personas'), { recursive: true });
writeFileSync(personaFile, 'Path persona');
const result = resolvePersona('../personas/coder.md', emptySections, tempDir);
// Path-like spec should be resolved as resource path, not name
expect(result.personaSpec).toBe('../personas/coder.md');
});
it('should return empty for undefined persona', () => {
const result = resolvePersona(undefined, emptySections, tempDir, context);
expect(result).toEqual({});
});
});
describe('facet directory path helpers', () => {
it('getProjectFacetDir should return .takt/{type}/ path', () => {
const dir = getProjectFacetDir('/my/project', 'personas');
expect(dir).toContain('.takt');
expect(dir).toContain('personas');
});
it('getGlobalFacetDir should return path with facet type', () => {
const dir = getGlobalFacetDir('policies');
expect(dir).toContain('policies');
});
it('getBuiltinFacetDir should return path with lang and facet type', () => {
const dir = getBuiltinFacetDir('ja', 'knowledge');
expect(dir).toContain('ja');
expect(dir).toContain('knowledge');
});
it('should work with all facet types', () => {
const types: FacetType[] = ['personas', 'policies', 'knowledge', 'instructions', 'output-contracts'];
for (const t of types) {
expect(getProjectFacetDir('/proj', t)).toContain(t);
expect(getGlobalFacetDir(t)).toContain(t);
expect(getBuiltinFacetDir('en', t)).toContain(t);
}
});
});
describe('parseFacetType', () => {
it('should map singular to plural facet types', () => {
expect(parseFacetType('persona')).toBe('personas');
expect(parseFacetType('policy')).toBe('policies');
expect(parseFacetType('knowledge')).toBe('knowledge');
expect(parseFacetType('instruction')).toBe('instructions');
expect(parseFacetType('output-contract')).toBe('output-contracts');
});
it('should return undefined for invalid facet types', () => {
expect(parseFacetType('invalid')).toBeUndefined();
expect(parseFacetType('personas')).toBeUndefined();
expect(parseFacetType('')).toBeUndefined();
});
it('VALID_FACET_TYPES should contain all singular forms', () => {
expect(VALID_FACET_TYPES).toContain('persona');
expect(VALID_FACET_TYPES).toContain('policy');
expect(VALID_FACET_TYPES).toContain('knowledge');
expect(VALID_FACET_TYPES).toContain('instruction');
expect(VALID_FACET_TYPES).toContain('output-contract');
expect(VALID_FACET_TYPES).toHaveLength(5);
});
});
describe('normalizePieceConfig with layer resolution', () => {
let tempDir: string;
let pieceDir: string;
let projectDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'takt-normalize-test-'));
pieceDir = join(tempDir, 'pieces');
projectDir = join(tempDir, 'project');
mkdirSync(pieceDir, { recursive: true });
mkdirSync(projectDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('should resolve persona by name when section map is absent and context provided', () => {
const raw = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'coder',
instruction: '{task}',
},
],
};
const context: FacetResolutionContext = { projectDir, lang: 'ja' };
const config = normalizePieceConfig(raw, pieceDir, context);
expect(config.movements[0]!.persona).toBe('coder');
// With context, it should find the builtin coder persona
expect(config.movements[0]!.personaPath).toBeDefined();
expect(config.movements[0]!.personaPath).toContain('coder.md');
});
it('should resolve policy by name when section map is absent', () => {
// Create project-level policy
const policiesDir = join(projectDir, '.takt', 'facets', 'policies');
mkdirSync(policiesDir, { recursive: true });
writeFileSync(join(policiesDir, 'custom-policy.md'), '# Custom Policy\nBe nice.');
const raw = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'coder',
policy: 'custom-policy',
instruction: '{task}',
},
],
};
const context: FacetResolutionContext = { projectDir, lang: 'ja' };
const config = normalizePieceConfig(raw, pieceDir, context);
expect(config.movements[0]!.policyContents).toBeDefined();
expect(config.movements[0]!.policyContents![0]).toBe('# Custom Policy\nBe nice.');
});
it('should prefer section map over layer resolution', () => {
// Create section map entry
const personaFile = join(pieceDir, 'my-coder.md');
writeFileSync(personaFile, 'Section map coder');
const raw = {
name: 'test-piece',
personas: {
coder: './my-coder.md',
},
movements: [
{
name: 'step1',
persona: 'coder',
instruction: '{task}',
},
],
};
const context: FacetResolutionContext = { projectDir, lang: 'ja' };
const config = normalizePieceConfig(raw, pieceDir, context);
// Section map should be used, not layer resolution
expect(config.movements[0]!.persona).toBe('./my-coder.md');
expect(config.movements[0]!.personaPath).toBe(personaFile);
});
it('should work without context (backward compatibility)', () => {
const raw = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'coder',
instruction: '{task}',
},
],
};
// No context — backward compatibility mode
const config = normalizePieceConfig(raw, pieceDir);
// Without context, name 'coder' resolves as relative path from pieceDir
expect(config.movements[0]!.persona).toBe('coder');
});
it('should resolve knowledge by name from project layer', () => {
const knowledgeDir = join(projectDir, '.takt', 'facets', 'knowledge');
mkdirSync(knowledgeDir, { recursive: true });
writeFileSync(join(knowledgeDir, 'domain-kb.md'), '# Domain Knowledge');
const raw = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'coder',
knowledge: 'domain-kb',
instruction: '{task}',
},
],
};
const context: FacetResolutionContext = { projectDir, lang: 'ja' };
const config = normalizePieceConfig(raw, pieceDir, context);
expect(config.movements[0]!.knowledgeContents).toBeDefined();
expect(config.movements[0]!.knowledgeContents![0]).toBe('# Domain Knowledge');
});
it('should resolve instruction_template from section map before layer resolution', () => {
const raw = {
name: 'test-piece',
instructions: {
implement: 'Mapped instruction template',
},
movements: [
{
name: 'step1',
persona: 'coder',
instruction_template: 'implement',
},
],
};
const context: FacetResolutionContext = { projectDir, lang: 'ja' };
const config = normalizePieceConfig(raw, pieceDir, context);
expect(config.movements[0]!.instruction).toBe('Mapped instruction template');
});
it('should resolve instruction_template by name via layer resolution', () => {
const instructionsDir = join(projectDir, '.takt', 'facets', 'instructions');
mkdirSync(instructionsDir, { recursive: true });
writeFileSync(join(instructionsDir, 'implement.md'), 'Project implement template');
const raw = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'coder',
instruction_template: 'implement',
},
],
};
const context: FacetResolutionContext = { projectDir, lang: 'ja' };
const config = normalizePieceConfig(raw, pieceDir, context);
expect(config.movements[0]!.instruction).toBe('Project implement template');
});
it('should keep inline instruction_template when no facet is found', () => {
const inlineTemplate = `Use this inline template.
Second line remains inline.`;
const raw = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'coder',
instruction_template: inlineTemplate,
},
],
};
const context: FacetResolutionContext = { projectDir, lang: 'ja' };
const config = normalizePieceConfig(raw, pieceDir, context);
expect(config.movements[0]!.instruction).toBe(inlineTemplate);
});
it('should resolve loop monitor judge instruction_template via layer resolution', () => {
const instructionsDir = join(projectDir, '.takt', 'facets', 'instructions');
mkdirSync(instructionsDir, { recursive: true });
writeFileSync(join(instructionsDir, 'judge-template.md'), 'Project judge template');
const raw = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'coder',
instruction: '{task}',
rules: [{ condition: 'next', next: 'step2' }],
},
{
name: 'step2',
persona: 'coder',
instruction: '{task}',
rules: [{ condition: 'done', next: 'COMPLETE' }],
},
],
loop_monitors: [
{
cycle: ['step1', 'step2'],
threshold: 2,
judge: {
persona: 'coder',
instruction_template: 'judge-template',
rules: [{ condition: 'continue', next: 'step2' }],
},
},
],
};
const context: FacetResolutionContext = { projectDir, lang: 'ja' };
const config = normalizePieceConfig(raw, pieceDir, context);
expect(config.loopMonitors?.[0]?.judge.instruction).toBe('Project judge template');
});
});