├── test ├── fixtures │ ├── ignore-test │ │ ├── kept.ts │ │ ├── readme.md │ │ └── .gitignore │ ├── large-file-project │ │ └── .gitkeep │ ├── test-project │ │ ├── test1.js │ │ └── test2.js │ ├── branch-fixture │ │ └── feature.js │ ├── graph-project │ │ ├── module2.js │ │ ├── index.js │ │ └── module1.js │ ├── sample-project │ │ ├── hello.js │ │ ├── test.ts │ │ ├── src │ │ │ ├── math.ts │ │ │ ├── config.ts │ │ │ ├── utils.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ ├── package.json │ │ └── README.md │ ├── config-save │ │ ├── invalid-jsonc │ │ │ └── ffg.config.jsonc │ │ ├── with-named │ │ │ └── ffg.config.jsonc │ │ └── with-defaults │ │ │ └── ffg.config.jsonc │ └── binary-file-project │ │ └── docs.png ├── debug-flag.test.ts ├── version-flag.test.ts ├── bulk-flag.test.ts ├── graph-flag.test.ts ├── helpers │ ├── runCLI.ts │ ├── fileWaiter.ts │ └── createTempGitRepo.ts ├── github-url.test.ts ├── exclude-flag.test.ts ├── template-flag-add-project-mdc.test.ts ├── max-size-flag.test.ts ├── help-flag.test.ts ├── templates.test.ts ├── formatter.test.ts ├── whitespace-flag.test.ts ├── include-glob-no-file-content.test.ts ├── template-date.test.ts ├── render-template-flag.test.ts ├── name-flag.test.ts ├── commit-flag.test.ts ├── ignore-flag.test.ts ├── repo-clone.test.ts ├── config-flag.test.ts ├── xmlFormatter.test.ts ├── gitignore-next.test.ts ├── branch-flag.test.ts ├── include-file-check.test.ts ├── svg-flag.test.ts ├── extension-flag.test.ts ├── open-flag.test.ts ├── template-file-loading.test.ts ├── digest-content.test.ts ├── include-flag.test.ts ├── token-limit.test.ts ├── clipboard.test.ts ├── duplicate-lines.test.ts ├── dry-run-flag.test.ts ├── verbose-flag.test.ts ├── test-cli-dot.test.ts ├── xml-path-attribute.test.ts └── test-helpers.ts ├── .npmrc ├── templates └── main │ ├── _partials │ ├── _footer.md │ ├── _step_push_branch.md │ ├── _step_verify_current_branch.md │ ├── _step_return_to_main.md │ ├── _step_create_branch.md │ ├── _step_retro.md │ ├── _header.md │ ├── _step_create_pr.md │ ├── _step_add_tests.md │ └── _step_implement_code.md │ ├── explain.md │ ├── fix.md │ ├── optimize.md │ ├── document.md │ ├── test.md │ ├── plan.md │ ├── commit.md │ ├── refactor.md │ ├── branch.md │ ├── pr.md │ ├── worktree.md │ ├── project.md │ └── README.md ├── branch-protection.json ├── tsconfig.json ├── src ├── version.ts ├── config.ts ├── tokenCounter.ts ├── editor.ts ├── constants.ts ├── types.ts ├── repo.ts ├── utils.ts ├── formatter.ts ├── gitUtils.ts ├── outputFormatter.ts ├── graph.ts └── fileUtils.ts ├── vitest.config.ts ├── performance-results.txt ├── .cursor └── rules │ ├── postmortem.mdc │ └── project.mdc ├── .husky ├── pre-commit └── pre-push ├── utils ├── runCLI.ts └── directTestRunner.ts ├── .eslintrc.json ├── eslint.config.js ├── .github └── workflows │ ├── pr.yml │ └── publish.yml ├── .gitignore ├── scripts └── test-templates.js └── CHANGELOG.md /test/fixtures/ignore-test/kept.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | use-node-version=23.6.1 2 | -------------------------------------------------------------------------------- /test/fixtures/ignore-test/readme.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/large-file-project/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/test-project/test1.js: -------------------------------------------------------------------------------- 1 | console.log('test1'); -------------------------------------------------------------------------------- /test/fixtures/test-project/test2.js: -------------------------------------------------------------------------------- 1 | console.log('test2'); -------------------------------------------------------------------------------- /test/fixtures/branch-fixture/feature.js: -------------------------------------------------------------------------------- 1 | console.log('feature') -------------------------------------------------------------------------------- /test/fixtures/graph-project/module2.js: -------------------------------------------------------------------------------- 1 | export const bar = 42; -------------------------------------------------------------------------------- /test/fixtures/sample-project/hello.js: -------------------------------------------------------------------------------- 1 | console.log('hello') 2 | -------------------------------------------------------------------------------- /test/fixtures/sample-project/test.ts: -------------------------------------------------------------------------------- 1 | console.log('test') 2 | -------------------------------------------------------------------------------- /test/fixtures/config-save/invalid-jsonc/ffg.config.jsonc: -------------------------------------------------------------------------------- 1 | { "key": value } 2 | -------------------------------------------------------------------------------- /test/fixtures/ignore-test/.gitignore: -------------------------------------------------------------------------------- 1 | # .gitignore in ignore-test fixture 2 | *.js 3 | -------------------------------------------------------------------------------- /test/fixtures/graph-project/index.js: -------------------------------------------------------------------------------- 1 | import { foo } from "./module1.js"; 2 | console.log(foo); -------------------------------------------------------------------------------- /test/fixtures/sample-project/src/math.ts: -------------------------------------------------------------------------------- 1 | export const add = (a: number, b: number) => a + b 2 | -------------------------------------------------------------------------------- /test/fixtures/graph-project/module1.js: -------------------------------------------------------------------------------- 1 | import { bar } from "./module2.js"; 2 | export const foo = bar + 1; -------------------------------------------------------------------------------- /templates/main/_partials/_footer.md: -------------------------------------------------------------------------------- 1 | **Conclusion:** 2 | Plan "{{TASK_DESCRIPTION}}" completed via iterative, verified steps with commits. 3 | -------------------------------------------------------------------------------- /test/fixtures/config-save/with-named/ffg.config.jsonc: -------------------------------------------------------------------------------- 1 | { "commands": { "docs-only": { "markdown": true, "exclude": ["*.ts", "*.js"] } } } 2 | -------------------------------------------------------------------------------- /test/fixtures/binary-file-project/docs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnlindquist/file-forge/HEAD/test/fixtures/binary-file-project/docs.png -------------------------------------------------------------------------------- /test/fixtures/config-save/with-defaults/ffg.config.jsonc: -------------------------------------------------------------------------------- 1 | { "defaultCommand": { "verbose": true, "include": ["src"], "exclude": ["**/*.test.ts"] } } 2 | -------------------------------------------------------------------------------- /test/fixtures/sample-project/src/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration settings for the sample project 3 | */ 4 | export const config = { 5 | defaultName: 'World', 6 | version: '1.0.0', 7 | description: 'A simple TypeScript sample project' 8 | }; -------------------------------------------------------------------------------- /test/fixtures/sample-project/src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility functions for the sample project 3 | */ 4 | 5 | /** 6 | * Creates a greeting message for the given name 7 | */ 8 | export function greet(name: string): string { 9 | return `Hello, ${name}!`; 10 | } -------------------------------------------------------------------------------- /templates/main/_partials/_step_push_branch.md: -------------------------------------------------------------------------------- 1 | **Step 3: Push Branch** 2 | 3 | - **Goal:** Share changes to remote repo for PR. 4 | - **Action:** Push branch to remote. 5 | ```bash 6 | git push -u origin {{BRANCH_NAME}} 7 | ``` 8 | - **Verify:** Check output confirms successful push to remote. 9 | 10 | --- 11 | -------------------------------------------------------------------------------- /branch-protection.json: -------------------------------------------------------------------------------- 1 | { 2 | "required_status_checks": { 3 | "strict": true, 4 | "contexts": ["test"] 5 | }, 6 | "enforce_admins": true, 7 | "required_pull_request_reviews": { 8 | "required_approving_review_count": 0 9 | }, 10 | "restrictions": null, 11 | "allow_force_pushes": false, 12 | "allow_deletions": false 13 | } -------------------------------------------------------------------------------- /templates/main/_partials/_step_verify_current_branch.md: -------------------------------------------------------------------------------- 1 | **Step 0: Verify Current Branch** 2 | 3 | - **Goal:** Confirm correct branch for "{{TASK_DESCRIPTION}}". 4 | - **Action:** Check current branch. 5 | ```bash 6 | git branch --show-current 7 | ``` 8 | - **Note:** If wrong branch, switch (`git checkout name`) or create (`git checkout -b name`). 9 | 10 | --- 11 | -------------------------------------------------------------------------------- /templates/main/explain.md: -------------------------------------------------------------------------------- 1 | 2 | - Provide a clear explanation of what the code provided in the `` tag does & how (plain language, concise). 3 | - **Do not** modify code or output code. 4 | - Explain based *only* on the provided code in the `` tag. 5 | 6 | 7 | **(Generated: {{ GENERATION_DATE }})** 8 | 9 | 10 | Explain this code: 11 | 12 | -------------------------------------------------------------------------------- /test/fixtures/sample-project/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "CommonJS", 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true 11 | }, 12 | "include": ["src/**/*"], 13 | "exclude": ["node_modules", "dist"] 14 | } 15 | -------------------------------------------------------------------------------- /templates/main/_partials/_step_return_to_main.md: -------------------------------------------------------------------------------- 1 | **Step 5: Return to Main** 2 | 3 | - **Goal:** Reset local env. 4 | - **Action:** Switch to main branch. 5 | ```bash 6 | git checkout main 7 | ``` 8 | - **Verify:** Check current branch. 9 | ```bash 10 | git branch --show-current 11 | # Expect: main 12 | ``` 13 | - **Optional:** Pull latest main. 14 | ```bash 15 | git pull 16 | ``` 17 | 18 | --- 19 | -------------------------------------------------------------------------------- /templates/main/fix.md: -------------------------------------------------------------------------------- 1 | 2 | - Identify bug(s) and issues based on user issue described in the `` tag. 3 | - Explain fix briefly in code comments. 4 | - Return corrected code, preserving structure. 5 | - Fix based *only* on the code and issue description provided in the `` tag. 6 | 7 | 8 | **(Generated: {{ GENERATION_DATE }})** 9 | 10 | 11 | Fix the bug in this code: 12 | 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": [ 4 | "@tsconfig/node23/tsconfig.json", 5 | "@tsconfig/strictest/tsconfig.json" 6 | ], 7 | "compilerOptions": { 8 | "outDir": "dist", 9 | "module": "NodeNext", 10 | "moduleResolution": "NodeNext", 11 | "sourceMap": true 12 | }, 13 | "include": ["src/**/*.ts"], 14 | "exclude": ["node_modules", "dist"] 15 | } 16 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | 3 | export function getVersion(): string { 4 | try { 5 | // Use a URL relative to this module to reliably locate package.json 6 | const pkgPath = new URL("../package.json", import.meta.url); 7 | const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); 8 | return pkg.version || "0.0.0-development"; 9 | } catch { 10 | return "0.0.0-development"; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/fixtures/sample-project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sample-project", 3 | "version": "1.0.0", 4 | "description": "A simple TypeScript sample project", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "start": "node dist/index.js", 9 | "test": "vitest" 10 | }, 11 | "dependencies": {}, 12 | "devDependencies": { 13 | "typescript": "^5.0.0", 14 | "vitest": "^1.0.0" 15 | } 16 | } -------------------------------------------------------------------------------- /test/fixtures/sample-project/src/index.ts: -------------------------------------------------------------------------------- 1 | // Main entry point for the sample project 2 | import { greet } from './utils'; 3 | import { config } from './config'; 4 | 5 | /** 6 | * Main function that runs the application 7 | */ 8 | export function main() { 9 | const message = greet(config.defaultName); 10 | console.log(message); 11 | } 12 | 13 | // Run the app if this is the main module 14 | if (require.main === module) { 15 | main(); 16 | } -------------------------------------------------------------------------------- /templates/main/optimize.md: -------------------------------------------------------------------------------- 1 | 2 | - Analyze the code provided in the `` tag for performance bottlenecks and performance improvements (CPU, memory, I/O). 3 | - Suggest specific optimizations & explain why. 4 | - Return optimized code w/ comments if modified. 5 | - Analyze based *only* on the provided code in the `` tag. 6 | 7 | 8 | **(Generated: {{ GENERATION_DATE }})** 9 | 10 | 11 | Optimize this code: 12 | 13 | -------------------------------------------------------------------------------- /templates/main/document.md: -------------------------------------------------------------------------------- 1 | 2 | comments 3 | - Add detailed doc comments (JSDoc/TSDoc/PyDoc) and explanatory comments to code snippet provided in the `` tag. 4 | - Add comments for complex/important logic. 5 | - **Do not** modify code logic/structure. 6 | - Document based *only* on the provided code in the `` tag. 7 | 8 | 9 | **(Generated: {{ GENERATION_DATE }})** 10 | 11 | 12 | Add documentation comments: 13 | 14 | -------------------------------------------------------------------------------- /templates/main/_partials/_step_create_branch.md: -------------------------------------------------------------------------------- 1 | **Step 0: Create Branch** 2 | 3 | - **Goal:** Isolate work for "{{TASK_DESCRIPTION}}". 4 | - **Action:** Create & switch to `feature/` or `fix/` branch. 5 | ```bash 6 | # Example: git checkout -b feature/add-profile 7 | git checkout -b {{BRANCH_NAME}} 8 | ``` 9 | - **Verify:** Check current branch. 10 | ```bash 11 | git branch --show-current 12 | # Expect: {{BRANCH_NAME}} 13 | ``` 14 | - **Commit:** None. 15 | 16 | --- 17 | -------------------------------------------------------------------------------- /templates/main/_partials/_step_retro.md: -------------------------------------------------------------------------------- 1 | **Step 5: Retrospective / Learnings** 2 | 3 | - **Goal:** Reflect on the process for "{{TASK_DESCRIPTION}}"; document takeaways. 4 | - **Action:** Write brief retrospective summary covering: Smooth parts? Challenges? Mistakes/Learnings? Surprises? Advice for others? Time estimate vs. actual (optional)? 5 | - **Format:** Concise summary. 6 | - **Sharing:** Add as comment on the created PR. 7 | - **Verify:** Confirm comment added to correct PR. 8 | 9 | --- 10 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: "node", 7 | include: ["**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], 8 | coverage: { 9 | provider: "v8", 10 | reporter: ["text", "json", "html"], 11 | }, 12 | silent: true, 13 | reporters: [ 14 | ["default", { 15 | summary: false 16 | }] 17 | ], 18 | logHeapUsage: false, 19 | testTimeout: 15000, 20 | pool: 'forks', 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /templates/main/test.md: -------------------------------------------------------------------------------- 1 | 2 | - Write comprehensive unit tests (e.g., Jest, Vitest, Pytest) for the code provided in the `` tag. Ensure the unit tests cover all relevant cases. 3 | - Cover edge cases, happy paths, errors. 4 | - Mock external dependencies (API, DB, FS). 5 | - Return complete test suite code. 6 | - Test based *only* on the provided code's apparent functionality in the `` tag. 7 | 8 | 9 | **(Generated: {{ GENERATION_DATE }})** 10 | 11 | 12 | Write unit tests for this code: 13 | 14 | -------------------------------------------------------------------------------- /test/debug-flag.test.ts: -------------------------------------------------------------------------------- 1 | // test/debug-flag.test.ts 2 | import { describe, it, expect } from "vitest"; 3 | import { runCLI } from "./test-helpers"; 4 | 5 | describe("CLI --debug", () => { 6 | it("prints extra debug output to STDOUT or STDERR", async () => { 7 | const { stdout, stderr, exitCode } = await runCLI([ 8 | "--path", 9 | "test/fixtures/sample-project", 10 | "--debug", 11 | "--pipe", 12 | ]); 13 | 14 | expect(exitCode).toBe(0); 15 | // We expect some debug lines 16 | expect(stdout + stderr).toMatch(/^\[DEBUG\]/m); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /performance-results.txt: -------------------------------------------------------------------------------- 1 | === PERFORMANCE COMPARISON === 2 | 3 | Trial 1/3: 4 | Direct execution: 261.62ms 5 | Process execution: 1832.25ms 6 | Speedup factor: 7.00x 7 | 8 | Trial 2/3: 9 | Direct execution: 156.36ms 10 | Process execution: 1175.79ms 11 | Speedup factor: 7.52x 12 | 13 | Trial 3/3: 14 | Direct execution: 104.26ms 15 | Process execution: 1013.83ms 16 | Speedup factor: 9.72x 17 | 18 | === SUMMARY === 19 | Average direct execution time: 174.08ms 20 | Average process execution time: 1340.62ms 21 | Average speedup factor: 7.70x 22 | Percentage improvement: 670.12% -------------------------------------------------------------------------------- /templates/main/plan.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: plan 3 | category: generation 4 | description: Plan on CURRENT branch; MANDATORY commits/step; ends before push/PR. 5 | variables: 6 | - TASK_DESCRIPTION 7 | --- 8 | 9 | Execute the request in the `` tag on the current branch: Implement+commit code & tests with strict verification per ``. Stop before push/PR. 10 | 11 | 12 | {% include '_header.md' %} 13 | {% include '_step_verify_current_branch.md' %} 14 | {% include '_step_implement_code.md' %} 15 | {% include '_step_add_tests.md' %} 16 | {% include '_footer.md' %} 17 | 18 | 19 | 20 | {{TASK_DESCRIPTION}} 21 | 22 | -------------------------------------------------------------------------------- /templates/main/commit.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: commit 3 | category: generation 4 | description: Plan on CURRENT branch; MANDATORY commits/step (code/tests); no PR. 5 | variables: 6 | - TASK_DESCRIPTION 7 | --- 8 | 9 | Execute the request in the `` tag on the current branch: Implement+commit code & tests with strict verification per ``. Stop before push/PR. 10 | 11 | 12 | {% include '_header.md' %} 13 | {% include '_step_verify_current_branch.md' %} 14 | {% include '_step_implement_code.md' %} 15 | {% include '_step_add_tests.md' %} 16 | {% include '_footer.md' %} 17 | 18 | 19 | 20 | {{TASK_DESCRIPTION}} 21 | 22 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import Conf from "conf"; 2 | import { EditorConfig } from "./types.js"; 3 | import { APP_NAME } from "./constants.js"; 4 | 5 | // Default editor configuration 6 | export const DEFAULT_EDITOR_CONFIG: EditorConfig = { 7 | command: "code", 8 | skipEditor: false 9 | }; 10 | 11 | // Single shared configuration instance 12 | export const config = new Conf<{ editor: EditorConfig }>({ 13 | projectName: APP_NAME, 14 | projectSuffix: '', // Prevent -nodejs suffix in config path 15 | defaults: { 16 | editor: DEFAULT_EDITOR_CONFIG 17 | } 18 | }); 19 | 20 | // Helper function to get the editor configuration 21 | export function getConfig(): typeof config { 22 | return config; 23 | } -------------------------------------------------------------------------------- /templates/main/refactor.md: -------------------------------------------------------------------------------- 1 | 2 | - Analyze the code provided in the `` tag for readability/maintainability issues (simplicity, clarity, readability). 3 | - Propose refactoring changes (preserve functionality). Consider organization, naming, best practices. 4 | - State refactor *goal* (e.g., "Extract method", "Improve naming"), potentially specified in the `` tag variable `{{REFACTOR_GOAL}}`. 5 | - **Do not** change external behavior. 6 | - Return refactored code w/ comments if needed. 7 | - Refactor based *only* on the provided code in the `` tag. 8 | 9 | 10 | **(Generated: {{ GENERATION_DATE }})** 11 | 12 | 13 | Refactor this code to improve its {{REFACTOR_GOAL}}: 14 | 15 | -------------------------------------------------------------------------------- /test/version-flag.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { runCLI } from "./test-helpers"; 3 | 4 | describe("CLI --version flag", () => { 5 | it("should output a version string", async () => { 6 | // Run the CLI with --version flag 7 | const { stdout, exitCode } = await runCLI(["--version"]); 8 | expect(exitCode).toBe(0); 9 | 10 | // Ensure that the output contains a valid version string 11 | // Extract just the version number from potentially multi-line output 12 | const versionMatch = stdout.match(/(\d+\.\d+\.\d+(?:-development)?)/); 13 | expect(versionMatch).not.toBeNull(); 14 | expect(versionMatch![1]).toMatch(/^\d+\.\d+\.\d+(?:-development)?$/); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /templates/main/branch.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: branch 3 | category: generation 4 | description: Plan on NEW branch; MANDATORY commits/step (code/tests); no PR. 5 | variables: 6 | - TASK_DESCRIPTION 7 | - BRANCH_NAME 8 | --- 9 | 10 | Execute the request in the `` tag: Create branch `{{BRANCH_NAME}}`, implement+commit code & tests with strict verification per ``, then return to main. 11 | 12 | 13 | {% include '_header.md' %} 14 | {% include '_step_create_branch.md' %} 15 | {% include '_step_implement_code.md' %} 16 | {% include '_step_add_tests.md' %} 17 | {% include '_step_return_to_main.md' %} 18 | {% include '_footer.md' %} 19 | 20 | 21 | 22 | {{TASK_DESCRIPTION}} 23 | 24 | -------------------------------------------------------------------------------- /.cursor/rules/postmortem.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Lessons learned from debugging test failures to avoid future mistakes. 3 | globs: [] 4 | alwaysApply: true 5 | --- 6 | 7 | # Test Failure Debugging 8 | Check test fixtures for missing files if tests fail locally but pass in CI. 9 | Verify assumptions made by tests about fixture contents (e.g., expected files). 10 | 11 | # Specific Test Execution 12 | Add specific pnpm scripts to run individual test files/suites for easier debugging. 13 | Example: `test:ignore-flag`: `pnpm build && vitest run test/ignore-flag.test.ts` 14 | 15 | # `ignore-test` Fixture Issue 16 | `test/cli.test.ts` and `test/ignore-flag.test.ts` failed because `test/fixtures/ignore-test/ignored.js` was missing. 17 | Creating the empty `ignored.js` file fixed the tests. 18 | -------------------------------------------------------------------------------- /test/bulk-flag.test.ts: -------------------------------------------------------------------------------- 1 | // test/bulk-flag.test.ts 2 | import { describe, it, expect } from "vitest"; 3 | import { runCLI } from "./test-helpers"; 4 | 5 | describe("CLI --bulk", () => { 6 | it("appends AI usage instructions to the end of the output", async () => { 7 | const { stdout, exitCode } = await runCLI([ 8 | "--path", 9 | "test/fixtures/sample-project", 10 | "--bulk", 11 | "--pipe", 12 | ]); 13 | 14 | expect(exitCode).toBe(0); 15 | // At the very end of the output we should see the instructions 16 | expect(stdout).toContain( 17 | "When I provide a set of files with paths and content, please return **one single shell script**" 18 | ); 19 | expect(stdout).toContain("Use `#!/usr/bin/env bash` at the start"); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/fixtures/sample-project/README.md: -------------------------------------------------------------------------------- 1 | # Sample Project 2 | 3 | A simple TypeScript project demonstrating basic project structure and configuration. 4 | 5 | ## Features 6 | 7 | - TypeScript configuration 8 | - Basic module structure 9 | - Configuration management 10 | - Unit testing setup 11 | 12 | ## Getting Started 13 | 14 | 1. Install dependencies: 15 | ```bash 16 | npm install 17 | ``` 18 | 19 | 2. Build the project: 20 | ```bash 21 | npm run build 22 | ``` 23 | 24 | 3. Run the project: 25 | ```bash 26 | npm start 27 | ``` 28 | 29 | ## Project Structure 30 | 31 | - `src/index.ts` - Main entry point 32 | - `src/utils.ts` - Utility functions 33 | - `src/config.ts` - Configuration settings 34 | - `package.json` - Project configuration 35 | - `tsconfig.json` - TypeScript configuration -------------------------------------------------------------------------------- /src/tokenCounter.ts: -------------------------------------------------------------------------------- 1 | import { get_encoding } from 'tiktoken'; 2 | 3 | /** 4 | * Counts the number of tokens in a text string using tiktoken 5 | * @param text - The text to count tokens for 6 | * @returns The number of tokens in the text 7 | */ 8 | export function countTokens(text: string): number { 9 | try { 10 | // Get the encoding for the specified model 11 | const encoding = get_encoding('cl100k_base'); // cl100k_base is used by gpt-3.5-turbo and gpt-4 12 | 13 | // Encode the text and get the token count 14 | const tokens = encoding.encode(text); 15 | const tokenCount = tokens.length; 16 | 17 | return tokenCount; 18 | } catch (error) { 19 | console.error(`Error counting tokens: ${error}`); 20 | // Return an estimate if tiktoken fails 21 | return Math.ceil(text.length / 4); // Rough estimate 22 | } 23 | } -------------------------------------------------------------------------------- /templates/main/_partials/_header.md: -------------------------------------------------------------------------------- 1 | # Guide: {{TASK_DESCRIPTION}} ({{ GENERATION_DATE }}) 2 | 3 | **Summary:** Mandatory steps for "{{TASK_DESCRIPTION}}". Follow precisely. Iterate quickly, verify constantly. 4 | 5 | **Principles:** 6 | 7 | - **Strict Order:** Follow steps exactly. 8 | - **Attempt Limit (Code/Test Steps):** Max 3 attempts. Announce attempt #. If verify fails on Attempt 3, STOP & report failure. Don't proceed. 9 | - **Mandatory Verify:** Verify _as instructed_ at each stage. DON'T proceed if verification fails (respect 3 attempts). Success = progress. 10 | - **Commit Discipline:** Commit _only_ when instructed, post-success, using specified format. 11 | - **Complete All:** Finish the entire sequence. 12 | 13 | **Goal:** Execute plan efficiently, proving progress via verification. Strict adherence is key. 14 | 15 | ## _(Note: Adapt `pnpm`/`npm`/`yarn` commands to project.)_ 16 | -------------------------------------------------------------------------------- /templates/main/pr.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: pr 3 | category: generation 4 | description: Full plan: new branch, MANDATORY commits/step (code/tests), push, PR. 5 | variables: 6 | - TASK_DESCRIPTION 7 | - BRANCH_NAME 8 | --- 9 | 10 | Execute the request in the `` tag (full workflow): Create branch `{{BRANCH_NAME}}`, implement+commit code & tests (verified), push, create PR, run retro, return main. Follow `` strictly. 11 | 12 | 13 | {% include '_header.md' %} 14 | {% include '_step_create_branch.md' %} 15 | {% include '_step_implement_code.md' %} 16 | {% include '_step_add_tests.md' %} 17 | {% include '_step_push_branch.md' %} 18 | {% include '_step_create_pr.md' %} 19 | {% include '_step_retro.md' %} 20 | {% include '_step_return_to_main.md' %} 21 | {% include '_footer.md' %} 22 | 23 | 24 | 25 | {{TASK_DESCRIPTION}} 26 | 27 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | # Check if HEAD is detached (likely during rebase or cherry-pick) 5 | if ! git symbolic-ref -q HEAD > /dev/null; then 6 | echo "Skipping pre-commit hooks on detached HEAD." 7 | exit 0 8 | fi 9 | 10 | # Block direct commits to main 11 | current_branch=$(git symbolic-ref --short HEAD) 12 | if [ "$current_branch" = "main" ]; then 13 | echo "🚫 Direct commits to the main branch are not allowed." 14 | echo "Please create a feature/fix branch and submit a Pull Request." 15 | exit 1 16 | fi 17 | 18 | echo "🔍 Running pre-commit checks on staged files..." 19 | 20 | # Run lint-staged 21 | npx lint-staged 22 | 23 | # Check the exit code of lint-staged 24 | if [ $? -ne 0 ]; then 25 | echo "❌ Pre-commit checks failed. Please fix the issues and try committing again." 26 | exit 1 27 | fi 28 | 29 | echo "✅ Pre-commit checks passed." 30 | exit 0 31 | -------------------------------------------------------------------------------- /utils/runCLI.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "node:child_process"; 2 | import { resolve } from "node:path"; 3 | 4 | interface ExecError extends Error { 5 | stdout?: Buffer; 6 | stderr?: Buffer; 7 | status?: number; 8 | } 9 | 10 | export async function runCLI( 11 | args: string[], 12 | ): Promise<{ stdout: string; stderr: string; exitCode: number }> { 13 | try { 14 | const cliPath = resolve(__dirname, "../dist/index.js"); 15 | const command = `pnpm node ${cliPath} ${args.join(" ")}`; 16 | 17 | const result = execSync(command, { 18 | encoding: "utf8", 19 | stdio: ["pipe", "pipe", "pipe"], 20 | }); 21 | 22 | return { 23 | stdout: result.toString(), 24 | stderr: "", 25 | exitCode: 0, 26 | }; 27 | } catch (error: unknown) { 28 | const err = error as ExecError; 29 | return { 30 | stdout: err.stdout?.toString() || "", 31 | stderr: err.stderr?.toString() || "", 32 | exitCode: err.status || 1, 33 | }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint", "unused-imports"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 9 | "plugin:@typescript-eslint/strict" 10 | ], 11 | "parserOptions": { 12 | "project": "./tsconfig.json" 13 | }, 14 | "rules": { 15 | "@typescript-eslint/no-non-null-assertion": "error", 16 | "@typescript-eslint/prefer-nullish-coalescing": "error", 17 | "@typescript-eslint/no-unnecessary-condition": "error", 18 | "@typescript-eslint/strict-boolean-expressions": "error", 19 | "prefer-template": "error", 20 | "unused-imports/no-unused-imports": "error", 21 | "unused-imports/no-unused-vars": [ 22 | "warn", 23 | { "vars": "all", "varsIgnorePattern": "^_", "args": "after-used", "argsIgnorePattern": "^_" } 24 | ] 25 | } 26 | } -------------------------------------------------------------------------------- /templates/main/worktree.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: worktree 3 | category: generation 4 | description: Plan in existing worktree; MANDATORY commits/step, PR creation. 5 | variables: 6 | - TASK_DESCRIPTION 7 | - BRANCH_NAME # Still needed for push/PR 8 | --- 9 | 10 | Execute the request in the `` tag within the existing worktree/branch `{{BRANCH_NAME}}`: Implement+commit code & tests (verified), push, create PR, run retro. Follow `` strictly. 11 | 12 | 13 | {% include '_header.md' %} 14 | 15 | **Important:** Assumes you are _inside_ the worktree for branch `{{BRANCH_NAME}}`. Run commands from worktree dir. 16 | 17 | {% include '_step_verify_current_branch.md' %} 18 | {% include '_step_implement_code.md' %} 19 | {% include '_step_add_tests.md' %} 20 | {% include '_step_push_branch.md' %} 21 | {% include '_step_create_pr.md' %} 22 | {% include '_step_retro.md' %} 23 | {% comment %} No return to main needed here {% endcomment %} 24 | {% include '_footer.md' %} 25 | 26 | 27 | 28 | {{TASK_DESCRIPTION}} 29 | 30 | -------------------------------------------------------------------------------- /test/graph-flag.test.ts: -------------------------------------------------------------------------------- 1 | // test/graph-flag.test.ts 2 | import { describe, it, expect } from "vitest"; 3 | import { runCLI } from "./test-helpers"; 4 | import { join } from "node:path"; 5 | import { APP_HEADER } from "../src/constants.js"; 6 | 7 | const FIXTURES_DIR = join(__dirname, "fixtures", "graph-project"); 8 | const ENTRY_FILE = join(FIXTURES_DIR, "index.js"); 9 | 10 | describe("CLI --graph", () => { 11 | it("builds dependency graph digest starting from the given file", async () => { 12 | const { stdout, exitCode } = await runCLI([ 13 | "--path", 14 | FIXTURES_DIR, 15 | "--graph", 16 | ENTRY_FILE, 17 | "--pipe", 18 | ]); 19 | expect(exitCode).toBe(0); 20 | expect(stdout).toContain(APP_HEADER); 21 | expect(stdout).toContain("Files analyzed: 3"); 22 | expect(stdout).toContain("index.js"); 23 | expect(stdout).toContain("module1.js"); 24 | expect(stdout).toContain("module2.js"); 25 | expect(stdout).toContain("================================"); 26 | expect(stdout).toContain("File:"); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/helpers/runCLI.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "node:child_process"; 2 | import { resolve } from "node:path"; 3 | 4 | interface ExecError extends Error { 5 | stdout?: Buffer; 6 | stderr?: Buffer; 7 | status?: number; 8 | } 9 | 10 | export async function runCLI( 11 | args: string[], 12 | ): Promise<{ stdout: string; stderr: string; exitCode: number }> { 13 | try { 14 | const cliPath = resolve(__dirname, "../../dist/index.js"); 15 | const command = `node ${cliPath} ${args.join(" ")}`; 16 | 17 | const result = execSync(command, { 18 | encoding: "utf8", 19 | stdio: ["pipe", "pipe", "pipe"], 20 | }); 21 | 22 | return { 23 | stdout: result.toString(), 24 | stderr: "", 25 | exitCode: 0, 26 | }; 27 | } catch (error: unknown) { 28 | const err = error as ExecError; 29 | return { 30 | stdout: err.stdout?.toString() || "", 31 | stderr: err.stderr?.toString() || "", 32 | exitCode: err.status || 1, 33 | }; 34 | } 35 | } -------------------------------------------------------------------------------- /templates/main/_partials/_step_create_pr.md: -------------------------------------------------------------------------------- 1 | **Step 4: Create Pull Request** 2 | 3 | - **Goal:** Request review for "{{TASK_DESCRIPTION}}". 4 | - **Action:** Create PR. 5 | 6 | 1. **Prep Desc File:** Use `edit_file` tool for `/tmp/pr_project_timestamp.md`: 7 | 8 | ```markdown 9 | ## Desc 10 | 11 | Implemented {{TASK_DESCRIPTION}}. 12 | 13 | Structured commits: 14 | 15 | - Commit 1: [Summary Step 1] 16 | - Commit 2: [Summary Step 2] 17 | - (Add more) 18 | 19 | ## Changes 20 | 21 | - Main change 1 22 | - Main change 2 23 | 24 | ## Testing 25 | 26 | - How tested (unit, manual). All tests pass. 27 | ``` 28 | 29 | _(Agent: Fill commit summaries from plan.)_ 30 | 31 | 2. **Create PR:** 32 | ```bash 33 | # Option 1: GitHub CLI 34 | gh pr create --title "{{TASK_DESCRIPTION}}" --body-file /tmp/pr_project_timestamp.md --base main 35 | ``` 36 | 3. **Alt: Web UI:** `https://github.com/USERNAME/REPO/pull/new/{{BRANCH_NAME}}` (Paste description). 37 | 38 | - **Verify:** Confirm PR created (URL). Check description accuracy. 39 | 40 | --- 41 | -------------------------------------------------------------------------------- /test/github-url.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { isGitUrl } from "../src/repo.js"; 3 | 4 | describe("isGitUrl", () => { 5 | it("should identify full GitHub URLs", () => { 6 | expect(isGitUrl("https://github.com/microsoft/vscode")).toBe(true); 7 | }); 8 | 9 | it("should identify github.com URLs without protocol", () => { 10 | expect(isGitUrl("github.com/microsoft/vscode")).toBe(true); 11 | }); 12 | 13 | it("should reject anything that doesn't explicitly contain github.com", () => { 14 | expect(isGitUrl("microsoft/vscode")).toBe(false); 15 | expect(isGitUrl("/Users/me/dev/project")).toBe(false); 16 | expect(isGitUrl("./dev/project")).toBe(false); 17 | expect(isGitUrl("dev/project")).toBe(false); 18 | expect(isGitUrl("../project")).toBe(false); 19 | expect(isGitUrl("C:\\Users\\me\\dev\\project")).toBe(false); 20 | expect(isGitUrl("github/microsoft/vscode")).toBe(false); 21 | expect(isGitUrl("github.org/microsoft/vscode")).toBe(false); 22 | }); 23 | 24 | it("should normalize URLs correctly", () => { 25 | expect(isGitUrl("github.com/microsoft/vscode")).toBe(true); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js" 2 | import ts from "@typescript-eslint/eslint-plugin" 3 | import tsParser from "@typescript-eslint/parser" 4 | import globals from "globals" 5 | 6 | export default [ 7 | { 8 | ignores: [ 9 | "dist/**", 10 | "node_modules/**", 11 | "test/**", 12 | "test/fixtures/**", 13 | "test/helpers/**", 14 | ], 15 | }, 16 | js.configs.recommended, 17 | { 18 | files: ["test/fixtures/**/*.js"], 19 | languageOptions: { 20 | globals: { 21 | ...globals.node 22 | } 23 | } 24 | }, 25 | { 26 | files: ["**/*.ts"], 27 | languageOptions: { 28 | parser: tsParser, 29 | parserOptions: { 30 | ecmaVersion: "latest", 31 | sourceType: "module", 32 | }, 33 | globals: { 34 | ...globals.node, 35 | console: true, 36 | process: true, 37 | }, 38 | }, 39 | plugins: { 40 | "@typescript-eslint": ts, 41 | }, 42 | rules: { 43 | ...ts.configs.recommended.rules, 44 | "@typescript-eslint/no-unused-vars": "error", 45 | "no-empty": ["error", { "allowEmptyCatch": true }], 46 | }, 47 | }, 48 | ] 49 | -------------------------------------------------------------------------------- /templates/main/project.md: -------------------------------------------------------------------------------- 1 | 2 | Create "./cursor/rules/project.mdc" file following the style/structure in the `` tag below. The output must be suitable for a project.mdc file. 3 | Include: Brief project desc, key files/purpose, core features, main components/interactions, dev workflows. 4 | Base *only* on the project context provided by the surrounding files/selection relevant to the `` tag content. 5 | 6 | 7 | **(Generated: {{ GENERATION_DATE }})** 8 | 9 | ## 10 | 11 | description: [Short project desc] 12 | globs: [Relevant globs] 13 | alwaysApply: true 14 | 15 | --- 16 | 17 | # Project Name 18 | 19 | ## Key Files 20 | 21 | - `path/to/file1`: Purpose 22 | - `path/to/file2`: Purpose 23 | 24 | ## Core Features 25 | 26 | - Feature 1: Brief desc 27 | - Feature 2: Brief desc 28 | 29 | ## Main Components 30 | 31 | - Component A: Interaction/role 32 | - Component B: Interaction/role 33 | 34 | ## Dev Workflow 35 | 36 | - Key aspects (language, build, test, conventions) 37 | 38 | 39 | 40 | Generate project.mdc content in markdown codefence: 41 | ```markdown 42 | [Generated project.mdc content here] 43 | ``` 44 | 45 | -------------------------------------------------------------------------------- /templates/main/README.md: -------------------------------------------------------------------------------- 1 | # File Forge Templates 2 | 3 | Templates for code gen, docs, refactoring used by File Forge. 4 | 5 | ## Template Structure 6 | 7 | Markdown files w/ YAML frontmatter (`name`, `category`, `description`, `variables`). 8 | 9 | ## Modular Templates 10 | 11 | Newer templates use reusable `_partials`: 12 | 13 | - `_header.md`: Intro/summary 14 | - `_step_create_branch.md`: Create Git branch 15 | - `_step_verify_current_branch.md`: Verify current branch 16 | - `_step_implement_code.md`: Code step (w/ commit) 17 | - `_step_implement_code_no_commit.md`: Code step (no commit) 18 | - `_step_add_tests.md`: Test step (w/ commit) 19 | - `_step_add_tests_no_commit.md`: Test step (no commit) 20 | - `_step_verify_changes.md`: Verify step (no-commit flow) 21 | - `_step_push_branch.md`: Push branch 22 | - `_step_create_pr.md`: Create PR 23 | - `_step_return_to_main.md`: Return to main 24 | - `_footer.md`: Conclusion 25 | 26 | ### Main Templates 27 | 28 | Use partials for workflows: `plan_modular`, `plan-current-branch`, `plan-no-commit`. 29 | 30 | ## Usage 31 | 32 | `ffg --include --template ` 33 | Example: `ffg --include src/f.ts --template plan_modular` 34 | List: `ffg --list-templates` 35 | 36 | ## Creating Custom Templates 37 | 38 | 1. New `.md` file in templates dir. 39 | 2. Add frontmatter. 40 | 3. Use `{% include '_filename.md' %}`. 41 | 4. Run `ffg`. 42 | -------------------------------------------------------------------------------- /test/exclude-flag.test.ts: -------------------------------------------------------------------------------- 1 | // test/exclude-flag.test.ts 2 | import { describe, it, expect } from "vitest"; 3 | import { runCLI } from "./test-helpers"; 4 | 5 | describe("CLI --exclude", () => { 6 | it("excludes patterns that match user-supplied glob", async () => { 7 | // We'll exclude *.md so that readme.md won't show up 8 | const { stdout, exitCode } = await runCLI([ 9 | "--path", 10 | "test/fixtures/sample-project", 11 | "--exclude", 12 | "*.md", 13 | "--pipe", 14 | ]); 15 | 16 | expect(exitCode).toBe(0); 17 | // Output shouldn't have 'readme.md' 18 | expect(stdout).not.toMatch(/readme\.md/); 19 | 20 | // But the .ts file is still present 21 | expect(stdout).toMatch(/test\.ts/); 22 | }); 23 | 24 | it("excludes patterns from multiple --exclude flags", async () => { 25 | // We'll exclude *.md and *.js 26 | const { stdout, exitCode } = await runCLI([ 27 | "--path", 28 | "test/fixtures/sample-project", 29 | "--exclude", 30 | "*.md", 31 | "--exclude", 32 | "*.js", 33 | "--pipe", 34 | ]); 35 | 36 | expect(exitCode).toBe(0); 37 | // Output shouldn't have 'readme.md' or 'hello.js' 38 | expect(stdout).not.toMatch(/readme\.md/); 39 | expect(stdout).not.toMatch(/hello\.js/); 40 | 41 | // But the .ts file is still present 42 | expect(stdout).toMatch(/test\.ts/); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | permissions: 13 | contents: read 14 | pull-requests: write 15 | 16 | jobs: 17 | check_changes: 18 | runs-on: ubuntu-latest 19 | outputs: 20 | should_skip: ${{ steps.filter.outputs.only_templates_md }} 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | - uses: dorny/paths-filter@v2 26 | id: filter 27 | with: 28 | filters: | 29 | only_templates_md: 30 | - templates/**/*.md 31 | - '!**/*.{js,ts,jsx,tsx,json,yml,yaml}' 32 | 33 | test: 34 | needs: check_changes 35 | if: ${{ needs.check_changes.outputs.should_skip != 'true' }} 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | with: 40 | fetch-depth: 0 41 | - uses: actions/setup-node@v4 42 | with: 43 | node-version: "20" 44 | registry-url: "https://registry.npmjs.org" 45 | - uses: pnpm/action-setup@v2 46 | with: 47 | version: 8 48 | - run: pnpm install 49 | - run: pnpm build 50 | - run: pnpm test 51 | - name: Verify CLI 52 | run: | 53 | pnpm link --global 54 | ffg --help || exit 1 -------------------------------------------------------------------------------- /test/template-flag-add-project-mdc.test.ts: -------------------------------------------------------------------------------- 1 | // test/template-flag-add-project-mdc.test.ts 2 | import { test, expect, describe } from "vitest"; 3 | import { runCLI } from "./test-helpers"; 4 | 5 | describe("CLI --template project", () => { 6 | test("should apply project template with correct instructions", async () => { 7 | const { stdout, exitCode } = await runCLI([ 8 | "--path", 9 | "test/fixtures/sample-project", 10 | "--template", 11 | "project", 12 | "--pipe", 13 | "--no-token-count" 14 | ]); 15 | 16 | expect(exitCode).toBe(0); 17 | 18 | // Check that the output contains the updated template instructions 19 | expect(stdout).toContain(""); 20 | // Updated assertion for the primary instruction 21 | expect(stdout).toContain("Create \"./cursor/rules/project.mdc\" file following the style/structure"); 22 | // Keep other checks if they are still relevant based on the new template content 23 | expect(stdout).toContain("Include: Brief project desc, key files/purpose"); 24 | expect(stdout).toContain(""); 25 | 26 | // Check for example section 27 | expect(stdout).toContain(""); 28 | expect(stdout).toContain(""); 29 | 30 | // Check for updated task tags 31 | expect(stdout).toContain(""); 32 | // Updated assertion for the task content 33 | expect(stdout).toContain("Generate project.mdc content in markdown codefence:"); 34 | expect(stdout).toContain(""); 35 | }); 36 | }); -------------------------------------------------------------------------------- /test/max-size-flag.test.ts: -------------------------------------------------------------------------------- 1 | // test/max-size-flag.test.ts 2 | import { describe, it, expect, beforeAll, afterAll } from "vitest"; 3 | import { runCLI } from "./test-helpers"; 4 | import { promises as fs } from "node:fs"; 5 | import { join } from "node:path"; 6 | import { FILE_SIZE_MESSAGE } from "../src/constants.js"; 7 | 8 | describe("CLI --max-size", () => { 9 | const testDir = "test/fixtures/large-file-project"; 10 | const largeFilePath = join(testDir, "large-file.txt"); 11 | 12 | // Setup: Create a large file before all tests 13 | beforeAll(async () => { 14 | // Create a file that's 1MB 15 | const oneKB = "x".repeat(1024); 16 | const oneMB = oneKB.repeat(1024); 17 | await fs.mkdir(testDir, { recursive: true }).catch(() => { }); 18 | await fs.writeFile(largeFilePath, oneMB); 19 | }); 20 | 21 | // Cleanup: Remove the large file after tests 22 | afterAll(async () => { 23 | await fs.unlink(largeFilePath).catch(() => { }); 24 | }); 25 | 26 | it("should handle large files correctly with max-size setting", async () => { 27 | // Run the same command twice to test different aspects 28 | const result = await runCLI([ 29 | "--path", 30 | testDir, 31 | "--max-size", 32 | "500000", // 500KB 33 | "--pipe", 34 | ]); 35 | 36 | expect(result.exitCode).toBe(0); 37 | 38 | // Test 1: The large file's content is replaced with the size message 39 | expect(result.stdout).toContain(FILE_SIZE_MESSAGE(1024 * 1024)); 40 | 41 | // Test 2: Check that the tree structure shows the file size 42 | expect(result.stdout).toContain("large-file.txt [1.00 MB - too large]"); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /test/help-flag.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { runCLI } from "./test-helpers.js"; 3 | 4 | describe("help flag", () => { 5 | it("should display comprehensive help text with both --help and -h flags", async () => { 6 | // Run both flag variations in parallel 7 | const [helpResult, hAliasResult] = await Promise.all([ 8 | // Test 1: Full --help flag 9 | runCLI(["--help"]), 10 | 11 | // Test 2: -h alias 12 | runCLI(["-h"]) 13 | ]); 14 | 15 | // Test 1: Verify full help output 16 | expect(helpResult.stdout).toContain("ffg [options] "); 17 | expect(helpResult.stdout).toContain("Options:"); 18 | expect(helpResult.stdout).toContain("Examples:"); 19 | 20 | // Verify common options are present 21 | expect(helpResult.stdout).toContain("--include"); 22 | expect(helpResult.stdout).toContain("--exclude"); 23 | expect(helpResult.stdout).toContain("--find"); 24 | expect(helpResult.stdout).toContain("--verbose"); 25 | expect(helpResult.stdout).toContain("--no-token-count"); 26 | 27 | // Verify examples are present 28 | expect(helpResult.stdout).toContain("ffg --path /path/to/project"); 29 | expect(helpResult.stdout).toContain("ffg /path/to/project --template"); 30 | 31 | // Test 2: Verify -h alias provides the same output 32 | expect(hAliasResult.stdout).toContain("ffg [options] "); 33 | expect(hAliasResult.stdout).toContain("Options:"); 34 | expect(hAliasResult.stdout).toContain("Examples:"); 35 | 36 | // Make sure both outputs are identical 37 | expect(helpResult.stdout).toEqual(hAliasResult.stdout); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/templates.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll } from 'vitest'; 2 | import { loadAllTemplates, applyTemplate } from '../src/templates'; 3 | 4 | // Add new describe block for applyTemplate tests 5 | describe('applyTemplate', () => { 6 | beforeAll(async () => { 7 | // Ensure templates (including partials) are loaded before tests 8 | await loadAllTemplates(); 9 | // Mock loading user templates if necessary for the test context 10 | // await loadUserTemplates('/path/to/mock/user/templates'); 11 | }); 12 | 13 | it('should ignore tags within the input code when rendering TASK_DESCRIPTION', async () => { 14 | const templateContent = 'Template Task: {{TASK_DESCRIPTION}} Branch: {{BRANCH_NAME}}'; 15 | const inputCodeWithTaskTag = 'File content with This Should Be Ignored tag.'; 16 | const expectedTaskDescription = 'Describe the task via CLI/config'; // Current hardcoded value 17 | const expectedBranchNamePart = 'describe-the-task-via-cliconfig'; // Derived from the hardcoded value 18 | 19 | const result = await applyTemplate(templateContent, inputCodeWithTaskTag); 20 | 21 | // Check that the hardcoded description is used 22 | expect(result).toContain(`${expectedTaskDescription}`); 23 | // Check that the branch name is derived from the hardcoded description 24 | expect(result).toContain(`Branch: feature/${expectedBranchNamePart}`); 25 | // Explicitly check that the ignored text is NOT present 26 | expect(result).not.toContain('This Should Be Ignored'); 27 | }); 28 | 29 | // Add more tests for applyTemplate as needed 30 | }); -------------------------------------------------------------------------------- /test/formatter.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { 3 | formatIntroMessage, 4 | formatErrorMessage, 5 | formatSpinnerMessage, 6 | formatDebugMessage, 7 | } from "../src/formatter"; 8 | 9 | describe("Formatter Module", () => { 10 | describe("formatIntroMessage", () => { 11 | it("should format intro messages with a bold green header", () => { 12 | const message = "Analyzing: /path/to/repo"; 13 | const formatted = formatIntroMessage(message); 14 | expect(formatted).toEqual( 15 | "\x1b[1m\x1b[32m🔍 Analyzing: /path/to/repo\x1b[0m" 16 | ); 17 | }); 18 | }); 19 | 20 | describe("formatErrorMessage", () => { 21 | it("should format error messages with red text and error prefix", () => { 22 | const message = "Failed to clone repository"; 23 | const formatted = formatErrorMessage(message); 24 | expect(formatted).toEqual( 25 | "\x1b[31m❌ Error: Failed to clone repository\x1b[0m" 26 | ); 27 | }); 28 | }); 29 | 30 | describe("formatSpinnerMessage", () => { 31 | it("should format spinner messages with a consistent prefix", () => { 32 | const message = "Building text digest..."; 33 | const formatted = formatSpinnerMessage(message); 34 | expect(formatted).toEqual("⚡ Building text digest..."); 35 | }); 36 | }); 37 | 38 | describe("formatDebugMessage", () => { 39 | it("should format debug messages with a [DEBUG] prefix", () => { 40 | const message = "Starting graph analysis"; 41 | const formatted = formatDebugMessage(message); 42 | expect(formatted).toEqual( 43 | "\x1b[90m[DEBUG] Starting graph analysis\x1b[0m" 44 | ); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/whitespace-flag.test.ts: -------------------------------------------------------------------------------- 1 | // test/whitespace-flag.test.ts 2 | import { describe, it, expect } from "vitest"; 3 | import { runCLI } from "./test-helpers"; 4 | 5 | describe("CLI --whitespace", () => { 6 | it("should control output indentation and spacing", async () => { 7 | // Run tests with and without whitespace flag 8 | const [defaultResult, whitespaceResult] = await Promise.all([ 9 | // Test 1: Default behavior (minimal whitespace) 10 | runCLI([ 11 | "--path", 12 | "test/fixtures/sample-project", 13 | "--pipe" 14 | // No --whitespace flag 15 | ]), 16 | 17 | // Test 2: With whitespace flag enabled 18 | runCLI([ 19 | "--path", 20 | "test/fixtures/sample-project", 21 | "--pipe", 22 | "--whitespace" 23 | ]) 24 | ]); 25 | 26 | // Both should exit successfully 27 | expect(defaultResult.exitCode).toBe(0); 28 | expect(whitespaceResult.exitCode).toBe(0); 29 | 30 | // Check XML indentation differences 31 | // By default, XML should have minimal indentation (no leading spaces before top-level tags) 32 | expect(defaultResult.stdout).toContain(""); // Check presence without leading space 33 | expect(defaultResult.stdout).not.toMatch(/^\s+/m); // Ensure no leading space 34 | 35 | // With whitespace flag, it should have indentation. Check for newline + indent + tag. 36 | // Use toMatch with regex to be robust against potential preceding output 37 | expect(whitespaceResult.stdout).toMatch(/ {2}/); 38 | expect(whitespaceResult.stdout).toMatch(/\n\s{4}/m); // Check for newline + 4 spaces + tag 39 | }); 40 | }); -------------------------------------------------------------------------------- /src/editor.ts: -------------------------------------------------------------------------------- 1 | // src/editor.ts 2 | 3 | import * as p from "@clack/prompts"; 4 | import { EditorConfig } from "./types.js"; 5 | import { formatDebugMessage } from "./formatter.js"; 6 | import { config } from "./config.js"; 7 | 8 | /** Prompt for editor configuration if not already set */ 9 | export async function getEditorConfig(): Promise { 10 | // Log the config path only when this function is actually called 11 | if (process.env['DEBUG']) { 12 | console.log(formatDebugMessage(`Editor config path: ${config.path}`)); 13 | console.log(formatDebugMessage(`Raw config store: ${JSON.stringify(config.store, null, 2)}`)); 14 | } 15 | 16 | console.log(formatDebugMessage(`getEditorConfig() called`)); 17 | console.log(formatDebugMessage(`Config file location: ${config.path}`)); 18 | console.log(formatDebugMessage(`Raw config store: ${JSON.stringify(config.store, null, 2)}`)); 19 | 20 | const saved = config.get("editor"); 21 | console.log(formatDebugMessage(`Editor config from file: ${JSON.stringify(saved, null, 2)}`)); 22 | 23 | if (saved) { 24 | console.log(formatDebugMessage(`Using saved editor configuration: command="${saved.command}", skipEditor=${saved.skipEditor}`)); 25 | return saved; 26 | } 27 | 28 | console.log(formatDebugMessage(`No editor config found, prompting user`)); 29 | const editorCommand = await p.text({ 30 | message: "Enter editor command (e.g. 'code', 'vim', 'nano')", 31 | placeholder: "code", 32 | validate(value: string) { 33 | if (!value) return "Please enter a command"; 34 | return undefined; 35 | }, 36 | }); 37 | if (p.isCancel(editorCommand)) { 38 | p.cancel("Setup cancelled"); 39 | process.exit(1); 40 | } 41 | const econf = { command: editorCommand, skipEditor: false }; 42 | console.log(formatDebugMessage(`Saving new editor config: ${JSON.stringify(econf, null, 2)}`)); 43 | config.set("editor", econf); 44 | return econf; 45 | } 46 | -------------------------------------------------------------------------------- /test/include-glob-no-file-content.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { runCLI } from "./test-helpers"; 3 | 4 | describe("Include flag with wildcard should not output file contents to console", () => { 5 | it("should hide file content in both XML and Markdown formats when not in verbose mode", async () => { 6 | // Run both format tests in parallel 7 | const [xmlResult, markdownResult] = await Promise.all([ 8 | // Test 1: XML format 9 | runCLI([ 10 | "--path", 11 | "test/fixtures/sample-project", 12 | "**/*.ts", 13 | "--pipe", 14 | ]), 15 | 16 | // Test 2: Markdown format 17 | runCLI([ 18 | "--path", 19 | "test/fixtures/sample-project", 20 | "**/*.ts", 21 | "--pipe", 22 | "--markdown", 23 | ]) 24 | ]); 25 | 26 | // Test 1: XML format validation 27 | expect(xmlResult.exitCode).toBe(0); 28 | // The output should contain the summary and directory tree 29 | expect(xmlResult.stdout).toContain(""); 30 | expect(xmlResult.stdout).toContain(""); 31 | // It should NOT include the files content tags 32 | expect(xmlResult.stdout).not.toContain(" { 13 | const start = Date.now(); 14 | let lastSize = -1; 15 | let sameCount = 0; 16 | let intervalMs = initialIntervalMs; 17 | 18 | // Exponential backoff constants 19 | const MAX_INTERVAL = 1000; // Max 1s between checks 20 | const BACKOFF_FACTOR = 1.5; // Increase interval by 50% each time 21 | 22 | // Number of times the file size must remain stable to consider it complete 23 | const STABILITY_THRESHOLD = 3; 24 | 25 | while (Date.now() - start < timeoutMs) { 26 | if (existsSync(path)) { 27 | try { 28 | const stats = readFileSync(path).length; 29 | 30 | if (stats === lastSize) { 31 | sameCount++; 32 | if (sameCount >= STABILITY_THRESHOLD) { 33 | return true; 34 | } 35 | 36 | // Increase the interval as we detect stability 37 | intervalMs = Math.min(intervalMs * BACKOFF_FACTOR, MAX_INTERVAL); 38 | } else { 39 | // Reset if the file is still changing 40 | lastSize = stats; 41 | sameCount = 0; 42 | // Reset interval to be more responsive during active changes 43 | intervalMs = initialIntervalMs; 44 | } 45 | } catch (error) { 46 | // File might exist but not be accessible yet, continue waiting 47 | console.warn(`Warning: File exists but couldn't be read: ${error}`); 48 | } 49 | } 50 | 51 | await new Promise((resolve) => setTimeout(resolve, intervalMs)); 52 | } 53 | 54 | return false; 55 | } 56 | -------------------------------------------------------------------------------- /test/template-date.test.ts: -------------------------------------------------------------------------------- 1 | // test/template-date.test.ts 2 | import { test, expect, describe } from "vitest"; 3 | import { runDirectCLI } from "../utils/directTestRunner.js"; // Correct path relative to test file 4 | 5 | describe("Template Date Injection", () => { 6 | // Regex for the format in _header.md: (YYYY-MM-DD) 7 | const headerDateRegex = /\(\d{4}-\d{2}-\d{2}\)/; 8 | // Regex for the format in explain.md: **(Generated: YYYY-MM-DD)** 9 | const generatedDateRegex = /\*\*\(Generated: \d{4}-\d{2}-\d{2}\)\*\*/; 10 | 11 | test("should inject the current date into plan templates", async () => { 12 | // Run ffg with a plan template (e.g., 'plan') 13 | const { stdout, exitCode } = await runDirectCLI([ 14 | "--path", 15 | "test/fixtures/sample-project", // Provide a path to avoid path errors 16 | "--template", 17 | "plan", // Use one of the plan templates 18 | "--pipe", 19 | "--no-token-count" 20 | ]); 21 | 22 | expect(exitCode).toBe(0); 23 | 24 | // Check for the date string in the output using the updated regex 25 | expect(stdout).toMatch(headerDateRegex); 26 | 27 | // Optionally, verify it's near the header (Adjust if needed) 28 | // expect(stdout).toContain("# Guide:"); // Keep or remove based on desired strictness 29 | }); 30 | 31 | test("should inject the date into non-plan templates (e.g., explain)", async () => { 32 | // Run ffg with a non-plan template (e.g., 'explain') 33 | const { stdout, exitCode } = await runDirectCLI([ 34 | "--path", 35 | "test/fixtures/sample-project/hello.js", // Target a specific file 36 | "--template", 37 | "explain", // Use a non-plan template 38 | "--pipe", 39 | "--no-token-count" 40 | ]); 41 | 42 | expect(exitCode).toBe(0); 43 | 44 | // Check that the date string IS present using the generated date regex 45 | expect(stdout).toMatch(generatedDateRegex); 46 | }); 47 | }); -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | permissions: 13 | contents: write 14 | issues: write 15 | pull-requests: write 16 | 17 | jobs: 18 | check_changes: 19 | runs-on: ubuntu-latest 20 | outputs: 21 | only_templates_md: ${{ steps.filter.outputs.only_templates_md }} 22 | steps: 23 | - uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | - uses: dorny/paths-filter@v2 27 | id: filter 28 | with: 29 | filters: | 30 | only_templates_md: 31 | - templates/**/*.md 32 | - '!**/*.{js,ts,jsx,tsx,json,yml,yaml}' 33 | 34 | release: 35 | name: Release 36 | needs: check_changes 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v4 40 | with: 41 | fetch-depth: 0 42 | persist-credentials: false 43 | - uses: actions/setup-node@v4 44 | with: 45 | node-version: "20" 46 | registry-url: "https://registry.npmjs.org" 47 | - uses: pnpm/action-setup@v2 48 | with: 49 | version: 8 50 | - run: pnpm install 51 | - run: pnpm build 52 | - if: ${{ needs.check_changes.outputs.only_templates_md != 'true' }} 53 | run: pnpm test 54 | - name: Verify CLI 55 | run: | 56 | pnpm link --global 57 | ffg --help || exit 1 58 | - name: Disable pre-push hook 59 | run: chmod -x .husky/pre-push 60 | - name: Release 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 64 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | run: | 66 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 67 | git config --global user.name "github-actions[bot]" 68 | pnpm semantic-release 69 | - name: Re-enable pre-push hook 70 | if: always() 71 | run: chmod +x .husky/pre-push 72 | -------------------------------------------------------------------------------- /test/render-template-flag.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from "vitest"; 2 | import { runCLI } from "./test-helpers"; 3 | import { spawn } from 'node:child_process'; 4 | 5 | // Mock the spawn function to check editor opening 6 | vi.mock('node:child_process', async (importOriginal) => { 7 | const actual = await importOriginal(); 8 | return { 9 | ...actual, 10 | spawn: vi.fn(() => { 11 | // Mock minimal child process functionality needed 12 | const mockProcess = { 13 | unref: vi.fn(), 14 | on: vi.fn(), 15 | stdout: { on: vi.fn() }, 16 | stderr: { on: vi.fn() }, 17 | }; 18 | // Immediately call the close event for testing purposes 19 | setTimeout(() => { 20 | const closeHandler = mockProcess.on.mock.calls.find(call => call[0] === 'close'); 21 | if (closeHandler && closeHandler[1]) { 22 | closeHandler[1](0); // Simulate successful exit 23 | } 24 | }, 0); 25 | return mockProcess as unknown as ReturnType; 26 | }), 27 | execSync: actual.execSync // Keep original execSync for other parts 28 | }; 29 | }); 30 | 31 | describe("CLI --render-template", () => { 32 | beforeEach(() => { 33 | vi.clearAllMocks(); // Clear mocks before each test 34 | }); 35 | 36 | it("should not perform analysis when rendering", async () => { 37 | const { stdout, exitCode } = await runCLI([ 38 | "--render-template", 39 | "worktree", 40 | "--path", // Include path flag to ensure it's ignored 41 | "test/fixtures/sample-project" 42 | ]); 43 | 44 | // Even if rendering doesn't work in test environment, it should still bypass the analysis 45 | expect(exitCode).toBe(0); 46 | 47 | // Should NOT contain analysis output 48 | expect(stdout).not.toContain(""); 49 | expect(stdout).not.toContain(""); 50 | expect(stdout).not.toContain(""); 51 | }, 30000); 52 | }); -------------------------------------------------------------------------------- /test/name-flag.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { runCLI } from "./test-helpers.js"; 3 | 4 | describe("name flag", () => { 5 | it("should validate name flag behavior across different scenarios", async () => { 6 | // Run all three tests in parallel 7 | const [ 8 | customNameResult, 9 | customNamePipeResult, 10 | defaultNameResult 11 | ] = await Promise.all([ 12 | // Test 1: Custom name with XML wrapping 13 | runCLI([ 14 | "--path", 15 | "test/fixtures/sample-project", 16 | "--name", 17 | "EXAMPLE_PROJECT", 18 | "--markdown", 19 | "--no-token-count" 20 | ]), 21 | 22 | // Test 2: Custom name with piping (no XML wrapping) 23 | runCLI([ 24 | "--path", 25 | "test/fixtures/sample-project", 26 | "--name", 27 | "EXAMPLE_PROJECT", 28 | "--pipe", 29 | "--markdown", 30 | "--no-token-count" 31 | ]), 32 | 33 | // Test 3: Default name without flag 34 | runCLI([ 35 | "--path", 36 | "test/fixtures/sample-project", 37 | "--markdown", 38 | "--no-token-count" 39 | ]) 40 | ]); 41 | 42 | // Test 1: Custom name with XML wrapping 43 | expect(customNameResult.exitCode).toBe(0); 44 | expect(customNameResult.stdout).toContain("# EXAMPLE_PROJECT"); 45 | expect(customNameResult.stdout).toContain(""); 46 | expect(customNameResult.stdout).toContain(""); 47 | 48 | // Test 2: Custom name with piping (no XML wrapping) 49 | expect(customNamePipeResult.exitCode).toBe(0); 50 | expect(customNamePipeResult.stdout).toContain("# EXAMPLE_PROJECT"); 51 | expect(customNamePipeResult.stdout).not.toContain(""); 52 | expect(customNamePipeResult.stdout).not.toContain(""); 53 | 54 | // Test 3: Default name without flag 55 | expect(defaultNameResult.exitCode).toBe(0); 56 | expect(defaultNameResult.stdout).toContain("# File Forge Analysis"); 57 | expect(defaultNameResult.stdout).not.toContain(""); 58 | expect(defaultNameResult.stdout).not.toContain(""); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const APP_NAME = "@johnlindquist/file-forge"; 2 | export const APP_COMMAND = "ffg"; 3 | export const APP_DISPLAY_NAME = "File Forge"; 4 | export const APP_DESCRIPTION = 5 | "File Forge is a powerful CLI tool for deep analysis of codebases, generating markdown reports to feed AI reasoning models."; 6 | 7 | // Used for env-paths and other system-level identifiers 8 | export const APP_SYSTEM_ID = APP_NAME; 9 | 10 | // Used for markdown headers and CLI output 11 | export const APP_HEADER = `# ${APP_DISPLAY_NAME}`; 12 | export const APP_ANALYSIS_HEADER = `${APP_DISPLAY_NAME} Analysis`; 13 | 14 | // Property names for consistent usage across files 15 | export const PROP_SUMMARY = "summary"; 16 | export const PROP_TREE = "tree"; 17 | export const PROP_CONTENT = "content"; 18 | 19 | // Status messages 20 | export const BRANCH_STATUS = (branch: string) => `Branch: ${branch}`; 21 | export const COMMIT_STATUS = (commit: string) => `Commit: ${commit}`; 22 | export const CHECKOUT_BRANCH_STATUS = (branch: string) => 23 | `Checked out branch ${branch}`; 24 | export const CHECKOUT_COMMIT_STATUS = (commit: string) => 25 | `Checked out commit ${commit}`; 26 | export const REPO_RESET_COMPLETE = "Repository reset complete"; 27 | export const TEXT_DIGEST_BUILT = "Text digest built"; 28 | 29 | // Used for file naming 30 | export const getAnalysisFilename = (hash: string, timestamp: string) => 31 | `${APP_NAME}-${hash}-${timestamp}.md`; 32 | 33 | export const FILE_SIZE_MESSAGE = (size: number) => 34 | ` [${(size / 1024 / 1024).toFixed(2)} MB - too large]`; 35 | 36 | // Return type for ingest functions 37 | export type DigestResult = { 38 | [PROP_SUMMARY]: string; 39 | [PROP_TREE]: string; 40 | [PROP_CONTENT]: string; 41 | }; 42 | 43 | /** Directories that should always be ignored, regardless of nesting */ 44 | export const PERMANENT_IGNORE_DIRS = [ 45 | "node_modules", 46 | ".git", 47 | "dist", 48 | "build", 49 | "__pycache__", 50 | ".cache", 51 | "coverage", 52 | ".next", 53 | ".nuxt", 54 | "bower_components", 55 | ] as const; 56 | 57 | /** Glob patterns for permanently ignored directories */ 58 | export const PERMANENT_IGNORE_PATTERNS = PERMANENT_IGNORE_DIRS.map(dir => `**/${dir}/**`); 59 | 60 | // Token limit constants 61 | export const APPROX_CHARS_PER_TOKEN = 4; 62 | export const DEFAULT_MAX_TOKEN_ESTIMATE = 200000; // Approx. 200k tokens 63 | -------------------------------------------------------------------------------- /test/commit-flag.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 | import { runDirectCLI } from "../utils/directTestRunner.js"; 3 | import { createTempGitRepo, TempGitRepoResult } from "./helpers/createTempGitRepo.js"; 4 | import { promises as fs } from "node:fs"; 5 | import { resolve } from "node:path"; 6 | 7 | describe.skip("CLI --commit flag", () => { 8 | let tempRepo: TempGitRepoResult; 9 | let firstCommitSha: string; 10 | 11 | beforeEach(async () => { 12 | tempRepo = await createTempGitRepo({ initialBranch: 'main' }); 13 | const { git, repoPath } = tempRepo; 14 | 15 | await fs.writeFile(resolve(repoPath, "main.js"), "console.log('main')"); 16 | await git.add("main.js"); 17 | await git.commit("Initial commit"); 18 | 19 | firstCommitSha = (await git.revparse(['HEAD'])).trim(); 20 | 21 | await fs.rm(resolve(repoPath, "main.js")); 22 | await git.rm("main.js"); 23 | await git.commit("Remove main.js"); 24 | 25 | await git.checkoutLocalBranch("some-feature-branch"); 26 | await fs.writeFile(resolve(repoPath, "feature.js"), "console.log('feature')"); 27 | await git.add("feature.js"); 28 | await git.commit("Feature commit"); 29 | 30 | // Test setup correction: checkout main *before* test runs 31 | // Ensure the test starts from a known branch state if needed, 32 | // although the commit checkout should override this. Let's leave it on feature branch. 33 | // await git.checkout('main'); // <-- Keep commented out 34 | }, 30000); // Timeout for setup 35 | 36 | afterEach(async () => { 37 | if (tempRepo) { 38 | await tempRepo.cleanup(); 39 | } 40 | }); 41 | 42 | it("checks out the specified commit SHA after cloning", async () => { 43 | const { stdout, stderr, exitCode } = await runDirectCLI([ 44 | "--repo", 45 | tempRepo.repoPath, // Use tempRepo.repoPath 46 | "--commit", 47 | firstCommitSha, // Use the stored SHA 48 | "--pipe", 49 | "--verbose", 50 | ]); 51 | 52 | // Ensure the stderr expectation is completely removed 53 | 54 | // Check stdout for final status message and file content 55 | expect(stdout).toContain(`Checked out commit ${firstCommitSha}`); // Check stdout for status 56 | expect(stdout).toContain(""); 57 | expect(stdout).not.toContain(""); 58 | expect(exitCode).toBe(0); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /test/ignore-flag.test.ts: -------------------------------------------------------------------------------- 1 | // test/ignore-flag.test.ts 2 | import { describe, it, expect, beforeAll } from "vitest"; 3 | import { execa } from "execa"; 4 | import { fileURLToPath } from "node:url"; 5 | import { dirname, join } from "node:path"; 6 | import { promises as fs } from 'node:fs'; 7 | import { runCLI } from "./test-helpers"; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = dirname(__filename); 11 | const cliPath = join(__dirname, "../dist/cli.js"); 12 | const fixturesDir = join(__dirname, "fixtures"); 13 | 14 | describe("CLI --ignore", () => { 15 | const ignoreTestDir = join(fixturesDir, "ignore-test"); 16 | 17 | beforeAll(async () => { 18 | const ignoredJsPath = join(ignoreTestDir, "ignored.js"); 19 | try { 20 | // Try to access the file, if it fails (ENOENT), create it. 21 | await fs.access(ignoredJsPath); 22 | } catch (error: any) { 23 | if (error.code === 'ENOENT') { 24 | console.log(`[CI FIX] Creating missing ignored.js for test in ${ignoreTestDir}`); 25 | await fs.writeFile(ignoredJsPath, '// Empty file created by test setup\n'); 26 | } else { 27 | // Rethrow unexpected errors 28 | throw error; 29 | } 30 | } 31 | }); 32 | 33 | it("should test .gitignore behavior with different settings", async () => { 34 | // Run both tests in parallel 35 | const [defaultResult, bypassResult] = await Promise.all([ 36 | // Test 1: Default behavior with --ignore 37 | runCLI([ 38 | "--path", 39 | "test/fixtures/ignore-test", 40 | "--ignore", 41 | "--pipe", 42 | ]), 43 | 44 | // Test 2: Bypass .gitignore with --ignore=false 45 | runCLI([ 46 | "--path", 47 | "test/fixtures/ignore-test", 48 | "--ignore=false", 49 | "--pipe", 50 | ]) 51 | ]); 52 | 53 | // Test 1: Default behavior with --ignore 54 | expect(defaultResult.exitCode).toBe(0); 55 | // We expect 'ignored.js' to not appear when respecting .gitignore 56 | expect(defaultResult.stdout).not.toMatch(/ignored\.js/); 57 | // We do expect 'kept.ts' 58 | expect(defaultResult.stdout).toMatch(/kept\.ts/); 59 | 60 | // Test 2: Bypass .gitignore with --ignore=false 61 | expect(bypassResult.exitCode).toBe(0); 62 | // 'ignored.js' is now included when bypassing .gitignore 63 | expect(bypassResult.stdout).toMatch(/ignored\.js/); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /templates/main/_partials/_step_add_tests.md: -------------------------------------------------------------------------------- 1 | **Step 2: Add/Update Tests** 2 | 3 | - **Goal:** Cover Step 1 changes with tests. 4 | - **Action & Attempt Cycle (Max 3 Attempts):** 5 | 6 | 1. **Attempt 1:** Add/update tests in relevant files (e.g., `test/feature.test.ts`). **Show complete changed test blocks (`describe`/`it`), setup/mocks.** 7 | 8 | ```typescript 9 | // Example: Show minimal BEFORE/AFTER blocks for test file(s) 10 | // === BEFORE in test/file.test.ts === 11 | // it(...) { ... } 12 | 13 | // === AFTER in test/file.test.ts (Attempt 1) === 14 | // import/mock changes... 15 | // describe(...) { it(...) { ... } // Updated/New test 16 | // } 17 | ``` 18 | 19 | 2. **Verify (Mandatory):** Run checks below. 20 | 3. **If Verify Fails:** Analyze errors. Announce **Attempt 2**. 21 | - Fix test logic _and/or_ amend Step 1 code (`git add ; git commit --amend --no-edit`). Show changes. Re-Verify. 22 | 4. **If Verify Fails Again:** Analyze errors. Announce **Attempt 3**. 23 | - Make final fixes (tests/code). Show changes. Re-Verify. 24 | 5. **If Verify Fails on Attempt 3:** **STOP.** Report: "Test verification failed after 3 attempts (Step 2). Plan revision needed." Do not commit. 25 | 26 | - **Context & Impact:** Note any test setup/util files affected. 27 | 28 | - **Files Potentially Impacted:** `[File Path]` - Reason: [Why] 29 | 30 | - **Verification (Mandatory - After EACH attempt. DO NOT COMMIT until passed):** 31 | 32 | 1. **Lint & Type Check:** Fix all issues in changed test/code files. 33 | ```bash 34 | # Adapt command 35 | pnpm lint [] && pnpm build:check 36 | ``` 37 | 2. **Run Full Test Suite:** All tests must pass (new & existing). 38 | - **If fails:** Return to Action Cycle. Analyze output. Fix. 39 | - **Do not proceed if tests fail.** 40 | ```bash 41 | # Adapt command 42 | pnpm test 43 | ``` 44 | 3. **Progress:** If passed, state: "Verification OK for Step 2 on Attempt X." 45 | 46 | - **Commit (Only After Success on Attempt 1, 2, or 3):** 47 | ```bash 48 | # 1. Add test files & any amended code files 49 | git add [] 50 | # 2. Commit 51 | git commit -m "test: Add/update tests for {{TASK_DESCRIPTION}} (Step 2)" -m "Desc: [Specific tests added/updated, mention code fixes]" -m "Verify: Passed lint, types, full test suite on Attempt [1/2/3]." 52 | ``` 53 | 54 | --- 55 | -------------------------------------------------------------------------------- /test/repo-clone.test.ts: -------------------------------------------------------------------------------- 1 | // test/repo-clone.test.ts 2 | import { describe, it, expect, beforeEach, afterEach, afterAll, beforeAll } from "vitest"; 3 | import { getRepoPath } from "../src/repo"; 4 | import { promises as fs } from "fs"; 5 | import { join } from "path"; 6 | import { createHash } from "crypto"; 7 | import envPaths from "env-paths"; 8 | import { mkdirp } from "mkdirp"; 9 | import { fileExists } from "../src/utils.js"; 10 | import { APP_SYSTEM_ID } from "../src/constants"; 11 | import { createTempGitRepo, TempGitRepoResult } from "./helpers/createTempGitRepo.js"; 12 | 13 | // Helper: compute a short MD5 hash from the source URL/path 14 | function hashSource(source: string): string { 15 | return createHash("md5").update(String(source)).digest("hex").slice(0, 6); 16 | } 17 | 18 | // Determine the cache directory from envPaths 19 | const cacheDir = envPaths(APP_SYSTEM_ID).cache; 20 | 21 | describe.skip("getRepoPath cloning behavior", () => { 22 | let tempRepo: TempGitRepoResult | null = null; 23 | let tempLocalPath: string | null = null; 24 | let repoCacheDir: string | null = null; 25 | 26 | beforeEach(async () => { 27 | [tempRepo] = await Promise.all([ 28 | createTempGitRepo({ files: { "repo1.txt": "Repo 1" } }), 29 | ]); 30 | }, 30000); 31 | 32 | afterEach(async () => { 33 | const repoPath = tempRepo?.repoPath; 34 | 35 | await tempRepo?.cleanup(); 36 | 37 | const cleanupTasks: Promise[] = []; 38 | if (repoPath) { 39 | if (!repoCacheDir) { 40 | repoCacheDir = join(cacheDir, `ingest-${hashSource(repoPath)}`); 41 | } 42 | if (repoCacheDir && await fileExists(repoCacheDir)) { 43 | cleanupTasks.push(fs.rm(repoCacheDir, { recursive: true, force: true })); 44 | } 45 | } 46 | await Promise.all(cleanupTasks); 47 | 48 | tempRepo = null; 49 | repoCacheDir = null; 50 | }); 51 | 52 | it("clones distinct repositories based on their source paths", async () => { 53 | expect(tempRepo?.repoPath).toBeDefined(); 54 | const repoPath = tempRepo!.repoPath; 55 | 56 | const flags = { useRegularGit: false }; 57 | 58 | const repoCache = await getRepoPath( 59 | repoPath, 60 | hashSource(repoPath), 61 | flags, 62 | /* isLocal */ false 63 | ); 64 | 65 | repoCacheDir = repoCache; 66 | 67 | expect(repoCache).toBeDefined(); 68 | 69 | const [repoFileExists, content] = await Promise.all([ 70 | fileExists(join(repoCache, "repo1.txt")), 71 | fs.readFile(join(repoCache, "repo1.txt"), "utf8") 72 | ]); 73 | 74 | expect(repoFileExists).toBe(true); 75 | expect(content).toBe("Repo 1"); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // src/types.ts 2 | 3 | export type EditorConfig = { 4 | command: string | null; 5 | skipEditor: boolean; 6 | }; 7 | 8 | export type IngestFlags = { 9 | repo?: string | undefined; 10 | path?: string | undefined; 11 | include?: string[]; 12 | exclude?: string[]; 13 | branch?: string | undefined; 14 | commit?: string | undefined; 15 | maxSize?: number | undefined; 16 | pipe?: boolean | undefined; 17 | debug?: boolean | undefined; 18 | bulk?: boolean | undefined; 19 | ignore?: boolean | undefined; 20 | skipArtifacts?: boolean | undefined; 21 | clipboard?: boolean | undefined; 22 | noEditor?: boolean | undefined; 23 | find?: string[]; 24 | require?: string[]; 25 | useRegularGit?: boolean | undefined; 26 | open?: string | boolean | undefined; 27 | graph?: string | undefined; 28 | verbose?: boolean | undefined; 29 | test?: boolean | undefined; 30 | name?: string | undefined; 31 | basePath?: string; 32 | extension?: string[]; 33 | svg?: boolean | undefined; 34 | template?: string | undefined; 35 | listTemplates?: boolean | undefined; 36 | createTemplate?: string | undefined; 37 | editTemplate?: string | undefined; 38 | markdown?: boolean | undefined; 39 | noTokenCount?: boolean; 40 | whitespace?: boolean | undefined; 41 | dryRun?: boolean | undefined; 42 | save?: boolean | undefined; 43 | saveAs?: string | undefined; 44 | use?: string | undefined; 45 | allowLarge?: boolean | undefined; 46 | }; 47 | 48 | export type ScanStats = { 49 | totalFiles: number; 50 | totalSize: number; 51 | }; 52 | 53 | export interface TreeNode { 54 | name: string; 55 | type: "file" | "directory"; 56 | path: string; 57 | size: number; 58 | children?: TreeNode[]; 59 | content?: string; 60 | file_count: number; 61 | dir_count: number; 62 | parent?: TreeNode; 63 | tooLarge?: boolean; 64 | isBinary?: boolean; 65 | isSvgIncluded?: boolean; 66 | } 67 | 68 | export interface GitResetOptions { 69 | branch?: string | undefined; 70 | commit?: string | undefined; 71 | useRegularGit?: boolean | undefined; 72 | source?: string | undefined; 73 | repoPath?: string | undefined; 74 | } 75 | 76 | // New type for a command definition within the config file 77 | export type FfgCommand = Partial; 78 | 79 | // New type for the overall ffg.config.jsonc structure 80 | export type FfgConfig = { 81 | defaultCommand?: FfgCommand; 82 | commands?: { 83 | [commandName: string]: FfgCommand; 84 | }; 85 | }; 86 | 87 | 88 | export interface ErrnoException extends Error { 89 | errno?: number; 90 | code?: string; 91 | path?: string; 92 | syscall?: string; 93 | stack?: string; 94 | } 95 | -------------------------------------------------------------------------------- /templates/main/_partials/_step_implement_code.md: -------------------------------------------------------------------------------- 1 | **Step 1: Implement Core Code** 2 | 3 | - **Goal:** Modify code file(s) for core logic of "{{TASK_DESCRIPTION}}". 4 | - **Action & Attempt Cycle (Max 3 Attempts):** 5 | 6 | 1. **Attempt 1:** Edit relevant file(s) (e.g., `src/feature.ts`). **Show complete changed code blocks (before/after).** 7 | 8 | ```typescript 9 | // Example: Show minimal BEFORE/AFTER blocks for code file(s) 10 | // === BEFORE in src/file.ts === 11 | // export function X(...) { ... } 12 | 13 | // === AFTER in src/file.ts (Attempt 1) === 14 | // import changes... 15 | // export function X(...) { ... // Updated logic } 16 | ``` 17 | 18 | _Self-Correction: Does this affect test mocks (e.g., network, `process.exit`)? Prep for test updates._ 19 | 20 | 2. **Verify (Mandatory):** Run checks below. 21 | 3. **If Verify Fails:** Analyze errors. Announce **Attempt 2**. 22 | - Modify code again. Show changes. Re-Verify. 23 | 4. **If Verify Fails Again:** Analyze errors. Announce **Attempt 3**. 24 | - Make final modifications. Show changes. Re-Verify. 25 | 5. **If Verify Fails on Attempt 3:** **STOP.** Report: "Code verification failed after 3 attempts (Step 1). Plan revision needed." Do not commit. 26 | 27 | - **Impact Analysis (Mandatory Pre-Commit):** How changes affect other parts (tests, components, types, docs)? 28 | 29 | - **Files Potentially Impacted:** `[File Path]` - Reason: [Why] 30 | - Acknowledge in commit message. 31 | 32 | - **Verification (Mandatory - After EACH attempt. DO NOT COMMIT until passed):** 33 | 34 | 1. **Lint & Type Check:** Fix _all_ issues. 35 | ```bash 36 | # Adapt command 37 | pnpm lint && pnpm build:check 38 | ``` 39 | 2. **Run Relevant Tests:** Analyze failures. Fix unexpected ones. (Ok if tests fail due to _intended_ change - fix in Step 2). 40 | - **If verification fails:** Return to Action Cycle. Use debuggers/logs. Check Impact Analysis. 41 | ```bash 42 | # Adapt command (run specific tests or full suite) 43 | pnpm test 44 | ``` 45 | 3. **(Optional) Manual Check:** Briefly check behavior. 46 | 4. **Progress:** If passed, state: "Verification OK for Step 1 on Attempt X." 47 | 48 | - **Commit (Only After Success on Attempt 1, 2, or 3):** 49 | ```bash 50 | # 1. Add only files modified THIS STEP 51 | git add 52 | # 2. Commit (Use ONE type: feat/fix/refactor...) 53 | git commit -m ": Core logic for {{TASK_DESCRIPTION}} (Step 1)" -m "Desc: [Specific changes]" -m "Verify: Passed lint, types, tests on Attempt [1/2/3]." -m "Impact: Acknowledged potential impact." 54 | ``` 55 | 56 | --- 57 | -------------------------------------------------------------------------------- /test/config-flag.test.ts: -------------------------------------------------------------------------------- 1 | // test/config-flag.test.ts 2 | import { describe, it, expect, vi, beforeEach } from "vitest"; 3 | import { runCLI } from "./test-helpers"; 4 | 5 | // Mock dependencies 6 | vi.mock("open", () => ({ 7 | default: vi.fn().mockResolvedValue(undefined), 8 | })); 9 | 10 | import open from 'open'; 11 | 12 | describe("CLI --config flag", () => { 13 | beforeEach(() => { 14 | vi.clearAllMocks(); // Reset mocks before each test 15 | }); 16 | 17 | it("should attempt to open the config file path when --config is used", async () => { 18 | const { stdout, stderr, exitCode } = await runCLI(["--config"]); 19 | 20 | expect(exitCode).toBe(0); 21 | expect(stderr).toBe(''); // No errors expected 22 | 23 | // Check for a valid config path in the output 24 | expect(stdout).toContain("Opening configuration file:"); 25 | expect(stdout).toContain("config.json"); 26 | 27 | // In test environment, it should log WOULD_OPEN_CONFIG_FILE 28 | expect(stdout).toContain("WOULD_OPEN_CONFIG_FILE:"); 29 | 30 | // Verify that open is not called in test mode 31 | expect(open).not.toHaveBeenCalled(); 32 | }); 33 | 34 | it("should exit normally and not run analysis when --config is used", async () => { 35 | const { stdout, exitCode } = await runCLI([ 36 | "--config", 37 | "--path", // Add other flags that would normally trigger analysis 38 | "test/fixtures/sample-project" 39 | ]); 40 | expect(exitCode).toBe(0); 41 | // Ensure analysis output (like summary, tree) is NOT present 42 | expect(stdout).not.toContain(""); 43 | expect(stdout).not.toContain(""); 44 | expect(stdout).toContain("Opening configuration file:"); // Should still show config opening message 45 | }); 46 | 47 | it("should respect the editor specified by --open flag when using --config", async () => { 48 | const editorCmd = "code-insiders"; 49 | const { stdout, exitCode } = await runCLI([ 50 | "--config", 51 | `--open=${editorCmd}`, 52 | "--debug" // Add debug flag to see more output 53 | ]); 54 | 55 | expect(exitCode).toBe(0); 56 | // Check for config path in the output 57 | expect(stdout).toContain("Opening configuration file:"); 58 | expect(stdout).toContain("config.json"); 59 | 60 | // Check that the editor command would be used 61 | expect(stdout).toContain("WOULD_OPEN_CONFIG_FILE:"); 62 | 63 | // Log output should mention the specific editor command 64 | expect(stdout).toContain(`Using editor from command line flag`); 65 | expect(stdout).toContain(editorCmd); 66 | }); 67 | }); -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Check if running in CI *and* likely during semantic-release (presence of GH_TOKEN) 3 | if [ -n "$CI" ] && [ -n "$GH_TOKEN" ]; then 4 | echo "CI environment with GH_TOKEN detected (likely semantic-release). Skipping pre-push checks." 5 | exit 0 # Exit successfully, bypassing tests 6 | fi 7 | 8 | # . "$(dirname "$0")/_/husky.sh" # DEPRECATED: Remove this line 9 | 10 | PNPM_CMD="pnpm" # Default command 11 | 12 | # If in CI, try to find pnpm explicitly 13 | if [ -n "$CI" ]; then 14 | # Try the standard PATH first 15 | if ! command -v pnpm &> /dev/null; then 16 | # If not found in PATH, try the known setup-pnpm location 17 | PNPM_BIN_PATH="/home/runner/setup-pnpm/node_modules/.bin/pnpm" 18 | if [ -x "$PNPM_BIN_PATH" ]; then 19 | echo "Found pnpm via fallback path: $PNPM_BIN_PATH" 20 | PNPM_CMD="$PNPM_BIN_PATH" # Use the full path 21 | else 22 | echo "Error: pnpm command not found in PATH or fallback location ($PNPM_BIN_PATH)." 23 | exit 1 24 | fi 25 | else 26 | echo "Found pnpm in PATH." 27 | fi 28 | else 29 | # Check pnpm normally for local environments 30 | if ! command -v pnpm &> /dev/null; then 31 | echo "Error: pnpm command not found. Please install pnpm." 32 | exit 1 33 | fi 34 | fi 35 | 36 | # Prevent direct pushes to main branch (skips check in CI environments) 37 | if [ -z "$CI" ]; then 38 | protected_branch="main" 39 | # Use git rev-parse --abbrev-ref HEAD for potentially more robust branch detection 40 | current_branch=$(git rev-parse --abbrev-ref HEAD) 41 | 42 | if [ "$current_branch" = "$protected_branch" ]; then 43 | echo "❌ You can't push directly to the $protected_branch branch!" 44 | echo "Please use feature branches and Pull Requests." 45 | exit 1 46 | fi 47 | 48 | # Check remote refs being pushed to (covers pushing specific branches) 49 | while read local_ref local_sha remote_ref remote_sha 50 | do 51 | # Extract branch name from ref (e.g., refs/heads/main -> main) 52 | remote_branch=${remote_ref#refs/heads/} 53 | if [ "$remote_branch" = "$protected_branch" ]; then 54 | echo "❌ Attempting to push to protected branch '$protected_branch'!" 55 | echo "Please use feature branches and Pull Requests." 56 | exit 1 57 | fi 58 | done 59 | fi 60 | 61 | echo "🔍 Running pre-push checks..." 62 | 63 | # Ensure pnpm is available (check already performed above) 64 | # if ! command -v pnpm &> /dev/null 65 | # then 66 | # echo "pnpm could not be found, please install it." 67 | # exit 1 68 | # fi 69 | 70 | # Run the full test suite using the determined pnpm command 71 | echo "🧪 Running tests using command: $PNPM_CMD" 72 | # Execute the command stored in the variable 73 | "$PNPM_CMD" test 74 | 75 | # Check the exit code of the test command 76 | if [ $? -ne 0 ]; then 77 | echo "❌ Tests failed. Please fix the tests before pushing." 78 | exit 1 79 | fi 80 | 81 | echo "✅ Pre-push checks passed." 82 | exit 0 -------------------------------------------------------------------------------- /test/xmlFormatter.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 | import { buildXMLOutput } from "../src/xmlFormatter.js"; 3 | import { DigestResult, PROP_SUMMARY, PROP_TREE, PROP_CONTENT } from "../src/constants.js"; 4 | 5 | describe.skip("XML Formatter", () => { 6 | const mockGitInfo = { 7 | branch: "main", 8 | }; 9 | 10 | it("should include command in output when provided", async () => { 11 | const digest = { 12 | [PROP_SUMMARY]: "Test summary", 13 | [PROP_TREE]: "Test tree", 14 | [PROP_CONTENT]: "Test content" 15 | }; 16 | const source = "/test/path"; 17 | const timestamp = "20240101-000000"; 18 | const command = "ffg test --verbose"; 19 | 20 | const output = await buildXMLOutput(digest, source, timestamp, { 21 | command, 22 | verbose: true 23 | }); 24 | 25 | expect(output).toContain(`${command}`); 26 | }); 27 | 28 | it("should not include command tag when command is not provided", async () => { 29 | const digest = { 30 | [PROP_SUMMARY]: "Test summary", 31 | [PROP_TREE]: "Test tree", 32 | [PROP_CONTENT]: "Test content" 33 | }; 34 | const source = "/test/path"; 35 | const timestamp = "20240101-000000"; 36 | 37 | const output = await buildXMLOutput(digest, source, timestamp, { 38 | verbose: true 39 | }); 40 | 41 | expect(output).not.toContain(""); 42 | }); 43 | 44 | it("should not escape special characters within content tags", async () => { 45 | const specialContent = `This & "special" 'chars'.`; 46 | const digest = { 47 | [PROP_SUMMARY]: `Summary with ${specialContent}`, 48 | [PROP_TREE]: `Tree with ${specialContent}`, 49 | [PROP_CONTENT]: `====================\nFile: src/test & file.ts\n====================\n${specialContent}\nAnother line.` 50 | }; 51 | const source = "/test/path"; 52 | const timestamp = "20240101-000000"; 53 | 54 | const output = await buildXMLOutput(digest, source, timestamp, { 55 | verbose: true, // Ensure content is included 56 | whitespace: true // Make checking easier 57 | }); 58 | 59 | // Check summary 60 | expect(output).toContain(` 61 | Summary with ${specialContent} 62 | `); 63 | // Check tree 64 | expect(output).toContain(` 65 | Tree with ${specialContent} 66 | `); 67 | // Check file content (escaping only in attribute) 68 | expect(output).toContain(` \n${specialContent}\nAnother line. `); 69 | // Specifically check that special chars are NOT escaped in content 70 | expect(output).not.toContain("<contains>"); 71 | expect(output).not.toContain(`& "special" 'chars'`); 72 | }); 73 | }); -------------------------------------------------------------------------------- /test/gitignore-next.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterAll } from "vitest"; 2 | import { join, resolve } from "node:path"; 3 | import { promises as fs } from "node:fs"; 4 | import { scanDirectory } from "../src/ingest.js"; 5 | import { runCLI } from "./test-helpers"; 6 | import { TreeNode } from "../src/types.js"; 7 | 8 | describe("gitignore .next directory exclusion", () => { 9 | const TEST_DIR = resolve(__dirname, "fixtures", "next-test"); 10 | const GITIGNORE_PATH = join(TEST_DIR, ".gitignore"); 11 | 12 | beforeAll(async () => { 13 | // Create test directory structure 14 | await fs.mkdir(TEST_DIR, { recursive: true }); 15 | await fs.mkdir(join(TEST_DIR, ".next"), { recursive: true }); 16 | 17 | // Create some test files 18 | await fs.writeFile(join(TEST_DIR, "regular.js"), "console.log('regular file')"); 19 | await fs.writeFile(join(TEST_DIR, ".next", "build.js"), "console.log('build file')"); 20 | 21 | // Create .gitignore with .next 22 | await fs.writeFile(GITIGNORE_PATH, "# Next.js build output\n/.next/\n"); 23 | }); 24 | 25 | afterAll(async () => { 26 | // Clean up test directory 27 | await fs.rm(TEST_DIR, { recursive: true, force: true }); 28 | }); 29 | 30 | it("should exclude .next directory when it's in .gitignore", async () => { 31 | const result = await scanDirectory(TEST_DIR, { 32 | ignore: true, 33 | debug: true, 34 | }); 35 | 36 | expect(result).not.toBeNull(); 37 | 38 | // Helper function to get all file paths from the tree 39 | function getAllFilePaths(node: TreeNode): string[] { 40 | const paths: string[] = []; 41 | 42 | function traverse(node: TreeNode) { 43 | if (node.type === "file") { 44 | paths.push(node.path); 45 | } 46 | 47 | if (node.children) { 48 | for (const child of node.children) { 49 | traverse(child); 50 | } 51 | } 52 | } 53 | 54 | traverse(node); 55 | return paths; 56 | } 57 | 58 | const allPaths = getAllFilePaths(result as TreeNode); 59 | 60 | // Should include regular.js 61 | expect(allPaths.some(path => path.endsWith("regular.js"))).toBe(true); 62 | 63 | // Should NOT include any files from .next directory 64 | expect(allPaths.some(path => path.includes(".next"))).toBe(false); 65 | }); 66 | 67 | it("should exclude .next directory when using CLI", async () => { 68 | const { stdout, exitCode } = await runCLI([ 69 | "--path", 70 | TEST_DIR, 71 | "--pipe", 72 | "--debug", 73 | ]); 74 | 75 | expect(exitCode).toBe(0); 76 | 77 | // Should include regular.js 78 | expect(stdout).toMatch(/regular\.js/); 79 | 80 | // Should NOT include any files from .next directory 81 | expect(stdout).not.toMatch(/\.next\/build\.js/); 82 | }); 83 | }); -------------------------------------------------------------------------------- /test/branch-flag.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterAll } from "vitest"; 2 | import { runCLI, isOnMainBranch } from "./test-helpers"; 3 | import { runDirectCLI } from "../utils/directTestRunner.js"; 4 | import { createTempGitRepo, TempGitRepoResult } from "./helpers/createTempGitRepo.js"; 5 | import { promises as fs } from "node:fs"; 6 | import { resolve } from "node:path"; 7 | 8 | // Only run these tests on main branch 9 | if (isOnMainBranch()) { 10 | describe.concurrent.skip("CLI --branch", () => { 11 | let tempRepo: TempGitRepoResult; 12 | 13 | beforeAll(async () => { 14 | tempRepo = await createTempGitRepo({ initialBranch: 'main' }); 15 | 16 | const { git, repoPath } = tempRepo; 17 | 18 | await fs.writeFile(resolve(repoPath, "main.js"), "console.log('main')"); 19 | await git.add("main.js"); 20 | await git.commit("Initial commit"); 21 | 22 | await fs.rm(resolve(repoPath, "main.js")); 23 | await git.rm("main.js"); 24 | await git.commit("Remove main.js"); 25 | 26 | await git.checkoutLocalBranch("some-feature-branch"); 27 | await fs.writeFile(resolve(repoPath, "feature.js"), "console.log('feature')"); 28 | await git.add("feature.js"); 29 | await git.commit("Feature commit"); 30 | }, 30000); 31 | 32 | afterAll(async () => { 33 | if (tempRepo) { 34 | await tempRepo.cleanup(); 35 | } 36 | }); 37 | 38 | it("attempts to clone the specified branch from a Git repository", async () => { 39 | const { stdout, exitCode } = await runCLI([ 40 | "--repo", 41 | tempRepo.repoPath, 42 | "--branch", 43 | "some-feature-branch", 44 | "--pipe", 45 | ]); 46 | 47 | expect(exitCode).toBe(0); 48 | expect(stdout).toMatch(/Branch: some-feature-branch/i); 49 | expect(stdout).toContain("feature.js"); 50 | 51 | const directoryTreeMatch = stdout.match( 52 | /([\s\S]*?)<\/directoryTree>/, 53 | ); 54 | expect(directoryTreeMatch).not.toBeNull(); 55 | const directoryTreeContent = directoryTreeMatch ? directoryTreeMatch[1] : ""; 56 | 57 | expect(directoryTreeContent).not.toContain("main.js"); 58 | }); 59 | 60 | it("attempts to clone the specified branch using direct execution", async () => { 61 | const { stdout, exitCode } = await runDirectCLI([ 62 | "--repo", 63 | tempRepo.repoPath, 64 | "--branch", 65 | "some-feature-branch", 66 | "--pipe", 67 | ]); 68 | 69 | expect(exitCode).toBe(0); 70 | expect(stdout).toMatch(/Branch: some-feature-branch/i); 71 | expect(stdout).toContain("feature.js"); 72 | 73 | const directoryTreeMatch = stdout.match( 74 | /([\s\S]*?)<\/directoryTree>/, 75 | ); 76 | expect(directoryTreeMatch).not.toBeNull(); 77 | const directoryTreeContent = directoryTreeMatch ? directoryTreeMatch[1] : ""; 78 | 79 | expect(directoryTreeContent).not.toContain("main.js"); 80 | }); 81 | }); 82 | } else { 83 | describe.skip("CLI --branch (skipped: not on main branch)", () => { 84 | it("placeholder test", () => { 85 | expect(true).toBe(true); 86 | }); 87 | }); 88 | } 89 | -------------------------------------------------------------------------------- /test/include-file-check.test.ts: -------------------------------------------------------------------------------- 1 | // test/include-file-check.test.ts 2 | import { describe, it, beforeEach, afterEach, expect } from "vitest"; 3 | import { runCLI } from "./test-helpers"; 4 | import { join } from "node:path"; 5 | import { promises as fs } from "node:fs"; 6 | import { mkdtempSync, rmSync } from "fs"; 7 | import { tmpdir } from "os"; 8 | 9 | describe("Include flag for specific file and directory paths", () => { 10 | let tempDir: string; 11 | 12 | beforeEach(() => { 13 | // Create a temporary directory for our fixture 14 | tempDir = mkdtempSync(join(tmpdir(), "include-test-")); 15 | }); 16 | 17 | afterEach(() => { 18 | // Clean up the temporary directory 19 | rmSync(tempDir, { recursive: true, force: true }); 20 | }); 21 | 22 | it("should correctly include a single file (package.json)", async () => { 23 | // Create a package.json file in the temporary directory 24 | const packageJsonPath = join(tempDir, "package.json"); 25 | const packageJsonContent = '{ "name": "include-test" }'; 26 | await fs.writeFile(packageJsonPath, packageJsonContent, "utf8"); 27 | 28 | // Also create another file to ensure only package.json is included 29 | const otherFilePath = join(tempDir, "index.js"); 30 | await fs.writeFile(otherFilePath, "console.log('hello');", "utf8"); 31 | 32 | // Run the CLI with the include flag set to package.json 33 | const { stdout, exitCode } = await runCLI([ 34 | "--path", 35 | tempDir, 36 | "--include", 37 | "package.json", 38 | "--pipe", 39 | "--verbose", 40 | ]); 41 | expect(exitCode).toBe(0); 42 | 43 | // The output should contain package.json and its content 44 | expect(stdout).toContain(''); 45 | expect(stdout).toContain('{ "name": "include-test" }'); 46 | 47 | // The output should not contain the content from index.js 48 | expect(stdout).not.toContain("index.js"); 49 | }); 50 | 51 | it("should correctly include a directory (.github)", async () => { 52 | // Create a .github directory with a workflow file in the temporary directory 53 | const githubDir = join(tempDir, ".github"); 54 | await fs.mkdir(githubDir, { recursive: true }); 55 | const workflowFilePath = join(githubDir, "workflow.yml"); 56 | const workflowContent = "name: CI"; 57 | await fs.writeFile(workflowFilePath, workflowContent, "utf8"); 58 | 59 | // Also create another file outside the .github folder 60 | const otherFilePath = join(tempDir, "README.md"); 61 | await fs.writeFile(otherFilePath, "# Readme", "utf8"); 62 | 63 | // Run the CLI with the include flag set to .github 64 | const { stdout, exitCode } = await runCLI([ 65 | "--path", 66 | tempDir, 67 | "--include", 68 | ".github", 69 | "--pipe", 70 | "--verbose", 71 | ]); 72 | expect(exitCode).toBe(0); 73 | 74 | // The output should include the .github directory and the workflow file content 75 | expect(stdout).toContain(".github"); 76 | expect(stdout).toContain("workflow.yml"); 77 | expect(stdout).toContain(workflowContent); 78 | 79 | // It should not include the content from files outside .github 80 | expect(stdout).not.toContain("Readme"); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # vitepress build output 108 | **/.vitepress/dist 109 | 110 | # vitepress cache directory 111 | **/.vitepress/cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | 138 | # test/.gitignore 139 | !fixtures/**/* 140 | fixtures/**/binary* 141 | plans 142 | *.diff 143 | diffs 144 | .idea 145 | 146 | # Test fixtures specific ignores 147 | test/fixtures/binary-file-project/large-binary-file.bin 148 | test/fixtures/binary-file-project/large-text-file.txt 149 | test/fixtures/binary-file-project/test-file.txt 150 | test/fixtures/next-test/ 151 | test/fixtures/svg-file-project/ 152 | ffg-output* -------------------------------------------------------------------------------- /scripts/test-templates.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable no-undef */ 3 | 4 | import { readFile } from 'node:fs/promises'; 5 | import { join } from 'node:path'; 6 | import { Liquid } from 'liquidjs'; 7 | 8 | // Set up paths 9 | const templatesDirPath = join(process.cwd(), 'templates', 'main'); 10 | const partialsDirPath = join(templatesDirPath, '_partials'); 11 | 12 | // Initialize Liquid with our templates directory 13 | const engine = new Liquid({ 14 | root: [templatesDirPath, partialsDirPath], // Include both main and partials in the search path 15 | extname: '.md', // Default extension for includes 16 | strictFilters: false, // Don't error on undefined filters 17 | strictVariables: false // Don't error on undefined variables 18 | }); 19 | 20 | // Test variables 21 | const testVariables = { 22 | TASK_DESCRIPTION: 'Add user authentication to the login form', 23 | BRANCH_NAME: 'feature/add-user-auth', 24 | }; 25 | 26 | /** 27 | * Test a template file 28 | */ 29 | async function testTemplate(templateName) { 30 | try { 31 | console.log(`\n=== Testing template: ${templateName} ===\n`); 32 | 33 | // Read the template file 34 | const templatePath = join(templatesDirPath, `${templateName}.md`); 35 | console.log(`Loading template from: ${templatePath}`); 36 | 37 | const templateContent = await readFile(templatePath, 'utf8'); 38 | 39 | // Extract the template content from within