diff --git a/.gitignore b/.gitignore index 0cc8ede..a93ef3b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Dependencies +node_modules node_modules/ # Build output diff --git a/CHANGELOG.md b/CHANGELOG.md index 2783b30..4185331 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,39 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.22.0] - 2026-02-22 + +### Added + +- **Repertoire package system** (`takt repertoire add/remove/list`): Import and manage external TAKT packages from GitHub — `takt repertoire add github:{owner}/{repo}@{ref}` downloads packages to `~/.takt/repertoire/` with atomic installation, version compatibility checks, lock files, and package content summary before confirmation +- **@scope references in piece YAML**: Facet references now support `@{owner}/{repo}/{facet-name}` syntax to reference facets from installed repertoire packages (e.g., `persona: @nrslib/takt-fullstack/expert-coder`) +- **4-layer facet resolution**: Upgraded from 3-layer (project → user → builtin) to 4-layer (package-local → project → user → builtin) — repertoire package pieces automatically resolve their own facets first +- **Repertoire category in piece selection**: Installed repertoire packages automatically appear as subcategories under a "repertoire" category in the piece selection UI +- **Build gate in implement/fix instructions**: `implement` and `fix` builtin instructions now require build (type check) verification before test execution +- **Repertoire package documentation**: Added comprehensive docs for the repertoire package system ([en](./docs/repertoire.md), [ja](./docs/repertoire.ja.md)) + +### Changed + +- **BREAKING: "ensemble" renamed to "repertoire"**: All CLI commands, directories, config keys, and APIs renamed — `takt ensemble` → `takt repertoire`, `~/.takt/ensemble/` → `~/.takt/repertoire/`. Migration: rename your `~/.takt/ensemble/` directory to `~/.takt/repertoire/` +- **BREAKING: Facets directory restructured**: Facet directories moved under a `facets/` subdirectory at all levels — `builtins/{lang}/{facetType}/` → `builtins/{lang}/facets/{facetType}/`, `~/.takt/{facetType}/` → `~/.takt/facets/{facetType}/`, `.takt/{facetType}/` → `.takt/facets/{facetType}/`. Migration: move your custom facet files into the new `facets/` subdirectory +- Contract string hardcoding prevention rule added to coding policy and architecture review instruction + +### Fixed + +- Override piece validation now includes repertoire scope via the resolver +- `takt export-cc` now reads facets from the new `builtins/{lang}/facets/` directory structure +- `confirm()` prompt now supports piped stdin (e.g., `echo "y" | takt repertoire add ...`) +- Suppressed `poll_tick` debug log flooding during iteration input wait +- Piece resolver `stat()` calls now catch errors gracefully instead of crashing on inaccessible entries + +### Internal + +- Comprehensive repertoire test suite: atomic-update, repertoire-paths, file-filter, github-ref-resolver, github-spec, list, lock-file, pack-summary, package-facet-resolution, remove-reference-check, remove, takt-repertoire-config, tar-parser, takt-repertoire-schema +- Added `src/faceted-prompting/scope.ts` for @scope reference parsing, validation, and resolution +- Added scope-ref tests for the faceted-prompting module +- Added `inputWait.ts` for shared input-wait state to suppress worker pool log noise +- Added piece-selection-branches and repertoire e2e tests + ## [0.21.0] - 2026-02-20 ### Added diff --git a/README.md b/README.md index 62d4a3d..9ad85b4 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ See the [Builtin Catalog](./docs/builtin-catalog.md) for all pieces and personas | `takt #N` | Execute GitHub Issue as task | | `takt switch` | Switch active piece | | `takt eject` | Copy builtin pieces/personas for customization | +| `takt repertoire add` | Install a repertoire package from GitHub | See the [CLI Reference](./docs/cli-reference.md) for all commands and options. @@ -212,10 +213,12 @@ See the [CI/CD Guide](./docs/ci-cd.md) for full setup instructions. ~/.takt/ # Global config ├── config.yaml # Provider, model, language, etc. ├── pieces/ # User piece definitions -└── personas/ # User persona prompts +├── facets/ # User facets (personas, policies, knowledge, etc.) +└── repertoire/ # Installed repertoire packages .takt/ # Project-level ├── config.yaml # Project config +├── facets/ # Project facets ├── tasks.yaml # Pending tasks ├── tasks/ # Task specifications └── runs/ # Execution reports, logs, context @@ -247,6 +250,7 @@ await engine.run(); | [Agent Guide](./docs/agents.md) | Custom agent configuration | | [Builtin Catalog](./docs/builtin-catalog.md) | All builtin pieces and personas | | [Faceted Prompting](./docs/faceted-prompting.md) | Prompt design methodology | +| [Repertoire Packages](./docs/repertoire.md) | Installing and sharing packages | | [Task Management](./docs/task-management.md) | Task queuing, execution, isolation | | [CI/CD Integration](./docs/ci-cd.md) | GitHub Actions and pipeline mode | | [Changelog](./CHANGELOG.md) ([日本語](./docs/CHANGELOG.ja.md)) | Version history | diff --git a/builtins/en/instructions/ai-fix.md b/builtins/en/facets/instructions/ai-fix.md similarity index 100% rename from builtins/en/instructions/ai-fix.md rename to builtins/en/facets/instructions/ai-fix.md diff --git a/builtins/en/instructions/ai-review.md b/builtins/en/facets/instructions/ai-review.md similarity index 100% rename from builtins/en/instructions/ai-review.md rename to builtins/en/facets/instructions/ai-review.md diff --git a/builtins/en/instructions/arbitrate.md b/builtins/en/facets/instructions/arbitrate.md similarity index 100% rename from builtins/en/instructions/arbitrate.md rename to builtins/en/facets/instructions/arbitrate.md diff --git a/builtins/en/instructions/architect.md b/builtins/en/facets/instructions/architect.md similarity index 100% rename from builtins/en/instructions/architect.md rename to builtins/en/facets/instructions/architect.md diff --git a/builtins/en/instructions/fix-supervisor.md b/builtins/en/facets/instructions/fix-supervisor.md similarity index 100% rename from builtins/en/instructions/fix-supervisor.md rename to builtins/en/facets/instructions/fix-supervisor.md diff --git a/builtins/en/instructions/fix.md b/builtins/en/facets/instructions/fix.md similarity index 66% rename from builtins/en/instructions/fix.md rename to builtins/en/facets/instructions/fix.md index 6278933..6342414 100644 --- a/builtins/en/instructions/fix.md +++ b/builtins/en/facets/instructions/fix.md @@ -2,12 +2,18 @@ Address the reviewer's feedback. Use reports in the Report Directory shown in the Piece Context and fix the issues raised by the reviewer. Use files in the Report Directory as primary evidence. If additional context is needed, you may consult Previous Response and conversation history as secondary sources (Previous Response may be unavailable). If information conflicts, prioritize reports in the Report Directory and actual file contents. +**Important**: After fixing, run both build and tests. +- Build verification is mandatory. Run the build (type check) and verify there are no type errors +- Running tests is mandatory. After build succeeds, always run tests and verify results + **Required output (include headings)** ## Work results - {Summary of actions taken} ## Changes made - {Summary of changes} +## Build results +- {Build execution results} ## Test results -- {Command executed and results} +- {Test command executed and results} ## Evidence - {List key points from files checked/searches/diffs/logs} diff --git a/builtins/en/instructions/implement-e2e-test.md b/builtins/en/facets/instructions/implement-e2e-test.md similarity index 100% rename from builtins/en/instructions/implement-e2e-test.md rename to builtins/en/facets/instructions/implement-e2e-test.md diff --git a/builtins/en/instructions/implement-test.md b/builtins/en/facets/instructions/implement-test.md similarity index 100% rename from builtins/en/instructions/implement-test.md rename to builtins/en/facets/instructions/implement-test.md diff --git a/builtins/en/instructions/implement.md b/builtins/en/facets/instructions/implement.md similarity index 77% rename from builtins/en/instructions/implement.md rename to builtins/en/facets/instructions/implement.md index cb230e8..5b894c1 100644 --- a/builtins/en/instructions/implement.md +++ b/builtins/en/facets/instructions/implement.md @@ -6,7 +6,9 @@ Use reports in the Report Directory as the primary source of truth. If additiona - Add unit tests for newly created classes and functions - Update relevant tests when modifying existing code - Test file placement: follow the project's conventions -- Running tests is mandatory. After completing implementation, always run tests and verify results +- Build verification is mandatory. After completing implementation, run the build (type check) and verify there are no type errors +- Running tests is mandatory. After build succeeds, always run tests and verify results +- When introducing new contract strings (file names, config key names, etc.), define them as constants in one place **Scope output contract (create at the start of implementation):** ```markdown @@ -43,5 +45,7 @@ Small / Medium / Large - {Summary of actions taken} ## Changes made - {Summary of changes} +## Build results +- {Build execution results} ## Test results -- {Command executed and results} +- {Test command executed and results} diff --git a/builtins/en/instructions/loop-monitor-ai-fix.md b/builtins/en/facets/instructions/loop-monitor-ai-fix.md similarity index 100% rename from builtins/en/instructions/loop-monitor-ai-fix.md rename to builtins/en/facets/instructions/loop-monitor-ai-fix.md diff --git a/builtins/en/instructions/plan-e2e-test.md b/builtins/en/facets/instructions/plan-e2e-test.md similarity index 100% rename from builtins/en/instructions/plan-e2e-test.md rename to builtins/en/facets/instructions/plan-e2e-test.md diff --git a/builtins/en/instructions/plan-investigate.md b/builtins/en/facets/instructions/plan-investigate.md similarity index 100% rename from builtins/en/instructions/plan-investigate.md rename to builtins/en/facets/instructions/plan-investigate.md diff --git a/builtins/en/instructions/plan-test.md b/builtins/en/facets/instructions/plan-test.md similarity index 100% rename from builtins/en/instructions/plan-test.md rename to builtins/en/facets/instructions/plan-test.md diff --git a/builtins/en/instructions/plan.md b/builtins/en/facets/instructions/plan.md similarity index 100% rename from builtins/en/instructions/plan.md rename to builtins/en/facets/instructions/plan.md diff --git a/builtins/en/instructions/research-analyze.md b/builtins/en/facets/instructions/research-analyze.md similarity index 100% rename from builtins/en/instructions/research-analyze.md rename to builtins/en/facets/instructions/research-analyze.md diff --git a/builtins/en/instructions/research-dig.md b/builtins/en/facets/instructions/research-dig.md similarity index 100% rename from builtins/en/instructions/research-dig.md rename to builtins/en/facets/instructions/research-dig.md diff --git a/builtins/en/instructions/research-plan.md b/builtins/en/facets/instructions/research-plan.md similarity index 100% rename from builtins/en/instructions/research-plan.md rename to builtins/en/facets/instructions/research-plan.md diff --git a/builtins/en/instructions/research-supervise.md b/builtins/en/facets/instructions/research-supervise.md similarity index 100% rename from builtins/en/instructions/research-supervise.md rename to builtins/en/facets/instructions/research-supervise.md diff --git a/builtins/en/instructions/review-ai.md b/builtins/en/facets/instructions/review-ai.md similarity index 100% rename from builtins/en/instructions/review-ai.md rename to builtins/en/facets/instructions/review-ai.md diff --git a/builtins/en/instructions/review-arch.md b/builtins/en/facets/instructions/review-arch.md similarity index 93% rename from builtins/en/instructions/review-arch.md rename to builtins/en/facets/instructions/review-arch.md index 6d9abde..d1afd74 100644 --- a/builtins/en/instructions/review-arch.md +++ b/builtins/en/facets/instructions/review-arch.md @@ -8,6 +8,7 @@ Do not review AI-specific issues (already covered by the ai_review movement). - Test coverage - Dead code - Call chain verification +- Scattered hardcoding of contract strings (file names, config key names) **Previous finding tracking (required):** - First, extract open findings from "Previous Response" diff --git a/builtins/en/instructions/review-cqrs-es.md b/builtins/en/facets/instructions/review-cqrs-es.md similarity index 100% rename from builtins/en/instructions/review-cqrs-es.md rename to builtins/en/facets/instructions/review-cqrs-es.md diff --git a/builtins/en/instructions/review-frontend.md b/builtins/en/facets/instructions/review-frontend.md similarity index 100% rename from builtins/en/instructions/review-frontend.md rename to builtins/en/facets/instructions/review-frontend.md diff --git a/builtins/en/instructions/review-qa.md b/builtins/en/facets/instructions/review-qa.md similarity index 100% rename from builtins/en/instructions/review-qa.md rename to builtins/en/facets/instructions/review-qa.md diff --git a/builtins/en/instructions/review-security.md b/builtins/en/facets/instructions/review-security.md similarity index 100% rename from builtins/en/instructions/review-security.md rename to builtins/en/facets/instructions/review-security.md diff --git a/builtins/en/instructions/review-test.md b/builtins/en/facets/instructions/review-test.md similarity index 100% rename from builtins/en/instructions/review-test.md rename to builtins/en/facets/instructions/review-test.md diff --git a/builtins/en/instructions/supervise.md b/builtins/en/facets/instructions/supervise.md similarity index 100% rename from builtins/en/instructions/supervise.md rename to builtins/en/facets/instructions/supervise.md diff --git a/builtins/en/knowledge/architecture.md b/builtins/en/facets/knowledge/architecture.md similarity index 100% rename from builtins/en/knowledge/architecture.md rename to builtins/en/facets/knowledge/architecture.md diff --git a/builtins/en/knowledge/backend.md b/builtins/en/facets/knowledge/backend.md similarity index 100% rename from builtins/en/knowledge/backend.md rename to builtins/en/facets/knowledge/backend.md diff --git a/builtins/en/knowledge/cqrs-es.md b/builtins/en/facets/knowledge/cqrs-es.md similarity index 100% rename from builtins/en/knowledge/cqrs-es.md rename to builtins/en/facets/knowledge/cqrs-es.md diff --git a/builtins/en/knowledge/frontend.md b/builtins/en/facets/knowledge/frontend.md similarity index 100% rename from builtins/en/knowledge/frontend.md rename to builtins/en/facets/knowledge/frontend.md diff --git a/builtins/en/knowledge/research-comparative.md b/builtins/en/facets/knowledge/research-comparative.md similarity index 100% rename from builtins/en/knowledge/research-comparative.md rename to builtins/en/facets/knowledge/research-comparative.md diff --git a/builtins/en/knowledge/research.md b/builtins/en/facets/knowledge/research.md similarity index 100% rename from builtins/en/knowledge/research.md rename to builtins/en/facets/knowledge/research.md diff --git a/builtins/en/knowledge/security.md b/builtins/en/facets/knowledge/security.md similarity index 100% rename from builtins/en/knowledge/security.md rename to builtins/en/facets/knowledge/security.md diff --git a/builtins/en/output-contracts/ai-review.md b/builtins/en/facets/output-contracts/ai-review.md similarity index 100% rename from builtins/en/output-contracts/ai-review.md rename to builtins/en/facets/output-contracts/ai-review.md diff --git a/builtins/en/output-contracts/architecture-design.md b/builtins/en/facets/output-contracts/architecture-design.md similarity index 100% rename from builtins/en/output-contracts/architecture-design.md rename to builtins/en/facets/output-contracts/architecture-design.md diff --git a/builtins/en/output-contracts/architecture-review.md b/builtins/en/facets/output-contracts/architecture-review.md similarity index 100% rename from builtins/en/output-contracts/architecture-review.md rename to builtins/en/facets/output-contracts/architecture-review.md diff --git a/builtins/en/output-contracts/coder-decisions.md b/builtins/en/facets/output-contracts/coder-decisions.md similarity index 100% rename from builtins/en/output-contracts/coder-decisions.md rename to builtins/en/facets/output-contracts/coder-decisions.md diff --git a/builtins/en/output-contracts/coder-scope.md b/builtins/en/facets/output-contracts/coder-scope.md similarity index 100% rename from builtins/en/output-contracts/coder-scope.md rename to builtins/en/facets/output-contracts/coder-scope.md diff --git a/builtins/en/output-contracts/cqrs-es-review.md b/builtins/en/facets/output-contracts/cqrs-es-review.md similarity index 100% rename from builtins/en/output-contracts/cqrs-es-review.md rename to builtins/en/facets/output-contracts/cqrs-es-review.md diff --git a/builtins/en/output-contracts/frontend-review.md b/builtins/en/facets/output-contracts/frontend-review.md similarity index 100% rename from builtins/en/output-contracts/frontend-review.md rename to builtins/en/facets/output-contracts/frontend-review.md diff --git a/builtins/en/output-contracts/plan.md b/builtins/en/facets/output-contracts/plan.md similarity index 100% rename from builtins/en/output-contracts/plan.md rename to builtins/en/facets/output-contracts/plan.md diff --git a/builtins/en/output-contracts/qa-review.md b/builtins/en/facets/output-contracts/qa-review.md similarity index 100% rename from builtins/en/output-contracts/qa-review.md rename to builtins/en/facets/output-contracts/qa-review.md diff --git a/builtins/en/output-contracts/security-review.md b/builtins/en/facets/output-contracts/security-review.md similarity index 100% rename from builtins/en/output-contracts/security-review.md rename to builtins/en/facets/output-contracts/security-review.md diff --git a/builtins/en/output-contracts/summary.md b/builtins/en/facets/output-contracts/summary.md similarity index 100% rename from builtins/en/output-contracts/summary.md rename to builtins/en/facets/output-contracts/summary.md diff --git a/builtins/en/output-contracts/supervisor-validation.md b/builtins/en/facets/output-contracts/supervisor-validation.md similarity index 100% rename from builtins/en/output-contracts/supervisor-validation.md rename to builtins/en/facets/output-contracts/supervisor-validation.md diff --git a/builtins/en/output-contracts/test-plan.md b/builtins/en/facets/output-contracts/test-plan.md similarity index 100% rename from builtins/en/output-contracts/test-plan.md rename to builtins/en/facets/output-contracts/test-plan.md diff --git a/builtins/en/output-contracts/validation.md b/builtins/en/facets/output-contracts/validation.md similarity index 100% rename from builtins/en/output-contracts/validation.md rename to builtins/en/facets/output-contracts/validation.md diff --git a/builtins/en/personas/ai-antipattern-reviewer.md b/builtins/en/facets/personas/ai-antipattern-reviewer.md similarity index 100% rename from builtins/en/personas/ai-antipattern-reviewer.md rename to builtins/en/facets/personas/ai-antipattern-reviewer.md diff --git a/builtins/en/personas/architect-planner.md b/builtins/en/facets/personas/architect-planner.md similarity index 100% rename from builtins/en/personas/architect-planner.md rename to builtins/en/facets/personas/architect-planner.md diff --git a/builtins/en/personas/architecture-reviewer.md b/builtins/en/facets/personas/architecture-reviewer.md similarity index 100% rename from builtins/en/personas/architecture-reviewer.md rename to builtins/en/facets/personas/architecture-reviewer.md diff --git a/builtins/en/personas/balthasar.md b/builtins/en/facets/personas/balthasar.md similarity index 100% rename from builtins/en/personas/balthasar.md rename to builtins/en/facets/personas/balthasar.md diff --git a/builtins/en/personas/casper.md b/builtins/en/facets/personas/casper.md similarity index 100% rename from builtins/en/personas/casper.md rename to builtins/en/facets/personas/casper.md diff --git a/builtins/en/personas/coder.md b/builtins/en/facets/personas/coder.md similarity index 100% rename from builtins/en/personas/coder.md rename to builtins/en/facets/personas/coder.md diff --git a/builtins/en/personas/conductor.md b/builtins/en/facets/personas/conductor.md similarity index 100% rename from builtins/en/personas/conductor.md rename to builtins/en/facets/personas/conductor.md diff --git a/builtins/en/personas/cqrs-es-reviewer.md b/builtins/en/facets/personas/cqrs-es-reviewer.md similarity index 100% rename from builtins/en/personas/cqrs-es-reviewer.md rename to builtins/en/facets/personas/cqrs-es-reviewer.md diff --git a/builtins/en/personas/expert-supervisor.md b/builtins/en/facets/personas/expert-supervisor.md similarity index 100% rename from builtins/en/personas/expert-supervisor.md rename to builtins/en/facets/personas/expert-supervisor.md diff --git a/builtins/en/personas/frontend-reviewer.md b/builtins/en/facets/personas/frontend-reviewer.md similarity index 100% rename from builtins/en/personas/frontend-reviewer.md rename to builtins/en/facets/personas/frontend-reviewer.md diff --git a/builtins/en/personas/melchior.md b/builtins/en/facets/personas/melchior.md similarity index 100% rename from builtins/en/personas/melchior.md rename to builtins/en/facets/personas/melchior.md diff --git a/builtins/en/personas/planner.md b/builtins/en/facets/personas/planner.md similarity index 100% rename from builtins/en/personas/planner.md rename to builtins/en/facets/personas/planner.md diff --git a/builtins/en/personas/pr-commenter.md b/builtins/en/facets/personas/pr-commenter.md similarity index 100% rename from builtins/en/personas/pr-commenter.md rename to builtins/en/facets/personas/pr-commenter.md diff --git a/builtins/en/personas/qa-reviewer.md b/builtins/en/facets/personas/qa-reviewer.md similarity index 100% rename from builtins/en/personas/qa-reviewer.md rename to builtins/en/facets/personas/qa-reviewer.md diff --git a/builtins/en/personas/research-analyzer.md b/builtins/en/facets/personas/research-analyzer.md similarity index 100% rename from builtins/en/personas/research-analyzer.md rename to builtins/en/facets/personas/research-analyzer.md diff --git a/builtins/en/personas/research-digger.md b/builtins/en/facets/personas/research-digger.md similarity index 100% rename from builtins/en/personas/research-digger.md rename to builtins/en/facets/personas/research-digger.md diff --git a/builtins/en/personas/research-planner.md b/builtins/en/facets/personas/research-planner.md similarity index 100% rename from builtins/en/personas/research-planner.md rename to builtins/en/facets/personas/research-planner.md diff --git a/builtins/en/personas/research-supervisor.md b/builtins/en/facets/personas/research-supervisor.md similarity index 100% rename from builtins/en/personas/research-supervisor.md rename to builtins/en/facets/personas/research-supervisor.md diff --git a/builtins/en/personas/security-reviewer.md b/builtins/en/facets/personas/security-reviewer.md similarity index 100% rename from builtins/en/personas/security-reviewer.md rename to builtins/en/facets/personas/security-reviewer.md diff --git a/builtins/en/personas/supervisor.md b/builtins/en/facets/personas/supervisor.md similarity index 100% rename from builtins/en/personas/supervisor.md rename to builtins/en/facets/personas/supervisor.md diff --git a/builtins/en/personas/test-planner.md b/builtins/en/facets/personas/test-planner.md similarity index 100% rename from builtins/en/personas/test-planner.md rename to builtins/en/facets/personas/test-planner.md diff --git a/builtins/en/policies/ai-antipattern.md b/builtins/en/facets/policies/ai-antipattern.md similarity index 100% rename from builtins/en/policies/ai-antipattern.md rename to builtins/en/facets/policies/ai-antipattern.md diff --git a/builtins/en/policies/coding.md b/builtins/en/facets/policies/coding.md similarity index 98% rename from builtins/en/policies/coding.md rename to builtins/en/facets/policies/coding.md index d658b28..188fdac 100644 --- a/builtins/en/policies/coding.md +++ b/builtins/en/facets/policies/coding.md @@ -284,6 +284,7 @@ function formatPercentage(value: number): string { ... } - **Direct mutation of objects/arrays** - Create new instances with spread operators - **console.log** - Do not leave in production code - **Hardcoded secrets** +- **Scattered hardcoded contract strings** - File names and config key names must be defined as constants in one place. Scattered literals are prohibited - **Scattered try-catch** - Centralize error handling at the upper layer - **Unsolicited backward compatibility / legacy support** - Not needed unless explicitly instructed - **Internal implementation exported from public API** - Only export domain-level functions and types. Do not export infrastructure functions or internal classes diff --git a/builtins/en/policies/qa.md b/builtins/en/facets/policies/qa.md similarity index 100% rename from builtins/en/policies/qa.md rename to builtins/en/facets/policies/qa.md diff --git a/builtins/en/policies/research.md b/builtins/en/facets/policies/research.md similarity index 100% rename from builtins/en/policies/research.md rename to builtins/en/facets/policies/research.md diff --git a/builtins/en/policies/review.md b/builtins/en/facets/policies/review.md similarity index 100% rename from builtins/en/policies/review.md rename to builtins/en/facets/policies/review.md diff --git a/builtins/en/policies/testing.md b/builtins/en/facets/policies/testing.md similarity index 96% rename from builtins/en/policies/testing.md rename to builtins/en/facets/policies/testing.md index 4f6471d..ffe8c55 100644 --- a/builtins/en/policies/testing.md +++ b/builtins/en/facets/policies/testing.md @@ -10,6 +10,7 @@ Every behavior change requires a corresponding test, and every bug fix requires | One test, one concept | Do not mix multiple concerns in a single test | | Test behavior | Test behavior, not implementation details | | Independence | Do not depend on other tests or execution order | +| Type safety | Code must pass the build (type check) | | Reproducibility | Do not depend on time or randomness. Same result every run | ## Coverage Criteria @@ -19,6 +20,7 @@ Every behavior change requires a corresponding test, and every bug fix requires | New behavior | Test required. REJECT if missing | | Bug fix | Regression test required. REJECT if missing | | Behavior change | Test update required. REJECT if missing | +| Build (type check) | Build must succeed. REJECT if it fails | | Edge cases / boundary values | Test recommended (Warning) | ## Test Priority diff --git a/builtins/ja/instructions/ai-fix.md b/builtins/ja/facets/instructions/ai-fix.md similarity index 100% rename from builtins/ja/instructions/ai-fix.md rename to builtins/ja/facets/instructions/ai-fix.md diff --git a/builtins/ja/instructions/ai-review.md b/builtins/ja/facets/instructions/ai-review.md similarity index 100% rename from builtins/ja/instructions/ai-review.md rename to builtins/ja/facets/instructions/ai-review.md diff --git a/builtins/ja/instructions/arbitrate.md b/builtins/ja/facets/instructions/arbitrate.md similarity index 100% rename from builtins/ja/instructions/arbitrate.md rename to builtins/ja/facets/instructions/arbitrate.md diff --git a/builtins/ja/instructions/architect.md b/builtins/ja/facets/instructions/architect.md similarity index 100% rename from builtins/ja/instructions/architect.md rename to builtins/ja/facets/instructions/architect.md diff --git a/builtins/ja/instructions/fix-supervisor.md b/builtins/ja/facets/instructions/fix-supervisor.md similarity index 100% rename from builtins/ja/instructions/fix-supervisor.md rename to builtins/ja/facets/instructions/fix-supervisor.md diff --git a/builtins/ja/instructions/fix.md b/builtins/ja/facets/instructions/fix.md similarity index 69% rename from builtins/ja/instructions/fix.md rename to builtins/ja/facets/instructions/fix.md index d39d28a..6882213 100644 --- a/builtins/ja/instructions/fix.md +++ b/builtins/ja/facets/instructions/fix.md @@ -2,12 +2,18 @@ Piece Contextに示されたReport Directory内のレポートを確認し、レビュアーの指摘事項を修正してください。 必要な根拠はReport Directory内のファイルを一次情報として取得してください。不足情報の補完が必要な場合に限り、Previous Responseや会話履歴を補助的に参照して構いません(Previous Responseは提供されない場合があります)。情報が競合する場合は、Report Directory内のレポートと実際のファイル内容を優先してください。 +**重要**: 修正後、ビルドとテストの両方を実行してください。 +- ビルド確認は必須。ビルド(型チェック)を実行し、型エラーがないことを確認 +- テスト実行は必須。ビルド成功後、必ずテストを実行して結果を確認 + **必須出力(見出しを含める)** ## 作業結果 - {実施内容の要約} ## 変更内容 - {変更内容の要約} +## ビルド結果 +- {ビルド実行結果} ## テスト結果 -- {実行コマンドと結果} +- {テスト実行コマンドと結果} ## 証拠 - {確認したファイル/検索/差分/ログの要点を列挙} diff --git a/builtins/ja/instructions/implement-e2e-test.md b/builtins/ja/facets/instructions/implement-e2e-test.md similarity index 100% rename from builtins/ja/instructions/implement-e2e-test.md rename to builtins/ja/facets/instructions/implement-e2e-test.md diff --git a/builtins/ja/instructions/implement-test.md b/builtins/ja/facets/instructions/implement-test.md similarity index 100% rename from builtins/ja/instructions/implement-test.md rename to builtins/ja/facets/instructions/implement-test.md diff --git a/builtins/ja/instructions/implement.md b/builtins/ja/facets/instructions/implement.md similarity index 79% rename from builtins/ja/instructions/implement.md rename to builtins/ja/facets/instructions/implement.md index dda69ca..e461317 100644 --- a/builtins/ja/instructions/implement.md +++ b/builtins/ja/facets/instructions/implement.md @@ -6,7 +6,9 @@ Report Directory内のレポートを一次情報として参照してくださ - 新規作成したクラス・関数には単体テストを追加 - 既存コードを変更した場合は該当するテストを更新 - テストファイルの配置: プロジェクトの規約に従う -- テスト実行は必須。実装完了後、必ずテストを実行して結果を確認 +- ビルド確認は必須。実装完了後、ビルド(型チェック)を実行し、型エラーがないことを確認 +- テスト実行は必須。ビルド成功後、必ずテストを実行して結果を確認 +- ファイル名・設定キー名などの契約文字列を新規導入する場合は、定数として1箇所で定義すること **Scope出力契約(実装開始時に作成):** ```markdown @@ -43,5 +45,7 @@ Small / Medium / Large - {実施内容の要約} ## 変更内容 - {変更内容の要約} +## ビルド結果 +- {ビルド実行結果} ## テスト結果 -- {実行コマンドと結果} +- {テスト実行コマンドと結果} diff --git a/builtins/ja/instructions/loop-monitor-ai-fix.md b/builtins/ja/facets/instructions/loop-monitor-ai-fix.md similarity index 100% rename from builtins/ja/instructions/loop-monitor-ai-fix.md rename to builtins/ja/facets/instructions/loop-monitor-ai-fix.md diff --git a/builtins/ja/instructions/plan-e2e-test.md b/builtins/ja/facets/instructions/plan-e2e-test.md similarity index 100% rename from builtins/ja/instructions/plan-e2e-test.md rename to builtins/ja/facets/instructions/plan-e2e-test.md diff --git a/builtins/ja/instructions/plan-investigate.md b/builtins/ja/facets/instructions/plan-investigate.md similarity index 100% rename from builtins/ja/instructions/plan-investigate.md rename to builtins/ja/facets/instructions/plan-investigate.md diff --git a/builtins/ja/instructions/plan-test.md b/builtins/ja/facets/instructions/plan-test.md similarity index 100% rename from builtins/ja/instructions/plan-test.md rename to builtins/ja/facets/instructions/plan-test.md diff --git a/builtins/ja/instructions/plan.md b/builtins/ja/facets/instructions/plan.md similarity index 100% rename from builtins/ja/instructions/plan.md rename to builtins/ja/facets/instructions/plan.md diff --git a/builtins/ja/instructions/research-analyze.md b/builtins/ja/facets/instructions/research-analyze.md similarity index 100% rename from builtins/ja/instructions/research-analyze.md rename to builtins/ja/facets/instructions/research-analyze.md diff --git a/builtins/ja/instructions/research-dig.md b/builtins/ja/facets/instructions/research-dig.md similarity index 100% rename from builtins/ja/instructions/research-dig.md rename to builtins/ja/facets/instructions/research-dig.md diff --git a/builtins/ja/instructions/research-plan.md b/builtins/ja/facets/instructions/research-plan.md similarity index 100% rename from builtins/ja/instructions/research-plan.md rename to builtins/ja/facets/instructions/research-plan.md diff --git a/builtins/ja/instructions/research-supervise.md b/builtins/ja/facets/instructions/research-supervise.md similarity index 100% rename from builtins/ja/instructions/research-supervise.md rename to builtins/ja/facets/instructions/research-supervise.md diff --git a/builtins/ja/instructions/review-ai.md b/builtins/ja/facets/instructions/review-ai.md similarity index 100% rename from builtins/ja/instructions/review-ai.md rename to builtins/ja/facets/instructions/review-ai.md diff --git a/builtins/ja/instructions/review-arch.md b/builtins/ja/facets/instructions/review-arch.md similarity index 93% rename from builtins/ja/instructions/review-arch.md rename to builtins/ja/facets/instructions/review-arch.md index 51ceb71..6932dc8 100644 --- a/builtins/ja/instructions/review-arch.md +++ b/builtins/ja/facets/instructions/review-arch.md @@ -8,6 +8,7 @@ AI特有の問題はレビューしないでください(ai_reviewムーブメ - テストカバレッジ - デッドコード - 呼び出しチェーン検証 +- 契約文字列(ファイル名・設定キー名)のハードコード散在 **前回指摘の追跡(必須):** - まず「Previous Response」から前回の open findings を抽出する diff --git a/builtins/ja/instructions/review-cqrs-es.md b/builtins/ja/facets/instructions/review-cqrs-es.md similarity index 100% rename from builtins/ja/instructions/review-cqrs-es.md rename to builtins/ja/facets/instructions/review-cqrs-es.md diff --git a/builtins/ja/instructions/review-frontend.md b/builtins/ja/facets/instructions/review-frontend.md similarity index 100% rename from builtins/ja/instructions/review-frontend.md rename to builtins/ja/facets/instructions/review-frontend.md diff --git a/builtins/ja/instructions/review-qa.md b/builtins/ja/facets/instructions/review-qa.md similarity index 100% rename from builtins/ja/instructions/review-qa.md rename to builtins/ja/facets/instructions/review-qa.md diff --git a/builtins/ja/instructions/review-security.md b/builtins/ja/facets/instructions/review-security.md similarity index 100% rename from builtins/ja/instructions/review-security.md rename to builtins/ja/facets/instructions/review-security.md diff --git a/builtins/ja/instructions/review-test.md b/builtins/ja/facets/instructions/review-test.md similarity index 100% rename from builtins/ja/instructions/review-test.md rename to builtins/ja/facets/instructions/review-test.md diff --git a/builtins/ja/instructions/supervise.md b/builtins/ja/facets/instructions/supervise.md similarity index 100% rename from builtins/ja/instructions/supervise.md rename to builtins/ja/facets/instructions/supervise.md diff --git a/builtins/ja/knowledge/architecture.md b/builtins/ja/facets/knowledge/architecture.md similarity index 100% rename from builtins/ja/knowledge/architecture.md rename to builtins/ja/facets/knowledge/architecture.md diff --git a/builtins/ja/knowledge/backend.md b/builtins/ja/facets/knowledge/backend.md similarity index 100% rename from builtins/ja/knowledge/backend.md rename to builtins/ja/facets/knowledge/backend.md diff --git a/builtins/ja/knowledge/cqrs-es.md b/builtins/ja/facets/knowledge/cqrs-es.md similarity index 100% rename from builtins/ja/knowledge/cqrs-es.md rename to builtins/ja/facets/knowledge/cqrs-es.md diff --git a/builtins/ja/knowledge/frontend.md b/builtins/ja/facets/knowledge/frontend.md similarity index 100% rename from builtins/ja/knowledge/frontend.md rename to builtins/ja/facets/knowledge/frontend.md diff --git a/builtins/ja/knowledge/research-comparative.md b/builtins/ja/facets/knowledge/research-comparative.md similarity index 100% rename from builtins/ja/knowledge/research-comparative.md rename to builtins/ja/facets/knowledge/research-comparative.md diff --git a/builtins/ja/knowledge/research.md b/builtins/ja/facets/knowledge/research.md similarity index 100% rename from builtins/ja/knowledge/research.md rename to builtins/ja/facets/knowledge/research.md diff --git a/builtins/ja/knowledge/security.md b/builtins/ja/facets/knowledge/security.md similarity index 100% rename from builtins/ja/knowledge/security.md rename to builtins/ja/facets/knowledge/security.md diff --git a/builtins/ja/output-contracts/ai-review.md b/builtins/ja/facets/output-contracts/ai-review.md similarity index 100% rename from builtins/ja/output-contracts/ai-review.md rename to builtins/ja/facets/output-contracts/ai-review.md diff --git a/builtins/ja/output-contracts/architecture-design.md b/builtins/ja/facets/output-contracts/architecture-design.md similarity index 100% rename from builtins/ja/output-contracts/architecture-design.md rename to builtins/ja/facets/output-contracts/architecture-design.md diff --git a/builtins/ja/output-contracts/architecture-review.md b/builtins/ja/facets/output-contracts/architecture-review.md similarity index 100% rename from builtins/ja/output-contracts/architecture-review.md rename to builtins/ja/facets/output-contracts/architecture-review.md diff --git a/builtins/ja/output-contracts/coder-decisions.md b/builtins/ja/facets/output-contracts/coder-decisions.md similarity index 100% rename from builtins/ja/output-contracts/coder-decisions.md rename to builtins/ja/facets/output-contracts/coder-decisions.md diff --git a/builtins/ja/output-contracts/coder-scope.md b/builtins/ja/facets/output-contracts/coder-scope.md similarity index 100% rename from builtins/ja/output-contracts/coder-scope.md rename to builtins/ja/facets/output-contracts/coder-scope.md diff --git a/builtins/ja/output-contracts/cqrs-es-review.md b/builtins/ja/facets/output-contracts/cqrs-es-review.md similarity index 100% rename from builtins/ja/output-contracts/cqrs-es-review.md rename to builtins/ja/facets/output-contracts/cqrs-es-review.md diff --git a/builtins/ja/output-contracts/frontend-review.md b/builtins/ja/facets/output-contracts/frontend-review.md similarity index 100% rename from builtins/ja/output-contracts/frontend-review.md rename to builtins/ja/facets/output-contracts/frontend-review.md diff --git a/builtins/ja/output-contracts/plan.md b/builtins/ja/facets/output-contracts/plan.md similarity index 100% rename from builtins/ja/output-contracts/plan.md rename to builtins/ja/facets/output-contracts/plan.md diff --git a/builtins/ja/output-contracts/qa-review.md b/builtins/ja/facets/output-contracts/qa-review.md similarity index 100% rename from builtins/ja/output-contracts/qa-review.md rename to builtins/ja/facets/output-contracts/qa-review.md diff --git a/builtins/ja/output-contracts/security-review.md b/builtins/ja/facets/output-contracts/security-review.md similarity index 100% rename from builtins/ja/output-contracts/security-review.md rename to builtins/ja/facets/output-contracts/security-review.md diff --git a/builtins/ja/output-contracts/summary.md b/builtins/ja/facets/output-contracts/summary.md similarity index 100% rename from builtins/ja/output-contracts/summary.md rename to builtins/ja/facets/output-contracts/summary.md diff --git a/builtins/ja/output-contracts/supervisor-validation.md b/builtins/ja/facets/output-contracts/supervisor-validation.md similarity index 100% rename from builtins/ja/output-contracts/supervisor-validation.md rename to builtins/ja/facets/output-contracts/supervisor-validation.md diff --git a/builtins/ja/output-contracts/test-plan.md b/builtins/ja/facets/output-contracts/test-plan.md similarity index 100% rename from builtins/ja/output-contracts/test-plan.md rename to builtins/ja/facets/output-contracts/test-plan.md diff --git a/builtins/ja/output-contracts/validation.md b/builtins/ja/facets/output-contracts/validation.md similarity index 100% rename from builtins/ja/output-contracts/validation.md rename to builtins/ja/facets/output-contracts/validation.md diff --git a/builtins/ja/personas/ai-antipattern-reviewer.md b/builtins/ja/facets/personas/ai-antipattern-reviewer.md similarity index 100% rename from builtins/ja/personas/ai-antipattern-reviewer.md rename to builtins/ja/facets/personas/ai-antipattern-reviewer.md diff --git a/builtins/ja/personas/architect-planner.md b/builtins/ja/facets/personas/architect-planner.md similarity index 100% rename from builtins/ja/personas/architect-planner.md rename to builtins/ja/facets/personas/architect-planner.md diff --git a/builtins/ja/personas/architecture-reviewer.md b/builtins/ja/facets/personas/architecture-reviewer.md similarity index 100% rename from builtins/ja/personas/architecture-reviewer.md rename to builtins/ja/facets/personas/architecture-reviewer.md diff --git a/builtins/ja/personas/balthasar.md b/builtins/ja/facets/personas/balthasar.md similarity index 100% rename from builtins/ja/personas/balthasar.md rename to builtins/ja/facets/personas/balthasar.md diff --git a/builtins/ja/personas/casper.md b/builtins/ja/facets/personas/casper.md similarity index 100% rename from builtins/ja/personas/casper.md rename to builtins/ja/facets/personas/casper.md diff --git a/builtins/ja/personas/coder.md b/builtins/ja/facets/personas/coder.md similarity index 100% rename from builtins/ja/personas/coder.md rename to builtins/ja/facets/personas/coder.md diff --git a/builtins/ja/personas/conductor.md b/builtins/ja/facets/personas/conductor.md similarity index 100% rename from builtins/ja/personas/conductor.md rename to builtins/ja/facets/personas/conductor.md diff --git a/builtins/ja/personas/cqrs-es-reviewer.md b/builtins/ja/facets/personas/cqrs-es-reviewer.md similarity index 100% rename from builtins/ja/personas/cqrs-es-reviewer.md rename to builtins/ja/facets/personas/cqrs-es-reviewer.md diff --git a/builtins/ja/personas/expert-supervisor.md b/builtins/ja/facets/personas/expert-supervisor.md similarity index 100% rename from builtins/ja/personas/expert-supervisor.md rename to builtins/ja/facets/personas/expert-supervisor.md diff --git a/builtins/ja/personas/frontend-reviewer.md b/builtins/ja/facets/personas/frontend-reviewer.md similarity index 100% rename from builtins/ja/personas/frontend-reviewer.md rename to builtins/ja/facets/personas/frontend-reviewer.md diff --git a/builtins/ja/personas/melchior.md b/builtins/ja/facets/personas/melchior.md similarity index 100% rename from builtins/ja/personas/melchior.md rename to builtins/ja/facets/personas/melchior.md diff --git a/builtins/ja/personas/planner.md b/builtins/ja/facets/personas/planner.md similarity index 100% rename from builtins/ja/personas/planner.md rename to builtins/ja/facets/personas/planner.md diff --git a/builtins/ja/personas/pr-commenter.md b/builtins/ja/facets/personas/pr-commenter.md similarity index 100% rename from builtins/ja/personas/pr-commenter.md rename to builtins/ja/facets/personas/pr-commenter.md diff --git a/builtins/ja/personas/qa-reviewer.md b/builtins/ja/facets/personas/qa-reviewer.md similarity index 100% rename from builtins/ja/personas/qa-reviewer.md rename to builtins/ja/facets/personas/qa-reviewer.md diff --git a/builtins/ja/personas/research-analyzer.md b/builtins/ja/facets/personas/research-analyzer.md similarity index 100% rename from builtins/ja/personas/research-analyzer.md rename to builtins/ja/facets/personas/research-analyzer.md diff --git a/builtins/ja/personas/research-digger.md b/builtins/ja/facets/personas/research-digger.md similarity index 100% rename from builtins/ja/personas/research-digger.md rename to builtins/ja/facets/personas/research-digger.md diff --git a/builtins/ja/personas/research-planner.md b/builtins/ja/facets/personas/research-planner.md similarity index 100% rename from builtins/ja/personas/research-planner.md rename to builtins/ja/facets/personas/research-planner.md diff --git a/builtins/ja/personas/research-supervisor.md b/builtins/ja/facets/personas/research-supervisor.md similarity index 100% rename from builtins/ja/personas/research-supervisor.md rename to builtins/ja/facets/personas/research-supervisor.md diff --git a/builtins/ja/personas/security-reviewer.md b/builtins/ja/facets/personas/security-reviewer.md similarity index 100% rename from builtins/ja/personas/security-reviewer.md rename to builtins/ja/facets/personas/security-reviewer.md diff --git a/builtins/ja/personas/supervisor.md b/builtins/ja/facets/personas/supervisor.md similarity index 100% rename from builtins/ja/personas/supervisor.md rename to builtins/ja/facets/personas/supervisor.md diff --git a/builtins/ja/personas/test-planner.md b/builtins/ja/facets/personas/test-planner.md similarity index 100% rename from builtins/ja/personas/test-planner.md rename to builtins/ja/facets/personas/test-planner.md diff --git a/builtins/ja/policies/ai-antipattern.md b/builtins/ja/facets/policies/ai-antipattern.md similarity index 100% rename from builtins/ja/policies/ai-antipattern.md rename to builtins/ja/facets/policies/ai-antipattern.md diff --git a/builtins/ja/policies/coding.md b/builtins/ja/facets/policies/coding.md similarity index 98% rename from builtins/ja/policies/coding.md rename to builtins/ja/facets/policies/coding.md index df77a33..eabef2f 100644 --- a/builtins/ja/policies/coding.md +++ b/builtins/ja/facets/policies/coding.md @@ -284,6 +284,7 @@ function formatPercentage(value: number): string { ... } - **オブジェクト/配列の直接変更** - スプレッド演算子で新規作成 - **console.log** - 本番コードに残さない - **機密情報のハードコーディング** +- **契約文字列のハードコード散在** - ファイル名・設定キー名は定数で1箇所管理。リテラルの散在は禁止 - **各所でのtry-catch** - エラーは上位層で一元処理 - **後方互換・Legacy対応の自発的追加** - 明示的な指示がない限り不要 - **内部実装のパブリック API エクスポート** - 公開するのはドメイン操作の関数・型のみ。インフラ層の関数や内部クラスをエクスポートしない diff --git a/builtins/ja/policies/qa.md b/builtins/ja/facets/policies/qa.md similarity index 100% rename from builtins/ja/policies/qa.md rename to builtins/ja/facets/policies/qa.md diff --git a/builtins/ja/policies/research.md b/builtins/ja/facets/policies/research.md similarity index 100% rename from builtins/ja/policies/research.md rename to builtins/ja/facets/policies/research.md diff --git a/builtins/ja/policies/review.md b/builtins/ja/facets/policies/review.md similarity index 100% rename from builtins/ja/policies/review.md rename to builtins/ja/facets/policies/review.md diff --git a/builtins/ja/policies/testing.md b/builtins/ja/facets/policies/testing.md similarity index 95% rename from builtins/ja/policies/testing.md rename to builtins/ja/facets/policies/testing.md index cf13a97..0a3081d 100644 --- a/builtins/ja/policies/testing.md +++ b/builtins/ja/facets/policies/testing.md @@ -19,6 +19,7 @@ | 新しい振る舞い | テスト必須。テストがなければ REJECT | | バグ修正 | リグレッションテスト必須。テストがなければ REJECT | | 振る舞いの変更 | テストの更新必須。更新がなければ REJECT | +| ビルド(型チェック) | ビルド成功必須。失敗は REJECT | | エッジケース・境界値 | テスト推奨(Warning) | ## テスト優先度 @@ -49,6 +50,7 @@ test('ユーザーが存在しない場合、NotFoundエラーを返す', async | 観点 | 良い | 悪い | |------|------|------| | 独立性 | 他のテストに依存しない | 実行順序に依存 | +| 型安全 | コードはビルド(型チェック)が通ること | | 再現性 | 毎回同じ結果 | 時間やランダム性に依存 | | 明確性 | 失敗時に原因が分かる | 失敗しても原因不明 | | 焦点 | 1テスト1概念 | 複数の関心事が混在 | diff --git a/docs/CHANGELOG.ja.md b/docs/CHANGELOG.ja.md index 396d15a..397ef4f 100644 --- a/docs/CHANGELOG.ja.md +++ b/docs/CHANGELOG.ja.md @@ -6,6 +6,39 @@ フォーマットは [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) に基づいています。 +## [0.22.0] - 2026-02-22 + +### Added + +- **Repertoire パッケージシステム** (`takt repertoire add/remove/list`): GitHub から外部 TAKT パッケージをインポート・管理 — `takt repertoire add github:{owner}/{repo}@{ref}` でパッケージを `~/.takt/repertoire/` にダウンロード。アトミックなインストール、バージョン互換チェック、ロックファイル生成、確認前のパッケージ内容サマリ表示に対応 +- **@scope 参照**: piece YAML のファセット参照で `@{owner}/{repo}/{facet-name}` 構文をサポート — インストール済み repertoire パッケージのファセットを直接参照可能(例: `persona: @nrslib/takt-fullstack/expert-coder`) +- **4層ファセット解決**: 3層(project → user → builtin)から4層(package-local → project → user → builtin)に拡張 — repertoire パッケージのピースは自パッケージ内のファセットを最優先で解決 +- **ピース選択に repertoire カテゴリ追加**: インストール済みの repertoire パッケージがピース選択 UI の「repertoire」カテゴリにサブカテゴリとして自動表示 +- **implement/fix インストラクションにビルドゲート追加**: `implement` と `fix` のビルトインインストラクションでテスト実行前にビルド(型チェック)の実行を必須化 +- **Repertoire パッケージドキュメント追加**: repertoire パッケージシステムの包括的なドキュメントを追加([en](./repertoire.md), [ja](./repertoire.ja.md)) + +### Changed + +- **BREAKING: "ensemble" を "repertoire" にリネーム**: 全 CLI コマンド、ディレクトリ、設定キー、API を変更 — `takt ensemble` → `takt repertoire`、`~/.takt/ensemble/` → `~/.takt/repertoire/`。マイグレーション: `~/.takt/ensemble/` ディレクトリを `~/.takt/repertoire/` にリネームしてください +- **BREAKING: ファセットディレクトリ構造の変更**: 全レイヤーでファセットディレクトリが `facets/` サブディレクトリ配下に移動 — `builtins/{lang}/{facetType}/` → `builtins/{lang}/facets/{facetType}/`、`~/.takt/{facetType}/` → `~/.takt/facets/{facetType}/`、`.takt/{facetType}/` → `.takt/facets/{facetType}/`。マイグレーション: カスタムファセットファイルを新しい `facets/` サブディレクトリに移動してください +- 契約文字列のハードコード散在防止ルールをコーディングポリシーとアーキテクチャレビューインストラクションに追加 + +### Fixed + +- オーバーライドピースの検証が repertoire スコープを含むリゾルバー経由で実行されるよう修正 +- `takt export-cc` が新しい `builtins/{lang}/facets/` ディレクトリ構造からファセットを読み込むよう修正 +- `confirm()` プロンプトがパイプ経由の stdin に対応(例: `echo "y" | takt repertoire add ...`) +- イテレーション入力待ち中の `poll_tick` デバッグログ連続出力を抑制 +- ピースリゾルバーの `stat()` 呼び出しでアクセス不能エントリ時にクラッシュせずエラーハンドリング + +### Internal + +- Repertoire テストスイート: atomic-update, repertoire-paths, file-filter, github-ref-resolver, github-spec, list, lock-file, pack-summary, package-facet-resolution, remove-reference-check, remove, takt-repertoire-config, tar-parser, takt-repertoire-schema +- `src/faceted-prompting/scope.ts` を追加(@scope 参照のパース・バリデーション・解決) +- faceted-prompting モジュールの scope-ref テストを追加 +- `inputWait.ts` を追加(ワーカープールのログノイズ抑制のための入力待ち状態共有) +- piece-selection-branches および repertoire の e2e テストを追加 + ## [0.21.0] - 2026-02-20 ### Added diff --git a/docs/README.ja.md b/docs/README.ja.md index 94036b8..729c661 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -156,6 +156,7 @@ movements: | `takt #N` | GitHub Issue をタスクとして実行します | | `takt switch` | 使う piece を切り替えます | | `takt eject` | ビルトインの piece/persona をコピーしてカスタマイズできます | +| `takt repertoire add` | GitHub から repertoire パッケージをインストールします | 全コマンド・オプションは [CLI Reference](./cli-reference.ja.md) を参照してください。 @@ -223,10 +224,12 @@ takt --pipeline --task "バグを修正して" --auto-pr ~/.takt/ # グローバル設定 ├── config.yaml # プロバイダー、モデル、言語など ├── pieces/ # ユーザー定義の piece -└── personas/ # ユーザー定義の persona +├── facets/ # ユーザー定義のファセット(personas, policies, knowledge など) +└── repertoire/ # インストール済み repertoire パッケージ .takt/ # プロジェクトレベル ├── config.yaml # プロジェクト設定 +├── facets/ # プロジェクトのファセット ├── tasks.yaml # 積まれたタスク ├── tasks/ # タスクの仕様書 └── runs/ # 実行レポート、ログ、コンテキスト @@ -258,6 +261,7 @@ await engine.run(); | [Agent Guide](./agents.md) | カスタムエージェントの設定 | | [Builtin Catalog](./builtin-catalog.ja.md) | ビルトイン piece・persona の一覧 | | [Faceted Prompting](./faceted-prompting.ja.md) | プロンプト設計の方法論 | +| [Repertoire Packages](./repertoire.ja.md) | パッケージのインストール・共有 | | [Task Management](./task-management.ja.md) | タスクの追加・実行・隔離 | | [CI/CD Integration](./ci-cd.ja.md) | GitHub Actions・パイプラインモード | | [Changelog](../CHANGELOG.md) ([日本語](./CHANGELOG.ja.md)) | バージョン履歴 | diff --git a/docs/cli-reference.ja.md b/docs/cli-reference.ja.md index 9a91927..da942bc 100644 --- a/docs/cli-reference.ja.md +++ b/docs/cli-reference.ja.md @@ -300,6 +300,26 @@ takt metrics review takt metrics review --since 7d ``` +### takt repertoire + +Repertoire パッケージ(GitHub 上の外部 TAKT パッケージ)を管理します。 + +```bash +# GitHub からパッケージをインストール +takt repertoire add github:{owner}/{repo}@{ref} + +# デフォルトブランチからインストール +takt repertoire add github:{owner}/{repo} + +# インストール済みパッケージを一覧表示 +takt repertoire list + +# パッケージを削除 +takt repertoire remove @{owner}/{repo} +``` + +インストールされたパッケージは `~/.takt/repertoire/` に保存され、ピース選択やファセット解決で利用可能になります。 + ### takt purge 古いアナリティクスイベントファイルを削除します。 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2902a43..c9a87cc 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -300,6 +300,26 @@ takt metrics review takt metrics review --since 7d ``` +### takt repertoire + +Manage repertoire packages (external TAKT packages from GitHub). + +```bash +# Install a package from GitHub +takt repertoire add github:{owner}/{repo}@{ref} + +# Install from default branch +takt repertoire add github:{owner}/{repo} + +# List installed packages +takt repertoire list + +# Remove a package +takt repertoire remove @{owner}/{repo} +``` + +Installed packages are stored in `~/.takt/repertoire/` and their pieces/facets become available in piece selection and facet resolution. + ### takt purge Purge old analytics event files. diff --git a/docs/repertoire.ja.md b/docs/repertoire.ja.md new file mode 100644 index 0000000..06316e1 --- /dev/null +++ b/docs/repertoire.ja.md @@ -0,0 +1,168 @@ +# Repertoire パッケージ + +[English](./repertoire.md) + +Repertoire パッケージを使うと、GitHub リポジトリから TAKT のピースやファセットをインストール・共有できます。 + +## クイックスタート + +```bash +# パッケージをインストール +takt repertoire add github:nrslib/takt-fullstack + +# 特定バージョンを指定してインストール +takt repertoire add github:nrslib/takt-fullstack@v1.0.0 + +# インストール済みパッケージを一覧表示 +takt repertoire list + +# パッケージを削除 +takt repertoire remove @nrslib/takt-fullstack +``` + +[GitHub CLI](https://cli.github.com/) (`gh`) のインストールと認証が必要です。 + +## パッケージ構造 + +TAKT パッケージは `takt-repertoire.yaml` マニフェストとコンテンツディレクトリを持つ GitHub リポジトリです。 + +``` +my-takt-repertoire/ + takt-repertoire.yaml # マニフェスト(.takt/takt-repertoire.yaml でも可) + facets/ + personas/ + expert-coder.md + policies/ + strict-review.md + knowledge/ + domain.md + instructions/ + plan.md + pieces/ + expert.yaml +``` + +`facets/` と `pieces/` ディレクトリのみがインポートされます。その他のファイルは無視されます。 + +### takt-repertoire.yaml + +マニフェストは、リポジトリ内のパッケージコンテンツの場所を TAKT に伝えます。 + +```yaml +# 説明(任意) +description: フルスタック開発用ピースとエキスパートレビュアー + +# パッケージルートへのパス(リポジトリルートからの相対パス、デフォルト: ".") +path: . + +# TAKT バージョン制約(任意) +takt: + min_version: 0.22.0 +``` + +マニフェストはリポジトリルート(`takt-repertoire.yaml`)または `.takt/` 内(`.takt/takt-repertoire.yaml`)に配置できます。`.takt/` が優先的に検索されます。 + +| フィールド | 必須 | デフォルト | 説明 | +|-----------|------|-----------|------| +| `description` | いいえ | - | パッケージの説明 | +| `path` | いいえ | `.` | `facets/` と `pieces/` を含むディレクトリへのパス | +| `takt.min_version` | いいえ | - | 必要な TAKT の最低バージョン(X.Y.Z 形式) | + +## インストール + +```bash +takt repertoire add github:{owner}/{repo}@{ref} +``` + +`@{ref}` は省略可能です。省略した場合、リポジトリのデフォルトブランチが使用されます。 + +インストール前に、パッケージの内容サマリ(ファセット種別ごとの数、ピース名、edit 権限の警告)が表示され、確認を求められます。 + +### インストール時の処理 + +1. `gh api` 経由で GitHub から tarball をダウンロード +2. `facets/` と `pieces/` のファイルのみを展開(`.md`、`.yaml`、`.yml`) +3. `takt-repertoire.yaml` マニフェストをバリデーション +4. TAKT バージョン互換性チェック +5. `~/.takt/repertoire/@{owner}/{repo}/` にファイルをコピー +6. ロックファイル(`.takt-repertoire-lock.yaml`)を生成(ソース、ref、コミット SHA) + +インストールはアトミックに行われます。途中で失敗しても中途半端な状態は残りません。 + +### セキュリティ制約 + +- `.md`、`.yaml`、`.yml` ファイルのみコピー +- シンボリックリンクはスキップ +- 1 MB を超えるファイルはスキップ +- 500 ファイルを超えるパッケージは拒否 +- `path` フィールドのディレクトリトラバーサルを拒否 +- realpath による symlink ベースのトラバーサル検出 + +## パッケージの使い方 + +### ピース + +インストールされたピースはピース選択 UI の「repertoire」カテゴリにパッケージごとのサブカテゴリとして表示されます。直接指定も可能です。 + +```bash +takt --piece @nrslib/takt-fullstack/expert +``` + +### @scope 参照 + +インストール済みパッケージのファセットは、piece YAML で `@{owner}/{repo}/{facet-name}` 構文を使って参照できます。 + +```yaml +movements: + - name: implement + persona: @nrslib/takt-fullstack/expert-coder + policy: @nrslib/takt-fullstack/strict-review + knowledge: @nrslib/takt-fullstack/domain +``` + +### 4層ファセット解決 + +repertoire パッケージのピースが名前(@scope なし)でファセットを解決する場合、次の順序で検索されます。 + +1. **パッケージローカル**: `~/.takt/repertoire/@{owner}/{repo}/facets/{type}/` +2. **プロジェクト**: `.takt/facets/{type}/` +3. **ユーザー**: `~/.takt/facets/{type}/` +4. **ビルトイン**: `builtins/{lang}/facets/{type}/` + +パッケージのピースは自身のファセットを最優先で見つけつつ、ユーザーやプロジェクトによるオーバーライドも可能です。 + +## パッケージ管理 + +### 一覧表示 + +```bash +takt repertoire list +``` + +インストール済みパッケージのスコープ、説明、ref、コミット SHA を表示します。 + +### 削除 + +```bash +takt repertoire remove @{owner}/{repo} +``` + +削除前に、ユーザーやプロジェクトのピースがパッケージのファセットを参照していないかチェックし、影響がある場合は警告します。 + +## ディレクトリ構造 + +インストールされたパッケージは `~/.takt/repertoire/` に保存されます。 + +``` +~/.takt/repertoire/ + @nrslib/ + takt-fullstack/ + takt-repertoire.yaml # マニフェストのコピー + .takt-repertoire-lock.yaml # ロックファイル(ソース、ref、コミット) + facets/ + personas/ + policies/ + ... + pieces/ + expert.yaml +``` diff --git a/docs/repertoire.md b/docs/repertoire.md new file mode 100644 index 0000000..3d3e78c --- /dev/null +++ b/docs/repertoire.md @@ -0,0 +1,168 @@ +# Repertoire Packages + +[Japanese](./repertoire.ja.md) + +Repertoire packages let you install and share TAKT pieces and facets from GitHub repositories. + +## Quick Start + +```bash +# Install a package +takt repertoire add github:nrslib/takt-fullstack + +# Install a specific version +takt repertoire add github:nrslib/takt-fullstack@v1.0.0 + +# List installed packages +takt repertoire list + +# Remove a package +takt repertoire remove @nrslib/takt-fullstack +``` + +**Requirements:** [GitHub CLI](https://cli.github.com/) (`gh`) must be installed and authenticated. + +## Package Structure + +A TAKT package is a GitHub repository with a `takt-repertoire.yaml` manifest and content directories: + +``` +my-takt-repertoire/ + takt-repertoire.yaml # Package manifest (or .takt/takt-repertoire.yaml) + facets/ + personas/ + expert-coder.md + policies/ + strict-review.md + knowledge/ + domain.md + instructions/ + plan.md + pieces/ + expert.yaml +``` + +Only `facets/` and `pieces/` directories are imported. Other files are ignored. + +### takt-repertoire.yaml + +The manifest tells TAKT where to find the package content within the repository. + +```yaml +# Optional description +description: Full-stack development pieces with expert reviewers + +# Path to the package root (relative to repo root, default: ".") +path: . + +# Optional TAKT version constraint +takt: + min_version: 0.22.0 +``` + +The manifest can be placed at the repository root (`takt-repertoire.yaml`) or inside `.takt/` (`.takt/takt-repertoire.yaml`). The `.takt/` location is checked first. + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `description` | No | - | Package description | +| `path` | No | `.` | Path to the directory containing `facets/` and `pieces/` | +| `takt.min_version` | No | - | Minimum TAKT version required (X.Y.Z format) | + +## Installation + +```bash +takt repertoire add github:{owner}/{repo}@{ref} +``` + +The `@{ref}` is optional. Without it, the repository's default branch is used. + +Before installing, TAKT displays a summary of the package contents (facet counts by type, piece names, and edit permission warnings) and asks for confirmation. + +### What happens during install + +1. Downloads the tarball from GitHub via `gh api` +2. Extracts only `facets/` and `pieces/` files (`.md`, `.yaml`, `.yml`) +3. Validates the `takt-repertoire.yaml` manifest +4. Checks TAKT version compatibility +5. Copies files to `~/.takt/repertoire/@{owner}/{repo}/` +6. Generates a lock file (`.takt-repertoire-lock.yaml`) with source, ref, and commit SHA + +Installation is atomic — if it fails partway, no partial state is left behind. + +### Security constraints + +- Only `.md`, `.yaml`, `.yml` files are copied +- Symbolic links are skipped +- Files exceeding 1 MB are skipped +- Packages with more than 500 files are rejected +- Directory traversal in `path` field is rejected +- Symlink-based traversal is detected via realpath validation + +## Using Package Content + +### Pieces + +Installed pieces appear in the piece selection UI under the "repertoire" category, organized by package. You can also specify them directly: + +```bash +takt --piece @nrslib/takt-fullstack/expert +``` + +### @scope references + +Facets from installed packages can be referenced in piece YAML using `@{owner}/{repo}/{facet-name}` syntax: + +```yaml +movements: + - name: implement + persona: @nrslib/takt-fullstack/expert-coder + policy: @nrslib/takt-fullstack/strict-review + knowledge: @nrslib/takt-fullstack/domain +``` + +### 4-layer facet resolution + +When a piece from a repertoire package resolves facets by name (without @scope), the resolution order is: + +1. **Package-local**: `~/.takt/repertoire/@{owner}/{repo}/facets/{type}/` +2. **Project**: `.takt/facets/{type}/` +3. **User**: `~/.takt/facets/{type}/` +4. **Builtin**: `builtins/{lang}/facets/{type}/` + +This means package pieces automatically find their own facets first, while still allowing user/project overrides. + +## Managing Packages + +### List + +```bash +takt repertoire list +``` + +Shows installed packages with their scope, description, ref, and commit SHA. + +### Remove + +```bash +takt repertoire remove @{owner}/{repo} +``` + +Before removing, TAKT checks if any user/project pieces reference the package's facets and warns about potential breakage. + +## Directory Structure + +Installed packages are stored under `~/.takt/repertoire/`: + +``` +~/.takt/repertoire/ + @nrslib/ + takt-fullstack/ + takt-repertoire.yaml # Copy of the manifest + .takt-repertoire-lock.yaml # Lock file (source, ref, commit) + facets/ + personas/ + policies/ + ... + pieces/ + expert.yaml +``` diff --git a/e2e/specs/eject.e2e.ts b/e2e/specs/eject.e2e.ts index bbb1628..1295643 100644 --- a/e2e/specs/eject.e2e.ts +++ b/e2e/specs/eject.e2e.ts @@ -153,8 +153,8 @@ describe('E2E: Eject builtin pieces (takt eject)', () => { expect(result.exitCode).toBe(0); - // Persona should be copied to project .takt/personas/ - const personaPath = join(repo.path, '.takt', 'personas', 'coder.md'); + // Persona should be copied to project .takt/facets/personas/ + const personaPath = join(repo.path, '.takt', 'facets', 'personas', 'coder.md'); expect(existsSync(personaPath)).toBe(true); const content = readFileSync(personaPath, 'utf-8'); expect(content.length).toBeGreaterThan(0); @@ -170,11 +170,11 @@ describe('E2E: Eject builtin pieces (takt eject)', () => { expect(result.exitCode).toBe(0); // Persona should be copied to global dir - const personaPath = join(isolatedEnv.taktDir, 'personas', 'coder.md'); + const personaPath = join(isolatedEnv.taktDir, 'facets', 'personas', 'coder.md'); expect(existsSync(personaPath)).toBe(true); // Should NOT be in project dir - const projectPersonaPath = join(repo.path, '.takt', 'personas', 'coder.md'); + const projectPersonaPath = join(repo.path, '.takt', 'facets', 'personas', 'coder.md'); expect(existsSync(projectPersonaPath)).toBe(false); }); diff --git a/e2e/specs/piece-selection-branches.e2e.ts b/e2e/specs/piece-selection-branches.e2e.ts new file mode 100644 index 0000000..92c8576 --- /dev/null +++ b/e2e/specs/piece-selection-branches.e2e.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createIsolatedEnv, updateIsolatedConfig, type IsolatedEnv } from '../helpers/isolated-env'; +import { createTestRepo, type TestRepo } from '../helpers/test-repo'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function writeAgent(baseDir: string): void { + const agentsDir = join(baseDir, 'agents'); + mkdirSync(agentsDir, { recursive: true }); + writeFileSync( + join(agentsDir, 'test-coder.md'), + 'You are a test coder. Complete the task exactly and respond with Done.', + 'utf-8', + ); +} + +function writeMinimalPiece(piecePath: string): void { + const pieceDir = dirname(piecePath); + mkdirSync(pieceDir, { recursive: true }); + writeFileSync( + piecePath, + [ + 'name: e2e-branch-piece', + 'description: Piece for branch coverage E2E', + 'max_movements: 3', + 'movements:', + ' - name: execute', + ' edit: true', + ' persona: ../agents/test-coder.md', + ' allowed_tools:', + ' - Read', + ' - Write', + ' - Edit', + ' required_permission_mode: edit', + ' instruction_template: |', + ' {task}', + ' rules:', + ' - condition: Done', + ' next: COMPLETE', + '', + ].join('\n'), + 'utf-8', + ); +} + +function runTaskWithPiece(args: { + piece?: string; + cwd: string; + env: NodeJS.ProcessEnv; +}): ReturnType { + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + const baseArgs = ['--task', 'Create a file called noop.txt', '--create-worktree', 'no', '--provider', 'mock']; + const fullArgs = args.piece ? [...baseArgs, '--piece', args.piece] : baseArgs; + return runTakt({ + args: fullArgs, + cwd: args.cwd, + env: { + ...args.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); +} + +describe('E2E: Piece selection branch coverage', () => { + let isolatedEnv: IsolatedEnv; + let testRepo: TestRepo; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + testRepo = createTestRepo(); + + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + model: 'mock-model', + enable_builtin_pieces: false, + }); + }); + + afterEach(() => { + try { + testRepo.cleanup(); + } catch { + // best-effort + } + try { + isolatedEnv.cleanup(); + } catch { + // best-effort + } + }); + + it('should execute when --piece is a file path (isPiecePath branch)', () => { + const customPiecePath = join(testRepo.path, '.takt', 'pieces', 'path-piece.yaml'); + writeAgent(join(testRepo.path, '.takt')); + writeMinimalPiece(customPiecePath); + + const result = runTaskWithPiece({ + piece: customPiecePath, + cwd: testRepo.path, + env: isolatedEnv.env, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Piece completed'); + }, 240_000); + + it('should execute when --piece is a known local name (resolver hit branch)', () => { + writeAgent(join(testRepo.path, '.takt')); + writeMinimalPiece(join(testRepo.path, '.takt', 'pieces', 'local-piece.yaml')); + + const result = runTaskWithPiece({ + piece: 'local-piece', + cwd: testRepo.path, + env: isolatedEnv.env, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Piece completed'); + }, 240_000); + + it('should execute when --piece is a repertoire @scope name (resolver hit branch)', () => { + const pkgRoot = join(isolatedEnv.taktDir, 'repertoire', '@nrslib', 'takt-ensembles'); + writeAgent(pkgRoot); + writeMinimalPiece(join(pkgRoot, 'pieces', 'critical-thinking.yaml')); + + const result = runTaskWithPiece({ + piece: '@nrslib/takt-ensembles/critical-thinking', + cwd: testRepo.path, + env: isolatedEnv.env, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Piece completed'); + expect(result.stdout).not.toContain('Piece not found'); + }, 240_000); + + it('should fail fast with message when --piece is unknown (resolver miss branch)', () => { + const result = runTaskWithPiece({ + piece: '@nrslib/takt-ensembles/not-found', + cwd: testRepo.path, + env: isolatedEnv.env, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Piece not found: @nrslib/takt-ensembles/not-found'); + expect(result.stdout).toContain('Cancelled'); + }, 240_000); + + it('should execute when --piece is omitted (selectPiece branch)', () => { + writeAgent(join(testRepo.path, '.takt')); + writeMinimalPiece(join(testRepo.path, '.takt', 'pieces', 'default.yaml')); + + const result = runTaskWithPiece({ + cwd: testRepo.path, + env: isolatedEnv.env, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Piece completed'); + }, 240_000); +}); diff --git a/e2e/specs/repertoire-real.e2e.ts b/e2e/specs/repertoire-real.e2e.ts new file mode 100644 index 0000000..7f46fc0 --- /dev/null +++ b/e2e/specs/repertoire-real.e2e.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +type LockFile = { + source?: string; + ref?: string; + commit?: string; + imported_at?: string; +}; + +function canAccessRepo(repo: string): boolean { + try { + execFileSync('gh', ['repo', 'view', repo], { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +function canAccessRepoRef(repo: string, ref: string): boolean { + try { + const out = execFileSync('gh', ['api', `/repos/${repo}/git/ref/tags/${ref}`], { + encoding: 'utf-8', + stdio: 'pipe', + }); + return out.includes('"ref"'); + } catch { + return false; + } +} + +function readYamlFile(path: string): T { + const raw = readFileSync(path, 'utf-8'); + return parseYaml(raw) as T; +} + +const FIXTURE_REPO = 'nrslib/takt-ensemble-fixture'; +const FIXTURE_REPO_SUBDIR = 'nrslib/takt-ensemble-fixture-subdir'; +const FIXTURE_REPO_FACETS_ONLY = 'nrslib/takt-ensemble-fixture-facets-only'; +const MISSING_MANIFEST_REPO = 'nrslib/takt'; +const FIXTURE_REF = 'v1.0.0'; + +const canUseFixtureRepo = canAccessRepo(FIXTURE_REPO) && canAccessRepoRef(FIXTURE_REPO, FIXTURE_REF); +const canUseSubdirRepo = canAccessRepo(FIXTURE_REPO_SUBDIR) && canAccessRepoRef(FIXTURE_REPO_SUBDIR, FIXTURE_REF); +const canUseFacetsOnlyRepo = canAccessRepo(FIXTURE_REPO_FACETS_ONLY) && canAccessRepoRef(FIXTURE_REPO_FACETS_ONLY, FIXTURE_REF); +const canUseMissingManifestRepo = canAccessRepo(MISSING_MANIFEST_REPO); + +describe('E2E: takt repertoire (real GitHub fixtures)', () => { + let isolatedEnv: IsolatedEnv; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + }); + + afterEach(() => { + try { + isolatedEnv.cleanup(); + } catch { + // best-effort + } + }); + + it.skipIf(!canUseFixtureRepo)('should install fixture package from GitHub and create lock file', () => { + const result = runTakt({ + args: ['repertoire', 'add', `github:${FIXTURE_REPO}@${FIXTURE_REF}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\n', + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(`📦 ${FIXTURE_REPO} @${FIXTURE_REF}`); + expect(result.stdout).toContain('インストールしました'); + + const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@nrslib', 'takt-ensemble-fixture'); + expect(existsSync(join(packageDir, 'takt-repertoire.yaml'))).toBe(true); + expect(existsSync(join(packageDir, '.takt-repertoire-lock.yaml'))).toBe(true); + expect(existsSync(join(packageDir, 'facets'))).toBe(true); + expect(existsSync(join(packageDir, 'pieces'))).toBe(true); + + const lock = readYamlFile(join(packageDir, '.takt-repertoire-lock.yaml')); + expect(lock.source).toBe('github:nrslib/takt-ensemble-fixture'); + expect(lock.ref).toBe(FIXTURE_REF); + expect(lock.commit).toBeTypeOf('string'); + expect(lock.commit!.length).toBeGreaterThanOrEqual(7); + expect(lock.imported_at).toBeTypeOf('string'); + }, 240_000); + + it.skipIf(!canUseFixtureRepo)('should list installed package after add', () => { + const addResult = runTakt({ + args: ['repertoire', 'add', `github:${FIXTURE_REPO}@${FIXTURE_REF}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\n', + timeout: 240_000, + }); + expect(addResult.exitCode).toBe(0); + + const listResult = runTakt({ + args: ['repertoire', 'list'], + cwd: process.cwd(), + env: isolatedEnv.env, + timeout: 120_000, + }); + + expect(listResult.exitCode).toBe(0); + expect(listResult.stdout).toContain('@nrslib/takt-ensemble-fixture'); + }, 240_000); + + it.skipIf(!canUseFixtureRepo)('should remove installed package with confirmation', () => { + const addResult = runTakt({ + args: ['repertoire', 'add', `github:${FIXTURE_REPO}@${FIXTURE_REF}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\n', + timeout: 240_000, + }); + expect(addResult.exitCode).toBe(0); + + const removeResult = runTakt({ + args: ['repertoire', 'remove', '@nrslib/takt-ensemble-fixture'], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\n', + timeout: 120_000, + }); + expect(removeResult.exitCode).toBe(0); + + const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@nrslib', 'takt-ensemble-fixture'); + expect(existsSync(packageDir)).toBe(false); + }, 240_000); + + it.skipIf(!canUseFixtureRepo)('should cancel installation when user answers N', () => { + const result = runTakt({ + args: ['repertoire', 'add', `github:${FIXTURE_REPO}@${FIXTURE_REF}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'n\n', + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('キャンセルしました'); + + const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@nrslib', 'takt-ensemble-fixture'); + expect(existsSync(packageDir)).toBe(false); + }, 240_000); + + it.skipIf(!canUseSubdirRepo)('should install subdir fixture package', () => { + const result = runTakt({ + args: ['repertoire', 'add', `github:${FIXTURE_REPO_SUBDIR}@${FIXTURE_REF}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\n', + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@nrslib', 'takt-ensemble-fixture-subdir'); + expect(existsSync(join(packageDir, 'takt-repertoire.yaml'))).toBe(true); + expect(existsSync(join(packageDir, '.takt-repertoire-lock.yaml'))).toBe(true); + expect(existsSync(join(packageDir, 'facets')) || existsSync(join(packageDir, 'pieces'))).toBe(true); + }, 240_000); + + it.skipIf(!canUseFacetsOnlyRepo)('should install facets-only fixture package without requiring pieces directory', () => { + const result = runTakt({ + args: ['repertoire', 'add', `github:${FIXTURE_REPO_FACETS_ONLY}@${FIXTURE_REF}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\n', + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@nrslib', 'takt-ensemble-fixture-facets-only'); + expect(existsSync(join(packageDir, 'facets'))).toBe(true); + expect(existsSync(join(packageDir, 'pieces'))).toBe(false); + }, 240_000); + + it.skipIf(!canUseMissingManifestRepo)('should fail when repository has no takt-repertoire.yaml', () => { + const result = runTakt({ + args: ['repertoire', 'add', `github:${MISSING_MANIFEST_REPO}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\n', + timeout: 240_000, + }); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout).toContain('takt-repertoire.yaml not found'); + }, 240_000); +}); diff --git a/e2e/specs/repertoire.e2e.ts b/e2e/specs/repertoire.e2e.ts new file mode 100644 index 0000000..aac3748 --- /dev/null +++ b/e2e/specs/repertoire.e2e.ts @@ -0,0 +1,221 @@ +/** + * E2E tests for `takt repertoire` subcommands. + * + * All tests are marked as `it.todo()` because the `takt repertoire` command + * is not yet implemented. These serve as the specification skeleton; + * fill in the callbacks when the implementation lands. + * + * GitHub fixture repos used: + * - github:nrslib/takt-ensemble-fixture (standard: facets/ + pieces/) + * - github:nrslib/takt-ensemble-fixture-subdir (path field specified) + * - github:nrslib/takt-ensemble-fixture-facets-only (facets only, no pieces/) + * + */ + +import { describe, it } from 'vitest'; + +// --------------------------------------------------------------------------- +// E2E: takt repertoire add — 正常系 +// --------------------------------------------------------------------------- + +describe('E2E: takt repertoire add (正常系)', () => { + // E1: 標準パッケージのインポート + // Given: 空の isolatedEnv + // When: takt repertoire add github:nrslib/takt-ensemble-fixture@v1.0.0、y 入力 + // Then: {taktDir}/repertoire/@nrslib/takt-ensemble-fixture/ に takt-repertoire.yaml, + // .takt-repertoire-lock.yaml, facets/, pieces/ が存在する + it.todo('should install standard package and verify directory structure'); + + // E2: lock ファイルのフィールド確認 + // Given: E1 完了後 + // When: .takt-repertoire-lock.yaml を読む + // Then: source, ref, commit, imported_at フィールドがすべて存在する + it.todo('should generate .takt-repertoire-lock.yaml with source, ref, commit, imported_at'); + + // E3: サブディレクトリ型パッケージのインポート + // Given: 空の isolatedEnv + // When: takt repertoire add github:nrslib/takt-ensemble-fixture-subdir@v1.0.0、y 入力 + // Then: path フィールドで指定されたサブディレクトリ配下のファイルのみコピーされる + it.todo('should install subdir-type package and copy only path-specified files'); + + // E4: ファセットのみパッケージのインポート + // Given: 空の isolatedEnv + // When: takt repertoire add github:nrslib/takt-ensemble-fixture-facets-only@v1.0.0、y 入力 + // Then: facets/ は存在し、pieces/ ディレクトリは存在しない + it.todo('should install facets-only package without creating pieces/ directory'); + + // E4b: コミットSHA指定 + // Given: 空の isolatedEnv + // When: takt repertoire add github:nrslib/takt-ensemble-fixture@{sha}、y 入力 + // Then: .takt-repertoire-lock.yaml の commit フィールドが指定した SHA と一致する + it.todo('should populate lock file commit field with the specified commit SHA when installing by SHA'); + + // E5: インストール前サマリー表示 + // Given: 空の isolatedEnv + // When: takt repertoire add github:nrslib/takt-ensemble-fixture@v1.0.0、N 入力(確認でキャンセル) + // Then: stdout に "📦 nrslib/takt-ensemble-fixture", "faceted:", "pieces:" が含まれる + it.todo('should display pre-install summary with package name, faceted count, and pieces list'); + + // E6: 権限警告表示(edit: true ピース) + // Given: edit: true を含むパッケージ + // When: repertoire add、N 入力 + // Then: stdout に ⚠ が含まれる + it.todo('should display warning symbol when package contains piece with edit: true'); + + // E7: ユーザー確認 N で中断 + // Given: 空の isolatedEnv + // When: repertoire add、N 入力 + // Then: インストールディレクトリが存在しない。exit code 0 + it.todo('should abort installation when user answers N to confirmation prompt'); +}); + +// --------------------------------------------------------------------------- +// E2E: takt repertoire add — 上書きシナリオ +// --------------------------------------------------------------------------- + +describe('E2E: takt repertoire add (上書きシナリオ)', () => { + // E8: 既存パッケージの上書き警告表示 + // Given: 1回目インストール済み + // When: 2回目 repertoire add + // Then: stdout に "⚠ パッケージ @nrslib/takt-ensemble-fixture は既にインストールされています" が含まれる + it.todo('should display already-installed warning on second add'); + + // E9: 上書き y で原子的更新 + // Given: E8後、y 入力 + // When: インストール完了後 + // Then: .tmp/, .bak/ が残っていない。新 lock ファイルが配置済み + it.todo('should atomically update package when user answers y to overwrite prompt'); + + // E10: 上書き N でキャンセル + // Given: E8後、N 入力 + // When: コマンド終了後 + // Then: 既存パッケージが維持される(元 lock ファイルが変わらない) + it.todo('should keep existing package when user answers N to overwrite prompt'); + + // E11: 前回異常終了残留物(.tmp/)クリーンアップ + // Given: {repertoireDir}/@nrslib/takt-ensemble-fixture.tmp/ が既に存在する状態 + // When: repertoire add、y 入力 + // Then: インストールが正常完了する。exit code 0 + it.todo('should clean up leftover .tmp/ directory from previous failed installation'); + + // E12: 前回異常終了残留物(.bak/)クリーンアップ + // Given: {repertoireDir}/@nrslib/takt-ensemble-fixture.bak/ が既に存在する状態 + // When: repertoire add、y 入力 + // Then: インストールが正常完了する。exit code 0 + it.todo('should clean up leftover .bak/ directory from previous failed installation'); +}); + +// --------------------------------------------------------------------------- +// E2E: takt repertoire add — バリデーション・エラー系 +// --------------------------------------------------------------------------- + +describe('E2E: takt repertoire add (バリデーション・エラー系)', () => { + // E13: takt-repertoire.yaml 不在リポジトリ + // Given: takt-repertoire.yaml のないリポジトリを指定 + // When: repertoire add + // Then: exit code 非0。エラーメッセージ表示 + it.todo('should fail with error when repository has no takt-repertoire.yaml'); + + // E14: path に絶対パス(/foo) + // Given: path: /foo の takt-repertoire.yaml + // When: repertoire add + // Then: exit code 非0 + it.todo('should reject takt-repertoire.yaml with absolute path in path field (/foo)'); + + // E15: path に .. によるリポジトリ外参照 + // Given: path: ../outside の takt-repertoire.yaml + // When: repertoire add + // Then: exit code 非0 + it.todo('should reject takt-repertoire.yaml with path traversal via ".." segments'); + + // E16: 空パッケージ(facets/ も pieces/ もない) + // Given: facets/, pieces/ のどちらもない takt-repertoire.yaml + // When: repertoire add + // Then: exit code 非0 + it.todo('should reject package with neither facets/ nor pieces/ directory'); + + // E17: min_version 不正形式(1.0、セグメント不足) + // Given: takt.min_version: "1.0" + // When: repertoire add + // Then: exit code 非0 + it.todo('should reject takt-repertoire.yaml with min_version "1.0" (missing patch segment)'); + + // E18: min_version 不正形式(v1.0.0、v プレフィックス) + // Given: takt.min_version: "v1.0.0" + // When: repertoire add + // Then: exit code 非0 + it.todo('should reject takt-repertoire.yaml with min_version "v1.0.0" (v prefix)'); + + // E19: min_version 不正形式(1.0.0-alpha、pre-release) + // Given: takt.min_version: "1.0.0-alpha" + // When: repertoire add + // Then: exit code 非0 + it.todo('should reject takt-repertoire.yaml with min_version "1.0.0-alpha" (pre-release suffix)'); + + // E20: min_version が現在の TAKT より新しい + // Given: takt.min_version: "999.0.0" + // When: repertoire add + // Then: exit code 非0。必要バージョンと現在バージョンが表示される + it.todo('should fail with version mismatch message when min_version exceeds current takt version'); +}); + +// --------------------------------------------------------------------------- +// E2E: takt repertoire remove +// --------------------------------------------------------------------------- + +describe('E2E: takt repertoire remove', () => { + // E21: 正常削除 y + // Given: パッケージインストール済み + // When: takt repertoire remove @nrslib/takt-ensemble-fixture、y 入力 + // Then: ディレクトリが削除される。@nrslib/ 配下が空なら @nrslib/ も削除 + it.todo('should remove installed package directory when user answers y'); + + // E22: owner dir 残存(他パッケージがある場合) + // Given: @nrslib 配下に別パッケージもインストール済み + // When: remove、y 入力 + // Then: 対象パッケージのみ削除。@nrslib/ は残る + it.todo('should keep @scope directory when other packages remain under same owner'); + + // E23: 参照ありでの警告付き削除 + // Given: ~/.takt/pieces/ に @scope 参照するファイルあり + // When: remove、y 入力 + // Then: 警告("⚠ 次のファイルが...を参照しています")が表示され、削除は実行される + it.todo('should display reference warning before deletion but still proceed when user answers y'); + + // E24: 参照ファイル自体は変更されない + // Given: E23後 + // When: 参照ファイルを読む + // Then: 元の @scope 参照がそのまま残っている + it.todo('should not modify reference files during removal'); + + // E25: 削除キャンセル N + // Given: パッケージインストール済み + // When: remove、N 入力 + // Then: ディレクトリが残る。exit code 0 + it.todo('should keep package directory when user answers N to removal prompt'); +}); + +// --------------------------------------------------------------------------- +// E2E: takt repertoire list +// --------------------------------------------------------------------------- + +describe('E2E: takt repertoire list', () => { + // E26: インストール済みパッケージ一覧表示 + // Given: パッケージ1件インストール済み + // When: takt repertoire list + // Then: "📦 インストール済みパッケージ:" と @nrslib/takt-ensemble-fixture、 + // description、ref、commit 先頭7文字が表示される + it.todo('should list installed packages with name, description, ref, and abbreviated commit'); + + // E27: 空状態での表示 + // Given: repertoire/ が空(パッケージなし) + // When: takt repertoire list + // Then: パッケージなし相当のメッセージ。exit code 0 + it.todo('should display empty-state message when no packages are installed'); + + // E28: 複数パッケージの一覧 + // Given: 2件以上インストール済み + // When: takt repertoire list + // Then: すべてのパッケージが表示される + it.todo('should list all installed packages when multiple packages exist'); +}); diff --git a/package-lock.json b/package-lock.json index 3cb1738..dabdf35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "takt", - "version": "0.21.0", + "version": "0.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "takt", - "version": "0.21.0", + "version": "0.22.0", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.47", diff --git a/package.json b/package.json index 7e83176..b0cf1f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "takt", - "version": "0.21.0", + "version": "0.22.0", "description": "TAKT: TAKT Agent Koordination Topology - AI Agent Piece Orchestration", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/__tests__/catalog.test.ts b/src/__tests__/catalog.test.ts index cb41c50..a9ea46a 100644 --- a/src/__tests__/catalog.test.ts +++ b/src/__tests__/catalog.test.ts @@ -37,6 +37,9 @@ let mockGlobalDir: string; vi.mock('../infra/config/paths.js', () => ({ getGlobalConfigDir: () => mockGlobalDir, getProjectConfigDir: (cwd: string) => join(cwd, '.takt'), + getGlobalFacetDir: (facetType: string) => join(mockGlobalDir, 'facets', facetType), + getProjectFacetDir: (cwd: string, facetType: string) => join(cwd, '.takt', 'facets', facetType), + getBuiltinFacetDir: (_lang: string, facetType: string) => join(mockBuiltinDir, 'facets', facetType), })); describe('parseFacetType', () => { @@ -131,9 +134,9 @@ describe('scanFacets', () => { it('should collect facets from all three layers', () => { // Given: facets in builtin, user, and project layers - const builtinPersonas = join(builtinDir, 'personas'); - const globalPersonas = join(globalDir, 'personas'); - const projectPersonas = join(projectDir, '.takt', 'personas'); + const builtinPersonas = join(builtinDir, 'facets', 'personas'); + const globalPersonas = join(globalDir, 'facets', 'personas'); + const projectPersonas = join(projectDir, '.takt', 'facets', 'personas'); mkdirSync(builtinPersonas, { recursive: true }); mkdirSync(globalPersonas, { recursive: true }); mkdirSync(projectPersonas, { recursive: true }); @@ -164,8 +167,8 @@ describe('scanFacets', () => { it('should detect override when higher layer has same name', () => { // Given: same facet name in builtin and user layers - const builtinPersonas = join(builtinDir, 'personas'); - const globalPersonas = join(globalDir, 'personas'); + const builtinPersonas = join(builtinDir, 'facets', 'personas'); + const globalPersonas = join(globalDir, 'facets', 'personas'); mkdirSync(builtinPersonas, { recursive: true }); mkdirSync(globalPersonas, { recursive: true }); @@ -187,8 +190,8 @@ describe('scanFacets', () => { it('should detect override through project layer', () => { // Given: same facet name in builtin and project layers - const builtinPolicies = join(builtinDir, 'policies'); - const projectPolicies = join(projectDir, '.takt', 'policies'); + const builtinPolicies = join(builtinDir, 'facets', 'policies'); + const projectPolicies = join(projectDir, '.takt', 'facets', 'policies'); mkdirSync(builtinPolicies, { recursive: true }); mkdirSync(projectPolicies, { recursive: true }); @@ -215,7 +218,7 @@ describe('scanFacets', () => { it('should only include .md files', () => { // Given: directory with mixed file types - const builtinKnowledge = join(builtinDir, 'knowledge'); + const builtinKnowledge = join(builtinDir, 'facets', 'knowledge'); mkdirSync(builtinKnowledge, { recursive: true }); writeFileSync(join(builtinKnowledge, 'valid.md'), '# Valid'); @@ -234,7 +237,7 @@ describe('scanFacets', () => { // Given: one facet in each type directory const types = ['personas', 'policies', 'knowledge', 'instructions', 'output-contracts'] as const; for (const type of types) { - const dir = join(builtinDir, type); + const dir = join(builtinDir, 'facets', type); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'test.md'), `# Test ${type}`); } @@ -328,7 +331,7 @@ describe('showCatalog', () => { it('should display only the specified facet type when valid type is given', () => { // Given: personas facet exists - const builtinPersonas = join(builtinDir, 'personas'); + const builtinPersonas = join(builtinDir, 'facets', 'personas'); mkdirSync(builtinPersonas, { recursive: true }); writeFileSync(join(builtinPersonas, 'coder.md'), '# Coder Agent'); diff --git a/src/__tests__/deploySkill.test.ts b/src/__tests__/deploySkill.test.ts index 24b34db..2b7354a 100644 --- a/src/__tests__/deploySkill.test.ts +++ b/src/__tests__/deploySkill.test.ts @@ -74,20 +74,20 @@ describe('deploySkill', () => { // Create language-specific directories (en/) const langDir = join(fakeResourcesDir, 'en'); mkdirSync(join(langDir, 'pieces'), { recursive: true }); - mkdirSync(join(langDir, 'personas'), { recursive: true }); - mkdirSync(join(langDir, 'policies'), { recursive: true }); - mkdirSync(join(langDir, 'instructions'), { recursive: true }); - mkdirSync(join(langDir, 'knowledge'), { recursive: true }); - mkdirSync(join(langDir, 'output-contracts'), { recursive: true }); + mkdirSync(join(langDir, 'facets', 'personas'), { recursive: true }); + mkdirSync(join(langDir, 'facets', 'policies'), { recursive: true }); + mkdirSync(join(langDir, 'facets', 'instructions'), { recursive: true }); + mkdirSync(join(langDir, 'facets', 'knowledge'), { recursive: true }); + mkdirSync(join(langDir, 'facets', 'output-contracts'), { recursive: true }); mkdirSync(join(langDir, 'templates'), { recursive: true }); // Add sample files writeFileSync(join(langDir, 'pieces', 'default.yaml'), 'name: default'); - writeFileSync(join(langDir, 'personas', 'coder.md'), '# Coder'); - writeFileSync(join(langDir, 'policies', 'coding.md'), '# Coding'); - writeFileSync(join(langDir, 'instructions', 'init.md'), '# Init'); - writeFileSync(join(langDir, 'knowledge', 'patterns.md'), '# Patterns'); - writeFileSync(join(langDir, 'output-contracts', 'summary.md'), '# Summary'); + writeFileSync(join(langDir, 'facets', 'personas', 'coder.md'), '# Coder'); + writeFileSync(join(langDir, 'facets', 'policies', 'coding.md'), '# Coding'); + writeFileSync(join(langDir, 'facets', 'instructions', 'init.md'), '# Init'); + writeFileSync(join(langDir, 'facets', 'knowledge', 'patterns.md'), '# Patterns'); + writeFileSync(join(langDir, 'facets', 'output-contracts', 'summary.md'), '# Summary'); writeFileSync(join(langDir, 'templates', 'task.md'), '# Task'); // Create target directories diff --git a/src/__tests__/facet-resolution.test.ts b/src/__tests__/facet-resolution.test.ts index 4a1e1d1..6e2acbe 100644 --- a/src/__tests__/facet-resolution.test.ts +++ b/src/__tests__/facet-resolution.test.ts @@ -84,7 +84,7 @@ describe('resolveFacetByName', () => { }); it('should resolve from project layer over builtin', () => { - const projectPersonasDir = join(projectDir, '.takt', 'personas'); + const projectPersonasDir = join(projectDir, '.takt', 'facets', 'personas'); mkdirSync(projectPersonasDir, { recursive: true }); writeFileSync(join(projectPersonasDir, 'coder.md'), 'Project-level coder persona'); @@ -98,7 +98,7 @@ describe('resolveFacetByName', () => { }); it('should resolve different facet types', () => { - const projectPoliciesDir = join(projectDir, '.takt', 'policies'); + const projectPoliciesDir = join(projectDir, '.takt', 'facets', 'policies'); mkdirSync(projectPoliciesDir, { recursive: true }); writeFileSync(join(projectPoliciesDir, 'custom-policy.md'), 'Custom policy content'); @@ -108,7 +108,7 @@ describe('resolveFacetByName', () => { it('should try project before builtin', () => { // Create project override - const projectPersonasDir = join(projectDir, '.takt', 'personas'); + const projectPersonasDir = join(projectDir, '.takt', 'facets', 'personas'); mkdirSync(projectPersonasDir, { recursive: true }); writeFileSync(join(projectPersonasDir, 'coder.md'), 'OVERRIDE'); @@ -137,7 +137,7 @@ describe('resolveRefToContent with layer resolution', () => { }); it('should use layer resolution for name refs when not in resolvedMap', () => { - const policiesDir = join(tempDir, '.takt', 'policies'); + const policiesDir = join(tempDir, '.takt', 'facets', 'policies'); mkdirSync(policiesDir, { recursive: true }); writeFileSync(join(policiesDir, 'coding.md'), 'Project coding policy'); @@ -189,7 +189,7 @@ describe('resolveRefList with layer resolution', () => { }); it('should resolve array of name refs via layer resolution', () => { - const policiesDir = join(tempDir, '.takt', 'policies'); + 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'); @@ -206,7 +206,7 @@ describe('resolveRefList with layer resolution', () => { }); it('should handle mixed array of name refs and path refs', () => { - const policiesDir = join(tempDir, '.takt', 'policies'); + const policiesDir = join(tempDir, '.takt', 'facets', 'policies'); mkdirSync(policiesDir, { recursive: true }); writeFileSync(join(policiesDir, 'name-policy.md'), 'Name-resolved policy'); @@ -230,7 +230,7 @@ describe('resolveRefList with layer resolution', () => { }); it('should handle single string ref (not array)', () => { - const policiesDir = join(tempDir, '.takt', 'policies'); + const policiesDir = join(tempDir, '.takt', 'facets', 'policies'); mkdirSync(policiesDir, { recursive: true }); writeFileSync(join(policiesDir, 'single.md'), 'Single policy'); @@ -284,7 +284,7 @@ describe('resolvePersona with layer resolution', () => { }); it('should resolve persona from project layer', () => { - const projectPersonasDir = join(projectDir, '.takt', 'personas'); + const projectPersonasDir = join(projectDir, '.takt', 'facets', 'personas'); mkdirSync(projectPersonasDir, { recursive: true }); const personaPath = join(projectPersonasDir, 'custom-persona.md'); writeFileSync(personaPath, 'Custom persona content'); @@ -416,7 +416,7 @@ describe('normalizePieceConfig with layer resolution', () => { it('should resolve policy by name when section map is absent', () => { // Create project-level policy - const policiesDir = join(projectDir, '.takt', 'policies'); + const policiesDir = join(projectDir, '.takt', 'facets', 'policies'); mkdirSync(policiesDir, { recursive: true }); writeFileSync(join(policiesDir, 'custom-policy.md'), '# Custom Policy\nBe nice.'); @@ -486,7 +486,7 @@ describe('normalizePieceConfig with layer resolution', () => { }); it('should resolve knowledge by name from project layer', () => { - const knowledgeDir = join(projectDir, '.takt', 'knowledge'); + const knowledgeDir = join(projectDir, '.takt', 'facets', 'knowledge'); mkdirSync(knowledgeDir, { recursive: true }); writeFileSync(join(knowledgeDir, 'domain-kb.md'), '# Domain Knowledge'); @@ -532,7 +532,7 @@ describe('normalizePieceConfig with layer resolution', () => { }); it('should resolve instruction_template by name via layer resolution', () => { - const instructionsDir = join(projectDir, '.takt', 'instructions'); + const instructionsDir = join(projectDir, '.takt', 'facets', 'instructions'); mkdirSync(instructionsDir, { recursive: true }); writeFileSync(join(instructionsDir, 'implement.md'), 'Project implement template'); @@ -576,7 +576,7 @@ Second line remains inline.`; }); it('should resolve loop monitor judge instruction_template via layer resolution', () => { - const instructionsDir = join(projectDir, '.takt', 'instructions'); + const instructionsDir = join(projectDir, '.takt', 'facets', 'instructions'); mkdirSync(instructionsDir, { recursive: true }); writeFileSync(join(instructionsDir, 'judge-template.md'), 'Project judge template'); diff --git a/src/__tests__/faceted-prompting/scope-ref.test.ts b/src/__tests__/faceted-prompting/scope-ref.test.ts new file mode 100644 index 0000000..4d39088 --- /dev/null +++ b/src/__tests__/faceted-prompting/scope-ref.test.ts @@ -0,0 +1,283 @@ +/** + * Tests for @scope reference resolution. + * + * Covers: + * - isScopeRef(): detects @{owner}/{repo}/{facet-name} format + * - parseScopeRef(): parses components from scope reference + * - resolveScopeRef(): resolves to ~/.takt/repertoire/@{owner}/{repo}/facets/{facet-type}/{facet-name}.md + * - facet-type mapping from field context (persona→personas, policy→policies, etc.) + * - Name constraint validation (owner, repo, facet-name patterns) + * - Case normalization (uppercase → lowercase) + */ + +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, + type ScopeRef, +} from '../../faceted-prompting/scope.js'; + +// --------------------------------------------------------------------------- +// isScopeRef +// --------------------------------------------------------------------------- + +describe('isScopeRef', () => { + it('should return true for @owner/repo/facet-name format', () => { + // Given: a valid scope reference + expect(isScopeRef('@nrslib/takt-fullstack/expert-coder')).toBe(true); + }); + + it('should return true for scope with short names', () => { + expect(isScopeRef('@a/b/c')).toBe(true); + }); + + it('should return false for plain facet name (no @ prefix)', () => { + expect(isScopeRef('expert-coder')).toBe(false); + }); + + it('should return false for regular resource path', () => { + expect(isScopeRef('./personas/coder.md')).toBe(false); + }); + + it('should return false for @ with only owner/repo (missing facet name)', () => { + expect(isScopeRef('@nrslib/takt-fullstack')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isScopeRef('')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// parseScopeRef +// --------------------------------------------------------------------------- + +describe('parseScopeRef', () => { + it('should parse @owner/repo/facet-name into components', () => { + // Given: a valid scope reference + const ref = '@nrslib/takt-fullstack/expert-coder'; + + // When: parsed + const parsed = parseScopeRef(ref); + + // Then: components are extracted correctly + expect(parsed.owner).toBe('nrslib'); + expect(parsed.repo).toBe('takt-fullstack'); + expect(parsed.name).toBe('expert-coder'); + }); + + it('should normalize uppercase owner to lowercase', () => { + // Given: owner with uppercase letters + const ref = '@NrsLib/takt-fullstack/coder'; + + // When: parsed + const parsed = parseScopeRef(ref); + + // Then: owner is normalized to lowercase + expect(parsed.owner).toBe('nrslib'); + }); + + it('should normalize uppercase repo to lowercase', () => { + // Given: repo with uppercase letters + const ref = '@nrslib/Takt-Fullstack/coder'; + + // When: parsed + const parsed = parseScopeRef(ref); + + // Then: repo is normalized to lowercase + expect(parsed.repo).toBe('takt-fullstack'); + }); + + it('should handle repo name with dots and underscores', () => { + // Given: GitHub repo names allow dots and underscores + const ref = '@acme/takt.backend_v2/expert'; + + // When: parsed + const parsed = parseScopeRef(ref); + + // Then: dots and underscores are preserved + expect(parsed.repo).toBe('takt.backend_v2'); + expect(parsed.name).toBe('expert'); + }); + + it('should preserve facet name exactly', () => { + // Given: facet name with hyphens + const ref = '@nrslib/security-facets/security-reviewer'; + + // When: parsed + const parsed = parseScopeRef(ref); + + // Then: facet name is preserved + expect(parsed.name).toBe('security-reviewer'); + }); +}); + +// --------------------------------------------------------------------------- +// resolveScopeRef +// --------------------------------------------------------------------------- + +describe('resolveScopeRef', () => { + let tempRepertoireDir: string; + + beforeEach(() => { + tempRepertoireDir = mkdtempSync(join(tmpdir(), 'takt-repertoire-')); + }); + + afterEach(() => { + rmSync(tempRepertoireDir, { recursive: true, force: true }); + }); + + it('should resolve persona scope ref to facets/personas/{name}.md', () => { + // Given: repertoire directory with the package's persona file + const facetDir = join(tempRepertoireDir, '@nrslib', 'takt-fullstack', 'facets', 'personas'); + mkdirSync(facetDir, { recursive: true }); + writeFileSync(join(facetDir, 'expert-coder.md'), 'Expert coder persona'); + + const scopeRef: ScopeRef = { owner: 'nrslib', repo: 'takt-fullstack', name: 'expert-coder' }; + + // When: scope ref is resolved with facetType 'personas' + const result = resolveScopeRef(scopeRef, 'personas', tempRepertoireDir); + + // Then: resolved to the correct file path + expect(result).toBe(join(tempRepertoireDir, '@nrslib', 'takt-fullstack', 'facets', 'personas', 'expert-coder.md')); + }); + + it('should resolve policy scope ref to facets/policies/{name}.md', () => { + // Given: repertoire directory with policy file + const facetDir = join(tempRepertoireDir, '@nrslib', 'takt-fullstack', 'facets', 'policies'); + mkdirSync(facetDir, { recursive: true }); + writeFileSync(join(facetDir, 'owasp-checklist.md'), 'OWASP content'); + + const scopeRef: ScopeRef = { owner: 'nrslib', repo: 'takt-fullstack', name: 'owasp-checklist' }; + + // When: scope ref is resolved with facetType 'policies' + const result = resolveScopeRef(scopeRef, 'policies', tempRepertoireDir); + + // Then: resolved to correct path + expect(result).toBe(join(tempRepertoireDir, '@nrslib', 'takt-fullstack', 'facets', 'policies', 'owasp-checklist.md')); + }); + + it('should resolve knowledge scope ref to facets/knowledge/{name}.md', () => { + // Given: repertoire directory with knowledge file + const facetDir = join(tempRepertoireDir, '@nrslib', 'takt-security-facets', 'facets', 'knowledge'); + mkdirSync(facetDir, { recursive: true }); + writeFileSync(join(facetDir, 'vulnerability-patterns.md'), 'Vuln patterns'); + + const scopeRef: ScopeRef = { owner: 'nrslib', repo: 'takt-security-facets', name: 'vulnerability-patterns' }; + + // When: scope ref is resolved with facetType 'knowledge' + const result = resolveScopeRef(scopeRef, 'knowledge', tempRepertoireDir); + + // Then: resolved to correct path + expect(result).toBe(join(tempRepertoireDir, '@nrslib', 'takt-security-facets', 'facets', 'knowledge', 'vulnerability-patterns.md')); + }); + + it('should resolve instructions scope ref to facets/instructions/{name}.md', () => { + // Given: instruction file + const facetDir = join(tempRepertoireDir, '@acme', 'takt-backend', 'facets', 'instructions'); + mkdirSync(facetDir, { recursive: true }); + writeFileSync(join(facetDir, 'review-checklist.md'), 'Review steps'); + + const scopeRef: ScopeRef = { owner: 'acme', repo: 'takt-backend', name: 'review-checklist' }; + + // When: scope ref is resolved with facetType 'instructions' + const result = resolveScopeRef(scopeRef, 'instructions', tempRepertoireDir); + + // Then: correct path + expect(result).toBe(join(tempRepertoireDir, '@acme', 'takt-backend', 'facets', 'instructions', 'review-checklist.md')); + }); + + it('should resolve output-contracts scope ref to facets/output-contracts/{name}.md', () => { + // Given: output contract file + const facetDir = join(tempRepertoireDir, '@acme', 'takt-backend', 'facets', 'output-contracts'); + mkdirSync(facetDir, { recursive: true }); + writeFileSync(join(facetDir, 'review-report.md'), 'Report contract'); + + const scopeRef: ScopeRef = { owner: 'acme', repo: 'takt-backend', name: 'review-report' }; + + // When: scope ref is resolved with facetType 'output-contracts' + const result = resolveScopeRef(scopeRef, 'output-contracts', tempRepertoireDir); + + // Then: correct path + expect(result).toBe(join(tempRepertoireDir, '@acme', 'takt-backend', 'facets', 'output-contracts', 'review-report.md')); + }); +}); + +// --------------------------------------------------------------------------- +// Name constraint validation +// --------------------------------------------------------------------------- + +describe('validateScopeOwner', () => { + it('should accept valid owner: nrslib', () => { + expect(() => validateScopeOwner('nrslib')).not.toThrow(); + }); + + it('should accept owner with numbers: owner123', () => { + expect(() => validateScopeOwner('owner123')).not.toThrow(); + }); + + it('should accept owner with hyphens: my-org', () => { + expect(() => validateScopeOwner('my-org')).not.toThrow(); + }); + + it('should reject owner starting with hyphen: -owner', () => { + // Pattern: /^[a-z0-9][a-z0-9-]*$/ + expect(() => validateScopeOwner('-owner')).toThrow(); + }); + + it('should reject owner with uppercase letters after normalization requirement', () => { + // Owner must be lowercase + expect(() => validateScopeOwner('MyOrg')).toThrow(); + }); + + it('should reject owner with dots', () => { + // Dots not allowed in owner + expect(() => validateScopeOwner('my.org')).toThrow(); + }); +}); + +describe('validateScopeRepo', () => { + it('should accept simple repo: takt-fullstack', () => { + expect(() => validateScopeRepo('takt-fullstack')).not.toThrow(); + }); + + it('should accept repo with dot: takt.backend', () => { + // Dots allowed in repo names + expect(() => validateScopeRepo('takt.backend')).not.toThrow(); + }); + + it('should accept repo with underscore: takt_backend', () => { + // Underscores allowed in repo names + expect(() => validateScopeRepo('takt_backend')).not.toThrow(); + }); + + it('should reject repo starting with hyphen: -repo', () => { + expect(() => validateScopeRepo('-repo')).toThrow(); + }); +}); + +describe('validateScopeFacetName', () => { + it('should accept valid facet name: expert-coder', () => { + expect(() => validateScopeFacetName('expert-coder')).not.toThrow(); + }); + + it('should accept facet name with numbers: expert2', () => { + expect(() => validateScopeFacetName('expert2')).not.toThrow(); + }); + + it('should reject facet name starting with hyphen: -expert', () => { + expect(() => validateScopeFacetName('-expert')).toThrow(); + }); + + it('should reject facet name with dots: expert.coder', () => { + // Dots not allowed in facet names + expect(() => validateScopeFacetName('expert.coder')).toThrow(); + }); +}); diff --git a/src/__tests__/helpers/repertoire-test-helpers.ts b/src/__tests__/helpers/repertoire-test-helpers.ts new file mode 100644 index 0000000..fc12aac --- /dev/null +++ b/src/__tests__/helpers/repertoire-test-helpers.ts @@ -0,0 +1,15 @@ +import { join } from 'node:path'; +import type { ScanConfig } from '../../features/repertoire/remove.js'; + +/** + * Build a ScanConfig for tests using tempDir as the root. + * + * Maps the 3 spec-defined scan locations to subdirectories of tempDir, + * enabling tests to run in isolation without touching real config paths. + */ +export function makeScanConfig(tempDir: string): ScanConfig { + return { + piecesDirs: [join(tempDir, 'pieces'), join(tempDir, '.takt', 'pieces')], + categoriesFiles: [join(tempDir, 'preferences', 'piece-categories.yaml')], + }; +} diff --git a/src/__tests__/it-notification-sound.test.ts b/src/__tests__/it-notification-sound.test.ts index 0959182..85f26c8 100644 --- a/src/__tests__/it-notification-sound.test.ts +++ b/src/__tests__/it-notification-sound.test.ts @@ -423,4 +423,5 @@ describe('executePiece: notification sound behavior', () => { expect(mockPlayWarningSound).not.toHaveBeenCalled(); }); }); + }); diff --git a/src/__tests__/piece-category-config.test.ts b/src/__tests__/piece-category-config.test.ts index 123f10f..0b1c44e 100644 --- a/src/__tests__/piece-category-config.test.ts +++ b/src/__tests__/piece-category-config.test.ts @@ -72,7 +72,7 @@ function writeYaml(path: string, content: string): void { writeFileSync(path, content.trim() + '\n', 'utf-8'); } -function createPieceMap(entries: { name: string; source: 'builtin' | 'user' | 'project' }[]): +function createPieceMap(entries: { name: string; source: 'builtin' | 'user' | 'project' | 'repertoire' }[]): Map { const pieces = new Map(); for (const entry of entries) { @@ -402,7 +402,7 @@ describe('buildCategorizedPieces', () => { othersCategoryName: 'Others', }; - const categorized = buildCategorizedPieces(allPieces, config); + const categorized = buildCategorizedPieces(allPieces, config, process.cwd()); expect(categorized.categories).toEqual([ { name: 'My Team', pieces: ['custom'], children: [] }, { @@ -441,4 +441,52 @@ describe('buildCategorizedPieces', () => { const paths = findPieceCategories('nested', categories); expect(paths).toEqual(['Parent / Child']); }); + + it('should append repertoire category for @scope pieces', () => { + const allPieces = createPieceMap([ + { name: 'default', source: 'builtin' }, + { name: '@nrslib/takt-ensemble/expert', source: 'repertoire' }, + { name: '@nrslib/takt-ensemble/reviewer', source: 'repertoire' }, + ]); + const config = { + pieceCategories: [{ name: 'Main', pieces: ['default'], children: [] }], + builtinPieceCategories: [{ name: 'Main', pieces: ['default'], children: [] }], + userPieceCategories: [], + hasUserCategories: false, + showOthersCategory: true, + othersCategoryName: 'Others', + }; + + const categorized = buildCategorizedPieces(allPieces, config, process.cwd()); + + // repertoire category is appended + const repertoireCat = categorized.categories.find((c) => c.name === 'repertoire'); + expect(repertoireCat).toBeDefined(); + expect(repertoireCat!.children).toHaveLength(1); + expect(repertoireCat!.children[0]!.name).toBe('@nrslib/takt-ensemble'); + expect(repertoireCat!.children[0]!.pieces).toEqual( + expect.arrayContaining(['@nrslib/takt-ensemble/expert', '@nrslib/takt-ensemble/reviewer']), + ); + + // @scope pieces must not appear in Others + const othersCat = categorized.categories.find((c) => c.name === 'Others'); + expect(othersCat?.pieces ?? []).not.toContain('@nrslib/takt-ensemble/expert'); + }); + + it('should not append repertoire category when no @scope pieces exist', () => { + const allPieces = createPieceMap([{ name: 'default', source: 'builtin' }]); + const config = { + pieceCategories: [{ name: 'Main', pieces: ['default'], children: [] }], + builtinPieceCategories: [{ name: 'Main', pieces: ['default'], children: [] }], + userPieceCategories: [], + hasUserCategories: false, + showOthersCategory: true, + othersCategoryName: 'Others', + }; + + const categorized = buildCategorizedPieces(allPieces, config, process.cwd()); + + const repertoireCat = categorized.categories.find((c) => c.name === 'repertoire'); + expect(repertoireCat).toBeUndefined(); + }); }); diff --git a/src/__tests__/pieceLoader.test.ts b/src/__tests__/pieceLoader.test.ts index 71ca03f..677be57 100644 --- a/src/__tests__/pieceLoader.test.ts +++ b/src/__tests__/pieceLoader.test.ts @@ -11,6 +11,7 @@ import { loadPieceByIdentifier, listPieces, loadAllPieces, + loadAllPiecesWithSources, } from '../infra/config/loaders/pieceLoader.js'; const SAMPLE_PIECE = `name: test-piece @@ -187,3 +188,98 @@ movements: }); }); + +describe('loadPieceByIdentifier with @scope ref (repertoire)', () => { + let tempDir: string; + let configDir: string; + const originalTaktConfigDir = process.env.TAKT_CONFIG_DIR; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-test-')); + configDir = mkdtempSync(join(tmpdir(), 'takt-config-')); + process.env.TAKT_CONFIG_DIR = configDir; + }); + + afterEach(() => { + if (originalTaktConfigDir !== undefined) { + process.env.TAKT_CONFIG_DIR = originalTaktConfigDir; + } else { + delete process.env.TAKT_CONFIG_DIR; + } + rmSync(tempDir, { recursive: true, force: true }); + rmSync(configDir, { recursive: true, force: true }); + }); + + it('should load piece by @scope ref (repertoire)', () => { + // Given: repertoire package with a piece file + const piecesDir = join(configDir, 'repertoire', '@nrslib', 'takt-ensemble', 'pieces'); + mkdirSync(piecesDir, { recursive: true }); + writeFileSync(join(piecesDir, 'expert.yaml'), SAMPLE_PIECE); + + // When: piece is loaded via @scope ref + const piece = loadPieceByIdentifier('@nrslib/takt-ensemble/expert', tempDir); + + // Then: the piece is resolved correctly + expect(piece).not.toBeNull(); + expect(piece!.name).toBe('test-piece'); + }); + + it('should return null for non-existent @scope piece', () => { + // Given: repertoire dir exists but the requested piece does not + const piecesDir = join(configDir, 'repertoire', '@nrslib', 'takt-ensemble', 'pieces'); + mkdirSync(piecesDir, { recursive: true }); + + // When: a non-existent piece is requested + const piece = loadPieceByIdentifier('@nrslib/takt-ensemble/no-such-piece', tempDir); + + // Then: null is returned + expect(piece).toBeNull(); + }); +}); + +describe('loadAllPiecesWithSources with repertoire pieces', () => { + let tempDir: string; + let configDir: string; + const originalTaktConfigDir = process.env.TAKT_CONFIG_DIR; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-test-')); + configDir = mkdtempSync(join(tmpdir(), 'takt-config-')); + process.env.TAKT_CONFIG_DIR = configDir; + }); + + afterEach(() => { + if (originalTaktConfigDir !== undefined) { + process.env.TAKT_CONFIG_DIR = originalTaktConfigDir; + } else { + delete process.env.TAKT_CONFIG_DIR; + } + rmSync(tempDir, { recursive: true, force: true }); + rmSync(configDir, { recursive: true, force: true }); + }); + + it('should include repertoire pieces with @scope qualified names', () => { + // Given: repertoire package with a piece file + const piecesDir = join(configDir, 'repertoire', '@nrslib', 'takt-ensemble', 'pieces'); + mkdirSync(piecesDir, { recursive: true }); + writeFileSync(join(piecesDir, 'expert.yaml'), SAMPLE_PIECE); + + // When: all pieces are loaded + const pieces = loadAllPiecesWithSources(tempDir); + + // Then: the repertoire piece is included with 'repertoire' source + expect(pieces.has('@nrslib/takt-ensemble/expert')).toBe(true); + expect(pieces.get('@nrslib/takt-ensemble/expert')!.source).toBe('repertoire'); + }); + + it('should not throw when repertoire dir does not exist', () => { + // Given: no repertoire dir created (configDir/repertoire does not exist) + + // When: all pieces are loaded + const pieces = loadAllPiecesWithSources(tempDir); + + // Then: no @scope pieces are present and no error thrown + const repertoirePieces = Array.from(pieces.keys()).filter((k) => k.startsWith('@')); + expect(repertoirePieces).toHaveLength(0); + }); +}); diff --git a/src/__tests__/repertoire-atomic-update.test.ts b/src/__tests__/repertoire-atomic-update.test.ts new file mode 100644 index 0000000..8b4f105 --- /dev/null +++ b/src/__tests__/repertoire-atomic-update.test.ts @@ -0,0 +1,153 @@ +/** + * Unit tests for repertoire atomic installation/update sequence. + * + * Target: src/features/repertoire/atomic-update.ts + * + * Atomic update steps under test: + * Step 0: Clean up leftover .tmp/ and .bak/ from previous failed runs + * Step 1: Rename existing → {repo}.bak/ (backup) + * Step 2: Create new packageDir, call install() + * Step 3: On success, remove .bak/; on failure, restore from .bak/ + * + * Failure injection scenarios: + * - Step 2 failure: .tmp/ removed, existing package preserved + * - Step 3→4 rename failure: restore from .bak/ + * - Step 5 failure: warn only, new package is in place + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + cleanupResiduals, + atomicReplace, + type AtomicReplaceOptions, +} from '../features/repertoire/atomic-update.js'; + +describe('repertoire atomic install: leftover cleanup (Step 0)', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-atomic-cleanup-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + // U24: 前回の .tmp/ をクリーンアップ + // Given: {repo}.tmp/ が既に存在する + // When: installPackage() 呼び出し + // Then: .tmp/ が削除されてインストールが継続する + it('should clean up leftover {repo}.tmp/ before starting installation', () => { + const packageDir = join(tempDir, 'takt-fullstack'); + const tmpDirPath = `${packageDir}.tmp`; + mkdirSync(packageDir, { recursive: true }); + mkdirSync(tmpDirPath, { recursive: true }); + writeFileSync(join(tmpDirPath, 'stale.yaml'), 'stale'); + + cleanupResiduals(packageDir); + + expect(existsSync(tmpDirPath)).toBe(false); + }); + + // U25: 前回の .bak/ をクリーンアップ + // Given: {repo}.bak/ が既に存在する + // When: installPackage() 呼び出し + // Then: .bak/ が削除されてインストールが継続する + it('should clean up leftover {repo}.bak/ before starting installation', () => { + const packageDir = join(tempDir, 'takt-fullstack'); + const bakDirPath = `${packageDir}.bak`; + mkdirSync(packageDir, { recursive: true }); + mkdirSync(bakDirPath, { recursive: true }); + writeFileSync(join(bakDirPath, 'old.yaml'), 'old'); + + cleanupResiduals(packageDir); + + expect(existsSync(bakDirPath)).toBe(false); + }); +}); + +describe('repertoire atomic install: failure recovery', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-atomic-recover-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + // U26: Step 2 失敗 — .tmp/ 削除後エラー終了、既存パッケージ維持 + // Given: 既存パッケージあり、Step 2(バリデーション)を失敗注入 + // When: installPackage() 呼び出し + // Then: 既存パッケージが維持される(install() が throw した場合、.bak から復元) + it('should remove .tmp/ and preserve existing package when Step 2 (validation) fails', async () => { + const packageDir = join(tempDir, 'takt-fullstack'); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, 'existing.yaml'), 'existing content'); + + const options: AtomicReplaceOptions = { + packageDir, + install: async () => { + throw new Error('Validation failed: invalid package contents'); + }, + }; + + await expect(atomicReplace(options)).rejects.toThrow('Validation failed'); + + // Existing package must be preserved + expect(existsSync(join(packageDir, 'existing.yaml'))).toBe(true); + // .bak directory must be cleaned up + expect(existsSync(`${packageDir}.bak`)).toBe(false); + }); + + // U27: Step 3→4 rename 失敗 — .bak/ から既存パッケージ復元 + // Given: 既存パッケージあり、install() が throw + // When: atomicReplace() 呼び出し + // Then: 既存パッケージが .bak/ から復元される + it('should restore existing package from .bak/ when Step 4 rename fails', async () => { + const packageDir = join(tempDir, 'takt-fullstack'); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, 'original.yaml'), 'original content'); + + const options: AtomicReplaceOptions = { + packageDir, + install: async () => { + throw new Error('Simulated rename failure'); + }, + }; + + await expect(atomicReplace(options)).rejects.toThrow(); + + // Original package content must be restored from .bak + expect(existsSync(join(packageDir, 'original.yaml'))).toBe(true); + }); + + // U28: Step 5 失敗(.bak/ 削除失敗)— 警告のみ、新パッケージは正常配置済み + // Given: install() が成功し、新パッケージが配置済み + // When: atomicReplace() 完了 + // Then: 新パッケージが正常に配置されている + it('should warn but not exit when Step 5 (.bak/ removal) fails', async () => { + const packageDir = join(tempDir, 'takt-fullstack'); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, 'old.yaml'), 'old content'); + + const options: AtomicReplaceOptions = { + packageDir, + install: async () => { + writeFileSync(join(packageDir, 'new.yaml'), 'new content'); + }, + }; + + // Should not throw even if .bak removal conceptually failed + await expect(atomicReplace(options)).resolves.not.toThrow(); + + // New package content is in place + expect(existsSync(join(packageDir, 'new.yaml'))).toBe(true); + // .bak directory should be cleaned up on success + expect(existsSync(`${packageDir}.bak`)).toBe(false); + }); +}); diff --git a/src/__tests__/repertoire-ref-integrity.test.ts b/src/__tests__/repertoire-ref-integrity.test.ts new file mode 100644 index 0000000..780d79c --- /dev/null +++ b/src/__tests__/repertoire-ref-integrity.test.ts @@ -0,0 +1,120 @@ +/** + * Unit tests for repertoire reference integrity scanner. + * + * Target: src/features/repertoire/remove.ts (findScopeReferences) + * + * Scanner searches for @scope package references in: + * - {root}/pieces/**\/*.yaml + * - {root}/preferences/piece-categories.yaml + * - {root}/.takt/pieces/**\/*.yaml (project-level) + * + * Detection criteria: + * - Matches "@{owner}/{repo}" substring in file contents + * - Plain names without "@" are NOT detected + * - References to a different @scope are NOT detected + */ + +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 { findScopeReferences } from '../features/repertoire/remove.js'; +import { makeScanConfig } from './helpers/repertoire-test-helpers.js'; + +describe('repertoire reference integrity: detection', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-ref-integrity-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + // U29: ~/.takt/pieces/ の @scope 参照を検出 + // Given: {root}/pieces/my-review.yaml に + // persona: "@nrslib/takt-ensemble-fixture/expert-coder" を含む + // When: findScopeReferences("@nrslib/takt-ensemble-fixture", config) + // Then: my-review.yaml が検出される + it('should detect @scope reference in global pieces YAML', () => { + const piecesDir = join(tempDir, 'pieces'); + mkdirSync(piecesDir, { recursive: true }); + const pieceFile = join(piecesDir, 'my-review.yaml'); + writeFileSync(pieceFile, 'persona: "@nrslib/takt-ensemble-fixture/expert-coder"'); + + const refs = findScopeReferences('@nrslib/takt-ensemble-fixture', makeScanConfig(tempDir)); + + expect(refs.some((r) => r.filePath === pieceFile)).toBe(true); + }); + + // U30: {root}/preferences/piece-categories.yaml の @scope 参照を検出 + // Given: piece-categories.yaml に @nrslib/takt-ensemble-fixture/expert を含む + // When: findScopeReferences("@nrslib/takt-ensemble-fixture", config) + // Then: piece-categories.yaml が検出される + it('should detect @scope reference in global piece-categories.yaml', () => { + const prefsDir = join(tempDir, 'preferences'); + mkdirSync(prefsDir, { recursive: true }); + const categoriesFile = join(prefsDir, 'piece-categories.yaml'); + writeFileSync(categoriesFile, 'categories:\n - "@nrslib/takt-ensemble-fixture/expert"'); + + const refs = findScopeReferences('@nrslib/takt-ensemble-fixture', makeScanConfig(tempDir)); + + expect(refs.some((r) => r.filePath === categoriesFile)).toBe(true); + }); + + // U31: {root}/.takt/pieces/ の @scope 参照を検出 + // Given: プロジェクト {root}/.takt/pieces/proj.yaml に @scope 参照 + // When: findScopeReferences("@nrslib/takt-ensemble-fixture", config) + // Then: proj.yaml が検出される + it('should detect @scope reference in project-level pieces YAML', () => { + const projectPiecesDir = join(tempDir, '.takt', 'pieces'); + mkdirSync(projectPiecesDir, { recursive: true }); + const projFile = join(projectPiecesDir, 'proj.yaml'); + writeFileSync(projFile, 'persona: "@nrslib/takt-ensemble-fixture/expert-coder"'); + + const refs = findScopeReferences('@nrslib/takt-ensemble-fixture', makeScanConfig(tempDir)); + + expect(refs.some((r) => r.filePath === projFile)).toBe(true); + }); +}); + +describe('repertoire reference integrity: non-detection', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-ref-nodetect-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + // U32: @scope なし参照は検出しない + // Given: persona: "coder" のみ(@scope なし) + // When: findScopeReferences("@nrslib/takt-ensemble-fixture", config) + // Then: 結果が空配列 + it('should not detect plain name references without @scope prefix', () => { + const piecesDir = join(tempDir, 'pieces'); + mkdirSync(piecesDir, { recursive: true }); + writeFileSync(join(piecesDir, 'plain.yaml'), 'persona: "coder"'); + + const refs = findScopeReferences('@nrslib/takt-ensemble-fixture', makeScanConfig(tempDir)); + + expect(refs).toHaveLength(0); + }); + + // U33: 別スコープは検出しない + // Given: persona: "@other/package/name" + // When: findScopeReferences("@nrslib/takt-ensemble-fixture", config) + // Then: 結果が空配列 + it('should not detect references to a different @scope package', () => { + const piecesDir = join(tempDir, 'pieces'); + mkdirSync(piecesDir, { recursive: true }); + writeFileSync(join(piecesDir, 'other.yaml'), 'persona: "@other/package/name"'); + + const refs = findScopeReferences('@nrslib/takt-ensemble-fixture', makeScanConfig(tempDir)); + + expect(refs).toHaveLength(0); + }); +}); diff --git a/src/__tests__/repertoire-scope-resolver.test.ts b/src/__tests__/repertoire-scope-resolver.test.ts new file mode 100644 index 0000000..d085005 --- /dev/null +++ b/src/__tests__/repertoire-scope-resolver.test.ts @@ -0,0 +1,275 @@ +/** + * 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); + }); +}); diff --git a/src/__tests__/repertoire/atomic-update.test.ts b/src/__tests__/repertoire/atomic-update.test.ts new file mode 100644 index 0000000..9021d6d --- /dev/null +++ b/src/__tests__/repertoire/atomic-update.test.ts @@ -0,0 +1,152 @@ +/** + * Tests for atomic package update (overwrite install). + * + * Covers: + * - cleanupResiduals: pre-existing .tmp/ and .bak/ are removed before install + * - atomicReplace: normal success path (new → .bak → rename) + * - atomicReplace: validation failure → .tmp/ is removed, existing package preserved + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + cleanupResiduals, + atomicReplace, + type AtomicReplaceOptions, +} from '../../features/repertoire/atomic-update.js'; + +// --------------------------------------------------------------------------- +// cleanupResiduals +// --------------------------------------------------------------------------- + +describe('cleanupResiduals', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-atomic-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should remove pre-existing .tmp directory', () => { + // Given: a .tmp directory remains from a previous failed install + const packageDir = join(tempDir, 'takt-fullstack'); + const tmpDir = join(tempDir, 'takt-fullstack.tmp'); + mkdirSync(packageDir, { recursive: true }); + mkdirSync(tmpDir, { recursive: true }); + writeFileSync(join(tmpDir, 'stale.yaml'), 'stale'); + + // When: cleanup is performed + cleanupResiduals(packageDir); + + // Then: .tmp directory is removed + expect(existsSync(tmpDir)).toBe(false); + }); + + it('should remove pre-existing .bak directory', () => { + // Given: a .bak directory remains from a previous failed install + const packageDir = join(tempDir, 'takt-fullstack'); + const bakDir = join(tempDir, 'takt-fullstack.bak'); + mkdirSync(packageDir, { recursive: true }); + mkdirSync(bakDir, { recursive: true }); + writeFileSync(join(bakDir, 'old.yaml'), 'old'); + + // When: cleanup is performed + cleanupResiduals(packageDir); + + // Then: .bak directory is removed + expect(existsSync(bakDir)).toBe(false); + }); + + it('should succeed even when neither .tmp nor .bak exist', () => { + // Given: no residual directories + const packageDir = join(tempDir, 'takt-fullstack'); + mkdirSync(packageDir, { recursive: true }); + + // When: cleanup is performed + // Then: no error thrown + expect(() => cleanupResiduals(packageDir)).not.toThrow(); + }); + + it('should remove both .tmp and .bak when both exist', () => { + // Given: both residuals exist + const packageDir = join(tempDir, 'takt-fullstack'); + const tmpDirPath = join(tempDir, 'takt-fullstack.tmp'); + const bakDir = join(tempDir, 'takt-fullstack.bak'); + mkdirSync(packageDir, { recursive: true }); + mkdirSync(tmpDirPath, { recursive: true }); + mkdirSync(bakDir, { recursive: true }); + + // When: cleanup is performed + cleanupResiduals(packageDir); + + // Then: both are removed + expect(existsSync(tmpDirPath)).toBe(false); + expect(existsSync(bakDir)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// atomicReplace +// --------------------------------------------------------------------------- + +describe('atomicReplace', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-atomic-replace-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should replace existing package and delete .bak on success', async () => { + // Given: an existing package directory + const packageDir = join(tempDir, 'takt-fullstack'); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, 'old.yaml'), 'old content'); + + const options: AtomicReplaceOptions = { + packageDir, + install: async () => { + // Simulate successful install into packageDir + writeFileSync(join(packageDir, 'new.yaml'), 'new content'); + }, + }; + + // When: atomicReplace is executed + await atomicReplace(options); + + // Then: new content is in place, .bak is cleaned up + expect(existsSync(join(packageDir, 'new.yaml'))).toBe(true); + expect(existsSync(join(tempDir, 'takt-fullstack.bak'))).toBe(false); + }); + + it('should preserve existing package when install throws (validation failure)', async () => { + // Given: an existing package with content + const packageDir = join(tempDir, 'takt-fullstack'); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, 'existing.yaml'), 'existing'); + + const options: AtomicReplaceOptions = { + packageDir, + install: async () => { + // Simulate validation failure + throw new Error('Validation failed: empty package'); + }, + }; + + // When: atomicReplace is executed with a failing install + await expect(atomicReplace(options)).rejects.toThrow('Validation failed'); + + // Then: existing package is preserved + expect(existsSync(join(packageDir, 'existing.yaml'))).toBe(true); + // .tmp directory should be cleaned up + expect(existsSync(join(tempDir, 'takt-fullstack.tmp'))).toBe(false); + }); +}); diff --git a/src/__tests__/repertoire/file-filter.test.ts b/src/__tests__/repertoire/file-filter.test.ts new file mode 100644 index 0000000..dcb236b --- /dev/null +++ b/src/__tests__/repertoire/file-filter.test.ts @@ -0,0 +1,191 @@ +/** + * Tests for file filtering during package copy operations. + * + * Covers: + * - Allowed extensions (.md, .yaml, .yml) + * - Disallowed extensions (.sh, .js, .env, .ts, etc.) + * - collectCopyTargets: only facets/ and pieces/ directories copied + * - collectCopyTargets: symbolic links skipped + * - collectCopyTargets: file count limit (error if exceeds MAX_FILE_COUNT) + * - collectCopyTargets: path subdirectory scenario + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, + symlinkSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + isAllowedExtension, + collectCopyTargets, + MAX_FILE_SIZE, + MAX_FILE_COUNT, + ALLOWED_EXTENSIONS, + ALLOWED_DIRS, +} from '../../features/repertoire/file-filter.js'; + +// --------------------------------------------------------------------------- +// isAllowedExtension +// --------------------------------------------------------------------------- + +describe('isAllowedExtension', () => { + it('should allow .md files', () => { + expect(isAllowedExtension('coder.md')).toBe(true); + }); + + it('should allow .yaml files', () => { + expect(isAllowedExtension('takt-repertoire.yaml')).toBe(true); + }); + + it('should allow .yml files', () => { + expect(isAllowedExtension('config.yml')).toBe(true); + }); + + it('should reject .sh files', () => { + expect(isAllowedExtension('setup.sh')).toBe(false); + }); + + it('should reject .js files', () => { + expect(isAllowedExtension('script.js')).toBe(false); + }); + + it('should reject .env files', () => { + expect(isAllowedExtension('.env')).toBe(false); + }); + + it('should reject .ts files', () => { + expect(isAllowedExtension('types.ts')).toBe(false); + }); + + it('should reject files with no extension', () => { + expect(isAllowedExtension('Makefile')).toBe(false); + }); + + it('should reject .json files', () => { + expect(isAllowedExtension('package.json')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// collectCopyTargets +// --------------------------------------------------------------------------- + +describe('collectCopyTargets', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-collect-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should only include files under facets/ and pieces/ directories', () => { + // Given: package root with facets/, pieces/, and a README.md at root + mkdirSync(join(tempDir, 'facets', 'personas'), { recursive: true }); + mkdirSync(join(tempDir, 'pieces'), { recursive: true }); + writeFileSync(join(tempDir, 'facets', 'personas', 'coder.md'), 'Coder persona'); + writeFileSync(join(tempDir, 'pieces', 'expert.yaml'), 'name: expert'); + writeFileSync(join(tempDir, 'README.md'), 'Readme'); // should be excluded + + // When: collectCopyTargets scans the package root + const targets = collectCopyTargets(tempDir); + const paths = targets.map((t) => t.relativePath); + + // Then: only facets/ and pieces/ files are included + expect(paths).toContain(join('facets', 'personas', 'coder.md')); + expect(paths).toContain(join('pieces', 'expert.yaml')); + expect(paths.some((p) => p === 'README.md')).toBe(false); + }); + + it('should skip symbolic links during scan', () => { + // Given: facets/ with a symlink + mkdirSync(join(tempDir, 'facets', 'personas'), { recursive: true }); + const target = join(tempDir, 'facets', 'personas', 'real.md'); + writeFileSync(target, 'Real content'); + symlinkSync(target, join(tempDir, 'facets', 'personas', 'link.md')); + + // When: collectCopyTargets scans + const targets = collectCopyTargets(tempDir); + const paths = targets.map((t) => t.relativePath); + + // Then: symlink is excluded + expect(paths.some((p) => p.includes('link.md'))).toBe(false); + expect(paths.some((p) => p.includes('real.md'))).toBe(true); + }); + + it('should throw when file count exceeds MAX_FILE_COUNT', () => { + // Given: more than MAX_FILE_COUNT files under facets/ + mkdirSync(join(tempDir, 'facets', 'personas'), { recursive: true }); + for (let i = 0; i <= MAX_FILE_COUNT; i++) { + writeFileSync(join(tempDir, 'facets', 'personas', `file-${i}.md`), 'content'); + } + + // When: collectCopyTargets scans + // Then: throws because file count limit is exceeded + expect(() => collectCopyTargets(tempDir)).toThrow(); + }); + + it('should skip files exceeding MAX_FILE_SIZE', () => { + // Given: facets/ with a valid file and a file exceeding the size limit + mkdirSync(join(tempDir, 'facets', 'personas'), { recursive: true }); + writeFileSync(join(tempDir, 'facets', 'personas', 'coder.md'), 'valid'); + writeFileSync( + join(tempDir, 'facets', 'personas', 'large.md'), + Buffer.alloc(MAX_FILE_SIZE + 1), + ); + + // When: collectCopyTargets scans + const targets = collectCopyTargets(tempDir); + const paths = targets.map((t) => t.relativePath); + + // Then: large file is skipped, valid file is included + expect(paths.some((p) => p.includes('large.md'))).toBe(false); + expect(paths.some((p) => p.includes('coder.md'))).toBe(true); + }); + + it('should adjust copy base when path is "takt" (subdirectory scenario)', () => { + // Given: package has path: "takt", so facets/ is under takt/facets/ + mkdirSync(join(tempDir, 'takt', 'facets', 'personas'), { recursive: true }); + writeFileSync(join(tempDir, 'takt', 'facets', 'personas', 'coder.md'), 'Coder'); + + // When: collectCopyTargets is called with packageRoot = tempDir/takt + const packageRoot = join(tempDir, 'takt'); + const targets = collectCopyTargets(packageRoot); + const paths = targets.map((t) => t.relativePath); + + // Then: file is found under facets/personas/ + expect(paths).toContain(join('facets', 'personas', 'coder.md')); + }); +}); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +describe('constants', () => { + it('ALLOWED_EXTENSIONS should include .md, .yaml, .yml', () => { + expect(ALLOWED_EXTENSIONS).toContain('.md'); + expect(ALLOWED_EXTENSIONS).toContain('.yaml'); + expect(ALLOWED_EXTENSIONS).toContain('.yml'); + }); + + it('ALLOWED_DIRS should include faceted and pieces', () => { + expect(ALLOWED_DIRS).toContain('facets'); + expect(ALLOWED_DIRS).toContain('pieces'); + }); + + it('MAX_FILE_SIZE should be defined as a positive number', () => { + expect(MAX_FILE_SIZE).toBeGreaterThan(0); + }); + + it('MAX_FILE_COUNT should be defined as a positive number', () => { + expect(MAX_FILE_COUNT).toBeGreaterThan(0); + }); +}); diff --git a/src/__tests__/repertoire/github-ref-resolver.test.ts b/src/__tests__/repertoire/github-ref-resolver.test.ts new file mode 100644 index 0000000..5016f5e --- /dev/null +++ b/src/__tests__/repertoire/github-ref-resolver.test.ts @@ -0,0 +1,83 @@ +/** + * Unit tests for resolveRef in github-ref-resolver.ts. + * + * Covers: + * - Returns specRef directly when provided + * - Calls execGh with correct API path to retrieve default branch + * - Returns trimmed branch name from execGh output + * - Throws when execGh returns empty string + */ + +import { describe, it, expect, vi } from 'vitest'; +import { resolveRef } from '../../features/repertoire/github-ref-resolver.js'; + +describe('resolveRef', () => { + it('should return specRef directly when provided', () => { + // Given: specRef is specified + const execGh = vi.fn(); + + // When: resolveRef is called with a specRef + const result = resolveRef('main', 'owner', 'repo', execGh); + + // Then: returns specRef without calling execGh + expect(result).toBe('main'); + expect(execGh).not.toHaveBeenCalled(); + }); + + it('should return specRef even when it is a SHA', () => { + // Given: specRef is a commit SHA + const execGh = vi.fn(); + + const result = resolveRef('abc1234def', 'owner', 'repo', execGh); + + expect(result).toBe('abc1234def'); + expect(execGh).not.toHaveBeenCalled(); + }); + + it('should call execGh with correct API args when specRef is undefined', () => { + // Given: specRef is undefined (omitted from spec) + const execGh = vi.fn().mockReturnValue('main\n'); + + // When: resolveRef is called without specRef + resolveRef(undefined, 'nrslib', 'takt-fullstack', execGh); + + // Then: calls gh api with the correct path and jq filter + expect(execGh).toHaveBeenCalledOnce(); + expect(execGh).toHaveBeenCalledWith([ + 'api', + '/repos/nrslib/takt-fullstack', + '--jq', '.default_branch', + ]); + }); + + it('should return trimmed branch name from execGh output', () => { + // Given: execGh returns branch name with trailing newline + const execGh = vi.fn().mockReturnValue('develop\n'); + + // When: resolveRef is called + const result = resolveRef(undefined, 'owner', 'repo', execGh); + + // Then: branch name is trimmed + expect(result).toBe('develop'); + }); + + it('should throw when execGh returns an empty string', () => { + // Given: execGh returns empty output (API error or unexpected response) + const execGh = vi.fn().mockReturnValue(''); + + // When / Then: throws an error with the owner/repo in the message + expect(() => resolveRef(undefined, 'owner', 'repo', execGh)).toThrow( + 'デフォルトブランチを取得できませんでした: owner/repo', + ); + }); + + it('should throw when execGh returns only whitespace', () => { + // Given: execGh returns whitespace only + const execGh = vi.fn().mockReturnValue(' \n'); + + // When / Then: throws (whitespace trims to empty string) + expect(() => resolveRef(undefined, 'myorg', 'myrepo', execGh)).toThrow( + 'デフォルトブランチを取得できませんでした: myorg/myrepo', + ); + }); +}); diff --git a/src/__tests__/repertoire/github-spec.test.ts b/src/__tests__/repertoire/github-spec.test.ts new file mode 100644 index 0000000..a9f1e5f --- /dev/null +++ b/src/__tests__/repertoire/github-spec.test.ts @@ -0,0 +1,98 @@ +/** + * Tests for parseGithubSpec in github-spec.ts. + * + * Covers the happy path and all error branches. + */ + +import { describe, it, expect } from 'vitest'; +import { parseGithubSpec } from '../../features/repertoire/github-spec.js'; + +describe('parseGithubSpec', () => { + describe('happy path', () => { + it('should parse a valid github spec', () => { + // Given: a well-formed spec + const spec = 'github:nrslib/takt-fullstack@main'; + + // When: parsed + const result = parseGithubSpec(spec); + + // Then: components are extracted correctly + expect(result.owner).toBe('nrslib'); + expect(result.repo).toBe('takt-fullstack'); + expect(result.ref).toBe('main'); + }); + + it('should normalize owner and repo to lowercase', () => { + // Given: spec with uppercase owner/repo + const spec = 'github:Owner/Repo@v1.0.0'; + + // When: parsed + const result = parseGithubSpec(spec); + + // Then: owner and repo are lowercased + expect(result.owner).toBe('owner'); + expect(result.repo).toBe('repo'); + expect(result.ref).toBe('v1.0.0'); + }); + + it('should handle a SHA as ref', () => { + // Given: spec with a full SHA as ref + const spec = 'github:myorg/myrepo@abc1234def5678'; + + // When: parsed + const result = parseGithubSpec(spec); + + // Then: ref is the SHA + expect(result.ref).toBe('abc1234def5678'); + }); + + it('should use lastIndexOf for @ so tags with @ in name work', () => { + // Given: spec where the ref contains no ambiguous @ + // and owner/repo portion has no @ + const spec = 'github:org/repo@refs/tags/v1.0'; + + // When: parsed + const result = parseGithubSpec(spec); + + // Then: ref is correctly picked as the last @ segment + expect(result.owner).toBe('org'); + expect(result.repo).toBe('repo'); + expect(result.ref).toBe('refs/tags/v1.0'); + }); + }); + + describe('error paths', () => { + it('should throw when prefix is not github:', () => { + // Given: spec with wrong prefix + const spec = 'npm:owner/repo@latest'; + + // When / Then: error is thrown + expect(() => parseGithubSpec(spec)).toThrow( + 'Invalid package spec: "npm:owner/repo@latest". Expected "github:{owner}/{repo}@{ref}"', + ); + }); + + it('should return undefined ref when @{ref} is omitted', () => { + // Given: spec with no @{ref} (ref is optional; caller resolves default branch) + const spec = 'github:owner/repo'; + + // When: parsed + const result = parseGithubSpec(spec); + + // Then: owner and repo are extracted, ref is undefined + expect(result.owner).toBe('owner'); + expect(result.repo).toBe('repo'); + expect(result.ref).toBeUndefined(); + }); + + it('should throw when repo name is missing (no slash)', () => { + // Given: spec with no slash between owner and repo + const spec = 'github:owneronly@main'; + + // When / Then: error about missing repo name + expect(() => parseGithubSpec(spec)).toThrow( + 'Invalid package spec: "github:owneronly@main". Missing repo name', + ); + }); + }); +}); diff --git a/src/__tests__/repertoire/list.test.ts b/src/__tests__/repertoire/list.test.ts new file mode 100644 index 0000000..8cad43c --- /dev/null +++ b/src/__tests__/repertoire/list.test.ts @@ -0,0 +1,222 @@ +/** + * Tests for repertoire list display data retrieval. + * + * Covers: + * - readPackageInfo(): reads description from takt-repertoire.yaml and ref/commit from .takt-repertoire-lock.yaml + * - commit is truncated to first 7 characters for display + * - listPackages(): enumerates all installed packages under repertoire/ + * - Multiple packages are correctly listed + */ + +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 { + readPackageInfo, + listPackages, +} from '../../features/repertoire/list.js'; + +// --------------------------------------------------------------------------- +// readPackageInfo +// --------------------------------------------------------------------------- + +describe('readPackageInfo', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-list-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should read description from takt-repertoire.yaml', () => { + // Given: a package directory with takt-repertoire.yaml and .takt-repertoire-lock.yaml + const packageDir = join(tempDir, '@nrslib', 'takt-fullstack'); + mkdirSync(packageDir, { recursive: true }); + writeFileSync( + join(packageDir, 'takt-repertoire.yaml'), + 'description: フルスタック開発ワークフロー\n', + ); + writeFileSync( + join(packageDir, '.takt-repertoire-lock.yaml'), + `source: github:nrslib/takt-fullstack +ref: v1.2.0 +commit: abc1234def5678 +imported_at: 2026-02-20T12:00:00.000Z +`, + ); + + // When: package info is read + const info = readPackageInfo(packageDir, '@nrslib/takt-fullstack'); + + // Then: description, ref, and truncated commit are returned + expect(info.scope).toBe('@nrslib/takt-fullstack'); + expect(info.description).toBe('フルスタック開発ワークフロー'); + expect(info.ref).toBe('v1.2.0'); + expect(info.commit).toBe('abc1234'); // first 7 chars + }); + + it('should truncate commit SHA to first 7 characters', () => { + // Given: package with a long commit SHA + const packageDir = join(tempDir, '@nrslib', 'takt-security-facets'); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, 'takt-repertoire.yaml'), 'description: Security facets\n'); + writeFileSync( + join(packageDir, '.takt-repertoire-lock.yaml'), + `source: github:nrslib/takt-security-facets +ref: HEAD +commit: def5678901234567 +imported_at: 2026-02-20T12:00:00.000Z +`, + ); + + // When: package info is read + const info = readPackageInfo(packageDir, '@nrslib/takt-security-facets'); + + // Then: commit is 7 chars + expect(info.commit).toBe('def5678'); + expect(info.commit).toHaveLength(7); + }); + + it('should handle package without description field', () => { + // Given: takt-repertoire.yaml with no description + const packageDir = join(tempDir, '@acme', 'takt-backend'); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, 'takt-repertoire.yaml'), 'path: takt\n'); + writeFileSync( + join(packageDir, '.takt-repertoire-lock.yaml'), + `source: github:acme/takt-backend +ref: v2.0.0 +commit: 789abcdef0123 +imported_at: 2026-01-15T08:30:00.000Z +`, + ); + + // When: package info is read + const info = readPackageInfo(packageDir, '@acme/takt-backend'); + + // Then: description is undefined (not present) + expect(info.description).toBeUndefined(); + expect(info.ref).toBe('v2.0.0'); + }); + + it('should use "HEAD" ref when package was imported without a tag', () => { + // Given: package imported from default branch + const packageDir = join(tempDir, '@acme', 'no-tag-pkg'); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, 'takt-repertoire.yaml'), 'description: No tag\n'); + writeFileSync( + join(packageDir, '.takt-repertoire-lock.yaml'), + `source: github:acme/no-tag-pkg +ref: HEAD +commit: aabbccddeeff00 +imported_at: 2026-02-01T00:00:00.000Z +`, + ); + + // When: package info is read + const info = readPackageInfo(packageDir, '@acme/no-tag-pkg'); + + // Then: ref is "HEAD" + expect(info.ref).toBe('HEAD'); + }); + + it('should fallback to "HEAD" ref when lock file is absent', () => { + // Given: package directory with no lock file + const packageDir = join(tempDir, '@acme', 'no-lock-pkg'); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, 'takt-repertoire.yaml'), 'description: No lock\n'); + // .takt-repertoire-lock.yaml intentionally not created + + // When: package info is read + const info = readPackageInfo(packageDir, '@acme/no-lock-pkg'); + + // Then: ref defaults to "HEAD" when lock file is missing + expect(info.ref).toBe('HEAD'); + expect(info.description).toBe('No lock'); + }); +}); + +// --------------------------------------------------------------------------- +// listPackages +// --------------------------------------------------------------------------- + +describe('listPackages', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-list-all-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + function createPackage( + repertoireDir: string, + owner: string, + repo: string, + description: string, + ref: string, + commit: string, + ): void { + const packageDir = join(repertoireDir, `@${owner}`, repo); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, 'takt-repertoire.yaml'), `description: ${description}\n`); + writeFileSync( + join(packageDir, '.takt-repertoire-lock.yaml'), + `source: github:${owner}/${repo} +ref: ${ref} +commit: ${commit} +imported_at: 2026-02-20T12:00:00.000Z +`, + ); + } + + it('should list all installed packages from repertoire directory', () => { + // Given: repertoire directory with 3 packages + const repertoireDir = join(tempDir, 'repertoire'); + createPackage(repertoireDir, 'nrslib', 'takt-fullstack', 'Fullstack workflow', 'v1.2.0', 'abc1234def5678'); + createPackage(repertoireDir, 'nrslib', 'takt-security-facets', 'Security facets', 'HEAD', 'def5678901234'); + createPackage(repertoireDir, 'acme-corp', 'takt-backend', 'Backend facets', 'v2.0.0', '789abcdef0123'); + + // When: packages are listed + const packages = listPackages(repertoireDir); + + // Then: all 3 packages are returned + expect(packages).toHaveLength(3); + const scopes = packages.map((p) => p.scope); + expect(scopes).toContain('@nrslib/takt-fullstack'); + expect(scopes).toContain('@nrslib/takt-security-facets'); + expect(scopes).toContain('@acme-corp/takt-backend'); + }); + + it('should return empty list when repertoire directory has no packages', () => { + // Given: empty repertoire directory + const repertoireDir = join(tempDir, 'repertoire'); + mkdirSync(repertoireDir, { recursive: true }); + + // When: packages are listed + const packages = listPackages(repertoireDir); + + // Then: empty list + expect(packages).toHaveLength(0); + }); + + it('should include correct commit (truncated to 7 chars) for each package', () => { + // Given: repertoire with one package + const repertoireDir = join(tempDir, 'repertoire'); + createPackage(repertoireDir, 'nrslib', 'takt-fullstack', 'Fullstack', 'v1.2.0', 'abc1234def5678'); + + // When: packages are listed + const packages = listPackages(repertoireDir); + + // Then: commit is 7 chars + const pkg = packages.find((p) => p.scope === '@nrslib/takt-fullstack')!; + expect(pkg.commit).toBe('abc1234'); + expect(pkg.commit).toHaveLength(7); + }); +}); diff --git a/src/__tests__/repertoire/lock-file.test.ts b/src/__tests__/repertoire/lock-file.test.ts new file mode 100644 index 0000000..423aa82 --- /dev/null +++ b/src/__tests__/repertoire/lock-file.test.ts @@ -0,0 +1,167 @@ +/** + * Tests for .takt-repertoire-lock.yaml generation and parsing. + * + * Covers: + * - extractCommitSha: parse SHA from tarball directory name {owner}-{repo}-{sha}/ + * - generateLockFile: produces correct fields (source, ref, commit, imported_at) + * - ref defaults to "HEAD" when not specified + * - parseLockFile: reads .takt-repertoire-lock.yaml content + */ + +import { describe, it, expect } from 'vitest'; +import { + extractCommitSha, + generateLockFile, + parseLockFile, +} from '../../features/repertoire/lock-file.js'; + +// --------------------------------------------------------------------------- +// extractCommitSha +// --------------------------------------------------------------------------- + +describe('extractCommitSha', () => { + it('should extract SHA from standard tarball directory name', () => { + // Given: tarball directory name in {owner}-{repo}-{sha} format + const dirName = 'nrslib-takt-fullstack-abc1234def5678'; + + // When: SHA is extracted + const sha = extractCommitSha(dirName); + + // Then: last segment (the SHA) is returned + expect(sha).toBe('abc1234def5678'); + }); + + it('should extract SHA when repo name contains hyphens', () => { + // Given: repo name has multiple hyphens + const dirName = 'nrslib-takt-security-facets-deadbeef1234'; + + // When: SHA is extracted + const sha = extractCommitSha(dirName); + + // Then: the last segment is the SHA + expect(sha).toBe('deadbeef1234'); + }); + + it('should extract SHA when owner is a single word', () => { + // Given: simple owner and repo + const dirName = 'owner-repo-0123456789abcdef'; + + // When: SHA is extracted + const sha = extractCommitSha(dirName); + + // Then: last segment is returned + expect(sha).toBe('0123456789abcdef'); + }); +}); + +// --------------------------------------------------------------------------- +// generateLockFile +// --------------------------------------------------------------------------- + +describe('generateLockFile', () => { + it('should produce lock file with all required fields', () => { + // Given: all parameters provided + const params = { + source: 'github:nrslib/takt-fullstack', + ref: 'v1.2.0', + commitSha: 'abc1234def5678', + importedAt: new Date('2026-02-20T12:00:00Z'), + }; + + // When: lock file is generated + const lock = generateLockFile(params); + + // Then: all fields are present + expect(lock.source).toBe('github:nrslib/takt-fullstack'); + expect(lock.ref).toBe('v1.2.0'); + expect(lock.commit).toBe('abc1234def5678'); + expect(lock.imported_at).toBe('2026-02-20T12:00:00.000Z'); + }); + + it('should default ref to "HEAD" when ref is undefined', () => { + // Given: ref not specified + const params = { + source: 'github:nrslib/takt-security-facets', + ref: undefined, + commitSha: 'deadbeef1234', + importedAt: new Date('2026-01-01T00:00:00Z'), + }; + + // When: lock file is generated + const lock = generateLockFile(params); + + // Then: ref is "HEAD" + expect(lock.ref).toBe('HEAD'); + }); + + it('should record the exact commit SHA from the tarball directory', () => { + // Given: SHA from tarball extraction + const sha = '9f8e7d6c5b4a'; + const params = { + source: 'github:someone/dotfiles', + ref: undefined, + commitSha: sha, + importedAt: new Date(), + }; + + // When: lock file is generated + const lock = generateLockFile(params); + + // Then: commit SHA matches + expect(lock.commit).toBe(sha); + }); +}); + +// --------------------------------------------------------------------------- +// parseLockFile +// --------------------------------------------------------------------------- + +describe('parseLockFile', () => { + it('should parse a valid .takt-repertoire-lock.yaml string', () => { + // Given: lock file YAML content + const yaml = `source: github:nrslib/takt-fullstack +ref: v1.2.0 +commit: abc1234def5678 +imported_at: 2026-02-20T12:00:00.000Z +`; + + // When: parsed + const lock = parseLockFile(yaml); + + // Then: all fields are present + expect(lock.source).toBe('github:nrslib/takt-fullstack'); + expect(lock.ref).toBe('v1.2.0'); + expect(lock.commit).toBe('abc1234def5678'); + expect(lock.imported_at).toBe('2026-02-20T12:00:00.000Z'); + }); + + it('should parse lock file with HEAD ref', () => { + // Given: lock file with HEAD ref (no tag specified at import) + const yaml = `source: github:acme-corp/takt-backend +ref: HEAD +commit: 789abcdef0123 +imported_at: 2026-01-15T08:30:00.000Z +`; + + // When: parsed + const lock = parseLockFile(yaml); + + // Then: ref is "HEAD" + expect(lock.ref).toBe('HEAD'); + expect(lock.commit).toBe('789abcdef0123'); + }); + + it('should return empty-valued lock without crashing when yaml is empty string', () => { + // Given: empty yaml (lock file absent - existsSync guard fell through to '') + // yaml.parse('') returns null, which must not cause TypeError + + // When: parsed + const lock = parseLockFile(''); + + // Then: returns defaults without throwing + expect(lock.source).toBe(''); + expect(lock.ref).toBe('HEAD'); + expect(lock.commit).toBe(''); + expect(lock.imported_at).toBe(''); + }); +}); diff --git a/src/__tests__/repertoire/pack-summary.test.ts b/src/__tests__/repertoire/pack-summary.test.ts new file mode 100644 index 0000000..a4eae49 --- /dev/null +++ b/src/__tests__/repertoire/pack-summary.test.ts @@ -0,0 +1,328 @@ +/** + * Unit tests for pack-summary utility functions. + * + * Covers: + * - summarizeFacetsByType: counting facets by type from relative paths + * - detectEditPieces: detecting pieces with edit: true movements + * - formatEditPieceWarnings: formatting warning lines per EditPieceInfo + */ + +import { describe, it, expect } from 'vitest'; +import { summarizeFacetsByType, detectEditPieces, formatEditPieceWarnings } from '../../features/repertoire/pack-summary.js'; + +// --------------------------------------------------------------------------- +// summarizeFacetsByType +// --------------------------------------------------------------------------- + +describe('summarizeFacetsByType', () => { + it('should return "0" for an empty list', () => { + expect(summarizeFacetsByType([])).toBe('0'); + }); + + it('should count single type correctly', () => { + const paths = [ + 'facets/personas/coder.md', + 'facets/personas/reviewer.md', + ]; + expect(summarizeFacetsByType(paths)).toBe('2 personas'); + }); + + it('should count multiple types and join with commas', () => { + const paths = [ + 'facets/personas/coder.md', + 'facets/personas/reviewer.md', + 'facets/policies/coding.md', + 'facets/knowledge/typescript.md', + 'facets/knowledge/react.md', + ]; + const result = summarizeFacetsByType(paths); + // Order depends on insertion order; check all types are present + expect(result).toContain('2 personas'); + expect(result).toContain('1 policies'); + expect(result).toContain('2 knowledge'); + }); + + it('should skip paths that do not have at least 2 segments', () => { + const paths = ['facets/', 'facets/personas/coder.md']; + expect(summarizeFacetsByType(paths)).toBe('1 personas'); + }); + + it('should skip paths where second segment is empty', () => { + // 'facets//coder.md' splits to ['facets', '', 'coder.md'] + const paths = ['facets//coder.md', 'facets/personas/coder.md']; + expect(summarizeFacetsByType(paths)).toBe('1 personas'); + }); +}); + +// --------------------------------------------------------------------------- +// detectEditPieces +// --------------------------------------------------------------------------- + +describe('detectEditPieces', () => { + it('should return empty array for empty input', () => { + expect(detectEditPieces([])).toEqual([]); + }); + + it('should return empty array when piece has edit: false, no allowed_tools, and no required_permission_mode', () => { + const pieces = [ + { name: 'simple.yaml', content: 'movements:\n - name: run\n edit: false\n' }, + ]; + expect(detectEditPieces(pieces)).toEqual([]); + }); + + it('should detect piece with edit: true and collect allowed_tools', () => { + const content = ` +movements: + - name: implement + edit: true + allowed_tools: [Bash, Write, Edit] +`.trim(); + const result = detectEditPieces([{ name: 'coder.yaml', content }]); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe('coder.yaml'); + expect(result[0]!.allowedTools).toEqual(expect.arrayContaining(['Bash', 'Write', 'Edit'])); + expect(result[0]!.allowedTools).toHaveLength(3); + }); + + it('should merge allowed_tools from multiple edit movements', () => { + const content = ` +movements: + - name: implement + edit: true + allowed_tools: [Bash, Write] + - name: fix + edit: true + allowed_tools: [Edit, Bash] +`.trim(); + const result = detectEditPieces([{ name: 'coder.yaml', content }]); + expect(result).toHaveLength(1); + expect(result[0]!.allowedTools).toEqual(expect.arrayContaining(['Bash', 'Write', 'Edit'])); + expect(result[0]!.allowedTools).toHaveLength(3); + }); + + it('should detect piece with edit: true and no allowed_tools', () => { + const content = ` +movements: + - name: implement + edit: true +`.trim(); + const result = detectEditPieces([{ name: 'coder.yaml', content }]); + expect(result).toHaveLength(1); + expect(result[0]!.allowedTools).toEqual([]); + }); + + it('should skip pieces with invalid YAML silently', () => { + const pieces = [ + { name: 'invalid.yaml', content: ': bad: yaml: [[[' }, + { + name: 'valid.yaml', + content: 'movements:\n - name: run\n edit: true\n', + }, + ]; + const result = detectEditPieces(pieces); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe('valid.yaml'); + }); + + it('should skip piece that has no movements field', () => { + const pieces = [{ name: 'empty.yaml', content: 'description: no movements' }]; + expect(detectEditPieces(pieces)).toEqual([]); + }); + + it('should return multiple results when multiple pieces have edit: true', () => { + const pieces = [ + { + name: 'coder.yaml', + content: 'movements:\n - name: impl\n edit: true\n allowed_tools: [Write]\n', + }, + { + name: 'reviewer.yaml', + content: 'movements:\n - name: review\n edit: false\n', + }, + { + name: 'fixer.yaml', + content: 'movements:\n - name: fix\n edit: true\n allowed_tools: [Edit]\n', + }, + ]; + const result = detectEditPieces(pieces); + expect(result).toHaveLength(2); + expect(result.map(r => r.name)).toEqual(expect.arrayContaining(['coder.yaml', 'fixer.yaml'])); + }); + + it('should set hasEdit: true for pieces with edit: true', () => { + const content = 'movements:\n - name: impl\n edit: true\n allowed_tools: [Write]\n'; + const result = detectEditPieces([{ name: 'coder.yaml', content }]); + expect(result).toHaveLength(1); + expect(result[0]!.hasEdit).toBe(true); + expect(result[0]!.requiredPermissionModes).toEqual([]); + }); + + it('should detect required_permission_mode and set hasEdit: false when no edit: true', () => { + const content = ` +movements: + - name: plan + required_permission_mode: bypassPermissions +`.trim(); + const result = detectEditPieces([{ name: 'planner.yaml', content }]); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe('planner.yaml'); + expect(result[0]!.hasEdit).toBe(false); + expect(result[0]!.requiredPermissionModes).toEqual(['bypassPermissions']); + expect(result[0]!.allowedTools).toEqual([]); + }); + + it('should detect both edit: true and required_permission_mode in same piece', () => { + const content = ` +movements: + - name: implement + edit: true + allowed_tools: [Write, Edit] + - name: plan + required_permission_mode: bypassPermissions +`.trim(); + const result = detectEditPieces([{ name: 'mixed.yaml', content }]); + expect(result).toHaveLength(1); + expect(result[0]!.hasEdit).toBe(true); + expect(result[0]!.allowedTools).toEqual(expect.arrayContaining(['Write', 'Edit'])); + expect(result[0]!.requiredPermissionModes).toEqual(['bypassPermissions']); + }); + + it('should deduplicate required_permission_mode values across movements', () => { + const content = ` +movements: + - name: plan + required_permission_mode: bypassPermissions + - name: execute + required_permission_mode: bypassPermissions +`.trim(); + const result = detectEditPieces([{ name: 'dup.yaml', content }]); + expect(result).toHaveLength(1); + expect(result[0]!.requiredPermissionModes).toEqual(['bypassPermissions']); + }); + + it('should return empty array when piece has edit: false, empty allowed_tools, and no required_permission_mode', () => { + const content = 'movements:\n - name: review\n edit: false\n'; + expect(detectEditPieces([{ name: 'reviewer.yaml', content }])).toEqual([]); + }); + + it('should detect piece with edit: false and non-empty allowed_tools', () => { + const content = ` +movements: + - name: run + edit: false + allowed_tools: [Bash] +`.trim(); + const result = detectEditPieces([{ name: 'runner.yaml', content }]); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe('runner.yaml'); + expect(result[0]!.hasEdit).toBe(false); + expect(result[0]!.allowedTools).toEqual(['Bash']); + expect(result[0]!.requiredPermissionModes).toEqual([]); + }); + + it('should exclude piece with edit: false and empty allowed_tools', () => { + const content = ` +movements: + - name: run + edit: false + allowed_tools: [] +`.trim(); + expect(detectEditPieces([{ name: 'runner.yaml', content }])).toEqual([]); + }); + + it('should detect piece with edit: false and required_permission_mode set', () => { + const content = ` +movements: + - name: plan + edit: false + required_permission_mode: bypassPermissions +`.trim(); + const result = detectEditPieces([{ name: 'planner.yaml', content }]); + expect(result).toHaveLength(1); + expect(result[0]!.hasEdit).toBe(false); + expect(result[0]!.requiredPermissionModes).toEqual(['bypassPermissions']); + expect(result[0]!.allowedTools).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// formatEditPieceWarnings +// --------------------------------------------------------------------------- + +describe('formatEditPieceWarnings', () => { + it('should format edit:true warning without allowed_tools', () => { + const warnings = formatEditPieceWarnings({ + name: 'piece.yaml', + hasEdit: true, + allowedTools: [], + requiredPermissionModes: [], + }); + expect(warnings).toEqual(['\n ⚠ piece.yaml: edit: true']); + }); + + it('should format edit:true warning with allowed_tools appended inline', () => { + const warnings = formatEditPieceWarnings({ + name: 'piece.yaml', + hasEdit: true, + allowedTools: ['Bash', 'Edit'], + requiredPermissionModes: [], + }); + expect(warnings).toEqual(['\n ⚠ piece.yaml: edit: true, allowed_tools: [Bash, Edit]']); + }); + + it('should format allowed_tools-only warning when edit:false', () => { + const warnings = formatEditPieceWarnings({ + name: 'runner.yaml', + hasEdit: false, + allowedTools: ['Bash'], + requiredPermissionModes: [], + }); + expect(warnings).toEqual(['\n ⚠ runner.yaml: allowed_tools: [Bash]']); + }); + + it('should return empty array when edit:false and no allowed_tools and no required_permission_mode', () => { + const warnings = formatEditPieceWarnings({ + name: 'review.yaml', + hasEdit: false, + allowedTools: [], + requiredPermissionModes: [], + }); + expect(warnings).toEqual([]); + }); + + it('should format required_permission_mode warnings', () => { + const warnings = formatEditPieceWarnings({ + name: 'planner.yaml', + hasEdit: false, + allowedTools: [], + requiredPermissionModes: ['bypassPermissions'], + }); + expect(warnings).toEqual(['\n ⚠ planner.yaml: required_permission_mode: bypassPermissions']); + }); + + it('should combine allowed_tools and required_permission_mode warnings when edit:false', () => { + const warnings = formatEditPieceWarnings({ + name: 'combo.yaml', + hasEdit: false, + allowedTools: ['Bash'], + requiredPermissionModes: ['bypassPermissions'], + }); + expect(warnings).toEqual([ + '\n ⚠ combo.yaml: allowed_tools: [Bash]', + '\n ⚠ combo.yaml: required_permission_mode: bypassPermissions', + ]); + }); + + it('should format both edit:true and required_permission_mode warnings', () => { + const warnings = formatEditPieceWarnings({ + name: 'mixed.yaml', + hasEdit: true, + allowedTools: [], + requiredPermissionModes: ['bypassPermissions'], + }); + expect(warnings).toEqual([ + '\n ⚠ mixed.yaml: edit: true', + '\n ⚠ mixed.yaml: required_permission_mode: bypassPermissions', + ]); + }); +}); diff --git a/src/__tests__/repertoire/package-facet-resolution.test.ts b/src/__tests__/repertoire/package-facet-resolution.test.ts new file mode 100644 index 0000000..9ed9c3f --- /dev/null +++ b/src/__tests__/repertoire/package-facet-resolution.test.ts @@ -0,0 +1,219 @@ +/** + * Tests for package-local facet resolution chain. + * + * Covers: + * - isPackagePiece(): detects if pieceDir is under ~/.takt/repertoire/@owner/repo/pieces/ + * - getPackageFromPieceDir(): extracts @owner/repo from pieceDir path + * - Package pieces use 4-layer chain: package-local → project → user → builtin + * - Non-package pieces use 3-layer chain: project → user → builtin + * - Package-local resolution hits before project-level + */ + +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 { + isPackagePiece, + getPackageFromPieceDir, + buildCandidateDirsWithPackage, +} from '../../infra/config/loaders/resource-resolver.js'; + +// --------------------------------------------------------------------------- +// isPackagePiece +// --------------------------------------------------------------------------- + +describe('isPackagePiece', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-pkg-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should return true when pieceDir is under repertoire/@owner/repo/pieces/', () => { + // Given: pieceDir under the repertoire directory structure + const repertoireDir = join(tempDir, 'repertoire'); + const pieceDir = join(repertoireDir, '@nrslib', 'takt-fullstack', 'pieces'); + + // When: checking if it is a package piece + const result = isPackagePiece(pieceDir, repertoireDir); + + // Then: it is recognized as a package piece + expect(result).toBe(true); + }); + + it('should return false when pieceDir is under user global pieces directory', () => { + // Given: pieceDir in ~/.takt/pieces/ (not repertoire) + const globalPiecesDir = join(tempDir, 'pieces'); + mkdirSync(globalPiecesDir, { recursive: true }); + + const repertoireDir = join(tempDir, 'repertoire'); + + // When: checking + const result = isPackagePiece(globalPiecesDir, repertoireDir); + + // Then: not a package piece + expect(result).toBe(false); + }); + + it('should return false when pieceDir is in project .takt/pieces/', () => { + // Given: project-level pieces directory + const projectPiecesDir = join(tempDir, '.takt', 'pieces'); + mkdirSync(projectPiecesDir, { recursive: true }); + + const repertoireDir = join(tempDir, 'repertoire'); + + // When: checking + const result = isPackagePiece(projectPiecesDir, repertoireDir); + + // Then: not a package piece + expect(result).toBe(false); + }); + + it('should return false when pieceDir is in builtin directory', () => { + // Given: builtin pieces directory + const builtinPiecesDir = join(tempDir, 'builtins', 'ja', 'pieces'); + mkdirSync(builtinPiecesDir, { recursive: true }); + + const repertoireDir = join(tempDir, 'repertoire'); + + // When: checking + const result = isPackagePiece(builtinPiecesDir, repertoireDir); + + // Then: not a package piece + expect(result).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// getPackageFromPieceDir +// --------------------------------------------------------------------------- + +describe('getPackageFromPieceDir', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-getpkg-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should extract owner and repo from repertoire pieceDir', () => { + // Given: pieceDir under repertoire + const repertoireDir = join(tempDir, 'repertoire'); + const pieceDir = join(repertoireDir, '@nrslib', 'takt-fullstack', 'pieces'); + + // When: package is extracted + const pkg = getPackageFromPieceDir(pieceDir, repertoireDir); + + // Then: owner and repo are correct + expect(pkg).not.toBeUndefined(); + expect(pkg!.owner).toBe('nrslib'); + expect(pkg!.repo).toBe('takt-fullstack'); + }); + + it('should return undefined for non-package pieceDir', () => { + // Given: pieceDir not under repertoire + const pieceDir = join(tempDir, 'pieces'); + const repertoireDir = join(tempDir, 'repertoire'); + + // When: package is extracted + const pkg = getPackageFromPieceDir(pieceDir, repertoireDir); + + // Then: undefined (not a package piece) + expect(pkg).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// buildCandidateDirsWithPackage +// --------------------------------------------------------------------------- + +describe('buildCandidateDirsWithPackage', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-candidates-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should include package-local dir as first candidate for package piece', () => { + // Given: a package piece context + const repertoireDir = join(tempDir, 'repertoire'); + const pieceDir = join(repertoireDir, '@nrslib', 'takt-fullstack', 'pieces'); + const projectDir = join(tempDir, 'project'); + const context = { projectDir, lang: 'ja' as const, pieceDir, repertoireDir }; + + // When: candidate directories are built + const dirs = buildCandidateDirsWithPackage('personas', context); + + // Then: package-local dir is first + const expectedPackageLocal = join(repertoireDir, '@nrslib', 'takt-fullstack', 'facets', 'personas'); + expect(dirs[0]).toBe(expectedPackageLocal); + }); + + it('should have 4 candidate dirs for package piece: package-local, project, user, builtin', () => { + // Given: package piece context + const repertoireDir = join(tempDir, 'repertoire'); + const pieceDir = join(repertoireDir, '@nrslib', 'takt-fullstack', 'pieces'); + const projectDir = join(tempDir, 'project'); + const context = { projectDir, lang: 'ja' as const, pieceDir, repertoireDir }; + + // When: candidate directories are built + const dirs = buildCandidateDirsWithPackage('personas', context); + + // Then: 4 layers (package-local, project, user, builtin) + expect(dirs).toHaveLength(4); + }); + + it('should have 3 candidate dirs for non-package piece: project, user, builtin', () => { + // Given: non-package piece context (no repertoire path) + const projectDir = join(tempDir, 'project'); + const userPiecesDir = join(tempDir, 'pieces'); + const context = { + projectDir, + lang: 'ja' as const, + pieceDir: userPiecesDir, + repertoireDir: join(tempDir, 'repertoire'), + }; + + // When: candidate directories are built + const dirs = buildCandidateDirsWithPackage('personas', context); + + // Then: 3 layers (project, user, builtin) + expect(dirs).toHaveLength(3); + }); + + it('should resolve package-local facet before project-level for package piece', () => { + // Given: both package-local and project-level facet files exist + const repertoireDir = join(tempDir, 'repertoire'); + const pkgFacetDir = join(repertoireDir, '@nrslib', 'takt-fullstack', 'facets', 'personas'); + mkdirSync(pkgFacetDir, { recursive: true }); + writeFileSync(join(pkgFacetDir, 'expert-coder.md'), 'Package persona'); + + const projectDir = join(tempDir, 'project'); + const projectFacetDir = join(projectDir, '.takt', 'facets', 'personas'); + mkdirSync(projectFacetDir, { recursive: true }); + writeFileSync(join(projectFacetDir, 'expert-coder.md'), 'Project persona'); + + const pieceDir = join(repertoireDir, '@nrslib', 'takt-fullstack', 'pieces'); + const context = { projectDir, lang: 'ja' as const, pieceDir, repertoireDir }; + + // When: candidate directories are built + const dirs = buildCandidateDirsWithPackage('personas', context); + + // Then: package-local dir comes before project dir + const pkgLocalIdx = dirs.indexOf(pkgFacetDir); + const projectIdx = dirs.indexOf(projectFacetDir); + expect(pkgLocalIdx).toBeLessThan(projectIdx); + }); +}); diff --git a/src/__tests__/repertoire/remove-reference-check.test.ts b/src/__tests__/repertoire/remove-reference-check.test.ts new file mode 100644 index 0000000..0b43bff --- /dev/null +++ b/src/__tests__/repertoire/remove-reference-check.test.ts @@ -0,0 +1,65 @@ +/** + * Tests for reference integrity check during repertoire remove. + * + * Covers: + * - shouldRemoveOwnerDir(): returns true when owner dir has no other packages + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { shouldRemoveOwnerDir } from '../../features/repertoire/remove.js'; + +// --------------------------------------------------------------------------- +// shouldRemoveOwnerDir +// --------------------------------------------------------------------------- + +describe('shouldRemoveOwnerDir', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-owner-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should return true when owner dir has no other packages', () => { + // Given: owner dir with only one package (the one being removed) + const ownerDir = join(tempDir, '@nrslib'); + mkdirSync(join(ownerDir, 'takt-fullstack'), { recursive: true }); + + // When: checking if owner dir should be removed (after deleting takt-fullstack) + const result = shouldRemoveOwnerDir(ownerDir, 'takt-fullstack'); + + // Then: owner dir can be removed (no other packages) + expect(result).toBe(true); + }); + + it('should return false when owner dir has other packages', () => { + // Given: owner dir with two packages + const ownerDir = join(tempDir, '@nrslib'); + mkdirSync(join(ownerDir, 'takt-fullstack'), { recursive: true }); + mkdirSync(join(ownerDir, 'takt-security-facets'), { recursive: true }); + + // When: checking if owner dir should be removed (after deleting takt-fullstack) + const result = shouldRemoveOwnerDir(ownerDir, 'takt-fullstack'); + + // Then: owner dir should NOT be removed (other package remains) + expect(result).toBe(false); + }); + + it('should return true when owner dir is empty after removal', () => { + // Given: owner dir with just the target package + const ownerDir = join(tempDir, '@acme-corp'); + mkdirSync(join(ownerDir, 'takt-backend'), { recursive: true }); + + // When: checking for acme-corp owner dir + const result = shouldRemoveOwnerDir(ownerDir, 'takt-backend'); + + // Then: empty → can be removed + expect(result).toBe(true); + }); +}); diff --git a/src/__tests__/repertoire/remove.test.ts b/src/__tests__/repertoire/remove.test.ts new file mode 100644 index 0000000..0690672 --- /dev/null +++ b/src/__tests__/repertoire/remove.test.ts @@ -0,0 +1,118 @@ +/** + * Regression test for repertoireRemoveCommand scan configuration. + * + * Verifies that findScopeReferences is called with exactly the 3 spec-defined + * scan locations: + * 1. ~/.takt/pieces (global pieces dir) + * 2. .takt/pieces (project pieces dir) + * 3. ~/.takt/preferences/piece-categories.yaml (categories file) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { join } from 'node:path'; + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- + +vi.mock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + rmSync: vi.fn(), +})); + +vi.mock('../../features/repertoire/remove.js', () => ({ + findScopeReferences: vi.fn().mockReturnValue([]), + shouldRemoveOwnerDir: vi.fn().mockReturnValue(false), +})); + +vi.mock('../../infra/config/paths.js', () => ({ + getRepertoireDir: vi.fn().mockReturnValue('/home/user/.takt/repertoire'), + getRepertoirePackageDir: vi.fn().mockReturnValue('/home/user/.takt/repertoire/@owner/repo'), + getGlobalConfigDir: vi.fn().mockReturnValue('/home/user/.takt'), + getGlobalPiecesDir: vi.fn().mockReturnValue('/home/user/.takt/pieces'), + getProjectPiecesDir: vi.fn().mockReturnValue('/project/.takt/pieces'), +})); + +vi.mock('../../shared/prompt/index.js', () => ({ + confirm: vi.fn().mockResolvedValue(false), +})); + +vi.mock('../../shared/ui/index.js', () => ({ + info: vi.fn(), + success: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Import after mocks are declared +// --------------------------------------------------------------------------- + +import { repertoireRemoveCommand } from '../../commands/repertoire/remove.js'; +import { findScopeReferences } from '../../features/repertoire/remove.js'; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('repertoireRemoveCommand — scan configuration', () => { + beforeEach(() => { + vi.mocked(findScopeReferences).mockReturnValue([]); + }); + + it('should call findScopeReferences with exactly 2 piecesDirs and 1 categoriesFile', async () => { + // When: remove command is invoked (confirm returns false → no deletion) + await repertoireRemoveCommand('@owner/repo'); + + // Then: findScopeReferences is called once + expect(findScopeReferences).toHaveBeenCalledOnce(); + + const [, scanConfig] = vi.mocked(findScopeReferences).mock.calls[0]!; + + // Then: exactly 2 piece directories + expect(scanConfig.piecesDirs).toHaveLength(2); + + // Then: exactly 1 categories file + expect(scanConfig.categoriesFiles).toHaveLength(1); + }); + + it('should include global pieces dir in scan', async () => { + // When: remove command is invoked + await repertoireRemoveCommand('@owner/repo'); + + const [, scanConfig] = vi.mocked(findScopeReferences).mock.calls[0]!; + + // Then: global pieces dir is in the scan list + expect(scanConfig.piecesDirs).toContain('/home/user/.takt/pieces'); + }); + + it('should include project pieces dir in scan', async () => { + // When: remove command is invoked + await repertoireRemoveCommand('@owner/repo'); + + const [, scanConfig] = vi.mocked(findScopeReferences).mock.calls[0]!; + + // Then: project pieces dir is in the scan list + expect(scanConfig.piecesDirs).toContain('/project/.takt/pieces'); + }); + + it('should include preferences/piece-categories.yaml in categoriesFiles', async () => { + // When: remove command is invoked + await repertoireRemoveCommand('@owner/repo'); + + const [, scanConfig] = vi.mocked(findScopeReferences).mock.calls[0]!; + + // Then: the categories file path is correct + expect(scanConfig.categoriesFiles).toContain( + join('/home/user/.takt', 'preferences', 'piece-categories.yaml'), + ); + }); + + it('should pass the scope as the first argument to findScopeReferences', async () => { + // When: remove command is invoked with a scope + await repertoireRemoveCommand('@owner/repo'); + + const [scope] = vi.mocked(findScopeReferences).mock.calls[0]!; + + // Then: scope is passed correctly + expect(scope).toBe('@owner/repo'); + }); +}); diff --git a/src/__tests__/repertoire/repertoire-paths.test.ts b/src/__tests__/repertoire/repertoire-paths.test.ts new file mode 100644 index 0000000..cd86caf --- /dev/null +++ b/src/__tests__/repertoire/repertoire-paths.test.ts @@ -0,0 +1,220 @@ +/** + * Tests for facet directory path helpers in paths.ts — items 42–45. + * + * Verifies the `facets/` segment is present in all facet path results, + * and that getRepertoireFacetDir constructs the correct full repertoire path. + */ + +import { describe, it, expect } from 'vitest'; +import { + getProjectFacetDir, + getGlobalFacetDir, + getBuiltinFacetDir, + getRepertoireFacetDir, + getRepertoirePackageDir, + type FacetType, +} from '../../infra/config/paths.js'; + +const ALL_FACET_TYPES: FacetType[] = ['personas', 'policies', 'knowledge', 'instructions', 'output-contracts']; + +// --------------------------------------------------------------------------- +// getProjectFacetDir — item 42 +// --------------------------------------------------------------------------- + +describe('getProjectFacetDir — facets/ prefix', () => { + it('should include "facets" segment in the path', () => { + // Given: project dir and facet type + // When: path is built + const dir = getProjectFacetDir('/my/project', 'personas'); + + // Then: path must contain the faceted segment + const normalized = dir.replace(/\\/g, '/'); + expect(normalized).toContain('facets'); + }); + + it('should return .takt/facets/{type} structure', () => { + // Given: project dir + // When: path is built + const dir = getProjectFacetDir('/my/project', 'personas'); + + // Then: segment order is .takt → facets → personas + const normalized = dir.replace(/\\/g, '/'); + expect(normalized).toMatch(/\.takt\/facets\/personas/); + }); + + it('should work for all facet types with facets/ prefix', () => { + // Given: all valid facet types + for (const t of ALL_FACET_TYPES) { + // When: path is built + const dir = getProjectFacetDir('/proj', t); + + // Then: contains both faceted and the type in the correct order + const normalized = dir.replace(/\\/g, '/'); + expect(normalized).toMatch(new RegExp(`\\.takt/facets/${t}`)); + } + }); +}); + +// --------------------------------------------------------------------------- +// getGlobalFacetDir — item 43 +// --------------------------------------------------------------------------- + +describe('getGlobalFacetDir — facets/ prefix', () => { + it('should include "facets" segment in the path', () => { + // Given: facet type + // When: path is built + const dir = getGlobalFacetDir('policies'); + + // Then: path must contain the faceted segment + expect(dir).toContain('facets'); + }); + + it('should return .takt/facets/{type} structure under global config dir', () => { + // Given: facet type + // When: path is built + const dir = getGlobalFacetDir('policies'); + + // Then: segment order is .takt → facets → policies + const normalized = dir.replace(/\\/g, '/'); + expect(normalized).toMatch(/\.takt\/facets\/policies/); + }); + + it('should work for all facet types with facets/ prefix', () => { + // Given: all valid facet types + for (const t of ALL_FACET_TYPES) { + // When: path is built + const dir = getGlobalFacetDir(t); + + // Then: contains both faceted and the type in the correct order + const normalized = dir.replace(/\\/g, '/'); + expect(normalized).toMatch(new RegExp(`\\.takt/facets/${t}`)); + } + }); +}); + +// --------------------------------------------------------------------------- +// getBuiltinFacetDir — item 44 +// --------------------------------------------------------------------------- + +describe('getBuiltinFacetDir — facets/ prefix', () => { + it('should include "facets" segment in the path', () => { + // Given: language and facet type + // When: path is built + const dir = getBuiltinFacetDir('ja', 'knowledge'); + + // Then: path must contain the faceted segment + expect(dir).toContain('facets'); + }); + + it('should return {lang}/facets/{type} structure', () => { + // Given: language and facet type + // When: path is built + const dir = getBuiltinFacetDir('ja', 'knowledge'); + + // Then: segment order is ja → facets → knowledge + const normalized = dir.replace(/\\/g, '/'); + expect(normalized).toMatch(/ja\/facets\/knowledge/); + }); + + it('should work for all facet types with facets/ prefix', () => { + // Given: all valid facet types + for (const t of ALL_FACET_TYPES) { + // When: path is built + const dir = getBuiltinFacetDir('en', t); + + // Then: contains both faceted and the type in the correct order + const normalized = dir.replace(/\\/g, '/'); + expect(normalized).toMatch(new RegExp(`en/facets/${t}`)); + } + }); +}); + +// --------------------------------------------------------------------------- +// getRepertoireFacetDir — item 45 (new function) +// --------------------------------------------------------------------------- + +describe('getRepertoireFacetDir — new path function', () => { + it('should return path containing repertoire/@{owner}/{repo}/facets/{type}', () => { + // Given: owner, repo, and facet type + // When: path is built + const dir = getRepertoireFacetDir('nrslib', 'takt-fullstack', 'personas'); + + // Then: all segments are present + const normalized = dir.replace(/\\/g, '/'); + expect(normalized).toContain('repertoire'); + expect(normalized).toContain('@nrslib'); + expect(normalized).toContain('takt-fullstack'); + expect(normalized).toContain('facets'); + expect(normalized).toContain('personas'); + }); + + it('should construct path as ~/.takt/repertoire/@{owner}/{repo}/facets/{type}', () => { + // Given: owner, repo, and facet type + // When: path is built + const dir = getRepertoireFacetDir('nrslib', 'takt-fullstack', 'personas'); + + // Then: full segment order is repertoire → @nrslib → takt-fullstack → facets → personas + const normalized = dir.replace(/\\/g, '/'); + expect(normalized).toMatch(/repertoire\/@nrslib\/takt-fullstack\/facets\/personas/); + }); + + it('should prepend @ before owner name in the path', () => { + // Given: owner without @ prefix + // When: path is built + const dir = getRepertoireFacetDir('myowner', 'myrepo', 'policies'); + + // Then: @ is included before owner in the path + const normalized = dir.replace(/\\/g, '/'); + expect(normalized).toContain('@myowner'); + }); + + it('should work for all facet types', () => { + // Given: all valid facet types + for (const t of ALL_FACET_TYPES) { + // When: path is built + const dir = getRepertoireFacetDir('owner', 'repo', t); + + // Then: path has correct repertoire structure with facet type + const normalized = dir.replace(/\\/g, '/'); + expect(normalized).toMatch(new RegExp(`repertoire/@owner/repo/facets/${t}`)); + } + }); +}); + +// --------------------------------------------------------------------------- +// getRepertoirePackageDir — item 46 +// --------------------------------------------------------------------------- + +describe('getRepertoirePackageDir', () => { + it('should return path containing repertoire/@{owner}/{repo}', () => { + // Given: owner and repo + // When: path is built + const dir = getRepertoirePackageDir('nrslib', 'takt-fullstack'); + + // Then: all segments are present + const normalized = dir.replace(/\\/g, '/'); + expect(normalized).toContain('repertoire'); + expect(normalized).toContain('@nrslib'); + expect(normalized).toContain('takt-fullstack'); + }); + + it('should construct path as ~/.takt/repertoire/@{owner}/{repo}', () => { + // Given: owner and repo + // When: path is built + const dir = getRepertoirePackageDir('nrslib', 'takt-fullstack'); + + // Then: full segment order is repertoire → @nrslib → takt-fullstack + const normalized = dir.replace(/\\/g, '/'); + expect(normalized).toMatch(/repertoire\/@nrslib\/takt-fullstack$/); + }); + + it('should prepend @ before owner name in the path', () => { + // Given: owner without @ prefix + // When: path is built + const dir = getRepertoirePackageDir('myowner', 'myrepo'); + + // Then: @ is included before owner in the path + const normalized = dir.replace(/\\/g, '/'); + expect(normalized).toContain('@myowner'); + }); +}); diff --git a/src/__tests__/repertoire/takt-repertoire-config.test.ts b/src/__tests__/repertoire/takt-repertoire-config.test.ts new file mode 100644 index 0000000..30f5186 --- /dev/null +++ b/src/__tests__/repertoire/takt-repertoire-config.test.ts @@ -0,0 +1,404 @@ +/** + * Tests for takt-repertoire.yaml parsing and validation. + * + * Covers: + * - Full field parsing (description, path, takt.min_version) + * - path field defaults, allowed/disallowed values + * - takt.min_version format validation + * - Version comparison (numeric, not lexicographic) + * - Empty package detection (facets/ and pieces/ presence) + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + parseTaktRepertoireConfig, + validateTaktRepertoirePath, + validateMinVersion, + isVersionCompatible, + checkPackageHasContent, + checkPackageHasContentWithContext, + validateRealpathInsideRoot, + resolveRepertoireConfigPath, +} from '../../features/repertoire/takt-repertoire-config.js'; + +// --------------------------------------------------------------------------- +// parseTaktRepertoireConfig +// --------------------------------------------------------------------------- + +describe('parseTaktRepertoireConfig', () => { + it('should parse all fields when present', () => { + // Given: a complete takt-repertoire.yaml content + const yaml = ` +description: My package +path: takt +takt: + min_version: "0.5.0" +`.trim(); + + // When: parsed + const config = parseTaktRepertoireConfig(yaml); + + // Then: all fields are populated + expect(config.description).toBe('My package'); + expect(config.path).toBe('takt'); + expect(config.takt?.min_version).toBe('0.5.0'); + }); + + it('should default path to "." when omitted', () => { + // Given: takt-repertoire.yaml with no path field + const yaml = `description: No path field`; + + // When: parsed + const config = parseTaktRepertoireConfig(yaml); + + // Then: path defaults to "." + expect(config.path).toBe('.'); + }); + + it('should parse minimal valid config (empty file is valid)', () => { + // Given: empty yaml + const yaml = ''; + + // When: parsed + const config = parseTaktRepertoireConfig(yaml); + + // Then: defaults are applied + expect(config.path).toBe('.'); + expect(config.description).toBeUndefined(); + expect(config.takt).toBeUndefined(); + }); + + it('should parse config with only description', () => { + // Given: config with description only + const yaml = 'description: セキュリティレビュー用ファセット集'; + + // When: parsed + const config = parseTaktRepertoireConfig(yaml); + + // Then: description is set, path defaults to "." + expect(config.description).toBe('セキュリティレビュー用ファセット集'); + expect(config.path).toBe('.'); + }); + + it('should parse path with subdirectory', () => { + // Given: path with nested directory + const yaml = 'path: pkg/takt'; + + // When: parsed + const config = parseTaktRepertoireConfig(yaml); + + // Then: path is preserved as-is + expect(config.path).toBe('pkg/takt'); + }); +}); + +// --------------------------------------------------------------------------- +// validateTaktRepertoirePath +// --------------------------------------------------------------------------- + +describe('validateTaktRepertoirePath', () => { + it('should accept "." (current directory)', () => { + // Given: default path + // When: validated + // Then: no error thrown + expect(() => validateTaktRepertoirePath('.')).not.toThrow(); + }); + + it('should accept simple relative path "takt"', () => { + expect(() => validateTaktRepertoirePath('takt')).not.toThrow(); + }); + + it('should accept nested relative path "pkg/takt"', () => { + expect(() => validateTaktRepertoirePath('pkg/takt')).not.toThrow(); + }); + + it('should reject absolute path starting with "/"', () => { + // Given: absolute path + // When: validated + // Then: throws an error + expect(() => validateTaktRepertoirePath('/etc/passwd')).toThrow(); + }); + + it('should reject path starting with "~"', () => { + // Given: home-relative path + expect(() => validateTaktRepertoirePath('~/takt')).toThrow(); + }); + + it('should reject path containing ".." segment', () => { + // Given: path with directory traversal + expect(() => validateTaktRepertoirePath('../outside')).toThrow(); + }); + + it('should reject path with ".." in middle segment', () => { + // Given: path with ".." embedded + expect(() => validateTaktRepertoirePath('takt/../etc')).toThrow(); + }); + + it('should reject "../../etc" (multiple traversal)', () => { + expect(() => validateTaktRepertoirePath('../../etc')).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// validateMinVersion +// --------------------------------------------------------------------------- + +describe('validateMinVersion', () => { + it('should accept valid SemVer "0.5.0"', () => { + expect(() => validateMinVersion('0.5.0')).not.toThrow(); + }); + + it('should accept "1.0.0"', () => { + expect(() => validateMinVersion('1.0.0')).not.toThrow(); + }); + + it('should accept "10.20.30"', () => { + expect(() => validateMinVersion('10.20.30')).not.toThrow(); + }); + + it('should reject pre-release suffix "1.0.0-alpha"', () => { + // Given: version with pre-release suffix + // When: validated + // Then: throws an error (pre-release not supported) + expect(() => validateMinVersion('1.0.0-alpha')).toThrow(); + }); + + it('should reject "1.0.0-beta.1"', () => { + expect(() => validateMinVersion('1.0.0-beta.1')).toThrow(); + }); + + it('should reject "1.0" (missing patch segment)', () => { + expect(() => validateMinVersion('1.0')).toThrow(); + }); + + it('should reject "one.0.0" (non-numeric segment)', () => { + expect(() => validateMinVersion('one.0.0')).toThrow(); + }); + + it('should reject empty string', () => { + expect(() => validateMinVersion('')).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// isVersionCompatible (numeric comparison) +// --------------------------------------------------------------------------- + +describe('isVersionCompatible', () => { + it('should return true when minVersion equals currentVersion', () => { + // Given: identical versions + // When: compared + // Then: compatible + expect(isVersionCompatible('1.0.0', '1.0.0')).toBe(true); + }); + + it('should return true when currentVersion is greater', () => { + expect(isVersionCompatible('0.5.0', '1.0.0')).toBe(true); + }); + + it('should return false when currentVersion is less than minVersion', () => { + expect(isVersionCompatible('1.0.0', '0.9.0')).toBe(false); + }); + + it('should compare minor version numerically: 1.9.0 < 1.10.0', () => { + // Given: versions that differ in minor only + // When: comparing minVersion=1.10.0 against current=1.9.0 + // Then: 1.9 < 1.10 numerically → not compatible + expect(isVersionCompatible('1.10.0', '1.9.0')).toBe(false); + }); + + it('should return true for minVersion=1.9.0 with current=1.10.0', () => { + // Given: minVersion=1.9.0, current=1.10.0 + // Then: 1.10 > 1.9 numerically → compatible + expect(isVersionCompatible('1.9.0', '1.10.0')).toBe(true); + }); + + it('should compare patch version numerically: 1.0.9 < 1.0.10', () => { + expect(isVersionCompatible('1.0.10', '1.0.9')).toBe(false); + expect(isVersionCompatible('1.0.9', '1.0.10')).toBe(true); + }); + + it('should return false when major is insufficient', () => { + expect(isVersionCompatible('2.0.0', '1.99.99')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// checkPackageHasContent (empty package detection) +// --------------------------------------------------------------------------- + +describe('checkPackageHasContent', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-repertoire-content-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should throw when neither facets/ nor pieces/ exists', () => { + // Given: empty package root directory + // When: content check is performed + // Then: throws an error (empty package not allowed) + expect(() => checkPackageHasContent(tempDir)).toThrow(); + }); + + it('should include manifest/path/hint details in contextual error', () => { + const manifestPath = join(tempDir, '.takt', 'takt-repertoire.yaml'); + expect(() => checkPackageHasContentWithContext(tempDir, { + manifestPath, + configuredPath: '.', + })).toThrow(/path: \.takt/); + }); + + it('should not throw when only facets/ exists', () => { + // Given: package with facets/ only + mkdirSync(join(tempDir, 'facets'), { recursive: true }); + + // When: content check is performed + // Then: no error (facet-only package is valid) + expect(() => checkPackageHasContent(tempDir)).not.toThrow(); + }); + + it('should not throw when only pieces/ exists', () => { + // Given: package with pieces/ only + mkdirSync(join(tempDir, 'pieces'), { recursive: true }); + + // When: content check is performed + // Then: no error (pieces-only package is valid) + expect(() => checkPackageHasContent(tempDir)).not.toThrow(); + }); + + it('should not throw when both facets/ and pieces/ exist', () => { + // Given: package with both directories + mkdirSync(join(tempDir, 'facets'), { recursive: true }); + mkdirSync(join(tempDir, 'pieces'), { recursive: true }); + + // When: content check is performed + // Then: no error + expect(() => checkPackageHasContent(tempDir)).not.toThrow(); + }); + +}); + +// --------------------------------------------------------------------------- +// validateRealpathInsideRoot (symlink-safe path traversal check) +// --------------------------------------------------------------------------- + +describe('validateRealpathInsideRoot', () => { + let tmpRoot: string; + let tmpOther: string; + + beforeEach(() => { + tmpRoot = mkdtempSync(join(tmpdir(), 'takt-realpath-root-')); + tmpOther = mkdtempSync(join(tmpdir(), 'takt-realpath-other-')); + }); + + afterEach(() => { + rmSync(tmpRoot, { recursive: true, force: true }); + rmSync(tmpOther, { recursive: true, force: true }); + }); + + it('should not throw when resolvedPath equals repoRoot', () => { + // Given: path is exactly the root itself + // When / Then: no error (root == root is valid) + expect(() => validateRealpathInsideRoot(tmpRoot, tmpRoot)).not.toThrow(); + }); + + it('should not throw when resolvedPath is a subdirectory inside root', () => { + // Given: a subdirectory inside root + const subdir = join(tmpRoot, 'subdir'); + mkdirSync(subdir); + + // When / Then: no error + expect(() => validateRealpathInsideRoot(subdir, tmpRoot)).not.toThrow(); + }); + + it('should throw when resolvedPath does not exist', () => { + // Given: a path that does not exist on the filesystem + const nonexistent = join(tmpRoot, 'nonexistent'); + + // When / Then: throws because realpathSync fails + expect(() => validateRealpathInsideRoot(nonexistent, tmpRoot)).toThrow(); + }); + + it('should throw when resolvedPath is outside root', () => { + // Given: a real directory that exists but is outside tmpRoot + // When / Then: throws security error + expect(() => validateRealpathInsideRoot(tmpOther, tmpRoot)).toThrow(); + }); + + it('should throw when resolvedPath resolves outside root via symlink', () => { + // Given: a symlink inside root that points to a directory outside root + const symlinkPath = join(tmpRoot, 'escaped-link'); + symlinkSync(tmpOther, symlinkPath); + + // When / Then: realpath resolves the symlink → outside root → throws + expect(() => validateRealpathInsideRoot(symlinkPath, tmpRoot)).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveRepertoireConfigPath (takt-repertoire.yaml search order) +// --------------------------------------------------------------------------- + +describe('resolveRepertoireConfigPath', () => { + let extractDir: string; + + beforeEach(() => { + extractDir = mkdtempSync(join(tmpdir(), 'takt-resolve-pack-')); + }); + + afterEach(() => { + rmSync(extractDir, { recursive: true, force: true }); + }); + + it('should return .takt/takt-repertoire.yaml when only that path exists', () => { + // Given: only .takt/takt-repertoire.yaml exists + const taktDir = join(extractDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync(join(taktDir, 'takt-repertoire.yaml'), 'description: dot-takt'); + + // When: resolved + const result = resolveRepertoireConfigPath(extractDir); + + // Then: .takt/takt-repertoire.yaml is returned + expect(result).toBe(join(extractDir, '.takt', 'takt-repertoire.yaml')); + }); + + it('should return root takt-repertoire.yaml when only that path exists', () => { + // Given: only root takt-repertoire.yaml exists + writeFileSync(join(extractDir, 'takt-repertoire.yaml'), 'description: root'); + + // When: resolved + const result = resolveRepertoireConfigPath(extractDir); + + // Then: root takt-repertoire.yaml is returned + expect(result).toBe(join(extractDir, 'takt-repertoire.yaml')); + }); + + it('should prefer .takt/takt-repertoire.yaml when both paths exist', () => { + // Given: both .takt/takt-repertoire.yaml and root takt-repertoire.yaml exist + const taktDir = join(extractDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync(join(taktDir, 'takt-repertoire.yaml'), 'description: dot-takt'); + writeFileSync(join(extractDir, 'takt-repertoire.yaml'), 'description: root'); + + // When: resolved + const result = resolveRepertoireConfigPath(extractDir); + + // Then: .takt/takt-repertoire.yaml takes precedence + expect(result).toBe(join(extractDir, '.takt', 'takt-repertoire.yaml')); + }); + + it('should throw when neither path exists', () => { + // Given: empty extract directory + // When / Then: throws an error + expect(() => resolveRepertoireConfigPath(extractDir)).toThrow('takt-repertoire.yaml not found in'); + }); +}); diff --git a/src/__tests__/repertoire/tar-parser.test.ts b/src/__tests__/repertoire/tar-parser.test.ts new file mode 100644 index 0000000..017b322 --- /dev/null +++ b/src/__tests__/repertoire/tar-parser.test.ts @@ -0,0 +1,174 @@ +/** + * Unit tests for parseTarVerboseListing in tar-parser.ts. + * + * Covers: + * - firstDirEntry extraction (commit SHA prefix) + * - BSD tar (HH:MM) and GNU tar (HH:MM:SS) timestamp formats + * - Directory entry skipping + * - Symlink entry skipping + * - ALLOWED_EXTENSIONS filtering + * - Empty input + */ + +import { describe, it, expect } from 'vitest'; +import { parseTarVerboseListing } from '../../features/repertoire/tar-parser.js'; + +// --------------------------------------------------------------------------- +// Helpers to build realistic tar verbose lines +// --------------------------------------------------------------------------- + +/** Build a BSD tar verbose line (HH:MM timestamp) */ +function bsdLine(type: string, path: string): string { + return `${type}rwxr-xr-x 0 user group 1234 Jan 1 12:34 ${path}`; +} + +/** Build a GNU tar verbose line (HH:MM:SS timestamp) */ +function gnuLine(type: string, path: string): string { + return `${type}rwxr-xr-x user/group 1234 2024-01-01 12:34:56 ${path}`; +} + +// --------------------------------------------------------------------------- +// parseTarVerboseListing +// --------------------------------------------------------------------------- + +describe('parseTarVerboseListing', () => { + it('should return empty results for an empty line array', () => { + const result = parseTarVerboseListing([]); + expect(result.firstDirEntry).toBe(''); + expect(result.includePaths).toEqual([]); + }); + + it('should extract firstDirEntry from the first directory line (BSD format)', () => { + // Given: first line is a directory entry in BSD tar format + const lines = [ + bsdLine('d', 'owner-repo-abc1234/'), + bsdLine('-', 'owner-repo-abc1234/facets/personas/coder.md'), + ]; + + // When: parsed + const result = parseTarVerboseListing(lines); + + // Then: firstDirEntry has trailing slash stripped + expect(result.firstDirEntry).toBe('owner-repo-abc1234'); + }); + + it('should extract firstDirEntry from the first directory line (GNU format)', () => { + // Given: first line is a directory entry in GNU tar format + const lines = [ + gnuLine('d', 'owner-repo-abc1234/'), + gnuLine('-', 'owner-repo-abc1234/facets/personas/coder.md'), + ]; + + // When: parsed + const result = parseTarVerboseListing(lines); + + // Then: firstDirEntry is set correctly + expect(result.firstDirEntry).toBe('owner-repo-abc1234'); + }); + + it('should include .md files', () => { + // Given: a regular .md file + const lines = [ + bsdLine('d', 'repo-sha/'), + bsdLine('-', 'repo-sha/facets/personas/coder.md'), + ]; + + // When: parsed + const result = parseTarVerboseListing(lines); + + // Then: .md is included + expect(result.includePaths).toContain('repo-sha/facets/personas/coder.md'); + }); + + it('should include .yaml files', () => { + // Given: a regular .yaml file + const lines = [ + bsdLine('d', 'repo-sha/'), + bsdLine('-', 'repo-sha/pieces/coder.yaml'), + ]; + + const result = parseTarVerboseListing(lines); + expect(result.includePaths).toContain('repo-sha/pieces/coder.yaml'); + }); + + it('should include .yml files', () => { + // Given: a regular .yml file + const lines = [ + bsdLine('d', 'repo-sha/'), + bsdLine('-', 'repo-sha/pieces/coder.yml'), + ]; + + const result = parseTarVerboseListing(lines); + expect(result.includePaths).toContain('repo-sha/pieces/coder.yml'); + }); + + it('should exclude files with non-allowed extensions (.ts, .json)', () => { + // Given: lines with non-allowed file types + const lines = [ + bsdLine('d', 'repo-sha/'), + bsdLine('-', 'repo-sha/src/index.ts'), + bsdLine('-', 'repo-sha/package.json'), + bsdLine('-', 'repo-sha/facets/personas/coder.md'), + ]; + + const result = parseTarVerboseListing(lines); + + // Then: only .md is included + expect(result.includePaths).toEqual(['repo-sha/facets/personas/coder.md']); + }); + + it('should skip directory entries (type "d")', () => { + // Given: mix of directory and file entries + const lines = [ + bsdLine('d', 'repo-sha/'), + bsdLine('d', 'repo-sha/facets/'), + bsdLine('-', 'repo-sha/facets/personas/coder.md'), + ]; + + const result = parseTarVerboseListing(lines); + + // Then: directories are not in includePaths + expect(result.includePaths).not.toContain('repo-sha/facets/'); + expect(result.includePaths).toContain('repo-sha/facets/personas/coder.md'); + }); + + it('should skip symlink entries (type "l")', () => { + // Given: a symlink entry (type "l") alongside a normal file + const lines = [ + bsdLine('d', 'repo-sha/'), + bsdLine('l', 'repo-sha/facets/link.md'), + bsdLine('-', 'repo-sha/facets/personas/coder.md'), + ]; + + const result = parseTarVerboseListing(lines); + + // Then: symlink is excluded, normal file is included + expect(result.includePaths).not.toContain('repo-sha/facets/link.md'); + expect(result.includePaths).toContain('repo-sha/facets/personas/coder.md'); + }); + + it('should handle lines that do not match the timestamp regex', () => { + // Given: lines without a recognizable timestamp (should be ignored) + const lines = [ + 'some-garbage-line', + bsdLine('-', 'repo-sha/facets/personas/coder.md'), + ]; + + const result = parseTarVerboseListing(lines); + + // Then: garbage line is skipped, file is included + expect(result.includePaths).toContain('repo-sha/facets/personas/coder.md'); + }); + + it('should set firstDirEntry to empty string when first matching line has no trailing slash', () => { + // Given: first line is a file, not a directory (no trailing slash) + const lines = [ + bsdLine('-', 'repo-sha/README.md'), + ]; + + const result = parseTarVerboseListing(lines); + + // Then: firstDirEntry has no trailing slash stripping needed + expect(result.firstDirEntry).toBe('repo-sha/README.md'); + }); +}); diff --git a/src/__tests__/review-only-piece.test.ts b/src/__tests__/review-only-piece.test.ts index 92ec975..ee741c3 100644 --- a/src/__tests__/review-only-piece.test.ts +++ b/src/__tests__/review-only-piece.test.ts @@ -188,7 +188,7 @@ describe('review-only piece (JA)', () => { describe('pr-commenter persona files', () => { it('should exist for EN with domain knowledge', () => { - const filePath = join(RESOURCES_DIR, 'en', 'personas', 'pr-commenter.md'); + const filePath = join(RESOURCES_DIR, 'en', 'facets', 'personas', 'pr-commenter.md'); const content = readFileSync(filePath, 'utf-8'); expect(content).toContain('PR Commenter'); expect(content).toContain('gh api'); @@ -196,7 +196,7 @@ describe('pr-commenter persona files', () => { }); it('should exist for JA with domain knowledge', () => { - const filePath = join(RESOURCES_DIR, 'ja', 'personas', 'pr-commenter.md'); + const filePath = join(RESOURCES_DIR, 'ja', 'facets', 'personas', 'pr-commenter.md'); const content = readFileSync(filePath, 'utf-8'); expect(content).toContain('PR Commenter'); expect(content).toContain('gh api'); @@ -204,7 +204,7 @@ describe('pr-commenter persona files', () => { }); it('should NOT contain piece-specific report names (EN)', () => { - const filePath = join(RESOURCES_DIR, 'en', 'personas', 'pr-commenter.md'); + const filePath = join(RESOURCES_DIR, 'en', 'facets', 'personas', 'pr-commenter.md'); const content = readFileSync(filePath, 'utf-8'); // Persona should not reference specific review-only piece report files expect(content).not.toContain('01-architect-review.md'); @@ -218,7 +218,7 @@ describe('pr-commenter persona files', () => { }); it('should NOT contain piece-specific report names (JA)', () => { - const filePath = join(RESOURCES_DIR, 'ja', 'personas', 'pr-commenter.md'); + const filePath = join(RESOURCES_DIR, 'ja', 'facets', 'personas', 'pr-commenter.md'); const content = readFileSync(filePath, 'utf-8'); expect(content).not.toContain('01-architect-review.md'); expect(content).not.toContain('02-security-review.md'); diff --git a/src/__tests__/selectAndExecute-autoPr.test.ts b/src/__tests__/selectAndExecute-autoPr.test.ts index 4dd60a7..bae7003 100644 --- a/src/__tests__/selectAndExecute-autoPr.test.ts +++ b/src/__tests__/selectAndExecute-autoPr.test.ts @@ -33,6 +33,7 @@ vi.mock('../infra/config/index.js', () => ({ resolvePieceConfigValue: (...args: unknown[]) => mockResolvePieceConfigValue(...args), listPieces: vi.fn(() => ['default']), listPieceEntries: vi.fn(() => []), + loadPieceByIdentifier: vi.fn((identifier: string) => (identifier === 'default' ? { name: 'default' } : null)), isPiecePath: vi.fn(() => false), })); @@ -86,11 +87,13 @@ vi.mock('../features/pieceSelection/index.js', () => ({ })); import { confirm } from '../shared/prompt/index.js'; +import { loadPieceByIdentifier } from '../infra/config/index.js'; import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../infra/task/index.js'; import { selectPiece } from '../features/pieceSelection/index.js'; import { selectAndExecuteTask, determinePiece } from '../features/tasks/execute/selectAndExecute.js'; const mockConfirm = vi.mocked(confirm); +const mockLoadPieceByIdentifier = vi.mocked(loadPieceByIdentifier); const mockCreateSharedClone = vi.mocked(createSharedClone); const mockAutoCommitAndPush = vi.mocked(autoCommitAndPush); const mockSummarizeTaskName = vi.mocked(summarizeTaskName); @@ -180,6 +183,14 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => { expect(mockSelectPiece).toHaveBeenCalledWith('/project'); }); + it('should accept repertoire scoped piece override when it exists', async () => { + mockLoadPieceByIdentifier.mockReturnValueOnce({ name: '@nrslib/takt-ensembles/critical-thinking' } as never); + + const selected = await determinePiece('/project', '@nrslib/takt-ensembles/critical-thinking'); + + expect(selected).toBe('@nrslib/takt-ensembles/critical-thinking'); + }); + it('should fail task record when executeTask throws', async () => { mockConfirm.mockResolvedValue(true); mockSummarizeTaskName.mockResolvedValue('test-task'); diff --git a/src/__tests__/takt-repertoire-schema.test.ts b/src/__tests__/takt-repertoire-schema.test.ts new file mode 100644 index 0000000..eb6886c --- /dev/null +++ b/src/__tests__/takt-repertoire-schema.test.ts @@ -0,0 +1,79 @@ +/** + * Unit tests for takt-repertoire.yaml schema validation. + * + * Target: src/features/repertoire/takt-repertoire-config.ts + * + * Schema rules under test: + * - description: optional + * - path: optional, defaults to "." + * - takt.min_version: must match /^\d+\.\d+\.\d+$/ (no "v" prefix, no pre-release) + * - path: must not start with "/" or "~" + * - path: must not contain ".." segments + */ + +import { describe, it, expect } from 'vitest'; +import { + parseTaktRepertoireConfig, + validateTaktRepertoirePath, + validateMinVersion, +} from '../features/repertoire/takt-repertoire-config.js'; + +describe('takt-repertoire.yaml schema: description field', () => { + it('should accept schema without description field', () => { + const config = parseTaktRepertoireConfig(''); + expect(config.description).toBeUndefined(); + }); +}); + +describe('takt-repertoire.yaml schema: path field', () => { + it('should default path to "." when not specified', () => { + const config = parseTaktRepertoireConfig(''); + expect(config.path).toBe('.'); + }); + + it('should reject path starting with "/" (absolute path)', () => { + expect(() => validateTaktRepertoirePath('/foo')).toThrow(); + }); + + it('should reject path starting with "~" (tilde-absolute path)', () => { + expect(() => validateTaktRepertoirePath('~/foo')).toThrow(); + }); + + it('should reject path with ".." segment traversing outside repository', () => { + expect(() => validateTaktRepertoirePath('../outside')).toThrow(); + }); + + it('should reject path with embedded ".." segments leading outside repository', () => { + expect(() => validateTaktRepertoirePath('sub/../../../outside')).toThrow(); + }); + + it('should accept valid relative path "sub/dir"', () => { + expect(() => validateTaktRepertoirePath('sub/dir')).not.toThrow(); + }); +}); + +describe('takt-repertoire.yaml schema: takt.min_version field', () => { + it('should accept min_version "0.5.0" (valid semver)', () => { + expect(() => validateMinVersion('0.5.0')).not.toThrow(); + }); + + it('should accept min_version "1.0.0" (valid semver)', () => { + expect(() => validateMinVersion('1.0.0')).not.toThrow(); + }); + + it('should reject min_version "1.0" (missing patch segment)', () => { + expect(() => validateMinVersion('1.0')).toThrow(); + }); + + it('should reject min_version "v1.0.0" (v prefix not allowed)', () => { + expect(() => validateMinVersion('v1.0.0')).toThrow(); + }); + + it('should reject min_version "1.0.0-alpha" (pre-release suffix not allowed)', () => { + expect(() => validateMinVersion('1.0.0-alpha')).toThrow(); + }); + + it('should reject min_version "1.0.0-beta.1" (pre-release suffix not allowed)', () => { + expect(() => validateMinVersion('1.0.0-beta.1')).toThrow(); + }); +}); diff --git a/src/app/cli/commands.ts b/src/app/cli/commands.ts index 50db1e7..187b39f 100644 --- a/src/app/cli/commands.ts +++ b/src/app/cli/commands.ts @@ -15,6 +15,9 @@ import { showCatalog } from '../../features/catalog/index.js'; import { computeReviewMetrics, formatReviewMetrics, parseSinceDuration, purgeOldEvents } from '../../features/analytics/index.js'; import { program, resolvedCwd } from './program.js'; import { resolveAgentOverrides } from './helpers.js'; +import { repertoireAddCommand } from '../../commands/repertoire/add.js'; +import { repertoireRemoveCommand } from '../../commands/repertoire/remove.js'; +import { repertoireListCommand } from '../../commands/repertoire/list.js'; program .command('run') @@ -173,3 +176,30 @@ program success(`Purged ${deleted.length} file(s): ${deleted.join(', ')}`); } }); + +const repertoire = program + .command('repertoire') + .description('Manage repertoire packages'); + +repertoire + .command('add') + .description('Install a repertoire package from GitHub') + .argument('', 'Package spec (e.g. github:{owner}/{repo}@{ref})') + .action(async (spec: string) => { + await repertoireAddCommand(spec); + }); + +repertoire + .command('remove') + .description('Remove an installed repertoire package') + .argument('', 'Package scope (e.g. @{owner}/{repo})') + .action(async (scope: string) => { + await repertoireRemoveCommand(scope); + }); + +repertoire + .command('list') + .description('List installed repertoire packages') + .action(async () => { + await repertoireListCommand(); + }); diff --git a/src/app/cli/index.ts b/src/app/cli/index.ts index 76394a9..eaf6e81 100644 --- a/src/app/cli/index.ts +++ b/src/app/cli/index.ts @@ -7,6 +7,8 @@ */ import { checkForUpdates } from '../../shared/utils/index.js'; +import { getErrorMessage } from '../../shared/utils/error.js'; +import { error as errorLog } from '../../shared/ui/index.js'; checkForUpdates(); @@ -41,6 +43,6 @@ import { executeDefaultAction } from './routing.js'; process.exit(0); } })().catch((err) => { - console.error(err); + errorLog(getErrorMessage(err)); process.exit(1); }); diff --git a/src/commands/repertoire/add.ts b/src/commands/repertoire/add.ts new file mode 100644 index 0000000..29bdf65 --- /dev/null +++ b/src/commands/repertoire/add.ts @@ -0,0 +1,201 @@ +/** + * takt repertoire add — install a repertoire package from GitHub. + * + * Usage: + * takt repertoire add github:{owner}/{repo}@{ref} + * takt repertoire add github:{owner}/{repo} (uses default branch) + */ + +import { mkdirSync, copyFileSync, existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { stringify as stringifyYaml } from 'yaml'; +import { getRepertoirePackageDir } from '../../infra/config/paths.js'; +import { parseGithubSpec } from '../../features/repertoire/github-spec.js'; +import { + parseTaktRepertoireConfig, + validateTaktRepertoirePath, + validateMinVersion, + isVersionCompatible, + checkPackageHasContentWithContext, + validateRealpathInsideRoot, + resolveRepertoireConfigPath, +} from '../../features/repertoire/takt-repertoire-config.js'; +import { collectCopyTargets } from '../../features/repertoire/file-filter.js'; +import { parseTarVerboseListing } from '../../features/repertoire/tar-parser.js'; +import { resolveRef } from '../../features/repertoire/github-ref-resolver.js'; +import { atomicReplace, cleanupResiduals } from '../../features/repertoire/atomic-update.js'; +import { generateLockFile, extractCommitSha } from '../../features/repertoire/lock-file.js'; +import { TAKT_REPERTOIRE_MANIFEST_FILENAME, TAKT_REPERTOIRE_LOCK_FILENAME } from '../../features/repertoire/constants.js'; +import { summarizeFacetsByType, detectEditPieces, formatEditPieceWarnings } from '../../features/repertoire/pack-summary.js'; +import { confirm } from '../../shared/prompt/index.js'; +import { info, success } from '../../shared/ui/index.js'; +import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; + +const require = createRequire(import.meta.url); +const { version: TAKT_VERSION } = require('../../../package.json') as { version: string }; + +const log = createLogger('repertoire-add'); + +export async function repertoireAddCommand(spec: string): Promise { + const { owner, repo, ref: specRef } = parseGithubSpec(spec); + + try { + execFileSync('gh', ['--version'], { stdio: 'pipe' }); + } catch { + throw new Error( + '`gh` CLI がインストールされていません。https://cli.github.com からインストールしてください', + ); + } + + const execGh = (args: string[]) => + execFileSync('gh', args, { encoding: 'utf-8', stdio: 'pipe' }); + + const ref = resolveRef(specRef, owner, repo, execGh); + + const tmpBase = join(tmpdir(), `takt-import-${Date.now()}`); + const tmpTarPath = `${tmpBase}.tar.gz`; + const tmpExtractDir = `${tmpBase}-extract`; + const tmpIncludeFile = `${tmpBase}-include.txt`; + + try { + mkdirSync(tmpExtractDir, { recursive: true }); + + info(`📦 ${owner}/${repo} @${ref} をダウンロード中...`); + const tarballBuffer = execFileSync( + 'gh', + [ + 'api', + `/repos/${owner}/${repo}/tarball/${ref}`, + ], + { stdio: ['inherit', 'pipe', 'pipe'] }, + ); + writeFileSync(tmpTarPath, tarballBuffer); + + const tarVerboseList = execFileSync('tar', ['tvzf', tmpTarPath], { + encoding: 'utf-8', + stdio: 'pipe', + }); + + const verboseLines = tarVerboseList.split('\n').filter(l => l.trim()); + const { firstDirEntry, includePaths } = parseTarVerboseListing(verboseLines); + + const commitSha = extractCommitSha(firstDirEntry); + + if (includePaths.length > 0) { + writeFileSync(tmpIncludeFile, includePaths.join('\n') + '\n'); + execFileSync( + 'tar', + ['xzf', tmpTarPath, '-C', tmpExtractDir, '--strip-components=1', '-T', tmpIncludeFile], + { stdio: 'pipe' }, + ); + } + + const packConfigPath = resolveRepertoireConfigPath(tmpExtractDir); + + const packConfigYaml = readFileSync(packConfigPath, 'utf-8'); + const config = parseTaktRepertoireConfig(packConfigYaml); + validateTaktRepertoirePath(config.path); + + if (config.takt?.min_version) { + validateMinVersion(config.takt.min_version); + if (!isVersionCompatible(config.takt.min_version, TAKT_VERSION)) { + throw new Error( + `このパッケージは TAKT ${config.takt.min_version} 以降が必要です(現在: ${TAKT_VERSION})`, + ); + } + } + + const packageRoot = config.path === '.' ? tmpExtractDir : join(tmpExtractDir, config.path); + + validateRealpathInsideRoot(packageRoot, tmpExtractDir); + + checkPackageHasContentWithContext(packageRoot, { + manifestPath: packConfigPath, + configuredPath: config.path, + }); + + const targets = collectCopyTargets(packageRoot); + const facetFiles = targets.filter(t => t.relativePath.startsWith('facets/')); + const pieceFiles = targets.filter(t => t.relativePath.startsWith('pieces/')); + + const facetSummary = summarizeFacetsByType(facetFiles.map(t => t.relativePath)); + + const pieceYamls: Array<{ name: string; content: string }> = []; + for (const pf of pieceFiles) { + try { + const content = readFileSync(pf.absolutePath, 'utf-8'); + pieceYamls.push({ name: pf.relativePath.replace(/^pieces\//, ''), content }); + } catch (err) { + log.debug('Failed to parse piece YAML for edit check', { path: pf.absolutePath, error: getErrorMessage(err) }); + } + } + const editPieces = detectEditPieces(pieceYamls); + + info(`\n📦 ${owner}/${repo} @${ref}`); + info(` facets: ${facetSummary}`); + if (pieceFiles.length > 0) { + const pieceNames = pieceFiles.map(t => + t.relativePath.replace(/^pieces\//, '').replace(/\.yaml$/, ''), + ); + info(` pieces: ${pieceFiles.length} (${pieceNames.join(', ')})`); + } else { + info(' pieces: 0'); + } + for (const ep of editPieces) { + for (const warning of formatEditPieceWarnings(ep)) { + info(warning); + } + } + info(''); + + const confirmed = await confirm('インストールしますか?', false); + if (!confirmed) { + info('キャンセルしました'); + return; + } + + const packageDir = getRepertoirePackageDir(owner, repo); + + if (existsSync(packageDir)) { + const overwrite = await confirm( + `${owner}/${repo} は既にインストールされています。上書きしますか?`, + false, + ); + if (!overwrite) { + info('キャンセルしました'); + return; + } + } + + cleanupResiduals(packageDir); + + await atomicReplace({ + packageDir, + install: async () => { + for (const target of targets) { + const destFile = join(packageDir, target.relativePath); + mkdirSync(dirname(destFile), { recursive: true }); + copyFileSync(target.absolutePath, destFile); + } + copyFileSync(packConfigPath, join(packageDir, TAKT_REPERTOIRE_MANIFEST_FILENAME)); + + const lock = generateLockFile({ + source: `github:${owner}/${repo}`, + ref, + commitSha, + importedAt: new Date(), + }); + writeFileSync(join(packageDir, TAKT_REPERTOIRE_LOCK_FILENAME), stringifyYaml(lock)); + }, + }); + + success(`✅ ${owner}/${repo} @${ref} をインストールしました`); + } finally { + if (existsSync(tmpTarPath)) rmSync(tmpTarPath, { force: true }); + if (existsSync(tmpExtractDir)) rmSync(tmpExtractDir, { recursive: true, force: true }); + if (existsSync(tmpIncludeFile)) rmSync(tmpIncludeFile, { force: true }); + } +} diff --git a/src/commands/repertoire/list.ts b/src/commands/repertoire/list.ts new file mode 100644 index 0000000..358f0c2 --- /dev/null +++ b/src/commands/repertoire/list.ts @@ -0,0 +1,22 @@ +/** + * takt repertoire list — list installed repertoire packages. + */ + +import { getRepertoireDir } from '../../infra/config/paths.js'; +import { listPackages } from '../../features/repertoire/list.js'; +import { info } from '../../shared/ui/index.js'; + +export async function repertoireListCommand(): Promise { + const packages = listPackages(getRepertoireDir()); + + if (packages.length === 0) { + info('インストール済みパッケージはありません'); + return; + } + + info('📦 インストール済みパッケージ:'); + for (const pkg of packages) { + const desc = pkg.description ? ` ${pkg.description}` : ''; + info(` ${pkg.scope}${desc} (${pkg.ref} ${pkg.commit})`); + } +} diff --git a/src/commands/repertoire/remove.ts b/src/commands/repertoire/remove.ts new file mode 100644 index 0000000..23c6bb4 --- /dev/null +++ b/src/commands/repertoire/remove.ts @@ -0,0 +1,56 @@ +/** + * takt repertoire remove — remove an installed repertoire package. + */ + +import { rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { getRepertoireDir, getRepertoirePackageDir, getGlobalConfigDir, getGlobalPiecesDir, getProjectPiecesDir } from '../../infra/config/paths.js'; +import { findScopeReferences, shouldRemoveOwnerDir } from '../../features/repertoire/remove.js'; +import { confirm } from '../../shared/prompt/index.js'; +import { info, success } from '../../shared/ui/index.js'; + +export async function repertoireRemoveCommand(scope: string): Promise { + if (!scope.startsWith('@')) { + throw new Error(`Invalid scope: "${scope}". Expected @{owner}/{repo}`); + } + const withoutAt = scope.slice(1); + const slashIdx = withoutAt.indexOf('/'); + if (slashIdx < 0) { + throw new Error(`Invalid scope: "${scope}". Expected @{owner}/{repo}`); + } + const owner = withoutAt.slice(0, slashIdx); + const repo = withoutAt.slice(slashIdx + 1); + + const repertoireDir = getRepertoireDir(); + const packageDir = getRepertoirePackageDir(owner, repo); + + if (!existsSync(packageDir)) { + throw new Error(`Package not found: ${scope}`); + } + + const refs = findScopeReferences(scope, { + piecesDirs: [getGlobalPiecesDir(), getProjectPiecesDir(process.cwd())], + categoriesFiles: [join(getGlobalConfigDir(), 'preferences', 'piece-categories.yaml')], + }); + if (refs.length > 0) { + info(`⚠ 以下のファイルが ${scope} を参照しています:`); + for (const ref of refs) { + info(` ${ref.filePath}`); + } + } + + const confirmed = await confirm(`${scope} を削除しますか?`, false); + if (!confirmed) { + info('キャンセルしました'); + return; + } + + rmSync(packageDir, { recursive: true, force: true }); + + const ownerDir = join(repertoireDir, `@${owner}`); + if (shouldRemoveOwnerDir(ownerDir, repo)) { + rmSync(ownerDir, { recursive: true, force: true }); + } + + success(`${scope} を削除しました`); +} diff --git a/src/faceted-prompting/index.ts b/src/faceted-prompting/index.ts index c50353a..9895906 100644 --- a/src/faceted-prompting/index.ts +++ b/src/faceted-prompting/index.ts @@ -49,3 +49,14 @@ export { extractPersonaDisplayName, resolvePersona, } from './resolve.js'; + +// Scope reference resolution +export type { ScopeRef } from './scope.js'; +export { + isScopeRef, + parseScopeRef, + resolveScopeRef, + validateScopeOwner, + validateScopeRepo, + validateScopeFacetName, +} from './scope.js'; diff --git a/src/faceted-prompting/scope.ts b/src/faceted-prompting/scope.ts new file mode 100644 index 0000000..310eae4 --- /dev/null +++ b/src/faceted-prompting/scope.ts @@ -0,0 +1,103 @@ +/** + * @scope reference resolution utilities for TAKT repertoire packages. + * + * Provides: + * - isScopeRef(): detect @{owner}/{repo}/{facet-name} format + * - parseScopeRef(): parse and normalize components + * - resolveScopeRef(): build file path in repertoire directory + * - validateScopeOwner/Repo/FacetName(): name constraint validation + */ + +import { join } from 'node:path'; + +/** Parsed components of an @scope reference. */ +export interface ScopeRef { + /** GitHub owner (lowercase). */ + owner: string; + /** Repository name (lowercase). */ + repo: string; + /** Facet name. */ + name: string; +} + +/** Matches @{owner}/{repo}/{facet-name} format. */ +const SCOPE_REF_PATTERN = /^@[^/]+\/[^/]+\/[^/]+$/; + +/** + * Return true if the string is an @{owner}/{repo}/{facet-name} scope reference. + */ +export function isScopeRef(ref: string): boolean { + return SCOPE_REF_PATTERN.test(ref); +} + +/** + * Parse an @scope reference into its components. + * Normalizes owner and repo to lowercase. + * + * @param ref - e.g. "@nrslib/takt-fullstack/expert-coder" + */ +export function parseScopeRef(ref: string): ScopeRef { + const withoutAt = ref.slice(1); + const firstSlash = withoutAt.indexOf('/'); + const owner = withoutAt.slice(0, firstSlash).toLowerCase(); + const rest = withoutAt.slice(firstSlash + 1); + const secondSlash = rest.indexOf('/'); + const repo = rest.slice(0, secondSlash).toLowerCase(); + const name = rest.slice(secondSlash + 1); + validateScopeOwner(owner); + validateScopeRepo(repo); + validateScopeFacetName(name); + return { owner, repo, name }; +} + +/** + * Resolve a scope reference to a file path in the repertoire directory. + * + * Path: {repertoireDir}/@{owner}/{repo}/facets/{facetType}/{name}.md + * + * @param scopeRef - parsed scope reference + * @param facetType - e.g. "personas", "policies", "knowledge" + * @param repertoireDir - root repertoire directory (e.g. ~/.takt/repertoire) + * @returns Absolute path to the facet file. + */ +export function resolveScopeRef( + scopeRef: ScopeRef, + facetType: string, + repertoireDir: string, +): string { + return join( + repertoireDir, + `@${scopeRef.owner}`, + scopeRef.repo, + 'facets', + facetType, + `${scopeRef.name}.md`, + ); +} + +/** Validate owner name: must match /^[a-z0-9][a-z0-9-]*$/ */ +export function validateScopeOwner(owner: string): void { + if (!/^[a-z0-9][a-z0-9-]*$/.test(owner)) { + throw new Error( + `Invalid scope owner: "${owner}". Must match /^[a-z0-9][a-z0-9-]*$/ (lowercase alphanumeric and hyphens, not starting with hyphen).`, + ); + } +} + +/** Validate repo name: must match /^[a-z0-9][a-z0-9._-]*$/ */ +export function validateScopeRepo(repo: string): void { + if (!/^[a-z0-9][a-z0-9._-]*$/.test(repo)) { + throw new Error( + `Invalid scope repo: "${repo}". Must match /^[a-z0-9][a-z0-9._-]*$/ (lowercase alphanumeric, hyphens, dots, underscores, not starting with hyphen).`, + ); + } +} + +/** Validate facet name: must match /^[a-z0-9][a-z0-9-]*$/ */ +export function validateScopeFacetName(name: string): void { + if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) { + throw new Error( + `Invalid scope facet name: "${name}". Must match /^[a-z0-9][a-z0-9-]*$/ (lowercase alphanumeric and hyphens, not starting with hyphen).`, + ); + } +} diff --git a/src/features/catalog/catalogFacets.ts b/src/features/catalog/catalogFacets.ts index 88160c3..38b2b38 100644 --- a/src/features/catalog/catalogFacets.ts +++ b/src/features/catalog/catalogFacets.ts @@ -9,8 +9,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join, basename } from 'node:path'; import chalk from 'chalk'; import type { PieceSource } from '../../infra/config/loaders/pieceResolver.js'; -import { getLanguageResourcesDir } from '../../infra/resources/index.js'; -import { getGlobalConfigDir, getProjectConfigDir } from '../../infra/config/paths.js'; +import { getBuiltinFacetDir, getGlobalFacetDir, getProjectFacetDir } from '../../infra/config/paths.js'; import { resolvePieceConfigValues } from '../../infra/config/index.js'; import { section, error as logError, info } from '../../shared/ui/index.js'; @@ -67,11 +66,11 @@ function getFacetDirs( if (config.enableBuiltinPieces !== false) { const lang = config.language; - dirs.push({ dir: join(getLanguageResourcesDir(lang), facetType), source: 'builtin' }); + dirs.push({ dir: getBuiltinFacetDir(lang, facetType), source: 'builtin' }); } - dirs.push({ dir: join(getGlobalConfigDir(), facetType), source: 'user' }); - dirs.push({ dir: join(getProjectConfigDir(cwd), facetType), source: 'project' }); + dirs.push({ dir: getGlobalFacetDir(facetType), source: 'user' }); + dirs.push({ dir: getProjectFacetDir(cwd, facetType), source: 'project' }); return dirs; } @@ -123,6 +122,8 @@ function colorSourceTag(source: PieceSource): string { return chalk.yellow(`[${source}]`); case 'project': return chalk.green(`[${source}]`); + default: + return chalk.blue(`[${source}]`); } } diff --git a/src/features/config/deploySkill.ts b/src/features/config/deploySkill.ts index f6ad7f4..4096751 100644 --- a/src/features/config/deploySkill.ts +++ b/src/features/config/deploySkill.ts @@ -33,16 +33,14 @@ function getSkillDir(): string { return join(homedir(), '.claude', 'skills', 'takt'); } -/** Directories within builtins/{lang}/ to copy as resource types */ -const RESOURCE_DIRS = [ - 'pieces', - 'personas', - 'policies', - 'instructions', - 'knowledge', - 'output-contracts', - 'templates', -] as const; +/** Directories directly under builtins/{lang}/ */ +const DIRECT_DIRS = ['pieces', 'templates'] as const; + +/** Facet directories under builtins/{lang}/facets/ */ +const FACET_DIRS = ['personas', 'policies', 'instructions', 'knowledge', 'output-contracts'] as const; + +/** All resource directory names (used for summary filtering) */ +const RESOURCE_DIRS = [...DIRECT_DIRS, ...FACET_DIRS] as const; /** * Deploy takt skill to Claude Code (~/.claude/). @@ -89,10 +87,18 @@ export async function deploySkill(): Promise { cleanDir(refsDestDir); copyDirRecursive(refsSrcDir, refsDestDir, copiedFiles); - // 3. Deploy all resource directories from builtins/{lang}/ - for (const resourceDir of RESOURCE_DIRS) { - const srcDir = join(langResourcesDir, resourceDir); - const destDir = join(skillDir, resourceDir); + // 3. Deploy direct resource directories from builtins/{lang}/ + for (const dir of DIRECT_DIRS) { + const srcDir = join(langResourcesDir, dir); + const destDir = join(skillDir, dir); + cleanDir(destDir); + copyDirRecursive(srcDir, destDir, copiedFiles); + } + + // 4. Deploy facet directories from builtins/{lang}/facets/ + for (const dir of FACET_DIRS) { + const srcDir = join(langResourcesDir, 'facets', dir); + const destDir = join(skillDir, dir); cleanDir(destDir); copyDirRecursive(srcDir, destDir, copiedFiles); } diff --git a/src/features/repertoire/atomic-update.ts b/src/features/repertoire/atomic-update.ts new file mode 100644 index 0000000..e4d36dc --- /dev/null +++ b/src/features/repertoire/atomic-update.ts @@ -0,0 +1,79 @@ +/** + * Atomic package installation / replacement. + * + * The sequence: + * 1. Rename existing packageDir → packageDir.bak (backup) + * 2. Create new empty packageDir + * 3. Call install() which writes to packageDir + * 4. On success: delete packageDir.bak + * 5. On failure: delete packageDir (partial), rename .bak → packageDir (restore) + * + * cleanupResiduals() removes any .tmp or .bak directories left by previous + * failed runs before starting a new installation. + */ + +import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs'; + +export interface AtomicReplaceOptions { + /** Absolute path to the package directory (final install location). */ + packageDir: string; + /** Callback that writes the new package content into packageDir. */ + install: () => Promise; +} + +/** + * Remove any leftover .tmp or .bak directories from a previous failed installation. + * + * @param packageDir - absolute path to the package directory (not the .tmp/.bak path) + */ +export function cleanupResiduals(packageDir: string): void { + const tmpPath = `${packageDir}.tmp`; + const bakPath = `${packageDir}.bak`; + + if (existsSync(tmpPath)) { + rmSync(tmpPath, { recursive: true, force: true }); + } + if (existsSync(bakPath)) { + rmSync(bakPath, { recursive: true, force: true }); + } +} + +/** + * Atomically replace a package directory. + * + * If the package directory already exists, it is renamed to .bak before + * installing the new version. On success, .bak is removed. On failure, + * the new (partial) directory is removed and .bak is restored. + * + * If the package directory does not yet exist, creates it fresh. + */ +export async function atomicReplace(options: AtomicReplaceOptions): Promise { + const { packageDir, install } = options; + const bakPath = `${packageDir}.bak`; + const hadExisting = existsSync(packageDir); + + // Step 1: backup existing package + if (hadExisting) { + renameSync(packageDir, bakPath); + } + + // Step 2: create new empty package directory + mkdirSync(packageDir, { recursive: true }); + + // Step 3: run install + try { + await install(); + } catch (err) { + // Step 5 (failure path): remove partial install, restore backup + rmSync(packageDir, { recursive: true, force: true }); + if (hadExisting && existsSync(bakPath)) { + renameSync(bakPath, packageDir); + } + throw err; + } + + // Step 4: remove backup on success + if (hadExisting && existsSync(bakPath)) { + rmSync(bakPath, { recursive: true, force: true }); + } +} diff --git a/src/features/repertoire/constants.ts b/src/features/repertoire/constants.ts new file mode 100644 index 0000000..e472025 --- /dev/null +++ b/src/features/repertoire/constants.ts @@ -0,0 +1,12 @@ +/** + * Shared constants for repertoire package manifest handling. + */ + +/** Directory name for the repertoire packages dir (~/.takt/repertoire). */ +export const REPERTOIRE_DIR_NAME = 'repertoire'; + +/** Manifest filename inside a package repository and installed package directory. */ +export const TAKT_REPERTOIRE_MANIFEST_FILENAME = 'takt-repertoire.yaml'; + +/** Lock file filename inside an installed package directory. */ +export const TAKT_REPERTOIRE_LOCK_FILENAME = '.takt-repertoire-lock.yaml'; diff --git a/src/features/repertoire/file-filter.ts b/src/features/repertoire/file-filter.ts new file mode 100644 index 0000000..919cfbe --- /dev/null +++ b/src/features/repertoire/file-filter.ts @@ -0,0 +1,136 @@ +/** + * File filtering for repertoire package copy operations. + * + * Security constraints: + * - Only .md, .yaml, .yml files are copied + * - Only files under facets/ or pieces/ top-level directories are copied + * - Symbolic links are skipped (lstat check) + * - Files exceeding MAX_FILE_SIZE (1 MB) are skipped + * - Packages with more than MAX_FILE_COUNT files throw an error + */ + +import { lstatSync, readdirSync, type Stats } from 'node:fs'; +import { join, extname, relative } from 'node:path'; +import { createLogger } from '../../shared/utils/debug.js'; + +const log = createLogger('repertoire-file-filter'); + +/** Allowed file extensions for repertoire package files. */ +export const ALLOWED_EXTENSIONS = ['.md', '.yaml', '.yml'] as const; + +/** Top-level directories that are copied from a package. */ +export const ALLOWED_DIRS = ['facets', 'pieces'] as const; + +/** Maximum single file size in bytes (1 MB). */ +export const MAX_FILE_SIZE = 1024 * 1024; + +/** Maximum total file count per package. */ +export const MAX_FILE_COUNT = 500; + +export interface CopyTarget { + /** Absolute path to the source file. */ + absolutePath: string; + /** Relative path from the package root (e.g. "facets/personas/coder.md"). */ + relativePath: string; +} + +/** + * Check if a filename has an allowed extension. + */ +export function isAllowedExtension(filename: string): boolean { + const ext = extname(filename); + return (ALLOWED_EXTENSIONS as readonly string[]).includes(ext); +} + +/** + * Determine whether a single file should be copied. + * + * @param filePath - absolute path to the file + * @param stats - result of lstat(filePath) + */ +function shouldCopyFile( + filePath: string, + stats: Stats, +): boolean { + if (stats.size > MAX_FILE_SIZE) return false; + if (!isAllowedExtension(filePath)) return false; + return true; +} + +/** + * Recursively collect files eligible for copying from within a directory. + * Used internally by collectCopyTargets. + */ +function collectFromDir( + dir: string, + packageRoot: string, + targets: CopyTarget[], +): void { + let entries: string[]; + try { + entries = readdirSync(dir, 'utf-8'); + } catch (err) { + log.debug('Failed to read directory', { dir, err }); + return; + } + + for (const entry of entries) { + if (targets.length >= MAX_FILE_COUNT) { + throw new Error( + `Package exceeds maximum file count of ${MAX_FILE_COUNT}`, + ); + } + + const absolutePath = join(dir, entry); + const stats = lstatSync(absolutePath); + + if (stats.isSymbolicLink()) continue; + + if (stats.isDirectory()) { + collectFromDir(absolutePath, packageRoot, targets); + continue; + } + + if (!shouldCopyFile(absolutePath, stats)) continue; + + targets.push({ + absolutePath, + relativePath: relative(packageRoot, absolutePath), + }); + } +} + +/** + * Collect all files to copy from a package root directory. + * + * Only files under facets/ and pieces/ top-level directories are included. + * Symbolic links are skipped. Files over MAX_FILE_SIZE are skipped. + * Throws if total file count exceeds MAX_FILE_COUNT. + * + * @param packageRoot - absolute path to the package root (respects takt-repertoire.yaml path) + */ +export function collectCopyTargets(packageRoot: string): CopyTarget[] { + const targets: CopyTarget[] = []; + + for (const allowedDir of ALLOWED_DIRS) { + const dirPath = join(packageRoot, allowedDir); + let stats: Stats | undefined; + try { + stats = lstatSync(dirPath); + } catch (err) { + log.debug('Directory not accessible, skipping', { dirPath, err }); + continue; + } + if (!stats?.isDirectory()) continue; + + collectFromDir(dirPath, packageRoot, targets); + + if (targets.length >= MAX_FILE_COUNT) { + throw new Error( + `Package exceeds maximum file count of ${MAX_FILE_COUNT}`, + ); + } + } + + return targets; +} diff --git a/src/features/repertoire/github-ref-resolver.ts b/src/features/repertoire/github-ref-resolver.ts new file mode 100644 index 0000000..37f02dd --- /dev/null +++ b/src/features/repertoire/github-ref-resolver.ts @@ -0,0 +1,40 @@ +/** + * GitHub ref resolver for repertoire add command. + * + * Resolves the ref for a GitHub package installation. + * When the spec omits @{ref}, queries the GitHub API for the default branch. + */ + +/** Injectable function for calling `gh api` (enables unit testing without network). */ +export type GhExecFn = (args: string[]) => string; + +/** + * Resolve the ref to use for a GitHub package installation. + * + * If specRef is provided, returns it directly. Otherwise calls the GitHub API + * via execGh to retrieve the repository's default branch. + * + * @throws if the API call returns an empty branch name + */ +export function resolveRef( + specRef: string | undefined, + owner: string, + repo: string, + execGh: GhExecFn, +): string { + if (specRef !== undefined) { + return specRef; + } + + const defaultBranch = execGh([ + 'api', + `/repos/${owner}/${repo}`, + '--jq', '.default_branch', + ]).trim(); + + if (!defaultBranch) { + throw new Error(`デフォルトブランチを取得できませんでした: ${owner}/${repo}`); + } + + return defaultBranch; +} diff --git a/src/features/repertoire/github-spec.ts b/src/features/repertoire/github-spec.ts new file mode 100644 index 0000000..6637b7a --- /dev/null +++ b/src/features/repertoire/github-spec.ts @@ -0,0 +1,48 @@ +/** + * GitHub package spec parser for repertoire add command. + * + * Parses "github:{owner}/{repo}@{ref}" format into structured components. + * The @{ref} part is optional; when omitted, ref is undefined and the caller + * should resolve the default branch via the GitHub API. + */ + +export interface GithubPackageRef { + owner: string; + repo: string; + /** The ref (branch, tag, or SHA) to install. `undefined` when omitted from the spec. */ + ref: string | undefined; +} + +/** + * Parse a GitHub package spec string into its components. + * + * @param spec - e.g. "github:nrslib/takt-fullstack@main" or "github:nrslib/takt-fullstack" + * @returns Parsed owner, repo, and ref (owner and repo are lowercased; ref may be undefined) + * @throws if the spec format is invalid + */ +export function parseGithubSpec(spec: string): GithubPackageRef { + if (!spec.startsWith('github:')) { + throw new Error(`Invalid package spec: "${spec}". Expected "github:{owner}/{repo}@{ref}"`); + } + const withoutPrefix = spec.slice('github:'.length); + const atIdx = withoutPrefix.lastIndexOf('@'); + + let ownerRepo: string; + let ref: string | undefined; + + if (atIdx < 0) { + ownerRepo = withoutPrefix; + ref = undefined; + } else { + ownerRepo = withoutPrefix.slice(0, atIdx); + ref = withoutPrefix.slice(atIdx + 1); + } + + const slashIdx = ownerRepo.indexOf('/'); + if (slashIdx < 0) { + throw new Error(`Invalid package spec: "${spec}". Missing repo name`); + } + const owner = ownerRepo.slice(0, slashIdx).toLowerCase(); + const repo = ownerRepo.slice(slashIdx + 1).toLowerCase(); + return { owner, repo, ref }; +} diff --git a/src/features/repertoire/list.ts b/src/features/repertoire/list.ts new file mode 100644 index 0000000..6fd3b50 --- /dev/null +++ b/src/features/repertoire/list.ts @@ -0,0 +1,85 @@ +/** + * Repertoire package listing. + * + * Scans the repertoire directory for installed packages and reads their + * metadata (description, ref, truncated commit SHA) for display. + */ + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { parseTaktRepertoireConfig } from './takt-repertoire-config.js'; +import { parseLockFile } from './lock-file.js'; +import { TAKT_REPERTOIRE_MANIFEST_FILENAME, TAKT_REPERTOIRE_LOCK_FILENAME } from './constants.js'; +import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; + +const log = createLogger('repertoire-list'); + +export interface PackageInfo { + /** e.g. "@nrslib/takt-fullstack" */ + scope: string; + description?: string; + ref: string; + /** First 7 characters of the commit SHA. */ + commit: string; +} + +/** + * Read package metadata from a package directory. + * + * @param packageDir - absolute path to the package directory + * @param scope - e.g. "@nrslib/takt-fullstack" + */ +export function readPackageInfo(packageDir: string, scope: string): PackageInfo { + const packConfigPath = join(packageDir, TAKT_REPERTOIRE_MANIFEST_FILENAME); + const lockPath = join(packageDir, TAKT_REPERTOIRE_LOCK_FILENAME); + + const configYaml = existsSync(packConfigPath) + ? readFileSync(packConfigPath, 'utf-8') + : ''; + const config = parseTaktRepertoireConfig(configYaml); + + const lockYaml = existsSync(lockPath) + ? readFileSync(lockPath, 'utf-8') + : ''; + const lock = parseLockFile(lockYaml); + + return { + scope, + description: config.description, + ref: lock.ref, + commit: lock.commit.slice(0, 7), + }; +} + +/** + * List all installed packages under the repertoire directory. + * + * Directory structure: + * repertoireDir/ + * @{owner}/ + * {repo}/ + * takt-repertoire.yaml + * .takt-repertoire-lock.yaml + * + * @param repertoireDir - absolute path to the repertoire root (~/.takt/repertoire) + */ +export function listPackages(repertoireDir: string): PackageInfo[] { + if (!existsSync(repertoireDir)) return []; + + const packages: PackageInfo[] = []; + + for (const ownerEntry of readdirSync(repertoireDir)) { + if (!ownerEntry.startsWith('@')) continue; + const ownerDir = join(repertoireDir, ownerEntry); + try { if (!statSync(ownerDir).isDirectory()) continue; } catch (e) { log.debug(`stat failed for ${ownerDir}: ${getErrorMessage(e)}`); continue; } + + for (const repoEntry of readdirSync(ownerDir)) { + const packageDir = join(ownerDir, repoEntry); + try { if (!statSync(packageDir).isDirectory()) continue; } catch (e) { log.debug(`stat failed for ${packageDir}: ${getErrorMessage(e)}`); continue; } + const scope = `${ownerEntry}/${repoEntry}`; + packages.push(readPackageInfo(packageDir, scope)); + } + } + + return packages; +} diff --git a/src/features/repertoire/lock-file.ts b/src/features/repertoire/lock-file.ts new file mode 100644 index 0000000..fe1def1 --- /dev/null +++ b/src/features/repertoire/lock-file.ts @@ -0,0 +1,74 @@ +/** + * Lock file generation and parsing for repertoire packages. + * + * The .takt-repertoire-lock.yaml records the installation provenance: + * source: github:{owner}/{repo} + * ref: tag or branch (defaults to "HEAD") + * commit: full SHA from tarball directory name + * imported_at: ISO 8601 timestamp + */ + +import { parse as parseYaml } from 'yaml'; + +export interface PackageLock { + source: string; + ref: string; + commit: string; + imported_at: string; +} + +interface GenerateLockFileParams { + source: string; + ref: string | undefined; + commitSha: string; + importedAt: Date; +} + +/** + * Extract the commit SHA from a GitHub tarball directory name. + * + * GitHub tarball directories follow the format: {owner}-{repo}-{sha} + * The SHA is always the last hyphen-separated segment. + * + * @param dirName - directory name without trailing slash + */ +export function extractCommitSha(dirName: string): string { + const parts = dirName.split('-'); + const sha = parts[parts.length - 1]; + if (!sha) { + throw new Error(`Cannot extract commit SHA from directory name: "${dirName}"`); + } + return sha; +} + +/** + * Generate a PackageLock object from installation parameters. + * + * @param params.source - e.g. "github:nrslib/takt-fullstack" + * @param params.ref - tag, branch, or undefined (defaults to "HEAD") + * @param params.commitSha - full commit SHA from tarball directory + * @param params.importedAt - installation timestamp + */ +export function generateLockFile(params: GenerateLockFileParams): PackageLock { + return { + source: params.source, + ref: params.ref ?? 'HEAD', + commit: params.commitSha, + imported_at: params.importedAt.toISOString(), + }; +} + +/** + * Parse .takt-repertoire-lock.yaml content into a PackageLock object. + * Returns empty-valued lock when yaml is empty (lock file missing). + */ +export function parseLockFile(yaml: string): PackageLock { + const rawOrNull = (yaml.trim() ? parseYaml(yaml) : null) as Record | null; + const raw = rawOrNull ?? {}; + return { + source: String(raw['source'] ?? ''), + ref: String(raw['ref'] ?? 'HEAD'), + commit: String(raw['commit'] ?? ''), + imported_at: String(raw['imported_at'] ?? ''), + }; +} diff --git a/src/features/repertoire/pack-summary.ts b/src/features/repertoire/pack-summary.ts new file mode 100644 index 0000000..a9e531f --- /dev/null +++ b/src/features/repertoire/pack-summary.ts @@ -0,0 +1,106 @@ +/** + * Pure utility functions for generating install summary information. + * + * Extracted to enable unit testing without file I/O or system dependencies. + */ + +import { parse as parseYaml } from 'yaml'; +import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; + +const log = createLogger('pack-summary'); + +export interface EditPieceInfo { + name: string; + allowedTools: string[]; + hasEdit: boolean; + requiredPermissionModes: string[]; +} + +/** + * Count facet files per type (personas, policies, knowledge, etc.) + * and produce a human-readable summary string. + * + * @param facetRelativePaths - Paths relative to package root, starting with `facets/` + */ +export function summarizeFacetsByType(facetRelativePaths: string[]): string { + const countsByType = new Map(); + for (const path of facetRelativePaths) { + const parts = path.split('/'); + if (parts.length >= 2 && parts[1]) { + const type = parts[1]; + countsByType.set(type, (countsByType.get(type) ?? 0) + 1); + } + } + return countsByType.size > 0 + ? Array.from(countsByType.entries()).map(([type, count]) => `${count} ${type}`).join(', ') + : '0'; +} + +/** + * Detect pieces that require permissions in any movement. + * + * A movement is considered permission-relevant when any of: + * - `edit: true` is set + * - `allowed_tools` has at least one entry + * - `required_permission_mode` is set + * + * @param pieceYamls - Pre-read YAML content pairs. Invalid YAML is skipped (debug-logged). + */ +export function detectEditPieces(pieceYamls: Array<{ name: string; content: string }>): EditPieceInfo[] { + const result: EditPieceInfo[] = []; + for (const { name, content } of pieceYamls) { + let raw: { movements?: { edit?: boolean; allowed_tools?: string[]; required_permission_mode?: string }[] } | null; + try { + raw = parseYaml(content) as typeof raw; + } catch (e) { + log.debug(`YAML parse failed for piece ${name}: ${getErrorMessage(e)}`); + continue; + } + const movements = raw?.movements ?? []; + const hasEditMovement = movements.some(m => m.edit === true); + const hasToolMovements = movements.some(m => (m.allowed_tools?.length ?? 0) > 0); + const hasPermissionMovements = movements.some(m => m.required_permission_mode != null); + if (!hasEditMovement && !hasToolMovements && !hasPermissionMovements) continue; + + const allTools = new Set(); + for (const m of movements) { + if (m.allowed_tools) { + for (const t of m.allowed_tools) allTools.add(t); + } + } + const requiredPermissionModes: string[] = []; + for (const m of movements) { + if (m.required_permission_mode != null) { + const mode = m.required_permission_mode; + if (!requiredPermissionModes.includes(mode)) { + requiredPermissionModes.push(mode); + } + } + } + result.push({ + name, + allowedTools: Array.from(allTools), + hasEdit: hasEditMovement, + requiredPermissionModes, + }); + } + return result; +} + +/** + * Format warning lines for a single permission-relevant piece. + * Returns one line per warning (edit, allowed_tools, required_permission_mode). + */ +export function formatEditPieceWarnings(ep: EditPieceInfo): string[] { + const warnings: string[] = []; + if (ep.hasEdit) { + const toolStr = ep.allowedTools.length > 0 ? `, allowed_tools: [${ep.allowedTools.join(', ')}]` : ''; + warnings.push(`\n ⚠ ${ep.name}: edit: true${toolStr}`); + } else if (ep.allowedTools.length > 0) { + warnings.push(`\n ⚠ ${ep.name}: allowed_tools: [${ep.allowedTools.join(', ')}]`); + } + for (const mode of ep.requiredPermissionModes) { + warnings.push(`\n ⚠ ${ep.name}: required_permission_mode: ${mode}`); + } + return warnings; +} diff --git a/src/features/repertoire/remove.ts b/src/features/repertoire/remove.ts new file mode 100644 index 0000000..4c5c4ab --- /dev/null +++ b/src/features/repertoire/remove.ts @@ -0,0 +1,126 @@ +/** + * Repertoire package removal helpers. + * + * Provides: + * - findScopeReferences: scan YAML files for @scope references (for pre-removal warning) + * - shouldRemoveOwnerDir: determine if the @owner directory should be deleted + */ + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { createLogger } from '../../shared/utils/debug.js'; + +const log = createLogger('repertoire-remove'); + +export interface ScopeReference { + /** Absolute path to the file containing the @scope reference. */ + filePath: string; +} + +/** + * Recursively scan a directory for YAML files containing the given @scope substring. + */ +function scanYamlFilesInDir(dir: string, scope: string, results: ScopeReference[]): void { + if (!existsSync(dir)) return; + + for (const entry of readdirSync(dir)) { + const filePath = join(dir, entry); + let stats: ReturnType; + try { + stats = statSync(filePath); + } catch (err) { + log.debug('Failed to stat file', { filePath, err }); + continue; + } + + if (stats.isDirectory()) { + scanYamlFilesInDir(filePath, scope, results); + continue; + } + + if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue; + + let content: string; + try { + content = readFileSync(filePath, 'utf-8'); + } catch (err) { + log.debug('Failed to read file', { filePath, err }); + continue; + } + + if (content.includes(scope)) { + results.push({ filePath }); + } + } +} + +/** + * Configuration for scope reference scanning. + * + * Separates the two kinds of scan targets to enable precise control over + * which paths are scanned, avoiding unintended paths from root-based derivation. + */ +export interface ScanConfig { + /** Directories to recursively scan for YAML files containing the scope substring. */ + piecesDirs: string[]; + /** Individual YAML files to check for the scope substring (e.g. piece-categories.yaml). */ + categoriesFiles: string[]; +} + +/** + * Find all files that reference a given @scope package. + * + * Scans the 3 spec-defined locations: + * 1. piecesDirs entries recursively (e.g. ~/.takt/pieces, .takt/pieces) + * 2. categoriesFiles entries individually (e.g. ~/.takt/preferences/piece-categories.yaml) + * + * @param scope - e.g. "@nrslib/takt-fullstack" + * @param config - explicit scan targets (piecesDirs + categoriesFiles) + */ +export function findScopeReferences(scope: string, config: ScanConfig): ScopeReference[] { + const results: ScopeReference[] = []; + + for (const dir of config.piecesDirs) { + scanYamlFilesInDir(dir, scope, results); + } + + for (const filePath of config.categoriesFiles) { + if (!existsSync(filePath)) continue; + try { + const content = readFileSync(filePath, 'utf-8'); + if (content.includes(scope)) { + results.push({ filePath }); + } + } catch (err) { + log.debug('Failed to read categories file', { filePath, err }); + } + } + + return results; +} + +/** + * Determine whether the @owner directory can be removed after deleting a repo. + * + * Returns true if the owner directory would have no remaining subdirectories + * once the given repo is removed. + * + * @param ownerDir - absolute path to the @owner directory + * @param repoBeingRemoved - repo name that will be deleted (excluded from check) + */ +export function shouldRemoveOwnerDir(ownerDir: string, repoBeingRemoved: string): boolean { + if (!existsSync(ownerDir)) return false; + + const remaining = readdirSync(ownerDir).filter((entry) => { + if (entry === repoBeingRemoved) return false; + const entryPath = join(ownerDir, entry); + try { + return statSync(entryPath).isDirectory(); + } catch (err) { + log.debug('Failed to stat entry', { entryPath, err }); + return false; + } + }); + + return remaining.length === 0; +} diff --git a/src/features/repertoire/takt-repertoire-config.ts b/src/features/repertoire/takt-repertoire-config.ts new file mode 100644 index 0000000..3aa3ba0 --- /dev/null +++ b/src/features/repertoire/takt-repertoire-config.ts @@ -0,0 +1,199 @@ +/** + * takt-repertoire.yaml parsing and validation. + * + * Handles: + * - YAML parsing with default values + * - path field validation (no absolute paths, no directory traversal) + * - min_version format validation (strict semver X.Y.Z) + * - Numeric semver comparison + * - Package content presence check (facets/ or pieces/ must exist) + * - Realpath validation to prevent symlink-based traversal outside root + */ + +import { existsSync, realpathSync } from 'node:fs'; +import { join } from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { TAKT_REPERTOIRE_MANIFEST_FILENAME } from './constants.js'; + +export interface TaktRepertoireConfig { + description?: string; + path: string; + takt?: { + min_version?: string; + }; +} + +interface PackageContentCheckContext { + manifestPath?: string; + configuredPath?: string; +} + +const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/; + +/** + * Parse takt-repertoire.yaml content string into a TaktRepertoireConfig. + * Applies default path "." when not specified. + */ +export function parseTaktRepertoireConfig(yaml: string): TaktRepertoireConfig { + const raw = (yaml.trim() ? parseYaml(yaml) : {}) as Record | null; + const data = raw ?? {}; + + const description = typeof data['description'] === 'string' ? data['description'] : undefined; + const path = typeof data['path'] === 'string' ? data['path'] : '.'; + const taktRaw = data['takt']; + const takt = taktRaw && typeof taktRaw === 'object' && !Array.isArray(taktRaw) + ? { min_version: (taktRaw as Record)['min_version'] as string | undefined } + : undefined; + + return { description, path, takt }; +} + +/** + * Validate that the path field is safe: + * - Must not start with "/" (absolute path) + * - Must not start with "~" (home-relative path) + * - Must not contain ".." segments (directory traversal) + * + * Throws on validation failure. + */ +export function validateTaktRepertoirePath(path: string): void { + if (path.startsWith('/')) { + throw new Error(`${TAKT_REPERTOIRE_MANIFEST_FILENAME}: path must not be absolute, got "${path}"`); + } + if (path.startsWith('~')) { + throw new Error(`${TAKT_REPERTOIRE_MANIFEST_FILENAME}: path must not start with "~", got "${path}"`); + } + const segments = path.split('/'); + if (segments.includes('..')) { + throw new Error(`${TAKT_REPERTOIRE_MANIFEST_FILENAME}: path must not contain ".." segments, got "${path}"`); + } +} + +/** + * Validate min_version format: must match /^\d+\.\d+\.\d+$/ exactly. + * Pre-release suffixes (e.g. "1.0.0-alpha") and "v" prefix are rejected. + * + * Throws on validation failure. + */ +export function validateMinVersion(version: string): void { + if (!SEMVER_PATTERN.test(version)) { + throw new Error( + `${TAKT_REPERTOIRE_MANIFEST_FILENAME}: takt.min_version must match X.Y.Z (no "v" prefix, no pre-release), got "${version}"`, + ); + } +} + +/** + * Compare versions numerically. + * + * @param minVersion - minimum required version (X.Y.Z) + * @param currentVersion - current installed version (X.Y.Z) + * @returns true if currentVersion >= minVersion + */ +export function isVersionCompatible(minVersion: string, currentVersion: string): boolean { + const parseParts = (v: string): [number, number, number] => { + const [major, minor, patch] = v.split('.').map(Number); + return [major ?? 0, minor ?? 0, patch ?? 0]; + }; + + const [minMajor, minMinor, minPatch] = parseParts(minVersion); + const [curMajor, curMinor, curPatch] = parseParts(currentVersion); + + if (curMajor !== minMajor) return curMajor > minMajor; + if (curMinor !== minMinor) return curMinor > minMinor; + return curPatch >= minPatch; +} + +/** + * Check that the package root contains at least one of facets/ or pieces/. + * Throws if neither exists (empty package). + */ +export function checkPackageHasContent(packageRoot: string): void { + const hasFacets = existsSync(join(packageRoot, 'facets')); + const hasPieces = existsSync(join(packageRoot, 'pieces')); + if (!hasFacets && !hasPieces) { + throw new Error( + `Package at "${packageRoot}" has neither facets/ nor pieces/ directory — empty package rejected`, + ); + } +} + +/** + * Check package content and include user-facing diagnostics when empty. + * + * Adds manifest/configured-path details and a practical hint for nested layouts + * (e.g. when actual content is under ".takt/" but path remains "."). + */ +export function checkPackageHasContentWithContext( + packageRoot: string, + context: PackageContentCheckContext, +): void { + const hasFacets = existsSync(join(packageRoot, 'facets')); + const hasPieces = existsSync(join(packageRoot, 'pieces')); + if (hasFacets || hasPieces) return; + + const checkedFacets = join(packageRoot, 'facets'); + const checkedPieces = join(packageRoot, 'pieces'); + const configuredPath = context.configuredPath ?? '.'; + const manifestPath = context.manifestPath ?? '(unknown)'; + const hint = configuredPath === '.' + ? `hint: If your package content is under ".takt/", set "path: .takt" in ${TAKT_REPERTOIRE_MANIFEST_FILENAME}.` + : `hint: Verify "path: ${configuredPath}" points to a directory containing facets/ or pieces/.`; + + throw new Error( + [ + 'Package content not found.', + `manifest: ${manifestPath}`, + `configured path: ${configuredPath}`, + `resolved package root: ${packageRoot}`, + `checked: ${checkedFacets}`, + `checked: ${checkedPieces}`, + hint, + ].join('\n'), + ); +} + +/** + * Resolve the path to takt-repertoire.yaml within an extracted tarball directory. + * + * Search order (first found wins): + * 1. {extractDir}/.takt/takt-repertoire.yaml + * 2. {extractDir}/takt-repertoire.yaml + * + * @param extractDir - root of the extracted tarball + * @throws if neither candidate exists + */ +export function resolveRepertoireConfigPath(extractDir: string): string { + const taktDirPath = join(extractDir, '.takt', TAKT_REPERTOIRE_MANIFEST_FILENAME); + if (existsSync(taktDirPath)) return taktDirPath; + + const rootPath = join(extractDir, TAKT_REPERTOIRE_MANIFEST_FILENAME); + if (existsSync(rootPath)) return rootPath; + + throw new Error( + `${TAKT_REPERTOIRE_MANIFEST_FILENAME} not found in "${extractDir}": checked .takt/${TAKT_REPERTOIRE_MANIFEST_FILENAME} and ${TAKT_REPERTOIRE_MANIFEST_FILENAME}`, + ); +} + +/** + * Validate that resolvedPath is inside (or equal to) repoRoot after realpath normalization. + * This prevents symlink-based traversal that would escape the package root. + * + * @param resolvedPath - absolute path to validate (must exist) + * @param repoRoot - absolute path of the repository/package root + * @throws if resolvedPath does not exist, or if it resolves outside repoRoot + */ +export function validateRealpathInsideRoot(resolvedPath: string, repoRoot: string): void { + let realPath: string; + try { + realPath = realpathSync(resolvedPath); + } catch { + throw new Error(`Path "${resolvedPath}" does not exist or cannot be resolved`); + } + const realRoot = realpathSync(repoRoot); + if (realPath !== realRoot && !realPath.startsWith(realRoot + '/')) { + throw new Error( + `Security: path resolves to "${realPath}" which is outside the package root "${realRoot}"`, + ); + } +} diff --git a/src/features/repertoire/tar-parser.ts b/src/features/repertoire/tar-parser.ts new file mode 100644 index 0000000..851b3a0 --- /dev/null +++ b/src/features/repertoire/tar-parser.ts @@ -0,0 +1,66 @@ +/** + * Parser for verbose tar listing output (BSD tar and GNU tar formats). + * + * Verbose tar (`tar tvzf`) emits one line per archive entry. The path + * appears after the timestamp field, which differs between implementations: + * BSD tar (macOS): HH:MM path + * GNU tar (Linux): HH:MM:SS path + */ + +import { extname } from 'node:path'; +import { ALLOWED_EXTENSIONS } from './file-filter.js'; + +/** + * Regex to extract the path from a verbose tar listing line. + * + * Matches both BSD (HH:MM) and GNU (HH:MM:SS) timestamp formats. + */ +const TAR_VERBOSE_PATH_RE = /\d{1,2}:\d{2}(?::\d{2})? (.+)$/; + +export interface TarVerboseListing { + /** The stripped top-level directory entry (commit SHA prefix). */ + firstDirEntry: string; + /** Archive paths of files that pass the extension filter. */ + includePaths: string[]; +} + +/** + * Parse a verbose tar listing into the top-level directory and filtered file paths. + * + * Skips: + * - Symlink entries (`l` type) + * - Directory entries (`d` type) + * - Files with extensions not in ALLOWED_EXTENSIONS + * + * @param lines - Non-empty lines from `tar tvzf` output + */ +export function parseTarVerboseListing(lines: string[]): TarVerboseListing { + let firstDirEntry = ''; + const includePaths: string[] = []; + + for (const [i, line] of lines.entries()) { + if (!line) continue; + const type = line[0]; + + const match = TAR_VERBOSE_PATH_RE.exec(line); + if (!match) continue; + const pathPart = match[1]; + if (!pathPart) continue; + + const archivePath = pathPart.trim(); + + if (i === 0) { + firstDirEntry = archivePath.replace(/\/$/, ''); + } + + if (type === 'd' || type === 'l') continue; + + const basename = archivePath.split('/').pop() ?? ''; + const ext = extname(basename); + if (!(ALLOWED_EXTENSIONS as readonly string[]).includes(ext)) continue; + + includePaths.push(archivePath); + } + + return { firstDirEntry, includePaths }; +} diff --git a/src/features/tasks/execute/inputWait.ts b/src/features/tasks/execute/inputWait.ts new file mode 100644 index 0000000..5c7ea52 --- /dev/null +++ b/src/features/tasks/execute/inputWait.ts @@ -0,0 +1,24 @@ +/** + * Shared input-wait state for worker pool log suppression. + * + * When a task is waiting for user input (e.g. iteration limit prompt), + * the worker pool should suppress poll_tick debug logs to avoid + * flooding the log file with identical entries. + */ + +let waitCount = 0; + +/** Call when entering an input-wait state (e.g. selectOption). */ +export function enterInputWait(): void { + waitCount++; +} + +/** Call when leaving an input-wait state. */ +export function leaveInputWait(): void { + if (waitCount > 0) waitCount--; +} + +/** Returns true if any task is currently waiting for user input. */ +export function isInputWaiting(): boolean { + return waitCount > 0; +} diff --git a/src/features/tasks/execute/parallelExecution.ts b/src/features/tasks/execute/parallelExecution.ts index 93b9dc6..da47da4 100644 --- a/src/features/tasks/execute/parallelExecution.ts +++ b/src/features/tasks/execute/parallelExecution.ts @@ -18,6 +18,7 @@ import { EXIT_SIGINT } from '../../../shared/exitCodes.js'; import { createLogger } from '../../../shared/utils/index.js'; import { executeAndCompleteTask } from './taskExecution.js'; import { ShutdownManager } from './shutdownManager.js'; +import { isInputWaiting } from './inputWait.js'; import type { TaskExecutionOptions } from './types.js'; const log = createLogger('worker-pool'); @@ -169,7 +170,7 @@ export async function runWithWorkerPool( } } - if (!abortController.signal.aborted) { + if (!abortController.signal.aborted && !isInputWaiting()) { const freeSlots = concurrency - active.size; if (freeSlots > 0) { const newTasks = taskRunner.claimNextTasks(freeSlots); diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index 1fdce46..6ae36d5 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -10,6 +10,7 @@ import type { PieceExecutionResult, PieceExecutionOptions } from './types.js'; import { detectRuleIndex } from '../../../shared/utils/ruleIndex.js'; import { interruptAllQueries } from '../../../infra/claude/query-manager.js'; import { callAiJudge } from '../../../agents/ai-judge.js'; +import { enterInputWait, leaveInputWait } from './inputWait.js'; export type { PieceExecutionResult, PieceExecutionOptions }; @@ -398,32 +399,37 @@ export async function executePiece( playWarningSound(); } - const action = await selectOption(getLabel('piece.iterationLimit.continueQuestion'), [ - { - label: getLabel('piece.iterationLimit.continueLabel'), - value: 'continue', - description: getLabel('piece.iterationLimit.continueDescription'), - }, - { label: getLabel('piece.iterationLimit.stopLabel'), value: 'stop' }, - ]); + enterInputWait(); + try { + const action = await selectOption(getLabel('piece.iterationLimit.continueQuestion'), [ + { + label: getLabel('piece.iterationLimit.continueLabel'), + value: 'continue', + description: getLabel('piece.iterationLimit.continueDescription'), + }, + { label: getLabel('piece.iterationLimit.stopLabel'), value: 'stop' }, + ]); - if (action !== 'continue') { - return null; - } - - while (true) { - const input = await promptInput(getLabel('piece.iterationLimit.inputPrompt')); - if (!input) { + if (action !== 'continue') { return null; } - const additionalIterations = Number.parseInt(input, 10); - if (Number.isInteger(additionalIterations) && additionalIterations > 0) { - pieceConfig.maxMovements = request.maxMovements + additionalIterations; - return additionalIterations; - } + while (true) { + const input = await promptInput(getLabel('piece.iterationLimit.inputPrompt')); + if (!input) { + return null; + } - out.warn(getLabel('piece.iterationLimit.invalidInput')); + const additionalIterations = Number.parseInt(input, 10); + if (Number.isInteger(additionalIterations) && additionalIterations > 0) { + pieceConfig.maxMovements = request.maxMovements + additionalIterations; + return additionalIterations; + } + + out.warn(getLabel('piece.iterationLimit.invalidInput')); + } + } finally { + leaveInputWait(); } }; diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index ab22c09..c717593 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -7,12 +7,11 @@ */ import { - listPieces, + loadPieceByIdentifier, isPiecePath, } from '../../../infra/config/index.js'; import { confirm } from '../../../shared/prompt/index.js'; import { createSharedClone, summarizeTaskName, getCurrentBranch, TaskRunner } from '../../../infra/task/index.js'; -import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import { info, error, withProgress } from '../../../shared/ui/index.js'; import { createLogger } from '../../../shared/utils/index.js'; import { executeTask } from './taskExecution.js'; @@ -30,9 +29,8 @@ export async function determinePiece(cwd: string, override?: string): Promise, + categorized: Set, +): PieceCategoryNode[] { + const packagePieces = new Map(); + for (const [pieceName] of allPieces.entries()) { + if (!pieceName.startsWith('@')) continue; + const withoutAt = pieceName.slice(1); + const firstSlash = withoutAt.indexOf('/'); + if (firstSlash < 0) continue; + const secondSlash = withoutAt.indexOf('/', firstSlash + 1); + if (secondSlash < 0) continue; + const owner = withoutAt.slice(0, firstSlash); + const repo = withoutAt.slice(firstSlash + 1, secondSlash); + const packageKey = `@${owner}/${repo}`; + const piecesList = packagePieces.get(packageKey) ?? []; + piecesList.push(pieceName); + packagePieces.set(packageKey, piecesList); + categorized.add(pieceName); + } + if (packagePieces.size === 0) return categories; + const repertoireChildren: PieceCategoryNode[] = []; + for (const [packageKey, pieces] of packagePieces.entries()) { + repertoireChildren.push({ name: packageKey, pieces, children: [] }); + } + return [...categories, { name: 'repertoire', pieces: [], children: repertoireChildren }]; +} + function appendOthersCategory( categories: PieceCategoryNode[], allPieces: Map, @@ -381,10 +415,11 @@ export function buildCategorizedPieces( const categorized = new Set(); const categories = buildCategoryTree(config.pieceCategories, allPieces, categorized); + const categoriesWithEnsemble = appendRepertoireCategory(categories, allPieces, categorized); const finalCategories = config.showOthersCategory - ? appendOthersCategory(categories, allPieces, categorized, config.othersCategoryName) - : categories; + ? appendOthersCategory(categoriesWithEnsemble, allPieces, categorized, config.othersCategoryName) + : categoriesWithEnsemble; return { categories: finalCategories, diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index fbedd07..ba5b2e8 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -12,6 +12,7 @@ import type { z } from 'zod'; import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js'; import type { PieceConfig, PieceMovement, PieceRule, OutputContractEntry, OutputContractItem, LoopMonitorConfig, LoopMonitorJudge, ArpeggioMovementConfig, ArpeggioMergeMovementConfig, TeamLeaderConfig } from '../../../core/models/index.js'; import { resolvePieceConfigValue } from '../resolvePieceConfigValue.js'; +import { getRepertoireDir } from '../paths.js'; import { type PieceSections, type FacetResolutionContext, @@ -441,6 +442,8 @@ export function loadPieceFromFile(filePath: string, projectDir: string): PieceCo const context: FacetResolutionContext = { lang: resolvePieceConfigValue(projectDir, 'language'), projectDir, + pieceDir, + repertoireDir: getRepertoireDir(), }; return normalizePieceConfig(raw, pieceDir, context); diff --git a/src/infra/config/loaders/pieceResolver.ts b/src/infra/config/loaders/pieceResolver.ts index 5b62385..b017790 100644 --- a/src/infra/config/loaders/pieceResolver.ts +++ b/src/infra/config/loaders/pieceResolver.ts @@ -9,14 +9,15 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { join, resolve, isAbsolute } from 'node:path'; import { homedir } from 'node:os'; import type { PieceConfig, PieceMovement, InteractiveMode } from '../../../core/models/index.js'; -import { getGlobalPiecesDir, getBuiltinPiecesDir, getProjectConfigDir } from '../paths.js'; +import { getGlobalPiecesDir, getBuiltinPiecesDir, getProjectConfigDir, getRepertoireDir } from '../paths.js'; +import { isScopeRef, parseScopeRef } from '../../../faceted-prompting/index.js'; import { resolvePieceConfigValues } from '../resolvePieceConfigValue.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { loadPieceFromFile } from './pieceParser.js'; const log = createLogger('piece-resolver'); -export type PieceSource = 'builtin' | 'user' | 'project'; +export type PieceSource = 'builtin' | 'user' | 'project' | 'repertoire'; export interface PieceWithSource { config: PieceConfig; @@ -136,12 +137,15 @@ export function isPiecePath(identifier: string): boolean { } /** - * Load piece by identifier (auto-detects name vs path). + * Load piece by identifier (auto-detects @scope ref, file path, or piece name). */ export function loadPieceByIdentifier( identifier: string, projectCwd: string, ): PieceConfig | null { + if (isScopeRef(identifier)) { + return loadRepertoirePieceByRef(identifier, projectCwd); + } if (isPiecePath(identifier)) { return loadPieceFromPath(identifier, projectCwd, projectCwd); } @@ -346,7 +350,8 @@ function* iteratePieceDir( if (!existsSync(dir)) return; for (const entry of readdirSync(dir)) { const entryPath = join(dir, entry); - const stat = statSync(entryPath); + let stat: ReturnType; + try { stat = statSync(entryPath); } catch (e) { log.debug(`stat failed for ${entryPath}: ${getErrorMessage(e)}`); continue; } if (stat.isFile() && (entry.endsWith('.yaml') || entry.endsWith('.yml'))) { const pieceName = entry.replace(/\.ya?ml$/, ''); @@ -355,13 +360,12 @@ function* iteratePieceDir( continue; } - // 1-level subdirectory scan: directory name becomes the category if (stat.isDirectory()) { const category = entry; for (const subEntry of readdirSync(entryPath)) { if (!subEntry.endsWith('.yaml') && !subEntry.endsWith('.yml')) continue; const subEntryPath = join(entryPath, subEntry); - if (!statSync(subEntryPath).isFile()) continue; + try { if (!statSync(subEntryPath).isFile()) continue; } catch (e) { log.debug(`stat failed for ${subEntryPath}: ${getErrorMessage(e)}`); continue; } const pieceName = subEntry.replace(/\.ya?ml$/, ''); const qualifiedName = `${category}/${pieceName}`; if (disabled?.includes(qualifiedName)) continue; @@ -371,6 +375,46 @@ function* iteratePieceDir( } } +/** + * Iterate piece YAML files in all repertoire packages. + * Qualified name format: @{owner}/{repo}/{piece-name} + */ +function* iterateRepertoirePieces(repertoireDir: string): Generator { + if (!existsSync(repertoireDir)) return; + for (const ownerEntry of readdirSync(repertoireDir)) { + if (!ownerEntry.startsWith('@')) continue; + const ownerPath = join(repertoireDir, ownerEntry); + try { if (!statSync(ownerPath).isDirectory()) continue; } catch (e) { log.debug(`stat failed for owner dir ${ownerPath}: ${getErrorMessage(e)}`); continue; } + const owner = ownerEntry.slice(1); + for (const repoEntry of readdirSync(ownerPath)) { + const repoPath = join(ownerPath, repoEntry); + try { if (!statSync(repoPath).isDirectory()) continue; } catch (e) { log.debug(`stat failed for repo dir ${repoPath}: ${getErrorMessage(e)}`); continue; } + const piecesDir = join(repoPath, 'pieces'); + if (!existsSync(piecesDir)) continue; + for (const pieceFile of readdirSync(piecesDir)) { + if (!pieceFile.endsWith('.yaml') && !pieceFile.endsWith('.yml')) continue; + const piecePath = join(piecesDir, pieceFile); + try { if (!statSync(piecePath).isFile()) continue; } catch (e) { log.debug(`stat failed for piece file ${piecePath}: ${getErrorMessage(e)}`); continue; } + const pieceName = pieceFile.replace(/\.ya?ml$/, ''); + yield { name: `@${owner}/${repoEntry}/${pieceName}`, path: piecePath, source: 'repertoire' }; + } + } + } +} + +/** + * Load a piece by @scope reference (@{owner}/{repo}/{piece-name}). + * Resolves to ~/.takt/repertoire/@{owner}/{repo}/pieces/{piece-name}.yaml + */ +function loadRepertoirePieceByRef(identifier: string, projectCwd: string): PieceConfig | null { + const scopeRef = parseScopeRef(identifier); + const repertoireDir = getRepertoireDir(); + const piecesDir = join(repertoireDir, `@${scopeRef.owner}`, scopeRef.repo, 'pieces'); + const filePath = resolvePieceFile(piecesDir, scopeRef.name); + if (!filePath) return null; + return loadPieceFromFile(filePath, projectCwd); +} + /** Get the 3-layer directory list (builtin → user → project-local) */ function getPieceDirs(cwd: string): { dir: string; source: PieceSource; disabled?: string[] }[] { const config = resolvePieceConfigValues(cwd, ['enableBuiltinPieces', 'language', 'disabledBuiltins']); @@ -406,6 +450,15 @@ export function loadAllPiecesWithSources(cwd: string): Map 0 ? contents : undefined; } /** Resolve persona from YAML field to spec + absolute path. */ @@ -122,8 +201,13 @@ export function resolvePersona( pieceDir: string, context?: FacetResolutionContext, ): { personaSpec?: string; personaPath?: string } { + if (rawPersona && isScopeRef(rawPersona) && context?.repertoireDir) { + const scopeRef = parseScopeRef(rawPersona); + const personaPath = resolveScopeRef(scopeRef, 'personas', context.repertoireDir); + return { personaSpec: rawPersona, personaPath: existsSync(personaPath) ? personaPath : undefined }; + } const candidateDirs = context - ? buildCandidateDirs('personas', context) + ? buildCandidateDirsWithPackage('personas', context) : undefined; return resolvePersonaGeneric(rawPersona, sections, pieceDir, candidateDirs); } diff --git a/src/infra/config/paths.ts b/src/infra/config/paths.ts index 214950b..d1cc86b 100644 --- a/src/infra/config/paths.ts +++ b/src/infra/config/paths.ts @@ -12,6 +12,7 @@ import type { Language } from '../../core/models/index.js'; import { getLanguageResourcesDir } from '../resources/index.js'; import type { FacetKind } from '../../faceted-prompting/index.js'; +import { REPERTOIRE_DIR_NAME } from '../../features/repertoire/constants.js'; /** Facet types used in layer resolution */ export type { FacetKind as FacetType } from '../../faceted-prompting/index.js'; @@ -48,9 +49,9 @@ export function getBuiltinPiecesDir(lang: Language): string { return join(getLanguageResourcesDir(lang), 'pieces'); } -/** Get builtin personas directory (builtins/{lang}/personas) */ +/** Get builtin personas directory (builtins/{lang}/facets/personas) */ export function getBuiltinPersonasDir(lang: Language): string { - return join(getLanguageResourcesDir(lang), 'personas'); + return join(getLanguageResourcesDir(lang), 'facets', 'personas'); } /** Get project takt config directory (.takt in project) */ @@ -90,19 +91,41 @@ export function ensureDir(dirPath: string): void { } } -/** Get project facet directory (.takt/{facetType} in project) */ +/** Get project facet directory (.takt/facets/{facetType} in project) */ export function getProjectFacetDir(projectDir: string, facetType: FacetType): string { - return join(getProjectConfigDir(projectDir), facetType); + return join(getProjectConfigDir(projectDir), 'facets', facetType); } -/** Get global facet directory (~/.takt/{facetType}) */ +/** Get global facet directory (~/.takt/facets/{facetType}) */ export function getGlobalFacetDir(facetType: FacetType): string { - return join(getGlobalConfigDir(), facetType); + return join(getGlobalConfigDir(), 'facets', facetType); } -/** Get builtin facet directory (builtins/{lang}/{facetType}) */ +/** Get builtin facet directory (builtins/{lang}/facets/{facetType}) */ export function getBuiltinFacetDir(lang: Language, facetType: FacetType): string { - return join(getLanguageResourcesDir(lang), facetType); + return join(getLanguageResourcesDir(lang), 'facets', facetType); +} + +/** Get repertoire directory (~/.takt/repertoire/) */ +export function getRepertoireDir(): string { + return join(getGlobalConfigDir(), REPERTOIRE_DIR_NAME); +} + +/** Get repertoire package directory (~/.takt/repertoire/@{owner}/{repo}/) */ +export function getRepertoirePackageDir(owner: string, repo: string): string { + return join(getRepertoireDir(), `@${owner}`, repo); +} + +/** + * Get repertoire facet directory. + * + * Defaults to the global repertoire dir when repertoireDir is not specified. + * Pass repertoireDir explicitly when resolving facets within a custom repertoire root + * (e.g. the package-local resolution layer). + */ +export function getRepertoireFacetDir(owner: string, repo: string, facetType: FacetType, repertoireDir?: string): string { + const base = repertoireDir ?? getRepertoireDir(); + return join(base, `@${owner}`, repo, 'facets', facetType); } /** Validate path is safe (no directory traversal) */ diff --git a/src/shared/prompt/confirm.ts b/src/shared/prompt/confirm.ts index 8f51668..7450c60 100644 --- a/src/shared/prompt/confirm.ts +++ b/src/shared/prompt/confirm.ts @@ -97,6 +97,10 @@ export async function confirm(message: string, defaultYes = true): Promise { + const rl = readline.createInterface({ input: process.stdin }); + + return new Promise((resolve) => { + let resolved = false; + + rl.once('line', (line) => { + resolved = true; + rl.close(); + pauseStdinSafely(); + const trimmed = line.trim().toLowerCase(); + if (!trimmed) { + resolve(defaultYes); + return; + } + resolve(trimmed === 'y' || trimmed === 'yes'); + }); + + rl.once('close', () => { + if (!resolved) { + resolve(defaultYes); + } + }); + }); +} diff --git a/vitest.config.e2e.mock.ts b/vitest.config.e2e.mock.ts index 96f8b00..7a7cc28 100644 --- a/vitest.config.e2e.mock.ts +++ b/vitest.config.e2e.mock.ts @@ -36,6 +36,9 @@ export default defineConfig({ 'e2e/specs/quiet-mode.e2e.ts', 'e2e/specs/task-content-file.e2e.ts', 'e2e/specs/config-priority.e2e.ts', + 'e2e/specs/ensemble.e2e.ts', + 'e2e/specs/ensemble-real.e2e.ts', + 'e2e/specs/piece-selection-branches.e2e.ts', ], }, });