├── 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 tags
40 | const templateMatch = templateContent.match(/([\s\S]*?)<\/template>/);
41 |
42 | if (!templateMatch) {
43 | console.error(`No tags found in ${templateName}.md`);
44 | return;
45 | }
46 |
47 | let template = templateMatch[1];
48 |
49 | // Debug output
50 | console.log(`Template includes found: ${(template.match(/{% include /g) || []).length}`);
51 |
52 | if (process.env.DEBUG) {
53 | console.log(`[DEBUG] Template engine settings:`);
54 | console.log(`[DEBUG] Root paths:`, engine.options.root);
55 | console.log(`[DEBUG] Template content excerpt: ${template.substring(0, 100)}...`);
56 | }
57 |
58 | try {
59 | // Render the template with our test variables
60 | const result = await engine.parseAndRender(template, testVariables);
61 |
62 | // Print the result
63 | console.log(result);
64 | } catch (renderError) {
65 | console.error(`Error rendering template ${templateName}:`, renderError);
66 | console.error('This might be due to issues with includes. Check paths and template syntax.');
67 | }
68 |
69 | console.log('\n=== End of template test ===\n');
70 | } catch (error) {
71 | console.error(`Error testing template ${templateName}:`, error);
72 | }
73 | }
74 |
75 | // Main function
76 | async function main() {
77 | // Determine which templates to test
78 | const templatesToTest = process.argv.slice(2).length > 0
79 | ? process.argv.slice(2)
80 | : ['plan_modular', 'plan-current-branch', 'plan-no-commit'];
81 |
82 | // Explicitly test the PR template if no arguments were provided
83 | if (process.argv.slice(2).length === 0) {
84 | templatesToTest.push('pr'); // Add pr to the default list
85 | }
86 |
87 | // Test each template
88 | for (const templateName of templatesToTest) {
89 | await testTemplate(templateName);
90 | }
91 | }
92 |
93 | main().catch(console.error);
--------------------------------------------------------------------------------
/test/svg-flag.test.ts:
--------------------------------------------------------------------------------
1 | // test/svg-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 |
7 | describe("SVG file handling", () => {
8 | const testDir = "test/fixtures/svg-file-project";
9 | const svgFilePath = join(testDir, "test-icon.svg");
10 | const textFilePath = join(testDir, "test-file.txt");
11 | const fileSvgPath = join(testDir, "file.svg");
12 |
13 | beforeAll(async () => {
14 | // Create test directory
15 | await fs.mkdir(testDir, { recursive: true });
16 |
17 | // Create a simple SVG file
18 | const svgContent = ``;
21 | await fs.writeFile(svgFilePath, svgContent);
22 |
23 | // Create another SVG file
24 | await fs.writeFile(fileSvgPath, svgContent);
25 |
26 | // Create a text file for comparison
27 | await fs.writeFile(textFilePath, "This is a text file");
28 | });
29 |
30 | afterAll(async () => {
31 | // Clean up test files
32 | await fs.rm(testDir, { recursive: true, force: true });
33 | });
34 |
35 | it("should handle SVG files correctly with different flags", async () => {
36 | // Run all three tests in parallel
37 | const [defaultResult, svgFlagResult, verboseResult] = await Promise.all([
38 | // Test 1: Default behavior (exclude SVG files)
39 | runCLI([
40 | "--path",
41 | testDir,
42 | "--pipe",
43 | // No --svg flag, so SVGs should be excluded
44 | ]),
45 |
46 | // Test 2: Include SVG files with --svg flag
47 | runCLI([
48 | "--path",
49 | testDir,
50 | "--pipe",
51 | "--svg", // Include SVG files
52 | ]),
53 |
54 | // Test 3: Include SVG content with --svg and --verbose flags
55 | runCLI([
56 | "--path",
57 | testDir,
58 | "--pipe",
59 | "--svg", // Include SVG files
60 | "--verbose", // Include file contents
61 | ])
62 | ]);
63 |
64 | // Test 1: Default behavior (exclude SVG files)
65 | expect(defaultResult.exitCode).toBe(0);
66 | expect(defaultResult.stdout).toContain("test-file.txt");
67 | expect(defaultResult.stdout).not.toContain("test-icon.svg");
68 | expect(defaultResult.stdout).not.toContain("file.svg");
69 |
70 | // Test 2: Include SVG files with --svg flag
71 | expect(svgFlagResult.exitCode).toBe(0);
72 | expect(svgFlagResult.stdout).toContain("test-icon.svg");
73 | expect(svgFlagResult.stdout).toContain("file.svg");
74 | expect(svgFlagResult.stdout).toContain("test-file.txt");
75 | expect(svgFlagResult.stdout).not.toContain("test-icon.svg (excluded - svg)");
76 | expect(svgFlagResult.stdout).not.toContain("file.svg (excluded - svg)");
77 |
78 | // Test 3: Include SVG content with --svg and --verbose flags
79 | expect(verboseResult.exitCode).toBe(0);
80 | expect(verboseResult.stdout).toContain("This is a text file");
81 | expect(verboseResult.stdout).toContain("