This is page 55 of 69. Use http://codebase.md/eyaltoledano/claude-task-master?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .changeset
│ ├── config.json
│ └── README.md
├── .claude
│ ├── commands
│ │ └── dedupe.md
│ └── TM_COMMANDS_GUIDE.md
├── .claude-plugin
│ └── marketplace.json
├── .coderabbit.yaml
├── .cursor
│ ├── mcp.json
│ └── rules
│ ├── ai_providers.mdc
│ ├── ai_services.mdc
│ ├── architecture.mdc
│ ├── changeset.mdc
│ ├── commands.mdc
│ ├── context_gathering.mdc
│ ├── cursor_rules.mdc
│ ├── dependencies.mdc
│ ├── dev_workflow.mdc
│ ├── git_workflow.mdc
│ ├── glossary.mdc
│ ├── mcp.mdc
│ ├── new_features.mdc
│ ├── self_improve.mdc
│ ├── tags.mdc
│ ├── taskmaster.mdc
│ ├── tasks.mdc
│ ├── telemetry.mdc
│ ├── test_workflow.mdc
│ ├── tests.mdc
│ ├── ui.mdc
│ └── utilities.mdc
├── .cursorignore
├── .env.example
├── .github
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.md
│ │ ├── enhancements---feature-requests.md
│ │ └── feedback.md
│ ├── PULL_REQUEST_TEMPLATE
│ │ ├── bugfix.md
│ │ ├── config.yml
│ │ ├── feature.md
│ │ └── integration.md
│ ├── PULL_REQUEST_TEMPLATE.md
│ ├── scripts
│ │ ├── auto-close-duplicates.mjs
│ │ ├── backfill-duplicate-comments.mjs
│ │ ├── check-pre-release-mode.mjs
│ │ ├── parse-metrics.mjs
│ │ ├── release.mjs
│ │ ├── tag-extension.mjs
│ │ ├── utils.mjs
│ │ └── validate-changesets.mjs
│ └── workflows
│ ├── auto-close-duplicates.yml
│ ├── backfill-duplicate-comments.yml
│ ├── ci.yml
│ ├── claude-dedupe-issues.yml
│ ├── claude-docs-trigger.yml
│ ├── claude-docs-updater.yml
│ ├── claude-issue-triage.yml
│ ├── claude.yml
│ ├── extension-ci.yml
│ ├── extension-release.yml
│ ├── log-issue-events.yml
│ ├── pre-release.yml
│ ├── release-check.yml
│ ├── release.yml
│ ├── update-models-md.yml
│ └── weekly-metrics-discord.yml
├── .gitignore
├── .kiro
│ ├── hooks
│ │ ├── tm-code-change-task-tracker.kiro.hook
│ │ ├── tm-complexity-analyzer.kiro.hook
│ │ ├── tm-daily-standup-assistant.kiro.hook
│ │ ├── tm-git-commit-task-linker.kiro.hook
│ │ ├── tm-pr-readiness-checker.kiro.hook
│ │ ├── tm-task-dependency-auto-progression.kiro.hook
│ │ └── tm-test-success-task-completer.kiro.hook
│ ├── settings
│ │ └── mcp.json
│ └── steering
│ ├── dev_workflow.md
│ ├── kiro_rules.md
│ ├── self_improve.md
│ ├── taskmaster_hooks_workflow.md
│ └── taskmaster.md
├── .manypkg.json
├── .mcp.json
├── .npmignore
├── .nvmrc
├── .taskmaster
│ ├── CLAUDE.md
│ ├── config.json
│ ├── docs
│ │ ├── autonomous-tdd-git-workflow.md
│ │ ├── MIGRATION-ROADMAP.md
│ │ ├── prd-tm-start.txt
│ │ ├── prd.txt
│ │ ├── README.md
│ │ ├── research
│ │ │ ├── 2025-06-14_how-can-i-improve-the-scope-up-and-scope-down-comm.md
│ │ │ ├── 2025-06-14_should-i-be-using-any-specific-libraries-for-this.md
│ │ │ ├── 2025-06-14_test-save-functionality.md
│ │ │ ├── 2025-06-14_test-the-fix-for-duplicate-saves-final-test.md
│ │ │ └── 2025-08-01_do-we-need-to-add-new-commands-or-can-we-just-weap.md
│ │ ├── task-template-importing-prd.txt
│ │ ├── tdd-workflow-phase-0-spike.md
│ │ ├── tdd-workflow-phase-1-core-rails.md
│ │ ├── tdd-workflow-phase-1-orchestrator.md
│ │ ├── tdd-workflow-phase-2-pr-resumability.md
│ │ ├── tdd-workflow-phase-3-extensibility-guardrails.md
│ │ ├── test-prd.txt
│ │ └── tm-core-phase-1.txt
│ ├── reports
│ │ ├── task-complexity-report_autonomous-tdd-git-workflow.json
│ │ ├── task-complexity-report_cc-kiro-hooks.json
│ │ ├── task-complexity-report_tdd-phase-1-core-rails.json
│ │ ├── task-complexity-report_tdd-workflow-phase-0.json
│ │ ├── task-complexity-report_test-prd-tag.json
│ │ ├── task-complexity-report_tm-core-phase-1.json
│ │ ├── task-complexity-report.json
│ │ └── tm-core-complexity.json
│ ├── state.json
│ ├── tasks
│ │ ├── task_001_tm-start.txt
│ │ ├── task_002_tm-start.txt
│ │ ├── task_003_tm-start.txt
│ │ ├── task_004_tm-start.txt
│ │ ├── task_007_tm-start.txt
│ │ └── tasks.json
│ └── templates
│ ├── example_prd_rpg.md
│ └── example_prd.md
├── .vscode
│ ├── extensions.json
│ └── settings.json
├── apps
│ ├── cli
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── command-registry.ts
│ │ │ ├── commands
│ │ │ │ ├── auth.command.ts
│ │ │ │ ├── autopilot
│ │ │ │ │ ├── abort.command.ts
│ │ │ │ │ ├── commit.command.ts
│ │ │ │ │ ├── complete.command.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── next.command.ts
│ │ │ │ │ ├── resume.command.ts
│ │ │ │ │ ├── shared.ts
│ │ │ │ │ ├── start.command.ts
│ │ │ │ │ └── status.command.ts
│ │ │ │ ├── briefs.command.ts
│ │ │ │ ├── context.command.ts
│ │ │ │ ├── export.command.ts
│ │ │ │ ├── list.command.ts
│ │ │ │ ├── models
│ │ │ │ │ ├── custom-providers.ts
│ │ │ │ │ ├── fetchers.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── prompts.ts
│ │ │ │ │ ├── setup.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── next.command.ts
│ │ │ │ ├── set-status.command.ts
│ │ │ │ ├── show.command.ts
│ │ │ │ ├── start.command.ts
│ │ │ │ └── tags.command.ts
│ │ │ ├── index.ts
│ │ │ ├── lib
│ │ │ │ └── model-management.ts
│ │ │ ├── types
│ │ │ │ └── tag-management.d.ts
│ │ │ ├── ui
│ │ │ │ ├── components
│ │ │ │ │ ├── cardBox.component.ts
│ │ │ │ │ ├── dashboard.component.ts
│ │ │ │ │ ├── header.component.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── next-task.component.ts
│ │ │ │ │ ├── suggested-steps.component.ts
│ │ │ │ │ └── task-detail.component.ts
│ │ │ │ ├── display
│ │ │ │ │ ├── messages.ts
│ │ │ │ │ └── tables.ts
│ │ │ │ ├── formatters
│ │ │ │ │ ├── complexity-formatters.ts
│ │ │ │ │ ├── dependency-formatters.ts
│ │ │ │ │ ├── priority-formatters.ts
│ │ │ │ │ ├── status-formatters.spec.ts
│ │ │ │ │ └── status-formatters.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── layout
│ │ │ │ ├── helpers.spec.ts
│ │ │ │ └── helpers.ts
│ │ │ └── utils
│ │ │ ├── auth-helpers.ts
│ │ │ ├── auto-update.ts
│ │ │ ├── brief-selection.ts
│ │ │ ├── display-helpers.ts
│ │ │ ├── error-handler.ts
│ │ │ ├── index.ts
│ │ │ ├── project-root.ts
│ │ │ ├── task-status.ts
│ │ │ ├── ui.spec.ts
│ │ │ └── ui.ts
│ │ ├── tests
│ │ │ ├── integration
│ │ │ │ └── commands
│ │ │ │ └── autopilot
│ │ │ │ └── workflow.test.ts
│ │ │ └── unit
│ │ │ ├── commands
│ │ │ │ ├── autopilot
│ │ │ │ │ └── shared.test.ts
│ │ │ │ ├── list.command.spec.ts
│ │ │ │ └── show.command.spec.ts
│ │ │ └── ui
│ │ │ └── dashboard.component.spec.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── docs
│ │ ├── archive
│ │ │ ├── ai-client-utils-example.mdx
│ │ │ ├── ai-development-workflow.mdx
│ │ │ ├── command-reference.mdx
│ │ │ ├── configuration.mdx
│ │ │ ├── cursor-setup.mdx
│ │ │ ├── examples.mdx
│ │ │ └── Installation.mdx
│ │ ├── best-practices
│ │ │ ├── advanced-tasks.mdx
│ │ │ ├── configuration-advanced.mdx
│ │ │ └── index.mdx
│ │ ├── capabilities
│ │ │ ├── cli-root-commands.mdx
│ │ │ ├── index.mdx
│ │ │ ├── mcp.mdx
│ │ │ ├── rpg-method.mdx
│ │ │ └── task-structure.mdx
│ │ ├── CHANGELOG.md
│ │ ├── command-reference.mdx
│ │ ├── configuration.mdx
│ │ ├── docs.json
│ │ ├── favicon.svg
│ │ ├── getting-started
│ │ │ ├── api-keys.mdx
│ │ │ ├── contribute.mdx
│ │ │ ├── faq.mdx
│ │ │ └── quick-start
│ │ │ ├── configuration-quick.mdx
│ │ │ ├── execute-quick.mdx
│ │ │ ├── installation.mdx
│ │ │ ├── moving-forward.mdx
│ │ │ ├── prd-quick.mdx
│ │ │ ├── quick-start.mdx
│ │ │ ├── requirements.mdx
│ │ │ ├── rules-quick.mdx
│ │ │ └── tasks-quick.mdx
│ │ ├── introduction.mdx
│ │ ├── licensing.md
│ │ ├── logo
│ │ │ ├── dark.svg
│ │ │ ├── light.svg
│ │ │ └── task-master-logo.png
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── style.css
│ │ ├── tdd-workflow
│ │ │ ├── ai-agent-integration.mdx
│ │ │ └── quickstart.mdx
│ │ ├── vercel.json
│ │ └── whats-new.mdx
│ ├── extension
│ │ ├── .vscodeignore
│ │ ├── assets
│ │ │ ├── banner.png
│ │ │ ├── icon-dark.svg
│ │ │ ├── icon-light.svg
│ │ │ ├── icon.png
│ │ │ ├── screenshots
│ │ │ │ ├── kanban-board.png
│ │ │ │ └── task-details.png
│ │ │ └── sidebar-icon.svg
│ │ ├── CHANGELOG.md
│ │ ├── components.json
│ │ ├── docs
│ │ │ ├── extension-CI-setup.md
│ │ │ └── extension-development-guide.md
│ │ ├── esbuild.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ ├── package.mjs
│ │ ├── package.publish.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── components
│ │ │ │ ├── ConfigView.tsx
│ │ │ │ ├── constants.ts
│ │ │ │ ├── TaskDetails
│ │ │ │ │ ├── AIActionsSection.tsx
│ │ │ │ │ ├── DetailsSection.tsx
│ │ │ │ │ ├── PriorityBadge.tsx
│ │ │ │ │ ├── SubtasksSection.tsx
│ │ │ │ │ ├── TaskMetadataSidebar.tsx
│ │ │ │ │ └── useTaskDetails.ts
│ │ │ │ ├── TaskDetailsView.tsx
│ │ │ │ ├── TaskMasterLogo.tsx
│ │ │ │ └── ui
│ │ │ │ ├── badge.tsx
│ │ │ │ ├── breadcrumb.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── collapsible.tsx
│ │ │ │ ├── CollapsibleSection.tsx
│ │ │ │ ├── dropdown-menu.tsx
│ │ │ │ ├── label.tsx
│ │ │ │ ├── scroll-area.tsx
│ │ │ │ ├── separator.tsx
│ │ │ │ ├── shadcn-io
│ │ │ │ │ └── kanban
│ │ │ │ │ └── index.tsx
│ │ │ │ └── textarea.tsx
│ │ │ ├── extension.ts
│ │ │ ├── index.ts
│ │ │ ├── lib
│ │ │ │ └── utils.ts
│ │ │ ├── services
│ │ │ │ ├── config-service.ts
│ │ │ │ ├── error-handler.ts
│ │ │ │ ├── notification-preferences.ts
│ │ │ │ ├── polling-service.ts
│ │ │ │ ├── polling-strategies.ts
│ │ │ │ ├── sidebar-webview-manager.ts
│ │ │ │ ├── task-repository.ts
│ │ │ │ ├── terminal-manager.ts
│ │ │ │ └── webview-manager.ts
│ │ │ ├── test
│ │ │ │ └── extension.test.ts
│ │ │ ├── utils
│ │ │ │ ├── configManager.ts
│ │ │ │ ├── connectionManager.ts
│ │ │ │ ├── errorHandler.ts
│ │ │ │ ├── event-emitter.ts
│ │ │ │ ├── logger.ts
│ │ │ │ ├── mcpClient.ts
│ │ │ │ ├── notificationPreferences.ts
│ │ │ │ └── task-master-api
│ │ │ │ ├── cache
│ │ │ │ │ └── cache-manager.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── mcp-client.ts
│ │ │ │ ├── transformers
│ │ │ │ │ └── task-transformer.ts
│ │ │ │ └── types
│ │ │ │ └── index.ts
│ │ │ └── webview
│ │ │ ├── App.tsx
│ │ │ ├── components
│ │ │ │ ├── AppContent.tsx
│ │ │ │ ├── EmptyState.tsx
│ │ │ │ ├── ErrorBoundary.tsx
│ │ │ │ ├── PollingStatus.tsx
│ │ │ │ ├── PriorityBadge.tsx
│ │ │ │ ├── SidebarView.tsx
│ │ │ │ ├── TagDropdown.tsx
│ │ │ │ ├── TaskCard.tsx
│ │ │ │ ├── TaskEditModal.tsx
│ │ │ │ ├── TaskMasterKanban.tsx
│ │ │ │ ├── ToastContainer.tsx
│ │ │ │ └── ToastNotification.tsx
│ │ │ ├── constants
│ │ │ │ └── index.ts
│ │ │ ├── contexts
│ │ │ │ └── VSCodeContext.tsx
│ │ │ ├── hooks
│ │ │ │ ├── useTaskQueries.ts
│ │ │ │ ├── useVSCodeMessages.ts
│ │ │ │ └── useWebviewHeight.ts
│ │ │ ├── index.css
│ │ │ ├── index.tsx
│ │ │ ├── providers
│ │ │ │ └── QueryProvider.tsx
│ │ │ ├── reducers
│ │ │ │ └── appReducer.ts
│ │ │ ├── sidebar.tsx
│ │ │ ├── types
│ │ │ │ └── index.ts
│ │ │ └── utils
│ │ │ ├── logger.ts
│ │ │ └── toast.ts
│ │ └── tsconfig.json
│ └── mcp
│ ├── CHANGELOG.md
│ ├── package.json
│ ├── src
│ │ ├── index.ts
│ │ ├── shared
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ └── tools
│ │ ├── autopilot
│ │ │ ├── abort.tool.ts
│ │ │ ├── commit.tool.ts
│ │ │ ├── complete.tool.ts
│ │ │ ├── finalize.tool.ts
│ │ │ ├── index.ts
│ │ │ ├── next.tool.ts
│ │ │ ├── resume.tool.ts
│ │ │ ├── start.tool.ts
│ │ │ └── status.tool.ts
│ │ ├── README-ZOD-V3.md
│ │ └── tasks
│ │ ├── get-task.tool.ts
│ │ ├── get-tasks.tool.ts
│ │ └── index.ts
│ ├── tsconfig.json
│ └── vitest.config.ts
├── assets
│ ├── .windsurfrules
│ ├── AGENTS.md
│ ├── claude
│ │ └── TM_COMMANDS_GUIDE.md
│ ├── config.json
│ ├── env.example
│ ├── example_prd_rpg.txt
│ ├── example_prd.txt
│ ├── GEMINI.md
│ ├── gitignore
│ ├── kiro-hooks
│ │ ├── tm-code-change-task-tracker.kiro.hook
│ │ ├── tm-complexity-analyzer.kiro.hook
│ │ ├── tm-daily-standup-assistant.kiro.hook
│ │ ├── tm-git-commit-task-linker.kiro.hook
│ │ ├── tm-pr-readiness-checker.kiro.hook
│ │ ├── tm-task-dependency-auto-progression.kiro.hook
│ │ └── tm-test-success-task-completer.kiro.hook
│ ├── roocode
│ │ ├── .roo
│ │ │ ├── rules-architect
│ │ │ │ └── architect-rules
│ │ │ ├── rules-ask
│ │ │ │ └── ask-rules
│ │ │ ├── rules-code
│ │ │ │ └── code-rules
│ │ │ ├── rules-debug
│ │ │ │ └── debug-rules
│ │ │ ├── rules-orchestrator
│ │ │ │ └── orchestrator-rules
│ │ │ └── rules-test
│ │ │ └── test-rules
│ │ └── .roomodes
│ ├── rules
│ │ ├── cursor_rules.mdc
│ │ ├── dev_workflow.mdc
│ │ ├── self_improve.mdc
│ │ ├── taskmaster_hooks_workflow.mdc
│ │ └── taskmaster.mdc
│ └── scripts_README.md
├── bin
│ └── task-master.js
├── biome.json
├── CHANGELOG.md
├── CLAUDE_CODE_PLUGIN.md
├── CLAUDE.md
├── context
│ ├── chats
│ │ ├── add-task-dependencies-1.md
│ │ └── max-min-tokens.txt.md
│ ├── fastmcp-core.txt
│ ├── fastmcp-docs.txt
│ ├── MCP_INTEGRATION.md
│ ├── mcp-js-sdk-docs.txt
│ ├── mcp-protocol-repo.txt
│ ├── mcp-protocol-schema-03262025.json
│ └── mcp-protocol-spec.txt
├── CONTRIBUTING.md
├── docs
│ ├── claude-code-integration.md
│ ├── CLI-COMMANDER-PATTERN.md
│ ├── command-reference.md
│ ├── configuration.md
│ ├── contributor-docs
│ │ ├── testing-roo-integration.md
│ │ └── worktree-setup.md
│ ├── cross-tag-task-movement.md
│ ├── examples
│ │ ├── claude-code-usage.md
│ │ └── codex-cli-usage.md
│ ├── examples.md
│ ├── licensing.md
│ ├── mcp-provider-guide.md
│ ├── mcp-provider.md
│ ├── migration-guide.md
│ ├── models.md
│ ├── providers
│ │ ├── codex-cli.md
│ │ └── gemini-cli.md
│ ├── README.md
│ ├── scripts
│ │ └── models-json-to-markdown.js
│ ├── task-structure.md
│ └── tutorial.md
├── images
│ ├── hamster-hiring.png
│ └── logo.png
├── index.js
├── jest.config.js
├── jest.resolver.cjs
├── LICENSE
├── llms-install.md
├── mcp-server
│ ├── server.js
│ └── src
│ ├── core
│ │ ├── __tests__
│ │ │ └── context-manager.test.js
│ │ ├── context-manager.js
│ │ ├── direct-functions
│ │ │ ├── add-dependency.js
│ │ │ ├── add-subtask.js
│ │ │ ├── add-tag.js
│ │ │ ├── add-task.js
│ │ │ ├── analyze-task-complexity.js
│ │ │ ├── cache-stats.js
│ │ │ ├── clear-subtasks.js
│ │ │ ├── complexity-report.js
│ │ │ ├── copy-tag.js
│ │ │ ├── create-tag-from-branch.js
│ │ │ ├── delete-tag.js
│ │ │ ├── expand-all-tasks.js
│ │ │ ├── expand-task.js
│ │ │ ├── fix-dependencies.js
│ │ │ ├── generate-task-files.js
│ │ │ ├── initialize-project.js
│ │ │ ├── list-tags.js
│ │ │ ├── models.js
│ │ │ ├── move-task-cross-tag.js
│ │ │ ├── move-task.js
│ │ │ ├── next-task.js
│ │ │ ├── parse-prd.js
│ │ │ ├── remove-dependency.js
│ │ │ ├── remove-subtask.js
│ │ │ ├── remove-task.js
│ │ │ ├── rename-tag.js
│ │ │ ├── research.js
│ │ │ ├── response-language.js
│ │ │ ├── rules.js
│ │ │ ├── scope-down.js
│ │ │ ├── scope-up.js
│ │ │ ├── set-task-status.js
│ │ │ ├── update-subtask-by-id.js
│ │ │ ├── update-task-by-id.js
│ │ │ ├── update-tasks.js
│ │ │ ├── use-tag.js
│ │ │ └── validate-dependencies.js
│ │ ├── task-master-core.js
│ │ └── utils
│ │ ├── env-utils.js
│ │ └── path-utils.js
│ ├── custom-sdk
│ │ ├── errors.js
│ │ ├── index.js
│ │ ├── json-extractor.js
│ │ ├── language-model.js
│ │ ├── message-converter.js
│ │ └── schema-converter.js
│ ├── index.js
│ ├── logger.js
│ ├── providers
│ │ └── mcp-provider.js
│ └── tools
│ ├── add-dependency.js
│ ├── add-subtask.js
│ ├── add-tag.js
│ ├── add-task.js
│ ├── analyze.js
│ ├── clear-subtasks.js
│ ├── complexity-report.js
│ ├── copy-tag.js
│ ├── delete-tag.js
│ ├── expand-all.js
│ ├── expand-task.js
│ ├── fix-dependencies.js
│ ├── generate.js
│ ├── get-operation-status.js
│ ├── index.js
│ ├── initialize-project.js
│ ├── list-tags.js
│ ├── models.js
│ ├── move-task.js
│ ├── next-task.js
│ ├── parse-prd.js
│ ├── README-ZOD-V3.md
│ ├── remove-dependency.js
│ ├── remove-subtask.js
│ ├── remove-task.js
│ ├── rename-tag.js
│ ├── research.js
│ ├── response-language.js
│ ├── rules.js
│ ├── scope-down.js
│ ├── scope-up.js
│ ├── set-task-status.js
│ ├── tool-registry.js
│ ├── update-subtask.js
│ ├── update-task.js
│ ├── update.js
│ ├── use-tag.js
│ ├── utils.js
│ └── validate-dependencies.js
├── mcp-test.js
├── output.json
├── package-lock.json
├── package.json
├── packages
│ ├── ai-sdk-provider-grok-cli
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── errors.test.ts
│ │ │ ├── errors.ts
│ │ │ ├── grok-cli-language-model.ts
│ │ │ ├── grok-cli-provider.test.ts
│ │ │ ├── grok-cli-provider.ts
│ │ │ ├── index.ts
│ │ │ ├── json-extractor.test.ts
│ │ │ ├── json-extractor.ts
│ │ │ ├── message-converter.test.ts
│ │ │ ├── message-converter.ts
│ │ │ └── types.ts
│ │ └── tsconfig.json
│ ├── build-config
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── src
│ │ │ └── tsdown.base.ts
│ │ └── tsconfig.json
│ ├── claude-code-plugin
│ │ ├── .claude-plugin
│ │ │ └── plugin.json
│ │ ├── .gitignore
│ │ ├── agents
│ │ │ ├── task-checker.md
│ │ │ ├── task-executor.md
│ │ │ └── task-orchestrator.md
│ │ ├── CHANGELOG.md
│ │ ├── commands
│ │ │ ├── add-dependency.md
│ │ │ ├── add-subtask.md
│ │ │ ├── add-task.md
│ │ │ ├── analyze-complexity.md
│ │ │ ├── analyze-project.md
│ │ │ ├── auto-implement-tasks.md
│ │ │ ├── command-pipeline.md
│ │ │ ├── complexity-report.md
│ │ │ ├── convert-task-to-subtask.md
│ │ │ ├── expand-all-tasks.md
│ │ │ ├── expand-task.md
│ │ │ ├── fix-dependencies.md
│ │ │ ├── generate-tasks.md
│ │ │ ├── help.md
│ │ │ ├── init-project-quick.md
│ │ │ ├── init-project.md
│ │ │ ├── install-taskmaster.md
│ │ │ ├── learn.md
│ │ │ ├── list-tasks-by-status.md
│ │ │ ├── list-tasks-with-subtasks.md
│ │ │ ├── list-tasks.md
│ │ │ ├── next-task.md
│ │ │ ├── parse-prd-with-research.md
│ │ │ ├── parse-prd.md
│ │ │ ├── project-status.md
│ │ │ ├── quick-install-taskmaster.md
│ │ │ ├── remove-all-subtasks.md
│ │ │ ├── remove-dependency.md
│ │ │ ├── remove-subtask.md
│ │ │ ├── remove-subtasks.md
│ │ │ ├── remove-task.md
│ │ │ ├── setup-models.md
│ │ │ ├── show-task.md
│ │ │ ├── smart-workflow.md
│ │ │ ├── sync-readme.md
│ │ │ ├── tm-main.md
│ │ │ ├── to-cancelled.md
│ │ │ ├── to-deferred.md
│ │ │ ├── to-done.md
│ │ │ ├── to-in-progress.md
│ │ │ ├── to-pending.md
│ │ │ ├── to-review.md
│ │ │ ├── update-single-task.md
│ │ │ ├── update-task.md
│ │ │ ├── update-tasks-from-id.md
│ │ │ ├── validate-dependencies.md
│ │ │ └── view-models.md
│ │ ├── mcp.json
│ │ └── package.json
│ ├── tm-bridge
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── add-tag-bridge.ts
│ │ │ ├── bridge-types.ts
│ │ │ ├── bridge-utils.ts
│ │ │ ├── expand-bridge.ts
│ │ │ ├── index.ts
│ │ │ ├── tags-bridge.ts
│ │ │ ├── update-bridge.ts
│ │ │ └── use-tag-bridge.ts
│ │ └── tsconfig.json
│ └── tm-core
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── docs
│ │ └── listTasks-architecture.md
│ ├── package.json
│ ├── POC-STATUS.md
│ ├── README.md
│ ├── src
│ │ ├── common
│ │ │ ├── constants
│ │ │ │ ├── index.ts
│ │ │ │ ├── paths.ts
│ │ │ │ └── providers.ts
│ │ │ ├── errors
│ │ │ │ ├── index.ts
│ │ │ │ └── task-master-error.ts
│ │ │ ├── interfaces
│ │ │ │ ├── configuration.interface.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── storage.interface.ts
│ │ │ ├── logger
│ │ │ │ ├── factory.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── logger.spec.ts
│ │ │ │ └── logger.ts
│ │ │ ├── mappers
│ │ │ │ ├── TaskMapper.test.ts
│ │ │ │ └── TaskMapper.ts
│ │ │ ├── types
│ │ │ │ ├── database.types.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── legacy.ts
│ │ │ │ └── repository-types.ts
│ │ │ └── utils
│ │ │ ├── git-utils.ts
│ │ │ ├── id-generator.ts
│ │ │ ├── index.ts
│ │ │ ├── path-helpers.ts
│ │ │ ├── path-normalizer.spec.ts
│ │ │ ├── path-normalizer.ts
│ │ │ ├── project-root-finder.spec.ts
│ │ │ ├── project-root-finder.ts
│ │ │ ├── run-id-generator.spec.ts
│ │ │ └── run-id-generator.ts
│ │ ├── index.ts
│ │ ├── modules
│ │ │ ├── ai
│ │ │ │ ├── index.ts
│ │ │ │ ├── interfaces
│ │ │ │ │ └── ai-provider.interface.ts
│ │ │ │ └── providers
│ │ │ │ ├── base-provider.ts
│ │ │ │ └── index.ts
│ │ │ ├── auth
│ │ │ │ ├── auth-domain.spec.ts
│ │ │ │ ├── auth-domain.ts
│ │ │ │ ├── config.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── managers
│ │ │ │ │ ├── auth-manager.spec.ts
│ │ │ │ │ └── auth-manager.ts
│ │ │ │ ├── services
│ │ │ │ │ ├── context-store.ts
│ │ │ │ │ ├── oauth-service.ts
│ │ │ │ │ ├── organization.service.ts
│ │ │ │ │ ├── supabase-session-storage.spec.ts
│ │ │ │ │ └── supabase-session-storage.ts
│ │ │ │ └── types.ts
│ │ │ ├── briefs
│ │ │ │ ├── briefs-domain.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── services
│ │ │ │ │ └── brief-service.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── utils
│ │ │ │ └── url-parser.ts
│ │ │ ├── commands
│ │ │ │ └── index.ts
│ │ │ ├── config
│ │ │ │ ├── config-domain.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── managers
│ │ │ │ │ ├── config-manager.spec.ts
│ │ │ │ │ └── config-manager.ts
│ │ │ │ └── services
│ │ │ │ ├── config-loader.service.spec.ts
│ │ │ │ ├── config-loader.service.ts
│ │ │ │ ├── config-merger.service.spec.ts
│ │ │ │ ├── config-merger.service.ts
│ │ │ │ ├── config-persistence.service.spec.ts
│ │ │ │ ├── config-persistence.service.ts
│ │ │ │ ├── environment-config-provider.service.spec.ts
│ │ │ │ ├── environment-config-provider.service.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── runtime-state-manager.service.spec.ts
│ │ │ │ └── runtime-state-manager.service.ts
│ │ │ ├── dependencies
│ │ │ │ └── index.ts
│ │ │ ├── execution
│ │ │ │ ├── executors
│ │ │ │ │ ├── base-executor.ts
│ │ │ │ │ ├── claude-executor.ts
│ │ │ │ │ └── executor-factory.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── services
│ │ │ │ │ └── executor-service.ts
│ │ │ │ └── types.ts
│ │ │ ├── git
│ │ │ │ ├── adapters
│ │ │ │ │ ├── git-adapter.test.ts
│ │ │ │ │ └── git-adapter.ts
│ │ │ │ ├── git-domain.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── services
│ │ │ │ ├── branch-name-generator.spec.ts
│ │ │ │ ├── branch-name-generator.ts
│ │ │ │ ├── commit-message-generator.test.ts
│ │ │ │ ├── commit-message-generator.ts
│ │ │ │ ├── scope-detector.test.ts
│ │ │ │ ├── scope-detector.ts
│ │ │ │ ├── template-engine.test.ts
│ │ │ │ └── template-engine.ts
│ │ │ ├── integration
│ │ │ │ ├── clients
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── supabase-client.ts
│ │ │ │ ├── integration-domain.ts
│ │ │ │ └── services
│ │ │ │ ├── export.service.ts
│ │ │ │ ├── task-expansion.service.ts
│ │ │ │ └── task-retrieval.service.ts
│ │ │ ├── reports
│ │ │ │ ├── index.ts
│ │ │ │ ├── managers
│ │ │ │ │ └── complexity-report-manager.ts
│ │ │ │ └── types.ts
│ │ │ ├── storage
│ │ │ │ ├── adapters
│ │ │ │ │ ├── activity-logger.ts
│ │ │ │ │ ├── api-storage.ts
│ │ │ │ │ └── file-storage
│ │ │ │ │ ├── file-operations.ts
│ │ │ │ │ ├── file-storage.ts
│ │ │ │ │ ├── format-handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── path-resolver.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── services
│ │ │ │ │ └── storage-factory.ts
│ │ │ │ └── utils
│ │ │ │ └── api-client.ts
│ │ │ ├── tasks
│ │ │ │ ├── entities
│ │ │ │ │ └── task.entity.ts
│ │ │ │ ├── parser
│ │ │ │ │ └── index.ts
│ │ │ │ ├── repositories
│ │ │ │ │ ├── supabase
│ │ │ │ │ │ ├── dependency-fetcher.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── supabase-repository.ts
│ │ │ │ │ └── task-repository.interface.ts
│ │ │ │ ├── services
│ │ │ │ │ ├── preflight-checker.service.ts
│ │ │ │ │ ├── tag.service.ts
│ │ │ │ │ ├── task-execution-service.ts
│ │ │ │ │ ├── task-loader.service.ts
│ │ │ │ │ └── task-service.ts
│ │ │ │ └── tasks-domain.ts
│ │ │ ├── ui
│ │ │ │ └── index.ts
│ │ │ └── workflow
│ │ │ ├── managers
│ │ │ │ ├── workflow-state-manager.spec.ts
│ │ │ │ └── workflow-state-manager.ts
│ │ │ ├── orchestrators
│ │ │ │ ├── workflow-orchestrator.test.ts
│ │ │ │ └── workflow-orchestrator.ts
│ │ │ ├── services
│ │ │ │ ├── test-result-validator.test.ts
│ │ │ │ ├── test-result-validator.ts
│ │ │ │ ├── test-result-validator.types.ts
│ │ │ │ ├── workflow-activity-logger.ts
│ │ │ │ └── workflow.service.ts
│ │ │ ├── types.ts
│ │ │ └── workflow-domain.ts
│ │ ├── subpath-exports.test.ts
│ │ ├── tm-core.ts
│ │ └── utils
│ │ └── time.utils.ts
│ ├── tests
│ │ ├── auth
│ │ │ └── auth-refresh.test.ts
│ │ ├── integration
│ │ │ ├── auth-token-refresh.test.ts
│ │ │ ├── list-tasks.test.ts
│ │ │ └── storage
│ │ │ └── activity-logger.test.ts
│ │ ├── mocks
│ │ │ └── mock-provider.ts
│ │ ├── setup.ts
│ │ └── unit
│ │ ├── base-provider.test.ts
│ │ ├── executor.test.ts
│ │ └── smoke.test.ts
│ ├── tsconfig.json
│ └── vitest.config.ts
├── README-task-master.md
├── README.md
├── scripts
│ ├── create-worktree.sh
│ ├── dev.js
│ ├── init.js
│ ├── list-worktrees.sh
│ ├── modules
│ │ ├── ai-services-unified.js
│ │ ├── bridge-utils.js
│ │ ├── commands.js
│ │ ├── config-manager.js
│ │ ├── dependency-manager.js
│ │ ├── index.js
│ │ ├── prompt-manager.js
│ │ ├── supported-models.json
│ │ ├── sync-readme.js
│ │ ├── task-manager
│ │ │ ├── add-subtask.js
│ │ │ ├── add-task.js
│ │ │ ├── analyze-task-complexity.js
│ │ │ ├── clear-subtasks.js
│ │ │ ├── expand-all-tasks.js
│ │ │ ├── expand-task.js
│ │ │ ├── find-next-task.js
│ │ │ ├── generate-task-files.js
│ │ │ ├── is-task-dependent.js
│ │ │ ├── list-tasks.js
│ │ │ ├── migrate.js
│ │ │ ├── models.js
│ │ │ ├── move-task.js
│ │ │ ├── parse-prd
│ │ │ │ ├── index.js
│ │ │ │ ├── parse-prd-config.js
│ │ │ │ ├── parse-prd-helpers.js
│ │ │ │ ├── parse-prd-non-streaming.js
│ │ │ │ ├── parse-prd-streaming.js
│ │ │ │ └── parse-prd.js
│ │ │ ├── remove-subtask.js
│ │ │ ├── remove-task.js
│ │ │ ├── research.js
│ │ │ ├── response-language.js
│ │ │ ├── scope-adjustment.js
│ │ │ ├── set-task-status.js
│ │ │ ├── tag-management.js
│ │ │ ├── task-exists.js
│ │ │ ├── update-single-task-status.js
│ │ │ ├── update-subtask-by-id.js
│ │ │ ├── update-task-by-id.js
│ │ │ └── update-tasks.js
│ │ ├── task-manager.js
│ │ ├── ui.js
│ │ ├── update-config-tokens.js
│ │ ├── utils
│ │ │ ├── contextGatherer.js
│ │ │ ├── fuzzyTaskSearch.js
│ │ │ └── git-utils.js
│ │ └── utils.js
│ ├── task-complexity-report.json
│ ├── test-claude-errors.js
│ └── test-claude.js
├── sonar-project.properties
├── src
│ ├── ai-providers
│ │ ├── anthropic.js
│ │ ├── azure.js
│ │ ├── base-provider.js
│ │ ├── bedrock.js
│ │ ├── claude-code.js
│ │ ├── codex-cli.js
│ │ ├── gemini-cli.js
│ │ ├── google-vertex.js
│ │ ├── google.js
│ │ ├── grok-cli.js
│ │ ├── groq.js
│ │ ├── index.js
│ │ ├── lmstudio.js
│ │ ├── ollama.js
│ │ ├── openai-compatible.js
│ │ ├── openai.js
│ │ ├── openrouter.js
│ │ ├── perplexity.js
│ │ ├── xai.js
│ │ ├── zai-coding.js
│ │ └── zai.js
│ ├── constants
│ │ ├── commands.js
│ │ ├── paths.js
│ │ ├── profiles.js
│ │ ├── rules-actions.js
│ │ ├── task-priority.js
│ │ └── task-status.js
│ ├── profiles
│ │ ├── amp.js
│ │ ├── base-profile.js
│ │ ├── claude.js
│ │ ├── cline.js
│ │ ├── codex.js
│ │ ├── cursor.js
│ │ ├── gemini.js
│ │ ├── index.js
│ │ ├── kilo.js
│ │ ├── kiro.js
│ │ ├── opencode.js
│ │ ├── roo.js
│ │ ├── trae.js
│ │ ├── vscode.js
│ │ ├── windsurf.js
│ │ └── zed.js
│ ├── progress
│ │ ├── base-progress-tracker.js
│ │ ├── cli-progress-factory.js
│ │ ├── parse-prd-tracker.js
│ │ ├── progress-tracker-builder.js
│ │ └── tracker-ui.js
│ ├── prompts
│ │ ├── add-task.json
│ │ ├── analyze-complexity.json
│ │ ├── expand-task.json
│ │ ├── parse-prd.json
│ │ ├── README.md
│ │ ├── research.json
│ │ ├── schemas
│ │ │ ├── parameter.schema.json
│ │ │ ├── prompt-template.schema.json
│ │ │ ├── README.md
│ │ │ └── variant.schema.json
│ │ ├── update-subtask.json
│ │ ├── update-task.json
│ │ └── update-tasks.json
│ ├── provider-registry
│ │ └── index.js
│ ├── schemas
│ │ ├── add-task.js
│ │ ├── analyze-complexity.js
│ │ ├── base-schemas.js
│ │ ├── expand-task.js
│ │ ├── parse-prd.js
│ │ ├── registry.js
│ │ ├── update-subtask.js
│ │ ├── update-task.js
│ │ └── update-tasks.js
│ ├── task-master.js
│ ├── ui
│ │ ├── confirm.js
│ │ ├── indicators.js
│ │ └── parse-prd.js
│ └── utils
│ ├── asset-resolver.js
│ ├── create-mcp-config.js
│ ├── format.js
│ ├── getVersion.js
│ ├── logger-utils.js
│ ├── manage-gitignore.js
│ ├── path-utils.js
│ ├── profiles.js
│ ├── rule-transformer.js
│ ├── stream-parser.js
│ └── timeout-manager.js
├── test-clean-tags.js
├── test-config-manager.js
├── test-prd.txt
├── test-tag-functions.js
├── test-version-check-full.js
├── test-version-check.js
├── tests
│ ├── e2e
│ │ ├── e2e_helpers.sh
│ │ ├── parse_llm_output.cjs
│ │ ├── run_e2e.sh
│ │ ├── run_fallback_verification.sh
│ │ └── test_llm_analysis.sh
│ ├── fixtures
│ │ ├── .taskmasterconfig
│ │ ├── sample-claude-response.js
│ │ ├── sample-prd.txt
│ │ └── sample-tasks.js
│ ├── helpers
│ │ └── tool-counts.js
│ ├── integration
│ │ ├── claude-code-error-handling.test.js
│ │ ├── claude-code-optional.test.js
│ │ ├── cli
│ │ │ ├── commands.test.js
│ │ │ ├── complex-cross-tag-scenarios.test.js
│ │ │ └── move-cross-tag.test.js
│ │ ├── manage-gitignore.test.js
│ │ ├── mcp-server
│ │ │ └── direct-functions.test.js
│ │ ├── move-task-cross-tag.integration.test.js
│ │ ├── move-task-simple.integration.test.js
│ │ ├── profiles
│ │ │ ├── amp-init-functionality.test.js
│ │ │ ├── claude-init-functionality.test.js
│ │ │ ├── cline-init-functionality.test.js
│ │ │ ├── codex-init-functionality.test.js
│ │ │ ├── cursor-init-functionality.test.js
│ │ │ ├── gemini-init-functionality.test.js
│ │ │ ├── opencode-init-functionality.test.js
│ │ │ ├── roo-files-inclusion.test.js
│ │ │ ├── roo-init-functionality.test.js
│ │ │ ├── rules-files-inclusion.test.js
│ │ │ ├── trae-init-functionality.test.js
│ │ │ ├── vscode-init-functionality.test.js
│ │ │ └── windsurf-init-functionality.test.js
│ │ └── providers
│ │ └── temperature-support.test.js
│ ├── manual
│ │ ├── progress
│ │ │ ├── parse-prd-analysis.js
│ │ │ ├── test-parse-prd.js
│ │ │ └── TESTING_GUIDE.md
│ │ └── prompts
│ │ ├── prompt-test.js
│ │ └── README.md
│ ├── README.md
│ ├── setup.js
│ └── unit
│ ├── ai-providers
│ │ ├── base-provider.test.js
│ │ ├── claude-code.test.js
│ │ ├── codex-cli.test.js
│ │ ├── gemini-cli.test.js
│ │ ├── lmstudio.test.js
│ │ ├── mcp-components.test.js
│ │ ├── openai-compatible.test.js
│ │ ├── openai.test.js
│ │ ├── provider-registry.test.js
│ │ ├── zai-coding.test.js
│ │ ├── zai-provider.test.js
│ │ ├── zai-schema-introspection.test.js
│ │ └── zai.test.js
│ ├── ai-services-unified.test.js
│ ├── commands.test.js
│ ├── config-manager.test.js
│ ├── config-manager.test.mjs
│ ├── dependency-manager.test.js
│ ├── init.test.js
│ ├── initialize-project.test.js
│ ├── kebab-case-validation.test.js
│ ├── manage-gitignore.test.js
│ ├── mcp
│ │ └── tools
│ │ ├── __mocks__
│ │ │ └── move-task.js
│ │ ├── add-task.test.js
│ │ ├── analyze-complexity.test.js
│ │ ├── expand-all.test.js
│ │ ├── get-tasks.test.js
│ │ ├── initialize-project.test.js
│ │ ├── move-task-cross-tag-options.test.js
│ │ ├── move-task-cross-tag.test.js
│ │ ├── remove-task.test.js
│ │ └── tool-registration.test.js
│ ├── mcp-providers
│ │ ├── mcp-components.test.js
│ │ └── mcp-provider.test.js
│ ├── parse-prd.test.js
│ ├── profiles
│ │ ├── amp-integration.test.js
│ │ ├── claude-integration.test.js
│ │ ├── cline-integration.test.js
│ │ ├── codex-integration.test.js
│ │ ├── cursor-integration.test.js
│ │ ├── gemini-integration.test.js
│ │ ├── kilo-integration.test.js
│ │ ├── kiro-integration.test.js
│ │ ├── mcp-config-validation.test.js
│ │ ├── opencode-integration.test.js
│ │ ├── profile-safety-check.test.js
│ │ ├── roo-integration.test.js
│ │ ├── rule-transformer-cline.test.js
│ │ ├── rule-transformer-cursor.test.js
│ │ ├── rule-transformer-gemini.test.js
│ │ ├── rule-transformer-kilo.test.js
│ │ ├── rule-transformer-kiro.test.js
│ │ ├── rule-transformer-opencode.test.js
│ │ ├── rule-transformer-roo.test.js
│ │ ├── rule-transformer-trae.test.js
│ │ ├── rule-transformer-vscode.test.js
│ │ ├── rule-transformer-windsurf.test.js
│ │ ├── rule-transformer-zed.test.js
│ │ ├── rule-transformer.test.js
│ │ ├── selective-profile-removal.test.js
│ │ ├── subdirectory-support.test.js
│ │ ├── trae-integration.test.js
│ │ ├── vscode-integration.test.js
│ │ ├── windsurf-integration.test.js
│ │ └── zed-integration.test.js
│ ├── progress
│ │ └── base-progress-tracker.test.js
│ ├── prompt-manager.test.js
│ ├── prompts
│ │ ├── expand-task-prompt.test.js
│ │ └── prompt-migration.test.js
│ ├── scripts
│ │ └── modules
│ │ ├── commands
│ │ │ ├── move-cross-tag.test.js
│ │ │ └── README.md
│ │ ├── dependency-manager
│ │ │ ├── circular-dependencies.test.js
│ │ │ ├── cross-tag-dependencies.test.js
│ │ │ └── fix-dependencies-command.test.js
│ │ ├── task-manager
│ │ │ ├── add-subtask.test.js
│ │ │ ├── add-task.test.js
│ │ │ ├── analyze-task-complexity.test.js
│ │ │ ├── clear-subtasks.test.js
│ │ │ ├── complexity-report-tag-isolation.test.js
│ │ │ ├── expand-all-tasks.test.js
│ │ │ ├── expand-task.test.js
│ │ │ ├── find-next-task.test.js
│ │ │ ├── generate-task-files.test.js
│ │ │ ├── list-tasks.test.js
│ │ │ ├── models-baseurl.test.js
│ │ │ ├── move-task-cross-tag.test.js
│ │ │ ├── move-task.test.js
│ │ │ ├── parse-prd-schema.test.js
│ │ │ ├── parse-prd.test.js
│ │ │ ├── remove-subtask.test.js
│ │ │ ├── remove-task.test.js
│ │ │ ├── research.test.js
│ │ │ ├── scope-adjustment.test.js
│ │ │ ├── set-task-status.test.js
│ │ │ ├── setup.js
│ │ │ ├── update-single-task-status.test.js
│ │ │ ├── update-subtask-by-id.test.js
│ │ │ ├── update-task-by-id.test.js
│ │ │ └── update-tasks.test.js
│ │ ├── ui
│ │ │ └── cross-tag-error-display.test.js
│ │ └── utils-tag-aware-paths.test.js
│ ├── task-finder.test.js
│ ├── task-manager
│ │ ├── clear-subtasks.test.js
│ │ ├── move-task.test.js
│ │ ├── tag-boundary.test.js
│ │ └── tag-management.test.js
│ ├── task-master.test.js
│ ├── ui
│ │ └── indicators.test.js
│ ├── ui.test.js
│ ├── utils-strip-ansi.test.js
│ └── utils.test.js
├── tsconfig.json
├── tsdown.config.ts
├── turbo.json
└── update-task-migration-plan.md
```
# Files
--------------------------------------------------------------------------------
/packages/tm-core/src/modules/git/adapters/git-adapter.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import os from 'os';
2 | import path from 'path';
3 | import {
4 | afterEach,
5 | beforeEach,
6 | describe,
7 | expect,
8 | it,
9 | jest
10 | } from '@jest/globals';
11 | import fs from 'fs-extra';
12 | import { GitAdapter } from '../../../../../packages/tm-core/src/git/git-adapter.js';
13 |
14 | describe('GitAdapter - Repository Detection and Validation', () => {
15 | let testDir;
16 | let gitAdapter;
17 |
18 | beforeEach(async () => {
19 | // Create temporary test directory
20 | testDir = path.join(os.tmpdir(), `git-test-${Date.now()}`);
21 | await fs.ensureDir(testDir);
22 | });
23 |
24 | afterEach(async () => {
25 | // Clean up test directory
26 | await fs.remove(testDir);
27 | });
28 |
29 | describe('isGitRepository', () => {
30 | it('should return false for non-git directory', async () => {
31 | gitAdapter = new GitAdapter(testDir);
32 |
33 | const isRepo = await gitAdapter.isGitRepository();
34 |
35 | expect(isRepo).toBe(false);
36 | });
37 |
38 | it('should return true for git repository', async () => {
39 | // Initialize real git repo
40 | await fs.ensureDir(path.join(testDir, '.git'));
41 | await fs.ensureDir(path.join(testDir, '.git', 'objects'));
42 | await fs.ensureDir(path.join(testDir, '.git', 'refs'));
43 | await fs.writeFile(
44 | path.join(testDir, '.git', 'HEAD'),
45 | 'ref: refs/heads/main\n'
46 | );
47 |
48 | gitAdapter = new GitAdapter(testDir);
49 |
50 | const isRepo = await gitAdapter.isGitRepository();
51 |
52 | expect(isRepo).toBe(true);
53 | });
54 |
55 | it('should detect git repository in subdirectory', async () => {
56 | // Initialize real git repo in parent
57 | await fs.ensureDir(path.join(testDir, '.git'));
58 | await fs.ensureDir(path.join(testDir, '.git', 'objects'));
59 | await fs.ensureDir(path.join(testDir, '.git', 'refs'));
60 | await fs.writeFile(
61 | path.join(testDir, '.git', 'HEAD'),
62 | 'ref: refs/heads/main\n'
63 | );
64 |
65 | // Create subdirectory
66 | const subDir = path.join(testDir, 'src', 'components');
67 | await fs.ensureDir(subDir);
68 |
69 | gitAdapter = new GitAdapter(subDir);
70 |
71 | const isRepo = await gitAdapter.isGitRepository();
72 |
73 | expect(isRepo).toBe(true);
74 | });
75 |
76 | it('should handle directory with .git file (submodule)', async () => {
77 | // Create .git file (used in submodules/worktrees)
78 | await fs.writeFile(path.join(testDir, '.git'), 'gitdir: /path/to/git');
79 |
80 | gitAdapter = new GitAdapter(testDir);
81 |
82 | const isRepo = await gitAdapter.isGitRepository();
83 |
84 | expect(isRepo).toBe(true);
85 | });
86 |
87 | it('should return false if .git is neither file nor directory', async () => {
88 | gitAdapter = new GitAdapter(testDir);
89 |
90 | const isRepo = await gitAdapter.isGitRepository();
91 |
92 | expect(isRepo).toBe(false);
93 | });
94 | });
95 |
96 | describe('validateGitInstallation', () => {
97 | it('should validate git is installed', async () => {
98 | gitAdapter = new GitAdapter(testDir);
99 |
100 | await expect(gitAdapter.validateGitInstallation()).resolves.not.toThrow();
101 | });
102 |
103 | it('should throw error if git version check fails', async () => {
104 | gitAdapter = new GitAdapter(testDir);
105 |
106 | // Mock simple-git to throw error
107 | const mockGit = {
108 | version: jest.fn().mockRejectedValue(new Error('git not found'))
109 | };
110 | gitAdapter.git = mockGit;
111 |
112 | await expect(gitAdapter.validateGitInstallation()).rejects.toThrow(
113 | 'git not found'
114 | );
115 | });
116 |
117 | it('should return git version info', async () => {
118 | gitAdapter = new GitAdapter(testDir);
119 |
120 | const versionInfo = await gitAdapter.getGitVersion();
121 |
122 | expect(versionInfo).toBeDefined();
123 | expect(versionInfo.major).toBeGreaterThan(0);
124 | });
125 | });
126 |
127 | describe('getRepositoryRoot', () => {
128 | it('should return repository root path', async () => {
129 | // Initialize real git repo
130 | await fs.ensureDir(path.join(testDir, '.git'));
131 | await fs.ensureDir(path.join(testDir, '.git', 'objects'));
132 | await fs.ensureDir(path.join(testDir, '.git', 'refs'));
133 | await fs.writeFile(
134 | path.join(testDir, '.git', 'HEAD'),
135 | 'ref: refs/heads/main\n'
136 | );
137 |
138 | gitAdapter = new GitAdapter(testDir);
139 |
140 | const root = await gitAdapter.getRepositoryRoot();
141 |
142 | // Resolve both paths to handle symlinks (e.g., /var vs /private/var on macOS)
143 | expect(await fs.realpath(root)).toBe(await fs.realpath(testDir));
144 | });
145 |
146 | it('should find repository root from subdirectory', async () => {
147 | // Initialize real git repo in parent
148 | await fs.ensureDir(path.join(testDir, '.git'));
149 | await fs.ensureDir(path.join(testDir, '.git', 'objects'));
150 | await fs.ensureDir(path.join(testDir, '.git', 'refs'));
151 | await fs.writeFile(
152 | path.join(testDir, '.git', 'HEAD'),
153 | 'ref: refs/heads/main\n'
154 | );
155 |
156 | // Create subdirectory
157 | const subDir = path.join(testDir, 'src', 'components');
158 | await fs.ensureDir(subDir);
159 |
160 | gitAdapter = new GitAdapter(subDir);
161 |
162 | const root = await gitAdapter.getRepositoryRoot();
163 |
164 | // Resolve both paths to handle symlinks (e.g., /var vs /private/var on macOS)
165 | expect(await fs.realpath(root)).toBe(await fs.realpath(testDir));
166 | });
167 |
168 | it('should throw error if not in git repository', async () => {
169 | gitAdapter = new GitAdapter(testDir);
170 |
171 | await expect(gitAdapter.getRepositoryRoot()).rejects.toThrow(
172 | 'not a git repository'
173 | );
174 | });
175 | });
176 |
177 | describe('validateRepository', () => {
178 | it('should validate repository is in good state', async () => {
179 | // Initialize git repo
180 | await fs.ensureDir(path.join(testDir, '.git'));
181 | await fs.ensureDir(path.join(testDir, '.git', 'refs'));
182 | await fs.ensureDir(path.join(testDir, '.git', 'objects'));
183 | await fs.writeFile(
184 | path.join(testDir, '.git', 'HEAD'),
185 | 'ref: refs/heads/main\n'
186 | );
187 |
188 | gitAdapter = new GitAdapter(testDir);
189 |
190 | await expect(gitAdapter.validateRepository()).resolves.not.toThrow();
191 | });
192 |
193 | it('should throw error for non-git directory', async () => {
194 | gitAdapter = new GitAdapter(testDir);
195 |
196 | await expect(gitAdapter.validateRepository()).rejects.toThrow(
197 | 'not a git repository'
198 | );
199 | });
200 |
201 | it('should detect corrupted repository', async () => {
202 | // Create .git directory but make it empty (corrupted)
203 | await fs.ensureDir(path.join(testDir, '.git'));
204 |
205 | gitAdapter = new GitAdapter(testDir);
206 |
207 | // This should either succeed or throw a specific error
208 | // depending on simple-git's behavior
209 | try {
210 | await gitAdapter.validateRepository();
211 | } catch (error) {
212 | expect(error.message).toMatch(/repository|git/i);
213 | }
214 | });
215 | });
216 |
217 | describe('ensureGitRepository', () => {
218 | it('should not throw if in valid git repository', async () => {
219 | // Initialize git repo
220 | await fs.ensureDir(path.join(testDir, '.git'));
221 |
222 | gitAdapter = new GitAdapter(testDir);
223 |
224 | await expect(gitAdapter.ensureGitRepository()).resolves.not.toThrow();
225 | });
226 |
227 | it('should throw error if not in git repository', async () => {
228 | gitAdapter = new GitAdapter(testDir);
229 |
230 | await expect(gitAdapter.ensureGitRepository()).rejects.toThrow(
231 | 'not a git repository'
232 | );
233 | });
234 |
235 | it('should provide helpful error message', async () => {
236 | gitAdapter = new GitAdapter(testDir);
237 |
238 | try {
239 | await gitAdapter.ensureGitRepository();
240 | fail('Should have thrown error');
241 | } catch (error) {
242 | expect(error.message).toContain('not a git repository');
243 | expect(error.message).toContain(testDir);
244 | }
245 | });
246 | });
247 |
248 | describe('constructor', () => {
249 | it('should create GitAdapter with project path', () => {
250 | gitAdapter = new GitAdapter(testDir);
251 |
252 | expect(gitAdapter).toBeDefined();
253 | expect(gitAdapter.projectPath).toBe(testDir);
254 | });
255 |
256 | it('should normalize project path', () => {
257 | const unnormalizedPath = path.join(testDir, '..', path.basename(testDir));
258 | gitAdapter = new GitAdapter(unnormalizedPath);
259 |
260 | expect(gitAdapter.projectPath).toBe(testDir);
261 | });
262 |
263 | it('should initialize simple-git instance', () => {
264 | gitAdapter = new GitAdapter(testDir);
265 |
266 | expect(gitAdapter.git).toBeDefined();
267 | });
268 |
269 | it('should throw error for invalid path', () => {
270 | expect(() => new GitAdapter('')).toThrow('Project path is required');
271 | });
272 |
273 | it('should throw error for non-absolute path', () => {
274 | expect(() => new GitAdapter('./relative/path')).toThrow('absolute');
275 | });
276 | });
277 |
278 | describe('error handling', () => {
279 | it('should provide clear error for permission denied', async () => {
280 | // Create .git but make it inaccessible
281 | await fs.ensureDir(path.join(testDir, '.git'));
282 |
283 | gitAdapter = new GitAdapter(testDir);
284 |
285 | try {
286 | await fs.chmod(path.join(testDir, '.git'), 0o000);
287 |
288 | await gitAdapter.isGitRepository();
289 | } catch (error) {
290 | // Error handling
291 | } finally {
292 | // Restore permissions
293 | await fs.chmod(path.join(testDir, '.git'), 0o755);
294 | }
295 | });
296 |
297 | it('should handle symbolic links correctly', async () => {
298 | // Create actual git repo
299 | const realRepo = path.join(testDir, 'real-repo');
300 | await fs.ensureDir(path.join(realRepo, '.git'));
301 |
302 | // Create symlink
303 | const symlinkPath = path.join(testDir, 'symlink-repo');
304 | try {
305 | await fs.symlink(realRepo, symlinkPath);
306 |
307 | gitAdapter = new GitAdapter(symlinkPath);
308 |
309 | const isRepo = await gitAdapter.isGitRepository();
310 |
311 | expect(isRepo).toBe(true);
312 | } catch (error) {
313 | // Skip test on platforms without symlink support
314 | if (error.code !== 'EPERM') {
315 | throw error;
316 | }
317 | }
318 | });
319 | });
320 |
321 | describe('integration with simple-git', () => {
322 | it('should use simple-git for git operations', () => {
323 | gitAdapter = new GitAdapter(testDir);
324 |
325 | // Check that git instance is from simple-git
326 | expect(typeof gitAdapter.git.status).toBe('function');
327 | expect(typeof gitAdapter.git.branch).toBe('function');
328 | });
329 |
330 | it('should pass correct working directory to simple-git', () => {
331 | gitAdapter = new GitAdapter(testDir);
332 |
333 | // simple-git should be initialized with testDir
334 | expect(gitAdapter.git._executor).toBeDefined();
335 | });
336 | });
337 | });
338 |
339 | describe('GitAdapter - Working Tree Status', () => {
340 | let testDir;
341 | let gitAdapter;
342 | let simpleGit;
343 |
344 | beforeEach(async () => {
345 | testDir = path.join(os.tmpdir(), `git-status-test-${Date.now()}`);
346 | await fs.ensureDir(testDir);
347 |
348 | // Initialize actual git repo
349 | simpleGit = (await import('simple-git')).default;
350 | const git = simpleGit(testDir);
351 | await git.init();
352 | await git.addConfig('user.name', 'Test User');
353 | await git.addConfig('user.email', '[email protected]');
354 |
355 | gitAdapter = new GitAdapter(testDir);
356 | });
357 |
358 | afterEach(async () => {
359 | await fs.remove(testDir);
360 | });
361 |
362 | describe('isWorkingTreeClean', () => {
363 | it('should return true for clean working tree', async () => {
364 | const isClean = await gitAdapter.isWorkingTreeClean();
365 | expect(isClean).toBe(true);
366 | });
367 |
368 | it('should return false when files are modified', async () => {
369 | // Create and commit a file
370 | await fs.writeFile(path.join(testDir, 'test.txt'), 'initial');
371 | const git = simpleGit(testDir);
372 | await git.add('test.txt');
373 | await git.commit('initial commit', undefined, { '--no-gpg-sign': null });
374 |
375 | // Modify the file
376 | await fs.writeFile(path.join(testDir, 'test.txt'), 'modified');
377 |
378 | const isClean = await gitAdapter.isWorkingTreeClean();
379 | expect(isClean).toBe(false);
380 | });
381 |
382 | it('should return false when untracked files exist', async () => {
383 | await fs.writeFile(path.join(testDir, 'untracked.txt'), 'content');
384 |
385 | const isClean = await gitAdapter.isWorkingTreeClean();
386 | expect(isClean).toBe(false);
387 | });
388 |
389 | it('should return false when files are staged', async () => {
390 | await fs.writeFile(path.join(testDir, 'staged.txt'), 'content');
391 | const git = simpleGit(testDir);
392 | await git.add('staged.txt');
393 |
394 | const isClean = await gitAdapter.isWorkingTreeClean();
395 | expect(isClean).toBe(false);
396 | });
397 | });
398 |
399 | describe('getStatus', () => {
400 | it('should return status for clean repo', async () => {
401 | const status = await gitAdapter.getStatus();
402 |
403 | expect(status).toBeDefined();
404 | expect(status.modified).toEqual([]);
405 | expect(status.not_added).toEqual([]);
406 | expect(status.deleted).toEqual([]);
407 | expect(status.created).toEqual([]);
408 | });
409 |
410 | it('should detect modified files', async () => {
411 | // Create and commit
412 | await fs.writeFile(path.join(testDir, 'test.txt'), 'initial');
413 | const git = simpleGit(testDir);
414 | await git.add('test.txt');
415 | await git.commit('initial', undefined, { '--no-gpg-sign': null });
416 |
417 | // Modify
418 | await fs.writeFile(path.join(testDir, 'test.txt'), 'modified');
419 |
420 | const status = await gitAdapter.getStatus();
421 | expect(status.modified).toContain('test.txt');
422 | });
423 |
424 | it('should detect untracked files', async () => {
425 | await fs.writeFile(path.join(testDir, 'untracked.txt'), 'content');
426 |
427 | const status = await gitAdapter.getStatus();
428 | expect(status.not_added).toContain('untracked.txt');
429 | });
430 |
431 | it('should detect staged files', async () => {
432 | await fs.writeFile(path.join(testDir, 'staged.txt'), 'content');
433 | const git = simpleGit(testDir);
434 | await git.add('staged.txt');
435 |
436 | const status = await gitAdapter.getStatus();
437 | expect(status.created).toContain('staged.txt');
438 | });
439 |
440 | it('should detect deleted files', async () => {
441 | // Create and commit
442 | await fs.writeFile(path.join(testDir, 'deleted.txt'), 'content');
443 | const git = simpleGit(testDir);
444 | await git.add('deleted.txt');
445 | await git.commit('add file', undefined, { '--no-gpg-sign': null });
446 |
447 | // Delete
448 | await fs.remove(path.join(testDir, 'deleted.txt'));
449 |
450 | const status = await gitAdapter.getStatus();
451 | expect(status.deleted).toContain('deleted.txt');
452 | });
453 | });
454 |
455 | describe('hasUncommittedChanges', () => {
456 | it('should return false for clean repo', async () => {
457 | const hasChanges = await gitAdapter.hasUncommittedChanges();
458 | expect(hasChanges).toBe(false);
459 | });
460 |
461 | it('should return true for modified files', async () => {
462 | await fs.writeFile(path.join(testDir, 'test.txt'), 'initial');
463 | const git = simpleGit(testDir);
464 | await git.add('test.txt');
465 | await git.commit('initial', undefined, { '--no-gpg-sign': null });
466 |
467 | await fs.writeFile(path.join(testDir, 'test.txt'), 'modified');
468 |
469 | const hasChanges = await gitAdapter.hasUncommittedChanges();
470 | expect(hasChanges).toBe(true);
471 | });
472 |
473 | it('should return true for staged changes', async () => {
474 | await fs.writeFile(path.join(testDir, 'staged.txt'), 'content');
475 | const git = simpleGit(testDir);
476 | await git.add('staged.txt');
477 |
478 | const hasChanges = await gitAdapter.hasUncommittedChanges();
479 | expect(hasChanges).toBe(true);
480 | });
481 | });
482 |
483 | describe('hasStagedChanges', () => {
484 | it('should return false when no staged changes', async () => {
485 | const hasStaged = await gitAdapter.hasStagedChanges();
486 | expect(hasStaged).toBe(false);
487 | });
488 |
489 | it('should return true when files are staged', async () => {
490 | await fs.writeFile(path.join(testDir, 'staged.txt'), 'content');
491 | const git = simpleGit(testDir);
492 | await git.add('staged.txt');
493 |
494 | const hasStaged = await gitAdapter.hasStagedChanges();
495 | expect(hasStaged).toBe(true);
496 | });
497 |
498 | it('should return false for unstaged changes only', async () => {
499 | await fs.writeFile(path.join(testDir, 'test.txt'), 'initial');
500 | const git = simpleGit(testDir);
501 | await git.add('test.txt');
502 | await git.commit('initial', undefined, { '--no-gpg-sign': null });
503 |
504 | await fs.writeFile(path.join(testDir, 'test.txt'), 'modified');
505 |
506 | const hasStaged = await gitAdapter.hasStagedChanges();
507 | expect(hasStaged).toBe(false);
508 | });
509 | });
510 |
511 | describe('hasUntrackedFiles', () => {
512 | it('should return false when no untracked files', async () => {
513 | const hasUntracked = await gitAdapter.hasUntrackedFiles();
514 | expect(hasUntracked).toBe(false);
515 | });
516 |
517 | it('should return true when untracked files exist', async () => {
518 | await fs.writeFile(path.join(testDir, 'untracked.txt'), 'content');
519 |
520 | const hasUntracked = await gitAdapter.hasUntrackedFiles();
521 | expect(hasUntracked).toBe(true);
522 | });
523 |
524 | it('should not count staged files as untracked', async () => {
525 | await fs.writeFile(path.join(testDir, 'staged.txt'), 'content');
526 | const git = simpleGit(testDir);
527 | await git.add('staged.txt');
528 |
529 | const hasUntracked = await gitAdapter.hasUntrackedFiles();
530 | expect(hasUntracked).toBe(false);
531 | });
532 | });
533 |
534 | describe('getStatusSummary', () => {
535 | it('should provide summary for clean repo', async () => {
536 | const summary = await gitAdapter.getStatusSummary();
537 |
538 | expect(summary).toBeDefined();
539 | expect(summary.isClean).toBe(true);
540 | expect(summary.totalChanges).toBe(0);
541 | });
542 |
543 | it('should count all types of changes', async () => {
544 | // Create committed file
545 | await fs.writeFile(path.join(testDir, 'committed.txt'), 'content');
546 | const git = simpleGit(testDir);
547 | await git.add('committed.txt');
548 | await git.commit('initial', undefined, { '--no-gpg-sign': null });
549 |
550 | // Modify it
551 | await fs.writeFile(path.join(testDir, 'committed.txt'), 'modified');
552 |
553 | // Add untracked
554 | await fs.writeFile(path.join(testDir, 'untracked.txt'), 'content');
555 |
556 | // Add staged
557 | await fs.writeFile(path.join(testDir, 'staged.txt'), 'content');
558 | await git.add('staged.txt');
559 |
560 | const summary = await gitAdapter.getStatusSummary();
561 |
562 | expect(summary.isClean).toBe(false);
563 | expect(summary.totalChanges).toBeGreaterThan(0);
564 | expect(summary.modified).toBeGreaterThan(0);
565 | expect(summary.untracked).toBeGreaterThan(0);
566 | expect(summary.staged).toBeGreaterThan(0);
567 | });
568 | });
569 |
570 | describe('ensureCleanWorkingTree', () => {
571 | it('should not throw for clean repo', async () => {
572 | await expect(gitAdapter.ensureCleanWorkingTree()).resolves.not.toThrow();
573 | });
574 |
575 | it('should throw for dirty repo', async () => {
576 | await fs.writeFile(path.join(testDir, 'dirty.txt'), 'content');
577 |
578 | await expect(gitAdapter.ensureCleanWorkingTree()).rejects.toThrow(
579 | 'working tree is not clean'
580 | );
581 | });
582 |
583 | it('should provide details about changes in error', async () => {
584 | await fs.writeFile(path.join(testDir, 'modified.txt'), 'content');
585 |
586 | try {
587 | await gitAdapter.ensureCleanWorkingTree();
588 | fail('Should have thrown');
589 | } catch (error) {
590 | expect(error.message).toContain('working tree is not clean');
591 | }
592 | });
593 | });
594 |
595 | describe('GitAdapter - Branch Operations', () => {
596 | let testDir;
597 | let gitAdapter;
598 | let simpleGit;
599 |
600 | beforeEach(async () => {
601 | testDir = path.join(os.tmpdir(), `git-branch-test-${Date.now()}`);
602 | await fs.ensureDir(testDir);
603 |
604 | // Initialize actual git repo with initial commit
605 | simpleGit = (await import('simple-git')).default;
606 | const git = simpleGit(testDir);
607 | await git.init();
608 | await git.addConfig('user.name', 'Test User');
609 | await git.addConfig('user.email', '[email protected]');
610 |
611 | // Create initial commit
612 | await fs.writeFile(path.join(testDir, 'README.md'), '# Test Repo');
613 | await git.add('README.md');
614 | await git.commit('Initial commit', undefined, { '--no-gpg-sign': null });
615 |
616 | // Rename master to main for consistency
617 | try {
618 | await git.branch(['-m', 'master', 'main']);
619 | } catch (error) {
620 | // Branch might already be main, ignore error
621 | }
622 |
623 | gitAdapter = new GitAdapter(testDir);
624 | });
625 |
626 | afterEach(async () => {
627 | if (await fs.pathExists(testDir)) {
628 | await fs.remove(testDir);
629 | }
630 | });
631 |
632 | describe('getCurrentBranch', () => {
633 | it('should return current branch name', async () => {
634 | const branch = await gitAdapter.getCurrentBranch();
635 | expect(branch).toBe('main');
636 | });
637 |
638 | it('should return updated branch after checkout', async () => {
639 | const git = simpleGit(testDir);
640 | await git.checkoutLocalBranch('feature');
641 |
642 | const branch = await gitAdapter.getCurrentBranch();
643 | expect(branch).toBe('feature');
644 | });
645 | });
646 |
647 | describe('listBranches', () => {
648 | it('should list all branches', async () => {
649 | const git = simpleGit(testDir);
650 | await git.checkoutLocalBranch('feature-a');
651 | await git.checkout('main');
652 | await git.checkoutLocalBranch('feature-b');
653 |
654 | const branches = await gitAdapter.listBranches();
655 | expect(branches).toContain('main');
656 | expect(branches).toContain('feature-a');
657 | expect(branches).toContain('feature-b');
658 | expect(branches.length).toBeGreaterThanOrEqual(3);
659 | });
660 |
661 | it('should return empty array if only on detached HEAD', async () => {
662 | const git = simpleGit(testDir);
663 | const log = await git.log();
664 | await git.checkout(log.latest.hash);
665 |
666 | const branches = await gitAdapter.listBranches();
667 | expect(Array.isArray(branches)).toBe(true);
668 | });
669 | });
670 |
671 | describe('branchExists', () => {
672 | it('should return true for existing branch', async () => {
673 | const exists = await gitAdapter.branchExists('main');
674 | expect(exists).toBe(true);
675 | });
676 |
677 | it('should return false for non-existing branch', async () => {
678 | const exists = await gitAdapter.branchExists('nonexistent');
679 | expect(exists).toBe(false);
680 | });
681 |
682 | it('should detect newly created branches', async () => {
683 | const git = simpleGit(testDir);
684 | await git.checkoutLocalBranch('new-feature');
685 |
686 | const exists = await gitAdapter.branchExists('new-feature');
687 | expect(exists).toBe(true);
688 | });
689 | });
690 |
691 | describe('createBranch', () => {
692 | it('should create a new branch', async () => {
693 | await gitAdapter.createBranch('new-branch');
694 |
695 | const exists = await gitAdapter.branchExists('new-branch');
696 | expect(exists).toBe(true);
697 | });
698 |
699 | it('should throw error if branch already exists', async () => {
700 | await gitAdapter.createBranch('existing-branch');
701 |
702 | await expect(
703 | gitAdapter.createBranch('existing-branch')
704 | ).rejects.toThrow();
705 | });
706 |
707 | it('should not switch to new branch by default', async () => {
708 | await gitAdapter.createBranch('new-branch');
709 |
710 | const current = await gitAdapter.getCurrentBranch();
711 | expect(current).toBe('main');
712 | });
713 |
714 | it('should throw if working tree is dirty when checkout is requested', async () => {
715 | await fs.writeFile(path.join(testDir, 'dirty.txt'), 'content');
716 |
717 | await expect(
718 | gitAdapter.createBranch('new-branch', { checkout: true })
719 | ).rejects.toThrow('working tree is not clean');
720 | });
721 | });
722 |
723 | describe('checkoutBranch', () => {
724 | it('should checkout existing branch', async () => {
725 | const git = simpleGit(testDir);
726 | await git.checkoutLocalBranch('feature');
727 | await git.checkout('main');
728 |
729 | await gitAdapter.checkoutBranch('feature');
730 |
731 | const current = await gitAdapter.getCurrentBranch();
732 | expect(current).toBe('feature');
733 | });
734 |
735 | it('should throw error for non-existing branch', async () => {
736 | await expect(
737 | gitAdapter.checkoutBranch('nonexistent')
738 | ).rejects.toThrow();
739 | });
740 |
741 | it('should throw if working tree is dirty', async () => {
742 | const git = simpleGit(testDir);
743 | await git.checkoutLocalBranch('feature');
744 | await git.checkout('main');
745 |
746 | await fs.writeFile(path.join(testDir, 'dirty.txt'), 'content');
747 |
748 | await expect(gitAdapter.checkoutBranch('feature')).rejects.toThrow(
749 | 'working tree is not clean'
750 | );
751 | });
752 |
753 | it('should allow force checkout with force flag', async () => {
754 | const git = simpleGit(testDir);
755 | await git.checkoutLocalBranch('feature');
756 | await git.checkout('main');
757 |
758 | await fs.writeFile(path.join(testDir, 'dirty.txt'), 'content');
759 |
760 | await gitAdapter.checkoutBranch('feature', { force: true });
761 |
762 | const current = await gitAdapter.getCurrentBranch();
763 | expect(current).toBe('feature');
764 | });
765 | });
766 |
767 | describe('createAndCheckoutBranch', () => {
768 | it('should create and checkout new branch', async () => {
769 | await gitAdapter.createAndCheckoutBranch('new-feature');
770 |
771 | const current = await gitAdapter.getCurrentBranch();
772 | expect(current).toBe('new-feature');
773 |
774 | const exists = await gitAdapter.branchExists('new-feature');
775 | expect(exists).toBe(true);
776 | });
777 |
778 | it('should throw if branch already exists', async () => {
779 | const git = simpleGit(testDir);
780 | await git.checkoutLocalBranch('existing');
781 | await git.checkout('main');
782 |
783 | await expect(
784 | gitAdapter.createAndCheckoutBranch('existing')
785 | ).rejects.toThrow();
786 | });
787 |
788 | it('should throw if working tree is dirty', async () => {
789 | await fs.writeFile(path.join(testDir, 'dirty.txt'), 'content');
790 |
791 | await expect(
792 | gitAdapter.createAndCheckoutBranch('new-feature')
793 | ).rejects.toThrow('working tree is not clean');
794 | });
795 | });
796 |
797 | describe('deleteBranch', () => {
798 | it('should delete existing branch', async () => {
799 | const git = simpleGit(testDir);
800 | await git.checkoutLocalBranch('to-delete');
801 | await git.checkout('main');
802 |
803 | await gitAdapter.deleteBranch('to-delete');
804 |
805 | const exists = await gitAdapter.branchExists('to-delete');
806 | expect(exists).toBe(false);
807 | });
808 |
809 | it('should throw error when deleting current branch', async () => {
810 | await expect(gitAdapter.deleteBranch('main')).rejects.toThrow();
811 | });
812 |
813 | it('should throw error for non-existing branch', async () => {
814 | await expect(gitAdapter.deleteBranch('nonexistent')).rejects.toThrow();
815 | });
816 |
817 | it('should force delete with force flag', async () => {
818 | const git = simpleGit(testDir);
819 | await git.checkoutLocalBranch('unmerged');
820 | await fs.writeFile(path.join(testDir, 'unmerged.txt'), 'content');
821 | await git.add('unmerged.txt');
822 | await git.commit('Unmerged commit', undefined, {
823 | '--no-gpg-sign': null
824 | });
825 | await git.checkout('main');
826 |
827 | await gitAdapter.deleteBranch('unmerged', { force: true });
828 |
829 | const exists = await gitAdapter.branchExists('unmerged');
830 | expect(exists).toBe(false);
831 | });
832 | });
833 | });
834 |
835 | describe('GitAdapter - Commit Operations', () => {
836 | let testDir;
837 | let gitAdapter;
838 | let simpleGit;
839 |
840 | beforeEach(async () => {
841 | testDir = path.join(os.tmpdir(), `git-commit-test-${Date.now()}`);
842 | await fs.ensureDir(testDir);
843 |
844 | // Initialize actual git repo with initial commit
845 | simpleGit = (await import('simple-git')).default;
846 | const git = simpleGit(testDir);
847 | await git.init();
848 | await git.addConfig('user.name', 'Test User');
849 | await git.addConfig('user.email', '[email protected]');
850 |
851 | // Create initial commit
852 | await fs.writeFile(path.join(testDir, 'README.md'), '# Test Repo');
853 | await git.add('README.md');
854 | await git.commit('Initial commit', undefined, { '--no-gpg-sign': null });
855 |
856 | // Rename master to main for consistency
857 | try {
858 | await git.branch(['-m', 'master', 'main']);
859 | } catch (error) {
860 | // Branch might already be main, ignore error
861 | }
862 |
863 | gitAdapter = new GitAdapter(testDir);
864 | });
865 |
866 | afterEach(async () => {
867 | if (await fs.pathExists(testDir)) {
868 | await fs.remove(testDir);
869 | }
870 | });
871 |
872 | describe('stageFiles', () => {
873 | it('should stage single file', async () => {
874 | await fs.writeFile(path.join(testDir, 'new.txt'), 'content');
875 | await gitAdapter.stageFiles(['new.txt']);
876 |
877 | const status = await gitAdapter.getStatus();
878 | expect(status.staged).toContain('new.txt');
879 | });
880 |
881 | it('should stage multiple files', async () => {
882 | await fs.writeFile(path.join(testDir, 'file1.txt'), 'content1');
883 | await fs.writeFile(path.join(testDir, 'file2.txt'), 'content2');
884 | await gitAdapter.stageFiles(['file1.txt', 'file2.txt']);
885 |
886 | const status = await gitAdapter.getStatus();
887 | expect(status.staged).toContain('file1.txt');
888 | expect(status.staged).toContain('file2.txt');
889 | });
890 |
891 | it('should stage all files with dot', async () => {
892 | await fs.writeFile(path.join(testDir, 'file1.txt'), 'content1');
893 | await fs.writeFile(path.join(testDir, 'file2.txt'), 'content2');
894 | await gitAdapter.stageFiles(['.']);
895 |
896 | const status = await gitAdapter.getStatus();
897 | expect(status.staged.length).toBeGreaterThanOrEqual(2);
898 | });
899 | });
900 |
901 | describe('unstageFiles', () => {
902 | it('should unstage single file', async () => {
903 | await fs.writeFile(path.join(testDir, 'staged.txt'), 'content');
904 | const git = simpleGit(testDir);
905 | await git.add('staged.txt');
906 |
907 | await gitAdapter.unstageFiles(['staged.txt']);
908 |
909 | const status = await gitAdapter.getStatus();
910 | expect(status.staged).not.toContain('staged.txt');
911 | });
912 |
913 | it('should unstage multiple files', async () => {
914 | await fs.writeFile(path.join(testDir, 'file1.txt'), 'content1');
915 | await fs.writeFile(path.join(testDir, 'file2.txt'), 'content2');
916 | const git = simpleGit(testDir);
917 | await git.add(['file1.txt', 'file2.txt']);
918 |
919 | await gitAdapter.unstageFiles(['file1.txt', 'file2.txt']);
920 |
921 | const status = await gitAdapter.getStatus();
922 | expect(status.staged).not.toContain('file1.txt');
923 | expect(status.staged).not.toContain('file2.txt');
924 | });
925 | });
926 |
927 | describe('createCommit', () => {
928 | it('should create commit with simple message', async () => {
929 | await fs.writeFile(path.join(testDir, 'new.txt'), 'content');
930 | await gitAdapter.stageFiles(['new.txt']);
931 |
932 | await gitAdapter.createCommit('Add new file');
933 |
934 | const git = simpleGit(testDir);
935 | const log = await git.log();
936 | expect(log.latest.message).toBe('Add new file');
937 | });
938 |
939 | it('should create commit with metadata', async () => {
940 | await fs.writeFile(path.join(testDir, 'new.txt'), 'content');
941 | await gitAdapter.stageFiles(['new.txt']);
942 |
943 | const metadata = {
944 | taskId: '2.4',
945 | phase: 'implementation',
946 | timestamp: new Date().toISOString()
947 | };
948 | await gitAdapter.createCommit('Add new file', { metadata });
949 |
950 | const commit = await gitAdapter.getLastCommit();
951 | expect(commit.message).toContain('Add new file');
952 | expect(commit.message).toContain('[taskId:2.4]');
953 | expect(commit.message).toContain('[phase:implementation]');
954 | });
955 |
956 | it('should throw if no staged changes', async () => {
957 | await expect(gitAdapter.createCommit('Empty commit')).rejects.toThrow();
958 | });
959 |
960 | it('should allow empty commits with allowEmpty flag', async () => {
961 | await gitAdapter.createCommit('Empty commit', { allowEmpty: true });
962 |
963 | const git = simpleGit(testDir);
964 | const log = await git.log();
965 | expect(log.latest.message).toBe('Empty commit');
966 | });
967 |
968 | it('should throw if on default branch without force', async () => {
969 | await fs.writeFile(path.join(testDir, 'new.txt'), 'content');
970 | await gitAdapter.stageFiles(['new.txt']);
971 |
972 | await expect(
973 | gitAdapter.createCommit('Add new file', {
974 | enforceNonDefaultBranch: true
975 | })
976 | ).rejects.toThrow('cannot commit to default branch');
977 | });
978 |
979 | it('should allow commit on default branch with force', async () => {
980 | await fs.writeFile(path.join(testDir, 'new.txt'), 'content');
981 | await gitAdapter.stageFiles(['new.txt']);
982 |
983 | await gitAdapter.createCommit('Add new file', {
984 | enforceNonDefaultBranch: true,
985 | force: true
986 | });
987 |
988 | const git = simpleGit(testDir);
989 | const log = await git.log();
990 | expect(log.latest.message).toBe('Add new file');
991 | });
992 |
993 | it('should allow commit on feature branch with enforcement', async () => {
994 | // Create and checkout feature branch
995 | await gitAdapter.createAndCheckoutBranch('feature-branch');
996 |
997 | await fs.writeFile(path.join(testDir, 'new.txt'), 'content');
998 | await gitAdapter.stageFiles(['new.txt']);
999 |
1000 | await gitAdapter.createCommit('Add new file', {
1001 | enforceNonDefaultBranch: true
1002 | });
1003 |
1004 | const git = simpleGit(testDir);
1005 | const log = await git.log();
1006 | expect(log.latest.message).toBe('Add new file');
1007 | });
1008 | });
1009 |
1010 | describe('getCommitLog', () => {
1011 | it('should get recent commits', async () => {
1012 | const log = await gitAdapter.getCommitLog();
1013 | expect(log.length).toBeGreaterThan(0);
1014 | expect(log[0].message.trim()).toBe('Initial commit');
1015 | });
1016 |
1017 | it('should limit number of commits', async () => {
1018 | // Create additional commits
1019 | for (let i = 1; i <= 5; i++) {
1020 | await fs.writeFile(path.join(testDir, `file${i}.txt`), `content${i}`);
1021 | await gitAdapter.stageFiles([`file${i}.txt`]);
1022 | await gitAdapter.createCommit(`Commit ${i}`);
1023 | }
1024 |
1025 | const log = await gitAdapter.getCommitLog({ maxCount: 3 });
1026 | expect(log.length).toBe(3);
1027 | });
1028 |
1029 | it('should return commits with hash and date', async () => {
1030 | const log = await gitAdapter.getCommitLog();
1031 | expect(log[0]).toHaveProperty('hash');
1032 | expect(log[0]).toHaveProperty('date');
1033 | expect(log[0]).toHaveProperty('message');
1034 | expect(log[0]).toHaveProperty('author_name');
1035 | expect(log[0]).toHaveProperty('author_email');
1036 | });
1037 | });
1038 |
1039 | describe('getLastCommit', () => {
1040 | it('should get the last commit', async () => {
1041 | await fs.writeFile(path.join(testDir, 'new.txt'), 'content');
1042 | await gitAdapter.stageFiles(['new.txt']);
1043 | await gitAdapter.createCommit('Latest commit');
1044 |
1045 | const commit = await gitAdapter.getLastCommit();
1046 | expect(commit.message.trim()).toBe('Latest commit');
1047 | });
1048 |
1049 | it('should return commit with metadata if present', async () => {
1050 | await fs.writeFile(path.join(testDir, 'new.txt'), 'content');
1051 | await gitAdapter.stageFiles(['new.txt']);
1052 | const metadata = { taskId: '2.4' };
1053 | await gitAdapter.createCommit('With metadata', { metadata });
1054 |
1055 | const commit = await gitAdapter.getLastCommit();
1056 | expect(commit.message).toContain('[taskId:2.4]');
1057 | });
1058 | });
1059 | });
1060 |
1061 | describe('GitAdapter - Default Branch Detection and Protection', () => {
1062 | let testDir;
1063 | let gitAdapter;
1064 | let simpleGit;
1065 |
1066 | beforeEach(async () => {
1067 | testDir = path.join(os.tmpdir(), `git-default-branch-test-${Date.now()}`);
1068 | await fs.ensureDir(testDir);
1069 |
1070 | // Initialize actual git repo with initial commit
1071 | simpleGit = (await import('simple-git')).default;
1072 | const git = simpleGit(testDir);
1073 | await git.init();
1074 | await git.addConfig('user.name', 'Test User');
1075 | await git.addConfig('user.email', '[email protected]');
1076 |
1077 | // Create initial commit
1078 | await fs.writeFile(path.join(testDir, 'README.md'), '# Test Repo');
1079 | await git.add('README.md');
1080 | await git.commit('Initial commit', undefined, { '--no-gpg-sign': null });
1081 |
1082 | // Rename master to main for consistency
1083 | try {
1084 | await git.branch(['-m', 'master', 'main']);
1085 | } catch (error) {
1086 | // Branch might already be main, ignore error
1087 | }
1088 |
1089 | gitAdapter = new GitAdapter(testDir);
1090 | });
1091 |
1092 | afterEach(async () => {
1093 | if (await fs.pathExists(testDir)) {
1094 | await fs.remove(testDir);
1095 | }
1096 | });
1097 |
1098 | describe('getDefaultBranch', () => {
1099 | it('should detect main as default branch', async () => {
1100 | const defaultBranch = await gitAdapter.getDefaultBranch();
1101 | expect(defaultBranch).toBe('main');
1102 | });
1103 |
1104 | it('should detect master if renamed back', async () => {
1105 | const git = simpleGit(testDir);
1106 | await git.branch(['-m', 'main', 'master']);
1107 |
1108 | const defaultBranch = await gitAdapter.getDefaultBranch();
1109 | expect(defaultBranch).toBe('master');
1110 | });
1111 | });
1112 |
1113 | describe('isDefaultBranch', () => {
1114 | it('should return true for main branch', async () => {
1115 | const isDefault = await gitAdapter.isDefaultBranch('main');
1116 | expect(isDefault).toBe(true);
1117 | });
1118 |
1119 | it('should return true for master branch', async () => {
1120 | const isDefault = await gitAdapter.isDefaultBranch('master');
1121 | expect(isDefault).toBe(true);
1122 | });
1123 |
1124 | it('should return true for develop branch', async () => {
1125 | const isDefault = await gitAdapter.isDefaultBranch('develop');
1126 | expect(isDefault).toBe(true);
1127 | });
1128 |
1129 | it('should return false for feature branch', async () => {
1130 | const isDefault = await gitAdapter.isDefaultBranch('feature-branch');
1131 | expect(isDefault).toBe(false);
1132 | });
1133 | });
1134 |
1135 | describe('isOnDefaultBranch', () => {
1136 | it('should return true when on main', async () => {
1137 | const onDefault = await gitAdapter.isOnDefaultBranch();
1138 | expect(onDefault).toBe(true);
1139 | });
1140 |
1141 | it('should return false when on feature branch', async () => {
1142 | await gitAdapter.createAndCheckoutBranch('feature-branch');
1143 |
1144 | const onDefault = await gitAdapter.isOnDefaultBranch();
1145 | expect(onDefault).toBe(false);
1146 | });
1147 | });
1148 |
1149 | describe('ensureNotOnDefaultBranch', () => {
1150 | it('should throw when on main branch', async () => {
1151 | await expect(gitAdapter.ensureNotOnDefaultBranch()).rejects.toThrow(
1152 | 'currently on default branch'
1153 | );
1154 | });
1155 |
1156 | it('should not throw when on feature branch', async () => {
1157 | await gitAdapter.createAndCheckoutBranch('feature-branch');
1158 |
1159 | await expect(
1160 | gitAdapter.ensureNotOnDefaultBranch()
1161 | ).resolves.not.toThrow();
1162 | });
1163 | });
1164 | });
1165 |
1166 | describe('GitAdapter - Push Operations', () => {
1167 | let testDir;
1168 | let gitAdapter;
1169 |
1170 | beforeEach(async () => {
1171 | testDir = path.join(os.tmpdir(), `git-push-test-${Date.now()}`);
1172 | await fs.ensureDir(testDir);
1173 |
1174 | const simpleGit = (await import('simple-git')).default;
1175 | const git = simpleGit(testDir);
1176 | await git.init();
1177 | await git.addConfig('user.name', 'Test User');
1178 | await git.addConfig('user.email', '[email protected]');
1179 |
1180 | await fs.writeFile(path.join(testDir, 'README.md'), '# Test Repo');
1181 | await git.add('README.md');
1182 | await git.commit('Initial commit', undefined, { '--no-gpg-sign': null });
1183 |
1184 | try {
1185 | await git.branch(['-m', 'master', 'main']);
1186 | } catch (error) {}
1187 |
1188 | gitAdapter = new GitAdapter(testDir);
1189 | });
1190 |
1191 | afterEach(async () => {
1192 | if (await fs.pathExists(testDir)) {
1193 | await fs.remove(testDir);
1194 | }
1195 | });
1196 |
1197 | describe('hasRemote', () => {
1198 | it('should return false when no remotes exist', async () => {
1199 | const hasRemote = await gitAdapter.hasRemote();
1200 | expect(hasRemote).toBe(false);
1201 | });
1202 | });
1203 |
1204 | describe('getRemotes', () => {
1205 | it('should return empty array when no remotes', async () => {
1206 | const remotes = await gitAdapter.getRemotes();
1207 | expect(remotes).toEqual([]);
1208 | });
1209 | });
1210 | });
1211 | });
1212 |
```
--------------------------------------------------------------------------------
/tests/unit/config-manager.test.js:
--------------------------------------------------------------------------------
```javascript
1 | import fs from 'fs';
2 | import path from 'path';
3 | import { jest } from '@jest/globals';
4 | import { fileURLToPath } from 'url';
5 |
6 | // Mock modules first before any imports
7 | jest.mock('fs', () => ({
8 | existsSync: jest.fn((filePath) => {
9 | // Prevent Jest internal file access
10 | if (
11 | filePath.includes('jest-message-util') ||
12 | filePath.includes('node_modules')
13 | ) {
14 | return false;
15 | }
16 | return false; // Default to false for config discovery prevention
17 | }),
18 | readFileSync: jest.fn(() => '{}'),
19 | writeFileSync: jest.fn(),
20 | mkdirSync: jest.fn()
21 | }));
22 |
23 | jest.mock('path', () => ({
24 | join: jest.fn((dir, file) => `${dir}/${file}`),
25 | dirname: jest.fn((filePath) => filePath.split('/').slice(0, -1).join('/')),
26 | resolve: jest.fn((...paths) => paths.join('/')),
27 | basename: jest.fn((filePath) => filePath.split('/').pop())
28 | }));
29 |
30 | jest.mock('chalk', () => ({
31 | red: jest.fn((text) => text),
32 | blue: jest.fn((text) => text),
33 | green: jest.fn((text) => text),
34 | yellow: jest.fn((text) => text),
35 | white: jest.fn(() => ({
36 | bold: jest.fn((text) => text)
37 | })),
38 | reset: jest.fn((text) => text),
39 | dim: jest.fn((text) => text) // Add dim function to prevent chalk errors
40 | }));
41 |
42 | // Mock console to prevent Jest internal access
43 | const mockConsole = {
44 | log: jest.fn(),
45 | info: jest.fn(),
46 | warn: jest.fn(),
47 | error: jest.fn()
48 | };
49 | global.console = mockConsole;
50 |
51 | // --- Define Mock Function Instances ---
52 | const mockFindConfigPath = jest.fn(() => null); // Default to null, can be overridden in tests
53 |
54 | // Mock path-utils to prevent config file path discovery and logging
55 | jest.mock('../../src/utils/path-utils.js', () => ({
56 | __esModule: true,
57 | findProjectRoot: jest.fn(() => '/mock/project'),
58 | findConfigPath: mockFindConfigPath, // Use the mock function instance
59 | findTasksPath: jest.fn(() => '/mock/tasks.json'),
60 | findComplexityReportPath: jest.fn(() => null),
61 | resolveTasksOutputPath: jest.fn(() => '/mock/tasks.json'),
62 | resolveComplexityReportOutputPath: jest.fn(() => '/mock/report.json')
63 | }));
64 |
65 | // --- Read REAL supported-models.json data BEFORE mocks ---
66 | const __filename = fileURLToPath(import.meta.url); // Get current file path
67 | const __dirname = path.dirname(__filename); // Get current directory
68 | const realSupportedModelsPath = path.resolve(
69 | __dirname,
70 | '../../scripts/modules/supported-models.json'
71 | );
72 | let REAL_SUPPORTED_MODELS_CONTENT;
73 | let REAL_SUPPORTED_MODELS_DATA;
74 | try {
75 | REAL_SUPPORTED_MODELS_CONTENT = fs.readFileSync(
76 | realSupportedModelsPath,
77 | 'utf-8'
78 | );
79 | REAL_SUPPORTED_MODELS_DATA = JSON.parse(REAL_SUPPORTED_MODELS_CONTENT);
80 | } catch (err) {
81 | console.error(
82 | 'FATAL TEST SETUP ERROR: Could not read or parse real supported-models.json',
83 | err
84 | );
85 | REAL_SUPPORTED_MODELS_CONTENT = '{}'; // Default to empty object on error
86 | REAL_SUPPORTED_MODELS_DATA = {};
87 | process.exit(1); // Exit if essential test data can't be loaded
88 | }
89 |
90 | // --- Define Mock Function Instances ---
91 | const mockFindProjectRoot = jest.fn();
92 | const mockLog = jest.fn();
93 |
94 | // --- Mock Dependencies BEFORE importing the module under test ---
95 |
96 | // Mock the 'utils.js' module using a factory function
97 | jest.mock('../../scripts/modules/utils.js', () => ({
98 | __esModule: true, // Indicate it's an ES module mock
99 | findProjectRoot: mockFindProjectRoot, // Use the mock function instance
100 | log: mockLog, // Use the mock function instance
101 | // Include other necessary exports from utils if config-manager uses them directly
102 | resolveEnvVariable: jest.fn() // Example if needed
103 | }));
104 |
105 | // --- Import the module under test AFTER mocks are defined ---
106 | import * as configManager from '../../scripts/modules/config-manager.js';
107 | // Import the mocked 'fs' module to allow spying on its functions
108 | import fsMocked from 'fs';
109 |
110 | // --- Test Data (Keep as is, ensure DEFAULT_CONFIG is accurate) ---
111 | const MOCK_PROJECT_ROOT = '/mock/project';
112 | const MOCK_CONFIG_PATH = path.join(
113 | MOCK_PROJECT_ROOT,
114 | '.taskmaster/config.json'
115 | );
116 |
117 | // Updated DEFAULT_CONFIG reflecting the implementation
118 | const DEFAULT_CONFIG = {
119 | models: {
120 | main: {
121 | provider: 'anthropic',
122 | modelId: 'claude-sonnet-4-20250514',
123 | maxTokens: 64000,
124 | temperature: 0.2
125 | },
126 | research: {
127 | provider: 'perplexity',
128 | modelId: 'sonar',
129 | maxTokens: 8700,
130 | temperature: 0.1
131 | },
132 | fallback: {
133 | provider: 'anthropic',
134 | modelId: 'claude-3-7-sonnet-20250219',
135 | maxTokens: 120000,
136 | temperature: 0.2
137 | }
138 | },
139 | global: {
140 | logLevel: 'info',
141 | debug: false,
142 | defaultNumTasks: 10,
143 | defaultSubtasks: 5,
144 | defaultPriority: 'medium',
145 | projectName: 'Task Master',
146 | ollamaBaseURL: 'http://localhost:11434/api',
147 | bedrockBaseURL: 'https://bedrock.us-east-1.amazonaws.com',
148 | enableCodebaseAnalysis: true,
149 | enableProxy: false,
150 | responseLanguage: 'English'
151 | },
152 | claudeCode: {},
153 | codexCli: {},
154 | grokCli: {
155 | timeout: 120000,
156 | workingDirectory: null,
157 | defaultModel: 'grok-4-latest'
158 | }
159 | };
160 |
161 | // Other test data (VALID_CUSTOM_CONFIG, PARTIAL_CONFIG, INVALID_PROVIDER_CONFIG)
162 | const VALID_CUSTOM_CONFIG = {
163 | models: {
164 | main: {
165 | provider: 'openai',
166 | modelId: 'gpt-4o',
167 | maxTokens: 4096,
168 | temperature: 0.5
169 | },
170 | research: {
171 | provider: 'google',
172 | modelId: 'gemini-1.5-pro-latest',
173 | maxTokens: 8192,
174 | temperature: 0.3
175 | },
176 | fallback: {
177 | provider: 'anthropic',
178 | modelId: 'claude-3-opus-20240229',
179 | maxTokens: 100000,
180 | temperature: 0.4
181 | }
182 | },
183 | global: {
184 | logLevel: 'debug',
185 | defaultPriority: 'high',
186 | projectName: 'My Custom Project'
187 | }
188 | };
189 |
190 | const PARTIAL_CONFIG = {
191 | models: {
192 | main: { provider: 'openai', modelId: 'gpt-4-turbo' }
193 | },
194 | global: {
195 | projectName: 'Partial Project'
196 | }
197 | };
198 |
199 | const INVALID_PROVIDER_CONFIG = {
200 | models: {
201 | main: { provider: 'invalid-provider', modelId: 'some-model' },
202 | research: {
203 | provider: 'perplexity',
204 | modelId: 'llama-3-sonar-large-32k-online'
205 | }
206 | },
207 | global: {
208 | logLevel: 'warn'
209 | }
210 | };
211 |
212 | // Claude Code test data
213 | const VALID_CLAUDE_CODE_CONFIG = {
214 | maxTurns: 5,
215 | customSystemPrompt: 'You are a helpful coding assistant',
216 | appendSystemPrompt: 'Always follow best practices',
217 | permissionMode: 'acceptEdits',
218 | allowedTools: ['Read', 'LS', 'Edit'],
219 | disallowedTools: ['Write'],
220 | mcpServers: {
221 | 'test-server': {
222 | type: 'stdio',
223 | command: 'node',
224 | args: ['server.js'],
225 | env: { NODE_ENV: 'test' }
226 | }
227 | },
228 | commandSpecific: {
229 | 'add-task': {
230 | maxTurns: 3,
231 | permissionMode: 'plan'
232 | },
233 | research: {
234 | customSystemPrompt: 'You are a research assistant'
235 | }
236 | }
237 | };
238 |
239 | const INVALID_CLAUDE_CODE_CONFIG = {
240 | maxTurns: 'invalid', // Should be number
241 | permissionMode: 'invalid-mode', // Invalid enum value
242 | allowedTools: 'not-an-array', // Should be array
243 | mcpServers: {
244 | 'invalid-server': {
245 | type: 'invalid-type', // Invalid enum value
246 | url: 'not-a-valid-url' // Invalid URL format
247 | }
248 | },
249 | commandSpecific: {
250 | 'invalid-command': {
251 | // Invalid command name
252 | maxTurns: -1 // Invalid negative number
253 | }
254 | }
255 | };
256 |
257 | const PARTIAL_CLAUDE_CODE_CONFIG = {
258 | maxTurns: 10,
259 | permissionMode: 'default',
260 | commandSpecific: {
261 | 'expand-task': {
262 | customSystemPrompt: 'Focus on task breakdown'
263 | }
264 | }
265 | };
266 |
267 | // Define spies globally to be restored in afterAll
268 | let consoleErrorSpy;
269 | let consoleWarnSpy;
270 | let fsReadFileSyncSpy;
271 | let fsWriteFileSyncSpy;
272 | let fsExistsSyncSpy;
273 |
274 | beforeAll(() => {
275 | // Set up console spies
276 | consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
277 | consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
278 | });
279 |
280 | afterAll(() => {
281 | // Restore all spies
282 | jest.restoreAllMocks();
283 | });
284 |
285 | // Reset mocks before each test for isolation
286 | beforeEach(() => {
287 | // Clear all mock calls and reset implementations between tests
288 | jest.clearAllMocks();
289 | // Reset the external mock instances for utils
290 | mockFindProjectRoot.mockReset();
291 | mockLog.mockReset();
292 | mockFindConfigPath.mockReset();
293 |
294 | // --- Set up spies ON the imported 'fs' mock ---
295 | fsExistsSyncSpy = jest.spyOn(fsMocked, 'existsSync');
296 | fsReadFileSyncSpy = jest.spyOn(fsMocked, 'readFileSync');
297 | fsWriteFileSyncSpy = jest.spyOn(fsMocked, 'writeFileSync');
298 |
299 | // --- Default Mock Implementations ---
300 | mockFindProjectRoot.mockReturnValue(MOCK_PROJECT_ROOT); // Default for utils.findProjectRoot
301 | mockFindConfigPath.mockReturnValue(null); // Default to no config file found
302 | fsExistsSyncSpy.mockReturnValue(true); // Assume files exist by default
303 |
304 | // Default readFileSync: Return REAL models content, mocked config, or throw error
305 | fsReadFileSyncSpy.mockImplementation((filePath) => {
306 | const baseName = path.basename(filePath);
307 | if (baseName === 'supported-models.json') {
308 | // Return the REAL file content stringified
309 | return REAL_SUPPORTED_MODELS_CONTENT;
310 | } else if (filePath === MOCK_CONFIG_PATH) {
311 | // Still mock the .taskmasterconfig reads
312 | return JSON.stringify(DEFAULT_CONFIG); // Default behavior
313 | }
314 | // For Jest internal files or other unexpected files, return empty string instead of throwing
315 | // This prevents Jest's internal file operations from breaking tests
316 | if (
317 | filePath.includes('jest-message-util') ||
318 | filePath.includes('node_modules')
319 | ) {
320 | return '{}'; // Return empty JSON for Jest internal files
321 | }
322 | // Throw for truly unexpected reads that should be caught in tests
323 | throw new Error(`Unexpected fs.readFileSync call in test: ${filePath}`);
324 | });
325 |
326 | // Default writeFileSync: Do nothing, just allow calls
327 | fsWriteFileSyncSpy.mockImplementation(() => {});
328 | });
329 |
330 | // --- Validation Functions ---
331 | describe('Validation Functions', () => {
332 | // Tests for validateProvider and validateProviderModelCombination
333 | test('validateProvider should return true for valid providers', () => {
334 | expect(configManager.validateProvider('openai')).toBe(true);
335 | expect(configManager.validateProvider('anthropic')).toBe(true);
336 | expect(configManager.validateProvider('google')).toBe(true);
337 | expect(configManager.validateProvider('perplexity')).toBe(true);
338 | expect(configManager.validateProvider('ollama')).toBe(true);
339 | expect(configManager.validateProvider('openrouter')).toBe(true);
340 | expect(configManager.validateProvider('bedrock')).toBe(true);
341 | });
342 |
343 | test('validateProvider should return false for invalid providers', () => {
344 | expect(configManager.validateProvider('invalid-provider')).toBe(false);
345 | expect(configManager.validateProvider('grok')).toBe(false); // Not in mock map
346 | expect(configManager.validateProvider('')).toBe(false);
347 | expect(configManager.validateProvider(null)).toBe(false);
348 | });
349 |
350 | test('validateProviderModelCombination should validate known good combinations', () => {
351 | // Re-load config to ensure MODEL_MAP is populated from mock (now real data)
352 | configManager.getConfig(MOCK_PROJECT_ROOT, true);
353 | expect(
354 | configManager.validateProviderModelCombination('openai', 'gpt-4o')
355 | ).toBe(true);
356 | expect(
357 | configManager.validateProviderModelCombination(
358 | 'anthropic',
359 | 'claude-3-5-sonnet-20241022'
360 | )
361 | ).toBe(true);
362 | });
363 |
364 | test('validateProviderModelCombination should return false for known bad combinations', () => {
365 | // Re-load config to ensure MODEL_MAP is populated from mock (now real data)
366 | configManager.getConfig(MOCK_PROJECT_ROOT, true);
367 | expect(
368 | configManager.validateProviderModelCombination(
369 | 'openai',
370 | 'claude-3-opus-20240229'
371 | )
372 | ).toBe(false);
373 | });
374 |
375 | test('validateProviderModelCombination should return true for ollama/openrouter (empty lists in map)', () => {
376 | // Re-load config to ensure MODEL_MAP is populated from mock (now real data)
377 | configManager.getConfig(MOCK_PROJECT_ROOT, true);
378 | expect(
379 | configManager.validateProviderModelCombination('ollama', 'any-model')
380 | ).toBe(false);
381 | expect(
382 | configManager.validateProviderModelCombination('openrouter', 'any/model')
383 | ).toBe(false);
384 | });
385 |
386 | test('validateProviderModelCombination should return true for providers not in map', () => {
387 | // Re-load config to ensure MODEL_MAP is populated from mock (now real data)
388 | configManager.getConfig(MOCK_PROJECT_ROOT, true);
389 | // The implementation returns true if the provider isn't in the map
390 | expect(
391 | configManager.validateProviderModelCombination(
392 | 'unknown-provider',
393 | 'some-model'
394 | )
395 | ).toBe(true);
396 | });
397 | });
398 |
399 | // --- Claude Code Validation Tests ---
400 | describe('Claude Code Validation', () => {
401 | test('validateClaudeCodeSettings should return valid settings for correct input', () => {
402 | const result = configManager.validateClaudeCodeSettings(
403 | VALID_CLAUDE_CODE_CONFIG
404 | );
405 |
406 | expect(result).toEqual(VALID_CLAUDE_CODE_CONFIG);
407 | expect(consoleWarnSpy).not.toHaveBeenCalled();
408 | });
409 |
410 | test('validateClaudeCodeSettings should return empty object for invalid input', () => {
411 | const result = configManager.validateClaudeCodeSettings(
412 | INVALID_CLAUDE_CODE_CONFIG
413 | );
414 |
415 | expect(result).toEqual({});
416 | expect(consoleWarnSpy).toHaveBeenCalledWith(
417 | expect.stringContaining('Warning: Invalid Claude Code settings in config')
418 | );
419 | });
420 |
421 | test('validateClaudeCodeSettings should handle partial valid configuration', () => {
422 | const result = configManager.validateClaudeCodeSettings(
423 | PARTIAL_CLAUDE_CODE_CONFIG
424 | );
425 |
426 | expect(result).toEqual(PARTIAL_CLAUDE_CODE_CONFIG);
427 | expect(consoleWarnSpy).not.toHaveBeenCalled();
428 | });
429 |
430 | test('validateClaudeCodeSettings should return empty object for empty input', () => {
431 | const result = configManager.validateClaudeCodeSettings({});
432 |
433 | expect(result).toEqual({});
434 | expect(consoleWarnSpy).not.toHaveBeenCalled();
435 | });
436 |
437 | test('validateClaudeCodeSettings should handle null/undefined input', () => {
438 | expect(configManager.validateClaudeCodeSettings(null)).toEqual({});
439 | expect(configManager.validateClaudeCodeSettings(undefined)).toEqual({});
440 | expect(consoleWarnSpy).toHaveBeenCalledTimes(2);
441 | });
442 | });
443 |
444 | // --- Claude Code Getter Tests ---
445 | describe('Claude Code Getter Functions', () => {
446 | test('getClaudeCodeSettings should return default empty object when no config exists', () => {
447 | // No config file exists, should return empty object
448 | fsExistsSyncSpy.mockReturnValue(false);
449 | const settings = configManager.getClaudeCodeSettings(MOCK_PROJECT_ROOT);
450 |
451 | expect(settings).toEqual({});
452 | });
453 |
454 | test('getClaudeCodeSettings should return merged settings from config file', () => {
455 | // Config file with Claude Code settings
456 | const configWithClaudeCode = {
457 | ...VALID_CUSTOM_CONFIG,
458 | claudeCode: VALID_CLAUDE_CODE_CONFIG
459 | };
460 |
461 | // Mock findConfigPath to return the mock config path
462 | mockFindConfigPath.mockReturnValue(MOCK_CONFIG_PATH);
463 |
464 | fsReadFileSyncSpy.mockImplementation((filePath) => {
465 | if (filePath === MOCK_CONFIG_PATH)
466 | return JSON.stringify(configWithClaudeCode);
467 | if (path.basename(filePath) === 'supported-models.json') {
468 | return JSON.stringify({
469 | openai: [{ id: 'gpt-4o' }],
470 | google: [{ id: 'gemini-1.5-pro-latest' }],
471 | anthropic: [
472 | { id: 'claude-3-opus-20240229' },
473 | { id: 'claude-3-7-sonnet-20250219' },
474 | { id: 'claude-3-5-sonnet' }
475 | ],
476 | perplexity: [{ id: 'sonar-pro' }],
477 | ollama: [],
478 | openrouter: []
479 | });
480 | }
481 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`);
482 | });
483 | fsExistsSyncSpy.mockReturnValue(true);
484 |
485 | const settings = configManager.getClaudeCodeSettings(
486 | MOCK_PROJECT_ROOT,
487 | true
488 | ); // Force reload
489 |
490 | expect(settings).toEqual(VALID_CLAUDE_CODE_CONFIG);
491 | });
492 |
493 | test('getClaudeCodeSettingsForCommand should return command-specific settings', () => {
494 | // Config with command-specific settings
495 | const configWithClaudeCode = {
496 | ...VALID_CUSTOM_CONFIG,
497 | claudeCode: VALID_CLAUDE_CODE_CONFIG
498 | };
499 |
500 | // Mock findConfigPath to return the mock config path
501 | mockFindConfigPath.mockReturnValue(MOCK_CONFIG_PATH);
502 |
503 | fsReadFileSyncSpy.mockImplementation((filePath) => {
504 | if (path.basename(filePath) === 'supported-models.json') return '{}';
505 | if (filePath === MOCK_CONFIG_PATH)
506 | return JSON.stringify(configWithClaudeCode);
507 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`);
508 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`);
509 | });
510 | fsExistsSyncSpy.mockReturnValue(true);
511 |
512 | const settings = configManager.getClaudeCodeSettingsForCommand(
513 | 'add-task',
514 | MOCK_PROJECT_ROOT,
515 | true
516 | ); // Force reload
517 |
518 | // Should merge global settings with command-specific settings
519 | const expectedSettings = {
520 | ...VALID_CLAUDE_CODE_CONFIG,
521 | ...VALID_CLAUDE_CODE_CONFIG.commandSpecific['add-task']
522 | };
523 | expect(settings).toEqual(expectedSettings);
524 | });
525 |
526 | test('getClaudeCodeSettingsForCommand should return global settings for unknown command', () => {
527 | // Config with Claude Code settings
528 | const configWithClaudeCode = {
529 | ...VALID_CUSTOM_CONFIG,
530 | claudeCode: PARTIAL_CLAUDE_CODE_CONFIG
531 | };
532 |
533 | // Mock findConfigPath to return the mock config path
534 | mockFindConfigPath.mockReturnValue(MOCK_CONFIG_PATH);
535 |
536 | fsReadFileSyncSpy.mockImplementation((filePath) => {
537 | if (path.basename(filePath) === 'supported-models.json') return '{}';
538 | if (filePath === MOCK_CONFIG_PATH)
539 | return JSON.stringify(configWithClaudeCode);
540 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`);
541 | });
542 | fsExistsSyncSpy.mockReturnValue(true);
543 |
544 | const settings = configManager.getClaudeCodeSettingsForCommand(
545 | 'unknown-command',
546 | MOCK_PROJECT_ROOT,
547 | true
548 | ); // Force reload
549 |
550 | // Should return global settings only
551 | expect(settings).toEqual(PARTIAL_CLAUDE_CODE_CONFIG);
552 | });
553 | });
554 |
555 | // --- getConfig Tests ---
556 | describe('getConfig Tests', () => {
557 | test('should return default config if .taskmasterconfig does not exist', () => {
558 | // Arrange
559 | fsExistsSyncSpy.mockReturnValue(false);
560 | // findProjectRoot mock is set in beforeEach
561 |
562 | // Act: Call getConfig with explicit root
563 | const config = configManager.getConfig(MOCK_PROJECT_ROOT, true); // Force reload
564 |
565 | // Assert
566 | expect(config).toEqual(DEFAULT_CONFIG);
567 | expect(mockFindProjectRoot).not.toHaveBeenCalled(); // Explicit root provided
568 | // The implementation checks for .taskmaster directory first
569 | expect(fsExistsSyncSpy).toHaveBeenCalledWith(
570 | path.join(MOCK_PROJECT_ROOT, '.taskmaster')
571 | );
572 | expect(fsReadFileSyncSpy).not.toHaveBeenCalled(); // No read if file doesn't exist
573 | expect(consoleWarnSpy).toHaveBeenCalledWith(
574 | expect.stringContaining('not found at provided project root')
575 | );
576 | });
577 |
578 | test.skip('should use findProjectRoot and return defaults if file not found', () => {
579 | // TODO: Fix mock interaction, findProjectRoot isn't being registered as called
580 | // Arrange
581 | fsExistsSyncSpy.mockReturnValue(false);
582 | // findProjectRoot mock is set in beforeEach
583 |
584 | // Act: Call getConfig without explicit root
585 | const config = configManager.getConfig(null, true); // Force reload
586 |
587 | // Assert
588 | expect(mockFindProjectRoot).toHaveBeenCalled(); // Should be called now
589 | expect(fsExistsSyncSpy).toHaveBeenCalledWith(MOCK_CONFIG_PATH);
590 | expect(config).toEqual(DEFAULT_CONFIG);
591 | expect(fsReadFileSyncSpy).not.toHaveBeenCalled();
592 | expect(consoleWarnSpy).toHaveBeenCalledWith(
593 | expect.stringContaining('not found at derived root')
594 | ); // Adjusted expected warning
595 | });
596 |
597 | test('should read and merge valid config file with defaults', () => {
598 | // Arrange: Override readFileSync for this test
599 | fsReadFileSyncSpy.mockImplementation((filePath) => {
600 | if (filePath === MOCK_CONFIG_PATH)
601 | return JSON.stringify(VALID_CUSTOM_CONFIG);
602 | if (path.basename(filePath) === 'supported-models.json') {
603 | // Provide necessary models for validation within getConfig
604 | return JSON.stringify({
605 | openai: [{ id: 'gpt-4o' }],
606 | google: [{ id: 'gemini-1.5-pro-latest' }],
607 | perplexity: [{ id: 'sonar-pro' }],
608 | anthropic: [
609 | { id: 'claude-3-opus-20240229' },
610 | { id: 'claude-3-5-sonnet' },
611 | { id: 'claude-3-7-sonnet-20250219' },
612 | { id: 'claude-3-5-sonnet' }
613 | ],
614 | ollama: [],
615 | openrouter: []
616 | });
617 | }
618 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`);
619 | });
620 | fsExistsSyncSpy.mockReturnValue(true);
621 | // findProjectRoot mock set in beforeEach
622 |
623 | // Act
624 | const config = configManager.getConfig(MOCK_PROJECT_ROOT, true); // Force reload
625 |
626 | // Assert: Construct expected merged config
627 | const expectedMergedConfig = {
628 | models: {
629 | main: {
630 | ...DEFAULT_CONFIG.models.main,
631 | ...VALID_CUSTOM_CONFIG.models.main
632 | },
633 | research: {
634 | ...DEFAULT_CONFIG.models.research,
635 | ...VALID_CUSTOM_CONFIG.models.research
636 | },
637 | fallback: {
638 | ...DEFAULT_CONFIG.models.fallback,
639 | ...VALID_CUSTOM_CONFIG.models.fallback
640 | }
641 | },
642 | global: { ...DEFAULT_CONFIG.global, ...VALID_CUSTOM_CONFIG.global },
643 | claudeCode: {
644 | ...DEFAULT_CONFIG.claudeCode,
645 | ...VALID_CUSTOM_CONFIG.claudeCode
646 | },
647 | grokCli: { ...DEFAULT_CONFIG.grokCli },
648 | codexCli: { ...DEFAULT_CONFIG.codexCli }
649 | };
650 | expect(config).toEqual(expectedMergedConfig);
651 | expect(fsExistsSyncSpy).toHaveBeenCalledWith(MOCK_CONFIG_PATH);
652 | expect(fsReadFileSyncSpy).toHaveBeenCalledWith(MOCK_CONFIG_PATH, 'utf-8');
653 | });
654 |
655 | test('should merge defaults for partial config file', () => {
656 | // Arrange
657 | fsReadFileSyncSpy.mockImplementation((filePath) => {
658 | if (filePath === MOCK_CONFIG_PATH) return JSON.stringify(PARTIAL_CONFIG);
659 | if (path.basename(filePath) === 'supported-models.json') {
660 | return JSON.stringify({
661 | openai: [{ id: 'gpt-4-turbo' }],
662 | perplexity: [{ id: 'sonar-pro' }],
663 | anthropic: [
664 | { id: 'claude-3-7-sonnet-20250219' },
665 | { id: 'claude-3-5-sonnet' }
666 | ],
667 | ollama: [],
668 | openrouter: []
669 | });
670 | }
671 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`);
672 | });
673 | fsExistsSyncSpy.mockReturnValue(true);
674 | // findProjectRoot mock set in beforeEach
675 |
676 | // Act
677 | const config = configManager.getConfig(MOCK_PROJECT_ROOT, true);
678 |
679 | // Assert: Construct expected merged config
680 | const expectedMergedConfig = {
681 | models: {
682 | main: { ...DEFAULT_CONFIG.models.main, ...PARTIAL_CONFIG.models.main },
683 | research: { ...DEFAULT_CONFIG.models.research },
684 | fallback: { ...DEFAULT_CONFIG.models.fallback }
685 | },
686 | global: { ...DEFAULT_CONFIG.global, ...PARTIAL_CONFIG.global },
687 | claudeCode: {
688 | ...DEFAULT_CONFIG.claudeCode,
689 | ...VALID_CUSTOM_CONFIG.claudeCode
690 | },
691 | grokCli: { ...DEFAULT_CONFIG.grokCli },
692 | codexCli: { ...DEFAULT_CONFIG.codexCli }
693 | };
694 | expect(config).toEqual(expectedMergedConfig);
695 | expect(fsReadFileSyncSpy).toHaveBeenCalledWith(MOCK_CONFIG_PATH, 'utf-8');
696 | });
697 |
698 | test('should handle JSON parsing error and return defaults', () => {
699 | // Arrange
700 | fsReadFileSyncSpy.mockImplementation((filePath) => {
701 | if (filePath === MOCK_CONFIG_PATH) return 'invalid json';
702 | // Mock models read needed for initial load before parse error
703 | if (path.basename(filePath) === 'supported-models.json') {
704 | return JSON.stringify({
705 | anthropic: [{ id: 'claude-3-7-sonnet-20250219' }],
706 | perplexity: [{ id: 'sonar-pro' }],
707 | fallback: [{ id: 'claude-3-5-sonnet' }],
708 | ollama: [],
709 | openrouter: []
710 | });
711 | }
712 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`);
713 | });
714 | fsExistsSyncSpy.mockReturnValue(true);
715 | // findProjectRoot mock set in beforeEach
716 |
717 | // Act
718 | const config = configManager.getConfig(MOCK_PROJECT_ROOT, true);
719 |
720 | // Assert
721 | expect(config).toEqual(DEFAULT_CONFIG);
722 | expect(consoleErrorSpy).toHaveBeenCalledWith(
723 | expect.stringContaining('Error reading or parsing')
724 | );
725 | });
726 |
727 | test('should handle file read error and return defaults', () => {
728 | // Arrange
729 | const readError = new Error('Permission denied');
730 | fsReadFileSyncSpy.mockImplementation((filePath) => {
731 | if (filePath === MOCK_CONFIG_PATH) throw readError;
732 | // Mock models read needed for initial load before read error
733 | if (path.basename(filePath) === 'supported-models.json') {
734 | return JSON.stringify({
735 | anthropic: [{ id: 'claude-3-7-sonnet-20250219' }],
736 | perplexity: [{ id: 'sonar-pro' }],
737 | fallback: [{ id: 'claude-3-5-sonnet' }],
738 | ollama: [],
739 | openrouter: []
740 | });
741 | }
742 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`);
743 | });
744 | fsExistsSyncSpy.mockReturnValue(true);
745 | // findProjectRoot mock set in beforeEach
746 |
747 | // Act
748 | const config = configManager.getConfig(MOCK_PROJECT_ROOT, true);
749 |
750 | // Assert
751 | expect(config).toEqual(DEFAULT_CONFIG);
752 | expect(consoleErrorSpy).toHaveBeenCalledWith(
753 | expect.stringContaining('Permission denied. Using default configuration.')
754 | );
755 | });
756 |
757 | test('should validate provider and fallback to default if invalid', () => {
758 | // Arrange
759 | fsReadFileSyncSpy.mockImplementation((filePath) => {
760 | if (filePath === MOCK_CONFIG_PATH)
761 | return JSON.stringify(INVALID_PROVIDER_CONFIG);
762 | if (path.basename(filePath) === 'supported-models.json') {
763 | return JSON.stringify({
764 | perplexity: [{ id: 'llama-3-sonar-large-32k-online' }],
765 | anthropic: [
766 | { id: 'claude-3-7-sonnet-20250219' },
767 | { id: 'claude-3-5-sonnet' }
768 | ],
769 | ollama: [],
770 | openrouter: []
771 | });
772 | }
773 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`);
774 | });
775 | fsExistsSyncSpy.mockReturnValue(true);
776 | // findProjectRoot mock set in beforeEach
777 |
778 | // Act
779 | const config = configManager.getConfig(MOCK_PROJECT_ROOT, true);
780 |
781 | // Assert
782 | expect(consoleWarnSpy).toHaveBeenCalledWith(
783 | expect.stringContaining(
784 | 'Warning: Invalid main provider "invalid-provider"'
785 | )
786 | );
787 | const expectedMergedConfig = {
788 | models: {
789 | main: { ...DEFAULT_CONFIG.models.main },
790 | research: {
791 | ...DEFAULT_CONFIG.models.research,
792 | ...INVALID_PROVIDER_CONFIG.models.research
793 | },
794 | fallback: { ...DEFAULT_CONFIG.models.fallback }
795 | },
796 | global: { ...DEFAULT_CONFIG.global, ...INVALID_PROVIDER_CONFIG.global },
797 | claudeCode: {
798 | ...DEFAULT_CONFIG.claudeCode,
799 | ...VALID_CUSTOM_CONFIG.claudeCode
800 | },
801 | grokCli: { ...DEFAULT_CONFIG.grokCli },
802 | codexCli: { ...DEFAULT_CONFIG.codexCli }
803 | };
804 | expect(config).toEqual(expectedMergedConfig);
805 | });
806 | });
807 |
808 | // --- writeConfig Tests ---
809 | describe('writeConfig', () => {
810 | test('should write valid config to file', () => {
811 | // Arrange (Default mocks are sufficient)
812 | // findProjectRoot mock set in beforeEach
813 | fsWriteFileSyncSpy.mockImplementation(() => {}); // Ensure it doesn't throw
814 |
815 | // Act
816 | const success = configManager.writeConfig(
817 | VALID_CUSTOM_CONFIG,
818 | MOCK_PROJECT_ROOT
819 | );
820 |
821 | // Assert
822 | expect(success).toBe(true);
823 | expect(fsWriteFileSyncSpy).toHaveBeenCalledWith(
824 | MOCK_CONFIG_PATH,
825 | JSON.stringify(VALID_CUSTOM_CONFIG, null, 2) // writeConfig stringifies
826 | );
827 | expect(consoleErrorSpy).not.toHaveBeenCalled();
828 | });
829 |
830 | test('should return false and log error if write fails', () => {
831 | // Arrange
832 | const mockWriteError = new Error('Disk full');
833 | fsWriteFileSyncSpy.mockImplementation(() => {
834 | throw mockWriteError;
835 | });
836 | // findProjectRoot mock set in beforeEach
837 |
838 | // Act
839 | const success = configManager.writeConfig(
840 | VALID_CUSTOM_CONFIG,
841 | MOCK_PROJECT_ROOT
842 | );
843 |
844 | // Assert
845 | expect(success).toBe(false);
846 | expect(fsWriteFileSyncSpy).toHaveBeenCalled();
847 | expect(consoleErrorSpy).toHaveBeenCalledWith(
848 | expect.stringContaining('Disk full')
849 | );
850 | });
851 |
852 | test.skip('should return false if project root cannot be determined', () => {
853 | // TODO: Fix mock interaction or function logic, returns true unexpectedly in test
854 | // Arrange: Override mock for this specific test
855 | mockFindProjectRoot.mockReturnValue(null);
856 |
857 | // Act: Call without explicit root
858 | const success = configManager.writeConfig(VALID_CUSTOM_CONFIG);
859 |
860 | // Assert
861 | expect(success).toBe(false); // Function should return false if root is null
862 | expect(mockFindProjectRoot).toHaveBeenCalled();
863 | expect(fsWriteFileSyncSpy).not.toHaveBeenCalled();
864 | expect(consoleErrorSpy).toHaveBeenCalledWith(
865 | expect.stringContaining('Could not determine project root')
866 | );
867 | });
868 | });
869 |
870 | // --- Getter Functions ---
871 | describe('Getter Functions', () => {
872 | test('getMainProvider should return provider from config', () => {
873 | // Arrange: Set up readFileSync to return VALID_CUSTOM_CONFIG
874 | fsReadFileSyncSpy.mockImplementation((filePath) => {
875 | if (filePath === MOCK_CONFIG_PATH)
876 | return JSON.stringify(VALID_CUSTOM_CONFIG);
877 | if (path.basename(filePath) === 'supported-models.json') {
878 | return JSON.stringify({
879 | openai: [{ id: 'gpt-4o' }],
880 | google: [{ id: 'gemini-1.5-pro-latest' }],
881 | anthropic: [
882 | { id: 'claude-3-opus-20240229' },
883 | { id: 'claude-3-7-sonnet-20250219' },
884 | { id: 'claude-3-5-sonnet' }
885 | ],
886 | perplexity: [{ id: 'sonar-pro' }],
887 | ollama: [],
888 | openrouter: []
889 | }); // Added perplexity
890 | }
891 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`);
892 | });
893 | fsExistsSyncSpy.mockReturnValue(true);
894 | // findProjectRoot mock set in beforeEach
895 |
896 | // Act
897 | const provider = configManager.getMainProvider(MOCK_PROJECT_ROOT);
898 |
899 | // Assert
900 | expect(provider).toBe(VALID_CUSTOM_CONFIG.models.main.provider);
901 | });
902 |
903 | test('getLogLevel should return logLevel from config', () => {
904 | // Arrange: Set up readFileSync to return VALID_CUSTOM_CONFIG
905 | fsReadFileSyncSpy.mockImplementation((filePath) => {
906 | if (filePath === MOCK_CONFIG_PATH)
907 | return JSON.stringify(VALID_CUSTOM_CONFIG);
908 | if (path.basename(filePath) === 'supported-models.json') {
909 | // Provide enough mock model data for validation within getConfig
910 | return JSON.stringify({
911 | openai: [{ id: 'gpt-4o' }],
912 | google: [{ id: 'gemini-1.5-pro-latest' }],
913 | anthropic: [
914 | { id: 'claude-3-opus-20240229' },
915 | { id: 'claude-3-7-sonnet-20250219' },
916 | { id: 'claude-3-5-sonnet' }
917 | ],
918 | perplexity: [{ id: 'sonar-pro' }],
919 | ollama: [],
920 | openrouter: []
921 | });
922 | }
923 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`);
924 | });
925 | fsExistsSyncSpy.mockReturnValue(true);
926 | // findProjectRoot mock set in beforeEach
927 |
928 | // Act
929 | const logLevel = configManager.getLogLevel(MOCK_PROJECT_ROOT);
930 |
931 | // Assert
932 | expect(logLevel).toBe(VALID_CUSTOM_CONFIG.global.logLevel);
933 | });
934 |
935 | test('getResponseLanguage should return responseLanguage from config', () => {
936 | // Arrange
937 | // Prepare a config object with responseLanguage property for this test
938 | const configWithLanguage = JSON.stringify({
939 | models: {
940 | main: { provider: 'openai', modelId: 'gpt-4-turbo' }
941 | },
942 | global: {
943 | projectName: 'Test Project',
944 | responseLanguage: '中文'
945 | }
946 | });
947 |
948 | // Set up fs.readFileSync to return our test config
949 | fsReadFileSyncSpy.mockImplementation((filePath) => {
950 | if (filePath === MOCK_CONFIG_PATH) {
951 | return configWithLanguage;
952 | }
953 | if (path.basename(filePath) === 'supported-models.json') {
954 | return JSON.stringify({
955 | openai: [{ id: 'gpt-4-turbo' }]
956 | });
957 | }
958 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`);
959 | });
960 |
961 | fsExistsSyncSpy.mockReturnValue(true);
962 |
963 | // Ensure getConfig returns new values instead of cached ones
964 | configManager.getConfig(MOCK_PROJECT_ROOT, true);
965 |
966 | // Act
967 | const responseLanguage =
968 | configManager.getResponseLanguage(MOCK_PROJECT_ROOT);
969 |
970 | // Assert
971 | expect(responseLanguage).toBe('中文');
972 | });
973 |
974 | test('getResponseLanguage should return undefined when responseLanguage is not in config', () => {
975 | // Arrange
976 | const configWithoutLanguage = JSON.stringify({
977 | models: {
978 | main: { provider: 'openai', modelId: 'gpt-4-turbo' }
979 | },
980 | global: {
981 | projectName: 'Test Project'
982 | // No responseLanguage property
983 | }
984 | });
985 |
986 | fsReadFileSyncSpy.mockImplementation((filePath) => {
987 | if (filePath === MOCK_CONFIG_PATH) {
988 | return configWithoutLanguage;
989 | }
990 | if (path.basename(filePath) === 'supported-models.json') {
991 | return JSON.stringify({
992 | openai: [{ id: 'gpt-4-turbo' }]
993 | });
994 | }
995 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`);
996 | });
997 |
998 | fsExistsSyncSpy.mockReturnValue(true);
999 |
1000 | // Ensure getConfig returns new values instead of cached ones
1001 | configManager.getConfig(MOCK_PROJECT_ROOT, true);
1002 |
1003 | // Act
1004 | const responseLanguage =
1005 | configManager.getResponseLanguage(MOCK_PROJECT_ROOT);
1006 |
1007 | // Assert
1008 | expect(responseLanguage).toBe('English');
1009 | });
1010 |
1011 | // Add more tests for other getters (getResearchProvider, getProjectName, etc.)
1012 | });
1013 |
1014 | // --- isConfigFilePresent Tests ---
1015 | describe('isConfigFilePresent', () => {
1016 | test('should return true if config file exists', () => {
1017 | fsExistsSyncSpy.mockReturnValue(true);
1018 | // findProjectRoot mock set in beforeEach
1019 | expect(configManager.isConfigFilePresent(MOCK_PROJECT_ROOT)).toBe(true);
1020 | expect(fsExistsSyncSpy).toHaveBeenCalledWith(MOCK_CONFIG_PATH);
1021 | });
1022 |
1023 | test('should return false if config file does not exist', () => {
1024 | fsExistsSyncSpy.mockReturnValue(false);
1025 | // findProjectRoot mock set in beforeEach
1026 | expect(configManager.isConfigFilePresent(MOCK_PROJECT_ROOT)).toBe(false);
1027 | expect(fsExistsSyncSpy).toHaveBeenCalledWith(MOCK_CONFIG_PATH);
1028 | });
1029 |
1030 | test.skip('should use findProjectRoot if explicitRoot is not provided', () => {
1031 | // TODO: Fix mock interaction, findProjectRoot isn't being registered as called
1032 | fsExistsSyncSpy.mockReturnValue(true);
1033 | // findProjectRoot mock set in beforeEach
1034 | expect(configManager.isConfigFilePresent()).toBe(true);
1035 | expect(mockFindProjectRoot).toHaveBeenCalled(); // Should be called now
1036 | });
1037 | });
1038 |
1039 | // --- getAllProviders Tests ---
1040 | describe('getAllProviders', () => {
1041 | test('should return all providers from ALL_PROVIDERS constant', () => {
1042 | // Arrange: Ensure config is loaded with real data
1043 | configManager.getConfig(null, true); // Force load using the mock that returns real data
1044 |
1045 | // Act
1046 | const providers = configManager.getAllProviders();
1047 |
1048 | // Assert
1049 | // getAllProviders() should return the same as the ALL_PROVIDERS constant
1050 | expect(providers).toEqual(configManager.ALL_PROVIDERS);
1051 | expect(providers.length).toBe(configManager.ALL_PROVIDERS.length);
1052 |
1053 | // Verify it includes both validated and custom providers
1054 | expect(providers).toEqual(
1055 | expect.arrayContaining(configManager.VALIDATED_PROVIDERS)
1056 | );
1057 | expect(providers).toEqual(
1058 | expect.arrayContaining(Object.values(configManager.CUSTOM_PROVIDERS))
1059 | );
1060 | });
1061 | });
1062 |
1063 | // Add tests for getParametersForRole if needed
1064 |
1065 | // --- defaultNumTasks Tests ---
1066 | describe('Configuration Getters', () => {
1067 | test('getDefaultNumTasks should return default value when config is valid', () => {
1068 | // Arrange: Mock fs.readFileSync to return valid config when called with the expected path
1069 | fsReadFileSyncSpy.mockImplementation((filePath) => {
1070 | if (filePath === MOCK_CONFIG_PATH) {
1071 | return JSON.stringify({
1072 | global: {
1073 | defaultNumTasks: 15
1074 | }
1075 | });
1076 | }
1077 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`);
1078 | });
1079 | fsExistsSyncSpy.mockReturnValue(true);
1080 |
1081 | // Force reload to clear cache
1082 | configManager.getConfig(MOCK_PROJECT_ROOT, true);
1083 |
1084 | // Act: Call getDefaultNumTasks with explicit root
1085 | const result = configManager.getDefaultNumTasks(MOCK_PROJECT_ROOT);
1086 |
1087 | // Assert
1088 | expect(result).toBe(15);
1089 | });
1090 |
1091 | test('getDefaultNumTasks should return fallback when config value is invalid', () => {
1092 | // Arrange: Mock fs.readFileSync to return invalid config
1093 | fsReadFileSyncSpy.mockImplementation((filePath) => {
1094 | if (filePath === MOCK_CONFIG_PATH) {
1095 | return JSON.stringify({
1096 | global: {
1097 | defaultNumTasks: 'invalid'
1098 | }
1099 | });
1100 | }
1101 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`);
1102 | });
1103 | fsExistsSyncSpy.mockReturnValue(true);
1104 |
1105 | // Force reload to clear cache
1106 | configManager.getConfig(MOCK_PROJECT_ROOT, true);
1107 |
1108 | // Act: Call getDefaultNumTasks with explicit root
1109 | const result = configManager.getDefaultNumTasks(MOCK_PROJECT_ROOT);
1110 |
1111 | // Assert
1112 | expect(result).toBe(10); // Should fallback to DEFAULTS.global.defaultNumTasks
1113 | });
1114 |
1115 | test('getDefaultNumTasks should return fallback when config value is missing', () => {
1116 | // Arrange: Mock fs.readFileSync to return config without defaultNumTasks
1117 | fsReadFileSyncSpy.mockImplementation((filePath) => {
1118 | if (filePath === MOCK_CONFIG_PATH) {
1119 | return JSON.stringify({
1120 | global: {}
1121 | });
1122 | }
1123 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`);
1124 | });
1125 | fsExistsSyncSpy.mockReturnValue(true);
1126 |
1127 | // Force reload to clear cache
1128 | configManager.getConfig(MOCK_PROJECT_ROOT, true);
1129 |
1130 | // Act: Call getDefaultNumTasks with explicit root
1131 | const result = configManager.getDefaultNumTasks(MOCK_PROJECT_ROOT);
1132 |
1133 | // Assert
1134 | expect(result).toBe(10); // Should fallback to DEFAULTS.global.defaultNumTasks
1135 | });
1136 |
1137 | test('getDefaultNumTasks should handle non-existent config file', () => {
1138 | // Arrange: Mock file not existing
1139 | fsExistsSyncSpy.mockReturnValue(false);
1140 |
1141 | // Force reload to clear cache
1142 | configManager.getConfig(MOCK_PROJECT_ROOT, true);
1143 |
1144 | // Act: Call getDefaultNumTasks with explicit root
1145 | const result = configManager.getDefaultNumTasks(MOCK_PROJECT_ROOT);
1146 |
1147 | // Assert
1148 | expect(result).toBe(10); // Should fallback to DEFAULTS.global.defaultNumTasks
1149 | });
1150 |
1151 | test('getDefaultNumTasks should accept explicit project root', () => {
1152 | // Arrange: Mock fs.readFileSync to return valid config
1153 | fsReadFileSyncSpy.mockImplementation((filePath) => {
1154 | if (filePath === MOCK_CONFIG_PATH) {
1155 | return JSON.stringify({
1156 | global: {
1157 | defaultNumTasks: 20
1158 | }
1159 | });
1160 | }
1161 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`);
1162 | });
1163 | fsExistsSyncSpy.mockReturnValue(true);
1164 |
1165 | // Force reload to clear cache
1166 | configManager.getConfig(MOCK_PROJECT_ROOT, true);
1167 |
1168 | // Act: Call getDefaultNumTasks with explicit project root
1169 | const result = configManager.getDefaultNumTasks(MOCK_PROJECT_ROOT);
1170 |
1171 | // Assert
1172 | expect(result).toBe(20);
1173 | });
1174 | });
1175 |
1176 | // Note: Tests for setMainModel, setResearchModel were removed as the functions were removed in the implementation.
1177 | // If similar setter functions exist, add tests for them following the writeConfig pattern.
1178 |
```