takt/src/__tests__/repertoire-scope-resolver.test.ts

276 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Unit tests for repertoire @scope resolution and facet resolution chain.
*
* Covers:
* A. @scope reference resolution (src/faceted-prompting/scope.ts)
* B. Facet resolution chain with package-local layer
* (src/infra/config/loaders/resource-resolver.ts)
*
* @scope resolution rules:
* "@{owner}/{repo}/{name}" in a facet field →
* {repertoireDir}/@{owner}/{repo}/facets/{type}/{name}.md
*
* Name constraints:
* owner: /^[a-z0-9][a-z0-9-]*$/ (lowercase only after normalization)
* repo: /^[a-z0-9][a-z0-9._-]*$/ (dot and underscore allowed)
* facet/piece name: /^[a-z0-9][a-z0-9-]*$/
*
* Facet resolution order (package piece):
* 1. package-local: {repertoireDir}/@{owner}/{repo}/facets/{type}/{facet}.md
* 2. project: .takt/facets/{type}/{facet}.md
* 3. user: ~/.takt/facets/{type}/{facet}.md
* 4. builtin: builtins/{lang}/facets/{type}/{facet}.md
*
* Facet resolution order (non-package piece):
* 1. project → 2. user → 3. builtin (package-local is NOT consulted)
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
isScopeRef,
parseScopeRef,
resolveScopeRef,
validateScopeOwner,
validateScopeRepo,
validateScopeFacetName,
} from '../faceted-prompting/scope.js';
import {
isPackagePiece,
buildCandidateDirsWithPackage,
resolveFacetPath,
} from '../infra/config/loaders/resource-resolver.js';
describe('@scope reference resolution', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'takt-scope-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
// U34: persona @scope 解決
// Input: "@nrslib/takt-ensemble-fixture/expert-coder" (personas field)
// Expect: resolves to {repertoireDir}/@nrslib/takt-ensemble-fixture/facets/personas/expert-coder.md
it('should resolve persona @scope reference to repertoire faceted path', () => {
const repertoireDir = tempDir;
const ref = '@nrslib/takt-ensemble-fixture/expert-coder';
const scopeRef = parseScopeRef(ref);
const resolved = resolveScopeRef(scopeRef, 'personas', repertoireDir);
const expected = join(repertoireDir, '@nrslib', 'takt-ensemble-fixture', 'facets', 'personas', 'expert-coder.md');
expect(resolved).toBe(expected);
});
// U35: policy @scope 解決
// Input: "@nrslib/takt-ensemble-fixture/strict-coding" (policies field)
// Expect: resolves to {repertoireDir}/@nrslib/takt-ensemble-fixture/facets/policies/strict-coding.md
it('should resolve policy @scope reference to repertoire faceted path', () => {
const repertoireDir = tempDir;
const ref = '@nrslib/takt-ensemble-fixture/strict-coding';
const scopeRef = parseScopeRef(ref);
const resolved = resolveScopeRef(scopeRef, 'policies', repertoireDir);
const expected = join(repertoireDir, '@nrslib', 'takt-ensemble-fixture', 'facets', 'policies', 'strict-coding.md');
expect(resolved).toBe(expected);
});
// U36: 大文字正規化
// Input: "@NrsLib/Takt-Ensemble-Fixture/expert-coder"
// Expect: owner and repo lowercase-normalized; name kept as-is (must already be lowercase per spec)
it('should normalize uppercase @scope references to lowercase before resolving', () => {
const repertoireDir = tempDir;
const ref = '@NrsLib/Takt-Ensemble-Fixture/expert-coder';
const scopeRef = parseScopeRef(ref);
// owner and repo are normalized to lowercase
expect(scopeRef.owner).toBe('nrslib');
expect(scopeRef.repo).toBe('takt-ensemble-fixture');
const resolved = resolveScopeRef(scopeRef, 'personas', repertoireDir);
const expected = join(repertoireDir, '@nrslib', 'takt-ensemble-fixture', 'facets', 'personas', 'expert-coder.md');
expect(resolved).toBe(expected);
});
// U37: 存在しないスコープは解決失敗(ファイル不在のため undefined
// Input: "@nonexistent/package/facet"
// Expect: resolveFacetPath returns undefined (file not found at resolved path)
it('should throw error when @scope reference points to non-existent package', () => {
const repertoireDir = tempDir;
const ref = '@nonexistent/package/facet';
// resolveFacetPath returns undefined when the @scope file does not exist
const result = resolveFacetPath(ref, 'personas', {
lang: 'en',
repertoireDir,
});
expect(result).toBeUndefined();
});
});
describe('@scope name constraints', () => {
// U38: owner 名前制約: 有効
// Input: "@nrslib"
// Expect: バリデーション通過
it('should accept valid owner name matching /^[a-z0-9][a-z0-9-]*$/', () => {
expect(() => validateScopeOwner('nrslib')).not.toThrow();
expect(() => validateScopeOwner('my-org')).not.toThrow();
expect(() => validateScopeOwner('org123')).not.toThrow();
});
// U39: owner 名前制約: 大文字は正規化後に有効
// Input: "@NrsLib" → normalized to "@nrslib"
// Expect: バリデーション通過(小文字正規化後)
it('should normalize uppercase owner to lowercase and pass validation', () => {
const ref = '@NrsLib/repo/facet';
const scopeRef = parseScopeRef(ref);
// parseScopeRef normalizes owner to lowercase
expect(scopeRef.owner).toBe('nrslib');
// lowercase owner passes validation
expect(() => validateScopeOwner(scopeRef.owner)).not.toThrow();
});
// U40: owner 名前制約: 無効(先頭ハイフン)
// Input: "@-invalid"
// Expect: バリデーションエラー
it('should reject owner name starting with a hyphen', () => {
expect(() => validateScopeOwner('-invalid')).toThrow();
});
// U41: repo 名前制約: ドット・アンダースコア許可
// Input: "@nrslib/my.repo_name"
// Expect: バリデーション通過
it('should accept repo name containing dots and underscores', () => {
expect(() => validateScopeRepo('my.repo_name')).not.toThrow();
expect(() => validateScopeRepo('repo.name')).not.toThrow();
expect(() => validateScopeRepo('repo_name')).not.toThrow();
});
// U42: facet 名前制約: 無効(ドット含む)
// Input: "@nrslib/repo/facet.name"
// Expect: バリデーションエラー
it('should reject facet name containing dots', () => {
expect(() => validateScopeFacetName('facet.name')).toThrow();
});
});
describe('facet resolution chain: package-local layer', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'takt-facet-chain-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
// U43: パッケージローカルが最優先
// Given: package-local, project, user, builtin の全層に同名ファセットが存在
// When: パッケージ内ピースからファセット解決
// Then: package-local 層のファセットが返る
it('should prefer package-local facet over project/user/builtin layers', () => {
const repertoireDir = join(tempDir, 'repertoire');
const packagePiecesDir = join(repertoireDir, '@nrslib', 'takt-ensemble-fixture', 'pieces');
const packageFacetDir = join(repertoireDir, '@nrslib', 'takt-ensemble-fixture', 'facets', 'personas');
const projectFacetDir = join(tempDir, 'project', '.takt', 'facets', 'personas');
// Create both package-local and project facet files with the same name
mkdirSync(packageFacetDir, { recursive: true });
mkdirSync(packagePiecesDir, { recursive: true });
mkdirSync(projectFacetDir, { recursive: true });
writeFileSync(join(packageFacetDir, 'expert-coder.md'), '# Package-local expert');
writeFileSync(join(projectFacetDir, 'expert-coder.md'), '# Project expert');
const candidateDirs = buildCandidateDirsWithPackage('personas', {
lang: 'en',
pieceDir: packagePiecesDir,
repertoireDir,
projectDir: join(tempDir, 'project'),
});
// Package-local dir should come first
expect(candidateDirs[0]).toBe(packageFacetDir);
});
// U44: package-local にない場合は project に落ちる
// Given: package-local にファセットなし、project にあり
// When: ファセット解決
// Then: project 層のファセットが返る
it('should fall back to project facet when package-local does not have it', () => {
const repertoireDir = join(tempDir, 'repertoire');
const packagePiecesDir = join(repertoireDir, '@nrslib', 'takt-ensemble-fixture', 'pieces');
const projectFacetDir = join(tempDir, 'project', '.takt', 'facets', 'personas');
mkdirSync(packagePiecesDir, { recursive: true });
mkdirSync(projectFacetDir, { recursive: true });
// Only create project facet (no package-local facet)
const projectFacetFile = join(projectFacetDir, 'expert-coder.md');
writeFileSync(projectFacetFile, '# Project expert');
const resolved = resolveFacetPath('expert-coder', 'personas', {
lang: 'en',
pieceDir: packagePiecesDir,
repertoireDir,
projectDir: join(tempDir, 'project'),
});
expect(resolved).toBe(projectFacetFile);
});
// U45: 非パッケージピースは package-local を使わない
// Given: package-local にファセットあり、非パッケージピースから解決
// When: ファセット解決
// Then: package-local は無視。project → user → builtin の3層で解決
it('should not consult package-local layer for non-package pieces', () => {
const repertoireDir = join(tempDir, 'repertoire');
const packageFacetDir = join(repertoireDir, '@nrslib', 'takt-ensemble-fixture', 'facets', 'personas');
// Non-package pieceDir (not under repertoireDir)
const globalPiecesDir = join(tempDir, 'global-pieces');
mkdirSync(packageFacetDir, { recursive: true });
mkdirSync(globalPiecesDir, { recursive: true });
writeFileSync(join(packageFacetDir, 'expert-coder.md'), '# Package-local expert');
const candidateDirs = buildCandidateDirsWithPackage('personas', {
lang: 'en',
pieceDir: globalPiecesDir,
repertoireDir,
});
// Package-local dir should NOT be in candidates for non-package pieces
expect(candidateDirs.some((d) => d.includes('@nrslib'))).toBe(false);
});
});
describe('package piece detection', () => {
// U46: パッケージ所属は pieceDir パスから判定
// Given: pieceDir が {repertoireDir}/@nrslib/repo/pieces/ 配下
// When: isPackagePiece(pieceDir) 呼び出し
// Then: true が返る
it('should return true for pieceDir under repertoire/@scope/repo/pieces/', () => {
const repertoireDir = '/home/user/.takt/repertoire';
const pieceDir = '/home/user/.takt/repertoire/@nrslib/takt-ensemble-fixture/pieces';
expect(isPackagePiece(pieceDir, repertoireDir)).toBe(true);
});
// U47: 非パッケージ pieceDir は false
// Given: pieceDir が ~/.takt/pieces/ 配下
// When: isPackagePiece(pieceDir) 呼び出し
// Then: false が返る
it('should return false for pieceDir under global pieces directory', () => {
const repertoireDir = '/home/user/.takt/repertoire';
const pieceDir = '/home/user/.takt/pieces';
expect(isPackagePiece(pieceDir, repertoireDir)).toBe(false);
});
});