615 lines
20 KiB
TypeScript
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');
|
|
});
|
|
});
|