diff --git a/docs/ai/design/feature-lint-command.md b/docs/ai/design/feature-lint-command.md new file mode 100644 index 0000000..cb2d72b --- /dev/null +++ b/docs/ai/design/feature-lint-command.md @@ -0,0 +1,140 @@ +--- +phase: design +title: System Design & Architecture +description: Define the technical architecture, components, and data models +--- + +# System Design & Architecture + +## Architecture Overview +**What is the high-level system structure?** + +```mermaid +graph TD + User --> CLI[ai-devkit lint command] + CLI --> ArgParser[Feature and output argument parser] + ArgParser --> LintService[Lint validation service] + LintService --> BaseCheck[Base docs checker] + LintService --> FeatureCheck[Feature docs checker] + LintService --> GitCheck[Worktree and branch checker] + BaseCheck --> FS[(File System)] + FeatureCheck --> FS + GitCheck --> Git[(Git metadata)] + CLI --> Reporter[Terminal and JSON reporter] +``` + +- Key components and responsibilities + - `lint` command handler: parse options and orchestrate checks. + - Name normalizer: convert `--feature` input into canonical `` and `feature-` (accept `foo` and `feature-foo`). + - Base docs checker: validate required phase template files (`docs/ai/*/README.md`). + - Feature docs checker: validate `docs/ai/{phase}/feature-.md` across lifecycle phases. + - Git/worktree checker: evaluate feature branch/worktree convention used by `dev-lifecycle` Phase 1 prerequisite. + - Reporter (command-owned): consistent output rows (`[OK]`, `[MISS]`, `[WARN]`) and final summary with exit code. +- Technology stack choices and rationale + - TypeScript within existing CLI package for shared UX and testability. + - Extract shell script checks into reusable TS utilities to avoid behavior drift. + - Git checks via lightweight git commands (`git worktree list`, branch existence checks) to preserve compatibility. + +## Data Models +**What data do we need to manage?** + +- Core entities and their relationships + - `LintOptions`: parsed CLI options. + - `LintTarget`: normalized feature identity (`name`, `branchName`, `docSlug`). + - `LintCheckResult`: result of individual check. + - `LintSummary`: aggregate counts and final status. +- Data schemas/structures + - `LintOptions` + - `{ feature?: string, json?: boolean }` + - `LintTarget` + - `{ rawFeature: string, normalizedName: string, branchName: string, docFilePrefix: string }` + - `LintCheckResult` + - `{ id: string, level: 'ok' | 'miss' | 'warn', category: 'base-docs' | 'feature-docs' | 'git-worktree', required: boolean, message: string, fix?: string }` + - `LintSummary` + - `{ checks: LintCheckResult[], hasRequiredFailures: boolean, warningCount: number }` +- Data flow between components + - Parse args -> normalize feature -> run applicable checks -> collect results -> render terminal or JSON output -> return exit code. + +## API Design +**How do components communicate?** + +- External APIs (if applicable) + - CLI invocations: + - `ai-devkit lint` + - `ai-devkit lint --feature ` + - `ai-devkit lint --feature --json` +- Internal interfaces + - `runLintChecks(options, dependencies?): LintReport` + - `renderLintReport(report, options): void` (in `commands/lint`) + - `normalizeFeatureName(input): string` + - `isInsideGitWorkTreeSync(cwd): boolean` + - `localBranchExistsSync(cwd, branchName): boolean` + - `getWorktreePathsForBranchSync(cwd, branchName): string[]` +- Request/response formats + - Input: CLI flags and current working directory. + - Output: + - Default: human-readable checklist and summary. + - `--json`: structured JSON object for CI parsing (checks, counts, normalized feature, pass/fail state). + - Exit code policy: + - `0`: no required failures. + - `1`: one or more required failures. + - Warnings (including missing dedicated worktree) do not change exit code when required checks pass. +- Authentication/authorization approach + - Read-only operations only (filesystem + git metadata queries). + +## Component Breakdown +**What are the major building blocks?** + +- Frontend components (if applicable) + - Terminal output formatter using existing CLI conventions. + - JSON formatter for machine-readable mode. +- Backend services/modules + - `commands/lint` command entry. + - `services/lint/lint.service.ts` for orchestration and business rules only. + - `services/lint/rules/*` for modular validation rules (base docs, feature docs, feature-name, git worktree). + - `util/git` sync helpers for git/worktree checks. + - `util/terminal-ui` for consistent terminal output formatting. +- Database/storage layer + - None. +- Third-party integrations + - Local git executable. + +## Design Decisions +**Why did we choose this approach?** + +- Key architectural decisions and trade-offs + - Re-implement shell checks in TypeScript while keeping output semantics: + - Pros: testable, reusable, cross-command integration. + - Cons: initial duplication until script is retired/repointed. + - Classify checks as required vs warning: + - Pros: keeps lifecycle gating strict for missing docs while allowing advisory worktree guidance. + - Cons: users may ignore warnings if not enforced in team policy. + - Normalize feature names automatically: + - Pros: better UX (`foo` and `feature-foo` both accepted). + - Cons: requires clear messaging of normalized value. + - Include `--json` output in v1: + - Pros: CI-friendly parsing and automated reporting. + - Cons: requires stable output schema maintenance. +- Alternatives considered + - Keep shell script only and wrap it from CLI: + - Rejected due to weaker cross-platform consistency and lower unit-test coverage. + - Hard-fail when dedicated worktree is missing: + - Rejected; requirement is warning-only behavior for this condition. +- Patterns and principles applied + - Single-responsibility check modules. + - Deterministic output for CI and humans. + - Collect-all-results reporting to surface all issues in one run. + - Avoid shell interpolation for git operations by using argument-based command execution. + +## Non-Functional Requirements +**How should the system perform?** + +- Performance targets + - Complete typical checks in under 1 second on standard repositories. +- Scalability considerations + - Check implementation should be extensible for additional lifecycle validations later. +- Security requirements + - No file writes or mutations. + - No network access required. +- Reliability/availability needs + - Command should gracefully handle missing git repo context and provide actionable fixes. diff --git a/docs/ai/implementation/feature-lint-command.md b/docs/ai/implementation/feature-lint-command.md new file mode 100644 index 0000000..2b454d2 --- /dev/null +++ b/docs/ai/implementation/feature-lint-command.md @@ -0,0 +1,95 @@ +--- +phase: implementation +title: Implementation Guide +description: Technical implementation notes, patterns, and code guidelines +--- + +# Implementation Guide + +## Development Setup +**How do we get started?** + +- `npm install` at repository root. +- CLI package local validation commands: + - `npm run lint` (from `packages/cli`) + - `nx run cli:test -- --runInBand lint.test.ts` (from repo root) + +## Code Structure +**How is the code organized?** + +- `packages/cli/src/cli.ts` + - Registers new `lint` command and options (`--feature`, `--json`). +- `packages/cli/src/commands/lint.ts` + - Command entrypoint that runs checks, renders output, and sets process exit code. +- `packages/cli/src/services/lint/lint.service.ts` + - Core lint orchestration and business logic only (no terminal rendering). +- `packages/cli/src/services/lint/rules/` + - Rule modules split by concern (`base-docs`, `feature-name`, `feature-docs`, `git-worktree`). +- `packages/cli/src/__tests__/services/lint/lint.test.ts` + - Unit coverage for normalization and check outcomes. +- `packages/cli/src/__tests__/commands/lint.test.ts` + - Command-level coverage for orchestration and exit-code behavior. + +## Implementation Notes +**Key technical details to remember:** + +### Core Features +- Base readiness checks validate `docs/ai/{requirements,design,planning,implementation,testing}/README.md`. +- Feature mode normalizes `feature-` and `` into a shared `normalizedName`. +- Feature names are validated as kebab-case before running feature-level checks. +- Feature mode validates lifecycle docs: + - `docs/ai/{phase}/feature-.md` +- Git validation behavior: + - Missing git repository => required failure. + - Missing `feature-` branch => required failure. + - Missing dedicated worktree for branch => warning only. +- Command rendering supports: + - human-readable checklist output + - JSON report output with summary and per-check metadata + +### Patterns & Best Practices +- `runLintChecks` accepts injected dependencies (`cwd`, `existsSync`, `execFileSync`) for testability. +- Shared phase-doc rule helper keeps base/feature doc checks consistent while avoiding duplication. +- Check results use a normalized shape (`LintCheckResult`) so rendering and JSON use one source of truth. +- Required failures drive exit code; warnings are advisory only. + +## Integration Points +**How do pieces connect?** + +- CLI wiring: Commander action in `packages/cli/src/cli.ts` calls `lintCommand`. +- `lintCommand` delegates to `runLintChecks` then `renderLintReport`. +- `lint.service` composes rule modules and uses `util/git` sync helpers to query `git rev-parse`, `git show-ref`, and `git worktree list --porcelain`. +- `commands/lint` owns `renderLintReport` and uses `util/terminal-ui` for consistent user-facing output. + +## Error Handling +**How do we handle failures?** + +- Git command failures are converted into deterministic lint results (miss or warn), not thrown errors. +- Missing files are reported with explicit path and remediation guidance. +- Output includes suggested fixes (for example `npx ai-devkit init`, `git worktree add ...`). + +## Performance Considerations +**How do we keep it fast?** + +- Uses direct existence checks and small git commands only. +- No recursive repository scans or network calls. + +## Security Notes +**What security measures are in place?** + +- Read-only filesystem and git metadata checks only. +- No mutation of repository state. +- Git commands use argument-based process execution (`execFileSync`) to avoid shell interpolation risks from user input. + +## Phase 6 Check Implementation + +- Design/requirements alignment: aligned for command surface, normalization, check categories, and exit behavior. +- Deviations and gaps: + - Full CLI binary execution via `npm run dev -- lint ...` is currently blocked by unrelated pre-existing TypeScript errors in `src/commands/memory.ts`. + +## Phase 8 Code Review + +- Blocking issue found and resolved: + - `packages/cli/src/services/lint/lint.service.ts`: replaced shell-command interpolation with argument-based git execution and added feature-name validation. +- Remaining non-blocking gap: + - Full CLI binary execution remains blocked by unrelated pre-existing TypeScript issues outside this feature. diff --git a/docs/ai/planning/feature-lint-command.md b/docs/ai/planning/feature-lint-command.md new file mode 100644 index 0000000..60ac0a5 --- /dev/null +++ b/docs/ai/planning/feature-lint-command.md @@ -0,0 +1,95 @@ +--- +phase: planning +title: Project Planning & Task Breakdown +description: Break down work into actionable tasks and estimate timeline +--- + +# Project Planning & Task Breakdown + +## Milestones +**What are the major checkpoints?** + +- [x] Milestone 1: Lint requirements/design approved +- [x] Milestone 2: `ai-devkit lint` base + feature checks implemented +- [x] Milestone 3: Tests, docs, and rollout complete + +## Task Breakdown +**What specific work needs to be done?** + +### Phase 1: Foundation +- [x] Task 1.1: Audit current CLI command registration and identify insertion point for `lint` +- [x] Task 1.2: Extract/reimplement `check-docs.sh` base and feature doc checks in TypeScript utilities +- [x] Task 1.3: Implement feature-name normalization utility (`foo` and `feature-foo` -> `foo`) +- [x] Task 1.4: Define shared lint result model and formatter (`ok/miss/warn`, remediation hints) + +### Phase 2: Core Features +- [x] Task 2.1: Add `ai-devkit lint` command handler with base workspace checks +- [x] Task 2.2: Add `--feature ` mode with feature doc checks across all lifecycle phases +- [x] Task 2.3: Add git checks for `feature-` branch/worktree presence and mapping +- [x] Task 2.4: Ensure proper exit codes and summary output for CI compatibility + +### Phase 3: Integration & Polish +- [x] Task 3.1: Update help text and README command documentation +- [x] Task 3.2: Decide whether to keep `skills/dev-lifecycle/scripts/check-docs.sh` as wrapper or migrate references to `ai-devkit lint` +- [x] Task 3.3: Add actionable remediation guidance in failures (`npx ai-devkit init`, worktree creation command) +- [x] Task 3.4: Validate behavior against existing lifecycle docs and feature naming conventions + +### Phase 4: Validation +- [x] Task 4.1: Unit tests for base docs checks and feature docs checks +- [x] Task 4.2: Unit tests for feature normalization and git/worktree validation logic +- [x] Task 4.3: Integration tests for CLI exit codes and terminal output +- [x] Task 4.4: Manual verification on repositories with and without required docs/worktrees + +## Dependencies +**What needs to happen in what order?** + +- Task dependencies and blockers + - Command registration and result model must be in place before integration tests. + - Feature-name normalization should be implemented before feature doc and git checks. + - Git check module should be stable before finalizing remediation messages. +- External dependencies (APIs, services, etc.) + - Local git executable availability for feature-level checks. +- Team/resource dependencies + - Maintainer review for lifecycle workflow compatibility and naming conventions. + +## Timeline & Estimates +**When will things be done?** + +- Estimated effort per task/phase + - Phase 1: 0.5-1 day + - Phase 2: 1-1.5 days + - Phase 3: 0.5 day + - Phase 4: 0.5-1 day +- Target dates for milestones + - Milestone 1: day 1 + - Milestone 2: day 2-3 + - Milestone 3: day 3-4 +- Buffer for unknowns + - +20% for git/worktree edge-case handling and cross-platform output differences + +## Risks & Mitigation +**What could go wrong?** + +- Technical risks + - Git worktree detection may vary by repo state and user flow. + - Divergence between shell script and new TypeScript checks can cause inconsistent behavior. +- Resource risks + - Limited test coverage for unusual git/worktree layouts. +- Dependency risks + - Existing scripts or docs may still assume `check-docs.sh` behavior/output. +- Mitigation strategies + - Add fixture-based tests for multiple git states. + - Keep output mapping close to existing `check-docs.sh` semantics initially. + - Update docs and scripts in same change to avoid workflow drift. + +## Resources Needed +**What do we need to succeed?** + +- Team members and roles + - CLI implementer and reviewer +- Tools and services + - Existing TypeScript unit/integration test tooling +- Infrastructure + - Local git repo fixtures for worktree tests +- Documentation/knowledge + - Dev-lifecycle skill conventions and existing `check-docs.sh` behavior diff --git a/docs/ai/requirements/feature-lint-command.md b/docs/ai/requirements/feature-lint-command.md new file mode 100644 index 0000000..ac48517 --- /dev/null +++ b/docs/ai/requirements/feature-lint-command.md @@ -0,0 +1,101 @@ +--- +phase: requirements +title: Requirements & Problem Understanding +description: Clarify the problem space, gather requirements, and define success criteria +--- + +# Requirements & Problem Understanding + +## Problem Statement + +**What problem are we solving?** + +- `ai-devkit` currently has no single validation command to confirm whether the current directory is ready to run AI DevKit workflows. +- Users can start lifecycle phases in misconfigured directories (missing `docs/ai` templates, missing feature docs, or missing feature worktree), which leads to late failures and confusion. +- Existing validation logic is partially implemented in `skills/dev-lifecycle/scripts/check-docs.sh`, but it is not exposed as a first-class CLI command. +- Who is affected: + - Contributors running `dev-lifecycle` commands manually. + - New users onboarding to AI DevKit workflow conventions. + - Teams needing a deterministic readiness check in local and CI workflows. + +## Goals & Objectives + +**What do we want to achieve?** + +- Primary goals + - Add a new command: `ai-devkit lint`. + - Validate base workspace readiness for AI DevKit workflows (presence of required `docs/ai/*/README.md` files). + - Add feature-scoped mode: `ai-devkit lint --feature `. + - Accept `--feature foo` and `--feature feature-foo`, normalizing both to `foo`. + - In feature mode, verify both: + - Feature docs exist across lifecycle phases. + - Dedicated git worktree/branch conventions are evaluated before execution phase. + - Return clear pass/fail output and non-zero exit code on failures. + - Support optional machine-readable output via `--json`. +- Secondary goals + - Reuse or extract logic from `skills/dev-lifecycle/scripts/check-docs.sh` to avoid duplicated validation rules. + - Make output consistent with existing CLI UX (readable statuses, actionable next steps). +- Non-goals (what's explicitly out of scope) + - Auto-generating missing docs/worktrees as part of `lint`. + - Validating full implementation/test completeness of a feature. + - Enforcing remote repository policies (PR checks, branch protections). + - Verifying skill script presence beyond docs/worktree prerequisites. + +## User Stories & Use Cases + +**How will users interact with the solution?** + +- As a contributor, I want to run `ai-devkit lint` before starting lifecycle work so that I can catch setup problems early. +- As a contributor working on a feature branch, I want `ai-devkit lint --feature sample-feature-name` so that I can ensure docs and worktree setup are correct before execution. +- As a CI maintainer, I want reliable exit codes and optional JSON output so that pipeline steps can block invalid workflow state and parse results. +- Key workflows and scenarios + - Run `ai-devkit lint` in project root to validate base `docs/ai` structure. + - Run `ai-devkit lint --feature lint-command` to validate feature docs and `feature-lint-command` worktree status. + - Run lint before `execute-plan` to ensure prerequisites are met. +- Edge cases to consider + - Command executed outside project root. + - Feature name provided with or without `feature-` prefix. + - Branch exists but worktree directory is missing. + - Worktree exists but points to unexpected branch. + - Partial doc presence across phases. + +## Success Criteria + +**How will we know when we're done?** + +- Measurable outcomes + - `ai-devkit lint` completes in under 1 second for normal repositories. + - Validation failures provide explicit missing paths and corrective commands. +- Acceptance criteria + - `ai-devkit lint` checks base structure equivalent to current `check-docs.sh` base check. + - `ai-devkit lint --feature ` checks all required `docs/ai/{phase}/feature-.md` files. + - `ai-devkit lint --feature ` checks dedicated worktree/branch convention (`feature-`). + - Missing dedicated worktree returns a warning, not a hard failure. + - Command exits `0` when all required checks pass and non-zero when required checks fail. + - Output includes recommended remediation (for example `npx ai-devkit init` or worktree creation commands). + - `--json` returns machine-readable structured results for CI tooling. +- Performance benchmarks (if applicable) + - File-system checks and git checks should avoid expensive scans and run in sub-second to low-second range. + +## Constraints & Assumptions + +**What limitations do we need to work within?** + +- Technical constraints + - Must work with existing AI DevKit CLI architecture and command registration. + - Git checks should handle repositories with multiple worktrees. + - Should support macOS/Linux/Windows path behavior for workspace checks. +- Business constraints + - Must preserve existing behavior of lifecycle scripts while adding a reusable lint interface. +- Time/budget constraints + - Prioritize deterministic validations first; defer advanced diagnostics/report formats beyond `--json`. +- Assumptions we're making + - Repository uses the documented phase structure under `docs/ai/`. + - Feature lifecycle convention remains `feature-` for branch and worktree naming. + - Users run the command from within a git repository for feature-level checks. + +## Questions & Open Items + +**What do we still need to clarify?** + +- No blocking open items for Phase 2. diff --git a/docs/ai/testing/feature-lint-command.md b/docs/ai/testing/feature-lint-command.md new file mode 100644 index 0000000..2c28ccf --- /dev/null +++ b/docs/ai/testing/feature-lint-command.md @@ -0,0 +1,76 @@ +--- +phase: testing +title: Testing Strategy +description: Define testing approach, test cases, and quality assurance +--- + +# Testing Strategy + +## Test Coverage Goals +**What level of testing do we aim for?** + +- Unit target: 100% of new/changed lint utilities and feature normalization logic. +- Integration target: CLI command invocation, rendered output categories, and exit code behavior. +- Manual target: run lint flows on real workspace states (healthy and missing prerequisites). + +## Unit Tests +**What individual components need testing?** + +### `packages/cli/src/services/lint/lint.service.ts` + `packages/cli/src/services/lint/rules/*` +- [x] Feature name normalization accepts both `lint-command` and `feature-lint-command`. +- [x] Invalid feature names fail fast as required failures before git checks run. +- [x] Base docs missing case returns required failures and non-zero exit code. +- [x] Branch exists + no dedicated worktree returns pass with warning. +- [x] Missing feature branch returns required failure. +- [x] Non-git directory in feature mode returns required failure. +- [x] Rule-level suites cover base-docs, feature-name, and git-worktree rule behavior directly. + +## Integration Tests +**How do we test component interactions?** + +- [x] Command-level test verifies `lintCommand` calls `runLintChecks` and `renderLintReport` with parsed options. +- [x] Command-level test verifies `process.exitCode` is set from report exit code (`0` and `1` paths). +- [x] Output rendering tests verify human-readable sections and `--json` serialization behavior. + +## End-to-End Tests +**What user flows need validation?** + +- [x] Real workspace state (current repo) via utility execution: feature check passes with required checks satisfied. +- [x] Missing docs scenario (temporary directory): required failures and non-zero exit code. +- [x] Branch exists/no dedicated worktree scenario (temporary git repo): warning-only worktree result with zero exit code. + +## Test Data +**What data do we use for testing?** + +- Dependency-injected stubs for `existsSync` and `execFileSync`. +- Synthetic git command outputs for branch/worktree combinations. + +## Test Reporting & Coverage +**How do we verify and communicate test results?** + +- Executed: + - `nx run cli:test -- --runInBand lint.test.ts` (pass, 5 suites / 16 tests) + - `npm run lint` in `packages/cli` (pass with pre-existing repo warnings unrelated to lint command) +- Manual verification runs: + - Current repo + `feature=lint-command`: pass `true`, exit code `0`, zero required failures. + - Temporary directory with no docs: pass `false`, exit code `1`, required failures `5`. + - Temporary git repo with `feature-sample` branch but no dedicated worktree: pass `true`, exit code `0`, git warning present. +- Current gap: + - Full CLI binary invocation (`npm run dev -- lint ...`) remains blocked by unrelated pre-existing TypeScript issues in `src/commands/memory.ts`. + +## Manual Testing +**What requires human validation?** + +- Validate output readability and remediation commands in terminal. +- Validate warning-only behavior when worktree is missing but branch exists. +- Validate JSON consumers in CI pipelines that parse `--json` output. + +## Performance Testing +**How do we validate performance?** + +- Confirm command remains sub-second for standard repository size during manual verification. + +## Bug Tracking +**How do we manage issues?** + +- Track unrelated pre-existing TypeScript issues in `src/commands/memory.ts` separately from this feature. diff --git a/packages/cli/README.md b/packages/cli/README.md index 8962903..9ba2fe3 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -49,6 +49,15 @@ ai-devkit init --template ./ai-devkit.init.yaml # Add a development phase ai-devkit phase requirements +# Validate workspace docs readiness +ai-devkit lint + +# Validate a feature's docs and git branch/worktree conventions +ai-devkit lint --feature lint-command + +# Emit machine-readable output for CI +ai-devkit lint --feature lint-command --json + # Install a skill ai-devkit skill add diff --git a/packages/cli/src/__tests__/commands/lint.test.ts b/packages/cli/src/__tests__/commands/lint.test.ts new file mode 100644 index 0000000..51cc548 --- /dev/null +++ b/packages/cli/src/__tests__/commands/lint.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, beforeEach, afterEach, jest } from '@jest/globals'; +import { ui } from '../../util/terminal-ui'; +import { lintCommand, renderLintReport } from '../../commands/lint'; +import { LintReport, runLintChecks } from '../../services/lint/lint.service'; + +jest.mock('../../services/lint/lint.service', () => ({ + runLintChecks: jest.fn() +})); + +jest.mock('../../util/terminal-ui', () => ({ + ui: { + text: jest.fn(), + breakline: jest.fn(), + info: jest.fn(), + success: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + spinner: jest.fn(), + table: jest.fn(), + summary: jest.fn() + } +})); + +describe('lint command', () => { + const mockedRunLintChecks = runLintChecks as jest.MockedFunction; + const mockedUi = jest.mocked(ui); + + beforeEach(() => { + jest.clearAllMocks(); + process.exitCode = undefined; + }); + + afterEach(() => { + process.exitCode = undefined; + }); + + it('runs checks, renders report, and sets exit code', async () => { + const report: LintReport = { + cwd: '/repo', + checks: [], + summary: { ok: 1, miss: 0, warn: 0, requiredFailures: 0 }, + pass: true, + exitCode: 0 + }; + mockedRunLintChecks.mockReturnValue(report); + + await lintCommand({ feature: 'lint-command', json: true }); + + expect(mockedRunLintChecks).toHaveBeenCalledWith({ feature: 'lint-command', json: true }); + expect(mockedUi.text).toHaveBeenCalledWith(JSON.stringify(report, null, 2)); + expect(process.exitCode).toBe(0); + }); + + it('propagates failure exit code from lint report', async () => { + const report: LintReport = { + cwd: '/repo', + checks: [], + summary: { ok: 0, miss: 1, warn: 0, requiredFailures: 1 }, + pass: false, + exitCode: 1 + }; + mockedRunLintChecks.mockReturnValue(report); + + await lintCommand({}); + + expect(process.exitCode).toBe(1); + expect(mockedUi.text).toHaveBeenCalledWith('1 required check(s) failed.'); + }); + + it('renders human-readable output by category', () => { + const report: LintReport = { + cwd: '/repo', + feature: { + raw: 'lint-command', + normalizedName: 'lint-command', + branchName: 'feature-lint-command' + }, + checks: [ + { id: 'base', level: 'ok', category: 'base-docs', required: false, message: 'docs/ai/requirements/README.md' }, + { id: 'feature', level: 'miss', category: 'feature-docs', required: true, message: 'docs/ai/design/feature-lint-command.md' }, + { id: 'git', level: 'warn', category: 'git-worktree', required: false, message: 'No dedicated worktree registered' } + ], + summary: { ok: 1, miss: 1, warn: 1, requiredFailures: 1 }, + pass: false, + exitCode: 1 + }; + + renderLintReport(report, {}); + + expect(mockedUi.text).toHaveBeenCalledWith('=== Base Structure ==='); + expect(mockedUi.text).toHaveBeenCalledWith('=== Feature: lint-command ==='); + expect(mockedUi.text).toHaveBeenCalledWith('=== Git: feature-lint-command ==='); + expect(mockedUi.text).toHaveBeenCalledWith('1 warning(s) reported.'); + }); +}); diff --git a/packages/cli/src/__tests__/services/lint/lint.test.ts b/packages/cli/src/__tests__/services/lint/lint.test.ts new file mode 100644 index 0000000..76cb2fd --- /dev/null +++ b/packages/cli/src/__tests__/services/lint/lint.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from '@jest/globals'; +import { normalizeFeatureName, runLintChecks } from '../../../services/lint/lint.service'; + +describe('lint service', () => { + it('normalizes feature names with optional feature- prefix', () => { + expect(normalizeFeatureName('lint-command')).toBe('lint-command'); + expect(normalizeFeatureName('feature-lint-command')).toBe('lint-command'); + }); + + it('fails when base docs are missing', () => { + const report = runLintChecks({}, { + cwd: () => '/repo', + existsSync: () => false + }); + + expect(report.exitCode).toBe(1); + expect(report.summary.requiredFailures).toBe(5); + expect(report.checks.every(check => check.category === 'base-docs')).toBe(true); + }); + + it('passes with warning when branch exists but no dedicated worktree', () => { + const report = runLintChecks( + { feature: 'feature-sample' }, + { + cwd: () => '/repo', + existsSync: () => true, + execFileSync: (_file: string, args: readonly string[]) => { + const cmd = args.join(' '); + if (cmd.startsWith('rev-parse')) { + return 'true\n'; + } + + if (cmd.startsWith('show-ref')) { + return ''; + } + + if (cmd.startsWith('worktree list --porcelain')) { + return 'worktree /repo\nbranch refs/heads/main\n\n'; + } + + return ''; + } + } + ); + + expect(report.exitCode).toBe(0); + expect(report.pass).toBe(true); + expect(report.summary.warn).toBeGreaterThan(0); + expect( + report.checks.some(check => check.category === 'git-worktree' && check.level === 'warn') + ).toBe(true); + }); + + it('fails when feature branch does not exist', () => { + const report = runLintChecks( + { feature: 'sample' }, + { + cwd: () => '/repo', + existsSync: () => true, + execFileSync: (_file: string, args: readonly string[]) => { + const cmd = args.join(' '); + if (cmd.startsWith('rev-parse')) { + return 'true\n'; + } + + if (cmd.startsWith('show-ref')) { + throw new Error('missing branch'); + } + + return ''; + } + } + ); + + expect(report.exitCode).toBe(1); + expect(report.pass).toBe(false); + expect( + report.checks.some(check => check.category === 'git-worktree' && check.level === 'miss') + ).toBe(true); + }); + + it('reports non-git directory as required failure for feature lint', () => { + const report = runLintChecks( + { feature: 'sample' }, + { + cwd: () => '/repo', + existsSync: () => true, + execFileSync: (_file: string, args: readonly string[]) => { + const cmd = args.join(' '); + if (cmd.startsWith('rev-parse')) { + throw new Error('not a git repo'); + } + + return ''; + } + } + ); + + expect(report.exitCode).toBe(1); + expect( + report.checks.some( + check => check.category === 'git-worktree' && check.level === 'miss' && check.required + ) + ).toBe(true); + }); + + it('fails fast for invalid feature names', () => { + const report = runLintChecks({ feature: 'bad name;rm -rf /' }, { + cwd: () => '/repo', + existsSync: () => true + }); + + expect(report.exitCode).toBe(1); + expect(report.checks.some(check => check.id === 'feature-name' && check.level === 'miss')).toBe(true); + }); + +}); diff --git a/packages/cli/src/__tests__/services/lint/rules/base-docs.rule.lint.test.ts b/packages/cli/src/__tests__/services/lint/rules/base-docs.rule.lint.test.ts new file mode 100644 index 0000000..b6433dd --- /dev/null +++ b/packages/cli/src/__tests__/services/lint/rules/base-docs.rule.lint.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from '@jest/globals'; +import { runBaseDocsRules } from '../../../../services/lint/rules/base-docs.rule'; +import { LintDependencies } from '../../../../services/lint/types'; + +describe('base docs rule', () => { + it('returns ok checks when all base docs exist', () => { + const deps: LintDependencies = { + cwd: () => '/repo', + existsSync: () => true, + execFileSync: () => '' + }; + + const checks = runBaseDocsRules('/repo', deps); + + expect(checks).toHaveLength(5); + expect(checks.every(check => check.level === 'ok')).toBe(true); + }); + + it('returns missing checks when base docs do not exist', () => { + const deps: LintDependencies = { + cwd: () => '/repo', + existsSync: () => false, + execFileSync: () => '' + }; + + const checks = runBaseDocsRules('/repo', deps); + + expect(checks).toHaveLength(5); + expect(checks.every(check => check.level === 'miss')).toBe(true); + expect(checks[0].fix).toBe('Run: npx ai-devkit init'); + }); +}); diff --git a/packages/cli/src/__tests__/services/lint/rules/feature-name.rule.lint.test.ts b/packages/cli/src/__tests__/services/lint/rules/feature-name.rule.lint.test.ts new file mode 100644 index 0000000..3cfb90f --- /dev/null +++ b/packages/cli/src/__tests__/services/lint/rules/feature-name.rule.lint.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from '@jest/globals'; +import { normalizeFeatureName, validateFeatureNameRule } from '../../../../services/lint/rules/feature-name.rule'; + +describe('feature name rule', () => { + it('normalizes optional feature- prefix', () => { + expect(normalizeFeatureName('feature-lint-command')).toBe('lint-command'); + expect(normalizeFeatureName('lint-command')).toBe('lint-command'); + }); + + it('returns no validation check for valid names', () => { + const result = validateFeatureNameRule('feature-lint-command'); + + expect(result.check).toBeUndefined(); + expect(result.target.branchName).toBe('feature-lint-command'); + }); + + it('returns missing check for invalid names', () => { + const result = validateFeatureNameRule('lint command'); + + expect(result.check?.id).toBe('feature-name'); + expect(result.check?.level).toBe('miss'); + }); +}); diff --git a/packages/cli/src/__tests__/services/lint/rules/git-worktree.rule.lint.test.ts b/packages/cli/src/__tests__/services/lint/rules/git-worktree.rule.lint.test.ts new file mode 100644 index 0000000..21c85e0 --- /dev/null +++ b/packages/cli/src/__tests__/services/lint/rules/git-worktree.rule.lint.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from '@jest/globals'; +import { runGitWorktreeRules } from '../../../../services/lint/rules/git-worktree.rule'; +import { LintDependencies } from '../../../../services/lint/types'; + +describe('git worktree rule', () => { + it('returns required failure when not in git repo', () => { + const deps: LintDependencies = { + cwd: () => '/repo', + existsSync: () => true, + execFileSync: (_file: string, args: readonly string[]) => { + if (args[0] === 'rev-parse') { + throw new Error('not git'); + } + return ''; + } + }; + + const checks = runGitWorktreeRules('/repo', 'feature-sample', deps); + + expect(checks).toHaveLength(1); + expect(checks[0].id).toBe('git-repo'); + expect(checks[0].required).toBe(true); + }); + + it('returns warning when branch exists and dedicated worktree is missing', () => { + const deps: LintDependencies = { + cwd: () => '/repo', + existsSync: () => true, + execFileSync: (_file: string, args: readonly string[]) => { + const cmd = args.join(' '); + if (cmd.startsWith('rev-parse')) { + return 'true\n'; + } + if (cmd.startsWith('show-ref')) { + return ''; + } + if (cmd.startsWith('worktree list --porcelain')) { + return 'worktree /repo\nbranch refs/heads/main\n\n'; + } + return ''; + } + }; + + const checks = runGitWorktreeRules('/repo', 'feature-sample', deps); + + expect(checks[0].id).toBe('git-branch'); + expect(checks[1].id).toBe('git-worktree'); + expect(checks[1].level).toBe('warn'); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 59b3a91..2e5c5d4 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -4,6 +4,7 @@ import { Command } from 'commander'; import { initCommand } from './commands/init'; import { phaseCommand } from './commands/phase'; import { setupCommand } from './commands/setup'; +import { lintCommand } from './commands/lint'; import { registerMemoryCommand } from './commands/memory'; import { registerSkillCommand } from './commands/skill'; import { registerAgentCommand } from './commands/agent'; @@ -35,6 +36,13 @@ program .option('-g, --global', 'Install commands to global environment folders') .action(setupCommand); +program + .command('lint') + .description('Validate workspace readiness for AI DevKit workflows') + .option('-f, --feature ', 'Validate docs and git worktree conventions for a feature') + .option('--json', 'Output lint results as JSON') + .action(lintCommand); + registerMemoryCommand(program); registerSkillCommand(program); registerAgentCommand(program); diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts new file mode 100644 index 0000000..be38ede --- /dev/null +++ b/packages/cli/src/commands/lint.ts @@ -0,0 +1,59 @@ +import { ui } from '../util/terminal-ui'; +import { LINT_STATUS_LABEL } from '../services/lint/constants'; +import { LintCheckResult, LintOptions, LintReport, runLintChecks } from '../services/lint/lint.service'; + +export async function lintCommand(options: LintOptions): Promise { + const report = runLintChecks(options); + renderLintReport(report, options); + process.exitCode = report.exitCode; +} + +export function renderLintReport(report: LintReport, options: LintOptions = {}): void { + if (options.json) { + ui.text(JSON.stringify(report, null, 2)); + return; + } + + const sections: Array<{ title: string; category: LintCheckResult['category'] }> = [ + { title: '=== Base Structure ===', category: 'base-docs' } + ]; + + if (report.feature) { + sections.push( + { title: `=== Feature: ${report.feature.normalizedName} ===`, category: 'feature-docs' }, + { title: `=== Git: ${report.feature.branchName} ===`, category: 'git-worktree' } + ); + } + + sections.forEach((section, index) => { + if (index > 0) { + ui.text(''); + } + printSection(section.title, section.category, report); + }); + + ui.text(''); + if (report.pass) { + ui.text('All checks passed.'); + } else { + ui.text(`${report.summary.requiredFailures} required check(s) failed.`); + } + + if (report.summary.warn > 0) { + ui.text(`${report.summary.warn} warning(s) reported.`); + } +} + +function printSection(title: string, category: LintCheckResult['category'], report: LintReport): void { + ui.text(title); + printRows(report.checks.filter(check => check.category === category)); +} + +function printRows(checks: LintCheckResult[]): void { + for (const check of checks) { + ui.text(`${LINT_STATUS_LABEL[check.level]} ${check.message}`); + if (check.fix) { + ui.text(` ${check.fix}`); + } + } +} diff --git a/packages/cli/src/services/lint/constants.ts b/packages/cli/src/services/lint/constants.ts new file mode 100644 index 0000000..5e4006c --- /dev/null +++ b/packages/cli/src/services/lint/constants.ts @@ -0,0 +1,15 @@ +export const DOCS_DIR = 'docs/ai'; +export const LIFECYCLE_PHASES = ['requirements', 'design', 'planning', 'implementation', 'testing'] as const; +export const FEATURE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export const LINT_LEVEL = { + OK: 'ok', + MISS: 'miss', + WARN: 'warn' +} as const; + +export const LINT_STATUS_LABEL = { + [LINT_LEVEL.OK]: '[OK] ', + [LINT_LEVEL.MISS]: '[MISS] ', + [LINT_LEVEL.WARN]: '[WARN] ' +} as const; diff --git a/packages/cli/src/services/lint/lint.service.ts b/packages/cli/src/services/lint/lint.service.ts new file mode 100644 index 0000000..c30bb8f --- /dev/null +++ b/packages/cli/src/services/lint/lint.service.ts @@ -0,0 +1,96 @@ +import * as fs from 'fs'; +import { execFileSync } from 'child_process'; +import { LINT_LEVEL } from './constants'; +import { runBaseDocsRules } from './rules/base-docs.rule'; +import { runFeatureDocsRules } from './rules/feature-docs.rule'; +import { validateFeatureNameRule, normalizeFeatureName } from './rules/feature-name.rule'; +import { runGitWorktreeRules } from './rules/git-worktree.rule'; +import { FeatureTarget, LintCheckResult, LintDependencies, LintOptions, LintReport } from './types'; + +const defaultDependencies: LintDependencies = { + cwd: () => process.cwd(), + existsSync: (targetPath: string) => fs.existsSync(targetPath), + execFileSync: (file: string, args: readonly string[], options?: { cwd?: string; stdio?: 'ignore' | 'pipe'; encoding?: BufferEncoding }) => + execFileSync(file, args, options) +}; + +export { normalizeFeatureName }; +export type { LintOptions, LintLevel, LintCheckResult, LintReport, LintDependencies } from './types'; + +export function runLintChecks( + options: LintOptions, + dependencies: Partial = {} +): LintReport { + const deps: LintDependencies = { + ...defaultDependencies, + ...dependencies + }; + + const cwd = deps.cwd(); + const checks: LintCheckResult[] = []; + + checks.push(...runBaseDocsRules(cwd, deps)); + + if (!options.feature) { + return finalizeReport(cwd, checks); + } + + return runFeatureChecks(cwd, checks, options.feature, deps); +} + +function runFeatureChecks( + cwd: string, + checks: LintCheckResult[], + rawFeature: string, + deps: LintDependencies +): LintReport { + const featureValidation = validateFeatureNameRule(rawFeature); + if (featureValidation.check) { + checks.push(featureValidation.check); + return finalizeReport(cwd, checks, featureValidation.target); + } + + checks.push(...runFeatureDocsRules(cwd, featureValidation.target.normalizedName, deps)); + checks.push(...runGitWorktreeRules(cwd, featureValidation.target.branchName, deps)); + + return finalizeReport(cwd, checks, featureValidation.target); +} + +function finalizeReport( + cwd: string, + checks: LintCheckResult[], + feature?: FeatureTarget +): LintReport { + const summary = checks.reduce( + (acc, check) => { + if (check.level === LINT_LEVEL.OK) { + acc.ok += 1; + } + + if (check.level === LINT_LEVEL.MISS) { + acc.miss += 1; + if (check.required) { + acc.requiredFailures += 1; + } + } + + if (check.level === LINT_LEVEL.WARN) { + acc.warn += 1; + } + + return acc; + }, + { ok: 0, miss: 0, warn: 0, requiredFailures: 0 } + ); + + const pass = summary.requiredFailures === 0; + + return { + cwd, + feature, + checks, + summary, + pass, + exitCode: pass ? 0 : 1 + }; +} diff --git a/packages/cli/src/services/lint/rules/base-docs.rule.ts b/packages/cli/src/services/lint/rules/base-docs.rule.ts new file mode 100644 index 0000000..d2268fd --- /dev/null +++ b/packages/cli/src/services/lint/rules/base-docs.rule.ts @@ -0,0 +1,15 @@ +import { DOCS_DIR, LIFECYCLE_PHASES } from '../constants'; +import { LintCheckResult, LintDependencies } from '../types'; +import { runPhaseDocRules } from './phase-docs.rule'; + +export function runBaseDocsRules(cwd: string, deps: LintDependencies): LintCheckResult[] { + return runPhaseDocRules({ + cwd, + phases: LIFECYCLE_PHASES, + idPrefix: 'base', + category: 'base-docs', + filePathForPhase: (phase: string) => `${DOCS_DIR}/${phase}/README.md`, + missingFix: 'Run: npx ai-devkit init', + deps + }); +} diff --git a/packages/cli/src/services/lint/rules/check-factories.ts b/packages/cli/src/services/lint/rules/check-factories.ts new file mode 100644 index 0000000..a721cdc --- /dev/null +++ b/packages/cli/src/services/lint/rules/check-factories.ts @@ -0,0 +1,48 @@ +import { LintCheckResult } from '../types'; +import { LINT_LEVEL } from '../constants'; + +export function createOkCheck( + id: string, + category: LintCheckResult['category'], + message: string +): LintCheckResult { + return { + id, + level: LINT_LEVEL.OK, + category, + required: false, + message + }; +} + +export function createMissingCheck( + id: string, + category: LintCheckResult['category'], + message: string, + fix?: string +): LintCheckResult { + return { + id, + level: LINT_LEVEL.MISS, + category, + required: true, + message, + fix + }; +} + +export function createWarnCheck( + id: string, + category: LintCheckResult['category'], + message: string, + fix?: string +): LintCheckResult { + return { + id, + level: LINT_LEVEL.WARN, + category, + required: false, + message, + fix + }; +} diff --git a/packages/cli/src/services/lint/rules/feature-docs.rule.ts b/packages/cli/src/services/lint/rules/feature-docs.rule.ts new file mode 100644 index 0000000..01aead6 --- /dev/null +++ b/packages/cli/src/services/lint/rules/feature-docs.rule.ts @@ -0,0 +1,18 @@ +import { DOCS_DIR, LIFECYCLE_PHASES } from '../constants'; +import { LintCheckResult, LintDependencies } from '../types'; +import { runPhaseDocRules } from './phase-docs.rule'; + +export function runFeatureDocsRules( + cwd: string, + normalizedName: string, + deps: LintDependencies +): LintCheckResult[] { + return runPhaseDocRules({ + cwd, + phases: LIFECYCLE_PHASES, + idPrefix: 'feature-doc', + category: 'feature-docs', + filePathForPhase: (phase: string) => `${DOCS_DIR}/${phase}/feature-${normalizedName}.md`, + deps + }); +} diff --git a/packages/cli/src/services/lint/rules/feature-name.rule.ts b/packages/cli/src/services/lint/rules/feature-name.rule.ts new file mode 100644 index 0000000..829beac --- /dev/null +++ b/packages/cli/src/services/lint/rules/feature-name.rule.ts @@ -0,0 +1,34 @@ +import { FEATURE_NAME_PATTERN } from '../constants'; +import { FeatureTarget, LintCheckResult } from '../types'; +import { createMissingCheck } from './check-factories'; + +export function normalizeFeatureName(input: string): string { + const trimmed = input.trim(); + return trimmed.startsWith('feature-') ? trimmed.slice('feature-'.length) : trimmed; +} + +export function validateFeatureNameRule(rawFeature: string): { + target: FeatureTarget; + check?: LintCheckResult; +} { + const normalizedName = normalizeFeatureName(rawFeature); + const target: FeatureTarget = { + raw: rawFeature, + normalizedName, + branchName: `feature-${normalizedName}` + }; + + if (FEATURE_NAME_PATTERN.test(normalizedName)) { + return { target }; + } + + return { + target, + check: createMissingCheck( + 'feature-name', + 'feature-docs', + `Invalid feature name: ${rawFeature}`, + 'Use kebab-case and optionally prefix with feature- (example: lint-command or feature-lint-command).' + ) + }; +} diff --git a/packages/cli/src/services/lint/rules/git-worktree.rule.ts b/packages/cli/src/services/lint/rules/git-worktree.rule.ts new file mode 100644 index 0000000..a62cbe4 --- /dev/null +++ b/packages/cli/src/services/lint/rules/git-worktree.rule.ts @@ -0,0 +1,61 @@ +import { + getWorktreePathsForBranchSync, + isInsideGitWorkTreeSync, + localBranchExistsSync +} from '../../../util/git'; +import { LintCheckResult, LintDependencies } from '../types'; +import { createMissingCheck, createOkCheck, createWarnCheck } from './check-factories'; + +export function runGitWorktreeRules( + cwd: string, + branchName: string, + deps: LintDependencies +): LintCheckResult[] { + const checks: LintCheckResult[] = []; + + if (!isInsideGitWorkTreeSync(cwd, deps.execFileSync)) { + checks.push( + createMissingCheck( + 'git-repo', + 'git-worktree', + 'Current directory is not inside a git repository', + 'Run lint --feature from the repository root or a repo worktree.' + ) + ); + return checks; + } + + const branchExists = localBranchExistsSync(cwd, branchName, deps.execFileSync); + if (!branchExists) { + checks.push( + createMissingCheck( + 'git-branch', + 'git-worktree', + `Branch ${branchName} does not exist`, + `Run: git worktree add -b ${branchName} ../${branchName}` + ) + ); + return checks; + } + + checks.push(createOkCheck('git-branch', 'git-worktree', `Branch ${branchName} exists`)); + + const worktreePaths = getWorktreePathsForBranchSync(cwd, branchName, deps.execFileSync); + if (worktreePaths.length === 0) { + checks.push( + createWarnCheck( + 'git-worktree', + 'git-worktree', + `No dedicated worktree registered for ${branchName}`, + `Suggested: git worktree add ../${branchName} ${branchName}` + ) + ); + return checks; + } + + checks.push( + createOkCheck('git-worktree', 'git-worktree', `Worktree detected for ${branchName}: ${worktreePaths.join(', ')}`) + ); + + return checks; +} diff --git a/packages/cli/src/services/lint/rules/phase-docs.rule.ts b/packages/cli/src/services/lint/rules/phase-docs.rule.ts new file mode 100644 index 0000000..3453358 --- /dev/null +++ b/packages/cli/src/services/lint/rules/phase-docs.rule.ts @@ -0,0 +1,33 @@ +import * as path from 'path'; +import { LintCheckResult, LintDependencies } from '../types'; +import { createMissingCheck, createOkCheck } from './check-factories'; + +interface PhaseDocRuleParams { + cwd: string; + phases: readonly string[]; + idPrefix: string; + category: LintCheckResult['category']; + filePathForPhase: (phase: string) => string; + missingFix?: string; + deps: LintDependencies; +} + +export function runPhaseDocRules(params: PhaseDocRuleParams): LintCheckResult[] { + const checks: LintCheckResult[] = []; + const { cwd, phases, idPrefix, category, filePathForPhase, missingFix, deps } = params; + + for (const phase of phases) { + const relativePath = filePathForPhase(phase); + const absolutePath = path.join(cwd, relativePath); + const id = `${idPrefix}-${phase}`; + + if (deps.existsSync(absolutePath)) { + checks.push(createOkCheck(id, category, relativePath)); + continue; + } + + checks.push(createMissingCheck(id, category, relativePath, missingFix)); + } + + return checks; +} diff --git a/packages/cli/src/services/lint/types.ts b/packages/cli/src/services/lint/types.ts new file mode 100644 index 0000000..f01f3ed --- /dev/null +++ b/packages/cli/src/services/lint/types.ts @@ -0,0 +1,44 @@ +import { GitExecFileSync } from '../../util/git'; +import { LINT_LEVEL } from './constants'; + +export interface LintOptions { + feature?: string; + json?: boolean; +} + +export type LintLevel = (typeof LINT_LEVEL)[keyof typeof LINT_LEVEL]; + +export interface LintCheckResult { + id: string; + level: LintLevel; + category: 'base-docs' | 'feature-docs' | 'git-worktree'; + required: boolean; + message: string; + fix?: string; +} + +export interface LintReport { + cwd: string; + feature?: FeatureTarget; + checks: LintCheckResult[]; + summary: { + ok: number; + miss: number; + warn: number; + requiredFailures: number; + }; + pass: boolean; + exitCode: 0 | 1; +} + +export interface FeatureTarget { + raw: string; + normalizedName: string; + branchName: string; +} + +export interface LintDependencies { + cwd: () => string; + existsSync: (targetPath: string) => boolean; + execFileSync: GitExecFileSync; +} diff --git a/packages/cli/src/util/git.ts b/packages/cli/src/util/git.ts index 57a6328..1867447 100644 --- a/packages/cli/src/util/git.ts +++ b/packages/cli/src/util/git.ts @@ -1,9 +1,20 @@ -import { exec } from 'child_process'; +import { exec, execFileSync } from 'child_process'; import { promisify } from 'util'; import * as fs from 'fs-extra'; import * as path from 'path'; const execAsync = promisify(exec); +export type GitExecFileSync = ( + file: string, + args: readonly string[], + options?: { cwd?: string; stdio?: 'ignore' | 'pipe'; encoding?: BufferEncoding } +) => string | Buffer; + +const defaultExecFileSync: GitExecFileSync = ( + file: string, + args: readonly string[], + options?: { cwd?: string; stdio?: 'ignore' | 'pipe'; encoding?: BufferEncoding } +) => execFileSync(file, args, options); /** * Checks if git is installed and available in PATH @@ -95,4 +106,86 @@ export async function fetchGitHead(gitUrl: string): Promise { } catch (error: any) { throw new Error(`Failed to fetch git HEAD: ${error.message}`); } -} \ No newline at end of file +} + +function normalizeExecResult(result: string | Buffer): string { + return Buffer.isBuffer(result) ? result.toString('utf8').trim() : result.trim(); +} + +export function isInsideGitWorkTreeSync(cwd: string, execFileSyncFn: GitExecFileSync = defaultExecFileSync): boolean { + try { + const result = execFileSyncFn('git', ['rev-parse', '--is-inside-work-tree'], { + cwd, + stdio: 'pipe', + encoding: 'utf8' + }); + + return normalizeExecResult(result) === 'true'; + } catch { + return false; + } +} + +export function localBranchExistsSync( + cwd: string, + branchName: string, + execFileSyncFn: GitExecFileSync = defaultExecFileSync +): boolean { + try { + execFileSyncFn('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`], { + cwd, + stdio: 'ignore' + }); + return true; + } catch { + return false; + } +} + +export function getWorktreePathsForBranchSync( + cwd: string, + branchName: string, + execFileSyncFn: GitExecFileSync = defaultExecFileSync +): string[] { + try { + const raw = execFileSyncFn('git', ['worktree', 'list', '--porcelain'], { + cwd, + stdio: 'pipe', + encoding: 'utf8' + }); + + const output = normalizeExecResult(raw); + const lines = output.split('\n'); + const matches: string[] = []; + + let currentPath = ''; + let currentBranch = ''; + + for (const line of lines) { + if (!line.trim()) { + if (currentBranch === `refs/heads/${branchName}` && currentPath) { + matches.push(currentPath); + } + currentPath = ''; + currentBranch = ''; + continue; + } + + if (line.startsWith('worktree ')) { + currentPath = line.slice('worktree '.length).trim(); + } + + if (line.startsWith('branch ')) { + currentBranch = line.slice('branch '.length).trim(); + } + } + + if (currentBranch === `refs/heads/${branchName}` && currentPath) { + matches.push(currentPath); + } + + return matches; + } catch { + return []; + } +}